
在 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 平臺實現 擴展函數 頂層函數就不會再把它們混成一團了。