【C++】C++基础记录(一)

【C++】C++基础记录(一)

本文记录了我学习C++的一些基础条目知识

HelloWorld

#include <iostream>

int main()
{
    std::cout << "Hello World" << std::endl;
    return 0;
}

命名空间

名称空间是给代码指定的名称,有助于降低命名冲突的风险。通过使用 std::cout,您命令编译器调用名称空间 std 中独一无二的 cout。

// Pre-processor directive
#include <iostream>

// Start of your program
int main()
{
   // Tell the compiler what namespace to look in
   using namespace std;

   /* Write to the screen using cout */
   cout << "Hello World" << endl;

   // Return a value to the OS
   return 0;
}

使用函数之前需要声明

#include <iostream>
using namespace std;

// Function declaration
int DemoConsoleOutput();

int main()
{
   // Function call
    DemoConsoleOutput();

    return 0;
}

// Function definition
int DemoConsoleOutput()
{
    cout << "This is a simple string literal" << endl;
    cout << "Writing number five: " << 5 << endl;
    cout << "Performing division 10 / 5 = " << 10 / 5 << endl;
    cout << "Pi when approximated is 22 / 7 = " << 22 / 7 << endl;
    cout << "Pi actually is 22 / 7 = " << 22.0 / 7 << endl;

    return 0;
}

使用变量最好初始化

int firstNumber = 0;

除非给变量赋初值,否则无法确保相应内存单元的内容是什么,这对程序可能不利。因此,初始化虽然是可选的,但这样做通常是一个不错的编程习惯。

常见变量类型

基本类型(基本数据类型)

‌整型(Integer types)

int:标准整型,通常是32位。 short:短整型,通常是16位。 long:长整型,通常是32位或64位,取决于系统。 long long:更长整型,通常是64位。 unsigned:无符号整型,可以是unsigned int、unsigned short等。

‌字符型(Character types)

char:字符类型,通常是8位。 signed char:有符号字符类型。 unsigned char:无符号字符类型。

‌浮点型(Floating-point types)

float:单精度浮点型,通常是32位。 double:双精度浮点型,通常是64位。 long double:扩展精度浮点型,通常是80位或更高。

‌布尔型(Boolean type)‌

bool:布尔类型,可以存储true或false。

auto可以自动推断类型

#include <iostream>
using namespace std;

int main()
{
   auto coinFlippedHeads = true;
   auto largeNumber = 2500000000000;

   cout << "coinFlippedHeads = " << coinFlippedHeads;
   cout << " , sizeof(coinFlippedHeads) = " << sizeof(coinFlippedHeads) << endl;
   cout << "largeNumber = " << largeNumber;
   cout << " , sizeof(largeNumber) = " << sizeof(largeNumber) << endl;

   return 0;
}

/**
coinFlippedHeads = 1 , sizeof(coinFlippedHeads) = 1
largeNumber = 2500000000000 , sizeof(largeNumber) = 8 
*/

typedef更改变量类型别名

C++允许您将变量类型替换为您认为方便的名称,为此可使用关键字 typedef。

在下面的示例中,程序员想给 unsigned int 指定一个更具描述性的名称— STRICTLY_POSITIVE_INTEGER

typedef unsigned int STRICTLY_POSITIVE_INTEGER;
STRICTLY_POSITIVE_INTEGER numEggsInBasket = 4532;

常量const和constexpr

如果变量的值不应改变,就应将其声明为常量,这是一种良好的编程习惯。通过使用关键字 const,程序员可确保数据不变,避免应用程序无意间修改该常量。在多位程序员合作开发时,这特别有用。

通过constexpr将函数声明为返回常量的函数:

constexpr double GetPi() {return 22.0 / 7;}

会在编译期就算出这个值,并在使用处自动替换,可以优化性能。

但是像计算用户输入数字的两倍,这种地方,就无法计算结果,不保证可以优化性能。

枚举

后一个都比前一个大1。默认第一个数值从0开始,中间也可以自己指定。

#include <iostream>
using namespace std;

enum CardinalDirections
{
   North = 25,
   South,
   East,
   West
};

int main()
{
    cout << "Displaying directions and their symbolic values" << endl;
    cout << "North: " << North << endl;
    cout << "South: " << South << endl;
    cout << "East: " << East << endl;
    cout << "West: " << West << endl;

   CardinalDirections windDirection = South;
   cout << "Variable windDirection = " << windDirection << endl;

   return 0;
}

#define pi 3.14286用来定义常量,已废弃

数组声明和访问元素

#include <iostream>

using namespace std;

int main ()
{
   int myNumbers [5] = {34, 56, -21, 5002, 365};

   cout << "First element at index 0: " << myNumbers [0] << endl;
   cout << "Second element at index 1: " << myNumbers [1] << endl;
   cout << "Third element at index 2: " << myNumbers [2] << endl;
   cout << "Fourth element at index 3: " << myNumbers [3] << endl;
   cout << "Fifth element at index 4: " << myNumbers [4] << endl;

   return 0;
}

多维数组

#include <iostream>
using namespace std;

int main()
{
   int threeRowsThreeColumns [3][3] = { {-501, 205, 2011}, {989, 101, 206}, {303, 456, 596} };
   
   cout << "Row 0: " << threeRowsThreeColumns [0][0] << " " << threeRowsThreeColumns [0][1] << " " << threeRowsThreeColumns [0][2] << endl;

   cout << "Row 1: " << threeRowsThreeColumns [1][0] << " " << threeRowsThreeColumns [1][1] << " " << threeRowsThreeColumns [1][2] << endl;

   cout << "Row 2: " << threeRowsThreeColumns [2][0] << " " << threeRowsThreeColumns [2][1] << " " << threeRowsThreeColumns [2][2] << endl;

   return 0;
}

使用vector声明动态数组

#include <iostream>
#include <vector>

using namespace std;

int main()
{
   vector<int> dynArray (3);

   dynArray[0] = 365;
   dynArray[1] = -421;
   dynArray[2]= 789;

   cout << "Number of integers in array: " << dynArray.size() << endl;

   cout << "Enter another element to insert" << endl;
   int newValue = 0;
   cin >> newValue;
   dynArray.push_back(newValue);

   cout << "Number of integers in array: " << dynArray.size() << endl;
   cout << "Last element in array: ";
   cout << dynArray[dynArray.size() - 1] << endl;
   
   return 0;
}

c风格字符串

char sayHello[] = {'H', 'e', 'l', 'l', 'o', ' ', 'W', 'o', 'r', 'l', 'd',
'\0'};
std::cout << sayHello << std::endl;

末尾是一个 \0 ,来告诉编译器字符串到这里就完结了。

危险性

使用 C 语言编写的应用程序经常使用 strcpy() 等字符串复制函数、strcat() 等拼接函数,还经常使用 strlen() 来确定字符串的长度;具有 C 语言背景的 C++程序员编写的应用程序亦如此。

这些 C 风格字符串作为输入的函数非常危险,因为它们寻找终止空字符,如果程序员没有在字符数组末尾添加空字符,这些函数将跨越字符数组的边界。

C++ string

要使用 C++字符串,需要包含头文件 string, #include <string> 不同于字符数组(C 风格字符串实现),std::string 是动态的,在需要存储更多数据时其容量将增大。

#include <iostream>
#include <string>

using namespace std;

int main()
{
   string greetStrings ("Hello std::string!");
   cout << greetStrings << endl;

   cout << "Enter a line of text: " << endl;
   string firstLine;
   getline(cin, firstLine);

   cout << "Enter another: " << endl;
   string secondLine;
   getline(cin, secondLine);

   cout << "Result of concatenation: " << endl;
   string concatString = firstLine + " " + secondLine;
   cout << concatString << endl;

   cout << "Copy of concatenated string: " << endl;
   string aCopy;
   aCopy = concatString;
   cout << aCopy << endl;

   cout << "Length of concat string: " << concatString.length() << endl;

   return 0;
}

函数值传递

默认情况下, 函数参数在作用域生效的是外部实参的拷贝 ,内部的操作不会影响原参数。

可以使用按引用传递的参数,在函数体内部也可以对外部传来的参数做修改。

#include <iostream>
using namespace std;

const double Pi = 3.1416;

// output parameter result by reference 
void Area(double radius, double& result)
{
   result = Pi * radius * radius;
}

int main() 
{
   cout << "Enter radius: ";
   double radius = 0;
   cin >> radius;

   double areaFetched = 0;
   Area(radius, areaFetched);

   cout << "The area is: " << areaFetched << endl;
   return 0;
}

函数调用栈的概念

栈是一种后进先出的内存结构,很像堆叠在一起的盘子,您从顶部取盘子,这个盘子是最后堆叠上去的。将数据加入栈被称为压入操作;从栈中取出数据被称为弹出操作。栈增大时,栈指针将不断递增,始终指向栈顶。

栈的性质使其非常适合用于处理函数调用。函数被调用时,所有局部变量都在栈中实例化,即被压入栈中。函数执行完毕时,这些局部变量都从栈中弹出,栈指针返回到原来的地方。

inline函数

也叫内联函数,函数执行时间入栈出栈时间 相当的简单函数,可以使用inline关键字,编译器会直接将其展开到调用处,省去入栈出栈的时间。

现代编译器甚至会自动寻找内联机会,帮助合理优化性能。

auto用于函数,同样可以自动推断返回值

指针初始化,以免自动指向垃圾值

与大多数变量一样,除非对指针进行初始化,否则它包含的值将是随机的。

您不希望访问随机的内存地址,因此将指针初始化为 NULL

NULL 是一个可以检查的值,且不会是内存地址。

指针语法

声明一个指针类型,使用 指向的变量类型加上星号(*) ,后跟指针名称。

int* pointsToInt;

使用取地址运算符(&)获取变量的内存地址,取地址运算符是编程中用于获取变量在内存中的地址的符号,通常用符号&表示。它在支持指针操作的编程语言中广泛使用,功能是返回变量的内存地址,以便后续操作。

#include <iostream>
using namespace std;

int main()
{
   int age = 30;

   int* pointsToInt = &age;
   cout << "pointsToInt points to age now" << endl;

   // Displaying the value of pointer
   cout << "pointsToInt = 0x" << hex << pointsToInt << endl;

   int DogsAge = 9;
   pointsToInt = &DogsAge;
   cout << "pointsToInt points to DogsAge now" << endl;

   cout << "pointsToInt = 0x" << hex << pointsToInt << endl;

   return 0;
}

使用解引用运算符(*)获取指针指向的数据值

解引用运算符是编程中用于访问指针所指向内存地址存储值的符号,通常用星号(*)表示‌。它在支持指针操作的编程语言如C语言和C++中广泛使用,主要功能是通过指针间接访问和修改数据。

其核心工作原理是:当对指针使用解引用运算符(如ptr)时,会获取该指针指向内存位置的实际值。例如,若指针int ptr指向整数变量num的地址,则*ptr操作等同于直接操作num的值。

与取地址运算符(&)形成互补关系:取地址运算符用于获取变量的内存地址(如&num返回地址),而解引用运算符则通过地址反向获取值。在面向对象编程中,解引用运算符还用于通过指针访问对象的成员,例如obj->member等价于(*obj).member,其中->被称为结构解引用运算符,是解引用与成员访问的语法糖。

使用时的注意事项包括:

  • ‌空指针解引用风险‌:若指针未初始化或指向无效内存(如空指针),解引用会导致程序崩溃或未定义行为;
  • ‌类型匹配‌:解引用指针类型需与目标数据类型一致,否则可能引发内存错误;
  • ‌运算符优先级‌:解引用运算符(*)的优先级高于自增(++),因此*p++会先解引用再对指针自增,而(*p)++则是对解引用的值自增。

典型应用场景包括动态内存管理、数据结构(如链表)操作及数组遍历。例如,通过指针遍历数组时,*(arr + i)等效于arr[i],这展示了指针算术与解引用的结合。

#include <iostream>
using namespace std;

int main()
{
   int age = 30;
   int dogsAge = 9;

   cout << "Integer age = " << age << endl;
   cout << "Integer dogsAge = " << dogsAge << endl;

   int* pointsToInt = &age;
   cout << "pointsToInt points to age" << endl;

   // Displaying the value of pointer
   cout << "pointsToInt = 0x" << hex << pointsToInt << endl;

   // Displaying the value at the pointed location
   cout << "*pointsToInt = " << dec << *pointsToInt << endl;

   pointsToInt = &dogsAge;
   cout << "pointsToInt points to dogsAge now" << endl;

   cout << "pointsToInt = 0x" << hex << pointsToInt << endl;
   cout << "*pointsToInt = " << dec << *pointsToInt << endl;

   return 0;
}

如果指针未初始化,它所在的内存单元将包含随机值,此时对其解除引用通常会导致非法访问(Access Violation),即访问应用程序未获得授权的内存单元。

使用new和delete动态的申请和释放内存

这两个必须成对使用。

不再使用分配的内存后,如果不释放它们,这些内存仍被预留并分配给您的应用程序。这将减少可供其他应用程序使用的系统内存量,甚至降低您的应用程序的执行速度。这被称为 内存泄露 ,应不惜一切代价避免这种情况发生。

#include <iostream>
using namespace std;

int main()
{
   // Request for memory space for an int
   int* pointsToAnAge = new int;

   // Use the allocated memory to store a number
   cout << "Enter your dog's age: ";
   cin >> *pointsToAnAge;

   // use indirection operator* to access value 
   cout << "Age " << *pointsToAnAge << " is stored at 0x" << hex << pointsToAnAge << endl;

   delete pointsToAnAge; // release dynamically allocated memory

   return 0;
}

不能将运算符 delete 用于任何包含地址的指针,而只能用于 new 返回的且未使用 delete 释放的指针。

对指针使用++和–

其会指向下一个int值,而不是移动一个内存地址指向中间,那毫无意义。 如果声明了如下指针:

Type* pType = Address;

则执行 ++pType 后, pType 将指向 Address + sizeof(Type)

可以推断,数组其实就是一个指向第一个元素的指针类型。

数组和指针

由于数组变量就是指针,因此也可将用于指针的解除引用运算符(*)用于数组。同样,可将数组运算符[ ]用于指针。

#include <iostream>
using namespace std;

int main()
{
   const int ARRAY_LEN = 5;

   // Static array of 5 integers, initialized
   int myNumbers[ARRAY_LEN] = {24, -1, 365, -999, 2011};

   // Pointer initialized to first element in array
   int* pointToNums = myNumbers;

   cout << "Display array using pointer syntax, operator*" << endl;
   for (int index = 0; index < ARRAY_LEN; ++index)
      cout << "Element " << index << " = " << *(myNumbers + index) << endl;

   cout << "Display array using ptr with array syntax, operator[]" << endl;
   for (int index = 0; index < ARRAY_LEN; ++index)
      cout << "Element " << index << " = " << pointToNums[index] << endl;

   return 0;
}

const指针

指针的主要功能是指向一个 变量的地址 ,理解为越靠近变量的限制性越高级。

  • const贴近变量名,不允许修改指针指向的地址,可以修改指向的变量。
#include <iostream>
using namespace std;

int main()  
{
   int age = 30;
   int dogsAge = 9;

   int* const pointsToAge = &age;
   cout << "*pointsToAge = " << *pointsToAge << endl;

   // pointsToAge = &dogsAge; // error! can't change pointer value
   *pointsToAge = 31; // ok! can change value pointed to
   cout << "*pointsToAge = " << *pointsToAge << endl;

   return 0;
}
  • const远离变量名,在最前,不允许修改指向变量的值,指针可以指向其他地方。
#include <iostream>
using namespace std;

int main()
{
   int age = 30;
   int dogsAge = 9;

   const int* pointsToAge = &age;
   cout << "*pointsToAge = " << *pointsToAge << endl;

   // *pointsToAge = 31; // error! can't change value pointed to
   pointsToAge = &dogsAge; // ok! can change pointer value
   cout << "*pointsToAge = " << *pointsToAge << endl;

   return 0; 
}
  • 两个都有 const 修饰,则都不可以修改。
#include <iostream>
using namespace std;

int main()
{
   int age = 30;
   int dogsAge = 9;

   const int* const pointsToAge = &age;
   cout << "*pointsToAge = " << *pointsToAge << endl;

   // *pointsToAge = 31; // error! can't change value pointed to
   // pointsToAge = &dogsAge; // error! can't change pointer value

   return 0; 
}

指针传递给函数

需要提前规定好 函数内部可以修改哪些值,不可以修改哪些值

#include <iostream>
using namespace std;

void CalcArea(const double* const ptrPi, // const pointer to const data
              const double* const ptrRadius, // i.e. no changes allowed
              double* const ptrArea)  // can change data pointed to,but not pointer
{
   // check pointers for validity before using!
   if (ptrPi && ptrRadius && ptrArea) 
      *ptrArea = (*ptrPi) * (*ptrRadius) * (*ptrRadius);
}

int main()
{
   const double Pi = 3.1416;

   cout << "Enter radius of circle: ";
   double radius = 0;
   cin >> radius;

   double area = 0;
   CalcArea (&Pi, &radius, &area);

   cout << "Area is = " << area << endl;

   return 0;
}

使用new的内存分配请求可能失败

为防止报错,此时使用 try-catch 或者使用 new(nothrow) ,它在分配内存失败时返回 NULL 。也不会报错退出。

  • try catch
#include <iostream>
using namespace std;

// remove the try-catch block to see this application crash 
int main()
{
   try
   {
      // Request a LOT of memory!
      int* pointsToManyNums = new int [0x1fffffff];

      // Use the allocated memory 

      delete[] pointsToManyNums;
   }
   catch (bad_alloc)
   {
      cout << "Memory allocation failed. Ending program" << endl;
   }
   return 0;
}
  • 使用new(nothrow)
#include <iostream>
using namespace std;

int main()
{
   // Request LOTS of memory space, use nothrow 
   int* pointsToManyNums = new(nothrow) int [0x1fffffff];

   if (pointsToManyNums) // check pointsToManyNums != NULL
   {
      // Use the allocated memory 
      delete[] pointsToManyNums;
   }
   else 
      cout << "Memory allocation failed. Ending program" << endl;

   return 0;
}

引用&是变量的别名

引用是变量的别名。声明引用时,需要将其初始化为一个变量,因此引用只是另一种访问相应变量存储的数据的方式。

#include <iostream>
using namespace std;

int main()
{
   int original = 30;
   cout << "original = " << original << endl;
   cout << "original is at address: " << hex << &original << endl;

   int& ref1 = original;
   cout << "ref1 is at address: " << hex << &ref1 << endl;

   int& ref2 = ref1;
   cout << "ref2 is at address: " << hex << &ref2 << endl;
   cout << "Therefore, ref2 = " << dec << ref2 << endl;

   return 0;
}

他们指向同一个地址。

引用的用处

函数参数,如果在合适的时机,按引用传递,可以省去变量复制的步骤,优化性能。

#include <iostream>
using namespace std;

void GetSquare(int& number)
{
   number *= number;
}

int main()
{
   cout << "Enter a number you wish to square: ";
   int number = 0;
   cin >> number;

   GetSquare(number);
   cout << "Square is: " << number << endl;

   return 0;
}

const用于引用

通过引用可以修改变量的值。可能需要禁止通过引用修改它指向的变量的值,为此可在声明引用时使用关键字 const。

int original = 30;
const int& constRef = original;
constRef = 40; // Not allowed: constRef can’t change value in original
int& ref2 = constRef; // Not allowed: ref2 is not const
const int& constRef2 = constRef; // OK

结合上一个,传参时,使用const引用,又可以避免复制,又可以确保函数体中,不可以修改按引用传进去的变量的值

声明和使用类

#include <iostream>
#include <string>
using namespace std;

class Human
{
public:
   string name;
   int age;

   void IntroduceSelf()
   {
      cout << "I am " + name << " and am ";
      cout << age << " years old" << endl;
   }
};

int main()
{
   // An object of class Human with attribute name as "Adam"
   Human firstMan;
   firstMan.name = "Adam";
   firstMan.age = 30;

   // An object of class Human with attribute name as "Eve"
   Human firstWoman;
   firstWoman.name = "Eve";
   firstWoman.age = 28;
   
   firstMan.IntroduceSelf();
   firstWoman.IntroduceSelf();
}

使用指针运算符访问成员

如果对象是使用 new 在自由存储区中实例化的,或者有指向对象的指针,则可使用指针运算符 -> 来访问成员属性和方法:

Human* firstWoman = new Human();
firstWoman->dateOfBirth = "1970";
firstWoman->IntroduceSelf();
delete firstWoman;

类中的变量和函数,如果未标明,默认都为 private ,外部不可以访问 默认构造函数:不用传参的构造函数,并非仅指无参构造函数。

构造函数可以重载,也可以设置成必须要初始化参数,还可以带默认参数 带初始化列表的构造函数。

写法如下:

#include <iostream>
#include <string>
using namespace std;

class Human
{
private:
   int age;
   string name;

public:
   Human(string humansName = "Adam", int humansAge = 25)
        :name(humansName), age(humansAge)
   {
      cout << "Constructed a human called " << name;
      cout << ", " << age << " years old" << endl;
   }
};

int main()
{
   Human adam;
   Human eve("Eve", 18);

   return 0;
}

析构函数

用于回收资源,当类中有动态申请内存的操作时,一般需要设计析构函数,在类销毁时释放内存,声明为 ~ClassName() {}

#include <iostream>
#include <string.h>
using namespace std;

class MyString
{
private:
   char* buffer;

public:
   MyString(const char* initString)  // constructor
   {
      if(initString != NULL)
      {
         buffer = new char [strlen(initString) + 1];
         strcpy(buffer, initString);
      }
      else 
         buffer = NULL;
   }

   ~MyString()
   {
      cout << "Invoking destructor, clearing up" << endl;
      if (buffer != NULL)
         delete [] buffer;
   }

   int GetLength() 
   {
      return strlen(buffer);
   }

   const char* GetString()
   {
       return buffer;
   }
};

int main()
{
   MyString sayHello("Hello from String Class");
   cout << "String buffer in sayHello is " << sayHello.GetLength();
   cout << " characters long" << endl;

   cout << "Buffer contains: " << sayHello.GetString() << endl;
}

类的浅复制的问题

把类当作参数传递给函数时,使用值传递模式,其会被复制。

如果其内部有指针指向的new出来的缓冲区,复制时会复制指针成员,但是不会复制一份缓冲区,在函数结束时,回收这个复制出来的类对象,删除掉了缓冲区,导致外部的类指向无效的内存地址。在外部类生命周期完结时,delete无效内存会报错。

#include <iostream>
#include <string.h>
using namespace std;

class MyString
{
private:
   char* buffer;

public:
   MyString(const char* initString) // Constructor
   {
      buffer = NULL;
      if(initString != NULL)
      {
         buffer = new char [strlen(initString) + 1];
         strcpy(buffer, initString);
      }
   }

   ~MyString() // Destructor
   {
      cout << "Invoking destructor, clearing up" << endl;
      delete [] buffer;
   }

   int GetLength() 
   { return strlen(buffer); }

   const char* GetString()
   { return buffer; }
};

void UseMyString(MyString str)
{
   cout << "String buffer in MyString is " << str.GetLength();
   cout << " characters long" << endl;

   cout << "buffer contains: " << str.GetString() << endl;
   return;
}

int main()
{
   MyString sayHello("Hello from String Class");
   UseMyString(sayHello); 

   return 0;
}

/**
String buffer in MyString is 23 characters long
buffer contains: Hello from String Class
Invoking destructor, clearing up
Invoking destructor, clearing up
<crash as seen in Figure 9.2>
*/

复制构造函数

一个专门的用于复制流程的构造函数,当通过 = 传递来复制类,或者当作函数参数来复制时。编译器会自动调用这个构造函数来生成一个新的对象。

默认格式为,传入一个 const引用 的构造函数,借用这个外部对象的数据来重新构造一个新的复制对象。

#include <iostream>
#include <string.h>
using namespace std;

class MyString
{
private:
   char* buffer;

public:
   MyString() {}
   MyString(const char* initString) // constructor
   {
      buffer = NULL;
      cout << "Default constructor: creating new MyString" << endl;
      if(initString != NULL)
      {
         buffer = new char [strlen(initString) + 1];
         strcpy(buffer, initString);

         cout << "buffer points to: 0x" << hex;
         cout << (unsigned int*)buffer << endl;
      }
   }

   MyString(const MyString& copySource) // Copy constructor
   {
      buffer = NULL;
      cout << "Copy constructor: copying from MyString" << endl;
      if(copySource.buffer != NULL)
      {
         // allocate own buffer 
         buffer = new char [strlen(copySource.buffer) + 1];

         // deep copy from the source into local buffer
         strcpy(buffer, copySource.buffer);

         cout << "buffer points to: 0x" << hex;
         cout << (unsigned int*)buffer << endl;
      }
   }

   MyString operator+ (const MyString& addThis) 
   {
      MyString newString;

      if (addThis.buffer != NULL)
      {
         newString.buffer = new char[GetLength() + strlen(addThis.buffer) + 1];
         strcpy(newString.buffer, buffer);
         strcat(newString.buffer, addThis.buffer);
      }

      return newString;
   }

   // Destructor
   ~MyString()
   {
      cout << "Invoking destructor, clearing up" << endl;
      delete [] buffer;
   }

   int GetLength() 
   { return strlen(buffer); }

   const char* GetString()
   { return buffer; }
};

void UseMyString(MyString str)
{
   cout << "String buffer in MyString is " << str.GetLength();
   cout << " characters long" << endl;

   cout << "buffer contains: " << str.GetString() << endl;
   return;
}

int main()
{
   MyString sayHello("Hello from String Class");
   UseMyString(sayHello);

   return 0;
}

将复制构造函数和 = 运算符覆写成私有的

这个类不允许复制操作,编译时就会提示。

单例类

进一步将构造函数设置私有,提供一个static函数,返回一个static对象的引用,就是一个单例类,禁止复制,赋值,创建多实例。static对象只会创建一次,全局均可访问。所有的地方调用的都是这同一个实例。

#include <iostream>
#include <string>
using namespace std;

class President
{
private:
   President() {}; // private default constructor
   President(const President&); // private copy constructor
   const President& operator=(const President&); // assignment operator

   string name;

public:
   static President& GetInstance()
   {
      // static objects are constructed only once
      static President onlyInstance; 
      return onlyInstance;
   }

   string GetName()
   { return name; }

   void SetName(string InputName)
   { name = InputName; }
};

int main()
{
   President& onlyPresident = President::GetInstance();
   onlyPresident.SetName("Abraham Lincoln");

   // uncomment lines to see how compile failures prohibit duplicates
   // President second; // cannot access constructor
   // President* third= new President(); // cannot access constructor
   // President fourth = onlyPresident; // cannot access copy constructor
   // onlyPresident = President::GetInstance(); // cannot access operator=

   cout << "The name of the President is: ";
   cout << President::GetInstance().GetName() << endl;

   return 0;
}

将析构函数设为私有,就禁止在栈中实例化

只能通过 new 关键字,在自由存储区实例化

#include <iostream>
using namespace std;

class MonsterDB 
{
private:
   ~MonsterDB() {}; // private destructor prevents instances on stack

public:
   static void DestroyInstance(MonsterDB* pInstance)
   {
      delete pInstance; // member can invoke private destructor
   }

