使用KMP实现原生UI + Compose混合的社交客户端

这两年一直在做这么一个项目:github.com/DimensionDe...

简单的说,是一个用Kotlin编写的,同时支持Mastodon,Misskey,Bluesky,X,RSS的,可以运行在Android,iOS,Windows,macOS的一个客户端,同时还有轻量级的服务器提供一些服务。在共享绝大多数业务逻辑的前提下能够做到不同平台都能有一个专门的UI。不同社交平台在共享大部分逻辑的同时还能同时支持其独特功能。这篇文章就来拆解一下具体的实现过程,也算是一个阶段性的总结。

如果用最简短的一段话来描述的话,那就是:

在共享业务逻辑中,不同社交平台通过实现接口实现功能,并且将不同数据结构映射到统一的数据结构中,其中独特功能由各个社交平台自行实现。对UI暴露统一数据结构以及Presenter,不同平台的UI只需要调用Presenter即可实现绝大部分的业务逻辑。其中部分可以共享的UI使用Compose进行实现。

接下来就从底层开始拆解一下,并且说说遇到的坑。

核心基础

这部分算是整个应用的基建,这应该是大多数应用都会有的一层。

网络/IO

Flare一开始就使用Ktor作为网络层基础,Ktor天然跨平台,并且配置起来也很简单,写interceptor也不复杂,这应该是目前KMP应用的首选。

在Ktor的基础上,对于REST请求,Flare选择使用了Foso/Ktorfit,与熟悉的retrofit使用起来非常相似,使用OpenAPI生成的retrofit的代码也可以很方便的移植到Ktorfit。

Flare还使用了Okio作为底层的IO库,Okio同样是KMP原生支持的,并且在处理文件读写上也非常方便。不过仍然定义了一个PlatformPathProducer接口来获取不同平台的文件路径,方便数据库等使用。

数据库

目前移动设备底层应该都是使用sqlite,一开始当Flare还只是Android应用的时候,使用Room作为数据库层,但是当时Room还不支持跨平台,中期有一段时间使用的是SQLDelight,SQLDelight也是原生跨平台,但是在实际使用过程中发现,一些查询如果使用SQLDelight那样直接写SQL语句的话,维护起来比较麻烦,并且在一些复杂查询上也不够灵活,而且需要手写migration。后来Room开始支持跨平台了,Flare也就重新切换回Room。

中间切换的时候并没有太多的成本,底层都是sqlite,数据结构也没有太大变化,只需要把SQLDelight的查询语句重新用Room实现一遍就行了。

而对于简单的数据存储,Flare使用了DataStore,这是Jetpack库里面最先支持KMP的一个库之一,配合Kotlinx.serialization + ProtoBuf使用起来也非常方便。

分页/缓存

Flare使用了Paging3作为分页库,这应该是目前Android上最成熟的分页库了,Paging3后来也添加了对KMP的支持,并且可以和Room无缝集成,使用起来非常方便。 对于一些不需要与Room集成,只需要简单的内存管理的分页数据源,Flare也使用了Paging3定义了一个MemoryPagingSource。理论上使用Room的inMemoryDatabaseBuilder()也是可以的。

对于不需要分页,简单对象的缓存,Flare自定义了一个简单的Cacheable,同时也模仿Paging3的缓存逻辑,优先提供缓存的数据,同时请求网络对缓存数据进行更新。同时对于不需要与Room集成的数据也同样编写了一个缓存在内存中的MemCacheable

Flare也使用Room管理了一个单独的缓存数据库,用于缓存用户数据,这样有两个好处:1.提高离线使用体验,在完全离线的状态下也能查看到之前缓存的数据,2.更加方便的保持数据一致性,不同页面的同一数据都是从同一个地方获取,更新也只需要更新这一处数据即可,也就是常说的单一数据源,后面会展开说。

业务逻辑

这部分是Flare的核心部分,所有社交平台都共享这一层的业务逻辑。

社交平台实现

Flare目前支持Mastodon,Misskey,Bluesky,X,RSS,其中RSS还不是社交平台,但是也作为一个数据源被支持。 几乎所有的社交平台都会继承自AuthenticatedMicroblogDataSource,实现这个接口就能过实现应用内大部分社交平台的功能。

