第二部分以一个模式收尾:最容易迁移的代码,是对 Android 一无所知的代码。按此逻辑,UI 层应该是迁移中最棘手的部分——它涉及屏幕、系统栏、键盘、返回手势,所有那些明显属于平台的东西。
然而,令我大为惊讶的是,事实并非如此。Composable 的迁移几乎和领域模型一样轻松。
真正难以迁移的是它们周围的一切。资源系统、构建插件、预览工具和截图测试,都建立在“一个 Compose 模块就是一个 Android 库模块”这一假设之上。一旦这个假设不再成立,应用程序代码安然无恙,而围绕它的生态系统却出了问题。
共享应用程序代码比共享其周围的生态系统更容易。这就是整篇文章的核心。
哪些东西轻松迁移了
大多数 Composable 原封不动地移到了 commonMain。布局、状态、主题、动画——Compose Multiplatform 覆盖了 BikeHub 所使用的全部功能,对于一个典型屏幕来说,差异主要在于导入语句的变更。
有两件事让这个过程比原本更顺畅:
- UDF 层先行迁移。BikeHub 的每个功能模块都使用了 Ouroboros-Compose,这是一个小型单向数据流库(受 Spotify 的 Mobius 启发,我在当年视图和 Fragment 统治 Android UI 时用过它)。它将 ViewModel 抽象化,把 UI 逻辑放在纯粹、可测试的类中。作为整个迁移的第零步,我首先将其重构为 KMP,然后才碰任何一个功能模块。
这个顺序的重要性远超表面所见。因为 UI 逻辑并不位于 Android ViewModel 内部,所以没有androidx.lifecycle依赖像线头一样穿过 38 个功能模块等着被拆解。状态持有者已经是纯粹的 Kotlin 了。 - UI 层中的 Android 平台代码早已被视为代码坏味道。多年来我一直这样对待它,因此需要清理的内容并不多。有一些
LocalContext.current调用很容易抽象掉,以及大量的文本和可绘制资源。
结果在模块布局中清晰可见。在所有功能模块中,大多数模块在 commonMain 之外只有一个文件,而且那是一个预览文件。只有两个模块包含任何实质性的平台代码:一个需要 PlatformWebView,另一个需要知道在当前实际平台上是否存在通知设置这种东西。
如果你想预测自己的 UI 迁移会有多痛苦,那么关键指标大概就是:你的表示层除了 Compose 本身之外,还接触了多少 Android 特有的内容。
androidMain 里到底有什么
第一部分提到 androidMain 占 Kotlin 代码总量的 12%,有一段时间我引用这个数字说它是“应用中与 Android 相关的部分”。
其实不然。在 androidMain 的 13,843 行代码中,有 9,653 行——即 70%——是 @Preview 函数。整整 74 个文件,唯一的任务就是在 Android Studio 的预览面板中渲染一个 Composable,并为截图测试提供素材。
去掉这些之后,真正的平台特定 Android 代码大约只有 4,190 行,不到整个代码库的 4%。
这让我对整个迁移有了全新的认识。我一直称之为“平台代码”的东西,大部分其实是工具脚手架——这是在单一平台上查看 UI 所付出的代价,而不是在三个平台上运行它所付出的代价。
预览函数之所以仍然留在 androidMain 中,是一次失败尝试的遗迹。@ComponentPreview 注解是 expect/actual 的,所以预览函数本可以放在 commonMain 中——但它们是为 Android 的 @Preview 编写的,因为最初的计划是利用 Compose Preview Screenshot Testing 从这些预览驱动截图测试。那个计划在迁移过程中没能存活下来,于是预览就留在了原地。
Roborazzi 并非首选。 Compose Preview Screenshot Testing 理论上更具吸引力:你编写本来就要写的预览,然后由工具来渲染它们。我试过了,它在 Kotlin Multiplatform 模块中无法工作。所以我回到了 Roborazzi,它在 KMP Android 目标已经提供的 androidHostTest 源集下,通过 Robolectric 运行。
我为第一种方法编写的预览依然存在——9,653 行——现在它们服务于 Android Studio 的预览面板,而非测试运行器。
哪些没有迁移:UI 层的真实平台接缝
去掉预览后,剩下的是一份简短而具体的清单。所有这些都位于 core:designsystem 和 core:ui 中,通过 expect/actual 实现:
- BackHandler —— Android 上的返回手势,iOS 上不同的概念,Web 上是浏览器的历史记录。
- FullScreenDialog —— “全屏”的含义由宿主决定。
- ContainerSize —— 如何测量可用窗口的大小。
- PhotoPager 和 ZoomAsyncImage —— 照片上的捏合缩放和分页浏览。
- PhotoHandler 以及
core:ui中的几个Modifier扩展。 - 某个功能模块中的 PlatformWebView,因为嵌入式 Web 视图在四个平台上各不相同。
所有离开应用的操作——打开浏览器、跳转商店页面、进入系统通知设置——都通过一个单一的 ExternalNavigator 接口进行,每个平台有一个实现,因此没有任何功能模块直接接触它。
然后是预览注解本身:
// commonMain
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.SOURCE)
expect annotation class ScreenPreview()
// androidMain - the real thing, five configurations deep
@Preview(name = "light theme")
@Preview(name = "dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "largest font", fontScale = 2f)
@Preview(name = "tablet", device = Devices.NEXUS_10)
actual annotation class ScreenPreview
// wasmJsMain, iosMain, jvmMain - an annotation that does nothing
actual annotation class ScreenPreview一个 expect/actual 对,唯一的作用是让某个平台的工具能够看到这段代码,同时让该注解在其他地方无害。这是一件小事,却是本文最清晰的例证:UI 迁移了,但围绕它的工具需要一个适配器。
资源:一个模块,两种变通方案
所有资源——字符串、可绘制对象、字体——都位于单个 core:res 模块下的 commonMain/composeResources 中。一个模块,一个生成的 Res 类,其他每个模块都依赖它。
集中化的好处显而易见:一处查找,没有重复键,无需为每个模块配置资源打包。代价也同样明显——core:res 几乎是所有内容的依赖,因此修改一个字符串会触发大量重建。
我没料到的是,为了让它在下游可用,竟然需要一个反射式的变通方案。Compose Multiplatform 资源插件在库模块中没有正确连接其 Android assets 复制任务的输出目录,而且任务类型是内部的——因此没有类型化的 API 来设置它:
tasks.matching { it.name.endsWith("ComposeResourcesToAndroidAssets") }.configureEach {
val outputDirProperty = javaClass.methods
.firstOrNull { it.name == "getOutputDirectory" && it.parameterCount == 0 }
?.invoke(this)
if (outputDirProperty is DirectoryProperty) {
outputDirProperty.set(
layout.buildDirectory.dir("generated/composeResources/androidAssets/$name"),
)
}
}构建文件中的反射不是我喜欢看的东西。之所以这样做,是因为否则生成的资产就没有稳定的位置,而下一节依赖于拥有这样一个位置。
安全网:KMP 模块中的 Roborazzi
BikeHub 的视觉回归安全网是 Roborazzi,在 Robolectric 下运行。测试位于每个模块的 androidHostTest 源集中——这是 KMP Android 库目标的宿主端测试变体——一个约定插件 bikehub.multiplatform.screenshot 负责启用它们。40 个模块应用了这个插件。测试紧挨着它们所覆盖的代码,位于多平台模块内部。
一个测试大致如下所示:
extensions.configure<KotlinMultiplatformExtension> {
extensions.configure<KotlinMultiplatformAndroidLibraryTarget>("android") {
withHostTest { isIncludeAndroidResources = true }
}
}core:testing 中的一个共享基类 ScreenshotTest 处理其余部分:每个用例在亮色和暗色模式下渲染,跨一组设备(以规格定义而非设备名称),以半比例捕获以减小 PNG 大小,并与 0.1 阈值进行比较。
资源问题再次出现
Robolectric 在 JVM 上渲染 UI,并从模块的测试资产中读取资源。Compose Multiplatform 资源不会自动到达那里,因此截图会出现缺失图像和未解析的字符串。
修复方法是同一个约定插件的后半部分:一个 Sync 任务,将生成的资源从 core:res 的构建输出中(上述反射变通方案创建的稳定位置)复制到宿主测试资产中,并在 Android 资产合并前后安排好时序:
val sync = tasks.register("syncComposeResourcesToAndroidHostTestAssets", Sync::class.java) {
dependsOn(coreResProject.tasks.named("copyAndroidMainComposeResourcesToAndroidAssets"))
mustRunAfter("mergeAndroidHostTestAssets")
from(/* core:res generated assets */) { include("**/composeResources/**"); include("**/*.cvr") }
into(layout.buildDirectory.dir("intermediates/assets/androidHostTest/mergeAndroidHostTestAssets"))
}
tasks.matching { it.name == "packageAndroidHostTestForUnitTest" }
.configureEach { dependsOn(sync) }两个变通方案串联在一起,一个在 core:res 中,一个在截图插件中,目的是让 Compose Multiplatform 模块的资源对 Android 宿主测试可见。涉及的应用程序代码为零行。又是工具问题。
价值何在,局限在哪
在你照搬这套设置之前,我希望你权衡两个取舍:
- 它只覆盖了一个平台。 Compose Multiplatform 在 iOS 和 Web 上通过 Skia 渲染。一套绿色的截图套件告诉我 Composable 在 Android 上是稳定的,但对另外两个目标平台则毫无信息。顺便问一下,如果你做了,你会如何弥补这个缺口?欢迎提出解决方案 🙂
- 仓库中有 55 MB 的 PNG 文件。 这是为了获得可查看的差异所付出的代价。以半比例录制避免了情况变得更糟。CI 运行
verifyRoborazziAndroidHostTest,当验证失败时,它会重新录制并推送一个提交,这保持了迭代循环的运转,但也意味着一个无意的视觉变化可能因为没有仔细审查而被接受。
我的总结
在更改模块类型之前,先检查你的工具链。 将 com.android.library 替换为 com.android.kotlin.multiplatform.library 是一行改动,但尾巴很长。任何依赖于“这是一个 Android 库模块”的东西——测试、预览、资源打包——都需要后续计划,提前规划比事后发现要划算得多。
将平台代码排除在表示层之外,原因与 KMP 无关。即使在单一平台上这也是良好实践。只不过它恰好决定了你的 UI 层迁移是花一个下午还是一个月。
问题找早鸟出海,早鸟出海(NBOVERSEA)深耕出海近十年。专业提供谷歌、苹果APP代上架服务,支持PWA/W2A/H5/APP广告代投,提供Google/Facebook/Tiktok广告账户,谷歌/苹果开发者账户,一站式解决您的后顾之忧。如有任何疑问,请点击服务流程或咨询飞机:@gobird_bot @verygood_appear