   void DoSomething() {} // sample member method
};

int main()
{
   MonsterDB* myDB = new MonsterDB(); // on heap
   myDB->DoSomething();

   // uncomment next line to see compile failure 
   // delete myDB; // private destructor cannot be invoked

   // use static member to release memory
   MonsterDB::DestroyInstance(myDB);

   return 0;
}

隐式转换和预防

Human类构造函数接受一个int类型作为参数。

这样的转换构造函数让您能够执行隐式转换:

Human anotherKid = 11; // int converted to Human
DoSomething(10); // 10 converted to Human!

函数 DoSomething(Human person)被声明为接受一个 Human(而不是 int)参数!前面的代码为何可行呢?这是因为编译器知道 Human 类包含一个将整数作为参数的构造函数,进而替您执行了隐式转换:将您提供的整数作为参数发送给这个构造函数,从而创建一个Human 对象。

使用 explicit 关键字避免隐式转换:

#include<iostream>
using namespace std;

class Human
{
   int age;
public:
   // explicit constructor blocks implicit conversions
   explicit Human(int humansAge) : age(humansAge) {}
};

void DoSomething(Human person)
{
   cout << "Human sent did something" << endl;
   return;
}

int main()
{
   Human kid(10);    // explicit converion is OK
   Human anotherKid = Human(11); // explicit, OK
   DoSomething(kid); // OK

   // Human anotherKid = 11; // failure: implicit conversion not OK
   // DoSomething(10); // implicit conversion 

   return 0;
}

this指针

当您在类成员方法中调用其他成员方法时,编译器将隐式地传递 this 指针—函数调用中不可见的参数:

class Human
{
private:
 void Talk (string Statement)
 {
 cout << Statement;
 }
public:
 void IntroduceSelf()
 {
 Talk("Bla bla"); // same as Talk(this, "Bla Bla")
 }
};

在这里,方法 IntroduceSelf( )使用私有成员 Talk( )在屏幕上显示一句话。实际上,编译器将在调用Talk 时嵌入 this 指针,即:

Talk(this, Blab la)

sizeof()用于类

在这种情况下,它将指出类声明中所有数据属性占用的总内存量,单位为字节。 sizeof() 可能对某些属性进行填充,使其与字边界对齐,也可能不这样做,这取决于您使用的编译器。

友元类和友元函数

可以访问类的私有private的属性和方法

#include <iostream>
#include <string>
using namespace std;

class Human
{
private:
   friend void DisplayAge(const Human& person);
   string name;
   int age;

public:
   Human(string humansName, int humansAge) 
   {
      name = humansName;
      age = humansAge;
   }
};

void DisplayAge(const Human& person)
{
   cout << person.age << endl;
}

int main()
{
   Human firstMan("Adam", 25);
   cout << "Accessing private member age via friend function: ";
   DisplayAge(firstMan);

   return 0;
}#include <iostream>
#include <string>
using namespace std;

class Human
{
private:
   friend class Utility;
   string name;
   int age;

public:
   Human(string humansName, int humansAge) 
   {
      name = humansName;
      age = humansAge;
   }
};

class Utility
{
public:
   static void DisplayAge(const Human& person)
   {
      cout << person.age << endl;
   }
};

int main()
{
   Human firstMan("Adam", 25);
   cout << "Accessing private member age via friend class: ";
   Utility::DisplayAge(firstMan);

   return 0;
}

盲猜用于共同实现某一功能的两个模块,比如混动车的燃油发动机给电动机供电。

struct结构体,和类类似,属性默认为公开

关键字 struct 来自 C 语言,在 C++编译器看来,它与类及其相似,差别在于程序员未指定时,默认的访问限定符(public 和 private)不同。因此,除非指定了,否则结构中的成员默认为公有的(而类成员默认为私有的);另外,除非指定了,否则结构以公有方式继承基结构(而类为私有继承)。

union共用体

共用体是一种特殊的类,每次只有一个非静态数据成员处于活动状态。因此,共用体与类一样,可包含多个数据成员,但不同的是只能使用其中的一个。

与结构类似,共用体的成员默认也是公有的,但不同的是,共用体不能继承。另外,将 sizeof() 用于共用体时,结果总是为共用体最大成员的长度,即便该成员并不处于活动状态。

常见使用场景

#include <iostream>
using namespace std;

union SimpleUnion
{
   int num;
   char alphabet;
};

struct ComplexType
{
   enum DataType
   {
      Int,
      Char
   } Type;

   union Value
   {
      int num;
      char alphabet;

      Value() {}
      ~Value() {}
   }value;
};

void DisplayComplexType(const ComplexType& obj)
{
   switch (obj.Type)
   {
   case ComplexType::Int:
      cout << "Union contains number: " << obj.value.num << endl;
      break;

   case ComplexType::Char:
      cout << "Union contains character: " << obj.value.alphabet << endl;
      break;
   }
}

int main()
{
   SimpleUnion u1, u2;
   u1.num = 2100;
   u2.alphabet = 'C';

   // Alternative using aggregate initialization:
   // SimpleUnion u1{ 2100 }, u2{ 'C' }; // Note that 'C' still initializes first / int member

   cout << "sizeof(u1) containing integer: " << sizeof(u1) << endl;
   cout << "sizeof(u2) containing character: " << sizeof(u2) << endl;

   ComplexType myData1, myData2;
   myData1.Type = ComplexType::Int;
   myData1.value.num = 2017;

   myData2.Type = ComplexType::Char;
   myData2.value.alphabet = 'X';

   DisplayComplexType(myData1);
   DisplayComplexType(myData2);

   return 0;
}

/**
sizeof(u1) containing integer: 4
sizeof(u2) containing character: 4
Union contains number: 2017
Union contains character: X
*/

换句话说,这个结构使用枚举来存储信息类型,并使用共用体来存储实际值。这是共用体的一种常见用法,例如,在 Windows 应用程序编程中常用的结构 VARIANT 就以这样的方式使用了共用体.

聚合初始化

#include <iostream>
#include<string>
using namespace std;

class Aggregate1
{
public:
   int num;
   double pi;
};

struct Aggregate2
{
   char hello[6];
   int impYears[3];
   string world;
};

int main()
{
   int myNums[] = { 9, 5, -1 }; // myNums is int[3]
   Aggregate1 a1{ 2017, 3.14 };
   cout << "Pi is approximately: " << a1.pi << endl;

   Aggregate2 a2{ {'h', 'e', 'l', 'l', 'o'}, {2011, 2014, 2017}, "world"};

   // Alternatively
   Aggregate2 a2_2{'h', 'e', 'l', 'l', 'o', '\0', 2011, 2014, 2017, "world"};

   cout << a2.hello << ' ' << a2.world << endl;
   cout << "C++ standard update scheduled in: " << a2.impYears[2] << endl;

   return 0;
}

constexpr还可以用于类的构造函数和成员函数,编译器会尽可能将其视为常量处理

#include <iostream>
using namespace std;

class Human
{
    int age;
public:
    constexpr Human(int humansAge) :age(humansAge) {}
    constexpr int GetAge() const { return age; }
};

int main()
{
    constexpr Human somePerson(15);
    const int hisAge = somePerson.GetAge();

    Human anotherPerson(45); // not constant expression

    return 0;
}

最简单的继承

#include <iostream>
using namespace std; 


class Fish
{
public:
   bool isFreshWaterFish;

   void Swim()
   {
      if (isFreshWaterFish)
         cout << "Swims in lake" << endl;
      else
         cout << "Swims in sea" << endl;
   }
};

class Tuna: public Fish
{
public:
   Tuna()
   {
      isFreshWaterFish = false;
   }
};

class Carp: public Fish
{
public:
   Carp()
   {
      isFreshWaterFish = true;
   }
};

int main()
{
   Carp myLunch;
   Tuna myDinner;

   cout << "Getting my food to swim" << endl;

   cout << "Lunch: ";
   myLunch.Swim();

   cout << "Dinner: ";
   myDinner.Swim();

   return 0;
}

基类使用protected关键字,该成员只有子类和友元中可以访问,外部不可以访问

基类构造器可以带参数,子类构造器必须一起初始化基类的构造器

#include <iostream>
using namespace std; 

class Fish
{
protected:
   bool isFreshWaterFish; // accessible only to derived classes

public:
   // Fish constructor
   Fish(bool isFreshWater) : isFreshWaterFish(isFreshWater){}

   void Swim()
   {
      if (isFreshWaterFish)
         cout << "Swims in lake" << endl;
      else
         cout << "Swims in sea" << endl;
   }
};

class Tuna: public Fish
{
public:
   Tuna(): Fish(false) {}
};

class Carp: public Fish
{
public:
   Carp(): Fish(true) {}
};

int main()
{
   Carp myLunch;
   Tuna myDinner;

   cout << "Getting my food to swim" << endl;

   cout << "Lunch: ";
   myLunch.Swim();

   cout << "Dinner: ";
   myDinner.Swim();

   return 0;
}

基类属性和方法的覆写

如果派生类实现了从基类继承的函数,且返回值和特征标相同,就相当于覆盖了基类的这个方法。

如果基类的方法是public的,外部可以通过域解析运算符::来调用基类方法。 myDinner.Fish::Swim(); // invokes Fish::Swim() using instance of Tuna在子类中,同样用上述方法来调用。

隐藏基类方法

基类中有同名的重载方法时,子类覆写其中一个,子类中会对外隐藏所有的同名方法。

#include <iostream>
using namespace std; 
   
class Fish
{
public:
   void Swim()
   {
       cout << "Fish swims... !" << endl;
   }

   void Swim(bool isFreshWaterFish)
   {
      if (isFreshWaterFish)
         cout << "Swims in lake" << endl;
      else
         cout << "Swims in sea" << endl;
   }
};

class Tuna: public Fish
{
public:
   void Swim(bool isFreshWaterFish)
   {
       Fish::Swim(isFreshWaterFish);
   }

   void Swim()
   {
      cout << "Tuna swims real fast" << endl;
   }
};

int main()
{
   Tuna myDinner;

   cout << "Getting my food to swim" << endl;
   
   myDinner.Swim(false);//failure: Tuna::Swim() hides Fish::Swim(bool)

   myDinner.Swim();

   return 0;
}

要想解除隐藏,外部可以通过域解析运算符直接调用到基类的方法。或者在子类中使用using解除对基类方法的隐藏。

class Tuna: public Fish
{
public:
 using Fish::Swim; // unhide all Swim() methods in class Fish
 void Swim()
 {
 cout << "Tuna swims real fast" << endl;
 }
}; 

构造和析构顺序

构造时先构造基类部分,再构造子类部分。

析构时先调用子类的,再调用基类的。

class Car:private Motor私有继承

只有子类可以访问基类中的属性,方法,外部不可以调用基类方法。也不可以通过域解析运算符访问。

私有继承时,子类的子类同样不可以访问基类的方法。例如RaceCar继承自Car,它也访问不了Motor

protected保护继承

同样屏蔽了外部访问,但是使用保护继承的子类的子类可以访问到基类的属性,方法。

仅当必要时才使用私有或保护继承。

对于大多数使用私有继承的情形(如 Car 和 Motor 之间的私有继承),更好的选择是,将基类对象作为派生类的一个成员属性。通过继承 Motor 类,相当于对 Car 类进行了限制,使其只能有一台发动机,同时,相比于将 Motor 对象作为私有成员,没有任何好处可言。汽车在不断发展,例如,混合动力车除电力发动机外,还有一台汽油发动机。在这种情况下,让 Car 类继承 Motor 类将成为兼容性瓶颈。

class Car
{
private:
 Motor heartOfCar;
public:
 void Move()
 {
 heartOfCar.SwitchIgnition();
 heartOfCar.PumpFuel();
 heartOfCar.FireCylinders();
 }
}; 

切除问题

一个方法接受一个基类参数,但是传递一个子类对象过去,这时候,复制机制只会复制基类部分,子类的部分将被切除。

可以同时继承多个基类

使用final关键字禁止继承,表示其为最终的子类

多态

上面的切除问题可以使用多态的特性来规避。让我们可以用类似的方式处理不同类型的对象。

将基类的方法声明为虚函数,可以确保编译器调用子类中的覆写方法。

下面的例子中,子类中的方法声明为:virtual void Swim()

#include <iostream>
using namespace std;

class Fish
{
public:
   virtual void Swim()
   {
      cout << "Fish swims!" << endl;
   }
};

class Tuna:public Fish
{
public:
   // override Fish::Swim
   void Swim()
   {
      cout << "Tuna swims!" << endl;
   }
};

class Carp:public Fish
{
public:
   // override Fish::Swim
   void Swim()
   {
      cout << "Carp swims!" << endl;
   }
};

void MakeFishSwim(Fish& InputFish)
{
   // calling Swim
   InputFish.Swim();
}

int main() 
{
   Tuna myDinner;
   Carp myLunch;

   // sending Tuna as Fish
   MakeFishSwim(myDinner);

   // sending Carp as Fish
   MakeFishSwim(myLunch);

   return 0;
}

/**
Tuna swims!
Carp swims!
*/

因为存在覆盖版本 Tuna::Swim()Carp::Swim() ,它们优先于被声明为虚函数的 Fish::Swim() 。这很重要,它意味着在 MakeFishSwim() 中,可通过 Fish& 参数调用派生类定义的 Swim() ,而无需知道该参数指向的是哪种类型的对象。

这就是多态:将派生类对象视为基类对象,并执行派生类的 Swim() 实现。

类似的,析构函数也需要声明为虚析构函数

将子类指针当作基类指针传递参数时,函数体内调用删除方法,删除时只会调用基类的析构函数,子类的部分将不会回收,将造成内存泄漏。

将基类的析构函数声明为 vitual 的,再使用基类指针删除时,将确保调用到子类的析构函数。

#include <iostream>
using namespace std;

class Fish
{
public:
   Fish()
   {
      cout << "Constructed Fish" << endl;
   }
   virtual ~Fish()   // virtual destructor!
   {
      cout << "Destroyed Fish" << endl;
   }
};

class Tuna:public Fish
{
public:
   Tuna()
   {
      cout << "Constructed Tuna" << endl;
   }
   ~Tuna()
   {
      cout << "Destroyed Tuna" << endl;
   }
};

void DeleteFishMemory(Fish* pFish)
{
   delete pFish;
}

int main() 
{
   cout << "Allocating a Tuna on the free store:" << endl;
   Tuna* pTuna = new Tuna;
   cout << "Deleting the Tuna: " << endl;
   DeleteFishMemory(pTuna);

   cout << "Instantiating a Tuna on the stack:" << endl;
   Tuna myDinner;
   cout << "Automatic destruction as it goes out of scope: " << endl;

   return 0;
}

虚函数表

编译器将为实现了虚函数的基类和覆盖了虚函数的派生类分别创建一个虚函数表(VirtualFunction Table,VFT)。换句话说,Base 和 Derived 类都将有自己的虚函数表。实例化这些类的对象时,将创建一个隐藏的指针(我们称之为 VFT*),它指向相应的 VFT。可将 VFT 视为一个包含函数指针的静态数组,其中每个指针都指向相应的虚函数。

子类覆写了基类的某些虚函数时,子类的虚函数表的函数指针将指向子类自己的函数实现。

对于未覆写的基类函数,虚函数表中的函数指针会指向基类的函数。

外部调用时,就通过虚函数表来查找到底该调用的子类方法还是基类方法。

有纯虚函数的类可以称为抽象基类

纯虚函数定义:virtual void Swim() = 0;

子类继承了抽象基类,则必须覆写其定义的纯虚函数。

#include <iostream>
using namespace std;

class Fish
{
public:
   // define a pure virtual function Swim
   virtual void Swim() = 0;
};

class Tuna:public Fish
{
public:
   void Swim()
   {
      cout << "Tuna swims fast in the sea!" << endl;
   }
};

class Carp:public Fish
{
   void Swim()
   {
      cout << "Carp swims slow in the lake!" << endl;
   }
};

void MakeFishSwim(Fish& inputFish)
{
   inputFish.Swim();
}

int main()
{
   // Fish myFish;   // Fails, cannot instantiate an ABC
   Carp myLunch;
   Tuna myDinner;

   MakeFishSwim(myLunch);
   MakeFishSwim(myDinner);

   return 0;
}

虚继承解决菱形问题

鸭嘴兽具备哺乳动物、鸟类和爬行动物的特征,这意味着 Platypus 类需要继承Mammal、Bird 和 Reptile。然而,这些类都从同一个类—Animal 派生而来。全部使用常规继承方式,将创建三个animal的实例,甚至可以分别设置每一个的age属性。

当派生类可能用作基类时,使用vitual虚继承是更好的选择。这样当一个类继承多个从相同基类衍生而来的类时,只创建一个基类实例。

#include <iostream>
using namespace std;

class Animal
{
public:
   Animal()
   {
      cout << "Animal constructor" << endl;
   }

   // sample member
   int age;
};

class Mammal:public virtual Animal
{
};

class Bird:public virtual Animal
{
};

class Reptile:public virtual Animal
{
};

class Platypus final:public Mammal, public Bird, public Reptile
{
public:
   Platypus()
   {
      cout << "Platypus constructor" << endl;
   }
};

int main()
{
   Platypus duckBilledP;

   // no compile error as there is only one Animal::age
   duckBilledP.age = 25; 

   return 0;
}

/**
Animal constructor
Platypus constructor
*/

C++关键字 virtual 的含义随上下文而异(我想这样做的目的很可能是为了省事),对其含义总结如下:

  1. 在函数声明中,virtual 意味着当基类指针指向派生对象时,通过它可调用派生类的相应函数。
  2. 从 Base 类派生出 Derived1 和 Derived2 类时,如果使用了关键字 virtual,则意味着再从Derived1 和 Derived2 派生出 Derived3 时,每个 Derived3 实例只包含一个 Base 实例。

也就是说,关键字 virtual 被用于实现两个不同的概念。

override关键字

子类中覆写基类方法时,通过override关键字,检查基类中对应的方法是否声明了虚函数,防止标记错误导致覆写失败。好的编程习惯是每个子类覆写函数后都加入override标记。

虚复制构造函数

不可能实现虚复制构造函数,因为在基类方法声明中使用关键字 virtual 时,表示它将被派生类的实现覆盖,这种多态行为是在运行阶段实现的。而构造函数只能创建固定类型的对象,不具备多态性,因此 C++不允许使用虚复制构造函数。

可以通过自己基类定义虚函数,并在子类实现,一个专门的Clone函数,外部来显式调用,定义返回一个基类指针类型,传入子类指针。就可以让返回的基类指针在调用方法时表现为子类的特性。

#include <iostream>
using namespace std;

class Fish
{
public:
   virtual Fish* Clone() = 0;
   virtual void Swim() = 0;
   virtual ~Fish() {};
};

class Tuna: public Fish
{
public:
   Fish* Clone() override
   {
      return new Tuna (*this);
   }

   void Swim() override final
   {
      cout << "Tuna swims fast in the sea" << endl;
   }
};

class BluefinTuna final:public Tuna
{
public:
   Fish* Clone() override
   {
      return new BluefinTuna(*this);
   }

   // Cannot override Tuna::Swim as it is "final" in Tuna
};

class Carp final: public Fish
{
   Fish* Clone() override
   {
      return new Carp(*this);
   }
   void Swim() override final
   {
      cout << "Carp swims slow in the lake" << endl;
   }
};

int main()
{
   const int ARRAY_SIZE = 4;

   Fish* myFishes[ARRAY_SIZE] = {NULL};
   myFishes[0] = new Tuna();
   myFishes[1] = new Carp();
   myFishes[2] = new BluefinTuna();
   myFishes[3] = new Carp();

   Fish* myNewFishes[ARRAY_SIZE];
   for (int index = 0; index < ARRAY_SIZE; ++index)
      myNewFishes[index] = myFishes[index]->Clone();

   // invoke a virtual method to check
   for (int index = 0; index < ARRAY_SIZE; ++index)
      myNewFishes[index]->Swim();

   // memory cleanup
   for (int index = 0; index < ARRAY_SIZE; ++index)
   {
      delete myFishes[index];
      delete myNewFishes[index];
   }

   return 0;
}

对类使用单目运算符

在类中定义:

return_type operator operator_symbol (...parameter list...);

比如将Date类实现++运算操作。

// also contains postfix increment and decrement

#include <iostream>
using namespace std;

class Date
{
private:
   int day, month, year;

public:
   Date (int inMonth, int inDay, int inYear)
        : month (inMonth), day(inDay), year (inYear) {};

   Date& operator ++ () // prefix increment
   {
      ++day;
      return *this;
   }

   Date& operator -- () // prefix decrement
   {
      --day;
      return *this;
   }

   Date operator ++ (int) // postfix increment
   {
      Date copy(month, day, year);
      ++day;
      return copy;
   }

   Date operator -- (int) // postfix decrement
   {
      Date copy(month, day, year);
      --day;
      return copy;
   }

   void DisplayDate()
   {
      cout << month << " / " << day << " / " << year << endl;
   }
};

int main ()
{
   Date holiday (12, 25, 2016); // Dec 25, 2016

   cout << "The date object is initialized to: ";
   holiday.DisplayDate ();

   ++holiday; // move date ahead by a day
   cout << "Date after prefix-increment is: ";
   holiday.DisplayDate ();

   --holiday; // move date backwards by a day
   cout << "Date after a prefix-decrement is: ";
   holiday.DisplayDate ();

   return 0;
}

转换运算符

operator const char*()
{
 // operator implementation that returns a char*
}

在外部希望这个类以const char*的类型使用时,比如cout « date。可以类比java里的toString。

#include <iostream>
#include <sstream> // new include for ostringstream
#include <string>
using namespace std;

class Date
{
private:
   int day, month, year;
   string dateInString;

public:
   Date(int inMonth, int inDay, int inYear)
      : month(inMonth), day(inDay), year(inYear) {};

   operator const char*()
   {
      ostringstream formattedDate; // assists easy string construction
      formattedDate << month << " / " << day << " / " << year;
     
      dateInString = formattedDate.str();
      return dateInString.c_str();
   }
};
   
int main ()
{
   Date Holiday (12, 25, 2016);

   cout << "Holiday is on: " << Holiday << endl;

   // string strHoliday (Holiday); // OK!
   // strHoliday = Date(11, 11, 2016); // also OK!

   return 0;
}

智能指针初体验

#include <iostream>
#include <memory>  // new include to use unique_ptr
using namespace std;

class Date
{
private:
   int day, month, year;
   string dateInString;   

public:
   Date(int inMonth, int inDay, int inYear)
      : month(inMonth), day(inDay), year(inYear) {};

   void DisplayDate()
   {
      cout << month << " / " << day << " / " << year << endl;
   }
};

int main()
{
   unique_ptr<int> smartIntPtr(new int);
   *smartIntPtr = 42;

   // Use smart pointer type like an int*
   cout << "Integer value is: " << *smartIntPtr << endl;

   unique_ptr<Date> smartHoliday (new Date(12, 25, 2016));
   cout << "The new instance of date contains: ";

   // use smartHoliday just as you would a Date*
   smartHoliday->DisplayDate();

   return 0;
}

这个示例表明,可像使用普通指针那样使用智能指针,如第 23 和 32 行所示。第 23 行使用了smartIntPtr 来显示指向的 int 值,而第 32 行使用了 smartHoliday->DisplayData(),就像这两个变量的类型分别是 int和 Date。其中的秘诀在于,智能指针类 std::unique_ptr 实现了运算符和->

类实现双目加减法

#include <iostream>
using namespace std;
   
class Date
{
private:
   int day, month, year;
   string dateInString;

public:
   Date(int inMonth, int inDay, int inYear)
      : month(inMonth), day(inDay), year(inYear) {};
   
   Date operator + (int daysToAdd) // binary addition
   {
      Date newDate (month, day + daysToAdd, year);
      return newDate;
   }

   Date operator - (int daysToSub) // binary subtraction
   {
      return Date(month, day - daysToSub, year);
   }

   void DisplayDate()
   {
      cout << month << " / " << day << " / " << year << endl;
   }
};

int main()
{
   Date Holiday (12, 25, 2016);
   cout << "Holiday on: ";
   Holiday.DisplayDate ();

   Date PreviousHoliday (Holiday - 19);
   cout << "Previous holiday on: ";
   PreviousHoliday.DisplayDate();

   Date NextHoliday(Holiday + 6);
   cout << "Next holiday on: ";
   NextHoliday.DisplayDate ();

   return 0;
}

字符串使用+拼接

优化MyString类: 定义运算符+

MyString operator+ (const MyString& addThis)
{
 MyString newString;
 if (addThis.buffer != NULL)
 {
 newString.buffer = new char[GetLength() + strlen(addThis.buffer) + 1];
 strcpy(newString.buffer, buffer);
 strcat(newString.buffer, addThis.buffer);
 }
 return newString;
}

重载==和!=运算符

定义这两个运算符之前,编译器会直接比较二进制数据,简单对象可以正常返回正确结果。但是如果有char* 等指针数据,我们需要比较的是其指向的数据,而不是指针成员的地址值。

#include <iostream>
using namespace std;

class Date
{
private:
    int day, month, year;

public:
    Date(int inMonth, int inDay, int inYear)
        : month(inMonth), day(inDay), year(inYear) {}

   bool operator== (const Date& compareTo)
   {
      return ((day == compareTo.day) 
            && (month == compareTo.month) 
           && (year == compareTo.year));
   }

   bool operator!= (const Date& compareTo)
   {
      return !(this->operator==(compareTo));
   }

   void DisplayDate()
   {
       cout << month << " / " << day << " / " << year << endl;
   }
};
   
int main()
{
   Date holiday1 (12, 25, 2016);
   Date holiday2 (12, 31, 2016);

   cout << "holiday 1 is: ";
   holiday1.DisplayDate();
   cout << "holiday 2 is: ";
   holiday2.DisplayDate();

   if (holiday1 == holiday2)
      cout << "Equality operator: The two are on the same day" << endl;
   else
      cout << "Equality operator: The two are on different days" << endl;

   if (holiday1 != holiday2)
      cout << "Inequality operator: The two are on different days" << endl;
   else
      cout << "Inequality operator: The two are on the same day" << endl;

   return 0;
}

/**
holiday 1 is: 12 / 25 / 2016
holiday 2 is: 12 / 31 / 2016
Equality operator: The two are on different days
Inequality operator: The two are on different days 
*/

小于(<)、大于(>)、小于等于(<=)和大于等于(>=)运算符大致同上 覆写,并指定自己的一套比较标准,返回结果即可。

复制赋值运算符=

复制构造函数是通过复制场景来创建一个类时调用,这个是将一个类通过复制运算符赋给另一个类时使用。

需要清空当前类里需要覆盖的部分,使用新值。

#include <iostream>
#include <string.h>
using namespace std;

class MyString
{
private:
   char* buffer;
   
public:
   MyString(const char* initialInput)
   {
      if(initialInput != NULL)
      {
         buffer = new char [strlen(initialInput) + 1];
         strcpy(buffer, initialInput);
     }
      else 
         buffer = NULL;
   }

