从 Android 组件化到 KMP 跨平台底座(四):KMP 的本质——公共业务逻辑 + 平台差异实现
前言
上一篇我们讲清楚了 KMP 项目的基本结构:
commonMain
androidMain
iosMain
其中:
commonMain
负责公共代码
androidMain
负责 Android 平台代码
iosMain
负责 iOS 平台代码
但只知道目录还不够。
真正开始写代码后,Android 开发者很快会遇到新的问题:
-
是不是所有代码都要先定义接口?
-
commonMain里的类能不能直接写实现? -
什么情况下应该使用
expect/actual? -
什么情况下应该使用普通接口?
-
Android 的 CameraX 和 iOS 的 AVFoundation,是否要抽成同一个扫码接口?
-
高德地图、MapKit 是否也要全部包装进
commonMain? -
平台代码到底应该由谁创建,又该怎么传给公共层?
这些问题背后,其实都指向同一个核心:
KMP 到底是怎么实现跨平台的?
KMP 并不会自动把 Android API 转换成 iOS API,也不会让 CameraX 直接运行在 iPhone 上。
它真正做的事情可以概括成一句话:
把不依赖平台的业务逻辑放进公共层,把依赖平台的能力留在平台层,再通过明确的边界连接起来。
也就是:
KMP
=
公共业务逻辑
+
平台差异实现
这就是 KMP 最核心的设计思想。
一、KMP 并不是“所有代码写一份”
很多人刚接触跨平台时,会把跨平台理解成:
所有代码只写一遍
Android、iOS、Web 全部直接运行
但真实项目几乎不会这样。
因为不同平台天然存在大量差异。
例如扫码:
Android
CameraX + ML Kit
iOS
AVFoundation / Vision
Web
浏览器 Camera API + JavaScript 扫码库
例如地图:
Android
高德地图 Android SDK
iOS
MapKit / 高德地图 iOS SDK
Web
高德地图 JavaScript API
例如本地安全存储:
Android
DataStore / EncryptedSharedPreferences
iOS
Keychain
Web
LocalStorage / IndexedDB
这些平台能力不可能因为使用 KMP,就突然变成同一套底层实现。
KMP 真正共享的是这些能力之上的业务部分。
例如扫码完成后,各个平台最终都可以得到:
一段二维码原始字符串
那么下面这些逻辑就可以共享:
-
提取设备编号
-
判断二维码类型
-
校验二维码是否合法
-
解析设备参数
-
转换为统一业务模型
因此,扫码可以拆成:
平台层
负责“怎么扫到二维码”
commonMain
负责“扫到以后怎么理解二维码”
这才是 KMP 的真实落地方式。
二、先建立一个最重要的判断标准
判断一段代码应该直接放进 commonMain,还是需要平台实现,可以先问:
这段代码是否依赖某个平台独有的 API?
如果完全不依赖平台 API,就可以直接写进 commonMain。
例如:
data class Device(
val id: String,
val name: String,
val status: DeviceStatus
)
enum class DeviceStatus {
ONLINE,
OFFLINE,
WARNING
}
这段代码没有使用:
-
Context -
Activity -
Parcelable -
CameraX
-
UIKit
-
MapKit
-
浏览器 API
因此,它就是普通的 Kotlin 公共代码,不需要做任何额外抽象。
直接放进:
commonMain
就可以了。
但是,如果代码中出现:
android.util.Log
或者:
platform.UIKit.UIDevice
就说明它依赖具体平台。
这时才需要思考:
-
代码是否应该留在平台层?
-
公共层是否真的需要调用它?
-
是否需要抽象接口?
-
是否适合使用
expect/actual?
commonMain 会参与所有已声明目标的编译,因此不能直接引用某一个目标独有的 API;平台 Source Set 可以访问公共代码和对应平台 API,但公共 Source Set 不能反向依赖平台 Source Set。
三、纯业务逻辑不要为了 KMP 强行接口化
KMP 中很容易出现一种过度设计:
只要放进 commonMain
就必须先定义接口
这是不对的。
例如设备状态判断:
class DeviceStatusChecker {
fun isAvailable(device: Device): Boolean {
return device.status == DeviceStatus.ONLINE
}
}
它就是一段纯业务逻辑。
不需要写成:
interface DeviceStatusChecker {
fun isAvailable(device: Device): Boolean
}
然后再写:
class DeviceStatusCheckerImpl : DeviceStatusChecker {
override fun isAvailable(device: Device): Boolean {
return device.status == DeviceStatus.ONLINE
}
}
如果项目中只有一种判断规则,也没有替换、扩展或者测试隔离需求,这样做只是增加代码量。
同样,下面这些代码都可以直接放进 commonMain。
1. 数据模型
data class GeoPoint(
val latitude: Double,
val longitude: Double
)
data class MapMarkerModel(
val id: String,
val title: String,
val point: GeoPoint
)
2. 业务枚举
enum class InspectionStatus {
PENDING,
PROCESSING,
COMPLETED
}
3. Result 封装
sealed interface ApiResult<out T> {
data class Success<T>(
val data: T
) : ApiResult<T>
data class Failure(
val code: String,
val message: String
) : ApiResult<Nothing>
}
4. 表单校验
class InspectionValidator {
fun validate(
deviceId: String,
items: List<InspectionItem>
): ValidationResult {
if (deviceId.isBlank()) {
return ValidationResult.Failure(
message = "设备编号不能为空"
)
}
if (items.isEmpty()) {
return ValidationResult.Failure(
message = "巡检项目不能为空"
)
}
return ValidationResult.Success
}
}
5. 二维码解析
class ScanParser {
fun parse(rawContent: String): ScanResult {
val params = rawContent
.substringAfter("?", rawContent)
.split("&")
.mapNotNull { item ->
val pair = item.split("=")
if (pair.size == 2) {
pair[0] to pair[1]
} else {
null
}
}
.toMap()
return ScanResult(
rawContent = rawContent,
deviceId = params["deviceId"],
type = params["type"]
)
}
}
这些逻辑并不会因为运行在 Android 或 iOS 上而发生变化。
所以,直接共享即可。
四、接口不是因为“跨平台”才存在
在 KMP 中使用接口,并不是因为:
KMP 要求所有平台代码必须有接口
而是因为:
某项能力的实现会发生变化,但公共业务只需要依赖稳定的能力定义。
例如,业务层需要记录日志。
业务真正关心的是:
记录一条调试日志
它不应该关心 Android 最终使用:
android.util.Log.d()
还是 iOS 使用:
NSLog()
因此可以在 commonMain 中定义:
interface AppLogger {
fun debug(
tag: String,
message: String
)
fun error(
tag: String,
message: String,
throwable: Throwable? = null
)
}
业务代码只依赖接口:
class GetDeviceListUseCase(
private val repository: DeviceRepository,
private val logger: AppLogger
) {
suspend operator fun invoke(): ApiResult<List<Device>> {
logger.debug(
tag = "GetDeviceListUseCase",
message = "开始获取设备列表"
)
return repository.getDeviceList()
}
}
Android 提供实现:
class AndroidLogger : AppLogger {
override fun debug(
tag: String,
message: String
) {
android.util.Log.d(tag, message)
}
override fun error(
tag: String,
message: String,
throwable: Throwable?
) {
android.util.Log.e(
tag,
message,
throwable
)
}
}
iOS 提供实现:
import platform.Foundation.NSLog
class IosLogger : AppLogger {
override fun debug(
tag: String,
message: String
) {
NSLog("DEBUG [$tag] $message")
}
override fun error(
tag: String,
message: String,
throwable: Throwable?
) {
NSLog(
"ERROR [$tag] $message ${throwable?.message.orEmpty()}"
)
}
}
所以接口的作用是:
隔离变化
而不是制造形式上的“跨平台感”
五、KMP 中可以把代码分成四类
为了更容易判断,我们可以把 KMP 项目里的代码分成四种。
第一类:完全不依赖平台的业务代码
例如:
-
数据模型
-
枚举
-
字符串解析
-
数据计算
-
表单校验
-
DTO 转换
-
Result 封装
-
Repository 接口
-
UseCase
-
业务状态机
处理方式:
直接写进 commonMain
不需要:
-
expect/actual -
Android 实现
-
iOS 实现
-
为了形式统一而强行抽接口
第二类:简单的平台值或者平台函数
例如:
-
当前平台名称
-
系统版本
-
当前时区
-
简单设备信息
-
UUID 生成
-
当前应用版本号
处理方式可以考虑:
expect / actual 函数或属性
例如:
// commonMain
expect fun getPlatformName(): String
Android:
// androidMain
actual fun getPlatformName(): String {
return "Android"
}
iOS:
// iosMain
import platform.UIKit.UIDevice
actual fun getPlatformName(): String {
return UIDevice.currentDevice.systemName
}
官方文档将 expect/actual 函数和属性定位为访问简单平台差异时非常适合的方式;编译某一目标时,编译器会将公共声明与对应平台的实现进行匹配。
第三类:复杂、可替换的平台能力
例如:
-
日志
-
Token 存储
-
文件存储
-
定位
-
推送
-
权限判断
-
剪贴板
-
加密
-
生物识别
-
系统通知
这类能力更适合:
普通接口
+
平台实现
+
构造函数注入或依赖注入
例如:
interface TokenStorage {
suspend fun saveAccessToken(token: String)
suspend fun getAccessToken(): String?
suspend fun clear()
}
Android:
class AndroidTokenStorage(
private val dataStore: TokenDataStore
) : TokenStorage {
override suspend fun saveAccessToken(token: String) {
dataStore.saveToken(token)
}
override suspend fun getAccessToken(): String? {
return dataStore.getToken()
}
override suspend fun clear() {
dataStore.clear()
}
}
iOS:
class IosTokenStorage(
private val keychain: KeychainStorage
) : TokenStorage {
override suspend fun saveAccessToken(token: String) {
keychain.save(
key = "access_token",
value = token
)
}
override suspend fun getAccessToken(): String? {
return keychain.read("access_token")
}
override suspend fun clear() {
keychain.delete("access_token")
}
}
公共业务只依赖:
TokenStorage
不依赖:
DataStore
Keychain
官方建议复杂平台能力优先通过普通接口表达依赖,再使用平台工厂、平台入口或依赖注入提供具体实现;如果项目已经使用依赖注入框架,最好继续使用同一种依赖管理方式。
第四类:强 UI、生命周期和硬件能力
例如:
-
CameraX 相机预览
-
AVFoundation 相机预览
-
高德 MapView
-
MapKit 页面
-
Android WebView
-
UIKit ViewController
-
Activity 权限回调
-
Android Service
-
iOS 后台任务
-
蓝牙扫描生命周期
这类能力通常不应该强行塞进 commonMain。
更合理的处理方式是:
平台层负责完整能力
commonMain
只接收最终业务输入或输出
例如扫码:
Android / iOS 平台
负责打开相机并识别二维码
commonMain
负责解析识别后的字符串
而不是让 commonMain 直接控制:
相机打开
预览页面
权限弹窗
页面生命周期
六、KMP 的依赖方向必须保持单向
一个健康的 KMP 结构应该是:
平台层
依赖
公共业务层
而不是:
公共业务层
直接依赖
Android 或 iOS
正确方向:
androidMain ─────┐
│
iosMain ─────────┼──▶ commonMain
│
webMain ─────────┘
更准确地说,平台 Source Set 可以使用 commonMain 中的声明,但 commonMain 不能直接访问平台 Source Set 中的实现。
如果公共层需要平台能力,可以通过抽象倒置依赖关系。
例如:
commonMain
定义 TokenStorage 接口
androidMain
实现 AndroidTokenStorage
iosMain
实现 IosTokenStorage
最终关系变成:
commonMain
依赖抽象
androidMain / iosMain
依赖抽象并提供实现
这其实就是我们熟悉的依赖倒置原则:
高层业务不依赖底层平台细节,高层和底层共同依赖稳定抽象。
所以 KMP 并没有创造一套完全陌生的架构思想。
它只是把原来 Android 组件化中的接口抽象,扩展到了 Android、iOS、Web 等多个平台。
七、expect/actual 本质上是什么?
expect/actual 是 Kotlin Multiplatform 提供的语言级平台匹配机制。
例如:
// commonMain
expect fun createPlatformUuid(): String
Android:
// androidMain
actual fun createPlatformUuid(): String {
return java.util.UUID
.randomUUID()
.toString()
}
iOS:
// iosMain
import platform.Foundation.NSUUID
actual fun createPlatformUuid(): String {
return NSUUID()
.UUIDString()
}
在编译 Android 时,编译器组合:
commonMain expect 声明
+
androidMain actual 实现
在编译 iOS 时,编译器组合:
commonMain expect 声明
+
iosMain actual 实现
所以它不是运行时判断:
if (platform == "Android") {
// Android
} else {
// iOS
}
而是编译不同平台时,使用不同实现。
编译器还会检查:
-
每个目标是否都有对应
actual -
包名是否一致
-
声明签名是否匹配
-
公共声明是否错误地包含实现
八、expect/actual 适合解决哪些问题?
适合使用 expect/actual 的场景通常具有以下特点:
-
能力比较简单
-
参数和返回值清晰
-
每个平台都必须提供唯一实现
-
不需要频繁替换
-
不需要多种实现同时存在
-
不依赖复杂生命周期
例如:
expect fun getPlatformName(): String
expect fun randomUuid(): String
expect val currentPlatform: PlatformType
也可以定义一个公共接口,再使用 expect/actual 工厂创建平台实现:
// commonMain
interface PlatformInfo {
val name: String
val version: String
}
expect fun createPlatformInfo(): PlatformInfo
Android:
// androidMain
class AndroidPlatformInfo : PlatformInfo {
override val name: String = "Android"
override val version: String =
android.os.Build.VERSION.RELEASE
}
actual fun createPlatformInfo(): PlatformInfo {
return AndroidPlatformInfo()
}
iOS:
// iosMain
import platform.UIKit.UIDevice
class IosPlatformInfo : PlatformInfo {
override val name: String
get() = UIDevice.currentDevice.systemName
override val version: String
get() = UIDevice.currentDevice.systemVersion
}
actual fun createPlatformInfo(): PlatformInfo {
return IosPlatformInfo()
}
这种方式的特点是:
接口负责定义能力
expect/actual 工厂
负责找到当前平台实现
官方文档也展示了“普通公共接口 + expect/actual 工厂函数”的组合方案。
九、为什么复杂能力更建议接口,而不是 expect class?
例如日志能力,如果写成:
expect class AppLogger() {
fun debug(
tag: String,
message: String
)
}
Android:
actual class AppLogger {
actual fun debug(
tag: String,
message: String
) {
android.util.Log.d(tag, message)
}
}
iOS:
actual class AppLogger {
actual fun debug(
tag: String,
message: String
) {
NSLog("[$tag] $message")
}
}
技术上可以实现。
但是这种设计会带来一些问题。
1. 每个平台通常只能围绕这个 actual 类提供固定实现
如果使用普通接口:
interface AppLogger
Android 平台可以同时提供:
AndroidLogcatLogger
FileLogger
RemoteLogger
CompositeLogger
FakeLogger
而不是被限定在一个固定的 actual class AppLogger 上。
2. 测试替换更麻烦
普通接口可以直接注入测试实现:
class FakeLogger : AppLogger {
val messages = mutableListOf<String>()
override fun debug(
tag: String,
message: String
) {
messages += "[$tag] $message"
}
override fun error(
tag: String,
message: String,
throwable: Throwable?
) {
messages += "ERROR [$tag] $message"
}
}
然后测试:
val logger = FakeLogger()
val useCase = GetDeviceListUseCase(
repository = fakeRepository,
logger = logger
)
这比替换一个平台绑定的 actual class 更自然。
3. 普通接口更符合已有架构习惯
无论是 Android、后端还是 KMP,普通接口都可以配合:
-
构造函数注入
-
工厂
-
Service Locator
-
Koin
-
手动依赖注入
Kotlin 官方当前也建议尽可能优先使用普通语言结构;expect/actual class 仍处于 Beta,简单场景下如果普通接口已经足够,不建议为了跨平台形式强行使用 expected/actual class。
因此可以先记住:
expect/actual 函数、属性、工厂
适合简单平台差异
普通接口 + 平台实现
适合复杂、可替换、可测试的能力
expect/actual class
不要滥用
十、平台实现应该在哪里创建?
定义了接口和平台实现后,还有一个问题:
谁负责创建 AndroidLogger 和 IosLogger?
主要有三种方式。
方式一:expect/actual 工厂
公共层:
interface AppLogger {
fun debug(
tag: String,
message: String
)
}
expect fun createAppLogger(): AppLogger
Android:
actual fun createAppLogger(): AppLogger {
return AndroidLogger()
}
iOS:
actual fun createAppLogger(): AppLogger {
return IosLogger()
}
公共层使用:
val logger = createAppLogger()
这种方式简单直接,适合小型项目。
方式二:由平台入口传入
公共层:
class SharedApplication(
private val logger: AppLogger,
private val tokenStorage: TokenStorage
)
Android App:
class MainApplication : Application() {
lateinit var sharedApplication: SharedApplication
override fun onCreate() {
super.onCreate()
sharedApplication = SharedApplication(
logger = AndroidLogger(),
tokenStorage = AndroidTokenStorage(
dataStore = createTokenDataStore(this)
)
)
}
}
iOS 入口:
let sharedApplication = SharedApplication(
logger: IosLogger(),
tokenStorage: IosTokenStorage()
)
这种方式的优势是:
公共层完全不知道平台实现怎么创建
平台入口掌握依赖组装权
官方文档将其称为通过不同平台入口提供实现:平台 App 创建对应实现,再传入共享模块。
方式三:依赖注入框架
例如使用支持 KMP 的依赖注入框架,公共层注册:
val commonModule = module {
factory {
GetDeviceListUseCase(
repository = get(),
logger = get()
)
}
}
Android 平台注册:
val androidPlatformModule = module {
single<AppLogger> {
AndroidLogger()
}
single<TokenStorage> {
AndroidTokenStorage(
dataStore = get()
)
}
}
iOS 平台注册:
val iosPlatformModule = module {
single<AppLogger> {
IosLogger()
}
single<TokenStorage> {
IosTokenStorage()
}
}
如果原项目已经统一使用依赖注入,那么平台依赖也建议继续进入同一套依赖管理体系,避免一部分依赖通过 DI 注入,另一部分又散落在手写的全局 expect 工厂中。
十一、Logger 应该怎么设计?
Logger 是最典型的平台差异能力。
公共层只定义业务需要的日志能力:
interface AppLogger {
fun debug(
tag: String,
message: String
)
fun info(
tag: String,
message: String
)
fun error(
tag: String,
message: String,
throwable: Throwable? = null
)
}
Android:
class AndroidLogger : AppLogger {
override fun debug(
tag: String,
message: String
) {
Log.d(tag, message)
}
override fun info(
tag: String,
message: String
) {
Log.i(tag, message)
}
override fun error(
tag: String,
message: String,
throwable: Throwable?
) {
Log.e(
tag,
message,
throwable
)
}
}
iOS:
class IosLogger : AppLogger {
override fun debug(
tag: String,
message: String
) {
NSLog("DEBUG [$tag] $message")
}
override fun info(
tag: String,
message: String
) {
NSLog("INFO [$tag] $message")
}
override fun error(
tag: String,
message: String,
throwable: Throwable?
) {
NSLog(
"ERROR [$tag] $message ${throwable?.message.orEmpty()}"
)
}
}
这里最重要的不是代码本身,而是边界:
commonMain
知道 AppLogger
commonMain
不知道 Android Log
commonMain
也不知道 NSLog
十二、TokenStorage 应该怎么设计?
Token 存储是另一个典型例子。
业务层只关心:
保存 Token
读取 Token
清除 Token
所以公共接口可以是:
interface TokenStorage {
suspend fun save(
accessToken: String,
refreshToken: String?
)
suspend fun getAccessToken(): String?
suspend fun getRefreshToken(): String?
suspend fun clear()
}
Android 使用:
DataStore
EncryptedSharedPreferences
数据库
iOS 使用:
Keychain
NSUserDefaults
公共登录逻辑可以直接依赖接口:
class LoginUseCase(
private val repository: AuthRepository,
private val tokenStorage: TokenStorage
) {
suspend operator fun invoke(
account: String,
password: String
): ApiResult<User> {
val result = repository.login(
account = account,
password = password
)
if (result is ApiResult.Success) {
tokenStorage.save(
accessToken = result.data.accessToken,
refreshToken = result.data.refreshToken
)
}
return result.map { response ->
response.user
}
}
}
这里:
登录规则共享
Token 存储方式平台化
这就是 KMP 的典型结构。
十三、扫码到底要不要抽 QrScanner 接口?
这个问题不能一概而论。
例如定义:
interface QrScanner {
suspend fun scan(): ScanResult
}
表面上很统一:
Android 实现 CameraX
iOS 实现 AVFoundation
但要继续思考:
-
谁负责打开扫码页面?
-
谁负责权限申请?
-
谁负责相机生命周期?
-
用户取消扫码怎么返回?
-
页面旋转和后台切换怎么处理?
-
Flutter、CMP、SwiftUI 的 UI 如何接入?
如果这些问题全部塞进:
suspend fun scan()
接口虽然简单,背后的平台生命周期却被隐藏得过重。
对于大多数项目,更推荐把扫码拆成两部分。
平台 UI 层
负责:
-
打开相机
-
展示扫码框
-
申请权限
-
识别二维码
-
处理取消操作
-
管理生命周期
最终输出:
rawContent: String
commonMain
负责:
class ScanParser {
fun parse(rawContent: String): ScanResult {
// 二维码业务解析
TODO()
}
}
调用流程:
Android CameraX / iOS AVFoundation
│
▼
rawContent
│
▼
commonMain ScanParser
│
▼
ScanResult
这样更加符合职责划分。
只有当公共业务流程确实需要把“扫描能力”当成可调用服务时,才考虑定义:
interface ScanContentProvider {
suspend fun getRawContent(): ScanContentResult
}
而且接口中最好返回平台无关的数据,不要把:
-
Activity
-
UIImage
-
Android Bitmap
-
AVCaptureSession
暴露给公共层。
十四、地图是否应该抽一个统一 MapView 接口?
通常不建议。
例如不要在 commonMain 中定义:
interface CommonMapView {
fun moveCamera()
fun addMarker()
fun setZoom()
fun setMapType()
fun setOnMarkerClickListener()
}
然后试图同时适配:
高德 MapView
MapKit
Web 地图
这种设计看起来统一,实际很容易变成一个巨大的最小公分母接口。
不同地图 SDK 在以下方面差异很大:
-
生命周期
-
相机模型
-
Marker
-
Overlay
-
聚合
-
定位
-
手势
-
坐标系
-
离线地图
-
路径规划
-
原生 View 接入
如果强行抽象,最终可能出现:
公共接口越来越大
平台实现到处不支持
大量参数只能使用 Any
平台特性被迫丢失
地图更适合拆成:
commonMain
共享地图业务数据
平台层
负责地图 UI 和 SDK
例如公共层:
data class GeoPoint(
val latitude: Double,
val longitude: Double
)
data class MapMarkerModel(
val id: String,
val title: String,
val point: GeoPoint,
val status: DeviceStatus
)
公共 UseCase:
class GetDeviceMarkersUseCase(
private val repository: DeviceRepository
) {
suspend operator fun invoke():
ApiResult<List<MapMarkerModel>> {
return repository
.getDeviceList()
.map { devices ->
devices.mapNotNull { device ->
device.location?.let { location ->
MapMarkerModel(
id = device.id,
title = device.name,
point = location,
status = device.status
)
}
}
}
}
}
Android:
把 MapMarkerModel
转换为高德 Marker
iOS:
把 MapMarkerModel
转换为 MapKit Annotation
这时共享的是:
地图上的业务数据和业务规则
而不是强行共享整个地图 SDK。
十五、定位能力和地图 UI不是一回事
虽然地图 UI 不适合强行抽象,但定位能力有时适合定义接口。
例如公共业务需要获取当前经纬度:
interface LocationProvider {
suspend fun getCurrentLocation():
LocationResult
}
公共结果:
sealed interface LocationResult {
data class Success(
val point: GeoPoint,
val accuracyMeters: Double?
) : LocationResult
data object PermissionDenied :
LocationResult
data class Failure(
val message: String
) : LocationResult
}
Android 可以使用:
高德定位
FusedLocationProvider
iOS 可以使用:
CoreLocation
这里可以抽接口,是因为业务真正需要的是:
获取当前位置
而不是控制整个地图页面。
因此要区分:
LocationProvider
是一项业务可调用能力
MapView
是一个强平台 UI 组件
两者不能因为都与地图有关,就使用同一种抽象方式。
十六、有跨平台库时,还要不要自己抽接口?
不一定。
例如某个能力已经有成熟的 KMP 库提供统一 API,并且符合当前项目需求,那么可以直接在 commonMain 中使用。
例如:
协程
序列化
跨平台网络
跨平台时间
跨平台数据库
不应该为了展示“架构能力”,再额外包装五六层没有实际价值的接口。
Kotlin 官方也建议尽量优先使用普通语言结构和现成的跨平台能力,而不是把所有平台问题都转化为 expect/actual 声明。
是否再抽一层接口,应该看:
-
是否需要替换底层库
-
是否需要隔离业务与框架
-
是否需要 Fake 实现
-
是否存在多个数据源
-
是否需要独立测试
-
该抽象是否真的稳定
例如 Repository 通常值得保留:
interface DeviceRepository
但一个纯工具函数未必需要包装:
interface JsonParser
十七、平台类型不要泄漏到 commonMain
公共层接口的参数和返回值,也必须保持平台无关。
例如下面的接口设计不合理:
interface ImageUploader {
suspend fun upload(
bitmap: android.graphics.Bitmap
): String
}
因为:
android.graphics.Bitmap
是 Android 类型。
iOS 无法使用它。
同样,下面的设计也不合理:
interface ImageUploader {
suspend fun upload(
image: platform.UIKit.UIImage
): String
}
更合理的公共模型可以是:
data class ImageData(
val bytes: ByteArray,
val fileName: String,
val mimeType: String
)
公共接口:
interface ImageUploader {
suspend fun upload(
image: ImageData
): ApiResult<String>
}
平台层负责把:
Android Bitmap
iOS UIImage
Flutter 图片
转换成平台无关的:
ByteArray
文件路径
公共 ImageData
然后再交给公共业务。
所以接口抽象不仅要统一方法名,更重要的是:
接口边界中的数据类型也必须属于公共领域。
十八、commonMain 不应该充斥平台判断
一种不推荐的写法是:
fun saveToken(token: String) {
when (getPlatformName()) {
"Android" -> {
// Android 存储逻辑
}
"iOS" -> {
// iOS 存储逻辑
}
}
}
这种写法的问题是:
-
公共层知道所有平台
-
每增加一个平台都要修改业务代码
-
平台细节不断泄漏
-
Web 和 Desktop 接入后判断越来越多
-
无法使用平台专属依赖
-
违反开闭原则
正确方式应该是:
class SaveTokenUseCase(
private val tokenStorage: TokenStorage
) {
suspend operator fun invoke(token: String) {
tokenStorage.saveAccessToken(token)
}
}
至于实际是:
DataStore
Keychain
LocalStorage
由各平台实现决定。
KMP 中的目标不是:
在 commonMain 中判断当前平台
而是:
让 commonMain 不需要知道当前平台
十九、不要为了共享而共享
KMP 项目还有一种常见误区:
代码放得越多进 commonMain
架构就越成功
实际上,共享比例不是唯一目标。
例如:
-
Android 后台 Service
-
iOS Widget
-
Android 系统广播
-
iOS Live Activity
-
高德地图高级覆盖物
-
CameraX 图像分析管线
-
UIKit 特定交互
这些能力可能只存在于某个平台,或者不同平台实现差异非常大。
这时最合理的做法就是:
留在平台层
没有必要为了追求共享率,设计一套复杂而脆弱的公共抽象。
真正有价值的共享应该具备:
-
业务规则一致
-
输入输出稳定
-
多个平台都需要
-
修改频率相近
-
共享后能减少重复实现
-
不会严重牺牲平台能力
所以 KMP 的目标不是:
100% 代码共享
而是:
共享真正值得共享的代码
二十、设备巡检 Demo 应该怎么划分?
结合这个系列的设备巡检 Demo,可以这样设计。
commonMain:直接共享的业务代码
domain/
├── model/
│ ├── Device.kt
│ ├── DeviceStatus.kt
│ ├── InspectionForm.kt
│ ├── GeoPoint.kt
│ └── ScanResult.kt
│
├── repository/
│ ├── AuthRepository.kt
│ ├── DeviceRepository.kt
│ └── InspectionRepository.kt
│
└── usecase/
├── LoginUseCase.kt
├── GetDeviceListUseCase.kt
├── ParseScanResultUseCase.kt
└── SubmitInspectionUseCase.kt
这些代码不依赖平台。
commonMain:需要平台实现的抽象
core/platform/
├── AppLogger.kt
├── TokenStorage.kt
├── LocationProvider.kt
├── NetworkStatusProvider.kt
└── FileStorage.kt
例如:
interface NetworkStatusProvider {
fun isNetworkAvailable(): Boolean
}
androidMain:Android 平台实现
platform/
├── AndroidLogger.kt
├── AndroidTokenStorage.kt
├── AndroidLocationProvider.kt
├── AndroidNetworkStatusProvider.kt
└── AndroidFileStorage.kt
可以调用:
-
Android Log
-
DataStore
-
高德定位
-
ConnectivityManager
-
Android 文件系统
iosMain:iOS 平台实现
platform/
├── IosLogger.kt
├── IosTokenStorage.kt
├── IosLocationProvider.kt
├── IosNetworkStatusProvider.kt
└── IosFileStorage.kt
可以调用:
-
NSLog
-
Keychain
-
CoreLocation
-
Network Framework
-
iOS 文件系统
androidApp:强 Android 能力
androidApp/
├── qrcode/
│ ├── CameraXScannerScreen.kt
│ └── MlKitQrAnalyzer.kt
│
├── map/
│ └── AMapScreen.kt
│
├── service/
│ └── DeviceForegroundService.kt
│
└── webview/
└── AndroidWebViewScreen.kt
iosApp:强 iOS 能力
iosApp/
├── QrScannerView.swift
├── MapKitView.swift
├── PermissionManager.swift
└── IosWebView.swift
扫码结果最终交给:
commonMain ScanParser
地图点位最终来自:
commonMain MapMarkerModel
这就形成了清晰边界。
二十一、这套设计带来的测试优势
当公共业务依赖接口后,可以很容易提供测试实现。
例如:
class FakeTokenStorage : TokenStorage {
private var accessToken: String? = null
override suspend fun saveAccessToken(
token: String
) {
accessToken = token
}
override suspend fun getAccessToken(): String? {
return accessToken
}
override suspend fun clear() {
accessToken = null
}
}
测试登录 UseCase:
class LoginUseCaseTest {
private val tokenStorage =
FakeTokenStorage()
private val repository =
FakeAuthRepository()
private val useCase =
LoginUseCase(
repository = repository,
tokenStorage = tokenStorage
)
@Test
fun loginSuccess_shouldSaveToken() = runTest {
val result = useCase(
account = "admin",
password = "123456"
)
assertTrue(
result is ApiResult.Success
)
assertEquals(
expected = "test_access_token",
actual = tokenStorage.getAccessToken()
)
}
}
这个测试不需要:
-
Android 模拟器
-
iPhone 模拟器
-
DataStore
-
Keychain
因为公共业务依赖的是:
TokenStorage 抽象
而不是具体平台存储。
这也是接口抽象真正的价值:
不仅为了跨平台
还为了测试、替换和维护
二十二、四个问题判断一段代码怎么设计
以后遇到一段代码,可以依次问四个问题。
问题一:它是否完全不依赖平台?
如果是:
直接放 commonMain
例如:
-
模型
-
校验
-
解析
-
UseCase
-
Result
-
数据转换
问题二:它是否只是简单的平台值或函数?
如果是,可以考虑:
expect / actual 函数或属性
例如:
-
平台名称
-
UUID
-
系统版本
-
应用版本
问题三:它是否是一项复杂、可替换的能力?
如果是,考虑:
接口
+
平台实现
+
依赖注入
例如:
-
日志
-
Token 存储
-
定位
-
文件
-
权限状态
-
网络状态
问题四:它是否强依赖 UI、生命周期或硬件 SDK?
如果是:
优先留在平台 App 层
只把业务结果交给 commonMain
例如:
-
CameraX
-
MapView
-
WebView
-
Service
-
ViewController
-
权限页面
-
相机预览
二十三、常见错误总结
错误一:所有类都抽接口
结果:
接口比实现还多
代码复杂度增加
业务逻辑难以阅读
正确原则:
只有变化点、替换点和平台差异才值得抽象。
错误二:所有平台差异都使用 expect class
结果:
-
类被固定为单一平台实现
-
测试替换不方便
-
多实现扩展困难
-
与依赖注入体系割裂
正确原则:
简单值用
expect/actual,复杂服务优先普通接口。
错误三:commonMain 到处判断平台名称
结果:
if Android
if iOS
if Web
正确原则:
公共业务不应该知道当前运行平台。
错误四:把平台 SDK 类型暴露给公共接口
例如:
Bitmap
UIImage
Activity
UIViewController
MapView
正确原则:
平台层先转换成公共模型,再交给 commonMain。
错误五:强行抽象整个地图、相机或者 WebView
结果往往是:
巨型接口
平台特性丢失
生命周期混乱
实现难以维护
正确原则:
共享业务数据和规则,不必共享所有平台 UI 组件。
错误六:为了共享率把所有代码迁进 shared
正确原则:
KMP 的价值是减少有价值的重复,而不是追求表面上的共享百分比。
二十四、从 Android 组件化看 KMP 的本质
回到 Android 组件化时代,我们其实已经在做类似的事情。
例如 Android 项目中定义:
interface ImageLoader {
fun load(
url: String,
target: ImageView
)
}
然后实现:
GlideImageLoader
CoilImageLoader
这里解决的是:
不同 Android 实现之间的替换
到了 KMP,我们只是把边界扩大了。
例如:
interface TokenStorage
实现变成:
AndroidTokenStorage
IosTokenStorage
WebTokenStorage
所以:
Android 组件化
解决 Android 内部实现差异
KMP
解决多个平台之间的实现差异
底层思想仍然是:
-
单一职责
-
依赖倒置
-
接口隔离
-
平台适配
-
业务与技术细节分离
KMP 并不是魔法。
它只是提供了:
-
多平台编译能力
-
Source Set
-
expect/actual -
Kotlin/Native
-
平台互操作
帮助我们把这些架构原则真正落到多个平台上。
二十五、总结
KMP 最核心的设计思想可以概括为:
公共业务逻辑
+
平台差异实现
其中,纯业务逻辑可以直接进入 commonMain:
-
数据模型
-
Result
-
业务规则
-
表单校验
-
数据解析
-
Repository 接口
-
UseCase
不需要因为使用 KMP,就把所有代码强行接口化。
只有涉及平台差异时,才需要选择合适的连接方式。
简单的平台值或函数
→ expect / actual
复杂、可替换的平台能力
→ 接口 + 平台实现 + 依赖注入
强 UI、生命周期和硬件能力
→ 留在平台层,向 commonMain 传递业务结果
还要始终保持依赖方向:
平台层
依赖公共层
公共层
不依赖具体平台
对于扫码:
平台负责扫
commonMain 负责解析
对于地图:
平台负责展示地图
commonMain 负责点位模型和业务规则
对于 Token:
commonMain 定义存储能力
Android 和 iOS 分别实现
对于日志:
commonMain 依赖 AppLogger
平台提供 Log 或 NSLog 实现
最终可以用一句话概括:
KMP 不是把所有平台代码强行写成一份,而是让真正一致的业务逻辑只写一份,让无法统一的平台能力各自实现。
下一篇预告
下一篇将继续回到 Android 组件化项目,正式分析:
《从 Android 组件化迁移到 KMP:哪些能共享,哪些不能共享?》
我们会拿现有 Android 项目中的模块逐个拆解:
lib-network
lib-storage
lib-qrcode
lib-map
lib-log
feature-auth
feature-device
重点讲清楚:
-
什么样的 Kotlin 代码可以直接迁入
commonMain -
为什么
data class也不一定能直接共享 -
Retrofit 和 OkHttp 代码应该怎么处理
-
SharedPreferences、DataStore、Room 如何迁移
-
CameraX、ML Kit、高德地图为什么不能直接迁
-
原来的 Android Library 应该保留还是拆分
-
如何用“剥洋葱”的方式逐步提取纯业务逻辑
-
怎样在不重写现有 Android App 的情况下接入 KMP
下一篇会真正进入“现有 Android 项目怎么迁”的实战阶段。
更多推荐




所有评论(0)