摘要
一直以来,跨平台技术被广泛探索与研究。时至今日,在不涉及界面层面的跨平台技术上,C++跨平台技术仍被广泛采用。Kotlin Multiplatform作为一种新兴技术,也开始在跨平台的领域上展现出自己独有的优势。本文基于自身分别使用两种方式进行跨平台项目开发的实际体验,对两种跨平台技术做了简要分析对比。
Kotlin Multiplatform技术简介
对于C++跨平台技术,大家对其原理应该都比较熟悉,不再赘述。
Kotlin Multiplatform主要分为Kotlin/JVM
、Kotlin/Native
与Kotlin/JS
,其中Kotlin/JVM
是我们最为熟悉的,也被广大Android开发人员广泛使用。Kotlin/Native
不再基于JVM平台,而是使用Kotlin编译器,将Kotlin代码编译成LLVM IR,再配合LLVM backend,最终编译成平台的原生二进制文件,不依赖虚拟机,执行效率媲美原生程序。其基本原理如下图:
Kotllin Multiplatform目前已经支持的平台:
-
Android (可编译生成Linux So文件,也可基于Kotlin/JVM编译成aar)
-
iOS 9.0+(Arm32, Arm64, x86_64)
-
MacOS(x86_64)
-
Linux
-
Windows(mingw x86_64, x86)
-
WebAssembly (wasm32)
开发对比
开发语言
Kotlin Multiplatform主要基于Kotlin语言,而C++跨平台主要基于C++进行开发。Kotlin作为一种现代型语言,在开发上的体验是要优于C++的。站在一个新手的角度(有其他语言的开发基础,比如Java/OC/Swift),你可以用一周的时间学习熟悉Kotlin语法并开始项目实战,但是很难用一周时间学习熟悉C++后就比较有信心地开始项目实战。使用Kotlin进行开发,你只需要熟悉Kotlin基本语法、一些基础库的使用加上一小部分高阶函数的使用就可以开始进行开发,而使用C++进行开发,首先需要熟悉C++的基本语法(C++本身的语法也非常的多),再需要去理解指针与引用两个概念,特别是指针(指针作为C++开放给用户的一种能力,在给用户赋予了更多开发权力与自由的同时也造成了很多的问题,C++项目的一大部分问题都是由于指针使用不当造成的),随后需要去理解手动内存管理(或者直接去理解智能指针,这也是需要花时间去理论与熟悉的),随后才可以慢慢地进行开发。
从两种语言的学习成本与开发体验上来看,Kotlin优于C++。
项目架构
作为跨平台技术,项目架构的区别主要体现在平台相关与平台无关代码的组织上。C++跨平台技术并没有一种固定的项目架构,不同的开发者可能采用不同的项目架构,以自己参与的一个C++跨平台项目为例,其项目架构如下图:
可以看到,平台相关性代码被分离到了不同的目录中,有效实现代码隔离。对于平台相关代码的实现,将其头文件定义在公共代码中,再在不同平台去分别进行实现,以一个简单的Log实现为例:
// 公共层定义, ComLogger.h
namespace cut {
class ComLogger {
public:
// 函数定义
void d(const char *fmt, ...);
...
};
}
// android层实现, AndroidLogger.cpp,代码放在cut_android中
#include <cut/ComLogger.h>
#include <android/log.h>
// 函数实现
void cut::ComLogger::d(const char * fmt, ...) {
va_list params;
va_start(params, fmt);
__android_log_vprint(ANDROID_LOG_DEBUG, TAG, fmt, params);
va_end(params);
}
打包时,再利用CmakeLists指定不同的源代码进行打包。比如打包Android产物时,只打包cut_android与cut目录下的源码,打包iOS产物时,只打包cut_ios与cut目录下的源码。
不同于C++跨平台,Kotlin Multiplatform跨平台技术已经制定了一种项目架构
可以看到,平台相关代码与平台无关代码也实现了代码隔离。对于平台相关代码的实现,其提供了expect/actual机制,在公共层定义expect接口,不同平台层分别调用平台相关接口去进行实现。还是以log实现举例:
// commonMain,定义在公共代码层,相当于一个接口声明
expect class PlatformLogger() {
fun logDebug(tag: String, message: String)
}
// androidMain,在android层,相当于Android平台上的接口实现
actual class PlatformLogger {
actual fun logDebug(tag: String, message: String) {
android.util.Log.d(tag, message)
}
}
上述对比可以看出,两者项目架构的本质其实是一致的,都是平台无关放到一个目录,平台相关单独放到不同平台目录中,且对于平台相关的接口与方法实现也比较一致,都是公共层定义,平台层不同实现。不同的是,C++跨平台需要开发者自己去搭建这样一套项目架构配置,即需要自己去编写CmakeLists.txt
类似的配置文件去实现,而Kotlin Multiplatform则基于gradle配置提供好了这样的项目配置,开发者的配置成本很少。
另一方面,C++跨平台的平台相关代码隔离其实只是一种约束而已,其并未在编译期间真正地实现了代码隔离。比如一位Android开发者在公共代码层直接include <jni.h>
后调用Android平台特有的JNI方法,如果只在Android平台上测试,其实都是看不出问题的,这个时候如果编译iOS平台,就会编译不通过。所以,对于C++跨平台而言,需要开发者自己去在编译期静态检查代码,防止开发者在公共代码层使用到平台相关库。而对于Kotlin Multiplatform跨平台,在公共代码层是访问不到任何平台相关的代码的,天然支持代码隔离。
由上述分析,从项目的配置成本来看,Kotlin Multiplatform小于C++。
开发社区
C++作为一个历史悠久、应用广泛的开发语言,在漫长的计算机发展中已经沉淀了相当一部分优秀的第三方库,在跨平台项目中碰到的一些通用的基础能力可以直接借助于成熟的第三方库,如json解析、网络请求等。
Kotlin Multiplatform作为一种新技术,社区成立时间较短,目前沉淀的第三方库比较少,开发者能够使用的通用的基础能力较少,社区还不够丰富,目前已有的KM库存档: KN库存档。Kotlin Multiplatform开发者也意识到了这个问题,所以其开发了cinterop这个工具,能够把c语言直接编译成Kotlin/Native库,让kotlin直接调用。所以Kotlin Multiplatform是可以使用所有C语言库的,但对于C++库,KN暂时还并不支持。不过,可以通过接口包装的形式让K/N使用C++库。
在平台相关库的使用上,相对于C++跨平台,Kotlin Multiplatform是可以非常方便地使用平台相关库的,比如Android平台上使用Okhttp进行网络请求,只需要在android依赖上加入对Okhttp的依赖,并在androidMain的实现中调用Okhttp进行实现即可。当然,C++跨平台也可以使用平台相关库,不过实现稍显麻烦,在Android平台上体现为需要借助一层JNI,且项目配置也略显复杂。
模型统一
对于跨平台库而言,模型统一可能是最大的问题了。比如简单的请求网络后得到一个json,将这个json序列化成一个模型实体类,且这个模型实体类会在各个平台中进行使用,需要在平台层包装一个实体类,以C++跨平台的一个UserInfo实体类为例:
// 模型定义,存在于公共代码层,UserInfo.h
class UserInfo {
private:
std::string name;
public:
UserInfo() {}
~UserInfo() = default;
const std::string & get_name() { return name; }
void set_name(const std::string & name) { this->name = name; }
}
// Android层,使用Java包装UserInfo,提供给业务方调用。UserInfo.java
public class UserInfo {
public String getName() {
return getNameFromJNI();
}
public void setName(String name) {
setNameFromJNI(name);
}
}
可以看到,为了实现能够给不同平台层提供平台层相关的调用,需要在不同层编写相应的包装类。包装类的编写并不复杂,主要问题在于工作量大(模型类越多工作量越大),且不好维护,当跨平台层的模型实体类修改一个字段时,每个平台包装类都需要进行相应修改,维护成本大。所以出现了QuickType之类的模型自动生成,可以根据自己定义的模型参数配置,自动生成UserInfo.h
和UserInfo.java
,大幅度减少维护成本,但模型自动成本框架的引入与配置、维护也会带来比较大的额外的成本 。
同样的需求场景,使用Kotlin/Native跨平台,其简要代码如下:
// 模型定义,存在于公共代码层,UserInfo.kt
data class UserInfo(
var name: String = ""
)
// 生成的libkntest.h文件
struct {
libkntest_KType* (*_type)(void);
libkntest_kref_sample_UserInfo (*UserInfo)(const char* name);
const char* (*get_name)(libkntest_kref_sample_UserInfo thiz);
void (*set_name)(libkntest_kref_sample_UserInfo thiz, const char* set);
libkntest_KBoolean (*equals)(libkntest_kref_sample_UserInfo thiz, libkntest_kref_kotlin_Any other);
libkntest_KInt (*hashCode)(libkntest_kref_sample_UserInfo thiz);
const char* (*toString)(libkntest_kref_sample_UserInfo thiz);
} UserInfo;
可以看到,Kotlin/Native框架编译生成平台相关库时自动生成了包装类,提供给平台相关业务方调用。相较于C++跨平台,KN跨平台减去了模型自动成本框架的引入与配置成本。
另一方面,对于C++跨平台在Android平台上,模型统一会造成频繁的JNI调用,会带来一些额外的性能损耗。当然,Kotlin Multiplatform在iOS和PC平台上的模型统一也会带来额外的性能损耗。
内存管理
C++被开发者诟病良久的一点就是手动内存管理了,new与delete、malloc与free的成对使用成为C++开发者在开发时需要经常关注的一点,然而还是会经常性出现内存泄漏或野指针问题。所以最顽固的C++也推出了智能指针,通过引用计数的形式帮助开发者实现自动内存管理,然而似乎也只是一定程度上缓解了这个问题。
Kotlin Multiplatform采用了自动内存管理,内部通过引用计数的方式实现自动内存管理,所以在编写纯Kotlin代码的时候是不需要去考虑内存管理的。但是,由于Kotlin/Native可以调用C语言,而C语言又是一个手动内存管理的语言,所以在Kotlin调用C时,手动内存分配成为一件必不可少的事情,其提供成对的内存分配与释放函数:
// 分配Native内存
nativeHeap.alloc
// 释放native内存
nativeHeap.free
当然,Kotlin/Native提供了一种更为友好的方式:memScope
作用域。memScope
的作用是当memScope
的作用域结束的时候,自动释放在里面分配的所有native内存。如:
// memScoped结束时buffer会自动释放
memScoped {
val buffer = allocArrayOf(destArray)
result = fread(buffer, destArray.size.toULong(), 1u, filePointer).toInt()
resultString = buffer.toKString()
return resultString
}
多线程
C++的多线程,可以直接使用pthread
库,也可以借助其他第三方多线程库,具体开发时可以不关心具体运行平台。 Kotlin Multiplatform的多线程模型不同主要体现在Kotlin/Native上,K/N提供了一个多线程框架,叫做Worker
,其内部也是基于pthread实现的,一个Worker
对应一个pthread
,其基本用法如下:
//1.创建一个worker实例
val worker = Worker.start()
//2.执行一个异步任务
val future = worker.execute(TransferMode.SAFE, {"Hello"}) {
it + ", Kotlin/Native"
}
//3. 获取返回值
future.consume {
println("Result: $it")
}
Worker的使用还是比较简单,但使用Worker时,坑主要在于K/N变量的共享性:Kotlin/Native 实现了严格的可变性检测,对象要么不可变,要么在同一时刻只在单个线程中访问(mutable XOR global),使得其具体使用也与平时的开发过程有一定不同,当然这样也有好处,K/N把原本在运行时概率性出现的问题,变成了运行时必现的问题,利于发现问题;更近一步把问题暴露在编译期就友好多了(当然这样做也是有点激进,K/N被吐槽最多的地方也在这里)。
总体来说,在混合编程中,多线程是Kotlin/Native中坑最多的地方,也是对开发者最不友好的地方。
调试
对于C++跨平台而言,各个平台对C++平台代码断点调试的支持程度较好。Xcode中在C++中设置断点与在OC中设置断点并无区别,Android Studio也可在JNI层设置断点进行调试,不过存在一系列问题(attach缓慢,容易断掉连接等)。
对于Kotlin Multiplatform,在Android平台上,调试即为原生调试,极为方便;在XCode中,也可利用插件:xcode-kotlin方便地调试Kotlin代码;对于PC平台,目前K/N并不支持在可视化的断点调试,即在Clion/VS中并不能调试Kotlin代码,需要通过lldb或者gdb去进行调试,或者使用kotlin编写测试代码,直接运行在PC平台进行调试。
产物对比
对比基于Kotlin 1.3.71版本。
产物形式
可以看到,Kotlin Multiplatform的最终产物平台相关,对平台相关的业务方接入更加友好。
产物包体积增加
不包含任何业务代码,引起的额外包体积增加如下表:
Kotlin Multiplatform主要在iOS与PC平台上会有额外的包体积增加,这主要是由于kotlin-runtime引入的,其内部主要包含一些Kotlin/Native GC相关代码与Kotlin基础库等。
产物性能
编写简单的测试程序,测试代码为“检测一个int32值,检测其二进制表示中含有多少个1,从0一直检测到100000000”,Kotlin版本测试代码如下:
fun test(): Int {
var sum = 0
// 循环一亿次
for (i in 0 until 1_0000_0000) {
sum += getInt32TrueCount(i)
}
return sum
}
private fun getInt32TrueCount(value: Int): Int {
if (value == 0) {
return 0
}
return getByteTrueCount(value and 0xFF) +
getByteTrueCount((value shr 8) and 0xFF) +
getByteTrueCount((value shr 16) and 0xFF) +
getByteTrueCount((value shr 24) and 0xFF)
}
private fun getByteTrueCount(value: Int): Int {
if (value == 0) {
return 0
}
val a = (value and 0x1)
val b = ((value and 0x2) shr 1)
val c = ((value and 0x4) shr 2)
val d = ((value and 0x8) shr 3)
val e = ((value and 0x10) shr 4)
val f = ((value and 0x20) shr 5)
val g = ((value and 0x40) shr 6)
val h = ((value and 0x80) shr 7)
return a + b + c + d + e + f + g + h
}
C++实现如下:
int getByteTrueCount(int value) {
if (value == 0) {
return 0;
}
int a = (value & 0x1);
int b = ((value & 0x2) >> 1);
int c = ((value & 0x4) >> 2);
int d = ((value & 0x8) >> 3);
int e = ((value & 0x10) >> 4);
int f = ((value & 0x20) >> 5);
int g = ((value & 0x40) >> 6);
int h = ((value & 0x80) >> 7);
return a + b + c + d + e + f + g + h;
}
int getInt32TrueCount(int value) {
if (value == 0) {
return 0;
}
return getByteTrueCount(value & 0xFF) +
getByteTrueCount((value >> 8) & 0xFF) +
getByteTrueCount((value >> 16) & 0xFF) +
getByteTrueCount((value >> 24) & 0xFF);
}
int test() {
int sum = 0;
for (int i = 0; i < 100000000; ++i) {
sum += getInt32TrueCount(i);
}
return sum;
}
在Kotlin/C++内部使用for循环一亿次,测试结果如下表:
在外部使用for循环一亿次,测试结果如下表(即频繁地进行跨语言调用):
从上述两张耗时对比表可以看出,在Android系统上,JNI调用存在一定性能损耗,短时间内频繁进行JNI调用性能较差;在iOS平台上,调用Kotlin存在一定性能损耗,但性能损耗明显小于JNI调用的性能损耗。
编写频繁的对象创建销毁测试程序进行测试,测试程序如下:
//kotlin版本, UserInfo为上文提到的数据类
fun allocObject() {
val user1 = UserInfo(name = "hello")
val user2 = UserInfo(name = "test")
val user3 = UserInfo(name = "hello")
val user4 = UserInfo(name = "world")
val user5 = UserInfo(name = "hello")
}
fun testAllocObject() {
// 循环一千万次
for (i in 0 until 10000000) {
testAllocObject()
}
}
// C++版本
void allocObject() {
UserInfo *userInfo1 = new UserInfo("hello");
UserInfo *userInfo2 = new UserInfo("test");
UserInfo *userInfo3 = new UserInfo("hello");
UserInfo *userInfo4 = new UserInfo("world");
UserInfo *userInfo5 = new UserInfo("hello");
delete userInfo1;
delete userInfo2;
delete userInfo3;
delete userInfo4;
delete userInfo5;
}
void testAllocObject() {
for (int i = 0; i < 10000000; i++) {
allocObject();
}
}
测试结果如下表:
从两者的原理与编译产物上分析,理论上,性能对比应如下表:
总结
基于上述的几个维度的分析,C++跨平台与Kotlin Mutiplatform各有优劣。对于一个跨平台项目的技术选型,如果稳定性、可靠性是最需要关心的,那已经发展地十分成熟的C++跨平台成为首选;如果项目主要运行在Android平台上,且项目开发者对Kotlin也比较熟悉(比如Android开发者),那么Kotlin Multiplatform也是一个不错的技术尝试。总体来说,C++跨平台最为成熟稳定,性能也高效,而Kotlin Multiplatform作为一种新技术,具备不错的前景,也是一种不错的跨平台技术尝试。
招聘
互娱研发正在大量招聘客户端研发(安卓/iOS)
点击链接进行内推:内推链接
发送简历到: wangchengyi.1@bytedance.com