從 SQLlin 的更新看 Kotlin Multiplatform 技術(shù)更迭
作者簡介
禹昂,攜程移動開發(fā)專家,Google 開發(fā)者專家(Android),上海 Kotlin User Group 組織者,圖書《Kotlin 編程實踐》譯者。
2022 年底,我們在攜程的 Github organization 下開源了 SQLlin,SQLlin 是一款基于 Kotlin DSL 及 KSP 技術(shù)的,支持眾多平臺的 Kotllin Multipllatform SQLite 數(shù)據(jù)庫框架。感興趣且不了解 SQLlin 的讀者可以參考:《攜程機票跨端 Kotlin DSL 數(shù)據(jù)庫框架 SQLlin》一文。
SQLlin作為攜程機票移動端團隊最為完備的一款開源項目,在接近 1 年的時間內(nèi)經(jīng)歷了不少升級與換血式的更新,也見證了這一年 Kotlin Multiplatform 技術(shù)的演進及社區(qū)生態(tài)的變化。本文將帶領(lǐng)大家梳理這些更新,并探求這些更新背后所涉及到的 Kotlin Multiplatform 技術(shù)棧在這一年來的更迭與進化。
一、重寫 native 驅(qū)動層
我們先來回顧一下最初的 SQLlin 架構(gòu)圖:
最初,SQLlin 在 Kotlin/Native 平臺上基于開源項目 SQLiter(見參考鏈接 1),目的是避免重復(fù)造輪子。雖然 SQLliter 是來自 Touchlab的優(yōu)秀開源項目,但最近一年維護更新緩慢。在本文撰寫時,SQLiter 于 2023 年 11 月發(fā)布了 1.3.0 和 1.3.1 兩個版本(1.3.1升級到了 Kotlin 1.9.21,用于修復(fù) 1.9.20 的 Kotlin/Native 庫版本號相關(guān)的問題)。但在這之前的版本,即 1.2.1 發(fā)布于 2022年 8 月,基于 Kotlin 1.6.20,一年以上沒有更新。對于 2023 年的項目來說,1.6.20 過于老舊。老舊的版本導(dǎo)致了如下一些問題。
1.1 Targets 更新維護不及時
Kotlin 在 1.8.20 版本廢棄了一眾 32 位 Kotlin/Native targets(目標平臺),包括:iosArm32、watchosX86、wasm32、mingwX86、linuxArm32Hfp、linuxMips32、linuxMipsel32。這些目標平臺幾乎已經(jīng)完全被淘汰,市面上已經(jīng)極少有可以運行這些targets 的設(shè)備,繼續(xù)支持已無意義。因此 Kotlin 決定將這些 targets 標記為“deprecated”,并在 1.9.20 版本將它們完全移除。
這些即將被移除的 targets 中,iosArm32、watchosX86、mingwX86 受到 SQLiter 及 SQLlin 的支持。由于 SQLiter 不更新版本,所以這些 targets 將繼續(xù)存在于 SQLiter 當中,雖然 sqllin-driver 可以在上層移除對這些平臺的支持,但長久來說由于編譯器版本的更迭,仍然不是最佳做法。
如果說在 sqllin-driver 中移除對舊編譯目標的支持可以暫時解決“廢棄舊 targets 不及時”的問題,那么“對新 targets 的支持”則無計可施。
Kotlin 在 1.8.0 版本開始支持 watchosDeviceArm64 新目標平臺,對應(yīng)于全新的 64 位 Apple Watch 設(shè)備。雖然可以預(yù)見使用 Kotlin Multiplatform 技術(shù)開發(fā) Apple Watch 應(yīng)用的開發(fā)者不會很多,但 SQLlin 原本支持所有的 watchOS 相關(guān) targets,不支持最新的 Arm64 架構(gòu)并不合理。由于 SQLiter 不支持 watchosDeviceArm64,因此 SQLlin 也無法支持。
1.2 Bug 無法及時修復(fù)
在 SQL 中我們會遇到一個常見的用法——join,在 join 查詢時遇到兩個表擁有相同名字的列也是常見現(xiàn)象。在 SQLiter的原始實現(xiàn)中,后查詢出來的同名列值會覆蓋掉先查詢出來的同名列值:
override val columnNames: Map<String, Int> by lazy {
val map = HashMap<String, Int>(this.columnCount)
for (i in 0 until columnCount) {
val key = columnName(i)
if (map.containsKey(key)) {
var index = 1
val basicKey = "$key&JOIN"
var finalKey = basicKey + index
while (map.containsKey(finalKey)) {
finalKey = basicKey + ++index
}
map[finalKey] = i
} else {
map[key] = i
}
}
map
}
之后,我于 2022 年 12 月提交了一個 PR 以修復(fù)此問題(參考鏈接 2),但 SQLliter 的維護者沒有任何回復(fù),同樣是直到 2023 年 11 月才合并該 PR。
無法支持的新平臺導(dǎo)致有剛需的用戶無法繼續(xù)使用 SQLlin,而無法修復(fù)的問題導(dǎo)致了特定場景必定出錯的硬傷。一年沒有任何維護讓我對 SQLiter 感到疑慮,此時自行實現(xiàn)已經(jīng)變成了必然選擇。
1.3 Native 驅(qū)動層重寫
重寫 Native 驅(qū)動層并不困難,我們可以參考 SQLiter 的不少設(shè)計理念。
首先,SQLite 在不同的 Native 平臺上都提供相同的 C API,所以我們絕大部分代碼是平臺(這里特指 Kotlin/Native 的諸多目標平臺)無關(guān)的。根據(jù)官方 KMP 工程的架構(gòu)約定,這部分平臺無關(guān)的代碼可以全部放在 nativeMain source set 下。但由于我們構(gòu)建了一套面向?qū)ο箫L(fēng)格的 API,加上需要處理例如線程同步等問題,因此還是會依賴一些系統(tǒng)平臺 API。比如說如果要在 nativeMain 中使用線程鎖,需要用 expect 關(guān)鍵字定義待實現(xiàn)的API,在各平臺相關(guān) source set 中使用 actual 關(guān)鍵字定義相關(guān)實現(xiàn)。比如說在 Apple 平臺上我們使用 Apple Foundation 中的 Objective-C 類 NSRecursiveLock,而在 Linux 和Windows 平臺上則使用 Posix C 中的 pthread_mutex_xxx 系列 C API。
我們將 SQLite 的 C 庫頭文件放在 include 路徑下(與 nativeMain 平級),然后編寫 .def 文件并放在 nativeInterop 路徑下(同樣與 nativeMain 平級),然后在 build.gradle.kts 文件中配置頭文件的路徑以及 SQLite C 庫的 linkerOpts(編譯鏈接參數(shù)),即可在所有 native 相關(guān)的 sourceSet 中調(diào)用 SQLite C 函數(shù),build.gradle.kts 中的配置如下:
fun KotlinNativeTarget.setupNativeConfig() {
val main by compilations.getting
val sqlite3 by main.cinterops.creating {
includeDirs("$projectDir/src/include")
}
binaries.all {
linkerOpts += when {
HostManager.hostIsLinux -> listOf("-lsqlite3", "-L$rootDir/libs/linux", "-L/usr/lib/x86_64-linux-gnu", "-L/usr/lib", "-L/usr/lib64")
HostManager.hostIsMingw -> listOf("-Lc:\\msys64\\mingw64\\lib", "-L$rootDir\\libs\\windows", "-lsqlite3")
else -> listOf("-lsqlite3")
}
}
}
這是一個 native 目標平臺可調(diào)用的擴展函數(shù),使所有 native targets 都調(diào)用它即可。其中 linkerOpts 在 Linux 和 Windows 平臺上都指向常見的 SQLite 安裝路徑(使用常見的包管理器),但為了確保 native 單元測試可以順利在任何 Linux 或 Windows host 上運行,SQLlin 的源碼目錄中實際上附帶了針對 Linux 及 Windows 的 SQLite .a 庫,因此當鏈接過程無法在常見路徑下找到 SQLite .a文件時,最終會鏈接到 SQLlin 源碼路徑下的版本。
但再次強調(diào),以上場景僅限單元測試,如果你是使用 SQLlin 的應(yīng)用開發(fā)者,且你的應(yīng)用支持 Linux 和 Windows,需要確保用戶的電腦安裝了SQLite,或者在應(yīng)用程序工程中附帶 SQLite C 庫,并自行添加 linkerOpts 鏈接到 SQLite .a 文件。至于 Apple 相關(guān)平臺(iOS、macOS、watchOS、tvOS),系統(tǒng)框架中已經(jīng)自帶了SQLite,因此不必擔(dān)心以上問題,sqllin-driver 中添加的編譯鏈接參數(shù)可以正確鏈接到系統(tǒng)框架中自帶的版本。最后我們來看一下 nativeMain 下的源碼結(jié)構(gòu):
cinterop 包包含所有對 SQLite C 函數(shù)直接互操作的代碼,通過單獨的包將其與其它代碼隔離;platform 包則存放所有待平臺實現(xiàn)的相關(guān)代碼,真正的實現(xiàn)則位于 appleMain、linuxMain、mingwMain 幾個 source sets 中;其余代碼是 sqllin-driver-native 的核心實現(xiàn),都位于根目錄包下。
二、JVM Target 支持
起初,根據(jù)預(yù)測,我認為使用 Kotlin Multiplatform 技術(shù)開發(fā) JVM 桌面應(yīng)用的人并不多。但由于 Compose Multiplatform 最初支持的平臺便是 Android 與 JVM,因此吸引了大量 Kotlin Multiplatform 開發(fā)者將自己的多平臺應(yīng)用的支持范圍擴展到 JVM。在部分用戶提交了一些 issue(參考鏈接 3)后,我決定著手進行 JVM 平臺的支持工作。而支持 JVM 平臺也有助于調(diào)研將 SQLlin 支持的數(shù)據(jù)庫擴展到 MySQL、H2、Oracle 等后端數(shù)據(jù)庫的可能性,因為它們都基于 JDBC。
JVM 平臺的實現(xiàn)基于 SQLite 官方的 JVM driver:sqlite-jdbc,庫的使用者通過 JDBC 連接到 sqlite-jdbc,而 sqlite-jdbc 底層則通過 JNI 操作 SQLite C 庫。由于 sqlite-jdbc本身就是 Java 庫,因此 API 的抽象程度比 native 平臺上直接調(diào)用 C API 高的多。所以 jvmMain 中的代碼實現(xiàn)比 nativeMain 要簡單很多。
但也有幾個點值得一提:
首先,Windows平臺上的文件路徑分隔符是 ‘\’,而 Linux 和 macOS 上都是 ‘/’,因此在處理用戶傳入的路徑參數(shù)時,即使是在 jvmMain 中也要判斷當前運行的操作系統(tǒng)是不是 Windows。
其次,由于sqlite-jdbc 中沒有對 sqlite3_config C 函數(shù)的調(diào)用,因此目前 lookasideSlotSize 和 lookasideSlotCount 兩個參數(shù)在 JVM 平臺上無法生效,后續(xù)我計劃通過提交 PR 的方式參與sqlite-jdbc 的開發(fā),使其支持 sqlite3_config,但目前還沒有具體的時間表。
當然,支持 JVM 平臺的開發(fā)過程還遇到過其他的細節(jié)問題,例如表示查詢結(jié)果集的 java.sql.ResultSet 類型起始下標是 1 而不是 Android 平臺 android.database.Cursor 和 Native 平臺 C API 中的 0。不過這類問題都較為容易處理,在此不多做贅述。
在重寫了 native 平臺的 driver 和支持了 JVM 平臺后,SQLlin 的架構(gòu)圖如下所示:
目前 SQLlin 支持的完整目標平臺列表如下:
- Multiplatform Common
- Android (6.0+)
- JVM (Java 11+, since 1.2.0)
- iOS (x64, arm64, simulatorArm64)
- macOS (x64, arm64)
- watchOS (x64, arm32, arm64, simulatorArm64, deviceArm64)
- tvOS (x64, arm64, simulatorArm64)
- Linux (x64, arm64)
- Windows (mingwX64)
三、sqllin-dsl 并發(fā)安全
sqllin-driver 作為低階 SQLite 框架,可以通過 SQLite 本身的線程安全機制來實現(xiàn)一定程度上的線程安全,我寫過一篇文章《關(guān)于 SQLite 多線程行為的結(jié)論》討論過相關(guān)知識。
簡而言之,在多數(shù)情況下 SQLite 的默認線程模式都是:Multi-thread,在單連接多線程的情況下是可以保證線程安全的。因此我們只需盡量避免多連接多線程的情形即可,將同一個連接在多個線程間共享是個好方法。
現(xiàn)在我們來回顧一下 sqllin-dsl 的基本用法,以便理解本節(jié)接下來的內(nèi)容:
private val db by lazy { Database(name = "person.db", path = path, version = 1) }
fun sample() {
val tom = Person(age = 4, name = "Tom")
val jerry = Person(age = 3, name = "Jerry")
val jack = Person(age = 8, name = "Jack")
val selectStatement: SelectStatement<Person> = db {
PersonTable { table ->
table INSERT listOf(tom, jerry, jack)
table UPDATE SET { age = 5; name = "Tom" } WHERE ((age LTE 5) AND (name NEQ "Tom"))
table DELETE WHERE ((age GTE 10) OR (name NEQ "Jerry"))
table SELECT WHERE (age LTE 5) GROUP_BY age HAVING (upper(name) EQ "TOM") ORDER_BY (age to DESC) LIMIT 2 OFFSET 1
}
}
selectStatement.getResult().forEach { person ->
println(person.name)
}
}
在 sqllin-dsl 中,一個 Database 對象中只會建立一個數(shù)據(jù)庫鏈接。但上述示例中如果我們將對象 db(類型為 Database)在多個線程(或運行在不同線程上的協(xié)程)中共享,幾乎必然會出現(xiàn)問題。
原因在于 Database 對象內(nèi)部使用一個雙向鏈表來進行一組 SQL 語句的構(gòu)建,一個 Database 對象持有一個雙向鏈表,每次子句的連接都會直接拼接到鏈表頭部的 SQL語句上,而當 SQL 語句組執(zhí)行完畢后鏈表會被清空。
如果在多個線程/協(xié)程中同事使用 db 對象,可以想象這可能會出現(xiàn) SQL 語句拼接混亂的問題,例如線程 A 和 線程 B 都在構(gòu)建自己的SQL 語句,由于沒有同步機制,線程 B 中的子句可能被拼接到線程 A 中已經(jīng)創(chuàng)建出的 SQL 語句后面,造成 SQL 語法錯誤。也有可能出現(xiàn)線程 A 還在構(gòu)建 SQL 語句,但線程 B 已經(jīng)進入SQL 語句執(zhí)行階段,線程 B 很可能會將還未構(gòu)建完成的 SQL 語句傳給 SQLite,造成運行錯誤。
SQLlin 最初之所以沒有設(shè)計線程同步機制主要是基于 Kotlin 版本的考量。在 SQLlin 第一個版本發(fā)布的 Kotlin 1.7.20 時期,Kotlin/Native new Memory Management(新內(nèi)存管理器,后文簡稱 new MM)還未進入正式版,不少開發(fā)者還在使用舊內(nèi)存管理器。在 Kotlin/Native 的舊內(nèi)存模型中,對象是不能直接跨線程訪問的,必須要手動進行對象子圖分離和再綁定操作,對象才能將自己的所有權(quán)轉(zhuǎn)移到另一個線程,這種設(shè)計其實是強制開發(fā)者在編譯期就保證對象在同一時刻只能被一個線程訪問。
關(guān)于舊內(nèi)存模型在本人以往的文章中討論過很多次,并且在當下 Kotlin 1.9.20 時代已經(jīng)被徹底淘汰,這里也不再過多討論。基于以上的時代背景,在不能確定用戶是否使用新內(nèi)存管理器的情況下,做線程同步的設(shè)計非常困難,因此最好的方式就是不處理,并且建議用戶不要在多線程間共享 Database 對象。但如今 2023 年末,在 Kotlin 1.9.2x 版本作為最新版本的背景下,new MM早已經(jīng)被絕大部分開發(fā)者所使用,因此此時基于 new MM 的設(shè)計進行線程同步機制的開發(fā)非常合適。
在 sqllin-dsl 新版本的設(shè)計中,新增了掛起函數(shù) API suspendScope,用于在并發(fā)環(huán)境下取代 operator 函數(shù) invoke,并且管理 SQL 語句構(gòu)建的雙向鏈表被改成成員變量,只有在每次invoke 或 suspendScope 函數(shù)被調(diào)用時才創(chuàng)建,在 SQL 語句執(zhí)行完畢后會被就會被拋棄。由于函數(shù)調(diào)用棧是線程私有的,因此這樣的設(shè)計可以在不同的線程同時構(gòu)建 SQL語句時隔離運行,既提高效率又保證了線程安全。
在 SQL 語句運行階段,由于每次 SQL 語句構(gòu)建完畢后執(zhí)行的都是一組 SQL,為了避免不同線程同時執(zhí)行 SQL語句時的順序的不確定性,例如線程 A 需要執(zhí)行 SQL 語句 a、b、c,線程 B 需要執(zhí)行 SQL 語句 d、e、f,不加任何同步機制同時執(zhí)行可能會導(dǎo)致 a、b、c、d、e、f的執(zhí)行順序不確定,從而導(dǎo)致不可預(yù)知的問題,因此 SQL 語句執(zhí)行階段必須加入?yún)f(xié)程鎖 Mutex 來保證并發(fā)安全,suspendScope 的實現(xiàn)如下:
private val executiveMutex by lazy { Mutex() }
public suspend infix fun <T> suspendedScope(block: suspend DatabaseScope.() -> T): T {
val databaseScope = DatabaseScope(databaseConnection, enableSimpleSQLLog)
val result = databaseScope.block()
executiveMutex.withLock {
databaseScope.executeAllStatements()
}
return result
}
由于使用了協(xié)程鎖 Mutex,因此自 1.2.2 版本起, sqllin-dsl 依賴 Kotlin 官方協(xié)程框架 kotlinx.coroutines。
四、Android 低版本向下兼容
Android 系統(tǒng)曾在 API 28(Android 9)版本對 framework 中的 SQLite Java APIs 進行了一次升級,這次升級提供了許多新 API 可以讓開發(fā)者對 SQLite進行具體的參數(shù)配置,這些參數(shù)包括:日志模式、同步模式、連接超時時間、lookaside memory,這在之前的版本都是不可以的。由于 SQLlin 最低支持的Android 版本是 API 23(Android 6),因此在 Android 9 以下的設(shè)備上,以上提到的參數(shù)都無法生效。
但最初的認知并不準確,因為日志模式、同步模式兩個參數(shù)都使用 PRAGMA 語句配置,因此只需要在 sqllin_driver 內(nèi)自行構(gòu)建 PRAGMA 語句并執(zhí)行,即可在舊Android 系統(tǒng)上也能進行日志模式與同步模式的設(shè)置。因此,自 1.2.0 版本起,SQLlin 在舊 Android 設(shè)備上也支持設(shè)置日志模式與同步模式。但基于 SQLite C API才能配置的連接超時時間和 lookaside memory 仍然無法在舊設(shè)備上生效。
五、CI/CD 優(yōu)化
在 SQLlin 開源之初沒有進行 CI/CD 環(huán)境的搭建。CI/CD 對于驗證 push、PR 的準確性,保證版本發(fā)布的 bug 率等方面具有重要意義。同時也是向 MavenCentral發(fā)布新版本的最佳途徑。起初的發(fā)布都在本人的工作電腦上進行(Macbook Pro),由于 Mac 電腦的 Kotlin/Native 編譯器不支持編譯 Windows 平臺的產(chǎn)物,導(dǎo)致1.0 版本的 SQLlin 不支持 MinGW 目標平臺。
在 2023 年 1 月,SQLlin 第一個版本的 CI/CD pipeline 上線。此后經(jīng)過持續(xù)的優(yōu)化,如今已經(jīng)進入較為完備的體系和狀態(tài)。在搭建、優(yōu)化的過程中,我認為以下幾點內(nèi)容頗為重要:
5.1 單元測試/儀器測試原則
單元測試對任何項目都具有重要意義,可以在一定程度上驗證代碼的修改不會導(dǎo)致原有預(yù)期行為的改變,因此單元測試是 CI/CD 流程中的關(guān)鍵步驟。我們可以先回看“二. JVMTarget 支持”一節(jié)中的 SQLlin 最終架構(gòu)設(shè)計圖,SQLlin 在任何一個平臺上運行在底層都會涉及平臺相關(guān)代碼,因此單元測試必須覆蓋所有平臺相關(guān)代碼。
例如,如果我們只在 macOS機器上執(zhí)行單元測試,可以保證平臺無關(guān)代碼(sqllin-dsl、sqllin-processor、sqllin-driver(commonMain))以及 macOS 平臺相關(guān)代碼(sqllin-driver(nativeMain、appleMain))的正確性,但是無法驗證其他平臺相關(guān)的代碼,例如 sqllin-driver 中的 androidMain、jvmMain、linuxMain、mingwMain。
所以我們有必要在 Linux 和 Mac 機器上同時執(zhí)行Kotlin/Native 單元測試,但沒有必要分別在 iOS 和 macOS 上執(zhí)行 Kotlin/Native 單元測試,因為所有 Apple 平臺的相關(guān)代碼都在 appleMain source set 下,iOS 和 macOS上運行的 SQLlin 代碼沒有任何區(qū)別,保證相同的代碼在 iOS 和 macOS 運行得到相同的結(jié)果是 Kotlin 編譯器需要保證的事情,而不是庫開發(fā)者。JVM 單元測試比較特殊,需要在三臺機器上都運行,因為文件路徑在三種不同的操作系統(tǒng)上的表示不同,這部分代碼的區(qū)別可能就幾個字符,但既然不是 100% 相同,那么就還是需要分別測試。
根據(jù)以上原則,我們需要執(zhí)行的單元測試如下:
- Kotlin/JVM: JVM Unit Tests (Mac, Linux, Windows), Android Instrumented Tests (Android 9 以下版本,及最新 Android 版本)
- Kotlin/Native: macOS x64 Unit Tests, Linux x64 Unit Tests, MinGW x64 Unit Tests
5.2 合理的 Host 分配
Kotlin 支持眾多平臺,這里的平臺是廣義的,其中既包括操作系統(tǒng)原生產(chǎn)物,又包括一些非原生開發(fā)環(huán)境。比如 WASM、JavaScript、JVM、Android就屬于非原生開發(fā)環(huán)境。WASM、JavaScript、JVM 這些技術(shù)的出現(xiàn)本身就是為了跨平臺(這里是狹義的“平臺”,特指各操作系統(tǒng)),而 Android 的 ART則是一個“非標準”的 JVM,這些編譯產(chǎn)物的運行能力由其相對應(yīng)的平臺本身提供,不依賴特定 CPU 架構(gòu)或操作系統(tǒng) API,因此在任何機器上都能編譯構(gòu)建。
但Kotlin/Native 編譯出的操作系統(tǒng)原生產(chǎn)物則不同,首先,所有的 Apple 平臺(iOS、macOS、watchOS、tvOS)的編譯構(gòu)建都依賴 Xcode 命令行工具,而Apple 只提供 macOS 版本的 Xcode,因此,一個 Kotlin Multiplatform 應(yīng)用或庫如果要支持 Apple 平臺,必須使用 Mac 電腦開發(fā)和構(gòu)建;其次,由于Kotlin/Native 在 Windows 平臺上依賴 MinGW,至少 Kotlin 1.7.20 之前的版本如果要構(gòu)建 Windows 產(chǎn)物就必須使用 Windows 電腦,但在 1.7.20之后的某個版本開始,官方悄無聲息的支持了 Mac 電腦編譯 mingwx64 產(chǎn)物;而 Linux 系統(tǒng)的產(chǎn)物 Mac 電腦一直可以構(gòu)建。SQLlin 支持的全部平臺已經(jīng)在“二. JVMTarget 支持”一節(jié)中詳細列出。因此看似只需一臺 Mac 電腦即可完成全部的 CI/CD 任務(wù)。
但我們必須確保 CI/CD 中的單元測試可以符合 5.1 小節(jié)中的原則。macOS 雖然可以編譯構(gòu)建 Linux 和 Windows 平臺產(chǎn)物,但是無法執(zhí)行這些平臺的單元測試。所以我們至少需要Mac、Windows、Linux 三臺機器來完成整個 CI/CD 過程。三臺機器需要構(gòu)建的產(chǎn)物如下:
- Mac:Android, JVM, iosX64, iosArm64, iosSimulatorArm64, macosX64, macosArm64, wachosX64, watchosArm32, watchosArm64, watchosSimulatorArm64, watchosDeviceArm64, tvosX64, tvosX64, tvosArm64, tvosSimulatorArm64
- Windows: JVM, mingwX64
- Linux: Android, JVM, linuxX64, linuxArm64
僅從編譯構(gòu)建來看,Mac 的任務(wù)最重,Windows 的任務(wù)最輕。但沒有辦法,所有的 Apple 產(chǎn)物都只能在 Mac 上構(gòu)建。為了盡量縮短各平臺的 CI/CD pipeline運行過程的時間差以節(jié)省總時間,我們盡量合理分配一下單元測試任務(wù)。各平臺執(zhí)行的單元測試任務(wù)如下所示:
- Mac: macOS x64 Unit Tests, JVM Unit Tests, Android Instrumented Tests (Android 13)
- Windows: MinGW x64 Unit Tests, JVM Unit Tests
- Linux: Linux x64 Unit Tests, JVM Unit Tests, Android Instrumented Tests (Android 8)
實際上 native 和 JVM 單元測試的流程都非???,但 Android 儀器測試的流程非常耗時(耗時甚至可能接近整個 CI/CD 流程耗時的一半),因為準備(沒有緩存的話要創(chuàng)建)Android 模擬器非常耗時,連接Android 模擬器的測試過程也非常耗時,因此將兩個不同版本的 Android 儀器測試分配到不同的機器上是非常有必要的,這也是為什么 Linux 機器上也要構(gòu)建一次 Android 產(chǎn)物的原因。
5.3 緩存
由于每次執(zhí)行 CI/CD 時,Github Actions 總是分配空閑的機器給你的項目運行 pipeline,因此每次 pipeline 執(zhí)行完畢后,流程中下載的構(gòu)建工具、依賴庫、編譯產(chǎn)物,以及創(chuàng)建的 Android模擬器都會被清除。在沒有任何緩存的情況下每次重新運行 pipeline 會浪費大量時間。因此配置緩存策略是節(jié)省 CI/CD 運行時間的訣竅之一。
我們主要需要緩存的東西有三個:下載的構(gòu)建工具、創(chuàng)建好的 Android 模擬器、Gradle 構(gòu)建產(chǎn)物。一些和緩存有關(guān)的 yml 腳本中的 steps 代碼如下:
- name: Cache Build Tooling
uses: actions/cache@v2
with:
path: |
~/.gradle/caches
~/.konan
key: ${{ runner.os }}-gradle-${{ hashFiles('*.gradle.kts') }}
- name: Gradle Cache
uses: gradle/gradle-build-action@v2
- name: AVD Cache
uses: actions/cache@v3
id: avd-cache
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-33
- name: Create AVD and Generate Snapshot for Caching
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 33
target: google_apis
arch: x86_64
profile: pixel_6
force-avd-creation: false
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: false
script: echo "Generated AVD snapshot for caching."
實際效果也非常好,使用緩存之前整個 CI/CD 流程執(zhí)行結(jié)束可能需要 26 分鐘以上,使用緩存后降低至 10 分鐘出頭。其實可以想象每次我們在電腦上下載 Android 模擬器所需的鏡像,然后再創(chuàng)建模擬器要花多長時間,就知道緩存是多么有用的時間優(yōu)化手段。
六、社區(qū)推廣
2022 年 SQLlin 剛開源之際,我在 2022 Kotlin 中文開發(fā)者大會上分享了 SQLlin 相關(guān)的內(nèi)容:以 SQLlin 為例,分享如何構(gòu)建自己的 KMP 庫的經(jīng)驗。收效較好,SQLlin 在 Kotlin Multiplatform 中文社區(qū)內(nèi)擁有了一定知名度。目前在 Github 上擁有 190 個 stars(2024.01.18),從 starts 數(shù)量上來看也許并不高,但Kotlin Multiplatform 開發(fā)者群體絕對數(shù)量目前仍然較低,與 Android、Java 等技術(shù)棧相比不在一個數(shù)量級,因此該成績算是可以接受。
相較于國內(nèi)的環(huán)境,英文社區(qū)對新技術(shù)的接受速度普遍更高,Kotlin Multiplatform 開發(fā)者的數(shù)量更大,因此將 SQLlin 的影響力擴大到英文社區(qū)是一個好的選擇。
SQLlin 自誕生之初就擁有全套的英文文檔,在這一整年的維護升級過程中,我發(fā)現(xiàn)國外開發(fā)者的 issue/PR 數(shù)量大概占一半,維護過程中我與過來自希臘、英國、巴西的開發(fā)者在issue 或 PR 中互動過。Stars 的來源也有大量國外開發(fā)者,包括美國、德國、韓國、俄羅斯等等。與國外開發(fā)者在 Github 合作、溝通是一種極為有趣的體驗。
此外,一家美國初創(chuàng)的語言學(xué)習(xí)類 App 公司——Migaku 在生產(chǎn)環(huán)境使用 SQLlin,這是我發(fā)現(xiàn)的第一例在生產(chǎn)環(huán)境使用 SQLlin 的國外商業(yè)公司。他們的員工曾幫助提交PR(參考鏈接 4)協(xié)助修復(fù)了一個 Native 平臺與 Android 平臺行為不一致的問題,并請求我盡快發(fā)布新版,因為他們希望在 App 發(fā)布新版時可以使用問題修復(fù)后的新版SQLlin。
我也將 SQLlin 作為講題內(nèi)容申請成為哥本哈根 KotlinConf 2024 大會的 speaker,KotlinConf 是世界性質(zhì)的行業(yè)大會,由 Kotlin 的開發(fā)商 JetBrains 舉辦。如果講題被 JetBrains選中,這將是一個擴大 SQLlin 在世界范圍內(nèi)影響力的絕佳機會,同時也是向英文社區(qū)分享中國 Kotlin Multiplatform 開發(fā)經(jīng)驗、貢獻知識的機會,還是一個能收獲許多世界優(yōu)秀開發(fā)者的反饋,提升個人技能、公司在相關(guān)領(lǐng)域技術(shù)實力的機會。
從 2022.11 ~ 2024.1,近一年的時間 Kotlin Multiplatform 技術(shù)迎來許多重要的變革。這其中包括 new MM 從實驗性階段轉(zhuǎn)入穩(wěn)定,也包括 Kotlin/Native 編譯器支持的 targets 的更迭,其他的小更新及優(yōu)化更是數(shù)不勝數(shù)。
事實上最近幾個版本的 Kotlin 在新功能的迭代速度上已經(jīng)放緩,其主要原因是官方最近將主要精力放在了 Kotlin 新編譯器 K2 的優(yōu)化上,2024 年 K2 正式版將會隨 Kotlin 2.0 一起到來。目前 SQLlin 1.2.4 版本基于 Kotlin 1.9.22,1.9.22 應(yīng)該會是 Kotlin 1.x 的最后一個發(fā)行版,而當 Kotlin 2.0 發(fā)布后,SQLlin 也會積極進行升級。隨著 Kotlin 語言特性、標準庫、生態(tài)環(huán)境的逐步提升,SQLlin 也會對內(nèi)部實現(xiàn)進行重構(gòu)和迭代,以求在性能和代碼結(jié)構(gòu)等方面帶來更多的提升。
SQLlin 在未來還有眾多的發(fā)展空間,例如更改表結(jié)構(gòu)的 SQL 語句 DSL 化還沒有實現(xiàn),Join 子查詢的 DSL 化也還沒有實現(xiàn),這些都已經(jīng)規(guī)劃到了未來的開發(fā)計劃中。希望在未來 SQLlin 可以在攜程機票及整個 Kotlin Multiplatform 技術(shù)社區(qū)中有更廣泛的應(yīng)用場景。
七、參考鏈接
開源項目 SQLiter:
https://github.com/touchlab/SQLiter
修復(fù) SQliter Join 語句問題的 PR:
https://github.com/touchlab/SQLiter/pull/89
SQLlin 支持 JVM 相關(guān)的 issue:
https://github.com/ctripcorp/SQLlin/issues/15
Migaku 提交的修復(fù) SQLlin bug 的 PR:
https://github.com/ctripcorp/SQLlin/pull/51