从纯 Android 到 KMP + CMP:一次真实迁移的做法和坑点

如果你手里是一个已经上线很久的 Android 项目,不要把 KMP/CMP 当成"重写项目"的理由。真正可行的路径是:保留现有 Android 业务节奏,把可共享的部分一点点搬到 shared。

迁移可以拆成三步:先迁数据层,再迁状态层,最后才碰 UI。


先定一个现实目标

一开始别追"复用率 80%"。这个目标听上去很美,但会逼着你把不该共享的东西硬塞进 shared,最后两端都难受。

更实用的目标是这三个:

  • Android 和 iOS 的业务结果一致(成功、失败、重试策略一致)
  • 关键页面状态流转一致(加载、空态、错误态一致)
  • 迁移过程可以随时停、随时回滚

第一步:先动数据层,不动 UI

最先迁的是网络 + 仓库层。这个阶段的原则很简单:Android 页面逻辑先不改,只把数据来源替换成 shared。

项目结构可以先变成这样:

text 复制代码
project
├── androidApp          # 现有 Android app,先继续用
├── iosApp              # iOS 壳(后面接入)
└── shared
    ├── model
    ├── network
    └── repository

shared/build.gradle.kts 先保持最小可用:

kotlin 复制代码
kotlin {
    androidTarget()
    iosX64()
    iosArm64()
    iosSimulatorArm64()

    sourceSets {
        val commonMain by getting {
            dependencies {
                implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
                implementation("io.ktor:ktor-client-core:2.3.12")
                implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1")
            }
        }

        val androidMain by getting {
            dependencies {
                implementation("io.ktor:ktor-client-okhttp:2.3.12")
            }
        }

        val iosMain by creating {
            dependsOn(commonMain)
            dependencies {
                implementation("io.ktor:ktor-client-darwin:2.3.12")
            }
        }

        getByName("iosX64Main").dependsOn(iosMain)
        getByName("iosArm64Main").dependsOn(iosMain)
        getByName("iosSimulatorArm64Main").dependsOn(iosMain)
    }
}

这里最容易漏的是 iosMain。不建它,后面 iOS 三套 sourceSet 配置会写到怀疑人生。


第二步:先统一错误语义,再说"多端一致"

很多迁移失败不是因为技术本身,而是 Android 和 iOS 的错误定义各自为政。

建议 shared 里先把错误语义钉死:

kotlin 复制代码
sealed interface AppError {
    data object Network : AppError
    data object Unauthorized : AppError
    data class Server(val code: Int, val message: String? = null) : AppError
    data class Unknown(val cause: Throwable) : AppError
}

sealed interface AppResult<out T> {
    data class Success<T>(val data: T) : AppResult<T>
    data class Failure(val error: AppError) : AppResult<Nothing>
}

仓库层统一返回 AppResult,不要把原生异常往上抛:

kotlin 复制代码
class UserRepository(private val api: UserApi) {

    suspend fun fetchProfile(): AppResult<UserProfile> = runCatching {
        api.profile()
    }.fold(
        onSuccess = { AppResult.Success(it) },
        onFailure = { throwable -> AppResult.Failure(throwable.toAppError()) }
    )
}

这个改动看起来小,但它会直接减少"同一个错误,双端两个文案和两个处理逻辑"的情况。


第三步:把 Android 的 ViewModel 思路抽成 shared StateHolder

这一步是迁移里的转折点。

如果你还在 Android 端单独维护一套 ViewModel 逻辑,而 shared 只提供数据,那你其实只做了"网络层复用"。

一般先把状态机抽成 shared:

kotlin 复制代码
data class ProfileState(
    val loading: Boolean = false,
    val userName: String = "",
    val error: AppError? = null
)

