實戰(zhàn):將 Android 多模塊應(yīng)用遷移到 Kotlin Multiplatform + Compose Multiplatform date: 2026-02-13

實戰(zhàn):將 Android 多模塊應(yīng)用遷移到 Kotlin Multiplatform + Compose Multiplatform

最近把自己的 NBA 數(shù)據(jù)應(yīng)用 HoopsNow 從純 Android 多模塊架構(gòu)遷移到了 KMP + CMP,實現(xiàn)了 Android/iOS 共享一套代碼。這篇文章記錄整個遷移過程中的思路、踩坑和最終方案。

項目背景

HoopsNow 是一個 NBA 數(shù)據(jù)展示應(yīng)用,功能包括比賽比分、球隊信息、球員搜索和收藏管理。遷移前的架構(gòu)參考了 Google 的 Now in Android 項目,是一個標(biāo)準(zhǔn)的 Android 多模塊架構(gòu):

hoopsnow/
├── app/                        # 入口 + Navigation3
├── core/                       # 9 個核心模塊
│   ├── common/                 # 工具類
│   ├── data/                   # Repository
│   ├── database/               # Room
│   ├── datastore/              # DataStore
│   ├── designsystem/           # 主題
│   ├── model/                  # 數(shù)據(jù)模型
│   ├── network/                # Ktor
│   ├── testing/                # 測試工具
│   └── ui/                     # 共享 UI
├── feature/                    # 4 個功能模塊 (api/impl)
│   ├── games/
│   ├── teams/
│   ├── players/
│   └── favorites/
└── build-logic/                # 7 個 Convention Plugins

技術(shù)棧:Hilt + Navigation3 + Room + ViewModel + Coil

這套架構(gòu)在純 Android 場景下很好用,模塊邊界清晰,構(gòu)建并行度高。但當(dāng)我想把應(yīng)用擴(kuò)展到 iOS 時,這些 Android 專屬的庫就成了障礙。

為什么選擇 KMP + CMP

考慮過幾個方案:

方案 優(yōu)點 缺點
Flutter 生態(tài)成熟,熱重載 需要重寫全部代碼,Dart 語言
React Native Web 開發(fā)者友好 性能開銷,橋接復(fù)雜
KMP + 原生 UI 共享邏輯,原生體驗 需要寫兩套 UI
KMP + CMP 共享邏輯 + UI,Kotlin 全棧 CMP iOS 端相對年輕

最終選了 KMP + CMP,原因很簡單:現(xiàn)有代碼是 Kotlin + Compose,遷移成本最低,UI 也能共享。

技術(shù)棧替換

遷移的核心就是把 Android 專屬庫替換為 KMP 兼容方案:

功能 遷移前 遷移后 遷移難度
依賴注入 Hilt Koin 4.0 ??
導(dǎo)航 Navigation3 Voyager 1.1.0-beta03 ???
數(shù)據(jù)庫 Room SQLDelight 2.0 ???
狀態(tài)管理 ViewModel Voyager ScreenModel ?
圖片加載 Coil Coil 3 (KMP) ?
網(wǎng)絡(luò) Ktor (Android) Ktor 3.0 (KMP) ?
UI Jetpack Compose Compose Multiplatform 1.7 ?

下面逐個說說遷移細(xì)節(jié)。

一、創(chuàng)建 shared 模塊

第一步是創(chuàng)建 KMP 共享模塊。shared/build.gradle.kts 的核心配置:

plugins {
    alias(libs.plugins.kotlin.multiplatform)
    alias(libs.plugins.android.library)
    alias(libs.plugins.kotlin.compose)
    alias(libs.plugins.compose.multiplatform)
    alias(libs.plugins.kotlin.serialization)
    alias(libs.plugins.sqldelight)
}

kotlin {
    androidTarget {
        compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
    }

    listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach {
        it.binaries.framework {
            baseName = "Shared"
            isStatic = true
        }
    }

    sourceSets {
        commonMain.dependencies {
            implementation(compose.runtime)
            implementation(compose.foundation)
            implementation(compose.material3)
            implementation(compose.materialIconsExtended)
            // Ktor, SQLDelight, Koin, Voyager, Coil ...
        }
        androidMain.dependencies {
            implementation(libs.ktor.client.okhttp)
            implementation(libs.sqldelight.android.driver)
        }
        iosMain.dependencies {
            implementation(libs.ktor.client.darwin)
            implementation(libs.sqldelight.native.driver)
        }
    }
}

