Kotlin Multiplatform ---- KMP-App-Template-Native

发布时间:2026/10/15 16:40:06
Kotlin Multiplatform ---- KMP-App-Template-Native
KMP-App-Template-Native 学习 Kotlin Multiplatform本文以KMP-App-Template-Native为例带着读者看一个 KMP 项目是怎样组织起来的。项目同时支持 Android、Desktop、iOS 和 Web数据和业务逻辑主要用 Kotlin 共享界面则根据平台分别使用 Compose、SwiftUI 和 React。项目本身是一个博物馆藏品浏览器。打开应用可以看到藏品列表点击其中一项后进入详情页。功能不多但已经把 KMP 中几个重要问题都放进来了代码怎样跨平台复用平台差异放在哪里数据又是怎样一路到达界面的。0. 项目简介0.1 App 的业务内容这是一个博物馆藏品浏览 App主要有两个页面藏品列表页以网格形式展示图片、标题、艺术家和创作日期。藏品详情页展示图片、标题、艺术家、年代、尺寸、材质、部门、收藏机构和来源说明。用户在列表页点击藏品后应用把它的objectID传给详情页再根据这个 ID 找到对应数据。Android 和 Desktop 用同一套 Compose 页面iOS 和 Web 则分别使用 SwiftUI 和 React。0.2 项目使用的数据来源数据模型是MuseumObject定义在shared/src/commonMain/.../data/MuseumObject.kt中。项目根目录的list.json保存了示例藏品数据运行时由MuseumApi从 GitHub Raw 地址读取https://raw.githubusercontent.com/Kotlin/KMP-App-Template-Native/main/list.json这些藏品信息来自大都会艺术博物馆The Metropolitan Museum of Art。不过应用运行时请求的是项目自己的list.json并没有直接调用博物馆的实时接口。这一点从MuseumApi.kt中的请求地址可以看出来。每条藏品数据包括以下内容字段含义objectID藏品 IDtitle藏品名称artistDisplayName艺术家或制造者medium材质或创作媒介dimensions尺寸objectURL藏品网页地址objectDate创作年代primaryImage原始图片地址primaryImageSmall小尺寸图片地址repository收藏机构department所属部门creditLine捐赠或来源说明0.3 从用户操作到界面显示的完整流程应用启动后各平台先初始化 Koin。Koin 创建MuseumRepositoryRepository 随后请求 JSON并把结果放进内存存储。ViewModel 从存储层读出StateFlow界面订阅这个状态数据到了以后就显示出来。完整的数据流如下平台应用入口 ↓ 初始化 Koin ↓ MuseumRepository 请求 list.json ↓ Ktor 获取 JSON ↓ kotlinx.serialization 解析为 MuseumObject ↓ MuseumStorage 保存数据 ↓ ViewModel 暴露 StateFlow ↓ 平台 UI 订阅状态并渲染点击列表中的藏品时UI 只传递objectID。详情 ViewModel 收到 ID 后向 Repository 查询查询结果再通过StateFlow返回给界面。1. KMP 工程的组织形式1.1 根目录结构项目的主要目录如下KMP-App-Template-Native/ ├── shared/ # 共享数据和业务逻辑 ├── sharedUI/ # Android 和 Desktop 共享的 Compose UI ├── androidApp/ # Android 应用壳 ├── desktopApp/ # Desktop 应用壳 ├── iosApp/ # iOS Xcode 工程和 SwiftUI UI ├── webApp/ # Web React 工程 ├── list.json # 示例藏品数据 ├── build.gradle.kts └── settings.gradle.kts这些目录大致分成三类shared跨平台的业务核心。sharedUIAndroid 和 Desktop 可以共同使用的界面。各平台 App 目录负责启动应用、创建窗口或实现平台专属 UI。1.2 Gradle 模块和非 Gradle 工程settings.gradle.kts中声明了四个 Gradle 模块include(:shared)include(:sharedUI)include(:androidApp)include(:desktopApp)iosApp和webApp没有加入这个 Gradle 模块列表iosApp是独立的 Xcode 工程通过Shared.framework使用 Kotlin 代码。webApp是独立的 npm/Vite/React 工程通过 Kotlin/JS 生成的 npm 包使用共享代码。这说明 KMP 项目不必让所有平台都使用同一种构建工具。iOS 通过 Framework 接入Web 通过 JavaScript 包接入Android 和 Desktop 则直接使用 Gradle 模块。1.3 KMP 的 source set 组织方式shared的源代码按平台分为多个 source setshared/src/ ├── commonMain/ # 所有平台共享 ├── androidMain/ # Android 特有代码和依赖 ├── iosMain/ # iOS 特有代码和桥接代码 ├── jvmMain/ # Desktop 使用的 JVM 代码和依赖 └── jsMain/ # Web 使用的 Kotlin/JS 代码commonMain中的代码只能依赖各个平台都能使用的 API例如数据类、Repository、Flow 和 ViewModel。只有某个平台需要特殊处理时才把代码放到对应的 source set 中。项目还把一部分平台差异交给库来处理。业务代码只使用HttpClient具体引擎在各平台分别配置Android 和 Desktop 使用 OkHttpiOS 使用 DarwinWeb 使用 JavaScript 引擎。1.4 各模块的目标平台模块AndroidiOSJVM/DesktopJS/Web主要职责shared是是是是数据、网络、Repository、ViewModelsharedUI是否是否Compose 页面和导航androidApp是否否否Android 应用入口和壳desktopApp否否是否桌面窗口和打包iosApp否是否否iOS 入口和 SwiftUIwebApp否否否是Web 页面和 React这里有一个容易混淆的地方sharedUI不是全平台 UI 模块。它只配置了 Android 和 JVM 两个目标所以只有 Android 和 Desktop 共用 Compose 页面iOS 和 Web 看不到这个模块使用的是各自的 UI 技术。1.5 依赖注入和初始化入口Koin 的配置位于shared/.../di/Koin.kt。其中dataModule提供HttpClient、MuseumApi、MuseumStorage和MuseumRepository。viewModelModule提供ListViewModel和DetailViewModel。每个平台在自己的入口初始化同一套依赖关系平台初始化位置AndroidMuseumApp.onCreate()Desktopmain()iOSiOSApp.init()调用 Kotlin 导出函数Web创建MuseumStore时初始化这样一来依赖关系只在共享代码中配置一次各个平台的入口只负责启动它。项目没有再单独设置 Use Case 或 Interactor 层ViewModel 直接使用 RepositoryRepository 再连接 API 和 Storage。对于这个规模的示例来说这样的层次已经够用。1.6 整体架构┌──────────────────────────────────────────────────────────────┐ │ 表现层 (Presentation) │ │ ──────────────────────────────────────────────────────── │ │ Android/Desktop: sharedUI (Compose Multiplatform) │ │ iOS: SwiftUI 视图原生 Swift 实现 │ │ Web: React 组件原生 TypeScript 实现 │ └──────────────────────────────────────────────────────────────┘ ▲ │ StateFlow / 属性访问 / Promise │ ┌──────────────────────────────────────────────────────────────┐ │ ViewModel 层shared/screens │ │ ListViewModel、DetailViewModel │ │ 基于 KMP-ObservableViewModel KMP-NativeCoroutines │ └──────────────────────────────────────────────────────────────┘ ▲ │ FlowListMuseumObject │ ┌──────────────────────────────────────────────────────────────┐ │ 数据层shared/data │ │ MuseumRepository ── MuseumApi (Ktor) │ │ └─ MuseumStorage (InMemory StateFlow) │ └──────────────────────────────────────────────────────────────┘ ▲ │ ┌──────────────────────────────────────────────────────────────┐ │ DI 容器shared/diKoin 单一图四端共用 │ └──────────────────────────────────────────────────────────────┘2. App 的数据模型和数据流2.1MuseumObject数据模型MuseumObject是应用中的核心数据类型位于shared的commonMain中。因此四个平台看到的是同一个数据定义。这样做有两个直接的好处数据字段只需要定义一次。不同平台展示同一件藏品时使用的是同一组数据。各平台可以按自己的界面需要选择字段同时避免 Android、iOS 和 Web 各自维护一套字段定义。2.2 数据模型使用的注解SerializableJsExportdataclassMuseumObject(...)Serializable让kotlinx.serialization能够把 JSON 转换成 Kotlin 对象。JsExport让 Kotlin/JS 能够把MuseumObject暴露给 Web 工程。这样 TypeScript 代码可以导入它并获得生成的类型定义。2.3 网络层Ktor网络代码位于shared/.../data/MuseumApi.kt由接口和实现两部分组成interfaceMuseumApi{suspendfungetData():ListMuseumObject}MuseumApi只描述“获取藏品数据”这件事KtorMuseumApi负责具体的 HTTP 请求。上层依赖的是接口不需要关心请求地址和 Ktor 的实现细节。Ktor 的 API 是跨平台的但底层引擎可以不同平台Ktor 引擎AndroidOkHttpDesktopOkHttpiOSDarwinWebJavaScript这样commonMain中的网络代码可以保持不变各个平台仍然可以使用自己的底层网络实现。2.4 存储层内存缓存存储接口位于MuseumStorage.ktinterfaceMuseumStorage{suspendfunsaveObjects(newObjects:ListMuseumObject)fungetObjectById(objectId:Int):FlowMuseumObject?fungetObjects():FlowListMuseumObject}当前实现是InMemoryMuseumStorage内部使用MutableStateFlow保存数据。从这个实现可以看出当前项目没有使用数据库。数据只保存在内存中。应用重新启动后需要重新请求数据。未来可以在不修改 Repository 和 UI 的情况下替换为数据库实现。以后如果需要离线缓存可以把它替换成 SQLDelight、Realm 或其他跨平台存储方案。2.5 Repository 层MuseumRepository位于 API 和 Storage 之间把两边连接起来MuseumApi → MuseumRepository → MuseumStorage它做的事情很集中初始化时刷新数据。请求最新数据并保存到存储层。提供全部藏品列表。根据objectID获取单个藏品。这样 ViewModel 就不需要知道数据来自网络、内存还是数据库只调用 Repository 提供的方法即可。2.6 ViewModel 层项目中有两个共享 ViewModel。ListViewModel对外暴露StateFlowListMuseumObject它负责为列表页提供藏品集合。DetailViewModel保存当前的objectID并根据 ID 查询藏品对外暴露StateFlowMuseumObject?ViewModel 不绘制界面只准备界面需要的状态。状态到了 Compose、SwiftUI 或 React 后才由各平台决定具体怎么显示。2.7 Flow 和响应式状态项目用 Kotlin Coroutines 和 Flow 处理异步数据网络请求使用suspend函数。存储层使用MutableStateFlow保存当前数据。ViewModel 使用stateIn把数据转换为稳定的StateFlow。UI 订阅状态后数据变化会自动触发刷新。不同平台的订阅方式不同Android/Desktop 使用 Compose 的collectAsStateWithLifecycle。iOS 通过 KMP-ObservableViewModel 和 KMP-NativeCoroutines 把 Kotlin 状态桥接到 SwiftUI。Web 通过MuseumStore暴露挂起函数React 使用 Promise 结果更新自己的 state。3. 使用了哪些组件以及它们的作用3.1 KtorKtor 是跨平台网络库负责发送 HTTP 请求和接收服务器响应。项目使用它请求list.json并通过内容协商插件把 JSON 解析为 Kotlin 数据对象。3.2 kotlinx.serialization它负责 JSON 与 Kotlin 数据类之间的转换。相比手写解析逻辑序列化库可以根据MuseumObject的字段自动完成转换并且支持多个 KMP 平台。3.3 KoinKoin 是依赖注入框架。它集中声明对象之间的关系例如HttpClient → MuseumApi → MuseumRepository → ViewModel这样 ViewModel 不用自己创建 Repository平台入口也不用重复写一遍对象装配代码。3.4 Kotlin Coroutines 和 FlowCoroutines 用来执行网络请求等异步任务Flow 用来表示会变化的数据。项目中的网络加载、内存存储和 UI 状态就是通过这两者串在一起的。3.5 KMP-ObservableViewModel这个组件让 SwiftUI 能够观察共享的 Kotlin ViewModel。这样 iOS 不必为了自己的界面再写一套业务 ViewModel。3.6 KMP-NativeCoroutines这个组件负责 Kotlin 协程和 Swift 异步代码之间的桥接把 Kotlin 的挂起函数和 Flow 转成 Swift 侧可以调用和观察的形式。3.7 Compose MultiplatformCompose Multiplatform 为 Android 和 Desktop 提供共同的页面实现。项目中的页面、主题和导航主要在sharedUI中MaterialTheme主题和颜色方案。NavHost页面导航。LazyVerticalGrid藏品网格。AsyncImage网络图片。Scaffold和TopAppBar详情页布局。AnimatedContent根据数据是否加载完成切换内容。3.8 Compose NavigationCompose Navigation 负责列表页和详情页之间的跳转。项目定义了两个路由ListDestinationDetailDestination(objectId)详情页通过路由参数接收藏品 ID再交给DetailViewModel查询数据。3.9 CoilCoil 负责 Compose 中的网络图片加载。列表和详情页都使用primaryImageSmall图片加载时先显示占位背景。3.10 SwiftUISwiftUI 是 iOS 的声明式 UI 框架。iOS 端使用NavigationStack管理导航。LazyVGrid展示藏品网格。AsyncImage加载图片。ScrollView展示详情内容。StateViewModel观察共享 ViewModel。3.11 React、TypeScript 和 ViteWeb 端用 React 写页面TypeScript 做类型检查Vite 负责开发和构建。MuseumStore是 Kotlin/JS 和 React 之间的桥梁。React 组件调用getObjects()或getObjectById()拿到 Promise 结果后再更新自己的 state。3.12 Compose DesktopCompose Desktop 负责创建 JVM 桌面窗口处理窗口标题、关闭事件、桌面布局和安装包配置。窗口内部嵌入sharedUI的App()所以页面可以和 Android 共用。4. 不同平台的代码组织形式4.1 Android原生壳 共享 Compose UIAndroid 的代码位于androidAppMuseumApp.onCreate() ↓ 初始化 Koin ↓ MainActivity ↓ setContent { App() } ↓ sharedUI 的 Compose 页面Android 壳负责Application、Activity、Manifest、应用图标和生命周期。页面来自sharedUI因此 Android 不需要另写列表页和详情页。4.2 Desktop桌面壳 共享 Compose UIDesktop 的入口是desktopApp/.../main.ktmain() ↓ 初始化 Koin ↓ 创建 Window ↓ 桌面窗口布局 ↓ App()桌面壳负责窗口标题、关闭事件、窗口拖动区域、桌面菜单和安装包配置。窗口右侧嵌入共享的 ComposeApp()。Android 和 Desktop 共享页面 UI、导航和 ViewModel但应用壳不同Android 从 Activity 启动Desktop 从 JVM Window 启动。4.3 iOSSwiftUI 原生 UI Kotlin 共享业务iOS 使用独立的 Xcode 工程和 SwiftUI 页面iOSApp ↓ ListView ↓ DetailView ↓ Shared.framework ↓ Kotlin ViewModel 和 RepositoryiOSApp.swift是应用入口启动时调用 Kotlin 导出的 Koin 初始化函数。ListView.swift和DetailView.swift负责页面布局和导航Kotlin 侧继续提供数据模型、Repository 和 ViewModel。这样既保留了 iOS 原生 UI 的写法也复用了 Kotlin 中的核心业务逻辑。4.4 WebReact UI Kotlin/JS 共享业务Web 使用独立的 React 工程React App ↓ MuseumList / MuseumDetail ↓ MuseumStore ↓ Kotlin/JS shared module ↓ MuseumRepositoryMuseumStore在 Kotlin/JS 中使用JsExport暴露给 TypeScript。React 不直接操作 Kotlin 的 Repository而是调用 Store 提供的getObjects()和getObjectById()。Web 端的页面切换由 React state 控制selectedObjectId为空时显示列表有值时显示详情。5. 同一业务在不同平台上的 UI 对比5.1 列表页面四个平台显示同一批藏品和相同的核心字段但使用的 UI 组件不同平台UI 技术主要组件AndroidComposeLazyVerticalGrid、AsyncImageDesktopCompose MultiplatformLazyVerticalGrid、桌面窗口布局iOSSwiftUILazyVGrid、AsyncImageWebReactReact 组件、HTML、CSS各平台共享的内容包括MuseumObject数据模型。藏品 ID、标题、艺术家、日期和图片地址。Repository 提供的数据。列表到详情的业务流程。各平台独立实现的内容包括页面布局和尺寸规则。导航方式。图片加载组件。加载和空数据状态的表现形式。返回按钮和交互细节。所以共享代码不等于所有界面都强行做成一样。这个项目选择共享稳定的数据和业务把差异明显的 UI 留给各个平台。5.2 共享什么不共享什么适合放在shared中共享数据模型。网络请求。JSON 解析。Repository。ViewModel。业务规则和数据状态。适合平台独立实现页面布局。导航实现。应用入口和生命周期。Activity、Window、SwiftUI App 等平台壳。平台主题、系统权限和平台交互。本项目可以概括成下面的结构共享业务逻辑 Android/Desktop 共享 Compose UI iOS 原生 SwiftUI Web 原生 React6. KMP 开发中的关键设计思想6.1 共享业务而不是强行共享所有代码KMP 的重点不是让所有平台的代码看起来一样而是把稳定、重复使用的部分放到共享模块中。本项目共享了数据、网络、Repository 和 ViewModelAndroid 与 Desktop 因为都适合 Compose所以进一步共享 UIiOS 和 Web 则保留自己的 UI 技术。6.2 接口隔离MuseumApi和MuseumStorage都是接口。Repository 依赖接口而不是依赖具体类。这样更容易替换实现和编写测试。例如可以把真实 API 换成测试数据也可以把内存存储换成本地数据库而不动 UI 和 ViewModel。6.3 平台差异下沉到依赖和壳子业务代码只使用统一的HttpClient、Flow和数据模型source set、跨平台库的具体实现以及各平台应用壳负责处理平台差异。这样 common 代码比较干净平台代码也只处理真正的平台问题。6.4 共享状态平台负责渲染ViewModel 提供状态平台 UI 负责把状态显示出来ViewModel 提供 StateFlow ↓ Android/Desktop Compose 订阅 ↓ iOS SwiftUI 订阅 ↓ Web Store 返回结果React 更新 state这样四个平台使用同一份业务状态同时可以按各自的 UI 习惯呈现页面。7. 推荐的学习顺序可以按下面的顺序阅读工程先了解 App 的业务内容和数据来源。阅读根目录和 Gradle 模块理解项目由哪些部分组成。阅读commonMain、androidMain、iosMain、jvmMain和jsMain的作用。阅读MuseumObject了解跨平台共享的数据模型。阅读MuseumApi和MuseumStorage理解数据从哪里来、保存在哪里。阅读MuseumRepository理解数据层如何连接网络和存储。阅读ListViewModel和DetailViewModel理解状态如何提供给 UI。阅读Koin.kt理解依赖如何被创建和注入。阅读sharedUI的 Compose 页面观察 Android 和 Desktop 如何共享 UI。阅读 iOS 的ListView.swift和DetailView.swift对比 SwiftUI 实现。阅读 Web 的MuseumStore、MuseumList和MuseumDetail理解 Kotlin/JS 如何连接 React。最后总结共享代码和平台代码的边界。沿着这条线读完基本就能看懂这个项目的 KMP 架构shared放跨平台业务核心各个平台用自己的入口和 UI 技术把它组装成最终应用。8. 项目地址KMP-App-Template-Native