   // Copy assignment operator
   MyString& operator= (const MyString& CopySource)
   {
      if ((this != &CopySource) && (CopySource.buffer != NULL))
      {
         if (buffer != NULL)
          delete[] buffer;

         // ensure deep copy by first allocating own buffer 
         buffer = new char [strlen(CopySource.buffer) + 1];

         // copy from the source into local buffer
         strcpy(buffer, CopySource.buffer);
      }

     return *this;
   }

   operator const char*()
   {
      return buffer;
   }

   ~MyString()
   {
       delete[] buffer;
   }

   MyString(const MyString& CopySource)
   {
       cout << "Copy constructor: copying from MyString" << endl;

       if (CopySource.buffer != NULL)
       {
           // ensure deep copy by first allocating own buffer 
           buffer = new char[strlen(CopySource.buffer) + 1];

           // copy from the source into local buffer
           strcpy(buffer, CopySource.buffer);
       }
       else
           buffer = NULL;
   }
};
   
int main()
{
   MyString string1("Hello ");
   MyString string2(" World");

   cout << "Before assignment: " << endl;
   cout << string1 << string2 << endl;
   string2 = string1;
   cout << "After assignment string2 = string1: " << endl;
   cout << string1 << string2 << endl;

   return 0;
}

/**
Before assignment:
Hello World
After assignment string2 = string1:
Hello Hello 
*/

如果您编写的类管理着动态分配的资源(如使用 new 分配的数组),除构造函数和析构函数外,请务必实现复制构造函数和复制赋值运算符。

如果没有解决对象被复制时出现的资源所有权问题,您的类就是不完整的,使用时甚至会影响应用程序的稳定性

下标运算符[]

编写封装了动态数组的类(如封装了 char* buffer 的 MyString)时,通过实现下标运算符,可轻松地随机访问缓冲区中的各个字符。

#include <iostream>
#include <string>
#include <string.h>
using namespace std;

class MyString
{
private:
   char* Buffer;
   
   // private default constructor
   MyString() {}

public:
   // constructor
   MyString(const char* InitialInput)
   {
      if(InitialInput != NULL)
      {
         Buffer = new char [strlen(InitialInput) + 1];
         strcpy(Buffer, InitialInput);
      }
      else 
         Buffer = NULL;
   }

   MyString operator + (const char* stringIn)
   {
      string strBuf(Buffer);
      strBuf += stringIn;
      MyString ret(strBuf.c_str());
      return ret;
   }

   // Copy constructor
   MyString(const MyString& CopySource)
   {
      if(CopySource.Buffer != NULL)
      {
         // ensure deep copy by first allocating own buffer 
         Buffer = new char [strlen(CopySource.Buffer) + 1];

         // copy from the source into local buffer
         strcpy(Buffer, CopySource.Buffer);
      }
      else 
         Buffer = NULL;
   }

   // Copy assignment operator
   MyString& operator= (const MyString& CopySource)
   {
      if ((this != &CopySource) && (CopySource.Buffer != NULL))
      {
         if (Buffer != NULL)
          delete[] Buffer;

         // ensure deep copy by first allocating own buffer 
         Buffer = new char [strlen(CopySource.Buffer) + 1];

         // copy from the source into local buffer
         strcpy(Buffer, CopySource.Buffer);
      }

     return *this;
   }

   const char& operator[] (int Index) const
   {
      if (Index < GetLength())
         return Buffer[Index];
   }
  
   // Destructor
   ~MyString()
   {
      if (Buffer != NULL)
         delete [] Buffer;
   }

   int GetLength() const
   {
      return strlen(Buffer);
   }

   operator const char*()
   {
      return Buffer;
   }
};

int main()
{
   cout << "Type a statement: ";
   string strInput;
   getline(cin, strInput);

   MyString youSaid(strInput.c_str());

   cout << "Using operator[] for displaying your input: " << endl;
   for (int index = 0; index < youSaid.GetLength(); ++index)
      cout << youSaid[index] << " ";
   cout << endl;

   cout << "Enter index 0 - " << youSaid.GetLength() - 1 << ": ";
   int index = 0;
   cin >> index;
   cout << "Input character at zero-based position: " << index;
   cout << " is: " << youSaid[index] << endl;

   return 0;
}

/**

Type a statement: Hey subscript operators[] are fabulous
Using operator[] for displaying your input:
H e y s u b s c r i p t o p e r a t o r s [ ] a r e f a b u l o u s
Enter index 0 - 37: 2
Input character at zero-based position: 2 is: y
*/

定义下标运算符时,如果不允许修改内部数组,则将返回的引用定义为const类型。将函数类型定义为const类型,禁止通过这个函数来修改其他的类成员属性。

如果需要通过这个函数去修改内部属性,则不定义成const。

函数运算符 operator()

operator() 让对象像函数,被称为函数运算符。函数运算符用于标准模板库(STL)中,通常是 STL算法中,其用途包括决策。根据使用的操作数数量,这样的函数对象通常称为单目谓词或双目谓词。

#include <iostream>
#include <string>
using namespace std;

class Display
{
public:
   void operator () (string input) const
   {
      cout << input << endl;
   }
};

int main ()
{
   Display displayFuncObj;

   // equivalent to displayFuncObj.operator () ("Display this string!");
   displayFuncObj ("Display this string!"); 

   return 0;
}

这个运算符也称为 operator()函数,而 Display 对象也称为函数对象或 functor。

移动构造函数

从上述代码可知,相比于常规赋值构造函数和复制赋值运算符的声明,移动构造函数和移动赋值运算符的不同之处在于,输入参数的类型为 Sample&&。另外,由于输入参数是要移动的源对象,因此不能使用 const 进行限定,因为它将被修改。返回类型没有变,因为它们分别是构造函数和赋值运算符的重载版本。 在需要创建临时右值时,遵循 C++的编译器将使用移动构造函数(而不是复制构造函数)和移动赋值运算符(而不是复制赋值运算符)。

移动构造函数和移动赋值运算符的实现中,只是将资源从源移到目的地,而没有进行复制。

在执行下面的拼接加赋值sayHelloAgain = Hello + World + CPP这个过程中,首先调用Hello的+运算符函数,将World的内容加进来,创造一个临时对象(Hello World),同理再把cpp加进来,创造另一个临时对象(Hello World of C++)。然后编译器会调用sayHelloAgain的移动构造函数,将这个临时对象的内容赋给sayHelloAgain,然后删除临时对象的buffer。这个过程只使用了一次移动构造函数。

完整示例:

#include <iostream>
#include <string.h>
using namespace std;

class MyString
{
private:
   char* buffer;

   MyString(): buffer(NULL) // private default constructor
   {
      cout << "Default constructor called" << endl;
   }

public:
   MyString(const char* initialInput) // constructor
   {
      cout << "Constructor called for: " << initialInput << endl;
      if(initialInput != NULL)
      {
         buffer = new char [strlen(initialInput) + 1];
         strcpy(buffer, initialInput);
      }
      else
         buffer = NULL;
   }

   MyString(MyString&& moveSrc) // move constructor
   {
      cout << "Move constructor moves: " << moveSrc.buffer << endl;
      if(moveSrc.buffer != NULL)
      {
         buffer = moveSrc.buffer; // take ownership i.e.  'move'
         moveSrc.buffer = NULL;   // free move source
      }
    }

   MyString& operator= (MyString&& moveSrc) // move assignment op.
   {
      cout << "Move assignment op. moves: " << moveSrc.buffer << endl;
      if((moveSrc.buffer != NULL) && (this != &moveSrc))
      {
         delete[] buffer; // release own buffer

         buffer = moveSrc.buffer; // take ownership i.e.  'move'
         moveSrc.buffer = NULL;   // free move source
      }

      return *this;
   }

   MyString(const MyString& copySrc) // copy constructor
   {
      cout << "Copy constructor copies: " << copySrc.buffer << endl;
      if (copySrc.buffer != NULL)
      {
         buffer = new char[strlen(copySrc.buffer) + 1];
         strcpy(buffer, copySrc.buffer);
      }
      else
         buffer = NULL;
   }

   MyString& operator= (const MyString& copySrc) // Copy assignment op.
   {
      cout << "Copy assignment op. copies: " << copySrc.buffer << endl;
      if ((this != &copySrc) && (copySrc.buffer != NULL))
      {
         if (buffer != NULL)
            delete[] buffer;

         buffer = new char[strlen(copySrc.buffer) + 1];
         strcpy(buffer, copySrc.buffer);
      }

      return *this;
   }

   ~MyString() // destructor
   {
      if (buffer != NULL)
         delete[] buffer;
   }

   int GetLength()
   {
      return strlen(buffer);
   }

   operator const char*()
   {
      return buffer;
   }
   
   MyString operator+ (const MyString& addThis)
   {
      cout << "operator+ called: " << endl;
      MyString newStr;

      if (addThis.buffer != NULL)
      {
         newStr.buffer = new char[GetLength()+strlen(addThis.buffer)+1];
         strcpy(newStr.buffer, buffer);
         strcat(newStr.buffer, addThis.buffer);
      }

      return newStr;
   }
};

int main()
{
   MyString Hello("Hello ");
   MyString World("World");
   MyString CPP(" of C++");

   MyString sayHelloAgain ("overwrite this");
   sayHelloAgain = Hello + World + CPP;

   return 0;
}

/*
Without move constructor and move assignment operator:
Constructor called for: Hello
Constructor called for: World
Constructor called for:  of C++
Constructor called for: overwrite this
operator+ called:
Default constructor called
Copy constructor to copy from: Hello World
operator+ called:
Default constructor called
Copy constructor to copy from: Hello World of C++
Copy assignment operator to copy from: Hello World of C++

With move constructor and move assignment operators:
Constructor called for: Hello
Constructor called for: World
Constructor called for:  of C++
Constructor called for: overwrite this
operator+ called:
Default constructor called
Move constructor to move from: Hello World
operator+ called:
Default constructor called
Move constructor to move from: Hello World of C++
Move assignment operator to move from: Hello World of C++
*/

学习过程中复制构造函数的一个问题

当使用临时对象当作函数参数进行值传递时,将不会走复制构造函数,而是直接使用这个对象。

自定义字面量

涉及热力学的温度声明,采用如下方式:

Temperature k1 = 32.15_F;
Temperature k2 = 0.0_C; ReturnType operator "" YourLiteral(ValueType value)
{
 // conversion code here
} 

实例:

#include <iostream>
using namespace std;

struct Temperature
{
   double Kelvin;
   Temperature(long double kelvin) : Kelvin(kelvin) {}
};

Temperature operator"" _C(long double celcius)
{
   return Temperature(celcius + 273);
}

Temperature operator "" _F(long double fahrenheit)
{
   return Temperature((fahrenheit + 459.67) * 5 / 9);
}

int main()
{
   Temperature k1 = 31.73_F;
   Temperature k2 = 0.0_C;

   cout << "k1 is " << k1.Kelvin << " Kelvin" << endl;
   cout << "k2 is " << k2.Kelvin << " Kelvin" << endl;

   return 0;
}

/**
k1 is 273 Kelvin
k2 is 273 Kelvin 
*/

static_cast类型转换

static_cast 用于在相关类型的指针之间进行转换,还可显式地执行标准数据类型的类型转换—这种转换原本将自动或隐式地进行。

将 Derived转换为 Base被称为向上转换,无需使用任何显式类型转换运算符就能进行这种转换:

Derived objDerived;
Base* objBase = &objDerived; // ok!

将 Base转换为 Derived被称为向下转换,如果不使用显式类型转换运算符,就无法进行这种转换:

Derived objDerived;
Base* objBase = &objDerived; // Upcast -> ok!
Derived* objDer = objBase; // Error: Downcast needs explicit cast

可以利用static_cast进行向下转换,而不会报错。

Base* objBase = new Base();
Derived* objDer = static_cast<Derived*>(objBase); // Still no errors!

然而,static_cast 只验证指针类型是否相关,而不会执行任何运行阶段检查。

因此 objDer ->DerivedFunction() 能够通过编译,但在运行阶段可能导致意外结果。

dynamic_cast隐式转换

给定一个指向基类对象的指针,程序员可使用 dynamic_cast 进行类型转换,并在使用指针前检查指针指向的目标对象的类型。

Base* objBase = new Derived();
// Perform a downcast
Derived* objDer = dynamic_cast<Derived*>(objBase);
if(objDer) // Check for success of the cast
objDer->CallDerivedFunction (); 

这种在运行阶段识别对象类型的机制称为

运行阶段类型识别(runtime type identification,RTTI)。

#include <iostream>
using namespace std; 
  
class Fish
{
public:
   virtual void Swim()
   {
      cout << "Fish swims in water" << endl;
   }

   // base class should always have virtual destructor
   virtual ~Fish() {}   
};

class Tuna: public Fish
{
public:
   void Swim()
   {
      cout << "Tuna swims real fast in the sea" << endl;
   }

   void BecomeDinner()
   {
      cout << "Tuna became dinner in Sushi" << endl;
   }
};

class Carp: public Fish
{
public:
   void Swim()
   {
      cout << "Carp swims real slow in the lake" << endl;
   }

   void Talk()
   {
      cout << "Carp talked carp!" << endl;
   }
};

void DetectFishType(Fish* objFish)
{
   Tuna* objTuna = dynamic_cast <Tuna*>(objFish);
   if (objTuna)
   {
      cout << "Detected Tuna. Making Tuna dinner: " << endl;
      objTuna->BecomeDinner();   // calling Tuna::BecomeDinner
   }

   Carp* objCarp = dynamic_cast <Carp*>(objFish);
   if(objCarp)
   {
      cout << "Detected Carp. Making carp talk: " << endl;
      objCarp->Talk();  // calling Carp::Talk
   }

   cout << "Verifying type using virtual Fish::Swim: " << endl;
   objFish->Swim(); // calling virtual function Swim
}

int main()
{
   Carp myLunch;
   Tuna myDinner;

   DetectFishType(&myDinner);
   DetectFishType(&myLunch);

   return 0;
}

务必检查 dynamic_cast 的返回值,看它是否有效。如果返回值为 NULL,说明转换失败。

reinterpret_cast强制转换

可以做任何类型转换。

这种类型转换实际上是强制编译器接受 static_cast 通常不允许的类型转换,通常用于低级程序(如驱动程序),在这种程序中,需要将数据转换为 API(应用程序编程接口)能够接受的简单类型(例如,有些 OS 级 API 要求提供的数据为 BYTE 数组,即 unsigned char*)。

由于其他 C++类型转换运算符都不允许执行这种有悖类型安全的转换,因此除非万不得已,否则不要使用 reinterpret_cast 来执行不安全(不可移植)的转换。

const_cast将const类型转换成非const

某些情况,使用的类我们无法修改,其内部如果使用了不合理的非const函数,外部的const对象指针无法使用非const函数,就可以将外部的const对象转换成非const,实现调用。

void DisplayAllData (const SomeClass* data)
{
 // data->DisplayMembers(); Error: attempt to invoke a non-const function!
 SomeClass* pCastedData = const_cast<SomeClass*>(data);
 pCastedData->DisplayMembers(); // Allowed!
}

使用类型转换需要注意

在现代 C++中,除 dynamic_cast 外的类型转换都是可以避免的。仅当需要满足遗留应用程序的需求时,才需要使用其他类型转换运算符。在这种情况下,程序员通常倾向于使用 C 风格类型转换而不是C++类型转换运算符。重要的是,应尽量避免使用类型转换;而一旦使用类型转换,务必要知道幕后发生的情况。

预处理

顾名思义,预处理器在编译器之前运行,换句话说,预处理器根据程序员的指示,决定实际要编译的内容。预处理器编译指令都以#打头。

宏定义常量

#include <iostream>
#include<string>
using namespace std;

#define ARRAY_LENGTH 25
#define PI 3.1416
#define MY_DOUBLE double
#define FAV_WHISKY "Jack Daniels"

/*
// Superior alternatives (comment those above when you uncomment these)
const int ARRAY_LENGTH = 25;
const double PI = 3.1416;
typedef double MY_DOUBLE;
const char* FAV_WHISKY = "Jack Daniels";
*/

int main()
{
   int MyNumbers [ARRAY_LENGTH] = {0};
   cout << "Array's length: " << sizeof(MyNumbers) / sizeof(int) << endl;

   cout << "Enter a radius: ";
   MY_DOUBLE Radius = 0;
   cin >> Radius;
   cout << "Area is: " << PI * Radius * Radius << endl;

   string FavoriteWhisky (FAV_WHISKY);
   cout << "My favorite drink is: " << FAV_WHISKY << endl;

   return 0;
}

定义常量时,更好的选择是使用关键字 const 和数据类型,因此下面的定义好得多:

const int ARRAY_LENGTH = 25;
const double PI = 3.1416;
const char* FAV_WHISKY = "Jack Daniels";
typedef double MY_DOUBLE; // typedef aliases a type

最常用的宏功能

如果在头文件 class1.h 中声明了一个类,而这个类将 class2.h 中声明的类作为其成员,则需要在 class1.h 中包含 class2.h。如果设计非常复杂,即第二个类需要第一个类,则在 class2.h 中也需要包含 class1.h!然而,在预处理器看来,两个头文件彼此包含对方会导致递归问题。

为了防止循环引用,可以使用#ifndef 强制这种引用只执行一次:

#ifndef HEADER1_H _//multiple inclusion guard:
#define HEADER1_H_ // preprocessor will read this and following lines once
#include <header2.h>
class Class1{ 
// class members
};
#endif // end of header1.h

header2.h 与此类似,但宏定义不同,且包含的是<header1.h>:

#ifndef HEADER2_H_  //multiple inclusion guard
#define HEADER2_H_
#include<header1.h>
class Class2{ 
// class members
};
#endif // end of header2.h 

#ifndef 可读作 if-not-defined。这是一个条件处理命令,让预处理器仅在标识符未定义时才继续。#endif 告诉预处理器,条件处理指令到此结束。

因此,预处理器首次处理 header1.h 并遇到#ifndef 后,发现宏 HEADER1H_还未定义,因此继续处理。#ifndef 后面的第一行定义了宏 HEADER1_H,确保预处理器再次处理该文件时,将在遇到包含#ifndef 的第一行时结束,因为其中的条件为 false.

#define定义宏函数

例如平方计算,#define SQUARE(x) ((x) * (x)) 相比于常规函数调用,宏函数的优点在于,它们将在编译前就地展开,因此在有些情况下有助于改善代码的性能。而且一个宏可使用另一个宏。比如计算面积的宏函数可以使用定义的宏变量PI。 有个缺点是宏函数不考虑数据类型,返回值精度依赖输入的精度。

为什么要加这么多括号?

因为宏是最简单的替换,不会提前计算。如果去掉括号: #define AREA_CIRCLE(r) (PIrr) 如果使用类似于下面的语句调用这个宏,结果将如何呢? cout « AREA_CIRCLE (4+6); 展开后,编译器看到的语句如下: cout « (PI4+64+6); // not the same as PI1010 根据运算符优先级,将先执行乘法运算,再执行加法运算,因此编译器将这样计算面积: cout « (PI*4+24+6); // 42.5664 (which is incorrect) 在省略了括号的情况下,简单的文本替换破坏了编程逻辑!

assert宏

可以插入到某些地方来验证执行结果。需要提前包含<assert.h>

assert (expression that evaluates to true or false); 

如果条件不满足,它将抛出一个错误信息。

由于断言在发布模式下不可用,对于对应用程序正确运行至关重要的检查(如检查dynamic_cast 的返回值),为了确保它们在发布模式下也会执行,应使用 if 语句,这很重要。断言可帮助您找出问题,但不能因此不在代码中对指针做必要的检查。

使用宏的应该和不应该事项

  1. 尽可能不要自己编写宏函数。
  2. 尽可能使用 const 变量,而不是宏常量。
  3. 请牢记,宏并非类型安全的,预处理器不执行类型检查。
  4. 在宏函数的定义中,别忘了使用括号将每个变量括起。
  5. 为了在头文件中避免多次包含,别忘了使用#ifndef、#define 和#endif。
  6. 别忘了在代码中大量使用 assert( ),它们在发行版本中将被禁用,但对提高代码的质量很有帮助。

模板

模板让程序员能够定义一种适用于不同类型对象的行为。这听起来有点像宏(参见前面用于判断两个数中哪个更大的简单宏 MAX),但宏不是类型安全的,而模板是类型安全的。

编写一个比较大小的模板函数,它的接收的类型可以根据传参的类型自动生成多个重载函数。

#include<iostream>
#include<string>
using namespace std;

template <typename Type>
const Type& GetMax (const Type& value1, const Type& value2)
{
    if (value1 > value2)
        return value1;
    else
        return value2;
}

template <typename Type>
void DisplayComparison(const Type& value1, const Type& value2)
{
   cout << "GetMax(" << value1 << ", " << value2 << ") = ";
   cout << GetMax(value1, value2) << endl;
}

int main()
{
   int num1 = -101, num2 = 2011;
   DisplayComparison<int>(num1, num2);

   double d1 = 3.14, d2 = 3.1416;
   DisplayComparison(d1, d2);

   string name1("Jack"), name2("John");
   DisplayComparison(name1, name2);

   return 0;
}

上述代码将导致编译器生成模板函数 GetMax 的两个版本。 如果进行调用的参数不一致,比如传一个int和srting类型一起比较大小,将导致编译错误。

模板类

类是设计对象的蓝图,而模板类就是蓝图的蓝图。

可以在类里面设置模板参数,让同一个类的同一个字段使用不同的类型来表示。

template <typename T1, typename T2>
class HoldsPair
{
private:
 T1 value1;
 T2 value2;
public:
 // Constructor that initializes member variables
 HoldsPair (const T1& val1, const T2& val2)
 {
 value1 = val1;
 value2 = val2;
 };
 // ... Other member functions
};

// 在这里,类 HoldsPair 接受两个模板参数,参数名分别为 T1 和 T2。可使用这个类来存储两个类型
// 相同或不同的对象,如下所示:
// A template instantiation that pairs an int with a double
HoldsPair <int, double> pairIntDouble (6, 1.99);
// A template instantiation that pairs an int with an int
HoldsPair <int, int> pairIntDouble (6, 500); 

还可以设置默认类型简化使用,如果实例使用的和默认类型相同,则可以简化对象的声明方式。

// template with default params: int & double
template <typename T1=int, typename T2=double>
class HoldsPair
{
private:
   T1 value1;
   T2 value2;
public:
   HoldsPair(const T1& val1, const T2& val2) // constructor
      : value1(val1), value2(val2) {}
 
   // Accessor functions
   const T1 & GetFirstValue () const 
   {
      return value1;
   }

   const T2& GetSecondValue () const
   {
      return value2;
   }
};
   
#include <iostream>
using namespace std;

int main ()
{
   HoldsPair <> pairIntDbl (300, 10.09);
   HoldsPair <short, const char*> pairShortStr(25, "Learn templates, love C++");

   cout << "The first object contains -" << endl;
   cout << "Value 1: " << pairIntDbl.GetFirstValue () <<  endl;
   cout << "Value 2: " << pairIntDbl.GetSecondValue () << endl;

   cout << "The second object contains -" << endl; 
   cout << "Value 1: " << pairShortStr.GetFirstValue () <<  endl;
   cout << "Value 2: " << pairShortStr.GetSecondValue () << endl;

   return 0;
}

模板实例化和具体化

定义但不使用的模板,编译器将忽略。因此,对模板来说,实例化指的是使用一个或多个模板参数来创建特定的类型。

HoldsPair<int, double> pairIntDbl;

相当于编译器使用这个模板创建了一个类。

另一方面,在有些情况下,使用特定的类型实例化模板时,需要显式地指定不同的行为。这就是具体化模板,即为特定的类型指定行为。

#include <iostream>
using namespace std;

template <typename T1 = int, typename T2 = double>
class HoldsPair
{
private:
   T1 value1;
   T2 value2;
public:
   HoldsPair(const T1& val1, const T2& val2) // constructor
      : value1(val1), value2(val2) {}

   // Accessor functions
   const T1 & GetFirstValue() const;
   const T2& GetSecondValue() const;
};

// specialization of HoldsPair for types int & int here
template<> class HoldsPair<int, int>
{
private:
   int value1;
   int value2;
   string strFun;
public:
   HoldsPair(const int& val1, const int& val2) // constructor
      : value1(val1), value2(val2) {}

   const int & GetFirstValue() const
   {
      cout << "Returning integer " << value1 << endl;
      return value1;
   }
};
   
int main()
{
   HoldsPair<int, int> pairIntInt(222, 333);
   pairIntInt.GetFirstValue();

   return 0;
}

带静态变量的模板类

#include <iostream>
using namespace std;

template <typename T>
class TestStatic
{
public:
   static int staticVal;
};
   
// static member initialization
template<typename T> int TestStatic<T>::staticVal;

int main()
{
   TestStatic<int> intInstance;
   cout << "Setting staticVal for intInstance to 2011" << endl;
   intInstance.staticVal = 2011;

   TestStatic<double> dblnstance;
   cout << "Setting staticVal for Double_2 to 1011" << endl;
   dblnstance.staticVal = 1011;

   cout << "intInstance.staticVal = " << intInstance.staticVal << endl;
   cout << "dblnstance.staticVal = " << dblnstance.staticVal << endl;

   return 0;
}

/**
Setting staticVal for intInstance to 2011
Setting staticVal for Double_2 to 1011
intInstance.staticVal = 2011
dblnstance.staticVal = 1011
*/

也就是说,如果模板类包含静态成员,该成员将在针对 int 具体化的所有实例之间共享;同样,它还将在针对 double 具体化的所有实例之间共享,且与针对 int 具体化的实例无关。换句话说,可以认为编译器创建了两个版本的 x:x_int 用于针对 int 具体化的实例,而 x_double 针对 double 具体化的实例。

参数数量可变的模板函数

参数数量可变的模板是 2014 年发布的 C++14 新增的。

#include <iostream>
using namespace std;

template <typename Res, typename ValType>
void Sum(Res& result, ValType& val)
{
   result = result + val;
}

template <typename Res, typename First, typename... Rest> 
void Sum(Res& result, First val1, Rest... numN)
{
   result = result + val1;
   return Sum(result, numN ...);
}

int main()
{
   double dResult = 0;
   Sum (dResult, 3.14, 4.56, 1.1111);
   cout << "dResult = " << dResult << endl;

   string strResult;
   Sum (strResult, "Hello ", "World");
   cout << "strResult = " << strResult.c_str() << endl;

   return 0;
}

您可能注意到了,在前面的代码示例中,使用了省略号…。在 C++中,模板中的省略号告诉编译器,默认类或模板函数可接受任意数量的模板参数,且这些参数可为任何类型。

元组

通过索引访问的一种数据结构,内部可以存储多种不同的数据类型。

#include <iostream>
#include <tuple>
#include <string>
using namespace std;

template <typename tupleType>
void DisplayTupleInfo(tupleType& tup)
{
   const int numMembers = tuple_size<tupleType>::value;
   cout << "Num elements in tuple: " << numMembers << endl;
   cout << "Last element value: " << get<numMembers - 1>(tup) << endl;
}