不过光说还是比较空泛的,就举一个例子吧,如果想要在Flare里面添加新的社交平台支持:

  • dev.dimension.flare.model.PlatformTypedev.dimension.flare.ui.model.UiApplicationdev.dimension.flare.ui.model.UiAccount中添加对应平台的内容
  • shared/src/commonMain/kotlin/dev/dimension/flare/data/network中添加对应平台的api调用,如果可以的话使用Flare内置的dev.dimension.flare.data.network.ktorClient作为httpClient
  • shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource中添加对应平台的DataSource,继承并实现dev.dimension.flare.data.datasource.microblog.AuthenticatedMicroblogDataSource
    • 在这一步,需要在datasource中实现对应平台的BaseTimelineRemoteMediator或者BasePagingSource,这两个的区别如下:
      • BaseTimelineRemoteMediator:数据源是一个类似的Feed,类似home timeline,这个Feed将会被Flare内置的缓存数据库缓存
        • 关于数据库缓存:需要为dev.dimension.flare.data.database.cache.model.StatusContentdev.dimension.flare.data.database.cache.model.UserContent添加对应具体的数据类型
        • 需要为数据类型写mapping,在shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/mapper/Render.ktshared/src/commonMain/kotlin/dev/dimension/flare/data/database/cache/mapper/Mastodon.kt中添加对应的的model mapping,你可以参考各自同目录下的Mastodon.kt的mapping方式
        • 在做mapping的过程中你需要为数据源实现dev.dimension.flare.data.datasource.microblog.StatusEvent,并处理对应事件
      • BasePagingSource:这是一个任意类型的、不会被缓存的数据源,你可以用来加载follower或者其他任意数据,返回类型应该是dev.dimension.flare.ui.model中的任意类型,例如dev.dimension.flare.ui.model.UiUser
  • 接下来需要修改compose-ui里面的内容,在compose-ui/src/commonMain/kotlin/dev/dimension/flare/data/model/TabSettings.kt文件中添加这个平台支持的tab,只需要补上defaultPrimarydefaultSecondarysecondaryFor这几个方法

看起来挺复杂的,实际也确实如此。中间有不少步骤是重复劳动,在未来作为优化方向Flare可以将数据源插件化,只需要实现对应的方法获取数据并返回即可,不需要像现在这样这么复杂。

数据结构映射

不同数据源返回的数据结构是完全不同的,想要让UI方便的渲染出各个平台的帖子,就需要将不同的数据源返回的数据映射到统一的数据结构中。Flare在这里的做法是:将收到的数据直接缓存到数据库中,在Paging3返回数据的时候针对不同的数据使用不同的映射处理,最终对于UI层来说,拿到的都是一个统一的数据结构。

不过Flare对于帖子的各种操作(例如点赞、转发)也是在这一层通过callback的方式返回给了UI层。简单地说,之前的做法都是在页面注册点击事件调用ViewModel中的方法,在这里Flare直接在UI Model中定义了点击事件所需要触发的callback,这样UI层只需要调用这个callback就可以完成各种操作,不需要再通过ViewModel。之所以用这个方式是因为在过去类似的项目中,同样一个操作处理逻辑会在很多ViewModel中使用,这些都算是重复代码,而且当Compose的层级变深之后,如果想要回传callback调用ViewModel,一个Composable方法就会有很多的参数,最后这些参数维护起来就变得极其棘手了。

这个callback除了会触发数据库改变之外,有一些操作还会触发Deeplink来让UI导航到一个新的页面。UI层只需要处理这个Deeplink即可。

不过做映射很多都是体力活,不同平台的API定义完全不同,具体怎么映射可以看这里

这一层说实话一直想改进一下,因为改进空间感觉还是有的,感觉目前的实现还是比较粗糙。有考虑过几个方案,比如直接使用JsonPath,或者抽象接口直接让不同平台只需要实现不同字段就好,但是目前还暂时没有想好。

数据流向/状态管理

