KMP 平台差异到底怎么设计?从日志导出重构看扩展函数、interface 与 expect/actual 在 KMP 项目里做到一定阶段后很容易遇到一个问题同一个功能Android 和 iOS 的底层实现完全不同到底应该怎么处理常见方案似乎有很多。比如expect / actual或者commonMain 定义 interface androidMain / iosMain 提供实现类又或者直接在平台层写fun AppLogger.exportLogs(...)这种扩展函数。它们看起来都在解决“跨平台差异”所以非常容易混在一起。但实际上这几种设计根本不是一个维度的问题。而且在实际项目继续重构以后我还发现另外一个很容易忽略的问题即使扩展函数本身没有问题也不代表这个函数就应该扩展在某个对象上。这篇文章就以一个真实的 KMP Logger 日志导出功能为例从最开始的AppLogger.exportLogs()一路讲到最终的exportLogs(...)把Source Set 扩展函数 顶层函数 interface 多态 expect / actual这些设计彻底串起来。一、先看第一版设计最开始我们希望业务层导出日志时足够简单。iOSAppLogger.exportLogs()AndroidAppLogger.exportLogs(context)看起来好像AppLogger自己知道当前运行的是 Android 还是 iOS。其实完全不是。这里没有if (isAndroid) { ... } else if (isIos) { ... }真正决定平台的是 KMP Source Set。例如commonMain androidMain iosMain编译 Android Target 时commonMain androidMain编译 iOS Target 时commonMain iosMain因此Android 看不到 iosMain iOS 看不到 androidMain所以 Android 可以拥有AppLogger.exportLogs(context)iOS 可以拥有AppLogger.exportLogs()两套代码互不冲突。这也是理解后面所有设计的基础。但是这里需要特别注意Source Set 只能说明这种代码“可以正确工作”并不能说明AppLogger.exportLogs()就一定是最合理的 API 设计。这两个问题要分开看。二、第一版为什么会写成 AppLogger.exportLogs()先看 iOS 第一版suspend fun AppLogger.exportLogs( config: LogExportConfig LogExportConfig(), ): ExportedLogFile exportLogs(createLogExporter(config))这段代码定义在iosMain真正创建导出器的是fun createLogExporter( config: LogExportConfig LogExportConfig(), ): LogExporter LogExporter( IosLogExportStorage(iosLogDirectory()), config, )也就是说AppLogger.exportLogs()本身并没有实现读取日志文件 写 ZIP 删除旧 ZIP 获取文件大小这些底层能力。它主要承担的是提供一个方便调用的入口 创建当前平台需要的对象Android 也是一样fun createLogExporter( context: Context, config: LogExportConfig LogExportConfig(), ): LogExporter { val logDirectory File(androidLogDirectory(context.applicationContext)) return LogExporter( AndroidLogExportStorage(logDirectory), config, ) }因此第一版可以理解为iOS AppLogger.exportLogs() ↓ createLogExporter() ↓ IosLogExportStorage Android AppLogger.exportLogs(context) ↓ createLogExporter(context) ↓ AndroidLogExportStorage从“平台差异怎么隔离”的角度来说这个设计是成立的。真正的问题出现在另外一个维度exportLogs()真的是AppLogger自己的职责吗三、扩展函数不是一种跨平台机制这一点非常重要。很多人看到fun AppLogger.exportLogs()会下意识觉得这是 KMP 处理平台差异的一种方式。其实不是。Kotlin 扩展函数本质上只是fun exportLogs(logger: AppLogger)换了一种调用形式AppLogger.exportLogs()所以扩展函数 ≠ 平台抽象机制真正让 Android 和 iOS 使用不同实现的是androidMain iosMain而不是扩展函数。扩展函数解决的是API 最终以什么形式暴露给调用者。例如Throwable.toAppError()和toAppError(throwable)能力上没有本质区别。前一种只是表达得更加自然。但扩展函数还有第二个问题以前我们只问用扩展函数调用是不是更方便继续重构以后发现这还不够。还应该继续问这个操作真的属于 receiver 吗例如Throwable.toAppError()非常合理。因为整个操作围绕Throwable展开Throwable ↓ 转换 ↓ AppError所以把它写成throwable.toAppError()语义很自然。但是AppLogger.exportLogs()就不太一样了。日志导出真正的流程其实是AppLogger.flush() ↓ LogExporter.export() ↓ LogExportStorageAppLogger在这里仅仅负责flush它只是整个日志导出流程中的一个参与者。真正负责日志筛选 ZIP 压缩 日志文件读取 旧文件清理 导出结果生成的是LogExporter。因此AppLogger.exportLogs()虽然写起来方便却容易给人一种错觉日志导出是 AppLogger 自己的能力。这就是第一版 API 最终被调整的原因。四、最终为什么改成顶层 exportLogs()最终 Common 层的日志导出入口改成suspend fun exportLogs( exporter: LogExporter, ): ExportedLogFile { AppLogger.flush() return exporter.export() }这段代码非常简单。先AppLogger.flush()确保 Logger 当前队列中的日志已经完成写入。然后exporter.export()正式执行导出流程。这里和之前最大的变化不是代码量。而是调用形式终于和真实职责对齐了。以前AppLogger.exportLogs(exporter)看起来像AppLogger ↓ 拥有 exportLogs 能力现在exportLogs(exporter)表达的是这是一个“日志导出流程” 它会协调 AppLogger LogExporter所以它更像一个流程协调函数。五、为什么流程协调适合顶层函数继续看suspend fun exportLogs( exporter: LogExporter, ): ExportedLogFile { AppLogger.flush() return exporter.export() }它没有自己的状态。也没有复杂生命周期。更没有一个需要替换的“ExportLogs 实现”。它只是flush ↓ export因此没有必要为了它再创建object LogExportCoordinator也没有必要class LogExportService更没有必要interface LogExportService class DefaultLogExportService一个普通顶层函数已经可以准确表达它的职责。这也是一个很实用的设计判断如果一段逻辑只是协调已有能力而且自身没有状态、生命周期和新的变化点普通函数往往就是最简单、最准确的设计。六、Android / iOS 平台入口还存在吗存在。删除AppLogger.exportLogs()并不意味着平台差异消失了。Android 仍然可以提供suspend fun exportLogs( context: Context, config: LogExportConfig LogExportConfig(), ): ExportedLogFile { return exportLogs( createLogExporter( context context, config config, ) ) }iOSsuspend fun exportLogs( config: LogExportConfig LogExportConfig(), ): ExportedLogFile { return exportLogs( createLogExporter(config) ) }于是业务调用变成AndroidexportLogs(context)iOSexportLogs()注意这里平台差异的处理方式和以前没有任何本质变化。依然是Android 编译 androidMain iOS 编译 iosMain变化的只是以前 把 exportLogs 挂到 AppLogger 上 现在 exportLogs 作为独立流程入口也就是说Source Set 解决平台代码放在哪里。顶层函数还是扩展函数解决 API 怎么表达。两者完全不是一件事。七、真正的公共业务逻辑在哪里平台入口最终都会创建LogExporterLogExporter定义在commonMain核心结构类似class LogExporter internal constructor( private val storage: LogExportStorage, private val config: LogExportConfig, )注意最关键的一行private val storage: LogExportStorageLogExporter并不依赖IosLogExportStorage也不依赖AndroidLogExportStorage它依赖的是LogExportStorage这个公共接口。然后exporter.export()开始执行公共日志导出流程。例如val sourceFiles storage.listLogFiles() .filter { it.name.endsWith(LOG_EXTENSION) } .sortedBy(ExportSourceFile::name)然后创建 ZIPval output storage.openExport(fileName) val zip ZipArchiveWriter(output, timeZone)读取日志sourceFiles.forEach { file - zip.add(file) { consume - storage.readLogFile(file, consume) } }最终返回ExportedLogFile( path storage.exportPath(fileName), fileName fileName, sizeBytes storage.exportSize(fileName), logFileCount sourceFiles.size, )这里有个非常漂亮的地方。LogExporter知道我要找日志 我要筛选日志 我要读取日志 我要生成 ZIP 我要清理旧 ZIP 我要返回导出结果但它完全不知道Android 到底怎么读文件 iOS 到底怎么读文件这就是公共业务逻辑和平台能力真正分开的地方。八、interface 到底在哪里发挥作用很多人第一次看这种设计时容易绕进去。调用链大概是exportLogs() ↓ LogExporter.export() ↓ storage.listLogFiles()然后就会疑惑LogExportStorage到底在哪里被调用了其实LogExportStorage不是一个需要“调用”的对象工厂。它是LogExporter持有的依赖。也就是说interface 在对象创建阶段就已经被注入进去了。比如 iOSLogExporter( IosLogExportStorage(iosLogDirectory()), config, )把代码展开val iosStorage IosLogExportStorage(iosLogDirectory()) val storage: LogExportStorage iosStorage val exporter LogExporter( storage storage, config config, )这时LogExporter只知道storage 是 LogExportStorage它并不知道 storage 实际上是IosLogExportStorage九、平台差异其实是在 createLogExporter() 完成注入这就是整个设计非常关键的位置。iOSLogExporter( IosLogExportStorage(...), config, )AndroidLogExporter( AndroidLogExportStorage(...), config, )而LogExporter要求的只是LogExportStorage因此形成LogExporter │ │ 依赖 ▼ LogExportStorage interface / \ / \ ▼ ▼ AndroidLogExportStorage IosLogExportStorage也就是说LogExporter这个公共业务组件不依赖任何具体平台实现。平台实现反过来实现它要求的抽象。这就是非常典型的依赖倒置。十、为什么调用 interface 最终会进入 iOS 实现假设当前运行的是 iOS。创建对象时val storage: LogExportStorage IosLogExportStorage(...)变量类型是LogExportStorage但真正保存的对象是IosLogExportStorage所以storage.listLogFiles()最终执行IosLogExportStorage.listLogFiles()同理storage.readLogFile(...)最终进入IosLogExportStorage.readLogFile(...)iOS 里面可能真正使用NSFileManager fopen fread fwriteAndroid 里面则可能使用File FileInputStream FileOutputStream BufferedInputStream BufferedOutputStream这一点其实并不属于 KMP 特有机制。本质上就是普通的interface 多态。十一、完整调用链终于可以串起来了现在以 iOS 为例。最终结构业务层 │ ▼ exportLogs() │ │ iosMain 平台便捷入口 ▼ createLogExporter(config) │ ├──────────── 创建 ────────────► IosLogExportStorage │ │ │ │ implements │ ▼ │ LogExportStorage │ ▼ LogExporter(storage, config) │ ▼ commonMain exportLogs(exporter) │ ├── AppLogger.flush() │ ▼ exporter.export() │ ├── storage.listLogFiles() ├── storage.openExport() ├── storage.readLogFile() ├── storage.listExportFiles() ├── storage.deleteExport() ├── storage.exportPath() └── storage.exportSize() │ ▼ 实际进入 IosLogExportStorage │ ▼ NSFileManager / fopen / fread / fwriteAndroid 完全一样exportLogs(context) ↓ createLogExporter(context) ↓ AndroidLogExportStorage ↓ LogExporter ↓ commonMain exportLogs(exporter) ↓ AppLogger.flush() ↓ exporter.export() ↓ storage.xxx() ↓ AndroidLogExportStorage.xxx() ↓ File / InputStream / OutputStream到这里整个调用链就非常清楚了。十二、现在整个日志导出实际上可以看成四层把前面的代码压缩以后可以分成四层。第一层平台便捷入口 / 装配层AndroidexportLogs(context)iOSexportLogs()职责给调用方提供简单入口 创建当前平台 LogExporter这部分放在androidMain iosMain第二层流程协调层exportLogs(exporter)职责非常简单AppLogger.flush() ↓ LogExporter.export()它只是协调已有能力。因此使用顶层函数就够了。第三层公共业务层LogExporter职责筛选日志 生成文件名 压缩 ZIP 异常处理 清理旧文件 生成 ExportedLogFile这些逻辑 Android 和 iOS 基本一致。所以放commonMain第四层平台能力层公共接口LogExportStorage它定义我要能够 列日志 读日志 创建导出文件 删除导出文件 获取文件路径 获取文件大小至于怎么实现由AndroidLogExportStorage IosLogExportStorage负责。十三、那什么时候应该用 expect / actual理解完interface后再来看expect/actual就简单很多。假设 commonMain 需要platformProcessName()但Android 获取进程名 和 iOS 获取进程名实现完全不同。可以写// commonMain expect fun platformProcessName(): StringAndroidactual fun platformProcessName(): String { ... }iOSactual fun platformProcessName(): String { ... }形成commonMain │ ▼ platformProcessName() │ expect / \ ▼ ▼ Android iOS actual actual这种方案比较适合平台名称 系统版本 进程名 线程信息 简单系统信息 简单目录查询特点是commonMain 直接需要一个平台能力而且能力本身比较简单、固定。十四、为什么 LogExportStorage 更适合 interface日志存储就不一样了。它已经包含很多能力listLogFiles readLogFile openExport exportPath exportSize listExportFiles deleteExport它本质上已经是一个完整组件。这种情况下interface LogExportStorage会比把所有方法做成expect/actual更合适。第一可以测试例如class FakeLogExportStorage : LogExportStorage { ... }然后LogExporter( FakeLogExportStorage(), config, )Common Test 不需要真的访问 Android 或 iOS 文件系统。第二可以替换以后甚至可以MemoryLogExportStorage DesktopLogExportStorage TestLogExportStorage而LogExporter完全不需要修改。第三符合依赖倒置LogExporter 不依赖 AndroidLogExportStorage IosLogExportStorage 而是依赖 LogExportStorage公共业务层只认识抽象。十五、为什么 uploadLogs() 也不应该挂在 AppLogger 上日志上传的情况其实更加明显。如果写AppLogger.uploadLogs(...)看起来会像AppLogger 自己具有日志上传能力。但真正流程其实是AppLogger.flush() ↓ LogExporter.export() ↓ LogUploader.upload()它至少涉及三个角色AppLogger LogExporter LogUploader所以这里更不应该强行选择AppLogger作为 receiver。最终 Common 层可以直接写suspend fun uploadLogs( exporter: LogExporter, uploader: LogUploader, metadata: LogUploadMetadata LogUploadMetadata(), ): LogUploadResult它表达得非常明确uploadLogs 是一个流程协调入口。它会协调Logger Exporter Uploader完成整个日志上传流程。十六、为什么不再造一个 LogService看到这里可能还会产生一个想法既然已经有exportLogs(...) uploadLogs(...)是不是可以再封一层interface LogService { suspend fun exportLogs(): ExportedLogFile suspend fun uploadLogs(): LogUploadResult }然后class DefaultLogService( private val exporter: LogExporter, private val uploader: LogUploader, ) : LogService看起来似乎更加“架构化”。但目前没有这个必要。因为项目已经有两个真正稳定的能力抽象LogExporter → 怎么导出日志 LogUploader → 怎么上传日志而uploadLogs()只是flush ↓ export ↓ upload它本身并没有出现一个新的独立变化点。如果现在再创建LogService LogServiceImpl LogCoordinator只是给已有接口再包一层接口层数增加了职责却没有增加。接口不是为了“架构完整”这是这里非常重要的一个判断。我们创建LogExportStorage是因为Android 文件系统 和 iOS 文件系统确实存在不同实现。我们保留LogUploader是因为上传实现本身可以替换 Fake 测试 组合所以这里存在稳定能力边界。但是uploadLogs()只是现有能力的流程编排。因此接口是为了隔离变化、定义稳定能力边界而不是为了给每一段流程都套一个 Service。十七、顶层函数、扩展函数、interface、expect/actual 到底怎么选现在可以重新整理成两个维度。这是整篇文章最重要的地方。第一个维度平台差异怎么处理首先问commonMain 是否需要这个平台能力如果不需要比如打开 Android Activity 调用 iOS UIViewController Android 分享 Intent 某个平台独有 UI直接放androidMain iosMain即可。不一定需要expect / actual也不一定需要interface平台层自己实现就够了。如果 commonMain 需要继续问这是一个简单、固定的平台能力吗如果是平台名 系统版本 进程名 线程名 简单目录可以考虑expect / actual如果已经是文件存储 数据库 KeyValue Storage Logger Sink LogExportStorage Crash 能力 设备通信而且要求可测试 可替换 可注入优先考虑interface 平台实现因此平台抽象可以先这样判断出现平台差异 │ ▼ commonMain 是否需要 │ ┌──┴──┐ │ │ 否 是 │ │ ▼ ▼ 平台层 是否简单、固定 直接写 │ ┌──┴──┐ │ │ 是 否 │ │ ▼ ▼ expect/actual 是否是完整组件 │ ▼ interface 平台实现第二个维度API 最终怎么表达完成平台抽象以后再问另外一个问题这个操作真正属于某个对象本身吗如果属于可以考虑成员函数 或者 扩展函数例如throwable.toAppError()非常自然。如果这个操作不属于任何一个参与者而是在协调多个能力例如AppLogger.flush() LogExporter.export() LogUploader.upload()那么更加适合普通函数 或者 顶层函数所以第二个判断图可以写成这个操作属于某个对象本身吗 │ ┌──┴──┐ │ │ 是 否 │ │ ▼ ▼ 成员函数 / 是否协调多个能力 扩展函数 │ ▼ 顶层函数 普通函数十八、扩展函数和 interface、expect/actual 根本不是三选一这是整篇文章最重要的认知之一。不要再思考扩展函数 VS expect / actual VS interface因为它们根本不是同一层。正确的问题应该是第一步平台差异是否需要暴露给 commonMain第二步如果需要 它是简单平台能力 还是完整可替换组件决定expect / actual 还是 interface第三步最终 API 应该怎么表达再决定成员函数 扩展函数 顶层函数 普通类方法所以一个成熟的 KMP 模块完全可能同时存在interface 平台实现类 expect / actual 扩展函数 顶层函数它们各自解决不同的问题。十九、回到 Logger最终这套设计到底做了什么现在再看整个 Logger 日志导出exportLogs() │ 平台便捷调用入口 │ ▼ createLogExporter() │ ┌─────────┴─────────┐ │ │ ▼ ▼ AndroidLogExportStorage IosLogExportStorage │ │ └─────────┬─────────┘ │ ▼ LogExportStorage interface │ ▼ LogExporter │ ▼ commonMain exportLogs() │ ┌──────────┴──────────┐ │ │ ▼ ▼ AppLogger.flush() exporter.export() │ ▼ 公共日志导出流程每一层的职责都非常明确。exportLogs(context) / exportLogs()负责好不好调用 当前平台对象怎么创建createLogExporter()负责当前平台到底装配 AndroidLogExportStorage 还是 IosLogExportStorageLogExportStorage负责公共业务层需要什么平台能力Android / iOS Storage负责平台到底怎么实现文件读写LogExporter负责公共日志导出业务流程怎么执行commonMain exportLogs(exporter)负责协调 AppLogger.flush() LogExporter.export()它没有额外状态因此顶层函数就够了二十、这次重构真正改的不是语法表面上看这次只是AppLogger.exportLogs()改成exportLogs()以及AppLogger.uploadLogs(...)改成uploadLogs(...)似乎只是少了一个 receiver。但实际上真正调整的是API 的调用形式是否准确表达了真实职责。以前AppLogger.exportLogs()很容易理解成AppLogger 自己负责日志导出现在exportLogs()表达的是这是一个日志导出流程而真正参与流程的是AppLogger LogExporter LogExportStorage同理uploadLogs()是AppLogger LogExporter LogUploader之间的协调过程。这也是为什么最终没有继续使用扩展函数。总结这次从最开始的AppLogger.exportLogs()一路向下追最后实际上可以看到 KMP 架构设计中非常核心的一套思想。可以压缩成五句话。第一Source Set 决定平台代码在哪里参与编译扩展函数本身不是跨平台机制。第二扩展函数解决的是 API 表达问题但只有当操作真正属于 receiver 时才自然。第三跨多个组件的无状态流程协调更适合普通函数或顶层函数。第四expect/actual 更适合 commonMain 直接需要的简单、固定平台能力。第五interface 平台实现更适合完整、可替换、可测试的平台组件。在这个 Logger 中exportLogs()负责平台便捷入口和流程协调LogExporter负责平台无关的日志导出业务LogExportStorage负责定义公共业务层需要的平台能力AndroidLogExportStorage IosLogExportStorage负责真正的平台文件实现而AppLogger最终重新回到了它真正应该负责的事情记录日志 管理日志队列 flush另外还有最后一个非常重要的经验不要为了“看起来架构完整”而给每一个流程都创建 Service、Coordinator 和 Impl。只有当一个地方真正出现独立变化 可替换实现 稳定能力边界 生命周期或状态才值得继续抽象。如果只是A ↓ B ↓ C这样简单地协调已有能力一个清晰的函数可能就是最好的设计。当这些职责真正分清以后再遇到 KMP 项目里的commonMain androidMain iosMain expect / actual interface 平台实现 扩展函数 顶层函数就不会再把它们混成一团了。