今天只做:
src/底下幾個資料夾的關係弄清楚。
Android developer 有在用類似的東西:src/main、src/debug、src/release、還有 product flavor 的 src/free、src/paid。這些都是 source set,AGP 依 build variant 把它們合併編譯。
KMP 的 source set 概念一樣,但切分的維度不是 build type,而是 target。而且合併的方式不是「疊起來」,而是「有方向的依賴」。
目錄裡看得到的只有三個:
commonMain
androidMain
iosMain
但實際上 Gradle 建出來的比這多。跑一下:
./gradlew :composeApp:tasks --all | grep -i compile
or 在 build.gradle.kts 裡加一段 debug:
kotlin {
sourceSets.all {
println("sourceSet: $name dependsOn: ${dependsOn.map { it.name }}")
}
}
sync 一次,Build output 裡會看到類似:
sourceSet: commonMain dependsOn: []
sourceSet: androidMain dependsOn: [commonMain]
sourceSet: iosMain dependsOn: [commonMain]
sourceSet: iosX64Main dependsOn: [iosMain]
sourceSet: iosArm64Main dependsOn: [iosMain]
sourceSet: iosSimulatorArm64Main dependsOn: [iosMain]
(以及對應的 *Test)
所以真正的圖是這樣:
commonMain
/ \
androidMain iosMain
/ | \
iosX64 iosArm64 iosSimulatorArm64
iosMain 不對應任何一個 compile target,它是一個 intermediate source set——三個 iOS target 的公因數。這解釋了昨天的疑問:為什麼 iosMain 一份 code 可以同時服務 simulator 和實機。
這張圖是 Kotlin Gradle plugin 的 default hierarchy template 自動建的。只要宣告了 iosX64()、iosArm64()、iosSimulatorArm64() 三個 target,iosMain 就會自動出現,不需要手寫 dependsOn。
規則只有一條:
一個 source set 可以看見它 dependsOn 的所有 source set 的 code;反過來不行。
具體來說:
| 在這裡寫 code | 看得見 | 看不見 |
|---|---|---|
| commonMain | Kotlin stdlib、multiplatform library 的 common API | Android SDK、Foundation、java.、platform. |
| androidMain | commonMain 全部 + Android SDK + JVM | iosMain |
| iosMain | commonMain 全部 + Kotlin/Native + Foundation/UIKit | androidMain |
| iosArm64Main | iosMain 全部 + arm64 專屬 | 其他 iOS target |
這張表回答了 Day 1 那個問題的一半:commonMain 看不見 java.*,不是因為誰把它擋掉,而是 commonMain 編譯時根本沒有 JVM 這個東西存在。 它會被編成 JVM bytecode(給 Android)也會被編成 native binary(給 iOS),所以只能用兩邊都有的 API。
Question: 兩邊都有的 API 有哪些?
昨天訂的規則,今天第一次正式執行。在 commonMain 隨便建一個檔案:
// commonMain/kotlin/Probe.kt
fun now(): Long = System.currentTimeMillis()
IDE 標示紅線:Unresolved reference: System。
改成:
import kotlin.time.Clock // Kotlin 2.x 的 multiplatform Clock
fun now(): Long = Clock.System.now().toEpochMilliseconds()
紅線消失。再把它搬去 androidMain,第一版 System.currentTimeMillis() 就能過。
再試一個更有意思的:
// iosMain/kotlin/Probe.ios.kt
import platform.Foundation.NSDate
fun iosNow() = NSDate()
這在 iosMain 可以編譯,證明 iosMain 看得見 Foundation。搬去 commonMain 就炸。
這種「寫一行、看紅線、搬資料夾」的動作,我建議每次遇到不確定的 API 都做一次。
commonTest → 用 kotlin.test,Android 和 iOS 都會跑
androidUnitTest → JVM 上跑,可以用 JUnit / Robolectric
iosTest → Kotlin/Native 上跑,simulator 執行
一個 commonTest 裡的 test 會被編兩次、跑兩次。這意味著寫在 commonMain 的邏輯,一個 test 就同時驗證了兩個平台——這是 Phase 5 的重點,今天先知道結構就好。
./gradlew :composeApp:testDebugUnitTest # Android
./gradlew :composeApp:iosSimulatorArm64Test # iOS
這次我故意問一個有陷阱的問題,看它會不會踩:
In my KMP project I have commonMain, androidMain and iosMain.
I want a `Platform` object that reports the OS name and version.
Where should I put it, and should I use expect/actual or an interface?
Show both options and explain the trade-off. Don't pick one for me.
它給了兩個版本。expect/actual 版本正確,interface 版本也正確,但它在 trade-off 那段說「expect/actual 是官方推薦」——這句我沒在官方文件找到。官方文件的說法是兩者各有適用場景,interface 更適合需要 mock 或 DI 的情況。
這是一種常見的 AI 錯誤模式:把「常見寫法」說成「官方推薦」。 記進 Phase 5 的清單。
But 什麼時候用哪一個。今天只要知道:兩者的產物都是「commonMain 宣告、platform source set 實作」,本質上都在利用 dependsOn 的方向。
今天只有 probe 用的檔案,不留在 repo 裡。刪掉之後 commit build.gradle.kts 那段 debug println(之後會用到):
git commit -am "chore: print source set hierarchy on sync (AI: none)"
Day 4,處理 Gradle 本身:version catalog 怎麼組織 KMP 的依賴、commonMain.dependencies 和 androidMain.dependencies 的差別、以及怎麼判斷一個第三方 library 到底支不支援 KMP(提示:看它有沒有發 -iosarm64 的 artifact)。之後 Phase 2 就可以放心加 library 了。