Flare使用MVI架构,并且遵守单向数据流和单一数据源(Single source of truth)的原则,前文也有提到,用户操作所造成对应显示的更新,需要直接对数据源进行操作,而非对UI进行修改。例如用户点赞的操作,因为需要即时反馈给用户已经点赞,所以会先操作数据库将点赞设置为true,此时UI因为订阅了数据变化,从而更新了当前的点赞UI状态,此时再发起网络请求进行点赞,如果失败再将数据库点赞设置回退为false,此时UI同样也会跟着数据库一同更新,用图来表示的话就是这样:

stateDiagram-v2 UI --> 点赞 点赞 --> 数据库 数据库 --> UI

这样间接达成了UI层是无状态的,保持UI层无状态对于跨平台的使用来说也是有好处的,不同的UI只需要根据数据库的状态进行渲染即可,不需要再手动管理自己的状态。对于编写UI看来说也方便了不少。

这也正好说到整个应用的状态管理,整个应用使用了我之前提到过的使用Compose编写业务逻辑的方式来进行UI状态的管理,Presenter层负责各种业务逻辑,并返回此时应用页面的状态,以StateFlow的方式返回。整个应用中除了一些不方便在Common中共享的状态,比如Text输入,以及一些很简单的状态,比如Toggle,这些状态之外,大多数的状态都在Kotlin Common层使用了Presenter进行管理,使得UI层编写的工作变得非常简单起来,只需要根据不同状态渲染不同的UI即可,重新使用SwiftUI编写的iOS应用只用了一个月的时间就从0到了第一个公测版本上线,基础功能全部完成,大大提高了UI的编写效率。

为了能够让SwiftUI更方便的使用Kotlin编写的Presenter中返回的StateFlow,结合SKIE,我单独编写了KotlinPresenter.swift,这样在SwiftUI中就可以很方便的使用了。

富文本解析

我觉得富文本解析值得单独拿出来说一下。不同社交平台的富文本格式都不一样,Flare的处理是统一映射到HTML中。在所有的社交平台中,最简单的当属Mastodon,返回的直接就是一个HTML,直接解析HTML即可。我非常希望各家不要再自行发明富文本了,反正网页端最后都是HTML的,就不能好好的用HTML吗。

接下来就说说各家自己发明的富文本格式

Bluesky

Bluesky的文档里面有提到,Bluesky用了一个Json结构来下标富文本的index,其中有一个潜在坑就是:Bluesky使用的是基于UTF-8的index,所有的index都指的是这个字符串在UTF-8中的index,需要稍微处理一下。以及,因为Bluesky后端似乎没有做验证(或者验证不严格),这个下标index就有可能是越界的,比如这个帖子

Misskey

Misskey使用了一个类似Markdown格式的标记语言MFM,这可以说是最折磨人的一个,在Markdown之外,MFM还支持调用动画函数,例如$[bounce.speed=5s 🍮]这样的格式,所以不得不手写一个Parser来对MFM进行解析。学过编译原理或者状态机应该对手写Parser比较熟悉。简单的说:对每一个输入,当符合一定条件时,进入一个状态,再继续处理下一个输入。用伪代码来表示的话就是:

csharp 复制代码
State currentState = TextState
while (nextChar()) {
    '@': {
        currentState = AtState
        markCurrentCharAsAt()
    }
    // ...
    default: {
        markCurrentCharAsText()
    }
}

不过MFM的各种功能还是太丰富了,各种功能交错起来就变得非常复杂了,所以现在也不能说是完全能够正确解析。

X/Twitter

可以说是比较简单的一个了,Twitter自己就有一个开源库来用于解析,但是这个库不支持KMP,那要不我们自己写一个解析库吧:github.com/Tlaster/twi...

和之前写的MFM解析库原理类似,因为Twitter的规则相对简单,所以这个写起来也比较容易,在这里就不再赘述了。

RSS文章内容解析

因为应用也是支持RSS的,也可以当作RSS阅读器,为了提高用户体验,在应用内打开RSS文章会比较好,如果单纯的是一个WebView放链接用户体验也不会太好,最好帮用户将文章内容手动解析出来,只显示文章内容,这样用户体验才会更好。

