资讯动态

【Jetpack Compose娓娓道来】 第22课:Kotlin Multiplatform深入实践——从“共享UI“到“共享一切“

发布时间:2026/9/26 11:09:04 来源:尧图企业网站定制
一、先讲一个真实的故事我见过一个团队用Compose Multiplatform把Android应用移植到了iOS。移植完成后他们发现一个尴尬的事实共享的代码只有UI层。网络请求用的是平台各自的库——Android用RetrofitiOS用Alamofire。数据库各写各的——Android用RoomiOS用Core Data。连数据模型都各定义了一份。结果就是改一个字段要改两套代码加一个接口要写两遍逻辑。他们确实“用了KMP”但只用了KMP的一半能力。UI共享了业务逻辑没共享。这就像两个人共用一套房子的装修风格但水管、电线、地基各建各的——表面上看起来一样底下全是重复劳动。这就是这一课要解决的问题如何从“共享UI”走到“共享一切”。第14课我们认识了Compose Multiplatform知道了它能让Compose代码在Android、iOS、桌面和Web上运行。但第14课更多是“介绍性”的——CMP是什么、各平台支持怎么样、工程结构长什么样。这一课我们要深入KMP的工程实践回答那些真正开始写多平台代码时才会遇到的问题expect/actual到底该怎么用什么时候用函数级什么时候用类级共享代码到底该共享什么UI和业务逻辑要不要分开模块我现有的Android Compose项目怎么一步步迁移到KMPKMP的依赖注入、网络、数据库怎么选和Android生态怎么衔接多平台代码怎么测试二、KMP的工程配置2026年的默认结构2.1 新旧项目结构的区别第14课我们看过一个简化的KMP项目结构但那是“旧版”的。2026年5月JetBrains发布了新的默认项目结构与AGP 9.0对齐。旧结构一个composeApp模块包含所有共享代码和所有平台的入口点。所有平台共用同一个Gradle模块。新结构共享KMP库模块shared 各平台的独立应用模块。共享代码放在一个纯粹的Kotlin Multiplatform库模块中每个平台有自己的入口模块依赖这个共享模块。为什么这个变化很重要AGP 9.0要求Android应用入口点与通用代码分离。如果你打算使用AGP 9或更高版本必须采用新结构。2.2 新结构的典型布局my-app/ ├── shared/ # 共享KMP库模块 │ ├── src/ │ │ ├── commonMain/ # 共享代码Composable、ViewModel、业务逻辑 │ │ ├── androidMain/ # Android平台特定实现 │ │ ├── iosMain/ # iOS平台特定实现 │ │ ├── desktopMain/ # 桌面平台特定实现 │ │ └── commonTest/ # 共享代码的测试 │ └── build.gradle.kts ├── androidApp/ # Android应用入口模块 │ └── src/main/kotlin/MainActivity.kt ├── iosApp/ # iOS应用入口Xcode项目 ├── desktopApp/ # 桌面应用入口模块 └── build.gradle.kts共享模块的commonMain包含所有可共享的代码——Composable函数、ViewModel、Repository、UseCase、数据模型。各平台的入口模块只负责启动应用和提供平台特定的能力。2.3 源集的依赖关系KMP的源集之间有一种“继承”关系commonMain是所有平台共享的代码。androidMain继承自commonMain可以使用Android SDK但不能被commonMain引用。iosMain继承自commonMain可以使用iOS API。desktopMain继承自commonMain可以使用JVM/桌面API。关键约束commonMain不能导入任何平台特定的包如android.*、platform.UIKit.*、java.awt.*。如果commonMain里出现平台导入会在iOS链接阶段报错而且错误信息距离出问题的代码很远非常难排查。简单原则默认放commonMain只有当代码必须依赖平台能力时才放到平台源集。每一行放在平台源集的代码都应该是一个“有正当理由的例外”。三、expect/actual深入不只是“条件编译”3.1 与前端条件编译的本质区别如果你有前端背景可能觉得expect/actual和process.env.PLATFORM web差不多。但它们有本质区别前端条件编译是运行时或打包时的分支判断。类型系统无法验证每个平台的实现是否齐全遗漏某个平台的实现往往要等到运行时才会暴露。expect/actual是编译时强制约束。expect定义跨平台的公共契约actual提供各平台的具体实现。遗漏任何一个平台的实现编译就会失败。这意味着你不需要担心“某个平台忘了实现”的问题。编译器会逼着你实现所有平台。3.2 函数级 vs 类级expect/actual可以用于函数、属性、类、接口等。但在实践中优先使用函数级别的expect/actual粒度最细最容易测试和维护。类级别的expect/actual会让代码导航复杂一些。// 推荐函数级expectfungetPlatformName():StringexpectfunrandomUUID():String// 也可以但更重expectclassPlatformSpecificLogger{funlog(message:String)}3.3 三种典型使用场景场景一平台标识// commonMainexpectfungetPlatformName():String// androidMainactualfungetPlatformName():StringAndroid// iosMainactualfungetPlatformName():StringiOS// desktopMainactualfungetPlatformName():StringDesktop场景二时间格式化不同平台的时间API不同。Android和桌面可以用java.timeiOS需要NSDateFormatterWeb需要JavaScript的Date。// commonMainexpectfunformatCurrentTime():String// androidMain / desktopMainactualfunformatCurrentTime():Stringjava.time.LocalDateTime.now().toString()// iosMainactualfunformatCurrentTime():StringNSDateFormatter().apply{dateFormatyyyy-MM-dd HH:mm:ss}.stringFromDate(NSDate())场景三UUID生成// commonMainexpectfunrandomUUID():String// androidMain / desktopMainactualfunrandomUUID():Stringjava.util.UUID.randomUUID().toString()// iosMainactualfunrandomUUID():StringNSUUID().UUIDString()3.4 什么时候不该用expect/actual重要的架构建议当平台差异是服务依赖而不是语言边界时优先用普通接口依赖注入而不是expect/actual。// 不推荐用expect/actual做依赖注入expectfuncreateHttpClient():HttpClient// 推荐定义接口用DI注入interfaceHttpClientProvider{funcreate():HttpClient}// Android实现、iOS实现分别在各自模块中通过Koin/Hilt注入expect/actual适合编译时就必须确定的平台差异。如果是运行时可以替换的服务用接口DI更灵活。3.5 Koin Annotations绕过expect/actual的新方式Koin 4.x 引入了一个有趣的模式用注解和Koin编译器完全绕过expect/actual。在commonMain中定义接口在平台源集中用Factory或Single注解实现Koin的ComponentScan会自动扫描所有平台的模块找到对应实现。// commonMaininterfacePlatformDomainProtocol{fundescription():String}// androidMainFactoryclassPlatformDomain:PlatformDomainProtocol{overridefundescription()from android}// iosMainFactoryclassPlatformDomain:PlatformDomainProtocol{overridefundescription()from ios}commonMain中完全不需要expect/actual。这个方式适合平台差异是实现细节而不是编译时契约的场景。四、共享逻辑与平台UI的权衡4.1 三种共享策略KMP项目有三种共享层次选择哪种取决于你的目标策略一只共享业务逻辑UI全部原生。Android用ComposeiOS用SwiftUI桌面用各自的UI框架。共享模块只包含Repository、UseCase、数据模型。策略二共享业务逻辑部分UI。一些页面用CMP共享一些页面用平台原生UI。共享模块分成sharedLogic和sharedUI两个模块。策略三共享业务逻辑全部UI。所有平台的UI都用CMP只有极少数平台特定的组件用expect/actual或原生互操作。4.2 模块拆分策略官方的建议是不要预先拆分从一个shared模块开始当某个平台真正需要原生UI时再拆。如果你确定iOS端需要原生UI比如用SwiftUI实现某些页面那么应该sharedLogic模块纯Kotlin包含Repository、UseCase、数据模型。所有平台都依赖它。sharedUI模块依赖sharedLogic包含CMP的Composable。只有使用CMP的平台才依赖它。// sharedLogic/build.gradle.ktskotlin{androidTarget()iosX64();iosArm64();iosSimulatorArm64()jvm(desktop)// 不依赖Compose Multiplatform}// sharedUI/build.gradle.ktskotlin{sourceSets{commonMain.dependencies{implementation(project(:sharedLogic))implementation(compose.runtime)implementation(compose.material3)}}}4.3 在共享UI中处理平台差异CMP的哲学是“共享优先平台例外”。在共享UI中你不应该到处写平台判断。正确做法是用接口隔离平台差异在平台层注入实现。// commonMain — 定义接口interfacePlatformUIProvider{ComposablefunPlatformSpecificComponent()}// androidMain — 提供实现classAndroidUIProvider:PlatformUIProvider{ComposableoverridefunPlatformSpecificComponent(){AndroidView(factory{...})}}然后在共享UI中通过DI注入PlatformUIProvider。这样共享UI的代码完全干净平台差异被隔离在平台层。五、从Android Compose项目迁移到KMP5.1 迁移的前置检查JetBrains官方指南指出如果你的项目已经用Kotlin和Jetpack Compose迁移复杂度会大幅降低。在开始迁移之前检查以下几项Java代码commonMain不能包含Java代码。如果项目里有Java代码要么隔离到androidMain要么转成Kotlin。用到的Java库如RxJava应该先迁移到kotlinx-coroutines。Android专属APIjava.time、Uri、Objects.hash()等需要替换为KMP兼容的替代品或者隔离到平台源集。Android资源管理Android的res/目录在commonMain中不可用。需要用CMP的资源管理方式composeResources/替代。5.2 逐模块迁移策略官方推荐的迁移方式是从依赖树中最底层的模块开始逐个迁移。第一步迁移数据层模块。把网络请求、数据库访问、数据模型迁移到KMP。用Ktor替代Retrofit用SQLDelight替代Room。第二步迁移Domain层模块。UseCase和Repository接口是纯Kotlin可以直接移到commonMain。第三步迁移UI层。把Composable函数逐个移到shared模块的commonMain中。Android的MainActivity变成只调用App()的薄入口。第四步创建其他平台入口。添加iOS的MainViewController、桌面的main()。5.3 资源迁移Android的res/values/strings.xml需要移到composeResources/values/strings.xml。图片从res/drawable移到composeResources/drawable。字体从res/font移到composeResources/font。构建后CMP会生成Res.strings和Res.drawable对象提供类型安全的资源访问。六、KMP生态工具链6.1 依赖注入KoinKMP项目推荐用Koin替代HiltHilt目前不直接支持KMP。Koin是纯Kotlin的轻量级DI框架基于DSL配置运行时解析依赖。// commonMainvalappModulemodule{singleArticleRepository{ArticleRepositoryImpl(get(),get())}viewModelOf(::ArticleListViewModel)}// 启动时初始化startKoin{modules(appModule)}Koin 4.x 还提供了注解和编译器可以进一步简化配置。6.2 网络KtorKtor Client是KMP上最流行的网络库设计上就支持多平台。// commonMainvalclientHttpClient{install(ContentNegotiation){json()}}suspendfungetArticles():ListArticleclient.get(https://api.example.com/articles).body()不同平台需要不同的HTTP引擎Android用OkHttpiOS用Darwin桌面用OkHttp或CIO。Ktor的HttpClient会自动选择可用的引擎。6.3 数据库SQLDelightSQLDelight从SQL查询生成类型安全的Kotlin代码是KMP上Room的替代方案。-- shared/src/commonMain/sqldelight/com/example/Article.sqCREATETABLEarticle(idINTEGERPRIMARYKEY,titleTEXTNOTNULL,contentTEXTNOTNULL);selectAll:SELECT*FROMarticleORDERBYidDESC;insert:INSERTINTOarticle(id,title,content)VALUES(?,?,?);SQLDelight会生成ArticleQueries类提供类型安全的方法。不同平台用不同的SQLite驱动Android用AndroidSqliteDriveriOS用NativeSqliteDriver桌面用JdbcSqliteDriver。6.4 其他KMP库库用途Coil图片加载支持KMPkotlinx-datetime跨平台日期时间处理kotlinx-serialization跨平台序列化替代Gson/MoshiVoyager / DecomposeKMP导航库Haze背景模糊效果Compose DND拖拽功能七、测试与调试7.1 共享代码的单元测试KMP的commonTest源集可以写共享的单元测试用kotlin.test库。测试会在所有平台上运行。// commonTestclassArticleRepositoryTest{TestfungetArticles returns list()runTest{valfakeApiFakeArticleApi()valfakeDaoFakeArticleDao()valrepositoryArticleRepositoryImpl(fakeApi,fakeDao)valresultrepository.getArticles()assertTrue(result.isSuccess)}}kotlin.test提供了跨平台的断言assertEquals、assertTrue、assertContains等。7.2 平台特定测试平台特定的测试放在各自的源集中。Android的仪器测试放在androidInstrumentedTestiOS的测试放在iosTest。7.3 调试技巧commonMain的导入清洁如果commonMain里出现了平台导入编译错误会在iOS链接阶段才暴露而且错误信息很模糊。建议在IDE中配置导入检查尽早发现。KMPify工具这是一个自动化工具帮助把Android Jetpack Compose项目迁移到KMP自动替换Android特定的资源导入。八、常见陷阱速查陷阱后果解决方案commonMain导入平台APIiOS链接阶段才报错用expect/actual抽象过度使用expect/actual代码僵化运行时可替换的用接口DI预拆分模块不必要的复杂度从一个shared开始按需拆分共享UI中写平台判断耦合、难维护用接口隔离DI注入忘记配置AGP 9新结构构建失败共享模块独立入口模块依赖版本不兼容编译失败CMP 1.11要求Kotlin 2.1DateTimeFormatter不缓存列表滚动卡顿用对象缓存formatter实例在commonMain用java.timeiOS编译失败用kotlinx-datetime九、综合实战一个KMP新闻应用我们把这一课的知识串起来做一个KMP新闻应用。9.1 共享模块结构shared/ ├── src/ │ ├── commonMain/ │ │ ├── kotlin/ │ │ │ ├── data/ │ │ │ │ ├── ArticleApi.kt # Ktor API │ │ │ │ ├── ArticleDao.kt # SQLDelight DAO │ │ │ │ └── ArticleRepository.kt # Repository │ │ │ ├── domain/ │ │ │ │ ├── Article.kt # 数据模型 │ │ │ │ └── GetArticlesUseCase.kt │ │ │ ├── ui/ │ │ │ │ ├── App.kt # 根Composable │ │ │ │ ├── ArticleListScreen.kt │ │ │ │ └── ArticleDetailScreen.kt │ │ │ └── di/ │ │ │ └── AppModule.kt # Koin模块 │ │ └── composeResources/ │ │ ├── values/strings.xml │ │ └── values-zh/strings.xml │ ├── androidMain/ │ │ └── kotlin/ │ │ └── MainActivity.kt │ ├── iosMain/ │ │ └── kotlin/ │ │ └── MainViewController.kt │ └── desktopMain/ │ └── kotlin/ │ └── main.kt9.2 数据层// commonMain/data/Article.ktSerializabledataclassArticle(valid:Long,valtitle:String,valcontent:String,valpublishedAt:Long)// commonMain/data/ArticleApi.ktclassArticleApi(privatevalclient:HttpClient){suspendfungetArticles():ListArticleclient.get(https://api.example.com/articles).body()}// commonMain/data/ArticleRepository.ktclassArticleRepository(privatevalapi:ArticleApi,privatevaldao:ArticleDao){fungetArticlesFlow():FlowListArticledao.getAllFlow()suspendfunrefresh():ResultUnittry{valremoteapi.getArticles()dao.insertAll(remote)Result.success(Unit)}catch(e:Exception){Result.failure(e)}}9.3 UI层// commonMain/ui/App.ktComposablefunApp(){MaterialTheme{valnavControllerrememberNavController()NavHost(navController,startDestinationlist){composable(list){ArticleListScreen(onArticleClick{id-navController.navigate(detail/$id)})}composable(detail/{id}){backStackEntry-validbackStackEntry.arguments?.getString(id)?.toLongOrNull()ArticleDetailScreen(articleIdid)}}}}9.4 各平台入口// androidMainclassMainActivity:ComponentActivity(){overridefunonCreate(savedInstanceState:Bundle?){super.onCreate(savedInstanceState)setContent{App()}}}// iosMainfunMainViewController():UIViewControllerComposeUIViewController{App()}// desktopMainfunmain()application{Window(onCloseRequest::exitApplication){App()}}十、KMP的思维模型最后用一张“思维模型”来总结这一课第一层新项目结构。共享KMP库模块各平台独立入口模块与AGP 9.0对齐。第二层expect/actual。编译时强制约束优先用函数级适合必须编译时确定的平台差异。运行时可替换的服务用接口DI。第三层共享策略。从单一shared模块开始按需拆分sharedLogic和sharedUI。共享优先平台例外。第四层迁移策略。从依赖树底层向上迁移先数据层再Domain层最后UI层。第五层生态工具。KoinDI、Ktor网络、SQLDelight数据库、Coil图片、kotlinx-serialization。第六层测试。commonTest写共享单元测试用kotlin.test。贯穿始终的原则共享优先平台例外。能共享的放commonMain不能共享的用expect/actual或接口DI隔离在平台层。十一、小结与下一课预告这一课我们深入了KMP的工程实践。关键点回顾新项目结构共享KMP库模块各平台独立入口模块与AGP 9.0对齐。expect/actual编译时强制约束优先用函数级适合必须编译时确定的平台差异。运行时可替换的服务用接口DI。共享策略从单一shared模块开始按需拆分sharedLogic和sharedUI。迁移策略从依赖树底层向上迁移先数据层再Domain层最后UI层。生态工具KoinDI、Ktor网络、SQLDelight数据库、Coil图片、kotlinx-serialization。测试commonTest写共享单元测试用kotlin.test。常见陷阱commonMain导入平台API、过度使用expect/actual、预拆分模块、共享UI中写平台判断、忘记配置AGP 9新结构。KMP的价值不在于“写一次跑所有平台”而在于“把可共享的共享把必须分开的分开”。共享优先平台例外——这是KMP工程实践的第一性原理。下一课我们会讲Compose与AI的结合——把生成式AI能力集成到Compose应用中。内容包括如何调用大模型API、如何设计AI对话界面、流式响应的处理、AI辅助UI生成、以及Compose在AI时代的机遇与挑战。这是把Compose从“UI框架”提升到“智能应用框架”的关键一课。课后练习建议找一个你之前写的Compose页面试着把它的数据层和Domain层迁移到KMP的commonMain。不用一开始就迁移UI只迁移Repository和UseCase。你会发现业务逻辑的共享比UI共享更简单收益也更大。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