int main()
{
   tuple<int, char, string> tup1(make_tuple(101, 's', "Hello Tuple!"));
   DisplayTupleInfo(tup1);

   auto tup2(make_tuple(3.14, false));
   DisplayTupleInfo(tup2);

   auto concatTup(tuple_cat(tup2, tup1)); // contains tup2, tup1 members
   DisplayTupleInfo(concatTup);

   double pi;
   string sentence;
   tie(pi, ignore, ignore, ignore, sentence) = concatTup;
   cout << "Unpacked! Pi: " << pi << " and \"" << sentence << "\"" << endl;

    return 0;
}

static_assert不满足条件直接禁止编译

static_assert 是 C++11 新增的一项功能,让您能够在不满足指定条件时禁止编译。这好像不可思议,但对模板类来说很有用。例如,您可能想禁止针对 int 实例化模板类,为此可使用 static_assert,它是一种编译阶段断言,可用于在开发环境(或控制台中)显示一条自定义消息。

template <typename T>
class EverythingButInt
{
public:
   EverythingButInt()
   {
      static_assert(sizeof(T) != sizeof(int), "No int please!");
   }
};

int main()
{
   EverythingButInt<int> test;

   return 0;
}

待补充STL标准库

【跨平台】IOS端应用开发扫盲

【跨平台】IOS端应用开发扫盲

本文从初学者角度出发,介绍IOS端应用开发的一些基础知识。

最新在KMP的开发过程中,对于一些IOS端的全局性的UI样式代码修改,有些无从下手。Swift语言层面上还比较容易看懂,但是对于系统规则机制,app运行机制等了解尚浅,对此做一个基础的总结。

开发语言层面对比

1. 语言特性对比

特性SwiftJava异同点与优势
类型系统强类型,类型推断强类型,类型推断有限Swift 的类型推断更强大,很多时候无需显式声明变量类型,代码更简洁。
内存管理自动引用计数 (ARC)垃圾回收 (GC)这是两者最核心的区别。Swift 的 ARC 性能更高,但需要注意循环引用;Java 的 GC 开发者更省心,但可能带来运行时卡顿。
可选类型Optional 类型NullSwift 强制处理 nil,开发者必须显式地用 if letguard let 解包,从而从语言层面杜绝了空指针异常。Java 的 NullPointerException 是一个常见痛点。
编程范式面向对象、函数式纯面向对象Swift 融合了面向对象、函数式和协议导向编程(Protocol-Oriented Programming, POP)思想,代码更灵活,尤其在泛型和协议方面。
结构体支持结构体 (Struct)不支持,只有类Swift 的结构体是值类型,类是引用类型。这提供了更多的灵活性和性能优化空间,例如在处理轻量级数据时使用结构体可以避免不必要的内存分配和引用计数开销。
函数式支持高阶函数、闭包支持 lambda 表达式Swift 的闭包(Closures)功能强大且易用,是其函数式编程特性的重要体现。
多线程GCD、Operation QueueThread, Executor, Coroutines两者都提供了完善的多线程解决方案,但具体实现方式不同。Swift 的 GCD (Grand Central Dispatch) 是一个非常强大的基于任务队列的并发模型。

2. 运行环境对比

环境SwiftJava异同点与优势
运行时原生 (Native) 运行时虚拟机 (JVM / ART)Swift 代码直接编译成机器码在 CPU 上执行,没有虚拟机的性能开销,启动更快,执行效率更高。
编译过程LLVM 编译器Java 编译器(Javac)Swift 的 LLVM 编译器非常先进,能够生成高度优化的机器码。
跨平台主要用于 Apple 生态跨平台能力强大Swift 主要用于 iOS、macOS、watchOS 等苹果平台,虽然有开源项目尝试跨平台,但生态和工具链远不如 Java。Java 的 JVM 可以运行在 Windows、Linux、Android 等多个操作系统上,“一次编写,到处运行”
语言版本频繁更新稳定,但更新较慢Swift 语言发展迅速,版本更新频繁,新特性不断加入。Java 语言相对稳定,版本更新周期较长。

总的来说,Swift 直接编译成机器码,没有虚拟机开销,运行速度快。安全可靠,可选类型从语言层面消除了空指针异常,类型推断减少了编程错误。融合了多种编程范式,如函数式、协议导向等,语法简洁、富有表现力。

而 Java 得益于 JVM,Java 具有无可比拟的跨平台能力。其拥有庞大而成熟的社区和工具生态,有无数的框架和库可供选择。语言版本和 API 相对稳定,适合大型企业级应用开发。

代码编写到运行经历了哪些流程

1. 代码编写与编译阶段

在这个阶段,开发者用 Swift 或 Objective-C 语言编写代码。使用 Xcode 这个集成开发环境 (IDE),它包含了所有的工具链,如编译器、调试器等。

  • 编译器: Xcode 默认使用 LLVM (Low Level Virtual Machine) 编译器。
    • Swift 源码通过 Swift 编译器编译成 LLVM Intermediate Representation (IR),然后再编译成机器码。
    • Objective-C 源码则直接通过 Clang 编译器编译成机器码。
  • 编译优化: 编译器会对代码进行各种优化,例如 dead code elimination(移除无用代码)、常量折叠等,以提高应用的运行效率。
  • 产物: 编译的最终产物是可执行的机器码文件(Mach-O 文件),以及应用所需的其他资源文件(如图片、UI 布局文件等)。

2. 应用打包阶段

编译完成后,Xcode 会将所有必需的文件打包成一个可分发、可安装的格式。

  • Bundle 概念: iOS 应用的核心是一个 Bundle。它是一个特殊的文件夹,其目录结构是固定的。Bundle 内部包含了可执行文件、所有的资源文件(图片、声音、NIB/Storyboards 等)、以及一个重要的 Info.plist 文件。
  • Info.plist 文件: 这是一个属性列表文件,包含了应用的元数据,例如应用名称、版本号、支持的设备方向、所需的权限(如相机、位置服务)等等。操作系统和应用本身都会读取这个文件来获取关键信息。
  • IPA 文件: 最终,整个 Bundle 会被压缩成一个 .ipa 文件。.ipa 文件本质上是一个 ZIP 压缩包,.ipa 的作用就类似于 Android 的 .apk 文件。

3. 应用安装阶段

用户从 App Store 下载或通过其他方式获取到 .ipa 文件后,系统会进行安装。

  • 解压与签名验证: 系统首先解压 .ipa 文件,然后进行严格的数字签名验证。每一个在 App Store 上发布的 iOS 应用都必须由 Apple 签发证书进行签名。
    • 目的: 签名验证的目的是确保应用没有被篡改,且来自可信的开发者。这是 iOS 安全机制的重要一环。
  • 权限配置: 系统会根据 Info.plist 文件中声明的权限,为应用配置对应的沙盒环境。这决定了应用可以访问哪些系统资源和数据。
  • 目录结构: 应用的 Bundle 会被安装到 /private/var/containers/Bundle/Application 目录下。同时,系统还会为应用创建数据目录,包括 Documents、Library 和 tmp,这些目录位于 /private/var/mobile/Containers/Data/Application 下,用于应用存储数据。

4. 应用运行阶段

当用户点击应用图标时,应用开始启动和运行。

  • main() 函数: 所有 iOS 应用都从一个 main() 函数开始执行,这与 C/C++ 程序的入口点相同。
  • UIApplication: main() 函数会调用 UIApplicationMain 函数来创建一个 UIApplication 对象。UIApplication 是 iOS 应用的单例,负责管理应用的生命周期、事件循环和与系统之间的交互。
  • AppDelegate: UIApplication 会将应用的生命周期事件(如应用启动、进入后台、收到内存警告等)通知给 AppDelegate 对象。AppDelegate 是应用的代理,开发者可以在其中实现相应的回调方法,来处理这些系统事件。
  • 主线程: UI 更新、事件处理等所有与界面相关的操作都必须在 主线程 上执行。这与 Android 上的 UI 线程(Main thread)是相同的概念,都是为了避免并发问题,保证用户界面的流畅性。
  • 沙盒机制: 应用在运行过程中,其读写操作都严格限制在其沙盒目录内,无法访问沙盒外的其他应用数据,从而保证了系统安全和数据隔离。

APP运行环境

Android 应用运行在 ART(Android Runtime) 虚拟机上,这是一个基于 JIT(Just-In-Time)和 AOT(Ahead-Of-Time)编译的运行时环境,负责执行 Java/Kotlin 代码。它提供了垃圾回收、内存管理和沙箱隔离,确保每个应用都在一个独立、受保护的环境中运行。

iOS 应用则直接运行在 原生(Native) 环境下,执行由 Objective-CSwift 编写的代码。这些代码直接编译成机器码,由苹果的 Cocoa Touch 框架和 XNU 内核直接执行。

  • 没有虚拟机:iOS 不使用虚拟机,这使得其应用的启动速度和执行效率通常更高。
  • 内存管理:iOS 主要通过 ARC(Automatic Reference Counting) 机制来自动管理内存。当一个对象的引用计数变为零时,系统会自动回收它。这与 ART 的垃圾回收机制(Garbage Collection)不同,但目标都是为了简化内存管理。
  • 沙盒机制:和 Android 一样,iOS 也有严格的沙盒机制。每个应用都在一个独立的沙盒中运行,不能随意访问其他应用的数据。

内存管理

Android 和 iOS 在内存管理上采用了两种截然不同的策略,这直接影响了开发者编写代码的方式和对性能的考量。简单来说,Android 使用了垃圾回收 (Garbage Collection, GC),而 iOS 则依赖于自动引用计数 (Automatic Reference Counting, ARC)

1. Android 的内存管理:垃圾回收(GC)

安卓的垃圾回收是一种自动的内存管理机制。

  • 工作原理:
    • 在 Android 中,当一个对象不再被任何变量引用时,它就成了“垃圾”。
    • GC 线程会定期扫描内存中的所有对象。当它发现一个不再被引用的对象时,就会将其标记为可回收,并在合适的时机释放这块内存。
    • 开发者无需手动释放内存。
  • GC 的优缺点:
    • 优点: 开发者不需要关心何时释放内存,这大大降低了内存管理的复杂性,可以更专注于业务逻辑。
    • 缺点:
      • 不可控性: GC 的执行时机是不确定的。当 GC 运行时,它会暂停应用的主线程(这被称为 “Stop-The-World”),这可能导致应用的卡顿,尤其是在处理大量对象时。
      • 内存开销: GC 需要额外的内存来追踪和管理对象,这可能导致比 ARC 略高的内存占用。
  • 内存泄漏:
    • 尽管有 GC,但 Android 仍然会发生内存泄漏。最常见的情况是长生命周期对象引用了短生命周期对象
    • 例如,一个 Activity 对象被一个全局的单例对象引用,当 Activity 应该被销毁时,由于单例对象依然持有它的引用,GC 无法回收它,从而导致内存泄漏。开发者需要特别注意这种情况。

2. iOS 的内存管理:自动引用计数(ARC)