二、數(shù)據(jù)庫遷移:Room → SQLDelight

這是遷移中工作量最大的部分。Room 不支持 KMP,必須換成 SQLDelight。

定義 .sq 文件

SQLDelight 用 .sq 文件定義表結(jié)構(gòu)和查詢,放在 commonMain/sqldelight/ 目錄下:

-- Team.sq
CREATE TABLE TeamEntity (
    id INTEGER PRIMARY KEY NOT NULL,
    conference TEXT NOT NULL,
    division TEXT NOT NULL,
    city TEXT NOT NULL,
    name TEXT NOT NULL,
    fullName TEXT NOT NULL,
    abbreviation TEXT NOT NULL
);

getAll: SELECT * FROM TeamEntity;
getById: SELECT * FROM TeamEntity WHERE id = ?;
upsert: INSERT OR REPLACE INTO TeamEntity VALUES (?, ?, ?, ?, ?, ?, ?);

平臺 Driver

通過 expect/actual 為不同平臺提供數(shù)據(jù)庫驅(qū)動:

// commonMain
expect class DatabaseDriverFactory {
    fun createDriver(): SqlDriver
}

// androidMain
actual class DatabaseDriverFactory(private val context: Context) {
    actual fun createDriver(): SqlDriver =
        AndroidSqliteDriver(NbaDatabase.Schema, context, "nba.db")
}

// iosMain
actual class DatabaseDriverFactory {
    actual fun createDriver(): SqlDriver =
        NativeSqliteDriver(NbaDatabase.Schema, "nba.db")
}

踩坑:SQLDelight 屬性名

SQLDelight 生成的 Queries 屬性名基于 .sq 文件名,不是表名。比如 Game.sq 生成 database.gameQueries,不是 database.gameEntityQueries。這個坑讓我排查了好一會兒。

踩坑:Kotlin 類型推斷

SQLDelight 的鏈?zhǔn)?mapper 調(diào)用會讓 Kotlin 的類型推斷犯迷糊。解決方案是寫顯式的擴(kuò)展函數(shù):

fun TeamEntity.toTeam(): Team = Team(
    id = id.toInt(),
    conference = conference,
    division = division,
    city = city,
    name = name,
    fullName = fullName,
    abbreviation = abbreviation,
)

三、依賴注入:Hilt → Koin

Hilt 依賴 Android 的注解處理器(KSP),不支持 KMP。Koin 是純 Kotlin 實現(xiàn),天然跨平臺。

// commonMain - KoinModules.kt
val sharedModule = module {
    // Network
    single<NbaNetworkDataSource> { KtorNbaNetwork(get()) }

    // Database
    single { get<DatabaseDriverFactory>().createDriver() }
    single { NbaDatabase(get()) }

    // Repositories
    single<GamesRepository> { OfflineFirstGamesRepository(get(), get()) }
    single<TeamsRepository> { OfflineFirstTeamsRepository(get(), get()) }
    single<PlayersRepository> { OfflineFirstPlayersRepository(get(), get()) }
    single<FavoritesRepository> { OfflineFirstFavoritesRepository(get(), get()) }

    // ScreenModels
    factory { GamesListScreenModel(get()) }
    factory { params -> GameDetailScreenModel(params.get(), get()) }
    // ...
}

// 平臺模塊通過 expect/actual 提供
expect fun platformModule(): Module

平臺模塊只需要提供 HTTP 引擎和數(shù)據(jù)庫驅(qū)動:

// androidMain
actual fun platformModule(): Module = module {
    single<HttpClientEngine> { OkHttp.create() }
    single { DatabaseDriverFactory(get()) }
}

// iosMain
actual fun platformModule(): Module = module {
    single<HttpClientEngine> { Darwin.create() }
    single { DatabaseDriverFactory() }
}

遷移體驗:Hilt 的 @HiltViewModel + @Inject constructor 全部刪掉,換成 Koin 的 factory { } 聲明。代碼量反而少了。

四、導(dǎo)航:Navigation3 → Voyager

導(dǎo)航是遷移中設(shè)計決策最多的部分。Voyager 提供了 TabNavigator + Navigator 的組合,很適合底部 Tab + 頁面棧的場景。

Tab 定義

object GamesTab : Tab {
    override val options @Composable get() = TabOptions(
        index = 0u,
        title = "Games",
        icon = rememberVectorPainter(Icons.Default.SportsBasketball),
    )

    @Composable
    override fun Content() {
        Navigator(GamesListScreen()) { navigator ->
            SlideTransition(navigator)
        }
    }
}

