Compose Multiplatform:用一套声明式 UI 连接多端
为什么会有 Compose Multiplatform
Android 开发者熟悉 Jetpack Compose 之后,很自然会遇到一个问题:既然 UI 已经可以用 Kotlin 和声明式方式表达,能不能把同一套 UI 思路带到桌面、iOS、Web 或更多平台?Compose Multiplatform 就是沿着这个方向发展出来的技术栈。
它不是简单地把 Android View 搬到其他平台,也不是让所有平台完全共用同一份应用代码。更准确地说,它提供了一套跨平台的 Compose Runtime、UI 组件模型和渲染能力,让开发者可以用 Kotlin 写共享界面和状态逻辑,再在不同平台接入各自的入口、权限、生命周期和系统能力。
对 Android 团队来说,它最大的吸引力有三个:语言和思维模型延续 Kotlin + Compose,业务页面可以在多个端复用,团队不必为每个平台完全重写一套 UI。它适合产品形态一致、交互差异不大、团队希望降低多端重复开发成本的场景。
它和 Jetpack Compose 的关系
Jetpack Compose 是 Android 官方的现代 UI 工具包,目标主要是 Android。Compose Multiplatform 则把 Compose 的核心思想扩展到更多平台。两者共享很多概念:Composable 函数、状态驱动 UI、remember、Modifier、Material 风格组件、预览和重组机制。
但它们也有明显区别。Android Compose 可以直接使用 Android SDK、Activity、ViewModel、Navigation、资源系统和平台组件;Compose Multiplatform 需要考虑多个平台,所以共享代码里不能随意依赖 Android 专属 API。涉及相机、定位、通知、文件选择、系统分享、支付、推送等能力时,通常要通过平台适配层实现。
可以把它理解为:Compose 的 UI 思维是共享的,平台能力仍然是分层的。写得好的多端项目,不是把平台差异藏起来假装不存在,而是把共性和差异放在合适的位置。
适合什么场景
Compose Multiplatform 比较适合这些类型的项目:
- 工具类应用:例如笔记、待办、Markdown 编辑器、数据看板。
- 中后台或桌面管理端:页面结构稳定,交互主要是表单、列表、筛选、编辑。
- Android 团队主导的多端尝试:团队已有 Kotlin 和 Compose 经验。
- 产品 UI 在多个平台高度一致:不需要大量平台原生交互。
- 内部工具或新业务验证:希望快速覆盖桌面端和移动端。
它不太适合一开始就强行覆盖所有场景。如果应用高度依赖平台原生体验,例如复杂视频编辑、地图深度交互、系统级能力、强平台风格页面,跨平台共享 UI 的收益可能会被适配成本抵消。
推荐的工程结构
一个常见结构会把共享代码和平台入口分开:
composeApp/
src/
commonMain/
kotlin/
App.kt
feature/
ui/
data/
androidMain/
kotlin/
MainActivity.kt
iosMain/
kotlin/
MainViewController.kt
desktopMain/
kotlin/
main.kt
commonMain 放可共享的 Composable、状态管理、业务模型、Repository 接口和通用工具。androidMain、iosMain、desktopMain 放平台入口和平台专属实现。
这种结构的关键是依赖方向:共享层定义能力,平台层提供实现。不要让 commonMain 直接依赖 Android 的 Context、Activity、Uri 或资源访问方式,否则代码很快会失去跨平台意义。
从一个共享 App 开始
共享 UI 通常从一个根 Composable 开始:
@Composable
fun App() {
MaterialTheme {
var count by rememberSaveable { mutableStateOf(0) }
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "Count: $count")
Spacer(Modifier.height(12.dp))
Button(onClick = { count++ }) {
Text("Increase")
}
}
}
}
Android 入口可以这样接入:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { App() }
}
}
桌面端入口则可能是:
fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "Compose App") {
App()
}
}
你会发现,共享 UI 的写法和 Android Compose 非常接近。真正需要设计的是:哪些状态和页面可以共享,哪些能力必须交给平台层。
状态管理:共享逻辑,隔离平台
Compose Multiplatform 里仍然推荐使用单向数据流。页面接收状态,事件向上抛,业务层处理后更新状态。
data class LoginUiState(
val account: String = "",
val password: String = "",
val loading: Boolean = false,
val error: String? = null
)
class LoginPresenter(
private val repository: LoginRepository
) {
var state by mutableStateOf(LoginUiState())
private set
fun onAccountChange(value: String) {
state = state.copy(account = value)
}
fun onPasswordChange(value: String) {
state = state.copy(password = value)
}
}
在 Android 项目中,我们常把 ViewModel 作为状态容器。多端项目也可以使用适合 Kotlin Multiplatform 的状态容器方案,或者自己封装轻量 Presenter。重点不是名字,而是让状态转换可测试、可复用,并且不要绑死在某个平台生命周期上。
平台差异怎么处理
跨平台项目一定会遇到平台差异。正确做法不是在共享 UI 里到处写平台判断,而是用接口隔离。
interface ShareService {
fun shareText(text: String)
}
共享页面只依赖接口:
@Composable
fun ArticleScreen(shareService: ShareService) {
Button(onClick = { shareService.shareText("Compose Multiplatform") }) {
Text("Share")
}
}
Android、iOS、Desktop 分别提供自己的实现。这样共享页面仍然干净,平台能力也能保留各自体验。
更复杂的能力可以使用 expect/actual:
expect fun currentPlatformName(): String
然后在不同平台实现:
actual fun currentPlatformName(): String = "Android"
适配层要控制规模。接口越多,维护成本越高。只有确实需要跨平台调用的能力才抽象,不要为了抽象而抽象。
资源、主题与设计一致性
Compose Multiplatform 可以共享颜色、字体、间距、组件样式和部分资源。建议把设计系统放在共享层:
object AppSpacing {
val small = 8.dp
val medium = 16.dp
val large = 24.dp
}
@Composable
fun PrimaryButton(text: String, onClick: () -> Unit) {
Button(onClick = onClick, modifier = Modifier.height(48.dp)) {
Text(text)
}
}
共享设计系统的好处很直接:同一个按钮、同一个卡片、同一个空状态,不需要在不同端重复实现。但也要允许平台有自己的细节。例如桌面端可能需要 hover、右键菜单、快捷键;移动端更关注触摸区域、软键盘和手势。
好的多端 UI 不是所有像素完全一致,而是品牌和交互逻辑一致,同时尊重平台使用习惯。
导航与页面拆分
导航是多端项目容易变复杂的地方。简单项目可以自己维护一个页面栈:
sealed interface Screen {
data object Home : Screen
data class Detail(val id: String) : Screen
}
然后根据当前 Screen 渲染不同页面:
@Composable
fun AppRoot() {
var screen by remember { mutableStateOf<Screen>(Screen.Home) }
when (val current = screen) {
Screen.Home -> HomeScreen(onOpenDetail = { screen = Screen.Detail(it) })
is Screen.Detail -> DetailScreen(id = current.id, onBack = { screen = Screen.Home })
}
}
业务复杂后,可以引入更完整的导航方案,但仍要注意:Android 的返回键、iOS 的手势返回、桌面端窗口行为并不完全一样。共享导航模型可以复用页面关系,平台入口仍要处理各自的系统交互。
网络、数据库与共享业务层
Compose Multiplatform 不只共享 UI,也可以共享业务模型、网络请求、序列化和部分数据层。常见组合包括 Ktor、kotlinx.serialization、SQLDelight、Settings、Koin 或其他多端友好的库。
Repository 可以放在共享层:
interface ArticleRepository {
suspend fun loadArticles(): List<Article>
}
class RemoteArticleRepository(
private val client: HttpClient
) : ArticleRepository {
override suspend fun loadArticles(): List<Article> {
return client.get("https://example.com/articles").body()
}
}
这样 UI、状态和业务请求都可以复用。平台层只负责提供网络引擎、文件路径、数据库驱动或系统能力实现。
需要注意的是,共享层越重,构建、调试和平台差异处理也会越复杂。建议从 UI + 轻业务逻辑开始,再逐步把稳定的能力沉淀到共享层。
测试策略
多端项目更需要测试,因为共享代码的改动会影响多个目标平台。建议从这些地方开始:
- 对状态容器写普通 Kotlin 单元测试。
- 对 Repository 使用 Mock 网络响应测试解析和错误处理。
- 对关键 Composable 做截图或语义节点测试。
- 对平台适配层做少量集成测试。
- 对核心用户路径做端到端冒烟测试。
共享逻辑的测试收益很高,因为一份测试能覆盖多个平台的公共行为。平台相关测试则要少而精,重点验证适配是否正确。
落地时最容易踩的坑
常见问题主要集中在这些方面:
- 过早追求全量共享,导致平台体验变差。
- 共享层依赖平台 API,后期拆分困难。
- 抽象接口太多,简单功能也变得绕。
- 忽视桌面端键盘、鼠标、窗口尺寸等交互差异。
- 忽视 iOS 审核、权限、原生控件和团队协作成本。
- 对构建链路、依赖版本和 Gradle 配置缺少治理。
更稳的做法是先选择一个边界清晰的模块试点,例如设置页、详情页、内部工具或新业务小应用。等团队熟悉工程结构、调试方式和发布流程后,再扩大共享范围。
和 Flutter、React Native 怎么取舍
如果团队主要是 Android/Kotlin 背景,已经在使用 Compose,那么 Compose Multiplatform 的学习成本相对低,代码和思维模型延续性很好。如果团队已有成熟 Flutter 或 React Native 体系,迁移成本就需要谨慎评估。
取舍时可以看几个问题:
- 团队主力语言是不是 Kotlin?
- 产品是否重视平台原生体验?
- UI 是否高度一致?
- 是否需要复用已有 Android Compose 页面?
- iOS、桌面、Web 的投入预期有多高?
- 构建、调试、测试和发布链路是否有人维护?
技术选择没有绝对答案。跨平台框架真正的成本往往不在写出第一个页面,而在长期维护、团队协作和平台差异处理。
实践建议
如果你准备在 Android 团队里尝试 Compose Multiplatform,可以按这个节奏推进:
- 先用一个小模块验证工程链路。
- 只共享页面、状态和稳定业务逻辑。
- 平台能力用接口隔离,不要污染共享层。
- 设计系统先统一基础组件,再逐步扩展复杂组件。
- 对共享状态和 Repository 补单元测试。
- 记录平台差异和适配约定,避免每个页面各写一套。
Compose Multiplatform 的价值,不是承诺“写一次到处完美运行”,而是让 Kotlin 和 Compose 的开发经验可以在更多平台复用。对 Android 开发者来说,它提供了一条自然的多端扩展路径:先把 UI 和状态写清楚,再把平台差异放到边界上处理。
当项目边界合适、团队能力匹配、平台差异可控时,它能明显减少重复开发,让多端应用的架构更统一。反过来,如果业务强依赖平台特性,就应该克制共享范围,把它用在真正能产生收益的部分。