Kotlin 移动端跨平台是种怎样的体验

人类对效率的极致追求推动着社会进步

by 苏格拉面

前言

近些年,移动端无论是在技术还是在市场方面都已经逐步成熟,效率成为各家争夺存量 、开拓疆土的利器,因而跨平台技术的热度也如日中天。其中Flutter算得上是跨平台技术的网红,易上手、UI像素级同步、性能优异,引起一大波粉丝(各大厂)为它打call,各大厂商都为Flutter社区带来了很大贡献,迅速扩大了它的技术生态。实不相瞒,个人也是Flutter的小粉丝,既然Flutter这么好,那我还在这吹什么Kotlin的牛逼呢。

首先, 两者不是竞争关系 ,可以相辅相成 ,Flutter是框架,从用户最关心的UI切入,Kotlin是编译器+SDK,从开发者最关心的语言切入,完全可以用Kotlin实现双端业务领域,用Flutter实现双端视觉领域。其次,纵观技术历史,语言比框架的寿命长,好比一个是原材料一个是零部件,而且框架是约束的集合,约束是取舍,取舍也意味着灵活性的丢失。再者,鸡蛋不要放在同一个篮子里,保持不同技术的关注也是很必要的。技术里面没有非什么不可的道理。

因为Kotlin移动端跨平台主要使用的Kotlin/Native这部分,接下来都会用KN来指代Kotlin移动端跨平台。

跨平台技术决策

在选择跨平台技术时,我们权衡的点是什么?一般都会从技术角度去思考的,性能要好、生态要大、技术栈迁移成本低,这其实有点本末倒置,因为用户才是企业根本,如果不能满足用户体验,画面丑、操作响应慢,跨平台技术能提高多少效率都是徒劳。我认为这也是Flutter迷倒万千少男的原因——漂亮、勤快的“小花瓶”,同样的,KN的切入点更为“底层”,在任何方面的体验都能跟原生一致,没有限制,所以使用KN来进行移动端跨平台研发也未尝不是个值得尝试的方向。

其实,我个人偏向于抱着商业的视角去看待技术,很少技术特性是仅此一家的,就像各家app的功能都在互抄,技术也有这样的现象,胜出的关键点在于启动时的技术定位(能不能吸引种子用户)、和社区运营(提高用户迁移的沉末成本),这有点扯远了。

Kotlin跨平台介绍 

>技术现状

Kotlin Multiplaform是一个相当庞大的内容,包含Android、IOS、macOS、Window等多系统,JS、Java、ObjC、Python等多个技术栈。目前整体上还处于试验阶段,但Android/IOS领域是Kotlin团队的首要落地场景,提供了很多的支持,已具备可用性。针对常用的工具库也有相关的跨平台支持:stdlib、io、serialization\序列化、coroutines\并发、ktor\网络、SQLDelight\数据库。

>技术优势

以往的跨平台技术通常是在原生之上构建 中间层 抹平平台差异,这个 中间层或是虚拟机或是框架 ,不可避免会带来 额外的成本 :引入技术栈、非原生性能、框架场景受限、割裂的技术生态。Kotlin选择从编译产物的角度实现跨平台,它的编译产物与原生一致,打通了原平台技术生态,可以与存量库零成本互操作, 以最小化可实现的方式在原工程中接入。 这意味着统一的技术栈,原生的性能,融合的技术生态,源码级别的灵活性。 从实践体验来说,这很香!

>银弹还是臭蛋 

一款技术没经过线上的洗礼,尽是纸上谈兵都是在耍流氓。庆幸的是,有一群开拓者愿意去尝试。目前使用Kotlin共享双端代码的团队有:Yandex:俄罗斯的百度月活跃4000W、Quizlet:月活跃5000W的在线教育应用、IceRock:企业服务公司、Careem:打车软件等等,在效率、研发流程、性能方面的收益都十分明显。

Kotlin跨平台原理

编译产物要跟原生平台一致,本质上靠的还是编译器。Kotlin跨平台目前实现了三个编译器,分别是JVM、JS、Native。Kotlin源码经过编译前端处理后,分别产生bytecode、js ast、llvm ir产物,在这个阶段开发者可以注册自定义编译插件对这些中间产物进行修改,最后再由编译后端把中间产物转化为平台可执行文件。

Kotlin跨平台 不是抽取平台公有的api再封装 起来,而是让开发者在 共享代码 的同时又可以 随意访问平台相关的api 。为了实现这一点,需要一套类似声明/定义的机制,也需要隔离不同平台的代码。