ARC 是一个编译器级别的内存管理机制。

  • 工作原理:
    • 每个对象都有一个引用计数器。当一个对象被创建时,其引用计数为 1。
    • 当一个变量引用了这个对象时,它的引用计数会加 1(称为 retain);当引用被移除时,引用计数会减 1(称为 release)。
    • 当引用计数减到 0 时,说明没有任何变量在使用这个对象,ARC 会立即释放这块内存。
    • 这个过程都是由编译器在编译时自动插入 retainrelease 代码来实现的,开发者无需手动调用。
  • ARC 的优缺点:
    • 优点:
      • 性能高: ARC 的内存管理在编译时完成,没有运行时暂停(Stop-The-World)的开销,因此性能更高,更适合对响应速度要求高的移动应用。
      • 可预测性: 内存释放的时机是确定的,一旦引用计数归零,对象就会立即被释放。
    • 缺点:
      • 循环引用(Retain Cycle): 这是 ARC 最主要的问题。当两个或多个对象相互持有对方的强引用时,它们的引用计数永远不会归零,导致内存无法被释放,造成内存泄漏。
  • 如何解决循环引用:
    • 开发者需要使用 弱引用(weak无主引用(unowned 来打破循环引用。
    • weak: 不会增加对象的引用计数。当引用的对象被释放后,weak 引用会自动置为 nil。常用于父子关系中,子对象对父对象的引用。
    • unowned: 类似于 weak,也不会增加引用计数,但它假定所引用的对象在它自身生命周期内永远不会被释放。如果引用的对象被释放了,访问 unowned 引用会引发运行时错误。常用于明确知道被引用对象生命周期比自身长的场景。

总的来说,Android 的 GC 让开发者更省心,但代价是可能牺牲部分性能。iOS 的 ARC 提供了更精细的控制和更高的性能,但要求开发者对引用关系有清晰的认识,并主动解决循环引用问题。理解这两种机制的优劣,是成为一名优秀的跨平台开发者必备的知识。

界面管理

Android 的 Activity 主要负责管理一个单独的用户界面(UI),并处理用户交互。它的生命周期(如 onCreateonPauseonDestroy)是开发者需要重点关注的。

iOS 中对应的概念是 UIViewController

  • UIViewController 是 iOS 应用界面的核心。每一个屏幕(或一个屏幕上的一个重要部分)都由一个 UIViewController 来管理。它负责管理视图(View),处理事件,并在用户界面和数据模型之间扮演桥梁角色。
  • 生命周期UIViewController 同样拥有自己的生命周期方法,例如 viewDidLoad(视图加载完成)、viewWillAppear(视图即将显示)和 viewWillDisappear(视图即将消失)。开发者需要在这几个方法中处理界面的初始化和状态保存。
  • 页面跳转:Android 通常通过 Intent 来启动新的 Activity。在 iOS 中,页面跳转通常通过 UINavigationController(用于堆栈式的页面管理)或 present(_:animated:completion:) 方法(用于模态弹出页面)来实现。

后台任务

Android 的 Service 主要用于在后台执行长时间运行的操作,且没有用户界面,比如下载文件或播放音乐。Service 可以在 Activity 被销毁后继续运行。

iOS 并没有一个与 Service 完全对应的组件,它的后台任务管理更加严格和精细化,主要通过以下几种机制实现:

  • 短暂的后台任务(Background Task):当应用从前台切换到后台时,系统会给它几秒钟的时间(通常是 3 到 10 分钟)来完成一些任务,例如保存数据或结束网络请求。
  • 后台模式(Background Modes):对于需要持续在后台运行的应用,如音乐播放器、位置追踪或 VoIP 电话,开发者需要在 Info.plist 文件中声明特定的“后台模式”。只有被系统明确允许的这些服务(如音乐播放、地理位置更新等)才能在后台持续运行。
  • 后台刷新(Background App Refresh):允许应用定期在后台刷新内容,但刷新时机由系统根据设备电量、网络状态等因素智能决定。
  • 静默推送(Silent Push Notifications):服务器可以向应用发送一种特殊的“静默推送”,唤醒应用在后台执行一小段代码(例如拉取新内容)。

【跨平台】Compose Desktop进行JNI开发

【跨平台】Compose Desktop进行JNI开发

本文介绍了使用Compose Multiplatform开发时,如何使用JNI接口

在 Kotlin Multiplatform (KMP) 项目中,要在桌面端(通常指 JVM 桌面应用,如 macOS, Windows, Linux)进行 JNI (Java Native Interface) 开发,核心思路是利用 KMP 的 expect/actual 机制,为桌面 JVM 平台提供 JNI 的实际实现。

JNI 简介

JNI 允许 Java 代码(或运行在 JVM 上的 Kotlin 代码)调用原生应用程序(用 C/C++ 等语言编写)或库,反之亦然。在 KMP 桌面端,这通常用于:

  • 集成现有 C/C++ 库: 如果你有成熟的原生库,JNI 是将其集成到 Kotlin 桌面应用的桥梁。
  • 访问平台特定功能: 某些操作系统级别的功能可能没有 JVM 或 Kotlin 友好的 API,此时可以通过 JNI 调用原生 API。
  • 性能敏感部分: 对于某些计算密集型任务,原生代码可能提供更好的性能。

KMP 桌面端 JNI 开发步骤

以下是使用 Kotlin Multiplatform 在桌面端进行 JNI 开发的详细步骤:

1. 设置 KMP 项目结构

确保你的 KMP 项目有一个 JVM 桌面模块。通常,你的 build.gradle.kts 文件会包含类似这样的配置:

// shared/build.gradle.kts
kotlin {
    jvm() // 这是针对桌面 JVM 平台的 target
    // ... 其他平台,如 android()

    sourceSets {
        val commonMain by getting {
            // ... common code
        }
        val jvmMain by getting {
            dependencies {
                // JNI 相关的依赖,通常在原生库编译时用到
                // 但在 Kotlin 代码中直接与 JNI 交互不需要额外的 Kotlin/JVM 依赖
            }
        }
    }
}

2. 定义 JNI 接口(Common Main)

commonMainexpect 类或接口中,定义你希望原生代码提供的功能。这与你在 Android 中使用 expect 声明平台特定功能的方式相同。

// shared/src/commonMain/kotlin/com/example/shared/NativeLib.kt

package com.example.shared

// 期望提供一个获取字符串的本地方法
expect class NativeLib() {
    fun getStringFromNative(): String
}

3. 实现 JNI 接口(JVM Main)

jvmMain 中,提供 expect 接口的 actual 实现。这个 actual 类将负责加载原生库,并声明 external 函数来映射到原生 C/C++ 方法。

// shared/src/jvmMain/kotlin/com/example/shared/NativeLib.jvm.kt

package com.example.shared

actual class NativeLib {
    // 静态代码块用于加载 JNI 库
    companion object {
        init {
            // "native_lib" 是你的 C/C++ 库的名称,不带前缀和后缀(例如 libnative_lib.so, native_lib.dll, libnative_lib.dylib)
            System.loadLibrary("native_lib")
        }
    }

    // 声明一个 external 方法,它会映射到 C/C++ 中的 JNI 函数
    external fun getStringFromNative(): String
}

4. 生成 JNI 头文件(.h

编译 jvmMain 模块,它会生成 .class 文件。然后,你可以使用 javah 工具(JDK 自带)或 javac -h 命令来生成 JNI 头文件。这个头文件定义了你需要用 C/C++ 实现的 JNI 函数签名。

步骤:

  1. 编译 jvmMain 运行 Gradle 命令编译你的项目,例如 gradlew :shared:compileJvmMainKotlin。这会在 shared/build/classes/kotlin/jvm/main/com/example/shared/NativeLib.class 生成类文件。

  2. 生成头文件: 打开终端,导航到你的项目根目录,然后执行以下命令。

    • 对于 Java 8 及更早版本,使用 javah
      javah -jni -d src/main/c++ shared/build/classes/kotlin/jvm/main/com/example/shared/NativeLib
      

      这里 src/main/c++ 是你希望存放头文件的目录。

    • 对于 Java 9 及更高版本,使用 javac -h
      javac -h src/main/c++ shared/src/jvmMain/kotlin/com/example/shared/NativeLib.jvm.kt
      

      javac -h 是直接从 .kt 源文件(实际上是 Kotlin 编译后生成 JVM 字节码的能力)生成 JNI 头文件,更方便。

    生成的头文件 (com_example_shared_NativeLib.h) 会包含类似以下内容:

    /* DO NOT EDIT THIS FILE - it is machine generated */
    #include <jni.h>
    /* Header for class com_example_shared_NativeLib */
    
    #ifndef _Included_com_example_shared_NativeLib
    #define _Included_com_example_shared_NativeLib
    #ifdef __cplusplus
    extern "C" {
    #endif
    /*
     * Class:     com_example_shared_NativeLib
     * Method:    getStringFromNative
     * Signature: ()Ljava/lang/String;
     */
    JNIEXPORT jstring JNICALL Java_com_example_shared_NativeLib_getStringFromNative
      (JNIEnv *, jobject);
    
    #ifdef __cplusplus
    }
    #endif
    #endif
    

5. 编写原生 C/C++ 代码

现在,你可以根据生成的 .h 头文件,编写 C/C++ 源文件(例如 native_lib.cpp),实现 Java_com_example_shared_NativeLib_getStringFromNative 函数。

// shared/src/main/c++/native_lib.cpp

#include "com_example_shared_NativeLib.h" // 包含生成的 JNI 头文件
#include <iostream> // 仅作示例

JNIEXPORT jstring JNICALL Java_com_example_shared_NativeLib_getStringFromNative
  (JNIEnv *env, jobject obj) {
    // 可以在这里调用其他 C/C++ 库或执行复杂逻辑
    std::string nativeString = "Hello from Native C++ in KMP Desktop!";
    std::cout << "Native C++ code executed!" << std::endl; // 打印到控制台,调试用
    return env->NewStringUTF(nativeString.c_str());
}

6. 编译原生库

你需要一个构建系统来编译你的 C/C++ 代码,并生成共享库文件(.so for Linux, .dylib for macOS, .dll for Windows)。常用的工具是 CMake 或 Gradle 的 Native Build 插件。

使用 CMake (推荐)

  1. 创建 CMakeLists.txt 在你的 shared 模块下创建一个 src/main/c++/CMakeLists.txt 文件。

    # shared/src/main/c++/CMakeLists.txt
    cmake_minimum_required(VERSION 3.10)
    project(native_lib CXX)
    
    # 查找 JNI 头文件和库
    find_package(JNI REQUIRED)
    
    # 添加你的 C++ 源文件
    add_library(native_lib SHARED native_lib.cpp)
    
    # 链接 JNI 库
    target_link_libraries(native_lib PRIVATE ${JNI_LIBRARIES})
    
    # 设置输出目录,方便 Gradle 查找
    set_target_properties(native_lib PROPERTIES
        LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib" # 输出到 build/lib 目录下
    )
    
  2. 配置 Gradle 调用 CMake:shared/build.gradle.kts 中配置 cmake

    // shared/build.gradle.kts
    android { // 如果有 Android 平台,通常会在这里配置 externalNativeBuild
        // ...
        externalNativeBuild {
            cmake {
                path("src/main/c++/CMakeLists.txt") // CMakeLists.txt 的路径
            }
        }
    }
    
    // 或者为 JVM 目标单独配置 Native 构建(如果只有 JVM 桌面)
    // 通常在 desktop 或 jvm target 的 task 中调用 CMake
    tasks.register("buildNativeLib", Exec::class) {
        dependsOn("compileJvmMainKotlin") // 确保 NativeLib.class 已生成,方便 javac -h
        workingDir = file("src/main/c++") // CMakeLists.txt 所在目录
        commandLine("cmake", "-B", "build", "-DCMAKE_BUILD_TYPE=Release") // 配置 build 目录
        commandLine("cmake", "--build", "build") // 运行构建
    }
    
    // 确保在构建 JVM 应用时先构建原生库
    tasks.getByName("jvmMainClasses") {
        dependsOn("buildNativeLib")
    }
    
    // 或者更简洁地通过 Gradle 的 native 插件来管理,但更复杂
    // 或者直接在你的 desktop 模块的 run task 中手动复制库到 classpath
    

    注意: 对于桌面 KMP,最直接的方法是:

    • 手动运行 cmakecmake --build 命令来生成库。
    • 或者使用 Gradle 的 Exec 任务来自动化这个过程。
    • 然后将生成的 .so/.dylib/.dll 文件放置到 JVM 运行时能够找到的路径,例如:
      • 打包到 JAR 中(不太常见,因为需要特殊的 ClassLoader)
      • 放在 JVM 应用启动时 java.library.path 指定的目录。
      • 最简单的是,放在运行应用的目录的 libs 文件夹下,或者直接放在项目的 shared/src/jvmMain/resources 目录下,这样它会被打包进 JAR,但加载时可能仍需要 java.library.path。更推荐将原生库复制到最终可执行文件的同级目录。

7. 运行 KMP 桌面应用

在你的桌面应用模块(例如 desktop/src/main/kotlin/Main.kt)中,你可以像调用普通 Kotlin 函数一样调用你的 NativeLib

// desktop/src/jvmMain/kotlin/Main.kt (或其他桌面端入口文件)

import com.example.shared.NativeLib

fun main() {
    val nativeLib = NativeLib()
    val message = nativeLib.getStringFromNative()
    println("Message from native: $message")
}

运行注意事项:

  • 原生库路径: 当你运行桌面 JVM 应用程序时,JVM 需要能够找到你编译好的原生库文件。
    • 最常见且推荐的做法: 将生成的 .so, .dylib, .dll 文件放置在你的应用程序的可执行 JAR 文件所在的目录,或者一个名为 libs 的子目录中。
    • 通过 java.library.path 你可以在启动 JVM 时通过 -Djava.library.path=/path/to/your/native/libs 参数指定原生库的查找路径。
    • 在 Gradle 中打包: 某些 Gradle 插件可以帮助你将原生库打包到最终的可执行文件中(例如 jpackage 或一些 shadowJar 配置),但这会增加复杂性。

总结与建议

在 KMP 桌面端进行 JNI 开发是一个相对复杂的过程,因为它涉及到 Kotlin/JVM、C/C++ 和构建系统(Gradle, CMake)之间的协作。

关键点:

  1. expect/actual 这是 KMP 实现平台特定功能的基石。
  2. external 关键字: 告诉 Kotlin 编译器这个方法将由外部原生代码提供。
  3. System.loadLibrary()actual 实现中加载你的原生库。
  4. javah / javac -h 生成正确的 JNI 头文件,确保 C/C++ 函数签名正确。
  5. CMake 或其他原生构建系统: 用于编译你的 C/C++ 代码并生成共享库。
  6. 原生库部署: 确保 JVM 运行时能够找到你的 .so, .dylib, .dll 文件。

建议:

  • 只在必要时使用 JNI: JNI 会增加项目的复杂性(需要维护 C/C++ 代码,处理内存管理,平台兼容性等)。如果 Kotlin/JVM 本身可以完成任务,尽量避免使用 JNI。
  • 考虑 Kotlin/Native: 如果你的目标平台是完全原生的(例如,直接构建 macOS/Windows/Linux 可执行文件而不是 JVM 应用),Kotlin/Native 可能是更好的选择,它允许你直接调用 C 语言家族的库,而无需 JNI 的开销。但如果你需要利用 JVM 生态系统,JNI 是你的选择。
  • 逐步进行: 从一个简单的 JNI 调用开始,逐步添加更复杂的功能。
  • 查阅官方文档: JNI 和 Kotlin Multiplatform 的官方文档是最好的资源。

【跨平台】腾讯基于KMP实现的双鸿蒙方案介绍

【跨平台】腾讯基于KMP实现的双鸿蒙方案介绍

本文介绍了JVM平台上的try-catch机制实现方式及使用中的注意事项

在6月10号,腾讯开源了基于KMP实现的 ovCompose(online-video-compose) 开发框架,结合之前发布的的 Kuikly ,腾讯大前端团队 Oteam 已经开源了两种支持鸿蒙的一码多端跨平台方案。

原文:

Kuikly: 开源公告|Kuikly 跨平台开发框架

vcCompose: 重磅!支持纯血鸿蒙!腾讯视频ovCompose跨平台框架发布

在看过了他们的架构介绍之后,我对他们的实现原理进行了一个简单的总结。将其和Jetbrains的CMP对比又会有哪些异同呢?

之前的文章中,将CMP和Flutter、RN做了一个简单的横评:

Flutter & RN & CMP三种跨平台方案对比

Kuikly

触发编译

【跨平台】HarmonyOS鸿蒙系统应用层扫盲

【跨平台】HarmonyOS鸿蒙系统应用层扫盲

本文针对鸿蒙系统的基础特性,应用层开发所需基础知识,进行一个简单的调研,对比一下和其他平台的差异。

JS & TS & ArkTS

在HarmonyOS中,应用开发使用的官方语言是ArkTS语言,了解到它是基于 TypeScript 语言的扩展,旨在为开发者提供更高效、更安全的开发体验。

TypeScript 又是什么东西呢,对没有做过前端的开发来说,这几个语言的概念和特性会有点疑惑。

JavaScript (JS)

JavaScript是一种脚本语言,最初用于网页交互,现广泛用于前端、后端(Node.js)、移动端(React Native)等,现在已成为Web开发的主要语言。它是由网景公司(Netscape)在1995年开发的,最初被称为LiveScript,但后来改名为JavaScript,但是和Java没有关系。

JavaScript具有以下特点:

  • 动态类型:变量类型在运行时确定,无需显式声明。
  • 解释执行:浏览器或引擎(如V8)直接解释运行,无需编译。
  • 灵活但易出错:开发快速,但大型项目中维护困难(如类型错误需运行时才发现)。
// 动态类型
let num = 10; 
num = "hello"; // 合法,但易引发后续问题

// 函数式编程
const sum = (a, b) => a + b;

适用于网页动态交互(DOM操作);快速原型开发或小型项目;与HTML/CSS配合的前端开发(如React/Vue)。

TypeScript (TS)

TypeScript是JavaScript的超集,由微软开发,添加了静态类型系统,编译为JavaScript运行。

它具有以下特点:

  • 静态类型:需在代码中声明变量类型,编译时检查错误。
  • 编译为JavaScript:与JS兼容,可直接运行在浏览器或Node.js。
  • 面向对象支持:类、接口、泛型等高级特性。
  • 工具链完善:VS Code深度集成,提供智能提示和重构。
// 类型注解
let num: number = 10;
num = "hello"; // 编译时报错

// 接口与泛型
interface User { id: number; name: string; }
const getUser = <T>(id: T): T => id;

适用于大型前端项目(如Angular默认使用TS),以及需要高可维护性和团队协作的场景,还有后端开发(如NestJS框架)。

ArkTS

ArkTS是HarmonyOS生态的专属语言,由HarmonyOS官方提供,融合了TS的静态类型系统和HarmonyOS的声明式UI、状态管理等特性。ArkTs和TS,有点类似Jetpack Compose和Kotlin的关系。

它具有以下特点:

  • 静态类型:与TS一致,编译时检查错误。
  • 声明式UI:提供类似React/Vue的声明式API,简化UI构建。
  • 分布式能力:直接调用HarmonyOS API(如分布式软总线)。
  • 高性能字节码:基于Ark运行时(类似JVM),性能优异。
// 声明式UI
@Entry
@Component
struct MyComponent {
  build() {
    Column() {
      Text("Hello, HarmonyOS!");
      Button("Click Me", () => { console.log("Button Clicked"); });      
    }
  }
}

适用于HarmonyOS应用开发,特别是需要高性能、分布式能力的场景,如智能穿戴设备、智能车等。

应用运行环境

Android

在Android平台上,每个应用程序都运行在一个独立的进程中,由Android Runtime(ART)虚拟机提供执行环境。

ART是基于寄存器架构的高级虚拟机,其设计继承自Dalvik虚拟机但进行了全面重构,采用AOT 预先编译技术将DEX字节码预先编译为本地机器码。

这是一个 类JVM环境 ,通过优化过的指令集执行编译后的代码,同时保持与Java SE标准库的部分兼容性(通过Android SDK提供的精简实现)。

在系统架构层面,应用进程通过 Binder 这个 IPC 机制与Android系统服务进行进程间通信。Binder作为基于Linux内核的高效通信框架,采用内存映射和引用计数技术实现跨进程方法调用(RPC),其传输性能比传统IPC方式提升5-10倍。

例如 AMS(ActivityManagerService) 通过Binder向应用进程发送生命周期控制指令,而应用则通过代理接口(如IActivityManager.Stub)回调系统服务,形成完整的进程间协作机制。

应用运行时,ART虚拟机通过以下核心机制实现资源管理:

  • 内存管理采用分代垃圾回收(Generational GC)策略,配合Android特有的 Low Memory Kill 机制
  • UI线程使用消息队列(MessageQueue)和Looper实现事件驱动模型
  • 图形渲染通过 RenderThreadSurfaceFlinger 服务协同,采用 VSync 信号同步的帧调度机制
  • 资源加载通过 Resources 类实现多维度(dpi/语言/方向等)资源匹配系统

这种架构设计使得Android应用在性能受限的移动设备环境下,仍能实现高效的进程隔离、资源调度和用户体验保障。最新的ART改进(如从Android 12引入的压缩引用指针技术)进一步将内存占用降低20%,体现了持续优化的技术演进路径。

IOS

在iOS平台上,每个应用程序都运行在一个独立的沙盒环境中,由Objective-C/Swift运行时(Runtime)提供核心执行能力,并基于Mach-O可执行格式直接在ARM架构上运行原生代码。不同于Android的虚拟机机制,iOS采用AOT(Ahead-Of-Time)全量编译,通过LLVM编译器链 将代码预先优化为机器指令 ,结合苹果自研的A系列芯片的定制指令集(如Apple Silicon的AMX矩阵加速指令),实现接近裸金属的执行效率。

LLVM(Low Level Virtual Machine)是一个模块化、可重用的编译器和工具链技术集合的开源项目。尽管它的名字中带有“虚拟机”,但它与传统的虚拟机关系不大,而是一个专注于提供现代编译器基础架构的平台。它是一个编译器基础设施项目,提供了一套灵活、可扩展的工具和库,使得开发人员可以轻松地构建自己的编译器、调试器、JIT 编译器和其他程序分析和转换工具。它的模块化设计和强大的优化能力使其在现代软件开发中扮演着越来越重要的角色。Swift官方编译器就是基于 LLVM 构建的。

系统架构与进程通信

iOS通过XNU混合内核(融合Mach微内核与BSD特性)实现系统级隔离,应用与系统服务的交互主要依赖以下机制:

  • Mach IPC:基于端口(port)的轻量级进程通信,延迟低于1微秒,用于底层服务调用(如进程生命周期通知)
  • XPC:高层抽象通信框架,采用序列化对象(NSXPCConnection)实现沙盒间的安全数据交换
  • Darwin Notify:基于notifyd服务的跨进程事件广播系统,用于处理系统状态变更(如低内存警告)

核心运行机制

内存管理
  • 采用自动引用计数(ARC)编译时内存管理,通过插入retain/release调用替代垃圾回收
  • 使用Jetsam机制主动终止内存超限进程,配合内存压缩(iOS 13+)降低OOM概率
UI渲染流水线
  • Core Animation合成器直接驱动GPU渲染(Metal API),通过CADisplayLink实现120Hz ProMotion自适应帧同步
  • UIKit主线程遵循严格的RunLoop事件模型(NSDefaultRunLoopMode),所有UI操作必须通过dispatch_async(dispatch_get_main_queue())提交
资源管理
  • Asset Catalog编译时自动生成多分辨率@2x/@3x图像集,并支持按设备型号动态加载
  • dyld共享缓存将系统库预链接为内存映射文件,减少应用启动时的符号解析开销

性能优化特性

  • Swift Runtime使用值类型(Value Types)减少堆分配,方法派发采用直接调用(非ObjC消息转发)
  • GCD(Grand Central Dispatch):基于线程池的优先级队列(QoS级别),支持硬件线程感知的任务调度
  • MetricKit:实时监控CPU/内存/电池消耗,通过signpost日志实现纳秒级性能分析

这种架构使得iOS应用在保持严格安全隔离的同时,仍能实现亚毫秒级响应(如相机启动仅需650ms)。随着Swift 6引入并发模型和actor隔离机制,系统进一步强化了多核环境下的线程安全能力,体现了苹果软硬协同设计的持续进化。

HarmonyOS

鸿蒙系统也可以说是源于AOSP,基于Android10的开源代码演进而来。在 HarmonyOS(鸿蒙系统) 中,应用程序的运行环境与 Android 的 ART 虚拟机不同,它采用了多模式混合执行架构,具体取决于应用类型和部署场景。有以下几种类型:

传统应用(兼容Android APK)—— 运行在 Ark Runtime(方舟运行时)

类似ART但优化更彻底,使用 方舟编译器(Ark Compiler) 对 Java/JS 代码进行 AOT(Ahead-of-Time)静态编译,生成高效机器码,避免 JIT(即时编译)带来的性能波动。内存管理采用 智能分代回收(Generational GC),相比 ART 减少 20% 的 GC 停顿时间。支持多实例隔离(如同时运行两个微信),每个实例在独立沙盒中运行。

原生鸿蒙应用(基于 ArkTS/JS/Java)—— 运行在 Ark Engine(方舟引擎)

轻量级高效运行时,ArkTS代码会被编译为字节码(Ark Bytecode, ABC),由 Ark Engine 解释执行(未来可能支持 AOT)。无虚拟机开销:相比 JVM,Ark Engine 直接操作内存和硬件资源,减少抽象层带来的性能损失。关键任务(如 IoT 设备控制)可绑定 CPU 核心,确保实时性(延迟 <10ms)。

Native应用(C/C++/Rust)—— 直接运行在 Linux/微内核

适用于高性能场景(如游戏、驱动),代码会直接编译为 ELF 可执行文件,由 Linux 内核(标准版鸿蒙)或 LiteOS-A(轻量版鸿蒙) 调度。通过 HDF(硬件驱动框架) 访问硬件,无需经过虚拟机或运行时层。

跨设备分布式应用 —— 运行在“软总线”虚拟化环境

应用组件可跨设备动态迁移。例如,手机上的视频播放可无缝切换到智慧屏,底层通过 DSoftBus(分布式软总线) 实现 IPC 通信(延迟 <20ms)。资源按需加载,不同设备共享同一份应用逻辑,但渲染和计算可能分布在多个终端。

HarmonyOS系统架构

简要对比完Android和IOS的应用层架构,我们再来看下鸿蒙系统的应用层架构。

这套架构主体分为应用、框架、引擎以及跨平台适配这几部分,应用层就是透出给开发者的语法,有好几种模式,下文详解。框架层实现了前端框架常见的组件化、MVVM 能力,能够响应式的更新 UI。下面是 JS 引擎,使用的是 QuickJS,应该也支持 V8。再向下是渲染引擎,包含了核心的渲染管线、动画、事件和各种布局绘制算法。 最下面的 porting layer 是适配多平台的关键,定义了平台无关的 layer 数据结构,可以提交给不同的合成器(Compositor)合成渲染,从代码上看,也是支持 Flutter Engine 的。

首先鸿蒙应用是打包成 HAP (Harmony Ability Package) 格式分发的,和安卓 APK 一样都是压缩包,包结构大同小异。分发到端上之后,统一由鸿蒙(概念)的 API 承接,然后就分了不同的模式。在华为手机上推送的鸿蒙版本,可以无感兼容安卓应用,肯定是依赖了 AOSP 的。从Harmony4.0开始,​​官方正式宣布终止 APK 兼容模式​​,系统层移除对 Android 生态的依赖(如不再内置 ART 虚拟机)。仅支持运行 ​​纯鸿蒙应用(.hap 格式)​​ 或通过 ​​方舟编译器静态转译的 Android 应用​​(需重新打包为 .hap)。并且,在​OpenHarmony 3.2+(开源分支)​​从内核层面移除 Linux 兼容层,仅保留 ​​LiteOS-A 微内核​​,彻底无法运行 Android 应用。

学习转载自:HarmonyOS ArkUI 框架的实现原理和落地实践

要设计操作系统以及打造一个框架,一定是重新研发的,而不是在现有平台基础之上修改, UI 也是重新设计的,它的设计目标总结起来基本包含这么几个方面。

  • 第一点要能够实现跨多端,多设备,还有多尺寸的屏幕;
  • 第二点是能够实现一套代码适配多终端设备;
  • 第三点是一次开发多端部署,要能保证一致的设计和开发风格;
  • 第四点是不使用 Java,也不使用 ART,因为鸿蒙操作系统在去 Java 化,基本上不再使用 Java 了;
  • 第五点要能保证一致的事件和动效的处理机制;
  • 最后一点要支持多语言和高效的语言运行时,而且还要支持在多种不同的设备上使用。

【跨平台】使用AYA项目Server重构DebugManager工具的应用管理

【跨平台】使用AYA项目Server重构DebugManager工具的应用管理

本文记录了跨平台Android设备调试工具DebugManager,在使用AYA项目重构应用管理功能的流程与分析

这个工具绝大部分功能都是基于 adb 命令来实现的,在ProcessBuilder中执行命令,获取输出流,对输出的内容进行解析,在结构化地展示到界面。

然而没有直接获取应用图标文件这样的命令,只能从android内部下手,获取png文件再发送到Desktop端。本文介绍一下应用管理功能,图标文件获取这个实现的发展历程和最新的使用AYA服务端更改的版本。

发展历程

一、车机阶段

这个工具在最初开发的时候,是面向车机平台。

由于我可以获取到我们平台的系统签名文件,所以是直接编写了一个带系统签名的APP,内部有一个服务,在 onCreate() 中获取安装所有的应用信息:

  • label
  • packageName
  • versionName
  • versionCode
  • lastUpdateTime

最麻烦的需要存储的是应用的ICON图标文件,因为想要在电脑端的JVM应用 DebugManager 中显示应用的图标,是没有adb命令可以使用的,只能从Android平台上获取到 Bitmap 数据,转换为png存储下来。最后拉取到Desktop端,读取文件显示图标。

编写玩服务代码,在安装这个APK后,通过 am startService 来运行这个服务,服务端获取信息,将所有信息准备好后再存储到应用内部路径的文件中。

/data/data/com.stephen.appinfoservice/files/appinfo.json
/data/data/com.stephen.appinfoservice/icons/*.png

二、手机阶段

在面对更普遍的手机端的应用管理功能时,我发现了没有系统签名的应用,是不可以直接使用 am startservice 命令来拉起服务的。而且不同手机平台还有权限上面的区别,比如类原生的系统上(Pixel平台,LineageOS),是不用动态申请就可以获取安装的应用信息,但是在国产OS上,有更严格的管理,需要处理读取应用列表权限。

当然可以专门做一个Activity来交互,让用户同意读取应用列表的权限。但是我更希望这个过程是无感的。

所以还是要探索其他的办法。

AYA项目服务端

AYA也是一个基于adb命令来显示Android设备信息,进行调试的项目,同样为跨平台的产品形态。

它是使用比较火的Electron框架,TypeScript 语言编写的。看到他们有类似的应用管理页面,可以显示APP图标,我想他们肯定也是在Android设备上有一个服务端的。看看他们服务端的实现方式,并将其改写成适配 DebugManager 项目的数据传输方案。

AYA的服务端不是承载于一个普通的Android应用进程上,而是类似于一个DEAMON守护进程。

代码运行分析

这是一个 Android 应用,但它并不是一个常规的 APK,而是一个在 Android 设备上通过 dex 文件直接运行的程序。这种方式通常用于需要更高权限或者直接访问系统服务的工具类应用,类似于 adb shell 上的一个服务。

在项目的Gradle脚本中,配置了一个任务,这个任务会将项目的代码build生成的apk里面的 dex 文件提取出来,然后通过 adb 命令,推送到设备的 data/local/tmp 路径下,使用 CLASSPATH 指定运行。

android.applicationVariants.all { variant ->
    variant.outputs.all {
        outputFileName = "aya-server-v${versionName}.apk"

        def dexPath = rootProject.rootDir.path
        variant.assembleProvider.get().doLast {
            copy {
                def file = zipTree(file(outputFile)).matching { include 'classes*.dex' }.singleFile

                from file
                into dexPath
                rename { String fileName ->
                    fileName.replace(file.getName(), "aya.dex")
                }
            }
        }
    }
}

从整个流程看,这个项目是一个用于在 Android 设备上获取和管理应用信息的工具。它利用了 LocalSocket 进行进程间通信,通过 反射 访问 Android 隐藏的 API 来获取详细的系统和应用信息,并将这些信息缓存到设备的文件系统中。

对原来的代码进行了改写,AYA项目继承了protobuf通信框架,直接将所有的应用信息,全部通过protobuf协议进行传输,包括图标文件(base64编码后)。

protobuf是Google开发的一种语言无关、平台无关、可扩展的序列化数据结构的方法,它可以将数据结构序列化为二进制格式,用于在网络上传输或存储。

我选择了更简单的通信方案,直接使用JSON字符串,在客户端使用Kotin序列化解析。然后服务端在运行时,将图标png存储到固定位置,拉取到电脑端再显示。这种方式打包的dex文件体积缩小了90%。

代码简要分析:

服务入口 Server.kt

Server.ktmain 方法是整个程序的入口点。

fun start(args: Array<String>) {
    Log.i(TAG, "Start server")

    val server = LocalServerSocket("aya")
    Log.i(TAG, "Server started, listening on ${server.localSocketAddress}")

    while (true) {
        val conn = Connection(server.accept())
        Log.i(TAG, "Client connected")
        executor.submit(conn)
    }
}
  • Server.main 方法被调用,创建一个 Server 实例并调用其 start 方法。
  • start 方法首先创建一个名为 "aya"LocalServerSocketLocalSocket 是 Android 上一种特殊的 IPC(Inter-Process Communication,进程间通信)机制,它允许同一个设备上的不同进程通过本地套接字进行通信。
  • 服务器进入一个无限循环 while (true),等待客户端连接。
  • 每当有客户端连接时 (server.accept()),服务器会创建一个新的 Connection 实例,并将该实例提交给一个缓存线程池 executor 来执行。这意味着每个客户端连接都会在一个独立的线程中处理,从而实现了并发处理。

请求处理 Connection.kt

Connection.kt 负责与单个客户端进行通信。

  • run 方法获取客户端的输入流和输出流,用于读取请求和发送响应。
  • 它进入一个循环,从输入流中读取客户端发送的 JSON 字符串请求。
  • 读取到的 JSON 字符串被解析,然后调用 handleRequest 方法来处理具体的请求。

handleRequest 方法根据请求中的 method 字段来分发不同的处理逻辑。

when (method) {
    "getVersion" -> {
        put("version", getVersion())
    }

    "getPackageInfos" -> {
        put("packageInfos", getPackageInfos(JSONObject(params)))
    }

    "saveAllInfoToFile" -> {
        put("saveResult", saveAllInfoToFile(params))
    }

    else -> {
        Log.e(TAG, "Unknown method: $method")
        put("error", "Unknown method: $method")
    }
}

调用的 getPackageInfo 方法是核心逻辑,它利用反射机制获取系统服务并查询应用信息。

  • 获取系统服务:
    • 通过 ServiceManager.packageManagerServiceManager.storageStatsManager 获取 PackageManagerStorageStatsManager 的实例。
    • ServiceManager.kt 中使用反射调用了隐藏的 android.os.ServiceManager.getService 方法,从而获取系统的 IPackageManagerIStorageStatsManager 服务。这种方式通常需要特殊的权限或者在 root 环境下才能成功。
  • 查询信息:
    • 使用 ServiceManager.packageManager.getPackageInfo 获取 PackageInfo 对象,其中包含了应用版本、安装时间等基本信息。
    • PackageInfo 中获取 ApplicationInfo,进而获取应用的 apkPathflags。通过 flags 判断应用是否为系统应用。
    • 如果设备版本在 Android 8.0(Oreo)及以上,会使用 ServiceManager.storageStatsManager.queryStatsForPackage 获取应用的存储统计信息,包括应用数据大小、缓存大小等。
  • 获取应用名称和图标:
    • 利用 apkPath,通过反射创建 AssetManager 并加载 APK 资源,然后获取应用的名称(label)和图标(icon)。
    • 如果图标存在,会将其转换为 PNG 格式并保存到 /data/local/tmp/aya/icons 目录下。
  • 组织和返回数据:
    • 所有获取到的信息都被封装成一个 JSONObject 返回。

生命周期与数据流转

普通 Android 应用(APK)的生命周期是由系统 PackageManagerActivityManager 严格管理的。而这个服务端则是一个 “裸” 进程

它的生命周期管理方式更接近于一个传统的 Linux 守护进程(daemon)。它没有标准的 Android 应用入口点(如 Launcher Activity)。它通常需要通过 adb shell 或其他特殊工具(如 Magisk 模块)来手动启动,命令通常类似于 app_process /system/bin io.liriliri.aya.Server

它的生命周期完全由启动它的进程控制。只要启动它的 shell 进程或父进程不被终止,这个服务端进程就会一直运行。它没有 onStoponPause 等 Android 生命周期回调。由于它不是一个常规的应用进程,系统不会像管理普通应用那样主动去管理它。如果它没有被其他应用组件绑定,系统通常不会轻易终止它,除非设备内存极度紧张。

使用 反射 来调用系统隐藏的 API 。这种方式绕过了标准的权限检查。

这个服务端运行在一个单独的 JVM(Java Virtual Machine)进程上。

具体来说:

  • 独立的进程: AYA的服务端代码有一个 main 方法在 Server.kt 中。在 Android 平台上,当一个 Java/Kotlin 程序通过 CLASSPATH 运行并带有 main 方法时,它会被系统启动为一个独立的进程。这个进程会拥有自己的 Dalvik 或 ART(Android Runtime)虚拟机实例。
  • 本地套接字服务器: 这个进程创建了一个 LocalServerSocketLocalSocket 是一种 Android 内部的 IPC(进程间通信)机制,它允许同一个设备上的不同进程进行通信。你的服务端进程会监听这个套接字,等待其他客户端进程(例如,一个通过 adb shell 启动的客户端,或者一个单独的 Android 应用)来连接。

服务端程序可以被看作是一个后台服务,它作为一个独立的进程在 Android 系统中运行,并通过本地套接字提供服务。运行成功后,再通过 adb forward tcpip:xxxx localabstract:aya 命令,把这个设备内部的本地套接字通信,映射到电脑的 TCP/IP 端口上。

以下是完整的通信流程:

  • 你的电脑上的客户端程序(例如,一个 Python 脚本或一个 C++ 程序)会尝试连接到 localhost:xxxx。这个连接请求是一个标准的 TCP/IP 连接请求。
  • adb 发现一个连接到你电脑 xxxx 端口的请求时,它会拦截这个请求。adb 作为一个桥梁,将这个 TCP/IP 流量通过 USB 数据线或 Wi-Fi 发送到你连接的 Android 设备。
  • 设备端的 adb 守护进程(adbd)接收到这个连接请求。它会识别出这个请求是为 localabstract:aya 设定的转发规则。
  • adbd 守护进程在设备上作为一个新的客户端,主动去连接 localabstract:aya 这个本地套接字。
  • Server.kt 里的 LocalServerSocket 正在监听 localabstract:aya。当 adbd 发出连接请求时,server.accept() 会返回一个与 adbd 建立连接的 LocalSocket

基于以上分析路线,电脑上的客户端发送的所有数据,都会通过以下路径传输:

PC 客户端PC 的 adbUSB 数据线/Wi-Fi设备上的 adbdlocalabstract:aya 本地套接字你的服务端进程 (Server.kt)

同时,Android服务端发送的响应数据也会通过这条路径原路返回。

这种方式允许你像调试一个常规的网络服务一样,直接在电脑上与运行在 Android 设备上的程序进行交互。你不需要在设备上安装一个完整的网络服务器,也不需要担心防火墙或其他网络配置问题。adb 巧妙地为你解决了跨进程、跨设备的通信问题,将本地 IPC 流量无缝地转发到了你的电脑上。

DebugManager对接

电脑端的代码,在数据处理上几乎没有改动,将原来的安装apk流程和启动服务流程,改为了推送dex文件到设备,使用

CLASSPATH=/data/local/tmp/aya/aya.dex app_process /system/bin io.liriliri.aya.Server

来启动服务,再通过adb forward tcpip:xxxx localabstract:aya命令,把这个设备内部的本地套接字通信,映射到电脑的TCP/IP端口上。

DebugManager内部,通过 adb shell pm list packages 命令,获取到所有安装的应用列表。解析出每一个包名,再调用服务端的 getPackageInfo 方法,获取应用label,版本信息。最后通过coil的AsyncImage组件,填入icon文件路径,异步加载图标。

界面升级

此前的交互也进行了同步升级,再有限的窗口内展示更多的信息,全局缩小了字号和模块之前的padding,将列表类型改为了图标矩阵,使用 LazyVerticalGrid 组件来承接应用图标展示。

详细信息弹窗:

同时为了缩小重组范围,使用应用的packageName作为key,来标识每一个item,还可以以此来实现每一个item的移动动效,比图标的体验更丝滑。

LazyVerticalGrid(columns = GridCells.Adaptive(minSize = 105.dp)) {
    items(appListState.sortedBy { it.label }, key = { it.packageName }) {
        Box(
            Modifier.animateItem(
                fadeInSpec = null,
                fadeOutSpec = null,
                placementSpec = tween(300)
            )
        ) {
            GridAppItem(
                label = it.label,
                iconFilePath = mainStateHolder.getIconFilePath(it.packageName),
                modifier = Modifier.padding(5.dp)
                    .size(100.dp).clip(RoundedCornerShape(10))
                    .padding(5.dp)
                    .bounceClick().clickable(
                        indication = null,
                        interactionSource = remember { MutableInteractionSource() }
                    ) {
                        dialogInfoItem.value = it
                    },
                onClickShowInfo = {
                    dialogInfoItem.value = it
                },
                onClickOpen = {
                    mainStateHolder.startMainActivity(it.packageName)
                },
                onForceStop = {
                    mainStateHolder.forceStopApp(it.packageName)
                },
                onExtractApk = {
                    mainStateHolder.pullInstalledApk(it.packageName, it.versionName)
                },
            )
        }
    }
}

在动效方面,直接使用 animateItem 函数就可以实现列表项的移动动效。

Modifier.animateItem(
    fadeInSpec = null,
    fadeOutSpec = null,
    placementSpec = tween(300)
)

【跨平台】Kotlin Multiplatform闲谈

【跨平台】Kotlin Multiplatform闲谈

本文介绍了Kotlin Multiplatform框架过去现在和未来的一些讨论主题。

论题来自于 霍丙乾(Benny Huo) 在B站上的答网友问。基于其描述,详细扩展开来。

有浏览器H5套壳,为什么还要用Kotlin跨端

H5 主要是使用 Web 技术(HTML、CSS、JavaScript)来构建应用程序,然后通过 WebView(一个内嵌的浏览器组件)在不同平台的原生应用中运行。常见的 H5 跨平台框架包括 Ionic、PhoneGap (Cordova)、以及一些基于小程序(如微信小程序)的开发方式。

实现上是将 Web 应用打包成原生应用,通过 WebView 渲染界面和执行逻辑。由于运行在 WebView 中,性能通常不如原生应用,尤其是在处理复杂动画、大量数据或需要高性能计算的场景。启动速度也不如原生应用。对原生功能的访问需要借助插件。

Kotlin Multiplatform (KMP) 是 JetBrains 推出的一项技术,它允许开发者使用 Kotlin 语言编写共享的业务逻辑(如数据处理、网络请求、商业规则等),并将其编译成适用于不同平台的原生代码。UI 层通常仍然使用各平台的原生技术(Android 的 Jetpack Compose/XML,iOS 的 SwiftUI/UIKit)来实现,但也支持使用 Compose Multiplatform 实现共享 UI。

Kotlin编译器会将面向不同平台的Kotlin代码直接 编译成对应平台的Native代码实现 ,可以说在每个平台上都是 原生应用 ,从性能上来说媲美原生。如果进一步使用Compose Multiplatform,则可以在各个平台上共享一套UI代码,使用 Skia 跨端渲染引擎,其性能也远高于 H5 开发。在开发周期上比 H5 长一点。体量大一点的应用一般都会追求更长远,性能更好的技术

总结起来就是:

  • H5 跨平台 更像是“将网页打包成应用”,优势在于开发速度快、Web 开发者门槛低,但性能和原生体验是其短板。
  • Kotlin Multiplatform 更像是“用 Kotlin 编写原生应用的一部分”,优势在于性能接近原生、能充分利用原生特性,并且可以灵活选择共享业务逻辑或 UI,但对开发者有一定的 Kotlin 基础要求,且 UI 部分(如果选择原生)仍需单独开发。

Kotlin/Native性能对比其他语言如何

Kotlin/Native 旨在将 Kotlin 代码编译为可以在没有虚拟机 (VM) 的情况下运行的本地二进制文件,使其适用于嵌入式设备或 iOS 等平台。Kotlin/Native 代码直接编译为机器码,因此它的执行速度通常非常快,可以与原生应用媲美。对于 CPU 密集型任务,其性能通常远优于 JVM 上的 Kotlin 或 JavaScript。

Kotlin/Native 包含一个现代的跟踪垃圾回收器。虽然自动内存管理简化了开发,但在某些情况下,GC 可能会引入微小的暂停,这可能会影响对实时性要求极高的应用。不过,JetBrains 正在不断优化其内存管理器。

同Swift相比,Kotlin不会自动回收,内存消耗会高一点,这使得其运行起来反而更快。对象在内存管理级别做了池化,创建和使用都会比Swift更快。

与 C/C++ 相比,它在以下几个方面通常存在差异。

C/C++: 提供对内存的直接控制(通过指针)、更细粒度的硬件优化(如 SIMD 指令、CPU 缓存优化)以及手动内存管理。这使得 C/C++ 成为需要极致性能和资源控制的场景(如操作系统、驱动程序、游戏引擎、高性能计算)的首选。

Kotlin/Native: 虽然性能接近原生,但它仍然是高级语言,抽象层高于 C/C++。它提供了垃圾回收机制,简化了内存管理,但也意味着开发者对内存布局和生命周期的直接控制较少。在需要极度细致的内存布局和手动优化以榨取每一丝性能的场景下,C/C++ 仍然更具优势。

非Android平台为什么不使用Kotlin/Native替代JVM

以Android平台为例,如果使用 Kotlin/Native 去绕过Android虚拟机,那么开发上,Google的api都不可以直接使用了,仍然需要套一层壳。

在移动端的另外两大平台上,KMP在开发时,是采用了类似RN的桥接调用的,腾讯在将Kotlin/Native移植到鸿蒙系统时,就是使用ArkTS包装鸿蒙的系统api,给Kotlin调用,包括IOS上也是如此实现。

对于桌面端,Compose Desktop 仍然是运行在JVM上。Windows、MACOS、Linux等桌面系统底层的差异性,想抹平是非常困难的。

目前桌面端最火的 Electron 框架为例,Electron 应用通常需要独立运行,不能依赖用户本地环境的 Chromium 或 Node.js 版本,都会自己打包一个 V8 引擎,负责解析、编译和执行应用中的 JavaScript 代码(包括前端页面逻辑和 Node.js 后端代码)。正是因为有这个附带的引擎,安装包体积高达几百M,但是其能提供的开发体验和跨平台生态是更应该关注的点。

同理,Compose Desktop没有选择直接对接操作系统,而是选择运行于 JVM 上,也是由于JVM已经把平台适配给做完了,并且提供的接口和性能已经过多年发展验证,可以很好地支持快速开发和功能达成。包括前段时间的Rust,甚至是基于浏览器来运行。

如果不谈Compose UI界面,Kotlin本身其实是可以通过Kotlin/Native直接跑在各个桌面平台上的,比如Windows端通过 Mingw(Windows系统api封装中间层)。另外也可以通过GTK来实现UI界面。

GTK(GIMP Toolkit) 是一个开源的跨平台 图形用户界面(GUI)工具库,最初为图像处理软件 GIMP 开发,现广泛用于 Linux 桌面环境(如 GNOME)以及其他操作系统(Windows、macOS)。GTK 本身是用 C 编写的,和 Kotlin 无法直接交互,Binding 充当了“桥梁”,将 GTK 的 C 语言接口通过某种方式(如 JNI、FFI 或原生库)暴露给 Kotlin,使 Kotlin 开发者能直接调用 GTK 的功能来构建 GUI 应用。

Compose IOS 的一些坑

Kotlin 最开始是和OC互调用的,后来的2.1版本才开始转向和 Swift 互调用。IOS开发端,Cocapods 处于不维护的存量状态,整体在往Swift生态迁移,后面会全面转向Swift了。

腾讯视频 在IOS上自研了一套渲染引擎,因为要兼容大量的原生存量代码,前期只能小范围替换,省掉了一个渲染层的内存。在鸿蒙端是纯以来CMP的Skia了。

  • 混合开发的时候,内存,画布开销。
  • 单独的View容器需要自己管理生命周期。
  • 列表组件单独嵌入到原生容器,可能开发上会比较麻烦,处理手势。

和 OC 互操作方面,类的导出有很多限制。和Swift应该差不多,也会有这些限制。编译的时候不会发现,运行时才知道,

霍老师在腾讯云开发者平台上发布的Kotlin/Native的文章,详细列出了这些限制的问题。

深入理解Kotlin/Native

互操作尽可能少,导出一些工具函数等。

能做到三大移动端一把梭哈吗

前段时间 Jetbrains 发布通知,CMP的IOS端已经稳定。

在鸿蒙端,腾讯视频团队已经实现了比较稳定的方案,在和Jetbrains沟通能否贡献到官方代码中。

所以技术上Android、IOS、鸿蒙三端共用是没有问题的。

CMP和RN和Flutter的对比

此前已经对比过一次三者的易用性,性能,渲染方式上的差异。 差异。

Kotlin Multiplatform 对比 React Native 和 Flutter

CMP最大的优势还是来自于Kotlin,在各个平台上,编译完后都是Native实现。另外在UI渲染上采用Skia,性能优于RN,持平Flutter。

能否替代Flutter?

有潜力,但是CMP的潜力不止于替换Flutter,Kotlin编译器编译完的代码,在框架上和原生开发无异,胃口大一点问问是否可以替代原生。

每个平台都有最合适的原生代码,CMP三段一码的开发成本,对大型app诱惑还是很大,成本和收益都是需要考虑的。

CMP前景如何

CMP前景依托于Kotlin跨端的前景。

Kotlin跨平台是语言特性,而不是框架,并没有做一个中间层来转换。

Kotlin中可以直接访问C的结构体。

以Java生态要求Kotlin,还是有很长的路要走。

Compose的WebAssembly,也是 Jetbrains 官方下一阶段的重点,IOS端已经稳定。

相比其他的框架,其问题在于正是因为到处都是Native,就需要 对这个target的原生环境有一定了解 。知道如何去调用原生的API,比如处理内存,处理生命周期,处理手势等。

在IOS上写代码,就需要对Swift有一定了解,依赖原生api去配置。

ovCompose

腾讯视频团队推出的跨平台开发框架ovCompose以及相关基础库KuiklyBase,旨在解决跨平台开发中的一些痛点问题,并推动Compose跨端生态的发展。

项目背景

  • 跨平台需求:随着鸿蒙系统的推出,客户端跨平台开发的需求显著增加。传统的UI跨端方案已无法满足业务需求,全跨端APP(覆盖Android、iOS和鸿蒙)的开发成为降低开发成本和提升效率的关键。
  • 现有技术的局限性:尽管Kotlin Multiplatform具备高性能和灵活的原生交互能力,但它存在一些问题,如不支持鸿蒙系统、iOS混排能力受限以及GC性能表现一般等。

ovCompose和KuiklyBase的特性优势

  • 鸿蒙高性能:KuiklyBase选择了Kotlin Native(KN)作为鸿蒙适配方案,相比JavaScript(JS)具有更快的执行速度和更好的三端一致性。通过性能优化,如内联优化、ThreadLocal优化等,显著提升了执行效率。
  • 鸿蒙三明治架构支持混排:利用Skia渲染方案和XComponent组件,解决了Compose与原生组件的混排问题,支持粘贴按钮等安全组件的混排。
  • 三端高一致性:通过Kotlin Native方案解决了跨线程访问问题,保持了逻辑运行的一致性。同时,iOS和鸿蒙平台均采用Skia渲染,确保了UI绘制的一致性。
  • iOS多模态渲染:采用指令映射方案,解决了Compose在iOS上的混排难题,并实现了与原生UI的灵活混排。
  • Kotlin Native内存优化:包括GC优化(如GC抑制、分段GC、Sweep优化)和堆Dump优化,显著提升了内存管理效率。
  • KuiklyBase组件生态:提供了丰富的组件,如异常堆栈还原组件、跨语言互调用组件、资源管理组件、原子操作组件、协程组件、序列化组件等,为开发者提供了强大的支持。

实现原理

  • KN鸿蒙平台适配:通过在Kotlin IR转LLVM IR时使用苹果的LLVM 11,在LLVM IR生成可执行文件时使用鸿蒙的LLVM 12,解决了鸿蒙平台的适配问题。
  • KN性能优化:包括内联优化、ThreadLocal优化、协程性能优化、调试性能优化等,显著提升了Kotlin Native在鸿蒙平台上的性能。
  • 鸿蒙绘制不同步问题解决:通过采用XComponent的Texture模式,解决了Compose与ArkUI绘制不同步的问题。
  • iOS多模态渲染:设计了基于iOS的PictureRecorder局部更新架构,通过增量hash减少hash计算量,优化了绘制效率。

开源信息

开源仓库:ovCompose和KuiklyBase已在GitHub开源,包含5个仓库,地址为:https://github.com/Tencent-TDS

未来计划

  • 持续优化:重点优化GC在业务场景的表现、Kotlin-Native组件化、开发体验优化以及UIKit渲染模式与Skia的进一步对齐。
  • 扩展支持:计划将ovCompose和KuiklyBase扩展到TV和PC端,进一步完善跨平台开发框架。

与KuiklyUI的差异

  • KuiklyUI:侧重于静态化+动态化双运行模式,采用轻量原生渲染,支持H5和小程序。
  • ovCompose:专注于全面对齐Compose Multiplatform标准API,采用自渲染方式实现鸿蒙平台的适配,确保三端高度一致性。

【跨平台】使用Compose Multiplatform开发跨平台的Android调试工具

【跨平台】使用Compose Multiplatform开发跨平台的Android调试工具

使用Compose Multiplatform开发了一款跨平台的电脑端Android设备调试工具。本文简单介绍了开发背景,功能点,特殊问题解决等信息。

背景

最近对CMP跨平台很感兴趣,为了练手,在移动端做了一个Android和IOS共享UI和逻辑代码的天气软件,简单适配了一下双端的深浅主题切换,网络状态监测,刷新调用振动器接口。

做了两年多车机Android开发,偶尔玩下手机端跨平台也蛮有意思。

然后又了解到CMP不仅仅是移动端的,还可以做web和desktop端。

在我们日常的开发过程中,对于车机设备的adb调试操作很多,一大半全是固定的流程。使用bat脚本的话又不那么灵活,体验也不好。所以我很早就想要做一个带界面的Android设备调试工具。在移动端上写纯原生的Compose界面比较熟悉了,想着这个估计也差不多的,就开启了为期一个多月的Compose for Desktop开发。开发体验可以算中上,很多的问题在stackoverflow和官网上都能找到方案。软件命名为DebugManager。

架构设计

我没有开发Desktop端的经验,不知道最优的架构设计是什么样的。使用CMP的话Google推崇的MVI模式依然可以通用,所以最初制定的技术路线就是使用响应式的架构。

由于功能单一,几乎所有操作都是执行一些命令行,获取反馈结果,所以没有抽象的很厉害,数据层直接使用单例类,使用adb工具获取数据透传到StateHolder。StateHolder为界面的状态State管理层,在Composable方法初入时,触发StateHolder的数据获取逻辑,数据拿取到之后,更新State状态,通过界面收集监听的stateflow通知composable方法刷新UI。

Google_mvi

即用户事件从上到下,数据状态从下到上,确保唯一可信数据流。

gradle配置

这一步决定DebugManager项目面向的各个平台的配置,软件版本,安装包。

由于这个软件面向不同岗位,不同操作系统,目标是一套代码适配Windows,Linux,MacOS,达到多端通用。而且目前没有交叉编译,只能在各自的系统上打包,windows打exe,ubuntu上打deb,macos上打dmg,所以我现在给使用不同系统的同事发布软件时,都是三端各打一遍。

Windows端有配置是否显示在开始菜单,桌面快捷方式,uuid用于更新识别,自行选择安装目录。

// 开始菜单
menu = true
// 桌面快捷方式
shortcut = true
// 可自行选择安装目录
dirChooser = true
// 可单独为当前用户安装,不需要管理员权限
perUserInstall = true
// 设置图标
iconFile.set(project.file("launcher/icon.ico"))
// uuid用于更新识别
upgradeUuid = "xxxx-xxxxxxx-xxxxx"

更详细的Gradle属性配置参考可以看官方github仓库的教程文档: JetBrains官方配置文档

图标配置

关于三个平台应用图标的设置,我们需要手动制作三端的图标文件。

  • Linux使用的是png格式。可以作为源文件,来制作Windows和MacOS的图标。

  • Windows端的图标为ico格式。可以通过这个在线网站来生成:

ICON图标在线制作

图片切圆角,可以使用这个网站:

在线对图片进行透明圆角处理

  • MacOS端的图标为icns格式。

注意 Mac 端的图标需要使用苹果电脑才能生成。

首先我们切到png格式的图片所在的目录,执行下面三组命令即可生成Mac端的图标文件了。第一步创建输出文件夹:

mkdir MyIcon.iconset

输入这些命令来生成不同尺寸的图标:

sips -z 16 16     original.png --out MyIcon.iconset/icon_16x16.png
sips -z 32 32     original.png --out MyIcon.iconset/icon_16x16@2x.png
sips -z 32 32     original.png --out MyIcon.iconset/icon_32x32.png
sips -z 64 64     original.png --out MyIcon.iconset/icon_32x32@2x.png
sips -z 128 128   original.png --out MyIcon.iconset/icon_128x128.png
sips -z 256 256   original.png --out MyIcon.iconset/icon_128x128@2x.png
sips -z 256 256   original.png --out MyIcon.iconset/icon_256x256.png
sips -z 512 512   original.png --out MyIcon.iconset/icon_256x256@2x.png
sips -z 512 512   original.png --out MyIcon.iconset/icon_512x512.png
sips -z 1024 1024 original.png --out MyIcon.iconset/icon_512x512@2x.png

最后合成不同尺寸的图标:

iconutil -c icns MyIcon.iconset

之后就可以看到一个后缀为icns的文件了,将其复制到项目中,gradle脚本里配置为应用图标。

图标配置过程中的bug

目前还发现一个奇怪的bug,就是有的png图标经过转换,配置到项目中,打包exe出来是正常大小,大概90M。

有的图片生成完毕之后,Windows平台打包后的 EXE 安装包大小直接暴涨到了2个G,甚至3个G,目前不确定什么原因导致的。还在排查和寻求官方的帮助。

解决

经过几轮尝试排查,问题应该出在那个windows平台的转换网站上:

https://www.butterpig.top/icopro/

通过 IDE 打开生成的ico文件,发现其实际的文件类型是JPEG,并不是显示的ico文件。

“假icon!!”

blogs_cmp_wrong_ico_file

目前怀疑图标类型错误,导致安装包暴涨。会产生这个现象的原因,可能是CMP所使用的Windows打包器的一个bug或者说一个规则吧。

使用Python的Pillow库来转换,发现生成的图标文件显示的是我需要的ICO类型了。

转换脚本很简单,如下:

from PIL import Image

def png_to_ico(png_path, ico_path):
    # 打开PNG图像
    image = Image.open(png_path)

    # 将图像转换为ICO格式
    image.save(ico_path, format='ICO', sizes=[(image.width, image.height)])

# 调用函数并传入PNG图像路径和ICO文件路径
png_to_ico('C:\\Users\\stephen\\Desktop\\logo.png', 'output.ico')

转换后的图标文件:

blogs_cmp_right_ico_file

将这个 ico 文件配置到项目之后,打包的大小已经恢复正常的90余M。

Multiplatform适配

开发Desktop跨平台碰到的的第一个问题,就是不同平台的路径连接符不一致:

在Windows上是反斜杠  \

在unix like的系统上是一个正斜杠  /

经过探索,JVM系的应用其实可以使用 File.separator 来获取这个连接符拼到路径字符串里。

而且关于平台类型的区分,Java也给我们提供了一个 System.getProperty 接口。

单例模式

在Windows平台上,多次通过入口来运行exe文件,会产生多个进程,对于本软件是没有必要的,甚至有可能导致bug。

所以需要像任务管理器那样,不管有多少次的打开动作,始终只有一个进程一个界面。

这里通过文件锁的方式来实现。

刚开启进程就创建一个文件,并将其锁定。在JVM关闭的时候,释放并删除这个文件。这样如果软件已经有一个进程在运行了,再次打开时尝试去获取这个文件的独占锁,如果获取不到,就说明已经有一个实例在运行,直接退出后打开的这个进程。

class SingleInstanceApp {

    private var lock: FileLock? = null
    private var channel: FileChannel? = null

    fun initCheckFileLock(lockFilePath: String) {
        LogUtils.printLog("initCheckFileLock")
        val file = File(lockFilePath)
        channel = RandomAccessFile(file, "rw").getChannel()
        lock = channel?.tryLock()
        if (lock == null) {
            LogUtils.printLog("Another instance is already running.", LogUtils.LogLevel.ERROR)
            exitProcess(1)
        }
        // 添加JVM关闭时的钩子,释放锁
        Runtime.getRuntime().addShutdownHook(Thread(Runnable {
            runCatching {
                lock?.let {
                    it.release()
                    channel?.close()
                    file.delete()
                }
            }.onFailure { e ->
                e.printStackTrace()
            }
        }))
    }
}

通过依赖注入到平台化的管理类中去,init方法中,当配置文件夹一创建完毕,就进行获取锁的操作。

class PlatformAdapter(private val singleInstanceApp: SingleInstanceApp) {

    init {
        println("PlatformAdapter init")
    }

    fun init() {
        createInitTempFile()
        singleInstanceApp.initCheckFileLock(lockFilePath)
    }
}

平台渠道管理

首先,定义一个枚举类来设定平台类型:

enum class PlatformType {
    UNKNOWN,
    WINDOWS,
    MAC,
    LINUX,
}

在应用初始化时,通过接口获取平台名称,解析出哪一个平台:

/**
 * 获取当前平台类型
 */
private fun getPlatformType(): PlatformType {
    val osName = System.getProperty("os.name").lowercase(Locale.getDefault())
    return when {
        osName.contains("win") -> PlatformType.WINDOWS
        osName.contains("mac") -> PlatformType.MAC
        osName.contains("nix") || osName.contains("nux") || osName.contains("aix") -> PlatformType.LINUX
        else -> PlatformType.UNKNOWN
    }
}

后面在涉及平台差分化的时候,可以使用此方法来获取,执行不同操作。

比如打开不同平台上的文件管理器:

/**
 * 打开一个文件夹
 */
fun openFolder(path: String) {
    when (getPlatformType()) {
        PlatformType.WINDOWS, PlatformType.UNKNOWN -> {
            executeTerminalCommand("explorer.exe $path")
        }

        PlatformType.MAC -> {
            executeTerminalCommand("open $path")
        }

        PlatformType.LINUX -> {
            executeTerminalCommand("xdg-open $path")
        }
    }
}

对于各个平台上执行终端命令,使用的两个方法是相同的,无需结果就直接exec(),需要执行结果就是用ProcessBuilder来执行,等待结果。

/**
 * 执行终端命令
 */
fun executeTerminalCommand(command: String) {
    runCatching {
        Runtime.getRuntime().exec(command)
    }.onFailure { e ->
        LogUtils.printLog("执行出错:${e.message}", LogUtils.LogLevel.ERROR)
    }
}

/**
 * 执行命令,获取输出
 */
suspend fun executeCommandWithResult(command: String) = withContext(Dispatchers.IO) {
    val processBuilder = ProcessBuilder(*command.split(" ").toTypedArray())
    val process = processBuilder.start()

    val reader = BufferedReader(InputStreamReader(process.inputStream))
    val output = StringBuilder()
    var line: String?
    while (reader.readLine().also { line = it } != null) {
        output.append(line).append("\n")
    }
    // 等待进程结束
    process.waitFor()
    // 关闭输入流
    reader.close()
    output.toString()
}

窗口框架

新项目的应用入口如下:

fun main() = application {

    Window(
        onCloseRequest = {

        },
        title = "DebugManager",
        undecorated = true,
        state = windowState,
        icon = painterResource("image/icon.png"),
    ) {
       ....
    }
}

我们主要的内容区就在Window这个 Composable 方法里。

通过 windowState ,我们可以设置窗口初始大小,窗口最大最小化。

undecorated 参数,这个可以配置软件界面是否选择系统默认的标题栏。由于我希望在三端上的设计语言都可以统一,都使用我自定义的标题栏,所以这里改为设置 true

有意思的一点是,在最开始将 undecorated 设为 true 后,我发现用上的Compose自定义的标题栏后,无法使用鼠标拖动窗口了,一度试了很多方案都不行。

这里最后还是咨询谷歌的 Gemini ,它给我展示了一个 WindowDraggableArea 组件,居然直接套用即可完美解决,里面的区域就是支持拖动移动的。把标题栏的 Composable 方法放在这个 WindowDraggableArea 可组合项里面,就可以鼠标拖动标题栏来移动窗口了。

WindowDraggableArea 源码方法签名如下:

@androidx.compose.runtime.Composable
@androidx.compose.runtime.ComposableInferredTarget
public fun androidx.compose.ui.window.WindowScope.WindowDraggableArea(
    modifier: androidx.compose.ui.Modifier = COMPILED_CODE,
    content: @androidx.compose.runtime.Composable () -> kotlin.Unit = COMPILED_CODE
): kotlin.Unit { /* compiled code */
}

关于各个页面之间的导航切换,我是使用的官方扩展的跨平台版本的 navigation 库。定义导航图,然后使用 navController 来切换页面。

val navController = rememberNavController()
NavHost(navController = navController, startDestination = "device_info") {
    composable("device_info") {
        DeviceInfoScreen(navController = navController)
    } 

    ...
}

每次启动应用,DebugManager 应用开屏页面,做的简单的延时跳转,timeout后自动进入主页面。

splash

功能划分

下面简单介绍下各个页面的调试功能有哪些。

在公司,一般的开发流程里有产品设计,有交互设计,UI设计,给我传达需求,输出资源。

  1. 产品的功能设计上,这个软件自己心血来潮要做。我结合日常工作中的调试痛点,还参考了 adb 的命令介绍,选取了一些热门的组合功能和单次功能,分类添加到了界面内。
  2. 在界面UI设计风格上,我是直接参考了每天打开的 Android Studio 里的主题插件, Atom One Dark 的颜色风格。

设备信息展示

一进入界面,首页当然是所连接设备的基本信息展示。

device_info

大致的实现思路如下,关于界面状态,先定义 UiState

data class DeviceState(
    val name: String? = null,
    val manufacturer: String? = null,
    val sdkVersion: String? = null,
    val systemVersion: String? = null,
    val buildType: String? = null,
    val innerName: String? = null,
    val resolution: String? = null,
    val density: String? = null,
    val cpuArch: String? = null,
    val serial:String? = null,
    val isConnected: Boolean = false
) {
    fun toUiState() =
        DeviceState(
            name = name,
            systemVersion = systemVersion,
            manufacturer = manufacturer,
            sdkVersion = sdkVersion,
            buildType = buildType,
            innerName = innerName,
            resolution = resolution,
            cpuArch = cpuArch,
            density = density,
            serial = serial,
            isConnected = isConnected
        )
}

定义好界面所需要展示的字段,再在StateHolder里维护一个StateFlow,同时对界面层暴露一个只读的字段,用于刷新界面数据。

// 单个设备信息
private val _deviceState = MutableStateFlow(DeviceState())
val deviceStateStateFlow = _deviceState.asStateFlow()

进来界面后,在协程中获取数据,界面拿到update后的数据之后自动更新信息:

   CoroutineScope(Dispatchers.IO).launch {
                prepareEnv()
                val deviceName = .....

                _deviceState.update {
                    it.copy(
                        name = deviceName,
                        manufacturer = manufacturer,
                        sdkVersion = sdkVersion,
                        systemVersion = systemVersion,
                        buildType = buildType,
                        density = displayDensity,
                        innerName = innerName,
                        resolution = displayResolution,
                        cpuArch = architecture,
                        serial = serialNum
                    )
                }
                _deviceState.value = _deviceState.value.toUiState()
                // 初始化获取文件列表
                getFileList()
            }

右侧的一堆按钮,是一些高频使用的功能。

简单的像reboot,root等,还有使用am打开隐藏app的界面,使用perfetto抓取trace,自动拉取到电脑端。

其中执行qnx命令为车机特有,现在市面上车机Android大多是运行在QNX系统上的子系统,DebugManager还可以直接桥接到QNX系统,执行更底层更精准的命令,比如执行reset重启整个IVI系统,而不只是reboot重启Android子系统。

录屏,截屏很实用,不用掏出手机到处找角度。我们提前设置好时长,通过自动执行多条指令,等操作完毕,可以直接将截屏录屏文件导出到电脑进行分享,也是我认为最好用的功能之一。

最下面还有一些基础的音量加减,模拟输入法输入等。

轮询查询机制

值得一提的是,我加入了循环获取连接设备数量和当前连接状态的机制,当电脑端的adb服务一初始化成功,就立即开启一个死循环的协程,每2s会查询一次当前设备的连接状态,设备数量。

 private fun recycleCheckConnection() {
        CoroutineScope(Dispatchers.IO).launch {
            while (true) {
                delay(2000L)
                runCatching {
                    // 通过系统命令,检索连接设备的数量是否变化
                    val deviceCount = ....
                    if (deviceCount != _deviceMapState.value.deviceMap.size) {
                        getDeviceMap()
                        MainScope().launch {
                            delay(800L)
                            getCurrentDeviceInfo()
                        }
                    }

                    // 检索当前设备连接状态
                    val result = ....
                    // 从断开到成功连接,主动刷新一次设备信息
                    if (!isConnected) {
                        getCurrentDeviceInfo()
                    }
                    isConnected = true
                    _deviceState.update {
                        it.copy(
                            isConnected = true,
                        )
                    }
                    _deviceState.value = _deviceState.value.toUiState()
                }.onFailure { error ->
                    LogUtils.printLog("${error.message}", LogUtils.LogLevel.ERROR)
                    isConnected = false
                    _deviceState.update {
                        it.copy(
                            isConnected = false,
                        )
                    }
                    _deviceState.value = _deviceState.value.toUiState()
                }
            }
        }
    }
  1. 当增减设备时,刷新设备列表,左上角展开后可以选择不同的设备进行调试。
  2. 当现在操作的设备断开连接时,会自动切换成其他设备,如果没有其他设备,就弹出警告弹窗,不允许继续操作页面了。

这两个都是轮询的。所以在重新连接设备后,会将当前状态通过state发送到界面,警告弹窗会自动消失。

软件安装管理

这个功能是耗时最长的板块之一,主要是Android系统里面每个包的信息如何展示,如何进一步对其进行替换,结合工作中积累的命令,在全网收集了很多指令,来实现软件包的管理功能。

APP列表加入了全部包扫描和三方包扫描,对于公司定制的包,也添加到了单独的筛选规则,可以自由选择查看全量信息和精简信息。

app_manage

最上面是安装功能,是使用adb install进行的操作,适合第三方app进行验证时,或者改bug进行非正式环境的验证时使用。下拉框展开后,可以选择覆盖安装,测试安装等,对应-r,-t等带参数的 install 操作。

界面展示了app的图标,版本号,包名,更新时间等。

应用图标怎么拿到的? 网上大多数的方案是说抠出apk,使用apktool解包,找到图标文件,再拿来显示。可行的确可行,但是这个速度要等到天荒地老了。

因为我之前做过一个Android端的简单的app管理应用,我选择的路线是,提前在AndroidStudio里开发一个服务app,里面设置一个Service,启动后扫描所有的已安装的app,将应用图标,应用label,包名都存到Android本地。再将这个apk内置到DebugManager安装目录的resources目录下,将其安装进系统,准备好资源后,通过adb pull拉出所需要的资源到电脑端,再读取png文件来显示到界面上。

单个app的操作

app_manage

对于选中的单个app,提供了打开应用界面,卸载,提取apk,对于系统应用,还可以push替换apk等操作。我们的测试同事在做非全量的发版验证时非常有用,不用再使用一条条繁琐的命令来替换apk升级了。

文件管理器

由于我在Android端也没有写过文件管理器应用,所以在这个页面,有些操作也是一拍脑袋想出来的,可能不算规范的解法。仍然是MVI架构,界面去监听StateHolder里面的UiState的Flow,切换目录时重新获取列表数据,update到界面来刷新UI。

file_manage

最开始的展示列表我是直接执行了”ls /”将列表发送到界面,显示根目录,解析出其中的文件文件夹,继续往子目录的话就把路径拼接起来,比如进入sdcard,就执行”ls /sdcard”,继续深入则再次拼接。同时最上方设置了返回上级,回到根目录和priv-app快捷按钮。

展示文件列表的就是@Composable LazyComuln方法。

有意思的是,我在加入item的双击和单击的区分时,最初想给Modifier定义一个扩展方法,直接实现双击回调。但是发现必须经过clickable方法来实现,这样会把外部的单机的clickable给挤掉。所以双击判断还是写在了同一个clickable里面,通过时间间隔判断的工具类来区分,单击则选中对应的文件/文件夹,双击则进入文件夹。

modifier = Modifier.clickable {
    // 点击则设置即将操作的path
    MainStateHolder.setSelectedFilePath(it.path)
    androidSelectedFile = MainStateHolder.selectedFilePath
    // 双击,执行操作
    if (DoubleClickUtils.isFastDoubleClick()) {
        if (it.isDirectory)
            destinationCall(it.path)
        else
            println("点击文件:${it.path}")
    }
}

android内的文件操作也是使用命令行的形式,cp mv rm等。

还可以将文件pull到电脑端,将电脑端的文件推送到Android端等。

命令模式

cmd

这一页比较简单,大家看到的输入框也是Compose原生的TextField方法,还自带动画,性价比蛮高。

主要实现就是将输入框的内容,拼接后直接通过Runtime.getRuntime().exec(command)执行即可。

除了最基础的adb命令透传,配合系统厂商Android端的可执行二进制程序,可以模拟车载信号的回调操作。还有语音部门的通过广播来调试的路径,整合到了DebugManager里面,一键发送广播,模拟可见扫描的点击。

关于页

about

最后就是关于页了,显示软件版本,缓存文件目录等。通过PlatformAdapter工具类获取路径,执行打开界面即可。

开源计划

这个软件最初是基于公司业务来设计开发的,有关于公司内部的信息需要抹除。 等后续有时间我会将其功能进行略微删减,改成通用性质的Android调试工具之后,会开源到Github。对CMP跨平台感兴趣的朋友,可以加关注稍作等待,后面一起进行技术交流。

12月25日已完成剥离修改开源: DebugManager开源地址

Material Design主题切换

目前进一步导入了两套主题方案,深色和浅色。

将最高级的 Composable 可组合项使用 MaterialTheme 包裹起来,初始化获取theme的值。主题值的下发设置在了 关于页面 ,操作后的存储使用跨平台版本的 DataStore 来做键值对存储。

并通过StateHolder管理器来维护这个主题状态。在切换之后,最顶级的 Composable 组合项可以立即作出反应,切换色值资源。

 MaterialTheme(
            colors = when (themeState.value) {
                ThemeState.DARK -> DarkColorScheme
                ThemeState.LIGHT -> LightColorScheme
                else -> if (isSystemInDarkTheme()) DarkColorScheme else LightColorScheme
            }
        ) {
            SplashScreen {
                XXXXXXXXXXX
            }
        }

MainStateHolder.kt

// 主题
    private val _themeState = MutableStateFlow(ThemeState.DEFAULT)
    val themeStateStateFlow = _themeState.asStateFlow()
    private val themePreferencesKey = stringPreferencesKey("ThemeState")

    /**
     * 下发主题切换,存储在dataStore中
     */
    fun setThemeState(themeState: Int) {
        CoroutineScope(Dispatchers.IO).launch {
            dataStoreHelper.dataStore.edit {
                it[themePreferencesKey] = themeState.toString()
            }
        }
        _themeState.update {
            themeState
        }
    }

    /**
     * 获取本地存储的主题
     */
    fun getThemeState() {
        CoroutineScope(Dispatchers.IO).launch {
            dataStoreHelper.dataStore.data.collect {
                val themeState = it[themePreferencesKey]?.toInt() ?: ThemeState.DARK
                LogUtils.printLog("getThemeState-> themeState:$themeState", LogUtils.LogLevel.INFO)
                _themeState.update {
                    themeState
                }
            }
        }
    }

DataStoreHelper.kt

class DataStoreHelper {

    lateinit var dataStore: DataStore<Preferences>

    fun init(path: String) {
        dataStore = createDataStore(path)
    }

    private fun createDataStore(path: String): DataStore<Preferences> {
        return PreferenceDataStoreFactory.createWithPath(
            corruptionHandler = null,
            migrations = emptyList(),
            produceFile = { path.toPath() }
        )
    }
}

Main.kt

    val themeState = mainStateHolder.themeStateStateFlow.collectAsState()

    LaunchedEffect(Unit) {
        // 获取存储的主题设置
        mainStateHolder.getThemeState()
    }

运行截图记录

开屏动画

splash

splash

设备信息

device

device

关于页

about

about

【跨平台】Flutter & RN & CMP三种跨平台方案对比

【跨平台】Flutter & RN & CMP三种跨平台方案对比

本文介绍了三种比较热门的跨平台技术的横向对比,涉及性能,易用性,开发成本等方面

历史背景

Flutter

Flutter的历史最早可以追溯到2014年10月,其前身是Google内部孵化的Sky项目。其是一款跨平台移动应用开发框架,它允许开发者使用单一代码库同时构建iOS和Android应用。Flutter采用了Dart编程语言,这是一种面向对象的、类型安全的编程语言,与JavaScript非常相似。Flutter的主要优势在于其快速的开发速度和流畅的用户体验。

具体的:

  • 2014.10 - Flutter的前身Sky在GitHub上开源;
  • 2015.10 - 经过一年的开源,Sky正式改名为Flutter;
  • 2017.5 - Google I/O正式向外界公布了Flutter,这个时候Flutter才正式进去大家的视野;
  • 2018.6 - 距5月Google I/O 1个月的时间,Flutter1.0预览版;
  • 2018.12 - Flutter1.0发布,它的发布将大家对Flutter的学习和研究推到了一个新的起点;
  • 2019.2 - Flutter1.2发布主要增加对web的支持。

React Native

React Native是Facebook于2015年发布的一款跨平台移动应用开发框架,它允许开发者使用JavaScript和React来构建iOS和Android应用。React Native的主要优势在于其灵活的组件化开发方式和丰富的第三方库支持。

js语言和React

JavaScript是一种动态类型的、解释型的、基于原型的、多范式的编程语言。它最初由Netscape公司开发,后来被许多公司采用,包括Google、Microsoft、Facebook等。JavaScript的主要优势在于其跨平台的特性和丰富的第三方库支持。

React是一种用于构建用户界面的JavaScript库,它采用了组件化的开发方式,使得开发者可以将用户界面分解为多个可重用的组件。React的主要优势在于其高效的渲染机制和灵活的组件化开发方式。

Compose Multiplatform

Compose Multiplatform是JetBrains于2021年发布的一款跨平台移动应用开发框架,它允许开发者使用Kotlin和Jetpack Compose来构建iOS和Android应用。Compose Multiplatform的主要优势在于其简洁的语法和强大的UI组件库。

Kotlin语言

Kotlin是一种静态类型的、基于JVM的编程语言,它与Java非常相似,但是它的语法更加简洁和灵活。Kotlin的主要优势在于其静态类型的特性和空安全的特性。Kotlin最强大的实际上是他的编译器,可以将Kotlin代码编译为Java字节码,从而可以在Java虚拟机上运行,也可以编译成js代码,从而可以在浏览器上运行等等。

开发流程

Flutter

Flutter的开发流程,开发者需要使用Dart语言编写应用程序,然后使用Flutter SDK进行编译和打包。

Flutter的开发流程包括以下几个步骤:

  • 编写Dart代码:开发者使用Dart语言编写应用程序的业务逻辑和界面。
  • 编译和打包:开发者使用Flutter SDK进行编译和打包,生成iOS和Android应用程序。
  • 运行应用程序:开发者可以使用模拟器或真机运行应用程序。

React Native

React Native的开发流程相对复杂,开发者需要使用JavaScript和React编写应用程序,然后使用React Native CLI进行编译和打包。

React Native的开发流程包括以下几个步骤:

  • 编写JavaScript代码:开发者使用JavaScript和React编写应用程序的业务逻辑和界面。
  • 编译和打包:开发者使用React Native CLI进行编译和打包,生成iOS和Android应用程序。
  • 运行应用程序

Compose Multiplatform

Compose Multiplatform的开发流程相对简单,开发者只需要使用Kotlin和Jetpack Compose编写应用程序,然后使用Compose Multiplatform CLI进行编译和打包。Compose Multiplatform的开发流程包括以下几个步骤:

  • 编写Kotlin代码:开发者使用Kotlin和Jetpack Compose编写应用程序的业务逻辑和界面。
  • 编译和打包:开发者可以选择使用Android Studio或者IDEA ItelliJ进行编译和打包,生成iOS和Android应用程序。
  • 运行应用程序

实现原理

Flutter

Flutter的框架图如下:

FLutter Engine 这是一个纯 C++实现的 SDK,其中囊括了 Skia引擎、Dart运行时、文字排版引擎等。 简单来说它就是一个 dart 运行时,可以以 JIT(动态编译) 或者 AOT(静态编译) 的方式运行 dart 代码。

Flutter Framework 最上层应用,我们的应用都是围绕这层来构建,所以该层也是我们打交道最多的层。 改层是一个纯 Dart实现的 SDK,类似于 React在 JavaScript中的作用。它实现了一套基础库, 用于处理动画、绘图和手势。并且基于绘图封装了一套 UI组件库,然后根据 Material 和Cupertino两种视觉风格区分开来。

  • 【Foundation】 在最底层,主要定义底层工具类和方法,以提供给其他层使用。
  • 【Animation】是动画相关的类,一些动画相关的都在该类中定义。
  • 【Painting】封装了 Flutter Engine 提供的绘制接口,例如绘制缩放图像、插值生成阴影、绘制盒模型边框等。
  • 【Gesture】提供处理手势识别和交互的功能。
  • 【Rendering】是框架中的渲染库。控件的渲染主要包括三个阶段:布局(Layout)、绘制(Paint)、合成(Composite)。
  • 【Widget】控件层。所有控件的基类都是 Widget,Widget 的数据都是只读的, 不能改变。
  • 【Material】&【Cupertino】这是在 Widget 层之上框架为开发者提供的基于两套设计语言实现的 UI 控件,可以帮助我们的 App 在不同平台上提供接近原生的用户体验。

Dart内存分配机制

DartVM的内存分配策略非常简单,创建对象时只需要在现有堆上移动指针,内存增长始终是线形的,省去了查找可用内存段的过程。

Dart中类似线程的概念叫做Isolate,每个Isolate之间是无法共享内存的,所以这种分配策略可以让Dart实现无锁的快速分配。

Dart单线程异步原理

对于移动端的交互来说,大多数情况下都是在等待状态,等待网络请求,等待用户输入等.那么设想一下,发起一个网络请求只在一个线程中可以进行吗?当然网络请求肯定是异步的(注意这里说的异步而多线程并非一个概念.),事实验证是可以的,Flutter就采用了Dart这种单线程机制,省去了多线程上下文切换带来的性能损耗.(对于高耗时操作,也同样支持多线程操作,通过Isolate开启,不过注意这里的多线程,内存是无法共享的.)

当一个Dart的方法开始执行时,他会一直执行直至达到这个方法的退出点。换句话说Dart的方法是不会被其他Dart代码打断的。 当一个Dart应用开始的标志是它的main isolate执行了main方法。当main方法退出后,main isolate的线程就会去逐一处理消息队列中的消息。

有了消息队列,然后有了循环去读取消息队列中的消息,就可以有单线程去执行异步消息的能力。一般的消息使用dart:async中使用Future来支持异步消息。

Flutter绘制

一般地来说,计算机系统中,CPU、GPU和显示器以一种特定的方式协作:CPU将计算好的显示内容提交给 GPU,GPU渲染后放入帧缓冲区,然后视频控制器按照 VSync信号从帧缓冲区取帧数据传递给显示器显示。

由于最终的图形计算和绘制都是由相应的硬件来完成,而直接操作硬件的指令通常都会有操作系统屏蔽,应用开发者通常不会直接面对硬件,操作系统屏蔽了这些底层硬件操作后会提供一些封装后的API供操作系统之上的应用调用。

但是对于应用开发者来说,直接调用这些操作系统提供的API是比较复杂和低效的,因为操作系统提供的API往往比较基础,直接调用需要了解API的很多细节。 正是因为这个原因,几乎所有用于开发GUI程序的编程语言都会在操作系统之上再封装一层,将操作系统原生API封装在一个编程框架和模型中,然后定义一种简单的开发规则来开发GUI应用程序。

例如:

Android SDK 正是封装了Android操作系统API,提供了一个“UI描述文件XML+Java操作DOM”的UI系统。iOS的UIKit 对View的抽象也是一样的,他们都将操作系统API抽象成一个基础对象(如用于2D图形绘制的Canvas),然后再定义一套规则来描述UI,如UI树结构,UI操作的单线程原则等。

Flutter只关心向 GPU 提供视图数据,GPU的 VSync信号同步到 UI 线程,UI线程使用 Dart 来构建抽象的视图结构,这份数据结构在 GPU 线程进行图层合成,视图数据提供给 Skia 引擎渲染为 GPU 数据,这些数据通过 OpenGL或者 Vulkan提供给 GPU。

所以 Flutter 并不关心显示器、视频控制器以及 GPU 具体工作,它只关心 GPU发出的 VSync 信号,尽可能快地在两个 VSync 信号之间计算并合成视图数据,并且把数据提供给GPU。Flutter的原理正是如此,它提供了一套Dart API,然后在底层通过skia这种跨平台的绘制库(内部会调用操作系统API)实现了一套代码跨多端。因此,组件更新(例如,iOS 16)对 Flutter 应用程序没有任何影响,但对 React Native 应用程序有影响。

Google官网的渲染流程示意图:

Flutter的测量布局

Flutter 采用约束进行单次测量布局. 整个布局过程中只需要深度遍历一次,极大的提升效能。

渲染对象树中的每个对象都会在布局过程中接受父 对象的 Constraints 参数,决定自己的大小, 然后父对象 就可以按照自己的逻辑决定各个子对象的位置,完成布局过程. 子对象不存储自己在容器中的位置, 所以在它的位置发生改变时并不需要重新布局或者绘制. 子对象的位 置信息存储在它自己的 parentData 字段中,但是该字段由它的父对象负责维护,自身并不关心该字段的内容。 同时也因为这种简单的布局逻辑, Flutter 可以在某些节 点设置布局边界 (Relayout boundary), 即当边界内的任 何对象发生重新布局时, 不会影响边界外的对象, 反之亦然。

React Native

DOM

文档对象模型(Document Object Model,DOM)是针对 HTML 和 XML 文档的一个编程接口。它将网页文档呈现为结构化的对象树,让程序和脚本能够动态地访问、修改文档的内容、结构和样式。

DOM 把整个文档看作是由节点(Node)构成的树形结构。每个节点代表文档中的一个部分,比如元素、属性、文本等,这些节点相互关联,形成了一个层次分明的树状结构。

在浏览器环境中,可以使用 JavaScript 来操作 DOM。以下是一些常见的 DOM 操作示例:

<!DOCTYPE html>
<html>
<head>
    <title>DOM 操作示例</title>
</head>
<body>
    <h1 id="heading">原始标题</h1>
    <button id="changeBtn">修改标题</button>
    <script>
        // 获取元素节点
        const heading = document.getElementById('heading');
        const changeBtn = document.getElementById('changeBtn');

        // 为按钮添加点击事件
        changeBtn.addEventListener('click', function() {
            // 修改元素的文本内容
            heading.textContent = '修改后的标题';
            // 修改元素的样式
            heading.style.color = 'red';
        });
    </script>
</body>
</html>

在这个示例里,借助 document.getElementById 方法获取元素节点,再用 textContent 修改元素文本内容,style 修改元素样式,addEventListener 绑定点击事件。这些都是典型的 DOM 操作。

React库原理

先简单了解下 React 的工作原理。React 是一个用于构建用户界面的 JavaScript 库,核心工作原理可概括为组件化开发、虚拟 DOM 和协调算法三个方面。

React 采用组件化的开发方式,开发者能将用户界面拆分成多个可复用的组件。每个组件都有独立的状态(state)和属性(props),并且可以管理自身的逻辑和渲染。

虚拟 DOM(Virtual DOM) 是 React 的核心概念之一,它是真实 DOM 的轻量级副本,以 JavaScript 对象的形式存在于内存中。当组件的状态或属性发生变化时,React 会先在虚拟 DOM 上进行修改,计算出与之前虚拟 DOM 的差异(Diff)。React 利用 协调算法 对比新旧虚拟 DOM 的差异,找出需要更新的最小 DOM 操作集合,然后只对真实 DOM 进行这些必要的更新。这样可以减少直接操作真实 DOM 的次数,提高性能。

React Native架构

基于Bridge的架构

示意图:

  • 在开发阶段仍然是聚焦于React Components的开发,Babel会将代码编译成浏览器可识别的表达式,并打包成jsbundle文件存储于App设备本地或者存储于服务器(热更新机制)
  • 打开App后,加载并解析jsbundle文件,在JavascriptCore中进行运行,这个地方Android和IOS的差异就是,IOS原生就带有一个JavascriptCore,而Android中需要重新加载,所以这也造成了Android的初始化过程会比IOS慢一些。
  • 运行时需要将前端的组件渲染成Native端的视图,首先如同React中的虚拟DOM一样,在Bridge中也会构造出一个Shadow Tree,然后通过Yoga布局引擎将Flex布局转换为Native的布局,最终交由UIManager在Native端完成组件的渲染。
  • Bridge架构对于开发者来说很好的屏蔽了各个平台之间的差异,相对于WebView也能够提供不错的近原生操作体验。但是Javascript与Native之间的通信过度的依赖Bridge,当交互频繁或数据量很大的时候可能造成白屏或事件阻塞。而且JSON的序列化操作的效率也比较低。
Bridge

Bridge 顾名思义就是 JS 和 Native 通信的一个桥梁, 所有的本地存储、图片资源访问、图形绘制、3D加速、网络访问、震动效果、NFC、原生控件绘制、地图、定位、通知等等很多功能都是由 Bridge 封装成 JS 接口以后注入 JS Engine 供 JS 调用。

每一个支持 RN 的原生功能必须有同一个原生模块和一个 JS 模块, JS 模块方便调用其接口, 原生模块去根据接口调用原生功能实现原生效果。 Bridge 原生代码负责管理原生模块并能够方便的被 React 调用, 每个功能 JS 封装主要是对 React 做适配, JS 和 Native 之间不存在任何指针传递, 所有的参数均由字符串传递。

重要组件MessageQueue

RN 是不用 JS 引擎的 UI 渲染控件的, 但是会用到 JS 引擎的 DOM 操作管理能力来管理所有 UI 节点, 每次在写完 UI 组件代码后会交给 yoga 去做布局排版, 然后调用原生组件绘制 MessageQueue 负责跳出 JS 引擎, 记录原生接口的地址和对应的 JS 函数名, 然后在 JS 调用该函数的时候把调用转发给原生接口

双端差异:JS 和 IOS 通信用的是 JavaScript Core。 JS 和 Android 通信用的是 Hermes。

RN 主要有 3 个线程

JS Thread 执行线程, 负责逻辑层面的处理, Metro 将 React 源码打包成 bundle 文件, 然后传给 JS 引擎执行, 现在 IOS 和 Android 统一的是 JSC UI Thead 主要主责原生渲染 Native UI 和调用原生能力 (Native Module) Shadow Thead 这个线程主要创建 Shadow Tree 来模拟 React 结构树, RN 使用 Flexbox 布局, 但是原生不支持, Yoga 引擎就是将 Flexbox 布局转换为原生布局的。

基础概念

  • UIManager: 在 Native 里只有它才有权限调用客户端UI
  • JS Thread: 运行打包好的 bundle 文件, 这个文件就是我们写完代码去进行打包的文件, 包含了业务逻辑, 交互和模块化组件
  • Shadow Node: Native 的一个组件树, 可以监听 App 里的 UI 变化, 类似于虚拟 DOM 和 DOM
  • Yoga: Fackbook 开源的一个布局引擎, 用来把 Flexbox 的布局转换为 Native 的布局
运行流程
  1. 用户点击 App 图标
  2. UIManager 线程: 加载所有 Native 库和 Native 组件比如 Images, View, Text
  3. 告诉 JS 线程, Native 部分准备好了, JS 侧开始加载 bundle 文件
  4. JS 通过 Bridge 发送一条 Json 数据到 Native , 告诉 Native 怎么创建 UI, 所有的 Bridge 通信都是异步的, 为了避免阻塞 UI
  5. Shadow 线程最先拿到消息, 创建 UI 树
  6. Yoga 引擎获取布局并转为 Native 的布局
  7. 之后 UI Manager 执行一些操作展示 Native UI
Brige的缺点

有两个不同的领域 JS 和 Native, 他们彼此之间不能相互感知, 也并不能共享相同内存 通信基于 Bridge 的异步通信, 所以并不能保证数据百分百及时传达到另一侧 JSON 传输大数据非常慢, 内存不能共享, 所有的传输都是新的复制 无法同步更新 UI, 比方在渲染列表的时候, 滑动大量加载数据, 屏幕可能会发生卡顿或闪烁 RN 代码仓库很大, 库比较重, 所以修复 Bug 和开源社区贡献代码的效率也相应更慢

引入了JSI的新架构

上层 JavaScript 代码需要一个运行时环境,在 React Native 中这个环境是 JSC(JavaScriptCore)。不同于之前直接将 JavaScript 代码输入给 JSC,新的架构中引入了一层 JSI(JavaScript Interface),作为 JSC 之上的抽象,用来屏蔽 JavaScript 引擎的差异,允许换用不同的 JavaScript 引擎

RN的新版架构图:

  • JSI(Javascript Interface):JSI的作用就是让Javascript可以持有C++对象的引用,并调用其方法,同时Native端(Android、IOS)均支持对于C++的支持。从而避免了使用Bridge对JSON的序列化与反序列化,实现了Javascript与Native端直接的通信。 JSI还屏蔽了不同浏览器引擎之间的差异,允许前端使用不同的浏览器引擎,因此Facebook针对Android 需要加载JavascriptCore的问题,研发了一个更适合Android的开源浏览器引擎Hermes。

  • CodeGen:作为一个工具来自动化的实现Javascript和Native端的兼容性,它可以让开发者创建JS的静态类,以便Native端(Fabric和Turbo Modules)可以识别它们,并且避免每次都校验数据,将会带来更好的性能,并且减少传输数据出错的可能性。

新的 Bridge 层被划分成 Fabric 和 TurboModules 两部分

  • Fabric:相当于之前的UIManager的作用,不同之处在于旧架构下Native端的渲染需要完成一系列的”跨桥“操作,即React -> Native -> Shadow Tree -> Native UI,新的架构下UIManager可以通过C++直接创建Shadow Tree大大提高了用户界面体验的速度。

  • TurboModules:旧架构下由于端与端之间的隔阂,运行时即便没有使用的模块也会被加载初始化,TurboModules允许Javascript代码仅在需要的时候才去加载对应的Native模块并保留对其直接的引用缩短了应用程序的启动时间。

新架构的核心改变就是避免了通过Bridge将数据从JavaScript序列化到Native.

新架构下,打开 App 会发生什么

  1. 点击 App 图标
  2. Fabric 加载 Native 侧
  3. 然后通知 JS 线程 Native 侧准备好了, JS 侧会加载所有的 bundle JS 文件, 里面包含了所有的 JS 和 React 逻辑组件
  4. JS 通过一个 Native 函数的引用, 调用到 Fabric, 同时 Shadow Node 创建一个和以前一样的 UI 树
  5. Yoga 进行布局计算, 把基于 Flexbox 的布局转化为 Native 端的布局
  6. Fabric 执行操作并显示 UI

没有了 Bridge 提升了性能,可以用同步的方式进行操作, 启动时间也快, App 也将更小。

Compose Multiplatform

KMP

首先应该了解Kotlin Multiplatform.

Kotlin 在 Android 世界中广受欢迎,但它并非专为 Android 设计的技术。

Kotlin 的初衷是创建一种通用语言,能够与其他编程语言兼容 ,从而用于构建不同平台(而非仅限于 Android)的应用程序。

所以,Kotlin 从设计上来说就是一门多平台语言。

Kotlin Multiplatform是一种跨平台开发技术,它允许开发者使用Kotlin语言编写代码,并在多个平台上运行,包括iOS、Android、Web、桌面等。不同的平台可以共享相同的代码库,从而减少了开发成本和维护成本。

Kotlin 编译器在Android和IOS上生成对应平台特有文件的流程,它包含以下两部分:

  • 前端- 它将 Kotlin 代码转换为 IR(中间表示)。该 IR 能够通过下文所述的后端转换为机器可执行的原生代码。
  • 后端- 它将 IR 转换为机器可执行的原生代码。这得益于 JetBrains 构建的 Kotlin/Native 基础架构。对于 Android,它将 IR 转换为 Java 字节码;对于 iOS,它将 IR 转换为 iOS 原生机器可执行代码。

支持转译成哪些语言?

Kotlin 编译器将源代码作为输入,并生成一组特定于平台的二进制文件。在编译多平台项目时,它可以从同一份代码生成多个二进制文件。例如,编译器可以从同一个 Kotlin 文件生成 JVM 文件和原生可执行文件。

目前,其中三种语言的支持最为成熟, JetBrains 正在不断努力扩展支持范围。

  • Java:这就是 Kotlin 在Android设备上运行的方式,转为class文件,在JVM平台上运行,但我们也可以在桌面或服务器应用程序中使用它。
  • JavaScript :对js语言的支持,使我们能够在Web应用程序中使用 Kotlin ,包括前端和后端应用程序。
  • C / Objective-C:这样,我们就可以访问所有基于Linux的平台和 Apple 操作系统,例如iOS设备、iPadOS、macOS、tvOS和watchOS 。而且由于 Objective-C 可以与 Swift 兼容,因此我们也可以在Swift项目中使用 Kotlin 。

并非所有 Kotlin 代码都能编译到所有平台。Kotlin 编译器会阻止您在通用代码中使用特定于平台的函数或类,因为这些代码无法编译到其他平台。

例如,您无法使用java.io.File公共代码中的依赖项。它是 JDK 的一部分,而公共代码也会编译为本机代码,而 JDK 类在本机代码中不可用。

Kotlin的业务逻辑代码向目标平台的代码转换,是在编译器中进行的。所以在应用程序上架之前,用于分发的软件包里面,实际上和目标平台的Native应用程序没有任何区别。

KMP开发模式

如果某些功能无法在通用代码中实现,可以使用KMP独特的 expect / actual 声明机制,在commonMain中声明需要实现的功能,在原生平台的代码中使用系统特有的实现。

如果我们想使用某些系统 API 或原生工具,我们也可以直接到原生文件夹中写native平台代码。这种高度灵活性使得 KMP 的风险比其他解决方案更低。

Kotlin官方文档推荐的几种usecases

比较流行KMP应用模式一般是将网络请求,数据库存储等使用KMP改写,在UI逻辑上仍然使用之前的代码来实现,这样可以减少重复代码的编写,提高开发效率。有最大限度保留旧的用户交互逻辑和功能。

Skia引擎

Skia是一个用C++编写的开源高性能二维图形库。它本质上是一个图形引擎,为在各种硬件和软件平台上绘制文本、几何图形(形状)和图像提供了通用的 API。

Skia 库的主要特点有:

  • 二维图形:专门绘制二维图形。
  • 跨平台:Skia 可在各种操作系统上无缝运行,包括 Windows、macOS、iOS、Android、Linux(Ubuntu、Debian、openSUSE、Fedora),甚至网络浏览器(通过 WebAssembly)。
  • 开源:由谷歌赞助和管理,Skia 采用 BSD 自由软件许可证,允许任何人使用和贡献。
  • 核心图形引擎:它是许多流行产品的基本图形引擎,包括
    • 谷歌浏览器和 ChromeOS
    • 安卓系统
    • Flutter(谷歌用于构建本地应用程序的用户界面工具包)
    • 火狐浏览器
    • LibreOffice(从 7.0 版开始)
    • 以及其他各种应用程序和框架。

Skia 的核心优势在于它能在各种硬件上高效地渲染这些图形。它通过支持各种后端渲染技术来实现这一目标:

  • GPU 加速: 对于现代设备,Skia 可以利用图形处理器(GPU)进行硬件加速渲染。为此,Skia 可将其内部绘图命令转换为对 GPU 应用程序接口的调用,例如:
    • OpenGL ES / OpenGL: 一种广泛应用于 2D 和 3D 图形的 API。
    • AGLE: 兼容性层,可将 OpenGL ES 调用转换为特定供应商的本地 API(如 Windows 上的 Direct3D 或 macOS 上的 Metal),以获得一致的性能。
    • Vulkan: 一种现代高性能图形和计算 API。
    • Metal: 苹果用于 iOS 和 macOS 的底层图形 API。
  • CPU 软件光栅化: 在 GPU 加速不可用或不可取的情况下(如某些服务器或特定的渲染需求),Skia 可以退回到 CPU 上的软件渲染。这包括直接在 CPU 上将矢量图形光栅化为像素。
  • PDF/SVG 输出: Skia 还可以渲染成 PDF 或 SVG 等格式,这些格式基于矢量,可按比例缩放而不会降低质量。

CMP的绘制

这是 Skia 发挥关键作用的地方。Compose Multiplatform 不会直接使用每个平台原生的 UI 组件(例如 Android 上的 View 或 iOS 上的 UIKit 控件)来绘制。相反,它采取了像素渲染 (pixel-painting) 的方式,这与 Flutter 的工作方式类似。

JetBrains 开发了一个名为 Skiko 的 Kotlin Multiplatform 库。Skiko 是 Skia 的 Kotlin 包装器,它提供了 Kotlin API 来与底层的 Skia 图形库进行交互。

在绘制阶段,Compose Multiplatform 会将布局阶段计算出的 UI 元素的形状、颜色、文本、图片等信息,转化为一系列 Skia 绘制命令。这些命令包括:

  • 绘制矩形、圆形、线条、路径等几何图形。
  • 绘制文本(包括字体、大小、颜色等)。
  • 绘制图片。
  • 应用变换(平移、旋转、缩放)和滤镜效果。

无论是在桌面、iOS 还是 Web 上,Compose Multiplatform 都会将这些 Skia 绘制命令传递给 Skia 库。Skia 再根据目标平台的不同,选择最合适的底层图形 API 进行渲染:

桌面 (Windows, macOS, Linux):, Skia 可以直接利用 OpenGL、Direct3D 或 Vulkan (如果可用) 等 GPU API 进行硬件加速渲染。

iOS: ,Compose Multiplatform 在 iOS 上也使用 Skia 进行画布渲染。这意味着它不会使用 iOS 原生的 UIKit 视图,而是直接通过 Skia 绘制到屏幕上。Skia 会利用 Metal (Apple 的图形 API) 或 OpenGL ES (较旧的 API) 进行渲染。

Android: ,Jetpack Compose (Compose Multiplatform 的 Android 部分) 本身就使用 Skia 作为其底层的渲染引擎。所以,这部分是无缝衔接的。

Web: ,在 Web 平台上,Compose Multiplatform 通常会利用 WebAssembly 和 HTML Canvas 元素。Skia 编译为 WebAssembly 并在 Canvas 上绘制像素。

CMP特有性能优化:

  • 增量渲染: Compose 只有在 UI 状态发生变化时才会重新执行受影响的 Composable 函数,并只更新屏幕上发生变化的部分。
  • GPU 加速: 通过 Skia 及其对底层图形 API 的支持,Compose Multiplatform 能够充分利用 GPU 进行硬件加速渲染,从而实现流畅的动画和高性能的 UI。
  • 缓存: Skia 会在内部进行各种优化,例如图形指令的缓存,以减少重复计算。

总结来说,Compose Multiplatform 绘制组件的流程是:

  1. 开发者通过 Kotlin 的 Composable 函数声明 UI。
  2. Compose 运行时构建 UI 元素的组合树。
  3. Compose 进行测量和布局,确定每个元素的尺寸和位置。
  4. 将 UI 元素转换为 Skia 绘制命令。
  5. 通过 Skiko 库,这些 Skia 绘制命令被传递给底层的 Skia 图形库。
  6. Skia 根据目标平台的特性,利用 GPU (通过 OpenGL/Direct3D/Vulkan/Metal 等) 或 CPU 进行像素渲染,最终将 UI 呈现在屏幕上。

这种方法使得 Compose Multiplatform 能够提供一致的 UI 外观和行为,无论应用程序运行在哪个平台上,同时也能利用平台原生的图形性能。

性能

在比较 Flutter、React Native 和 Compose Multiplatform (CMP) 的性能时,需要考虑它们各自的架构和设计哲学,因为这直接影响了它们的运行时性能。以下是这三者在性能方面的对比:

1. Flutter

架构核心: Flutter 使用 Dart 语言,并拥有自己的渲染引擎,该引擎直接通过 Skia 图形库(在最新版本中,桌面和移动端已逐步转向 Impeller 渲染引擎)绘制 UI。这意味着 Flutter 不依赖于平台原生的 UI 组件。Dart 代码在发布时会被编译为原生机器码 (ahead-of-time, AOT)

性能特点:

  • 接近原生性能: 由于直接编译为机器码并使用自己的渲染引擎,Flutter 在 UI 渲染和动画方面通常能达到与原生应用非常接近的性能。它能够以 60 FPS (甚至 120 FPS) 的流畅度运行复杂动画和高负载 UI。
  • 无桥接开销: Flutter 消除了 JavaScript 桥接的开销,因为 Dart 代码直接与底层平台通信,避免了在 JavaScript 和原生代码之间进行序列化和反序列化的性能瓶颈。
  • 启动时间: 相对于原生应用,Flutter 应用的启动时间可能会略长,因为它需要初始化 Flutter 引擎。然而,Google 正在不断优化这方面。
  • 内存占用: 在某些基准测试中,Flutter 应用的内存占用可能略高于原生应用,因为它捆绑了自己的引擎和渲染器。
  • 包大小: Flutter 应用的包大小通常比原生应用大,因为它包含了 Flutter 引擎和 Dart 运行时。

总结: Flutter 在 UI 渲染和动画流畅度方面表现出色,适合需要复杂、高度定制 UI 和高性能动画的应用。

2. React Native

架构核心: React Native 使用 JavaScript/TypeScript。它不直接绘制 UI,而是通过一个 JavaScript 桥接 (Bridge) 与平台原生的 UI 组件进行通信。当 JavaScript 端更新状态时,通过桥接将指令发送到原生 UI 线程,由原生组件进行渲染。

性能特点:

  • 有桥接开销: 传统的 React Native 架构中,JavaScript 线程和原生 UI 线程之间的通信需要通过桥接,这会引入一定的序列化和反序列化开销,尤其是在频繁更新 UI 或进行大量数据传输时,可能导致性能瓶颈和 UI 卡顿。
  • 原生组件渲染: 优势在于使用原生 UI 组件,能够提供原生的外观和感觉,但在需要高度定制的 UI 或跨平台像素级一致性时,可能需要额外的努力。
  • 新架构 (Fabric & TurboModules): React Native 正在积极推广其“新架构”,其中包含 Fabric 渲染系统TurboModules
    • Fabric: 旨在解决旧桥接的性能问题,通过 C++ 层实现 JavaScript 和原生之间的同步通信,减少了桥接开销,提高了 UI 响应速度和动画流畅度。它也支持并发渲染。
    • TurboModules: 允许原生模块按需加载,从而改善了应用启动时间。
    • JSI (JavaScript Interface): 替换了旧的桥接,允许 JavaScript 直接调用 C++ 代码,从而实现更高效的通信。
  • 启动时间: 相对于原生应用,React Native 应用的启动时间可能较长,尤其是在加载 JavaScript 包时。新架构旨在改善这一点。
  • 内存占用: 内存占用通常介于原生和 Flutter 之间,因为需要 JavaScript 运行时和原生组件。

总结: React Native 在性能方面受 JavaScript 桥接的限制,但在新架构(Fabric、TurboModules、JSI)的推动下,其性能正在显著提升,尤其是在复杂 UI 和动画方面。对于需要快速开发、且对绝对原生性能要求不那么极致的应用,React Native 仍是一个强有力的选择。

3. Compose Multiplatform (CMP)

架构核心: Compose Multiplatform 基于 Kotlin Multiplatform (KMP) 技术,使用 Kotlin 语言。它在 Android 上复用 Google 的 Jetpack Compose,而在其他平台(iOS、桌面、Web)上,通过 Skiko (Skia 的 Kotlin 包装器) 直接调用 Skia 图形库进行 UI 绘制,与 Flutter 的像素渲染方式类似。Kotlin 代码会编译为原生二进制文件。

性能特点:

  • 接近原生性能:
    • Android: 直接使用 Jetpack Compose,其性能与原生 Android UI 相当,并受益于 Android 系统内置的 Skia 库。
    • iOS/桌面: 通过 Skiko/Skia 直接绘制 UI,避免了桥接开销,因此在 UI 渲染和动画方面能达到接近原生应用的性能。
    • AOT 编译: Kotlin 代码可以编译为原生机器码(AOT 编译),进一步提升了运行时性能。
  • 启动时间: 在 Android 上,与 Jetpack Compose 应用类似,启动时间通常良好。在 iOS 上,由于需要捆绑 Skia 库(不像 Android 可以依赖系统内置),可能会增加一点启动时间,但总体上仍然表现优秀。
  • 内存占用: 通常表现良好,与原生应用或 Jetpack Compose 应用类似。
  • 包大小: 在 Android 上,CMP 应用的包大小与 Jetpack Compose 应用类似。在 iOS 和桌面端,由于需要捆绑 Skia 库,包大小会比原生应用略大,但通常比 Flutter 应用小。

总结: Compose Multiplatform 在性能上非常具有竞争力。它在 Android 上直接受益于 Jetpack Compose 的原生整合,而在其他平台则通过 Skia 提供了高性能的像素渲染。对于追求原生性能和统一代码库的 Kotlin 开发者来说,CMP 是一个非常吸引人的选择。

适用场景

对极致性能要求高、或拥有复杂定制 UI 的应用: FlutterCompose Multiplatform 通常是更好的选择。它们通过直接绘制像素来绕过原生组件的限制,提供高度优化的渲染管道。

对开发速度和 Web 开发者友好度有高要求、或希望逐步迁移现有原生应用: React Native (尤其是新架构下) 仍是强有力的竞争者。其庞大的社区和成熟的生态系统也是巨大优势。

对于 Kotlin 开发者、希望最大化代码共享并获得接近原生性能,同时能够方便地与现有原生代码互操作的项目: Compose Multiplatform 提供了非常吸引人的平衡点。

最终的选择取决于你的 项目需求、团队技术栈、以及对性能、开发速度和原生体验 的优先级。随着这三个框架的不断发展和优化,它们之间的 性能差距也在逐渐缩小

外部讨论

在热门论坛Reddit上,某篇帖子如下: Compose Multiplatform 与 Flutter

您好,我正在决定将我的事业重心放在这两者之间: Dart(Flutter)VS Kotlin(KMP 和 CMP) 因为我也想做独立移动应用程序,但同时也想担任移动工程师一职。 我的工作地点在美国,所以这里的 Flutter 职位比较少。 我知道 CMP 在 iOS 上还不稳定,但它是未来的趋势吗? 我喜欢在 Ktor 的后端也可以使用 Kotlin。 但 Flutter 有生态系统和热重载功能,所以我很纠结到底要继续使用哪一种……

网友1:

您正在询问 kotlin Reddit,所以这里会有一些偏见。 但抛开这些不谈,学习 KMP 比学习 Flutter 更接近学习原生 Android。这就是关键优势。安卓开发者可以轻松地将他们的知识迁移到 Kotlin Multiplatform,Compose 与他们在安卓中使用的完全相同,因此可以相互映射 与 KMP 相比,Flutter 有 2-3 年的先发优势,而且拥有更成熟的生态系统。但是,您将在 Android 和 iOS SDK 的基础上学习 Flutter SDK,而原生开发人员的知识迁移学习曲线更大。

网友2:

使用 Compose 的 Kotlin 多平台令人惊叹。不过我不确定是否有适合它的工作,如果你正在考虑的话。这是更新颖的技术。但通过它,会有 Android 的机会。

网友3:

我使用过 jetpack compose 和 flutter,flutter 非常缺乏优秀的库,而且创建一个小部件需要大量的模板,令人厌恶。 你最好还是学习这两个平台的真正原生程序,我发现 Flutter 从未真正解决开发 iOS 应用程序的痛苦。 从安全角度来看,Flutter 也有点弱,因为你无法真正控制许多东西与操作系统的交互方式(如安全存储)或字符串的永久性,而且大多数代码扫描程序都不包括 dart 或 pub 包。 你也得不到一个合适的集成开发环境,flutter 支持是在 android studio 上附加的。 试着在 flutter 中做一个懒列表,然后再在 jetpack compose 中做,这就是它们生态系统的完美体现。

Me: 就我个人的情况,专业为Android开发,对于Kotlin和Jetpack Compose的写法,架构设计,已经是比较熟悉了,如果有做跨平台的需求,在业务不是太复杂的情况下,使用CMP几乎是最佳选择。所以这个跨平台的能力对于熟悉这两个技术的Android开发可以说是买一送一,拿起电脑,稍微看看文档就可以写功能。

国内已经有Bilibili,快手在使用KMP来重构自己的产品,腾讯甚至基于CMP自己改了一套Kuikly来适配鸿蒙平台,所以站在发展的角度看,我认为CMP日后的成熟度和公司接受度,说不定可以超过Flutter。

【跨平台】Compose Multiplatform开发记录之输入框问题

【跨平台】Compose Multiplatform开发记录之输入框问题

本文介绍了CMP开发过程中,TextField可组合项对Enter按键监听不符合预期的问题

此前发过一篇文章介绍了我开发的Desktop端端跨平台Android设备调试软件——DebugManager。

包含了基础设备信息,应用管理,文件管理,性能监测,主题切换等。

blogs_dark_deviceinfo.png

本次记录问题点

记录为开发AI大模型对话功能页面中,对TextField输入框回车键监听问题的解决。

页面如下:

Snipaste_2025-03-04_20-47-00.png

普通用户在电脑程序中对于输入框的期望,就是按Enter键可以直接确认,按Alt+Enter可以输入换行符。

第一版——基础输入功能

对 Compose 官方的 TextField 可组合项进行简单封装:

@Composable
fun WrappedEditText(
    value: String,
    onValueChange: (String) -> Unit,
    tipText: String,
    modifier: Modifier = Modifier
) {
    TextField(
        value = value,
        textStyle = infoText,
        colors = TextFieldDefaults.textFieldColors(
            textColor = MaterialTheme.colors.onPrimary,
            cursorColor = MaterialTheme.colors.onPrimary,
            focusedIndicatorColor = MaterialTheme.colors.onPrimary,
            unfocusedIndicatorColor = MaterialTheme.colors.onSecondary
        ),
        label = { Text(tipText, color = MaterialTheme.colors.onSecondary) },
        onValueChange = { onValueChange(it) },
        modifier = modifier
            .widthIn(max = 200.dp, min = 100.dp)
            .clip(RoundedCornerShape(10.dp))
            .background(MaterialTheme.colors.secondary)
            .border(2.dp, MaterialTheme.colors.onSecondary, RoundedCornerShape(10.dp)),
    )
}

外部调用的时候,通过维护一个mutableStringState,和这里的onValueChange配合,来进行TextField显示内容和实际字符串变量的更新。

@Composable
fun AiModelPage() {
    BasePage("AI大模型对话") {
        val mainStateHolder by remember { mutableStateOf(GlobalContext.get().get<MainStateHolder>()) }

        val toastState = rememberToastState()

        val userInputSting = remember { mutableStateOf("") }

        WrappedEditText(
            value = userInputSting.value,
            tipText = "输入对话文字",
            onValueChange = { userInputSting.value = it },
            modifier = Modifier.padding(start = 10.dp, end = 10.dp).weight(1f),
        )
        CommonButton(
            "发送", onClick = {
                if (userInputSting.value.isEmpty()) {
                    toastState.show("请先输入对话内容")
                } else {
                    mainStateHolder.chatWithAI(userInputSting.value)
                    userInputSting.value = ""
                }
            },
            modifier = Modifier.padding(10.dp)
        )
    }
}

只有点击来发送按钮后,才会将对话内容发给大模型。

第二版——加入Enter事件回调

为了实现按下 Enter 按键就可以发送消息,我在Modifier修饰符参数里加入了对Enter的 KeyEvent 监听:

@Composable
fun WrappedEditText(
    value: String,
    onValueChange: (String) -> Unit,
    tipText: String,
    modifier: Modifier = Modifier,
    onEnterPressed: () -> Unit = {}
) {
    val focusRequester = remember { FocusRequester() }
    TextField(
        value = value,
        textStyle = infoText,
        colors = TextFieldDefaults.colors(
            focusedTextColor = MaterialTheme.colorScheme.onPrimary,
            cursorColor = MaterialTheme.colorScheme.onPrimary,
            focusedIndicatorColor = MaterialTheme.colorScheme.onPrimary,
            unfocusedIndicatorColor = MaterialTheme.colorScheme.onPrimary
        ),
        label = { Text(tipText, color = MaterialTheme.colorScheme.onSecondary) },
        onValueChange = { onValueChange(it) },
        modifier = modifier
            .widthIn(max = 200.dp, min = 100.dp)
            .clip(RoundedCornerShape(10.dp))
            .background(MaterialTheme.colorScheme.secondary)
            .border(2.dp, MaterialTheme.colorScheme.onSecondary, RoundedCornerShape(10.dp))
            .focusRequester(focusRequester)
            .onKeyEvent {
                if (it.key == Key.Enter) {
                    onEnterPressed()
                    return onKeyEvent true
                }
                false
            },
    )
}

在监测到Enter键按下时,执行外部的onEnterPressed这个Lambda块,外部调用配置的时候,在这里执行和点击右侧的发送按钮一样的逻辑。

问题就是,最后的这个换行符,连同输入的内容一起被添加到了输入框的UI,还有对话气泡中去了。

huanhang.png

第三版——AI提供的传参数方案

查看官方文档,提供的几个api都会和上面那个按键监听策略一样的问题,换行符和内容混到了一起。

询问Gemini给出了一个方法,通过自定义 keyboardOptionskeyboardActions 两个参数,并在keyboardActions的onDone回调里调用onEnterPressed代码块。

@Composable
fun WrappedEditText(
    value: String,
    onValueChange: (String) -> Unit,
    tipText: String,
    modifier: Modifier = Modifier,
    onEnterPressed: () -> Unit = {}
) {
    val focusRequester = remember { FocusRequester() }

    TextField(
        value = value,
        textStyle = infoText,
        colors = TextFieldDefaults.colors(
            focusedTextColor = MaterialTheme.colorScheme.onPrimary,
            cursorColor = MaterialTheme.colorScheme.onPrimary,
            focusedIndicatorColor = MaterialTheme.colorScheme.onPrimary,
            unfocusedIndicatorColor = MaterialTheme.colorScheme.onPrimary
        ),
        label = { Text(tipText, color = MaterialTheme.colorScheme.onSecondary) },
        onValueChange = { onValueChange(it) },
        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
        keyboardActions = KeyboardActions(
            onDone = {
                onEnterPressed()
            }
        ),
        modifier = modifier
            .widthIn(max = 200.dp, min = 100.dp)
            .clip(RoundedCornerShape(10.dp))
            .background(MaterialTheme.colorScheme.secondary)
            .border(2.dp, MaterialTheme.colorScheme.onSecondary, RoundedCornerShape(10.dp))
            .focusRequester(focusRequester),
    )
}

实测发现并没有成功监听到Enter键的事件。

为了搞清楚按键的顺序,恢复到第二版的方案后,通过在 onValueChangeonKeyEvent 里打印log看到:

  • 在普通按键按下时,KeyEvent可以拦截,先手回调。
  • 然而按下Enter键时,KeyEvent却在onValueChange的后面回调,即输入框的内容已经吃掉了换行符,这样就无法提前对onValueChange回调之前进行操作。

第四版——使用内部状态来多重判断

先上代码:

@Composable
fun WrappedEditText(
    value: String,
    onValueChange: (String) -> Unit,
    tipText: String,
    modifier: Modifier = Modifier,
    onEnterPressed: () -> Unit = {}
) {
    val focusRequester = remember { FocusRequester() }

    var ctrlPressed by remember { mutableStateOf(false) }

    var altPressed by remember { mutableStateOf(false) }

    TextField(
        value = value,
        textStyle = infoText,
        colors = TextFieldDefaults.colors(
            focusedTextColor = MaterialTheme.colorScheme.onPrimary,
            cursorColor = MaterialTheme.colorScheme.onPrimary,
            focusedIndicatorColor = MaterialTheme.colorScheme.onPrimary,
            unfocusedIndicatorColor = MaterialTheme.colorScheme.onPrimary
        ),
        label = { Text(tipText, color = MaterialTheme.colorScheme.onSecondary) },
        onValueChange = {
            // 如果此时使用了ctrl或者alt键,那么就不做处理
            // 否则就处理,丢弃掉最后一个换行符
            onValueChange(if (!ctrlPressed && !altPressed) it.processText() else it)
        },
        modifier = modifier
            .widthIn(max = 200.dp, min = 100.dp)
            .clip(RoundedCornerShape(10.dp))
            .background(MaterialTheme.colorScheme.secondary)
            .border(2.dp, MaterialTheme.colorScheme.onSecondary, RoundedCornerShape(10.dp))
            .focusRequester(focusRequester)
            .onKeyEvent {
                // 只有单独按下enter键才触发,其余组合键只换行
                if (it.isCtrlPressed) {
                    ctrlPressed = true
                    return onKeyEvent false
                } else {
                    ctrlPressed = false
                }
                if (it.isAltPressed) {
                    altPressed = true
                    return onKeyEvent false
                } else {
                    altPressed = false
                }
                if (it.key == Key.Enter) {
                    onEnterPressed()
                    return onKeyEvent true
                }
                false
            },
    )
}

/**
 * 用来兜底TextField的bug,暂时没有找到更好的解决方案
 * 手动丢弃掉最后一个换行符
 */
private fun String.processText(): String {
    return if (this.endsWith("\n")) {
        // 如果是单一个换行符,直接置空
        // 如果非单换行符,就丢弃最后一个字符
        if (this.length == 1) ""
        else this.dropLast(1)
    } else this
}

KeyEvent里面提供了几个重要按键按下的状态回调,我使用内部State来记录Ctrl和Alt这两个按键的按键状态,isPressed时置为true,没有按下时置为false,这样就可以在onValueChange时对回调过来的字符串进行加工处理。即,在Ctrl按键和Alt按键按下时,如实地回调键盘事件给输入框,这两个按键都没有按时,对字符串的最后一个字符进行检查。

处理方法如 String.processText(),如果以换行符结尾,再判断这个字符串是不是就只有一个换行符,这种情况就直接置为空字符串,如果有多个字符,就把最后一个换行符给去掉,再传递给外部的调用方,保证了输入框的UI和实际的字符串里都不会显示异常。

blogs_cmp_debugmanager_chat_fixed.png

最终实现组合按键正常换行,单独换行键直接发送对话。后续计划持续跟进,看看这里是不是跨平台库中的一个BUG,还有就是有没有官方封装完善的方案来直接使用。

Pagination