KMM 技术在移动 App 开发中的探索与实践
本文整理自百度资深研发工程师袁晗光在 QCon 2022 北京站 的演讲分享,主题为“KMM 技术在移动 App 开发中的探索与实践”。 提升效率永远是软件研发要追求的目标,让代码实现跨平台运行似平就是永恒的主题。当前本该一样的业务逻辑双端需要各实现一遍,最后不仅体验上有着细微的差别,QA 也需要双端各验证一遍;以及由于 UI 代码没有很好的和业务逻辑代码解耦合,导致业务逻辑代码复用困难等,这些影响人效的瓶颈该如何突破?
为什么偏爱 KMM 技术?如何减少为不同平台编写和维护相同业务逻辑代码所花费的时间,同时又能保留 NA 编程的录活性和优势。
编码成本高,需降本增效 。
相同的业务逻辑双端需用不同的语言各自实现一遍 。双端业务逻辑的实现完全一致还很难做到。
同样的业务逻辑仅靠口头上或文档上方案对齐导致最终编码实现上有着不小的差别。
双端体验上有着不小的差别,数据上产生的不一致性导致不便解释其合理性(交互体验、收益回顾)。
双端业务逻辑实现方式不一致导致修改点不能完全对齐、同步 。
后期升级维护、测试成本都较高 。
UI 代码没有很好的和业务逻辑代码解耦合,造成业务逻辑代码复用困难,不方便做单元测试,组件间的循环依赖增多。
业务逻辑变更需要拉上双端的研发都对齐一遍,然后各自编码实现一遍 。
同样的业务逻辑双端都需要测试验证一遍 。
我对跨平台产生了兴趣,大约在 Swift 语言刚推出不久,约 17 年左右,曾在一款创新型的 APP 上(简单搜索 App)进行过一个小实验。那时,我们在做创新类 APP 方面很活跃,我做了一个类似于云控的模块,云控模块估计在许多 APP 上都存在。按照以往的做法是 iOS 和 Android 双端各出一个 RD 来开发这个模块,过程中通过设计文档对齐技术方案,但我这次的实验重点在于先用 Swift 编写了这个云控模块,然后还是同一个人去通过 kotlin 来开发这个模块使得其能运行在 Android 平台上,因为大家都知道这两种语言的语法越来越接近,而不像早期的 Objective-C 和 Java 那样,语法格式和其他方面的差异较大。所以在 Swift 版本开发、测试完成之后,开始逐行翻译每一行 Swift 代码,用 Kotlin 代码逐一对应地写出来。最后我发现效果还是不错,在使用 Swift 编写代码的过程中踩过的坑或遇到的问题,在直接翻译到 Kotlin 上后这些问题基本上都消失了。只需少量的工作和测试就能在 Android 上运行,而且问题很少运行得非常好。
因此,我有一个想法就是如果 Swfit 可以直接跨平台那就太好了。这也是我对语言跨平台最初的兴趣。当然,这与我早年是 C++ 程序员也有关,因为众所周知 C++ 是可以跨平台的。
如果 Swift 语言是否能够直接在 Android 平台上运行就不用再翻译一遍了,两三年前我在偶然的机会下发现了与 Kotlin 相关的代码和文档,看到了一个名为 Kotlin Native 的技术。当时官方还没有发布 KMM 插件,Kotlin Multiplatform Mobile,其中 iOS 端基于 Kotlin Native(简称 KN)技术,Android 端基于 Kotlin JVM,利用 KMM 技术可以使用 Kotlin 语言技术栈在 iOS 和 Android 应用程序之间共享通用代码,只在必要时编写特定于平台的代码,用来构建统一的业务逻辑代码。
无需内置多套引擎 (runtime),包体积增量更少 。
对于 Android 开发者无需多学习一套编程语言和编程思想,门槛更低 。
基于双端标准组件输出,审核被拒风险较小(iOS)。
更强的互操作性, 支持与本地编程语言的双向互操作,可以直接使用现有库,避免了众多基础组件的重复建设。
安装工具:Android Studio(建议官方最新版)
Kotlin 插件
Kotlin Multiplatform Mobile 插件
Xcode(12.5 版本及以上)
JDK(8 及以上)
步骤:
① 在 Android Studio 中,选择文件|新建|新建项目。
② 在项目模板列表中选择 Kotlin Multiplatform App/Library。
③ 为应用程序指定名称。
④ 保留应用程序和共享文件夹的默认名称,并在 iOS 框架分发选项列表中选择常规框架。
三个部分组成:
① 共享模块:包含 Android 和 iOS 应用程序的核心应用程序逻辑:类、函数等。构建到 AAR& Framework 中 ,使用 Gradle 作为构建系统(commonMain、androidMain、iosMain )。
② iOS 应用程序中的 Xcode 项目。
③ Android 应用程序中的 Kotlin 模块。
如下图所示:
我们可以看到,这个项目主要分为三个模块。首先,我们先看一下名为“shared”的模块,它位于最下面。其次是 iOSApp 模块,再上面是 AndroidApp 模块。为什么会有这三个模块呢?因为在我们的核心目标即双端共享代码。什么是共享代码呢?让同一份代码能在 Android & iOS 上运行,那怎么实现这个目标呢?简单来说把全部代码分为两个部分,其中一部分就是与平台无关的代码。啥是平台无关的代码呢 ?比如我们要编写一个检验电子邮件的算法,我们认为这个算法的代码是平台无关的,因为输入就是一个字符串,里面的实现就是根据指定的规则来判断这个输入字符串的合法性,期间不涉及任何平台相关特性的访问,比如系统 API 的访问。但是光有与平台无关的代码是不够的,一旦涉及到与平台相关的访问,例如获取设备版本号或硬件信息等,我们就需要独立于平台去完成它们,这些独立于平台的代码在哪实现?这就有了 AndroidApp 和 iOSApp 这两个模块。在“shared”模块中,我们包含了核心应用程序逻辑,例如类、函数,并使用 Gradle 作为构建系统。
expect/actual 机制 :
KMM 里 expect/actual 机制是非常重要的,因为它提供了一种语法技术来解决平台相关的问题。举例来说,当我们需要在业务逻辑中使用设备的 model 号时,我们需要编写特定平台的代码才能实现。这时,expect 就相当于声明一个协议,它定义了我们期望得到的接口或数据,之后各个平台都需要独立地实现这个协议来满足业务需要。
可能会有人认为这样写了两遍,每个平台都写一遍不如让各个平台自己去写。但是,这种方式只需要建设一次,就像一些基础库一样只需要做一次就能在后面被大家共用。这种共用甚至不限于公司界,整个业界都可以共用一组基础库。
基本流程如下:在 commonMain 目录下建立一个 expect 类或 Top-Level 方法 , 类似创建一个协议声明。分别在 androidMain 和 iosMain 目录中,创建与 expect 声明(类名、方法名、参数类型及名称)完全一致的实现,否则编译器会报错。
在使用 expect 机制后我们解决了平台相关的代码问题。但要充分发挥 KMM 技术的优势,还有一个重要的点,那就是能否复用已有的组件。因为,如果需要再开发一个新的网络库、数据库或多线程相关的组件成本是很高的,新开发的组件稳定性也是有待考验的 。
KMM 技术的一个很重要优势之一就是可以轻松地引用原生平台上已有的组件。例如,我们可以在 androidMain 后的闭包中按照 Gradle 规范添加依赖项,然后在 androidMain 目录下的 Kotlin 代码中调用依赖库中的类或方法。在这里,我们举了一个自定义组件的例子,引用安卓平台上已有的组件和 android 原生开发没啥本质上的区别,关键是如何引用 iOS 上已有的组件呢?
同样,举个例子如果我想在 kotlin 代码中判断当前系统是 iPad 还是 iPhone,类似这样的操作该如何实现呢?
为了解决这个问题,我们需要使用官方提供的工具 cinterop,它可以扫描 Apple Framework 并根据 .h 文件获取可调用的类、方法、变量、常量以及它们对应的类型,最终生成 klib 文件。如果需要使用 Swift 的方法,需要添加 @objc 前缀。
要使用 cinterop,需要了解相关的头文件或声明。首先,cinterop 会下载构建依赖的苹果原生 framework,例如 CFNetwork、CoreData、Foundation 等,然后编写 def 文件并配置 cinterop 的编译和链接。最后,使用脚本生成可用于 KMM 的 API。cinterop 工具将生成中间文件 Klib,它包含了扫描到的所有的 framework。经过扫描之后,每个 framework 都会对应一个 Kotlin 可以识别的 Klib 文件,这样 Kotlin 就可以识别这些 iOS 原生组件了。
由于『百度 APP』工程使用 EasyBox 管理依赖及 iOS 工程结构,而 KMM 官方只能够支持 CocoaPods 或上面介绍的直接引用 Framework 的形式实现 Kotlin 与 Objective-C 的交互,如需兼容 EasyBox 组件,在不使用自定义插件的情况下只能使用常规模式(非 CocoaPods)引入依赖库。
但官方提供的 Framework 引用配置比较复杂,根据官方文档的说明,如果不使用 CocoaPods 引入依赖库的模式, build.gradle.kts
文件内的配置项相当复杂,每次新增依赖时需要做大量的 cinterop
配置,对于新入门 KMM 的开发人员,非常不友好,影响开发效率,甚至破坏工程配置环境 。为简化 KMM 引用 EasyBox 组件的配置流程,做好二进制版本控制,并避免造成代码库文件冗余,我们参考 KMM 官方的 CocoaPods 插件实现,开发了 EasyBox KMM 插件,以便在 KMM 工程中,充分利用现有的各类组件,避免重复建设的问题 ,大家可以结合自己的组件管理工具或源码管理工具来使用。
EasyBox KMM Gradle 插件实现了的主要功能:
根据配置,自动拉取 Box 仓库(二进制源)中的 Framework 。
自动生成并配置 cinterop 所需要的 def 文件,让 KMM 模块能够识别并扫描 Framework 中对外公开的类、方法、常量等元素 。
生成带有 cinterop 产物的 klib,并支持发布到 Maven 仓库中,在不具备 cinterop 环境的设备上无需再次进行 Framework 下载和扫描即可复用 cinterop 后的能力 。EasyBox KMM Gradle 插件的使用:
有 EasyBox KMM Gradle 插件,使用过程就相对简单了。首先声明 easybox-kmm 插件,然后在 easybox 闭包中添加所有依赖的组件,如图所示 2 个步骤。
接下来,我们来看下如何将 KMM 的产物集成到现有的工程中。由于 KMM 模块在 iOS 平台是以 Framework 的形式产出的 ,所以 在 xcode 里面我们可以像使用其他 Framework 一样引入它,在使用 Framework 中,它的所有协议都是 Objective-C 格式的,我们可以在我们的工程中像使用其他的 API 一样使用它。同样 KMM 模块在 Android 上是以 AAR 的形式产出的,如下图输出的 api 之间的对应关系,名字一致,参数也基本上一致。因此,在已有工程中集成 KMM 的产物的便利性也是 KMM 的一个优势。
基础流程已经介绍完毕,现在让我们来看看重点技术。其中最主要的技术还是多线程处理,它涉及到协程、状态共享、不可变性(frozen)和原子类(Atomic)等。接下来,第二个重点是基础库的建设,主要包括网络库和数据库。
多线程并发开 App 开发中一直都是个重点和难点,也是我们在实际编码工作中容易出错的地方,这点在 KMM 中的开发中也是。所以技术选型是关键,官方提供了多种方式支持多线程,我们也对比了几种方式的优缺点。首先是协程,虽然协程的概念上类似于线程语法简单且概念新颖,但我们当时认为它对 Kotlin Native 的支持不够成熟和完善,虽然在安卓平台上它仍然使用 JVM,但我们最终还是没有选择协程方式去继续探索。
接下下来看第三方库,CoroutineWorker 对 Kotlin 协程进行了封装,但迭代较少,不是很稳定。Reaktive 采用 RxJava 的实现思想,Native(iOS 和 macOS)底层采用 Kotlin Native 实现,具有多种功能,但框架相对较重。
最终我们选择的是利用 expect/actual 加 Block 方法,Android 端可以利用线程池,而 iOS 端可以使用 GCD 自行实现。优势是使用了各自平台已有比较成熟的多线程方案,更稳定,运行效率更高 。
我们认为这是风险最低的方案,因为新技术前期稳定性是首要考虑的因素。因为在原生开发中,iOS 的 GCD 多线程方案已经运行了很多多年,我们对这些多线程的属性和用法也都非常熟悉,这也是我们选择它的一个关键原因,当然不足之处是需要编写一定量平台差异化的代码,但这些都是一次性的基建。
这是我们最终选择的一个与多线程相关的模型,即 Common 代码。通常情况下,如果我们要执行一个与多线程相关的异步任务,我们将其分发到两种任务队列里面去,串行队列和并行队列。先聊下串行异步队列,将一系列小的任务放入后台串行队列中执行是非常常见的,这对于平台类的应用开发这通常就足够了,因为这些任务比较小单独开一个线程去执行又显得较重,但直接扔进主线程多了又会卡顿 UI,有时各任务间又有一定的实效性需要保障。另一方面,如果我们有耗时较高的任务,我们需要将其放入独立的异步线程中执行。我们通常把需要异步执行的任务封装为一个个代码块,然后把它扔到合适的异步任务队列里面去执行。因此,可以看到我们的通用代码分为两条线路,iOS 上通过 iOSMain 的 GCD 实现,通过 dispatch 实现。而在 Android 上,我们则通过 BackgroudTaskUtils 来实现。在实际应用中,我们可以直接用自己 APP 中常用多线程组件来替换它,但对外接口上应该还是大同小异的,common 层声明了对外输出的接口层,抹平了双端接口的差异化,再向下就是各个平台独立于平台的实现了。
我们做移动 APP 开发的同学可能对线程和对象之间的关系可能还没太多的概念,因此在对象和线程关联方面可能没有形成一个习惯。KN 目前主要还是采用了传统的 legacy 内存管理方式,虽然从 1.8 开始也开发了一种新的叫做 New Memory 的内存管理方式,但在本文中我们将重点介绍 lex 的内存管理方式,先介绍两个规则如下:
规则 1:可变状态仅单个线程可见 。
状态,就是我们平时所说的变量或者说是属性,可以简单的理解为每个新生成的变量都是和当前线程关联的 ,离开这个线程到别的线程去这个变量就是不可见的,直接访问的话会产生 crash 异常。
规则 2:不可变状态才多线程可见 。
如果我们的应用程序永远只有一个线程,那所有的状态都是可变的就够了,但这是不现实的,所以如果希望一个状态能被夸线程访问或修改那它应该是不变的 , 这样多个线程才可以安全的访问它.
KN 定义一个新的运行期状态, 称为冻结 (Frozen). 一个对象的任何实例都可以冻结. KN 运行期对所有类添加了一个扩展函数 Freeze() . 调用它将会冻结一个对象, 并递归的冻结这个对象引用的所有对象. 如果一个对象被冻结, 那么你不能改变它的状态的任何部分. 尝试这样做会导致一个运行期 crash。
被 freeze 的实体,其所有属性都不可被修改 。
被 const 修饰的常量默认都是被冻结的状态。
在一个对象上执行 freeze() 会变成 frozen 状态,该对象所引用的其他对象都会变成 frozen 状态(含容器)。
freeze() 操作是不可逆的,如果需要获得非 frozen 的对象,只能将原先被 froze 的对象进行深拷贝,成为一个新的对象 。
当一个对象被 freeze 后,其子对象也会被冻结。换句话说,一旦一个对象被冻结,它引用的所有子对象、数组列表等也将被冻结。当运行期需要检查一个对象是否可以被其他线程访问, 它只需要检查对象是否被冻结。
Object 单例:默认在创建后就会被 freeze,对所有线程可见,但如果修改其内部引用,将会导致报错。
Top level 属性:默认仅对主线程可见,且是可变的,从其他线程访问将引发异常。
注:Top Level 的 val 变量被认为是 const , 默认也是 frozen 状态。
注意事项 :Top-level 属性必须在主线程初始化!
① @SharedImmutable:该注解可以让 Top Level 属性全局范围可见,但是被 freeze 之后无法修改。(此注解只能用于 val 属性)
② @ThreadLocal:给每个线程提供独立的可变副本。(此注解可用于 val、var 属性)。
如果需要在不同的线程中对其进行修改,用 @ThreadLocal 对其进行注解,这将允许它是可变的,并为每个线程提供其状态的副本,如何让多线程共享修改同一个对象的属性,将在后面提到。
前面我们讨论清楚了如何实现状态的多线程可见,但实际项目中仅可见是不够的,@ThreadLocal 只用来修饰 top level 属性,且只是为每个线程提供一份独立的副本,那如何让多线程能修改共享的属性呢?即可变性。
① Atomics(原子类)
② Thread-isolated states(线程隔离状态)
③ Low-level capabilities (略)
为了能够让多线程同时读取、修改一个冻结的状态中的值, KN 提供了一组 Atomic 类库。
1、对简单数值使用 AtomicInt/AtomicLong:持有共享的 Int/Long 允许多个线程读写。示例中 SomeState 是一个全局的 object, 在 KN 中它默认是冻结的,左边的 count++ 会产生异常,而右边的 increment() 是可以在任何线程中正常工作的。
2、AtomicReference :持有一个对象实例允许多个线程读写,持有的对象必须是 frozen 状态的。
示例:
data class SomeData(val i: Int)
采用 Atomic 封装的状态,取值、赋值的过程较繁琐 ,可以通过对外暴露一个普通的属性,然后修改其 get/set 方法来实现 Atomic 封装 。
循环强引用 & 内存泄露是我们经常遇到的一个问题,在 KN 中也不例外,比如当我们定义了一个 Kotlin 对象时,这个对象需要在 iOS 的 ViewController 中使用,对它有了第一个持有引用。由于在 ViewController 中会持有这个 kotlin 对象,同时为了使用的便利性 AtomicReference 对象也会引用这个 ViewController 对象而产生循环引用,这时候我们应该在 AtomicReference 中使用一个可为 null 的类型, 并在使用完毕时, 明确的设置为 null. 这样就可以这个循环引用导致的内存泄露了。
接下来是性能问题 。和一个标准的可变状态相比,访问并修改 AtomicReference 内引用的值是比较耗时 的,尤其是在频繁对引用的对象进行修改操作的情况下会对性能造成更大的影响,主要原因是跨线程导致,出现这种情况的时候不再建议使用该类实现,如果需要实现 MutableList 或 HashMap 时,该如何操作?我们引入线程隔离状态 。
隔离状态:将可变状态隔离到单个线程,并允许其他线程与该状态通信。比如一个列表对象,需要经常进行增删改查操作,用 AtomicReference<?>封装会导较大的致性能问题。这时,我们可以通过 Iso 提供的相关功能来解决这个问题。
IsoArrayDeque 的用法 :创建一个对线程具有独占访问权限的工作队列,并创建一个仅存在于该线程中的可变状态, 其他线程通过调度工作队列上的工作与可变线程进行通信。
AtomicReference> 和 IsoArrayDeque 该用谁 ?
① 对于单个的值,AtomicReference 是更好的选择。
② 对于值的集合,使用线程隔离状态是更好的选择, 主要的性能损失实际上是跨线程。
最后是基础库的建设,要发挥新技术的优势建设强大的基础库是关键。为此,我们充分利用『百度 APP』沉淀的各类组件和能力, 避免了重复建设。双端接口差异化抹平,使用起来也更方便,更有利于以保证双端逻辑代码编写的一致性 。
例如 SQLDelight 可以很方便的实现跨平台操作数据库的增、删、改、查,但是引入新库往往又会增加应用包体积,这些都是我们在做技术选型的时候要优先考虑的点,我们也可以采用 expect/actual 机制来定义接口协议,并用原生的实现来解决这个问题。我们也采用各自平台原生的实现(GCD)来解决多线程问题;对于我们必用的 KV 存储,底层使用了 SwanKV 和 MMKV;Json 解析库使用了 Serialization 来实现,我们也正在优化 Json 解析库的包体积。更底层的 StringUtls 和 Log 等都需要一起建设,尽管这些建设都是一次性的,但要做到业界共享目前还是有困难的,因为各公司的底层基础库还是有较大的差异,需要桥接到上面就需要各个团队开发自己的底层实现。所以,在实际更复杂的业务场景中,需要一套丰富、稳定、可靠的「跨端」基础能力也有不少的工作量,但随之逐步的完善 KMM 的核心效率优势就逐渐发挥出来了。
关于技术的应用落地,我们其实从 2021 年开始探索。最初我们落地了一个创新类的 APP,但目前该 APP 已经停止服务。接着,我们又成功地在百度 APP 中应用了该技术,大字版也于去年的 5 月份推出该技术。目前,我们已经在百度 APP 中应用了几个重要的业务场景,包括首页、一级 Tab 等。由于一些业务逻辑的共用,这些场景在运营中显得尤为重要。
就落地情况而言,我们发现在我们正在开发的这个新业务中,UI 交互占比较大。目前的数据显示,其占比大约为 33% 左右,因此我们认为这个领域还有很大的发展空间。
袁晗光,百度资深研发工程师。软件开发 16 余年,2013 年入职百度,目前负责百度 App 和其他多个创新类 App 客户端功能迭代与架构方向,长期从事研发质量效率的平衡探索。
当前,不管是前端还是移动端开发,都需要突破自身技术边界,实现单维能力向多维能力的转变,提前布局,并面向业务持续交付技术价值。从技术和组织架构两个角度来看,这都是一个痛苦的过程,前端和客户端能不能站在对方的角度全局性地看问题?技术上要如何储备?5 月 26-27 日,QCon 全球软件开发大会(广州站)将与你一同探讨,点击底部【阅读原文】直达大会官网,现在购票享 8 折优惠,组团购票还有更多折扣,感兴趣的同学请联系票务经理:15600537884(电话同微信)。