>隔离平台代码

Kotlin通过目录的方式,区分共享代码与平台代码,编译时编辑器合并共享目录+平台目录的源码,再编译成指定平台产物。过程如下:

>声明/定义机制

充当声明/定义角色的就是expect/actual机制,common共享目录只有Kotlin标准库依赖,无法调用任何平台相关的api。开发者需要在共享目录下使用expect关键字声明平台目录需要实现的类/方法/属性,然后在平台目录下使用actual关键字实现具体逻辑。平台目录与共享目录的关系类似C++里面的头文件与源文件的关系,只是这种关系更加“弱”,平台代码可以在expect声明之外扩展更多平台差异化功能。举个栗子:

// common目录

expect class LogUtils {

fun log(msg:String)

}

// ios目录

actual class LogUtils: UIView {

actual fun log(msg:String) = NSLog("ios: $msg")

fun showLogMsgView() = NSLog("show")

}

// android目录

actual class LogUtils: Runnable {

actual fun log(msg:String) = Log.d("ios: $msg")

override fun run() = Log.d("upload log to server")

}

进一步的,可以通过Kotlin类继承IOS平台各种UIView,实现 纯Kotlin 手撸IOS界面。

并发模型

KN 借鉴了JVM的GC停顿以及Objc的GC循环引用内存泄露问题,为了实现更好的自动内存管理与并发编程范式,提出了新的并发模型。 在这套模型下,可以实现高性能、全自动的内存管理。同时,在新的并发模型下,我们不能以常规的方式去写并发代码,在实践KN之前,必须全面了解这套并发模型,否则会重复掉坑,爬坑的过程。当然,也不需要过度恐慌,一旦掌握了,写起来也是丝毫不费劲的。

>特点

  • 每个线程只管理范围内的对象,避免JVM “stop world”问题

  • 每个线程会各自分析循环引用链,避免Objc引用计数的内存泄露隐患

  • 每个线程的引用计数默认为单线程非原子性,性能优异

为了方便直观理解 ,画了个内存模型图: KN对象 代表受KN运行时管理的对象,KN运行时可以理解为内存管理框架; native对象 恰好相反,可能是开发者管理也可能受Objc运行时管理; 实线箭头 代表对象引用关系; 虚线箭头 代表角色转换关系。

废话不多说,上高清图:

>基本规则

  1. 每个 KN对象 都有并发标志Frozen,默认为false

  2. Frozen标志会 递归传递 给属性变量以及属性变量的属性变量

  3. Frozen一旦设置就 不能取消

  4. Frozen对象 不能修改但是 能被其他线程读取

  5. 非Frozen对象 只能 被创建对象的线程读/写

  6. KN对象在 KN运行环境 中一旦被其他线程的作用域捕获,则自动设置为Frozen

  7. Object单例以及基础类型默认为Frozen,能够在线程间共享

>开发指引

  • 上述规则都由KN运行时保证,非KN对象不受限制,而KN对象与Native对象之间可以通过DetachedObjectGraph来相互转换,基于这个原理可以 实现KN对象在不同线程间可读写 ,如下例子

上述例子中, mutableData 先在 子线程读写 ,随后还能在 主线程读写

底层实现是通过把对象引用链从一个线程的GC转移到另一个线程的GC

DetachedObjectGraph的闭包返回值对象必须没有外部引用,否则两个线程同时持有mutable对象可能出现野指针异常

KN运行时默认会保证转换安全

这种检查可以通过设置TransferMode.UNSAFE来禁止

禁止KN运行时检查可以提升运行性能,在实际应用中可以在开发测试阶段开启,发布阶段关闭。

  • 因为有这些规则的存在,在并发编程上,应该由常用的锁编程转向无锁编程,用消息共享内存,配合协程实现高并发

  • 确实需要锁编程的地方可以用atomicafu,但可能会导致内存泄露以及性能瓶颈

  • 匿名类的隐式依赖可能导致外部类被自动Frozen,需要额外注意,举个栗子:

这样的代码十分常见,但在KN中会有意外的“表现”。

KAPT与KCP

Android工程化中经常会用KAPT配合实现字节码增强工具,但是KAPT只面向JVM平台。 在跨平台开发中,也有相同的需求场景,取而代之的是KCP(Kotlin Compile plugin)。 KCP作用于编译阶段,可以对编译产物进行高度 制,功能强大到能实现新语法特性,满足各种奇怪姿势,但使用起来难度也相当高。