Flare在这里使用了mozilla/readability,这是Firefox内置的阅读模式所使用的库。但是这是个Javascript库,Kotlin当然不能直接使用。在这里Flare的处理方式是:下载网页HTML,使用WebView加载HTML,最后使用WebView运行readability并返回结果。为此Flare定义了一个NativeWebScraper,并使用expect/actual在各个平台分别实现。

在未来也许可以直接使用JavascriptCore或者QuickJS,这样比起WebView占用的资源更小,速度更快,这算一个未来的优化方向吧,不过目前暂时还没有这个计划。

UI层

虽然大部分状态都在Presenter层处理了,UI层也还是有不少东西的,除了使用了Compose UI之外,在iOS和macOS中还使用了SwiftUI,在Windows中还使用到了WinUI3,各有各的不同之处,接下来一点点说。

Compose UI

这应该是最熟悉的一个,其中Android是全Compose,macOS和Windows也大部分是Compose,并且Android和桌面端在共享了不少Compose组件的前提下实现了不同平台不同的UI Style。并且部分Compose UI也一同共享到了iOS端中。接下来就展开说说。

富文本渲染

因为有大量的富文本需要渲染,这应该是最基础的组件之一,Compose的富文本渲染也不算复杂,Flare定义了一个RichText的组件来渲染富文本。因为在之前就已经将各种不同的富文本格式统一到了HTML,接下来就只需要按照这个HTML进行渲染即可。有一点要特别注意的是:不要在Compose UI内使用各种库解析HTML,这会使得你的LazyColumn之类的列表性能非常差,最好提前在业务逻辑层就把HTML解析这一步做好,Compose UI拿到的应该是已经解析好的HTML结构,这是之前做Twidere X时候得到的经验。

具体的渲染逻辑在这里,就是遍历HTML树,然后根据tag使用不同的style来进行渲染,不过这里面有两个需要注意的地方:

  • 如果你真的非常想榨干最后一点性能,请不要使用LinkAnnotation,在Compose 1.9测试出来结果表明LinkAnnotation越多性能消耗越大,我在这里进行了一些测试,结果表明100个LinkAnnotation就已经足以在Release模式下严重影响性能了。官方的说法LinkAnnotation为了支持accessibility以及适配键盘方向移动,单独做了额外的工作,这是预期行为,如果觉得不合理请提交issue。好吧,也许未来会有性能提升,目前的workaround是:手动模仿链接渲染这部分文字,然后使用Modifier.pointerInput来获取点击事件,这里在RichText的源码中有过处理。
  • 对于一些尺寸无法在一开始就确定的InlineContent,例如图片,需要使用Layout再封装一层。初始的Placeholder的宽高可以是任意尺寸,然后使用外部的constraints在Layout里面测量好尺寸之后再改变Placeholder的尺寸即可。这一点也在RichText的源码中有处理。这里是有潜在的性能消耗,所以如果是需要榨干最后一点性能的话,这里也可以选择不支持动态大小的InlineContent。

对于RSS文章内容,Flare编写了另一个富文本渲染组件,RSS的文章内容有更多的样式需要解析,例如h1~h6。这里使用了一个第三方库halilozercan/compose-richtext来更好的对更多样式进行渲染。渲染逻辑和之前的RichText也基本一致,只不过多了一些对这个库的API调用。

平台UI组件

compose-ui模块中,对每个平台都提供了统一的UI组件,而且对于每一个组件,在不同平台都可以有不同的UI样式,在Android中使用Material 3 Expressive,在iOS中使用Cupertino,在Windows/macOS中使用Fluent Design,这是因为在compose-ui中定义了一组Platform*基础组件库以及对应主题,并且使用expect/actual在不同平台上实现不同的样式,Android使用了官方的Material3库,iOS使用了slanos/compose-cupertino,Windows/macOS使用了compose-fluent/compose-fluent-ui。对于iOS有些地方甚至可以跳过Compose直接使用UIKit,例如PlatformPicker在iOS上就是直接使用了UIKit的UISegmentedControl,这样在保持UI样式原生的同时,还能享受到Compose带来的跨平台开发体验。

Android

我觉得Android上的Compose大家都比较熟悉了,都是一些老生常谈的东西