每個 Tab 內(nèi)嵌獨立的 Navigator,Tab 切換時各自的導(dǎo)航棧互不影響。

Screen 定義

class GamesListScreen : Screen {
    @Composable
    override fun Content() {
        val screenModel = koinScreenModel<GamesListScreenModel>()
        val uiState by screenModel.uiState.collectAsState()
        // UI ...
    }
}

頁面間傳參

Voyager 通過構(gòu)造函數(shù)傳參,簡單直接:

class GameDetailScreen(private val gameId: Int) : Screen { ... }

// 導(dǎo)航
navigator.push(GameDetailScreen(gameId = 123))

Koin 端用 parametersOf 傳遞:

// 定義
factory { params -> GameDetailScreenModel(params.get(), get()) }

// 使用
val screenModel = koinScreenModel<GameDetailScreenModel> { parametersOf(gameId) }

主入口

@Composable
fun HoopsNowApp() {
    HoopsNowTheme {
        TabNavigator(GamesTab) {
            Scaffold(
                bottomBar = {
                    NavigationBar {
                        TabNavigationItem(GamesTab)
                        TabNavigationItem(TeamsTab)
                        TabNavigationItem(PlayersTab)
                        TabNavigationItem(FavoritesTab)
                    }
                },
            ) {
                CurrentTab()
            }
        }
    }
}

五、狀態(tài)管理:ViewModel → ScreenModel

這是最簡單的一步。Voyager 的 ScreenModelViewModel 幾乎一模一樣:

// 遷移前
@HiltViewModel
class GamesListViewModel @Inject constructor(
    private val gamesRepository: GamesRepository,
) : ViewModel() {
    val uiState = gamesRepository.getGames()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), Loading)
}

// 遷移后
class GamesListScreenModel(
    private val gamesRepository: GamesRepository,
) : ScreenModel {
    val uiState = gamesRepository.getGames()
        .stateIn(screenModelScope, SharingStarted.WhileSubscribed(5000), Loading)
}

改動點:

  • 刪除 @HiltViewModel@Inject constructor
  • ViewModel()ScreenModel
  • viewModelScopescreenModelScope
  • collectAsStateWithLifecycle()collectAsState()(CMP 中沒有 AndroidX Lifecycle)

六、Android 入口精簡

遷移后 app 模塊只剩兩個文件:

// HoopsNowApplication.kt
class HoopsNowApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        startKoin {
            androidContext(this@HoopsNowApplication)
            modules(sharedModule, platformModule())
        }
    }
}

// MainActivity.kt
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge(...)
        setContent {
            CompositionLocalProvider(
                LocalTeamLogos provides TeamLogoProvider.getAllLogos(),
                LocalPlayerHeadshot provides PlayerHeadshotProvider::getHeadshotUrl,
            ) {
                HoopsNowApp()  // 來自 shared 模塊
            }
        }
    }
}

七、iOS 接入

iOS 端更簡單,只需要一個 SwiftUI 殼:

// iOSApp.swift
@main
struct iOSApp: App {
    init() {
        KoinHelperKt.doInitKoin()
    }
    var body: some Scene {
        WindowGroup { ContentView() }
    }
}

// ContentView.swift
struct ContentView: View {
    var body: some View {
        ComposeView().ignoresSafeArea(.all)
    }
}

struct ComposeView: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> UIViewController {
        MainViewControllerKt.MainViewController()
    }
    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}

shared 模塊中提供 iOS 入口(最終落地版本):

// iosMain - MainViewController.kt
fun MainViewController() = ComposeUIViewController {
    CompositionLocalProvider(
        LocalTeamLogos provides TeamLogoProvider.getAllLogos(),
        LocalPlayerHeadshot provides PlayerHeadshotProvider::getHeadshotUrl,
    ) {
        HoopsNowApp()
    }
}

就這樣,iOS 端就能跑起來了。整個 Compose UI 通過 ComposeUIViewController 嵌入 SwiftUI。

最終 iOS 工程入口路徑:

iosApp/iosApp/iosApp.xcodeproj

常用構(gòu)建命令:

# Apple Silicon 模擬器
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64

# Intel 模擬器
./gradlew :shared:linkDebugFrameworkIosX64

八、清理舊代碼

遷移完成后建議清理:

  • core/ — 9 個舊 Android 模塊
  • feature/ — 4 個功能模塊
  • app/navigation/ — 舊 Navigation3 代碼
  • build-logic/ 中的 6 個 Convention Plugin(Hilt、Room、Feature、Library 等)
  • libs.versions.toml 中的 Hilt、KSP 相關(guān)聲明

