第二部分以一个模式收尾:最容易迁移的代码,是对 Android 一无所知的代码。按此逻辑,UI 层应该是迁移中最棘手的部分——它涉及屏幕、系统栏、键盘、返回手势,所有那些明显属于平台的东西。

然而,令我大为惊讶的是,事实并非如此。Composable 的迁移几乎和领域模型一样轻松。

真正难以迁移的是它们周围的一切。资源系统、构建插件、预览工具和截图测试,都建立在“一个 Compose 模块就是一个 Android 库模块”这一假设之上。一旦这个假设不再成立,应用程序代码安然无恙,而围绕它的生态系统却出了问题。

共享应用程序代码比共享其周围的生态系统更容易。这就是整篇文章的核心。

哪些东西轻松迁移了

大多数 Composable 原封不动地移到了 commonMain。布局、状态、主题、动画——Compose Multiplatform 覆盖了 BikeHub 所使用的全部功能,对于一个典型屏幕来说,差异主要在于导入语句的变更。

有两件事让这个过程比原本更顺畅:

  1. UDF 层先行迁移。BikeHub 的每个功能模块都使用了 Ouroboros-Compose,这是一个小型单向数据流库(受 Spotify 的 Mobius 启发,我在当年视图和 Fragment 统治 Android UI 时用过它)。它将 ViewModel 抽象化,把 UI 逻辑放在纯粹、可测试的类中。作为整个迁移的第零步,我首先将其重构为 KMP,然后才碰任何一个功能模块。
    这个顺序的重要性远超表面所见。因为 UI 逻辑并不位于 Android ViewModel 内部,所以没有 androidx.lifecycle 依赖像线头一样穿过 38 个功能模块等着被拆解。状态持有者已经是纯粹的 Kotlin 了。
  2. 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 宿主测试可见。涉及的应用程序代码为零行。又是工具问题。


价值何在,局限在哪

在你照搬这套设置之前,我希望你权衡两个取舍:

  1. 它只覆盖了一个平台。​ Compose Multiplatform 在 iOS 和 Web 上通过 Skia 渲染。一套绿色的截图套件告诉我 Composable 在 Android 上是稳定的,但对另外两个目标平台则毫无信息。顺便问一下,如果你做了,你会如何弥补这个缺口?欢迎提出解决方案 🙂
  2. 仓库中有 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