>功能对比

KAPT KCP
功能

代码生成

代码生成/修改/删除,扩展语法特性,优化编译产物,代码静态分析

相关资料

文档齐全 只有源码
上手难度 切菜模式 地狱模式
产物 Java源码或字节码

LLVM IR/Js/Java字节码

源码支持 Java/Kotlin Kotlin
平台 JVM 跨平台

>插件架构

  • Plugin:普通Gradle插件,是KP(Kotlin Plugin)在Gradle中的代理,用于读取Gradle中的配置

  • Subplugin:为KCP提供自定义KP的maven库下载信息,以及配置信息

  • CommandLineProcessor:将参数转换为KP可识别参数

  • ComponentRegistrar:负责注册Extension到KCP不同流程中

  • Extension:实现自定义的KP功能

项目实践

改造的项目选用一款账号SDK,它本身依赖其他的SDK,代码量适中,存在View的依赖,有网络、线程、序列化、io存取操作,具有相当的普遍性。

>改造流程

  1. 源码全部转为Kotlin,因为本身就是用Kotlin开发的,这一步直接省去了 

  2. 跨平台项目改造,涉及Gradle配置和源码目录修改,成本不高,可参照 官网指引

  3. 把所有源码放入到common目录中,此时对于java以及android的依赖都会出现错误,因为common不会有平台相关的依赖。

  4. 出现的错误分类为:序列化、线程、网络请求、View、数据库、并发控制、外部SDK依赖

下面列出改造计划

改造前依赖  改造后
序列化

JSONObject

serialization库

线程

Handler

coroutines库

网络请求

HttpClient

分平台实现(Ktor在Native中存在bug)

View

原生View

分平台实现

数据库 DBHelper

SQLDelight库

并发控制

concurrent库

atomicfu库

依赖

Gradle

提供接口,分平台实现(cocoapod插件还在制作中)

>产出效果

  • 改造后项目结构

  • 改造后,桥接第三方库的代码写了不到100行的Swift代码,其他全是Kotlin,共享文件31个,平台相关文件8个,瞎算复用率大概74%

  • 运行效果的视频涉及保密就不放了

总结

虽然标题是移动端跨平台实践体验,但是好像没怎么说改造流程,确实很标题党。因为个人感觉改造的具体流程跟项目相关性较大,没有参考意义,而更多的是讲一些通用的,在改造过程中需要注意的问题和概念。起初也只是抱着了解的心态去尝试, 但是整个流程体验下来,确实 惊艳到了我 ,无论是debug还是编码,而当我用Kotlin类继承IOS的UIView实现自定义组件时,那感觉实在是太爽了。唯一感觉不适的是不支持cocoapod,导致写了100行的swift代码,跟官方沟通了解到,cocoapod的插件已经在进行中了,相信不久后就能尝上鲜。

思考

1. Flutter、Kotlin跨平台会不会被苹果大爷禁?

可能性小,我理解苹果的原则有两个点,一个是以用户体验至上:你别整个UI丑的技术,叫你支持暗黑模式你就得乖乖支持;另一个是商业利益:不能背着我赚钱,不能阻碍我赚钱,前者也是后者的根基。不打破这两点一般都表现得人畜无害。要是小程序Flutter化,苹果大爷可能还蛮开心的,用户体验梭梭 升,功能集动态化就好了。

2. Jetpack Compose也是独立的UI框架,未来有没有可能支持IOS?

很可能,最近闲暇时间会看相关源码,但也不意味着Flutter就没用了,就像上面提到的可以用来做小程序引擎,而且Fushsia系统也把Flutter当做UI框架。

3. KCP这么难开发,会不会成为推广阻碍?

前段时间撸了一个跨平台的hugo插件,能在函数中插入打印运行耗时的代码,整个过程其实也还好,打断点查看对象数据结构,再参照源码,了解ir的结构就能实现代码增/删/改了,如果开发一个 类似ASMPlugin的插件,能直接查看源码的中间产物,那上手还是很容易的。

4. Kotlin会支持 Flutter 互操作,或者Flutter会支持Kotlin 互操 吗?

互操作本质是因为构建的产物一致,我理解Flutter如果要支持互操作存量Kotlin代码是很难的,至少在现有基础上很难。但是可以以一种伪互操作的形式,比如IDE插件支持自动提示,KCP负责把调用转换为Channel。

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章