class ProfileStateHolder(
    private val repo: UserRepository,
    private val scope: CoroutineScope
) {
    private val _state = MutableStateFlow(ProfileState())
    val state: StateFlow<ProfileState> = _state.asStateFlow()

    fun load() {
        scope.launch {
            _state.update { it.copy(loading = true, error = null) }
            when (val result = repo.fetchProfile()) {
                is AppResult.Success -> _state.update {
                    it.copy(loading = false, userName = result.data.name)
                }
                is AppResult.Failure -> _state.update {
                    it.copy(loading = false, error = result.error)
                }
            }
        }
    }
}

Android 端先消费这个状态;等 iOS 接入时,iOS 也走同一个状态流。这样迁移的收益才会真正显现出来。


什么时候开始用 CMP 比较稳

别把第一个 CMP 页面选在登录、支付、下单这些链路上。建议从"低风险 + 状态简单"的页面开始,比如:

  • 设置页
  • 纯展示详情页
  • 简单列表页

一个最小 CMP 页面大概这样:

kotlin 复制代码
@Composable
fun ProfileScreen(holder: ProfileStateHolder) {
    val uiState by holder.state.collectAsState()

    when {
        uiState.loading -> CircularProgressIndicator()
        uiState.error != null -> Text("Load failed")
        else -> Text("Hello, ${uiState.userName}")
    }
}

先跑通 1 个页面,再决定要不要扩大 CMP 覆盖面,这样风险最低。


迁移里最容易复现的坑

1) 把平台能力硬抽到 shared

比如权限、推送、支付 SDK。如果硬抽,很快就会陷入 expect/actual 爆炸。建议这些继续放在平台层,只把业务决策结果交给 shared。

2) shared 里默认跑主线程

重解析 JSON、批量 DB 写入不切 dispatcher,两端一起卡。这个问题非常常见,而且第一次排查很难想到是 shared 造成的。

3) iOS 接 Flow 时没处理生命周期

订阅了不取消,页面销毁后还在收数据。建议做一层 watch + close 封装,把取消行为显式化。

4) 一次迁太多

同时迁网络、数据库、UI,最后出了问题没人说得清是哪里坏了。建议每次只迁一个业务点,保持可回退。


给纯 Android 团队的最小迁移清单

按这个顺序做,基本不会翻车:

  1. 建立 shared,只放模型和 1 个接口请求
  2. Android 先接 shared repository,不改页面
  3. 抽一个 StateHolder,替换 Android 里同类 ViewModel 逻辑
  4. iOS 接入同一个 StateHolder
  5. 选 1 个低风险页面试 CMP
  6. 观察一轮线上数据,再决定是否扩大范围

结尾

KMP + CMP 这件事,不难在"会不会写代码",难在"能不能控制迁移节奏"。

从纯 Android 转过来,最怕的不是慢,而是乱。只要你能保证每一步都可验证、可回滚,迁移就能稳稳推进。

相关推荐
fengci.4 小时前
Microweber CMS 未授权路径穿越漏洞(CVE-2026-65694)
android·开发语言·前端·学习·php
stevenzqzq5 小时前
Android 矢量图(Vector Drawable)核心坐标与尺寸机制技术文档
android
plainGeekDev5 小时前
运行时获取依赖 → 编译时注入
android·java·kotlin
Wiktok5 小时前
Vue3(Vite)打包安卓APP 完整最佳实践
android·vue
Co_Hui6 小时前
Android 系统服务的添加
android
杉氧7 小时前
用 Compose 挑战交互与动效天花板:ComposeCraftLab 开源实验室全解析
android·前端·kotlin
朝与同歌暮同酒8 小时前
冒泡社区《幻想三国》还能玩吗?安卓手机与电脑模拟器试玩记录
android·智能手机·电脑
音视频牛哥9 小时前
从数字孪生到机器人操控:Android Unity3D下RTMP/RTSP多路低延迟播放实践
android·unity·音视频·unity rtsp播放器·unity rtmp播放器·rtsp player·rtmp player
用户693717500138410 小时前
了解一下 Agent Harness
android·前端·后端
淡淡的香烟10 小时前
Android15适配16kb完整版
android