前言

上一篇我们讲清楚了 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 项目怎么迁”的实战阶段。

Logo

一站式 AI 云服务平台

更多推荐