昨天看到鸿蒙设备正式突破1000万,其实在鸿蒙Next还在内测的时候,我们就参与了鸿蒙的生态建设,推进我们的产品鸿蒙化,当时还上过2024年华为开发者大会的宣传。未来也差不多明确了一个项目必须要同时维护三个平台,着实压力不小。可以说Harmony也是我们下定决心要使用跨平台技术接入的“最后一根稻草”。
多端同步的问题
其实我们一直有一个很头疼的问题,因为项目的起步时间和公司内部技术栈的情况,我们的所有项目几乎都是Android已经发展了一段时间,已经有了一定的用户体量后,才会推出iOS端。iOS端一“出生”就面临着必须追赶Android端的脚步实现已有的功能、也必须迎接市场的挑战去做新的功能。
这也导致了我们的项目在不同的端的进度一直没有办法对齐,Android端1年前就有的功能,iOS端可能一直没排上期。现在又多了一个平台,那也意味着三端的功能“一定”是对不齐的。而我们的鸿蒙端上线后收到的最多的反馈就是,“鸿蒙端怎么找不到xx功能”、“xx功能怎么没有Android好用”、“什么时候上xx功能”,而我们负责鸿蒙的同事就算把键盘敲冒烟也不可能在短期内做到Android端的全部功能。
而同时在已有的Android和iOS项目中同步的功能也一直在出现问题,同一个业务逻辑,Android和iOS处理时总会出现不一样的情况,以一个数据上传的功能举例,Android端实现后,iOS端做的时候发现接口的字段iOS需要额外包一层,所以需要做出修改,这个时候只能服务端开一个v2接口给iOS端使用。
这也意味着,我们必须做出适应时代和市场的改变。
在考虑和研究了很多跨平台方案后,因为团队技术栈和现有项目的因素,最终选择了KMP做为解决方案,实现以下目标:
1、快速的同步我们项目的Android、iOS、Harmony端的功能,特别是Android端已经存在的功能,能够快速同步到其他端
2、保持较高的性能
3、统一业务逻辑,减少多端对齐带来的时间浪费
4、以业务层为核心,指导ui层,ui层只需要根据接收到的指令显示布局和交互即可
从最简单的模块开始
我们的项目中有一个意见反馈模块,这个模块的功能和页面是比较简单的,也是最容易迁移的模块之一,我们决定拿这个模块作为试水的模块之一,首先梳理一下将这个模块kmp化的思路
1、意见反馈界面需要展示qq、邮箱、反馈类别等数据,需要集中管理这些数据由kmp下发
2、需要封装用户反馈的文本和设备信息传递给服务端
3、封装上传文件
4、下发状态给ui层,指导ui展示“反馈成功”、“加载中”、“反馈失败”等交互
总结其实就数据和ui状态下发的处理
在项目中选择创建module,然后选择kmp
不同的目录下对应不同的平台的特性代码
其中commonMain是公用的代码逻辑所在的地方
以ui状态下发举例子
在commonMain中先创建状态
sealed class FeedBackState {
object Init : FeedBackState()
object SubmitSuccess : FeedBackState()
object SubmitError : FeedBackState()
object SubmitLoading : FeedBackState()
}
然后创建控制中心
class FeedbackViewModel() {
private val _uiStateFlow = MutableStateFlow<FeedBackState>(FeedBackState.Init)
val uiStateFlow get() = _uiStateFlow
}
然后在相关界面的ui改为接收
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
mFeedbackViewModel.uiStateFlow.collect { state ->
when (state) {
is FeedBackState.Init -> {
}
is FeedBackState.SubmitLoading -> {
}
is FeedBackState.SubmitSuccess -> {
}
is FeedBackState.SubmitError -> {
}
}
}
}
}
数据管理同样放入FeedbackViewModel中,网络请求改用ktor,创建client后就可以触发请求:
fun createDefaultClient(): HttpClient {
return HttpClient {
install(Logging) {
logger = object : Logger {
override fun log(message: String) {
timber().d(message)
}
}
level = LogLevel.ALL // 可选:BODY / INFO / HEADERS / ALL
}
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = true
})
}
defaultRequest {
val headers = getPlatformHeaderProvider().getHeaders()//加入请求头
headers.forEach { (key, value) ->
header(key, value)
}
}
install(HttpTimeout) {
requestTimeoutMillis = 15_000
connectTimeoutMillis = 10_000
socketTimeoutMillis = 15_000
}
}
}
平台特性
现在遇到个问题,如果我们需要用到平台特性代码,例如获取设备id,设备系统版本,公共的部分不支持,就可以使用Kmp提供另外一个能力:各平台自己实现相关能力
首先在commonMain中定义一个接口和一个expect方法,他会提示你需要在各个平台单独实现功能
interface PlatformInfoProvider {
fun getChannel(): String
fun getVersion(): String
fun getModel(): String
fun getNetwork(): String
}
expect fun getPlatformInfoProvider(): PlatformInfoProvider
以Android平台举例,在androidMain下自动生成PlatformInfoProvider.android.kt类,我们稍加改造:
class AndroidPlatformInfoProvider() : PlatformInfoProvider {
override fun getChannel(): String = ChannelUtil.getChannel()
override fun getVersion(): String = “1.0.0”
override fun getModel(): String = "${Build.BRAND}-${Build.MODEL}"
override fun getNetwork(): String = getCellularOperatorType()
}
actual fun getPlatformInfoProvider(): PlatformInfoProvider = AndroidPlatformInfoProvider()
Flow替代liveData
我们原本的项目结构是MVVM结构的,在ViewModel中使用的是liveData,替换为kmp之后需要注意改用FLow,而FLow有两中,SateFlow和SharedFlow,两者的区别如下:
| 特性 | StateFlow | SharedFlow |
|---|---|---|
| 是否有初始值 | 是,必须有 | 否,可以没有 |
| 是否会保存最新值 | 是 | 默认不会(可配置 replay) |
| 是否支持多个订阅者 | 是 | 是 |
| 是否粘性(新订阅立刻收到数据) | 是(立即收到最新值) | (根据 replay 配置) |
| 是否适用于状态类 UI 数据 | 非常适合 | 更适合事件或一次性通知 |
| 是否替代 LiveData | 是(最接近 LiveData) | 更像 EventBus |
举个使用的例子:
当你需要传递重复的数据时,应该使用StateFlow,因为SharedFlow对于重复的数据会忽略,当然可以通过配置解决,但是还是建议使用官方推荐的方式。
在Kmp中可以这样调用
suspend fun submit(feedbackMessage: ModelFeedbackMessage) {
val messageString = Json {
encodeDefaults = true // 控制是否输出默认值
ignoreUnknownKeys = true
}.encodeToString(feedbackMessage)
val result = withContext(Dispatchers.IO) { comment(messageString) } // 其实kotlin更推荐切换flow实现切换上下文,但是我们快速实现所以不在这里演示
if (result.code == 200) {
_uiStateFlow.emit(FeedBackState.SubmitSuccess)
} else {
_uiStateFlow.emit(FeedBackState.SubmitError)
}
}
对于必须传入的平台特性的参数
有时候我们需要传入平台特性的参数,例如Android的Context作为参数,这个时候在Kmp中没有办法直接使用Context,有两种解决方法:
1、使用Any作为参数类型,在具体的使用的地方转为真正的参数类型
// 使用Any
expect fun getPlatformInfoProvider(context: Any): PlatformInfoProvider
// 使用的地方强转
actual fun getPlatformInfoProvider(context: Any): PlatformInfoProvider = AndroidPlatformInfoProvider(context as Context)
2、使用依赖注入
如果觉得强转的方法不够优雅,kotlin也提供了解决方法,例如:Koin
// 定义接口
interface PlatformInfoProvider {
fun getPlatformName(): String
}
// 用 Koin 提供依赖
expect val platformInfoProvider: PlatformInfoProvider
// androidMain中的实现
actual val platformInfoProvider: PlatformInfoProvider
get() = AndroidPlatformInfoProvider()
// 模块化的方式注册到Koin
val androidModule = module {
single<PlatformInfoProvider> { AndroidPlatformInfoProvider() }
}
// Koin初始化,放在应用入口,例如Android的Application
startKoin {
modules(androidModule)
}
// 调用方式
val provider: PlatformInfoProvider by inject()
如何实现kmp运行在Harmony设备上
最开始我们在研究直接编译的可能性,参考了b站的做法,直到6月份,腾讯正式开源Kuikly框架,其中ui的部分其实还有一点不自由,但是作为业务层,可以支持我们导出为Harmony使用已经足够满足我们的需求了。
以上面的使用方式列举,可以将Android现有模块快速的封装为kmp模块。这也是我们本次客户端方面技术变革的核心目标,快速高效高性能的将Android业务逻辑以kmp共享出去,让各端专注于实现Ui方面的交互。
其他方案
我们也考虑过其他方案,例如Flutter、uniappX、uniapp、C++/Rust,但是都没有kmp更适合我们的团队。
Flutter:Flutter的优势其实是Ui层的统一,而在跟我们已有的原生代码进行交互的化,就必须要经过序列化和反序列化数据的过程,这会带来不可避免的性能损失,而同时我们更希望只是将业务层进行共享,Flutter的优势并不能完全体现出来。
Uniapp:uniapp的优势是支持小程序,但是在性能上就显得有点鸡肋,特别是uniapp和原生之间的交互包括生命周期的管理非常恐怖。
UniappX:据uniappX团队描述uniappX是将代码翻译成各个平台的原生代码,但是目前为止似乎并没有达到真正可以商用的地步。
Rust:大部分使用rust的技术团队都面对着高性能高安全的需求,例如谷歌就用rust写了部分Android底层代码,但是对于业务需求来说rust的代码量会是kmp的数倍。rust的跨平台需要繁琐的FFI,同时rust和kotlin、oc、swift之间都需要桥接,同时rust的生态是面向系统编程,例如图形学、数据模型等对于业务常用的生命周期、网络状态可能并没有非常合适的库可以使用,也或许是我们没有找到。
结尾
面对三端并行开发带来的巨大复杂度,我们并不急于“全栈统一”,而是更关注架构的合理性与团队协作的可持续性。Kotlin Multiplatform 为我们提供了一种相对平衡的解法:既不牺牲原生的性能与体验,也能实现核心逻辑的最大化复用。它本质上不是一种“跨平台替代品”,而是对多端架构设计能力的升级。