什么是 KMP
KMP(Kotlin multiplatform)是 Kotlin 语言的一项重要特性,允许将 kotlin 代码运行在不同平台上,通过『一码多端』的方式来节省成本。
而与诸如 Java / React 这类跨端方案不同,KMP 没有采用所谓的虚拟机的思路,而是选择直接将 kotlin 源码编译成目标平台代码运行的方案。
KMP 的优势和限制
KMP 的优势:
相较传统的跨平台框架而言,由于 Kotlin 会将代码编译成目标平台原生代码执行(可以简单理解为将 Kotlin 源码翻译成 java/c++/js 代码),其最大的优势在于进行 FFI(跨语言调用)时几乎没有性能折损,并且执行性能接近于原生系统。
KMP 的限制:
由于早期的 kotlin 是基于 java / android 平台,这些 kotlin 二/三方库在设计时候也不可能考虑过跨平台。考虑到这些情况,kotlin 在编译时使用了 Target Platform 概念,即 kotlin 每个类 / 方法 都是有对应平台的,早期的的 java / android 二三方库只属于 jvm 平台,意味只能在 java / android 平台调用,在其他平台上调用会编译报错。
而对于系统接口,在 KMP 下也是有对应平台限制的,一个简单的判定方法如下:
-
所有 kotlin.、kotlinx. 包名的接口,都是跨平台的。
-
所有java.、sun. 包名的接口,只能在 jvm 平台使用。
-
所有android.* 包名的接口,只能在 android 平台使用。
-
androidx.* 比较特殊,部分库可以(比如 Room),部分不行,需要自行查看文档判断。
因此,如果想要将 Android 代码通过 KMP 直接编译成其他平台产物,那基本上是不可能直接成功的。如果没有提前设计隔离层的话,工程中二、三方依赖,以及源码中几乎不可避免的含有 Android / jvm 的平台接口,你很可能需要进行大量的抽象改造才可以完成。
KMP 实现原理
跨平台概述
前面提到,KMP 核心思路,是直接将 kotlin 源码编译成目标平台代码运行。而实现这一能力的关键就是 Kotlin 编译器,其核心职责就是将源码翻译成目标平台代码。
在实现上,kotlin 编译器使用了前后端分离的思路。
简单来说,前端负责语法解析&代码分析 、后端负责将前端产物翻译成目标平台代码 ,二者职责清晰。未来如果如果需要支持一个新平台,添加一个新后端即可。
至于 Optimizer,由于不同目标平台的优化方式不同,在 kotlin 编译器中被放在了后端中。
Kotlin Native 编译器
由于 Kotlin Jvm 大家相对比较熟悉,而 Kotlin JS 笔者还没有看,因此本文只着重介绍 Kotlin Native 的相关分析
编译流程
Kotlin Native 编译入口通常为 Gradle Task 或者命令行(konanc),二者最终执行代码是共通的,最终会根据根据产物类型不同执行不同逻辑。
产物分为四类:
-
Klib:Kotlin Native Library,可以简单理解为 Kotlin Native 版本的 jar / aar,只保存了 kotlin ir 信息。
-
ObjCFramework:给 iOS 使用的 .framework.
-
Binary:缓存 / 可执行文件。
-
CLibrary:动态库 / 静态库。
produce(Klib)
Compile 作用是将 kt 编译成 Klib(可以类比为 aar)。
Klib 解开后结构如下:
所有 KN 模块在编译阶段都会先编译成 Klib,在 link 阶段才会调用 c++ 工具链处理。
produce(Binary/CLibrary/ObjCFramework)
这三个基本流程都差不多,都是将多个 Klib 聚合编译成一个二进制库(类似于 C 的 link、或者 android 的打 apk),区别在于产物不同。核心为编译器后端处理,用于将 kotlin ir 转换为目标平台的二进制库 ,核心流程如下:
各步骤说明:
-
Add entry points:如果编译可执行文件,就加一个入口文件的 ir file,比较简单。
-
Lowering module && dependencies:将所有依赖库合并,并针对合并后的每个 ir 文件(包括依赖的库的 ir)执行 Lowerings(对 Ir 进行前置优化,比如内联,语法糖处理),每个 lowering 文件需要执行 51 步,每一步都可以在 NativeLoweringPhases.kt 中找到对应的定义。
-
Run after lowering:即真正的 Native 编译流程,主要通过 llvm 将 kotlin IR 翻译为二进制产物,主要步骤:
-
CodeGen:将 kotlin ir 『翻译』成 llvm IR,这部分主要通过调用 llvm 的 c 函数实现
-
Generate Export Api + Compile Export Api:生成一个对外 api 的 c++ 接口文件并编译,用于暴露接口给外部调用。
-
Post Processing :在和底层依赖库(Runtime)的 bit code 链接前,做一些优化工作,比如去除无用代码。
-
Write BitCode:将所有 bitcode 链接完毕后,生成 out.bc
-
Compile and link:
-
调用 clang 将 .bc 编译成 .o,这里会根据 debug / release 添加不同编译参数。
-
调用 lld 将 .o 文件 link 成目标平台汇编代码
IR 转换
假设有如下源码:
package com.demo.kmp
classHelloWorld{
funhelloFun1(a: Int, b: Int): Int {
return a + b
}
}
其编译后的 llvm ir 长这样:
除开一些流转指令、调试指令外、其翻译回 C / C++ 代码大概是这样。
// 没错这个函数名就是这么长
int"kfun:com.demo.kmp.HelloWorld#helloFun1(kotlin.Int;kotlin.Int){}kotlin.Int"(*struct.ObjHeader this,int a,int b) {
return a + b;
}
可以看出和用 C / C++ 写的代码基本上差不多,所以执行效率是非常高的(相当于写 C / C++ 代码去运行)。
其主要的『翻译』逻辑如下:
- kotlin 基础类型 会『翻译』为对应的 C 的基本数据类型,如: int / float / double / short / long / double。
- Kotlin 类 会『翻译』成 llvm typeInfo 形式,用来记录类名等信息。
- Kotlin 对象 会『翻译』成 ObjHeader + 一段内存空间形式,前者用于记录 typeinfo,后者用来存放所有的类字段。
- Kotlin 函数 会『翻译』成 C 函数,差别在于会多一个 ObjHeader* 参数,用作 $this 指针。
- Kotlin 属性 会『翻译』成 Get/Set 函数,这个跟 java 是一致的。
- Kotlin 运算符 会『翻译』成对应的 operator 函数(举例来说,加号(+)会翻译成 add 函数),一些类型(比如基础类型)会进一步通过内联翻译成 C 的运算符。
- 其余类型则不再赘述,有兴趣可以自行参考源码(位于ir2bitcode.kt )实现。
Kotlin Native 运行时
为了实现内存的自动回收,在 Kotlin Native 平台上,会打包一套 Kotlin Runtime 到最终产物中,包含异常处理、线程管理、内存管理等常规能力。
运行时包括如下几个部分,创建线程或者已存在的线程都可以 initRuntime
-
SetKonanTerminateHandler 为线程设置异常处理 Handler ,这样可以捕获 kotlin excepiton
-
globalData 初始化全局变量
-
theaddata 初始化线程内存分配器
-
workInit 初始化线程消息队列,用于执行协程
和 android 相比,kmp 运行时不支持 synchronized 关键字,可以使用 atomicFu 来解决。
内存管理
Kotlin Native 有 3 种内存分配器:
-
custom:kotlin 自己开发的内存分配器,也是默认的内存分配器
-
std:标准库内存分配器,在鸿蒙上是 jemalloc
-
mimalloc:微软开源的 native 分配器
目前 std/mimalloc 在最新版本已经去掉了,kmp 未来会持续优化 custom 内存分配器
custom 内存分配器是 kotlin 自己实现的内存分配器,包括几个部分
-
Safealloc mmap 虚拟内存,每次大小256k,分配后检查是否需要触发 alloc gc
-
CreateObject 分配对象,每个对象额外增加16字节内存,包括 objectData/objectHeader
-
CreateObject 分配对象时,如果类(typeInfo)加了 TF_HAS_FINALIZER 标记,会通过 extraObject 增加对象弱引用,gc 后调用对象 finialize 方法,objectHeader 指向 extraObject
-
CreateArray 分配 array,每个 array 额外增加24字节内存,包括 objectData/ArrayAHeader,ArrayHeader 12字节按照8字节对齐到16字节
和 android 相比,有 3 点不同:
-
Kotlin Native 只支持 Weakreference,不支持 SoftRefere nce
-
Kotlin Native 对象分配支持逃逸分析 ,除了在堆上分配,还可以在编译时通过静态代码分析决定哪些变量在栈上分配
-
Kotlin Native 把 Array 类型单独拿出来了,Android 认为所有类型都是 Object
基础类型
基础类型包括 Byte/Short/Int/Float/String 等,和 android 一致
对象类型
class 包括几部分
- instanceSize_:对象大小,如果是 array,instanceSize_ 为每个元素大小
- superType: 父类
- objOffsets:成员变量 offset 数组,根据 offset 查找成员变量
- objOffsetCount_:成员变量数量
- interfaceTableSize:interface 数量
- interfaceTable:interface 表,指向 interface 实现
和 android 相比,Kotlin Native 将 interface 方法和 abstract 方法都通过 interfacetable 存储,android 是分开存储的
内存回收(GC)
GC 有三种类型,默认 pcms,cms 需要手动配置
-
cms 是并发标记的,只在遍历 gc root 时暂停线程,性能最好
-
Stms 需要 stop the world 暂停线程,性能很差
-
默认 pcms 可以支持多线程 gc,也会 stop the world 暂停线程
由于 cms 性能最好,目前 KMP 项目里面默认使用 cms
cms 类型主要包括几个功能,在在 gc root 收集完成后,会 resume the world 唤醒线程
-
StopTheWord 所有线程将线程暂停执行
-
collectRootSet 收集 gc root
-
resumeTheWorld 唤醒线程
-
Mark 会根据 gc root 标记存活对象
-
processWeaks 处理 weakReference
-
heap.Sweep 释放非存活对象
-
finalizerProcessor 调用对象 finialize 方法,之前会收集所有线程的 finalize 对象
和 android 相比
-
heap 默认10M,android 是大对象/小对象各512M,导致比较容易触发 alloc gc,目前已经优化
-
concurrent gc 通过定时10s触发实现,在空闲时容易造成 cpu 浪费,目前已经优化
-
cms 目前不会做内存碎片整理,会导致内存占用过高,目前在优化中
-
cms mark 阶段产生的对象都是存活对象
-
gc 不支持分代,目前已经优化
小结
Kotlin Multiplatform 在经历了这么多年迭代后,目前现在已经是一个相对成熟的解决方案了。虽然在内存管理方案还有一些瑕疵,但其『IR 翻译成 Native』设计理念使得整个系统的性能上限很高,理论上能达到接近原生的执行性能。而 Jetbrain 的号召力也使得整个研发生态非常有想象力,目前 androidx 已经在开始逐步适配 KMP 中,可以预见的将来会非常有潜力。