一开始是用的官方的Jetpack Navigation,后来Navigation3出来之后很快就切换过去了,相比于老的Navigation,Navigation3还是挺不错的:

  • Google终于丢掉了那个String导航,实际这几年体验下来String导航其实并不是很方便
  • sceneStrategy和entryDecorators的设计非常优秀,可以非常方便的设计页面导航方式以及给页面添加装饰。过去想要适配List/Detail都需要单独开一个页面,现在只需要在sceneStrategy里面添加即可,非常的方便。
  • BackStack和OnBack行为都可以自己控制,意味着过去有很多因为BackStack无法自由改动而不好实现的内容现在可以更方便的实现了。例如BottomNavigation在过去常常需要嵌套两层NavHost,现在只需要一个NavDisplay就可以实现同样的行为。
  • predictive back的动画可以更好的自定义了,过去的NavHost没法很方便的定义predictive back的动画,现在直接在predictivePopTransitionSpec里面定义即可。

只不过目前最新版本还是beta01,还是有一些bug的。希望能在正式版里面修复,Navigation 3的设计还是非常优秀的。

好像Android也没什么可以说的了。

Windows/macOS

这是一个桌面平台,设计交互与移动平台有着很大的差别,所以一开始的想法就不是简单的将Android版本移植到桌面,而是在复用基础组件的前提下重新设计整个应用。

因为在这个时间点Navigation3还没有正式在JVM平台可用,一开始甚至连alpha版都还没有,所以Flare自己编写了一套简单的Navigation,这套Navigation在未来Navigation 3正式上线JVM平台之后也可以很方便的迁移过去。

这套Navigation其实就是一个堆栈管理,无非就是导航进入新页面,页面进入BackStack,页面返回时标记页面需要被释放,当导航动画结束之后释放页面资源,因为不复杂所以有兴趣的可以自己看看源码就行。

平台Native UI调用

因为是社交客户端,不可避免的要播放视频,前期尝试过各种不同的视频播放方案,但总的来说都不是很理想,所以最后决定直接使用Native UI来播放视频。这里的Native UI并不是指Swing,而是Windows的WinUI 3和macOS的SwiftUI。这里就简单的提一下如何调用的。

这里的调用指的并不是在Compose Desktop中嵌入SwiftUI/WinUI,而是创建新窗口来实现的。

macOS

Swift可以直接编译成dynLib,JVM只需要编写JNI/JNA就可以很方便的调用,所以只需要在Swift中编写好一个方法创建新窗口并渲染UI,然后在Kotlin中调用即可。

Windows

Windows平台比较特殊,因为在Windows中想要处理Deeplink callback没法像macOS那样直接使用Desktop.getDesktop().setOpenURIHandler就行,需要要求管理员权限改注册表,或者WinUI 3封装一层打包成Package然后上架Windows商店,这里选择了后者,毕竟弹一个管理员权限还是不太好。

这样在Windows平台其实启动的是一个WinUI 3程序,这个WinUI 3程序再调起Compose Desktop来启动应用,这是两个不同的进程。虽然WinUI 3现在可以支持Native AOT,可以像macOS那样使用JNI/JNA调用,但是我们都已经有一个WinUI 3的Host了,那就直接使用Host的比较好。因为是不同的进程,所以Flare设计了一套IPC机制来通信,简单的说就是在WinUI 3启动的时候随机两个没有被占用的端口,然后启动一个IPC Server,然后在启动Compose Desktop的时候通过参数传入这两个端口,在Compose Desktop启动的时候也根据这两个端口启动一个IPC Server,然后就是两个Server之间互相通信即可。

其实这个IPC的方式如果想象力大一点的话理论上是可以做到UI是全套WinUI 3,业务逻辑全在Kotlin的,这就成TDLib了,未来如果有时间的话也许可以做出来吧。

iOS

在iOS中使用Compose UI还是挺简单的,只要用ComposeUIViewController就行,不过这里Flare有一些特殊情况:iOS中的状态更新是在Swift里面进行更新的,而默认情况下ComposeUIViewController的参数是不会跟着一同更新,因为UIViewControllerRepresentable.updateUIViewController里面无法调用,并且因为Flare只是一些页面使用了Compose,大部分页面还是SwifUI,这里需要处理Compose中的Lifecycle,所以需要一套机制来处理上面的一个问题。

