本篇为KMP技术的技术及实践系列文章的第三篇。在这篇文章中我们以一个实际业务视角,总结我们在使用 KMP 的 Share Logic 和 Share UI 两种模式在三端落地的经验与 infra 工程建设的互补。
工程结构简介
业务的落地离不开完善的基建能力,首先我们向大家介绍一下我们的 KMP 工程以及与各端 Monorepo 之间介面的基建情况。
构建系统
在之前的文章中,我们比较了 Gradle 与 Bazel 两种构建体系中使用 KMP 开发的优劣,并最终在我们的 KMP 工程实践中选择了使用 Bazel 构建系统作为根基。
选择“非JetBrains官方推荐的构建系统”意味着我们失去了许多由官方提供的原生能力,但也因此摆脱了 Gradle 体系的天然桎梏,使得我们可以基于 kotlinc 编译器等原子能力,在 Bazel 工具链中自由组合、调优传参,实现在 Gradle 体系中不可能做到的效果,这里介绍一下我们项目中实现的两个功能。
多语言混编
在项目一开始,我们就认为不同语言之间的互调用能力是跨平台项目的重点。在原生的 KMP + Kotlin Gradle Plugin 项目中,虽然能做到 kotlin 模块依赖 Objective-C 模块,但是需要模块开发者自行组织 objc 代码的编译过程,如 ComposeMultiplatform 项目配置所示。
所以,我们的第一个工作是实现 Kotlin Swift Objective-C 三者之间的无缝互调。借助 Bazel 的工具链注册机制和 Aspect 概念,我们可以在编译时提取出各语言模块的 binding 元信息,借助 Kotlin 本身优秀的 c-interop 能力,即可轻松实现 kotlin 与各语言之间的互相调用。我们只需要这么配置项目:
kt_library(
name = "kt_module",
deps = [
"c_module",
{
"ios" = [
":objc_module",
":swift_module",
]
},
]
)
objc_library(
name = "objc_module",
srcs = [ "oc_module.oc" ],
hdrs = [ "oc_module.h" ],
)
swift_library(
name = "swift_module",
srcs = [ "swift_module.swift" ].
)
cc_library(
name = "c_module",
srcs = ["module.c"],
hdrs = ["module.h"],
)
就可以直接在 kt_module 的 kt 代码中使用到 objc 和 swift 两个模块提供的符号和能力,无需额外配置 c-interop 的内容了。这使得我们在需要使用平台能力,特别是在iOS上需要使用系统api的时候,可以方便地直接新建swift模块,从而避免在kt中调用oc代码的种种不便。
基于此能力,我们已经实现了 Kotlin 语言与 Swift、Objc、C/C++,甚至于 zig 语言的混编,并且后续也能方便地添加 go、Rust 等其他语言的支持。
依赖注入与分模块导出
在B站的实践中,KMP 实现的模块是作为被依赖的底层,被原本各平台的私信模块直接依赖而带入主站app中。
碰到需要使用平台现有代码的时候,借助依赖注入框架可以很方便地使用平台现有的接口及实现。在安卓中,因为所有 KMP 模块都是独立模块,没有任何问题:
在 iOS 平台,为了能使用平台现有接口及实现,我们首先实现了自动生成 swift 模块在 kotlin 语言中的 binding 功能,然后借助 Bazel 的自由组织编译工具的能力,做到了可以在 KMP 工程里自由地添加和依赖 Swift 模块,这样靠现有的 iOS 依赖注入功能,可以用这样的模块依赖关系实现平台能力依赖:
但以上方式只能做到在 KMP 模块中使用接口声明在平台层中的功能模块,并且需要在 KMP 模块中额外声明一份接口、做一次功能转换,容易出现模板代码。
如果希望将接口声明在处以依赖层级较底层的 KMP 模块中,然后使用独立的 Swift 模块实现这个接口并使用平台代码,就会出现这样的依赖关系:
虽然这个问题可以转化为上一种情况,在平台和 KMP 层都声明同一份接口,在 Kotlin 中做互转来避免,但这样一定会出现重复代码,不符合我们的代码美学。我们发现这个问题的根因是所有的 KMP 模块只能聚合成为一个framework中的一个模块进行导出,并且经过调研,发现 Jetbrains 有计划支持 Swift 导出、且支持分模块导出;但是他们的分模块导出实际上是支持声明选择多个 Kotlin 模块进行合并导出,并且需要一个形式上的根节点进行汇总,这同样不满足我们的需求,并且此功能正式上线还为时尚早,我们决定同样借助 Bazel 的能力,自行实现 KMP 工程的分模块导出功能。
同样借助 Bazel 的 Aspect 机制,我们在编译时首先提取了 Kotlin 模块的元信息,在编译 Swift 模块的时候作为一个个独立的、可被直接依赖调用的 clang module 作为编译参考,让这些 Swift 模块得以独立编译通过,随后再使用 kotlinc 的编译、导出能力整合成为 framework 编译产物,与 Swift 模块的产物共同链接成为 iOS 的包产物。
分模块导出的能力让我们的 KMP 工程在 iOS 项目中获得了跟安卓平台相媲美的自由度,并且已经成功上线。基于目前公开的技术分享信息,我们相信B站是国内乃至世界上首家独立实现KMP工程分模块导出的公司。Bazel 的自由组织能力让我们的基建能领先 Jetbrains 一步,自行扩展工具链的能力。
从工程落地到业务落地
经过一段时间的建设,我们实现了在安卓、iOS、鸿蒙(以下简称三端)之间共享逻辑代码(Share Logic),以及在安卓、iOS(以下简称双端)之间共享Compose UI代码(Share UI)两个基础功能,因此将 KMP 工程投入到业务落地的检验中也提上了日程。
适合怎样的业务
作为 KMP 跨平台技术在B站的首次实战,并且是推广给其他业务方参考的样板项目,这个首次业务落地就完整地需要做到三端共享 kotlin 实现的逻辑代码,以及在双端共享 Compose UI 代码两个特点,那么就会要求这个模块实现完全的UI逻辑分离;
同时,考虑到我们在之前的 Compose 实践中遇到的基础体验不完善的问题,如 TextField 无法处理 TextInlineContent 内容等问题,我们总结了能比较好地适配 KMP 技术特征的落地业务的业务特点:
-
重逻辑轻UI,能将尽可能多的代码沉淀在可三端复用的逻辑层;
-
重展示轻交互,充分发挥 Compose 在静态展示上的编写和性能优势。
于是我们选择了B站的私信会话列表页作为这一次的实践场景。
私信是在B站app中存在已久的业务,在列表页中就沉淀了大量的业务功能逻辑,如收到信息推送时需要主动刷新列表信息;单个会话更新时需要对整个列表排序;点击单个会话时需要主动消去对应的未读数等等,这些复杂易错的逻辑代码正适合收拢在 KMP 中一次实现,降低各端各自出错的可能性。
而在 UI 展现上,会话列表页则是一个很典型的列表展示页面,列表元素较为复杂,但没有复杂的交互元素和动画样式,也适合发挥 Compose 构建复杂展示页面的特长。
技术选型
作为一种全新开发模式的一次尝试,我们需要为这次的模块设计选择能适应 KMP 特性的方案。
首先是因为我们需要实现逻辑UI的完全分离,并且需要在平台实现的UI层接入跨平台的逻辑层,那么就需要一个范式来保持两层之间的切面足够小;
其次,在已经成熟上线的双端app中使用 KMP 跨平台开发,就免不了需要使用到主工程中已有的代码与功能。虽然说 KMP 有着优秀的与平台代码的互调用能力,但跨语言调用肯定还是存在一些限制的;并且目前 KMP 工程在 iOS 项目中的导出会更突出这一点限制,后文中会详细展开这一部分;
与之前单端内能闭环完成的大需求项目不同,这一次 KMP 重构的私信会话首页使用的是由三端各出一名开发,共同完成共享的逻辑层和各自平台的UI层的合作方式,团队成员的技术栈差异比以往的都更加地大,那么在技术架构设计中,降低团队内部沟通成本也称为了需要注意的一个点。
在这个全新的私信中,我们的结构设计主抓三个关键点:
-
单向数据流
-
依赖注入
-
函数式编程
状态机实现的单向数据流范式
使用 Compose UI 其实已经将我们的结构设计的上界限定为单向数据流了;而我们在之前的实践中,尝试过*行实现* MVI 范式实现逻辑代码,发现它并不适合在大型模块中处理逻辑。其中一个典型的问题是:每个Intent的处理流程是独立的,在一个Intent正在被处理的过程中很难因为其他条件变化而取消这个流程。
考虑一个常见的场景:页面支持上滑翻页和刷新,在翻页加载的过程中一般是运行进行刷新操作的,而如果刷新的结果提前被响应了,那么后续返回的翻页结果应当被抛弃。在 普通的 MVI 范式中,可能会考虑记录翻页的任务,在刷新时主动取消:
var loadJob: Job? = null
suspend fun refresh() {
loadJob?.cancel()
// ...
}
或者在状态类的设计中设置大量的判断标记位,所有的异步任务在更新数据之前都要判断标记位是否满足自身的限制:
data class ListPage(
val refreshCount: Int = 0,
val page: Int = 0,
// ... other data fields
)
suspend fun onPageLoaded(state: ListPage, intent: LoadedIntent): ListPage {
if (state.refreshCount != intent.refreshCount || (state.page + 1) != intent.page) {
// 判定不满足翻页条件,不作任何数据操作
return state
}
// 追加翻页数据
}
但这两种方式在复杂业务中都难以适用,手动记录所有的耗时任务需要引入大量的额外字段,并且需要自行判断在什么情况下需要取消什么任务,会极大地增加后续的维护难度;同理,增加标记位也存在难以维护的问题,并且可能存在无法被简单标记位排除的情况。
经过一段时间的实践与选型,我们最后选择使用了FlowRedux实现的基于状态机的单向数据流范式。在我们的项目中,它有着这些特点:
-
可以精确地控制每种 Intent 可以生效的作用域,在不合适的状态或条件下不再响应,降低控制逻辑代码的复杂度;
-
状态生效作用域同时也是对应协程的作用域,在离开对应状态时直接取消对应作用域下正在运行的挂起函数,避免耗时任务的结果被错误响应;
-
状态机可以方便地嵌套在其他状态机中使用,方便地实现领域的隔离和复用。
对于上面这个case,使用状态机可以这么实现:
spec {
inState<ContentState> {
on<LoadMore> { action, state ->
val nextPage = load()
// 只需要关心自己的逻辑
state.mutate {
// 追加翻页数据
}
}
on<Refresh> { action, state ->
state.override {
// 需要刷新时,直接进入新的状态
Refreshing()
}
}
}
// 进入新的状态时,前面 on<LoadMore> 块发起的所有挂起任务都被取消,加载下一页的请求自然也不会再被响应
inState<Refreshing> {
onEnter { state ->
val newPage = refresh()
state.override {
ContentState(newPage)
}
}
}
}
在状态机明确的生命周期的保护下,我们可以方便地实现复杂业务逻辑,并且不用担心事件在不合适的条件下被响应。
在这个设计之下的私信会话首页的逻辑层,对外只有两个核心元素:val state: Flow<SessionState> 用来获取最新的页面状态数据类,以及 suspend fun dispatch(action: SessionAction) 分发用户的页面操作事件,保证了足够小的UI逻辑交互切面之后,就能很方便地把多端的UI实现对接到统一的逻辑层中。
函数式的功能模块实现
在前范式中,已经有了一个能完整描述整个业务状态的数据类、和统一管理这个状态类的状态机,那么在实现私信状态机所需要依赖使用的服务模块时,我们借鉴了函数式编程的理念,优先考虑实现为无状态的纯函数代码。同时服务模块对于其他服务的依赖,则要求声明为对于相应接口的依赖,由依赖注入框架提供实现。
interface AService {
suspend fun doSth(param: Param): Result<String>
}
interface BService {
fun getDataFlow(): Flow<Data>
}
// 对于其他服务模块的依赖,一律声明为接口,由依赖注入框架提供实现
class BServiceImpl @Inject(private val aService: AService): BService {
// 没有成员变量,保证接口实现没有副作用
override fun getDataFlow(): Flow<Data> = flow {
// 业务实现,可以随意编排依赖服务的功能
aService.doSth(Param()).fold(
onSuccess = {
emit(Data(it))
}
)
}
}
在这样的设计规范下,接口本身即可描述自身的功能,开发人员可以根据接口与函数签名理解其中需要实现的功能,降低团队内部沟通实现上下文的成本。在需要其他依赖时,也能将需要的功能抽象成为接口描述,在现有的接口中寻找支持的功能。
这样功能模块的实现就成为了互相独立的部分,我们可以方便地按照不同开发人员的进度灵活地分配开发任务,发挥跨平台团队中多人开发的人力优势。
并且在编写单元测试的时候,因为每个功能模块的实现逻辑高度内聚,对外接口简洁且保证幂等,方便我们将测试切入点关注在模块功能本身,依赖项则可以按需提供mock实现,能在多人并行开发的时候不需要等待依赖项完成即可验证自己的功能,在状态机本身的高可测试性的基础上提供更多维度的单测入口,能有效提高代码的维护性和开发效率。
与平台代码的融合
在B站的项目中,KMP模为现有代码的补充,融入到既有代码框架中的。为了让KMP模块成为整个大仓项目中的有机组成部分,而不是一个独立于工程之外的三方依赖,在实践中还有两个问题需要解决:通过怎样的接口范式被平台代码调用,以及怎么跟现有的 monorepo 大仓一起组织起编译。
项目组织方式
B站的各端app工程都使用 monorepo 模式,在编译时会将所有内部代码的源码放在一起构建,并不会将 KMP 模块提前打包上传二进制产物。在 KMP 模块确定使用 Bazel 进行构建组织后,怎么跟现有项目进行集成则成了下一步的问题。
B站的 iOS 大仓本身同样是使用 Bazel 进行构建组织的,所以与我们的 KMP 项目天然亲和。在实现上述的 Kotlin 分模块导出之前,尚且需要一个壳导出模块依赖所有的需要导出的 Kotlin 模块,作为 Swift 的依赖入口:
// Shell module
kt_module(
name: "KMP_Shell",
srcs: [...],
deps: [
"ModuleA",
"ModuleB",
],
)
// Module A
kt_module(
name: "ModuleA",
srcs: [...],
)
// Module B
kt_module(
name: "ModuleB",
srcs: [...],
)
在 Swift 模块中,需要依赖壳模块作为入口:
import KMP_Shell
//... Code here
一个突出的问题是,所有的 kt 符号聚合到一个模块中之后,面向 Swift 的头文件会变得非常巨大,不利于开发寻找自己需要的内容,并且容易卡死 XCode。
而完成了前文所说的分模块导出之后,所有的 Kotlin 模块可以直接独立导出,让 Swift 模块分别按需依赖。
// 不再需要壳模块了
// Module A
kt_module(
name: "ModuleA",
srcs: [...],
)
// Module B
kt_module(
name: "ModuleB",
srcs: [...],
)
// 按需依赖
import ModuleA
//... Code here
在与 Android 项目集成方面,原本 KMP 模块是能做到无缝集成的,但因为我们选择了 Bazel 构建,那么还需要做一些额外工作。目前我们选择的是通过特定的 Bazel rules 为所有的 KMP 模块生成一个标准的、有效的 build.gradle.kts 配置文件,在安卓大仓编译时作为普通 gradle 项目进行集成。
在鸿蒙系统上,因为鸿蒙本身的构建工具链还不够稳定,因此我们选择了暂时使用二进制集成的方案,使用 Bazel 构建出 .so 二进制产物进行集成。
整体的项目结构图如下:
与平台代码的交互范式
在包含了 KMP 模块的项目中,安卓与之的互交互性,而选定交互范式则是为了让 iOS 和鸿蒙两个 Native 平台能访问到对自己相对友好的 Kotlin 接口。
Coroutine First
在上文中提到了我们在私信业务中使用了 Redux 这个使用状态机实现的 MVI 接口。事实上它可以简单地被描述为一个接口声明:
interface StateHolder<State, Action> {
val state: Flow<State>
suspend fun dispatch(action: Action)
}
为了让协程中的 Flow 和 suspend 函数在 Native 中也可用,我们首先选择了 NativeCoroutines 插件,用来在 iOS 平台上生成可以友好地被 Swift 调用的入口。
NativeCoroutines 要求我们在标注 flow 或者 suspend 函数的同时,在相同的作用域中为一个有效的 CoroutineScope 加上 @NativeCoroutineScope 注解。而安卓开发常用的 androidx.viewmodel 中自带的 scope 是无法在跨平台项目中正常使用的,因为在 iOS 和鸿蒙系统中没有对应的 Lifecycle 概念,无法像安卓一样把 ViewModel 的生命周期与页面关联,做到自动取消。
所以在 iOS 平台上,可以借助 Swift 语言的 deinit 特性,为 CoroutineScope 封装一个 wrapper ,并在对应的 deinit 回调中自动调用 CoroutineScope.cancel() ;在使用时,需要在页面层级持有此 wrapper ,那么在页面被关闭回收时,其持有的 wrapper 将相应地被回收,自动触发 deinit 中的 cancel,实现 CoroutineScope 的自动清理。
在鸿蒙平台上,可以将 Flow 转换为 Observerable 对象,然后使用类似 Rx 系列库的 SubscriptionManager 进行统一的取消管理。
在实践过程中,我们确立了一个准则:即使是在跨平台的代码中,协程也是异步代码的第一选择。相比其他的方案,如使用 Rx 系的三方库,或者裸传 callback 对象,使用协程可以让我们避免在 kotlin 中持有 native 世界的对象,这一点在 iOS 平台上犹为重要,因为在 Swift 这种 ARC 管理对象的语言中传递一个实体对象到使用 GC 的 Kotlin中是特别容易产生内存泄露或者空指针异常的;而协程自带的 Scope 与生命周期管理则能帮我们避免这些问题。
Keep internal as possible
Kotlin 在将模块导出到其他编译时,有两种处理方式:
-
在编译 K/JS 目标时,只有标记为 @JsExport 的符号会被导出;
-
在导出Objc时,除了标记了 @HiddenFromObjc 的 public 可见性符号都会被导出。
在鸿蒙转为 K/NA 目标之后,我们只需要关心方式2的导出。我们发现,按安卓平时习惯的将更多的符号保持为 public ;然而这会导致面向 iOS 世界的导出产物特别巨大,影响编译速度和 iOS 的包体积。因此结合上一条,我们将要求我们的代码:
-
尽可能将符号保持为 internal 可见性;
-
确实需要 public 可见性的,考虑加上 @HiddenFromObjc 注解;
-
确实需要暴露给 iOS 调用的协程接口,需要使用 NativeCoroutines 相关注解。
以此来保证 KMP 代码对原生平台的友好度。
结语
KMP 实践在私信模块的成功落地,标志着我们的工程建设已经到了可以支撑实际复杂业务的阶段。而我们将继续扩展 KMP 项目在B站中的应用场景、扩展基础能力对于业务需要的易用性。
在后续的文章中,我们将继续分享 Compose Multiplatform 在项目中的落地情况,以及跨语言依赖注入的实现思路。
-End-
作者丨肖志康、Snorlax