當(dāng)前倉庫為了遷移對照,仍保留了部分 core/、feature/ 歷史代碼,但它們不在 settings.gradle.kts 中參與構(gòu)建。構(gòu)建維度已經(jīng)是 2 個模塊(app + shared)。

遷移后的項目結(jié)構(gòu)

hoopsnow/
├── app/                                # Android 入口(2 個文件)
├── shared/                             # KMP 共享模塊
│   └── src/
│       ├── commonMain/                 # 全部業(yè)務(wù)邏輯 + UI
│       │   ├── kotlin/.../
│       │   │   ├── core/               # 數(shù)據(jù)層(model, data, database, network)
│       │   │   ├── di/                 # Koin 模塊
│       │   │   └── ui/                 # UI 層(screens, components, theme, navigation)
│       │   ├── composeResources/files/ # 跨平臺資源
│       │   │   ├── nba_players_name_id.json
│       │   │   └── teams.json
│       │   └── sqldelight/             # 數(shù)據(jù)庫定義
│       ├── androidMain/                # Android 平臺實現(xiàn)
│       └── iosMain/                    # iOS 平臺實現(xiàn)
├── iosApp/                             # iOS 入口
│   └── iosApp/
│       ├── iosApp.xcodeproj
│       ├── iOSApp.swift
│       ├── ContentView.swift
│       └── Info.plist
└── build-logic/                        # Convention Plugins(精簡)

踩坑總結(jié)

1. SQLDelight 屬性名

生成的 Queries 屬性名基于 .sq 文件名(gameQueries),不是 CREATE TABLE 的表名(gameEntityQueries)。

2. collectAsStateWithLifecycle 不可用

這是 AndroidX Lifecycle 的擴(kuò)展,CMP 中用 collectAsState() 替代。ScreenModel 會在 Screen dispose 時自動取消 scope,不用擔(dān)心泄漏。

3. Kotlin 類型推斷與 SQLDelight

鏈?zhǔn)?mapper 調(diào)用時類型推斷可能失敗,寫顯式的 toModel() 擴(kuò)展函數(shù)解決。

4. Material Icons Extended

Icons.Default.StarBorder、Icons.Default.OpenInNew 等圖標(biāo)需要額外添加 compose.materialIconsExtended 依賴。

5. Koin ScreenModel 參數(shù)傳遞

帶參數(shù)的 ScreenModel 需要用 factory { params -> } 定義,使用時通過 koinScreenModel { parametersOf(...) } 傳入。

6. iOS Framework 編譯

每次修改 shared 代碼后需要重新編譯 Framework。Xcode 工程路徑如果是 iosApp/iosApp/iosApp.xcodeproj,Framework Search Paths 和 Run Script 的相對路徑要按兩級目錄配置,否則容易出現(xiàn) Framework not found Shared。

遷移收益

指標(biāo) 遷移前 遷移后
模塊數(shù)量 20+ 2 (app + shared)
支持平臺 Android Android + iOS
UI 代碼共享 0% 100%
業(yè)務(wù)邏輯共享 0% 100%
build.gradle 文件 20+ 3
Convention Plugins 7 2

最大的收益是 iOS 端幾乎零成本接入 — 只需要兩個 Swift 文件就能跑起完整的應(yīng)用。

依賴版本參考

版本
Kotlin 2.0.21
Compose Multiplatform 1.7.3
Ktor 3.0.3
SQLDelight 2.0.2
Koin 4.0.0
Voyager 1.1.0-beta03
Coil 3 3.0.4
kotlinx-serialization 1.7.3
kotlinx-datetime 0.6.1
Coroutines 1.9.0

總結(jié)

整個遷移花了大約一天時間,其中數(shù)據(jù)庫遷移(Room → SQLDelight)和導(dǎo)航遷移(Navigation3 → Voyager)占了大部分工作量。網(wǎng)絡(luò)層(Ktor)和序列化(kotlinx-serialization)本身就是 KMP 庫,基本不用改。

如果你的 Android 項目已經(jīng)在用 Kotlin + Compose,遷移到 KMP + CMP 的成本比想象中低很多。最大的障礙是 Room 和 Hilt 這兩個 Android 專屬庫的替換,但 SQLDelight 和 Koin 都是成熟的替代方案。

項目源碼:GitHub - laibinzhi/hoopsnow(cmp 分支)

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容