在这里Flare封装了一层FlareComposeUIViewController,并且使用ComposeUIStateProxy<T>来管理状态,简单的说:在UIViewControllerRepresentable创建Compose UI的时候需要使用ComposeUIStateProxy<T>来创建对应状态,并作为参数传入Compose Controller,这时候Swift中只需要对ComposeUIStateProxy<T>进行状态更新即可。具体使用可以参考这里

SwiftUI

SwiftUI我并不是特别熟悉,所以可能会有说错的地方。

在上文也提到过,iOS应用中大部分都是SwiftUI,并且很多状态都在Kotlin中,SwiftUI只需要根据对应状态渲染UI即可,并且为了方便使用Kotlin中定义的Presenter,Swift中也定义了一个KotlinPresenter.swift作为桥接使用,这里有一个潜在的坑:不推荐对Kotlin中的ViewModel/Presenter使用SwiftUI的Observation。举个例子,在SwiftUI的View中这样初始化ViewModel:

Swift 复制代码
@State private var viewModel: KotlinViewModel
init(param: String) {
    _viewModel = .init(initialValue: .init(param: param))
}

看起来挺好没问题,但是实际运行时KotlinViewModel可能会被创建非常多次,有可能造成严重的性能问题,这个问题在其他地方也有看到: github.com/Dimillian/I...

而解决办法就是不使用Observation:

swift 复制代码
@StateObject private var viewModel: KotlinViewModel
init(param: String) {
    _viewModel = .init(wrappedValue: .init(param: param))
}

这样KotlinViewModel就只会被创建一次,并且生命周期与当前的View绑定,这样对于Kotlin编写的ViewModel/Presenter来说更友好一些。不过要注意的是:即使生命周期与View绑定,如果你有一些资源需要释放,你还是需要自己编写一个deinit,所以Flare就编写了一个KotlinPresenter来更好的桥接Kotlin。

结语

这两年感觉自己在Compose上没什么进步,反而是横向在Swift/SwiftUI上进步挺大,特别是有了AI之后学习起来效率更高了,Flare里面也有不少代码是AI写出来的,不过AI也不能全信,目前AI幻觉情况还是有不少的,需要有判断AI是否正确的能力。

现在Swift官方也开始支持Android,不过我感觉Swift最大的问题其实不是语言本身,而是XCode。XCode只能在macOS上使用,在使用XCode的过程中会经常遇到各种问题,最经典的就是写的太复杂Swift Compiler不认,或者报错信息根本就不是在真正错误的那一行。希望Swift LSP能够好好发展一下,要是能直接使用VS Code来写那就更爽了。

Flare如果在未来继续发展的话,我希望能够发展出一个中间层,将社交平台的支持插件化,对UI输出各种状态,有些类似TDLib那样,不过也许在很远的将来才能够实现吧。

相关推荐
带娃的IT创业者27 分钟前
Inkling:当开源模型开始思考“如何思考”
人工智能·开源·大语言模型·多模态·moe·开源模型·inkling
在水一缸1 小时前
KOReader:当开源精神遇上电子墨水屏,一场阅读体验的静默革命
开源·开源项目·电子墨水屏·极客·kindle·koreader·阅读体验
爱笑鱼1 小时前
Binder(八):远端进程死了,BinderProxy 为什么还能收到通知?
android
Android-Flutter1 小时前
Kotlin 冷流与热流详解
android·kotlin
zhangphil2 小时前
Android ContentProvider/ContentResolver读图片,跨进程 IPC慢
android
yueqc12 小时前
Android .so 文件压缩
android·so·包体积
龚礼鹏2 小时前
深入解析 Android Automotive (AAOS) 启动流程与 CarService 核心机制(基于 Android 16 最新源码视角)
android
rosmis3 小时前
agent各指标定义
android·java·开发语言
fthux3 小时前
装闭 RenoPit 源码解析(13):生成AI装修闭坑PDF报告
人工智能·ai·pdf·开源·github
TunerT_TQ4 小时前
Valhalla 静态工程审阅 #007|AgentENV 源码证据驱动评测【大厂开源基础设施特辑】
rust·开源·go·github·sandbox·分布式系统·ai基础设施