Kotlin Multiplatform 原理深入分析

yumo6662小时前技术文章1

什么是 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 下也是有对应平台限制的,一个简单的判定方法如下:

  1. 所有 kotlin.*、kotlinx.*包名的接口,都是跨平台的。

  2. 所有 java.*、sun.* 包名的接口,只能在 jvm 平台使用。

  3. 所有 android.*包名的接口,只能在 android 平台使用。

  4. 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 转换为目标平台的二进制库,核心流程如下:

各步骤说明:

1. Add entry points:如果编译可执行文件,就加一个入口文件的 ir file,比较简单。

2. Lowering module && dependencies:将所有依赖库合并,并针对合并后的每个 ir 文件(包括依赖的库的 ir)执行 Lowerings(对 Ir 进行前置优化,比如内联,语法糖处理),每个 lowering 文件需要执行 51 步,每一步都可以在 NativeLoweringPhases.kt 中找到对应的定义。

3. Run after lowering:即真正的 Native 编译流程,主要通过 llvm 将 kotlin IR 翻译为二进制产物,主要步骤:

  1. CodeGen:将 kotlin ir 『翻译』成 llvm IR,这部分主要通过调用 llvm 的 c 函数实现

  2. Generate Export Api + Compile Export Api:生成一个对外 api 的 c++ 接口文件并编译,用于暴露接口给外部调用。

  3. Post Processing :在和底层依赖库(Runtime)的 bit code 链接前,做一些优化工作,比如去除无用代码。

  4. Write BitCode:将所有 bitcode 链接完毕后,生成 out.bc

  5. Compile and link:

  1. 调用 clang 将 .bc 编译成 .o,这里会根据 debug / release 添加不同编译参数。

  2. 调用 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++ 代码去运行)。

其主要的『翻译』逻辑如下:

  1. kotlin 基础类型会『翻译』为对应的 C 的基本数据类型,如: int / float / double / short / long / double。

  2. Kotlin 类会『翻译』成 llvm typeInfo 形式,用来记录类名等信息。

  3. Kotlin 对象会『翻译』成 ObjHeader + 一段内存空间形式,前者用于记录 typeinfo,后者用来存放所有的类字段。

  4. Kotlin 函数会『翻译』成 C 函数,差别在于会多一个 ObjHeader* 参数,用作 $this 指针。

  5. Kotlin 属性会『翻译』成 Get/Set 函数,这个跟 java 是一致的。

  6. Kotlin 运算符会『翻译』成对应的 operator 函数(举例来说,加号(+)会翻译成 add 函数),一些类型(比如基础类型)会进一步通过内联翻译成 C 的运算符。

  7. 其余类型则不再赘述,有兴趣可以自行参考源码(位于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,不支持 SoftReference

  • 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 中,可以预见的将来会非常有潜力。

相关文章

数据结构-位运算_数据结构按位查找

左移( << ):操作数的非0位左移n位,低位补0右移( >> ):操作数的非0位右移n位,高位补0无符号右移( >>> ):正数右移,高位用0补,负数右移,...

PLC的位逻辑运算指令_plc中的位怎么理解

PLC(可编程逻辑控制器)的位指令是针对单个二进制位(0 或 1)进行操作的基础指令,主要用于逻辑控制,是梯形图(LD)编程中最常用的指令类型。以下是 PLC 位指令的核心类别及常用指令:常开常闭输出...

【C语言·015】逗号运算符的求值顺序与返回值规则

很多人第一次看到 , 都把它当“分隔符”:函数实参之间的逗号、初始化列表里的逗号……但在表达式里,, 还有另一个身份——逗号运算符。它既能强制求值顺序,又能控制返回值,是解决副作用与顺序问题的一把小刀...

C 语言指针全解析:从门牌号到内存黑魔法,一文带你彻底搞懂!

很多人一提到 C 语言指针 就皱眉:“指针是不是地址?”“数组和指针是不是一样的?”“为什么 * 有时候是解引用,有时候是乘法?”其实指针没那么神秘。只要把它拆开理解,就会发现它不过是一串数字,存的就...

C语言应用笔记:整数字节大小端翻转

在C语言中,实现大小端(Endian)翻转可以通过位操作或内存操作完成。以下提供两种常用方法:方法1:位运算(推荐)通过移位和掩码操作直接交换字节位置:// 16位翻转(安全版本) #define S...

C语言应用笔记:获取结构体成员偏移地址

在C语言中,获取结构体成员的偏移地址(即成员相对于结构体起始地址的字节偏移量)有两种常用方法:1. 使用标准库宏 offsetof(推荐)<stddef.h> 头文件提供了 offseto...