前言
KMM(Kotlin Multiplatform Mobile),2022年10月迎來了KMM的beta版,攜程機票也是從KMM開始出道的alpha版本就已在探索。
本文主要圍繞下面幾個方面展開說明:
- 如何在KMM項目中配置iOS的依賴
- KMM工程的CI/CD環境搭建和配置
- 常見的內建問題的解決方法
本文适合于對KMM有一定的了解的iOS開發者,KMM相關資料可參閱Kotlin Multiplatform官網介紹。
一、背景
攜程App已有很長的曆史了,在類似這樣一個龐大成熟的App中要引入一套新的跨端架構,最先考慮的就是接入成本。而曆史的跨端架構以及現存的RN、Flutter等,都需要大量的基建工作,最後才能利用上這個跨平台架構。
通常對于大型的APP引用新的架構,通信本身的屬性肯定是沒問題的,那麼最關鍵要解決的就是對現有依賴的處理,像RN和Flutter如果需要對iOS原生API調用,需要從RN和Flutter内部底層增加通路API,而對于現有成型的一些API或者第三方SDK的API調用,将需要在iOS的工程中寫好對接的接口API才可以實作,而這個工作量是巨大的。而KMM這個跨端架構,正好可以規避這個問題,他隻需要通過簡單的配置就可直接調用原有的API,甚至不需要寫額外的路由代碼就可以實作。
二、如何在KMM項目中配置iOS的依賴
針對不同的開發階段,工程的依賴環境也是不一樣的,大緻可以分為下面幾種情況:
2.1 隻依賴系統架構(項目剛起步、開發完全獨立的架構)
按照官方的介紹,直接進行邏輯開發,依賴于iOS平台相關的,在引用API時,隻需 import platform.xxx即可,更多内容可參見官方文檔。如:
import platform.UIKit.UIDevice
class IOSPlatform: Platform {
override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
}
2.2 有部分API的依賴(一定的代碼積累,但又不想在KMM中重寫已有的API)
此種情況KMM可以直接依賴原始邏輯,隻需要将依賴的檔案聲明,做成一個def檔案,通過官方提供的cinterop工具将其轉換為KMM内部能調用的API即可。
這裡官網是在C interop中介紹的,而這其實也可以直接用到Objective-C中。
方法如下:xxx.def
language = Objective-C
headers = AAA.h BBB.h
compilerOpts = -I/xxx(/xxx為h檔案所在目錄)
另外需要将def檔案位置告知KMM工程,同時設定包名,具體如下:
compilations["main"].cinterops.create(name) {
defFile = project.file("src/nativeInterop/cinterop/xxx.def")
packageName = "com.xxx.ioscall"
}
最終,在KMM調用時,隻需要按照正常的kotlin文法調用。(這裡能正常import的前提是需要保證def能正常通過cinterop轉換為klib,并會被添加到KMM項目中的External Libraries中)
import com.xxx.ioscall.AAA
攜程機票最開始的做法也是這種方式,同時為了應對API的變更同步,将iOS工程作為KMM的git submodule,這樣def的配置中就可以引用相對路徑下的頭檔案,同時也避免了不同的開發人員源檔案路徑不同導緻的尋址錯誤問題。
這裡注意KMM項目中實際無法真實調用,隻是做了編譯檢查,真實調用需要到iOS平台上才可以。
2.3 依賴本地現有/第三方的framework/library
此種情況方法和上述類似,同樣需要依賴建立一個def,但需要添加一些對framework/library的link配置才可以。有了2中的方式後,還需要增加靜态庫的依賴配置項staticLibraries,如下:
language = Objective-C
package = com.yy.FA
headers = /xxx/TestLocalLibraryCinterop/extframework/FA.framework/Headers/AAA.h
libraryPaths = /xxx/TestLocalLibraryCinterop/extframework/
staticLibraries = FA.framework FB.framework
由于業務的逐漸增多,我們對基礎API也依賴的多了,因而此部分API也是在封裝好的Framework/Library中,故我們第二階段也增加諸如上面對靜态庫的配置。(這裡同樣需要注意配置的路徑,最好是相對路徑)
2.4 依賴私有/公用的pods,攜程機票也在開發過程中遇到了基礎部門對iOS工程Cocoapods內建改造,現在也是用此種方式進行的依賴內建
這種方式在iOS中是比較成熟的,也是比較友善的,但也是我們在內建時遇到問題較多的,特别是自定義的pods倉庫,而我們項目中依賴的pods比較複雜多樣,涵蓋了源碼、framework,library,swift多種依賴。
如官網上提及的AFNetworing,其實很簡單就可以添加到KMM中,但是用到自建的pods倉庫時,就會遇到一些問題。這裡基礎步驟和官網一緻,需要對cocoapods中的specRepos、pod等進行配置。如果是私有pods庫,并有依賴靜态庫,具體內建步驟如下:
1)添加cocoapods的相關配置,如下:
cocoapods {
summary = "Some description for the Shared Module"
homepage = "https://xxxx.com/xxxx"
version = "1.0"
ios.deploymentTarget = "13.0"
framework {
baseName = "shared"
}
specRepos {
url("https://github.com/hxxyyangyong/yyspec.git")
}
pod("yytestpod"){
version = "0.1.11"
}
useLibraries()
}
這裡注意1.7.20 對靜态庫的Link的進行了修複。
當低于1.7.20時,會遇到framework無法找到的錯誤 ld: framework not found XXXFrameworkName。
2)針對cocoapods生成Def檔案時添加配置
當我們确定哪些pods中的class需要被引用,我們就需要在KMM插件建立def檔案的時候進行配置。這一步其實就是前面我們自己建立def的那個過程,這裡隻不過是通過pods來确定def的檔案,最終也都是通過cinterop來進行API的轉換。
這裡和普通def的不同點是監聽了def的建立,def的名稱和個數和前面配置cocoapods中的pod是一緻的。這個步驟主要配置的是引用的檔案,以及引用檔案的位置,如果沒有這些設定,如果是對靜态庫的pods,那麼此處是不會有Class被轉換進klib的,也就無法在KMM項目中調用了。這裡的引用頭檔案的路徑,可依賴buildDir的相對目錄進行配置。
gradle.taskGraph.whenReady {
tasks.filter { it.name.startsWith("generateDef") }
.forEach {
tasks.named<org.jetbrains.kotlin.gradle.tasks.DefFileTask>(it.name).configure {
doLast {
val taskSuffix = this.name.replace("generateDef", "", false)
val headers = when (taskSuffix) {
"Yytestpod" -> "TTDemo.h DebugLibrary.h NSString+librarykmm.h TTDemo+kmm.h NSString+kmm.h"
else -> ""
}
val compilerOpts = when (taskSuffix) {
"Yytestpod" -> "compilerOpts = -I${buildDir}/cocoapods/synthetic/IOS/Pods/yytestpod/yytestpod/Classes/DebugFramework.framework/Headers -I${buildDir}/cocoapods/synthetic/IOS/Pods/yytestpod/yytestpod/Classes/library/include/DebugLibrary\n"
else -> ""
}
outputFile.writeText(
"""
language = Objective-C
headers = $headers
$compilerOpts
""".trimIndent()
)
}
}
}
}
(這裡配置時,需要注意不同版本的Android Studio和KMM插件以及IDEA,build中cocoapods子目錄有差異,低版本會多一層moduleName目錄層級)
當配置好這些之後,重新build,可以通過build/cocoapods/defs中的def檔案check相關的配置是否正确。
3)build成功後,項目的External Libraries中就會出現對應的klib,如下:
調用API代碼,import包名為cocoapods.xxx.xxx,如下:
``` kotlin
import cocoapods.yytestpod.TTDemo
class IosGreeting {
fun calctTwoDate() {
println("Test1:" + TTDemo.callTTDemoCategoryMethod())
}
}
```
pods配置可參考我的Demo,pods和def方式可以混用,但需注意依賴的沖突。
2.5 依賴的釋出
當解決了上面現有依賴之後,就可以直接調用依賴API了。但是如果有多個KMM項目需要用到這個依賴或者讓代碼和配置更簡潔,就可以把現有依賴做成個單獨依賴的KMM工程,自己有maven倉庫環境的前提下,可以将build的klib産物釋出到自己的Maven倉庫。本身KMM就是一個gradle項目,是以這一點很容易做到。
首先隻需要在KMM項目中增加Maven倉庫的配置:
publishing {
repositories {
maven {
credentials {
username = "username"
password = "password"
}
url = uri("http://maven.xxx.com/aaa/yy")
}
}
}
然後可以在Gradle的tasks看到Publish項,執行publish的Task即可釋出到Maven倉庫。
使用依賴時,這裡和一般的kotlin項目的配置依賴一樣。(上面釋出的klib,在配置時需要區分iosX64和iosArm64指令集,不區分會有klib缺失,實際maven看産物綜合目錄klib也是缺失)
配置如下:
val iosX64Main by getting {
dependencies{
implementation("groupId:artifactId:iosx64-version:cinterop-packagename@klib")
}
}
val iosArm64Main by getting {
dependencies{
implementation("groupId:artifactId:iosarm64-version:cinterop-packagename@klib")
}
}
三、KMM工程的CI/CD環境搭建和配置
目前面的流程完成之後,可以得到對應的Framework産物,如果沒有配置相關的CI/CD過程,則需要在本地手動将framework添加到iOS工程。是以我們這裡做了一些CI/CD的配置,來簡化這裡的Build、Test以及釋出內建操作。
這裡CI/CD主要分為下面幾個stage:
- pre: 主要做一些環境的check操作
- build: 執行KMM工程的build
- test: 執行KMM工程中的UT
- upload: 上傳UT的報告(手動執行)
- deploy: 釋出最終的內建産物(手動執行)
3.1 CI/CD環境的搭建
這裡由于公司内部現階段無macOS鏡像的伺服器,而KMM工程時需要依賴XCode的,故我們這裡暫時使用自己的開發機器做成gitlab-runner,供CI/CD使用(使用gitlab-runner前提是工程為gitlab管理)。如果是gitlab環境,倉庫的Setting-CI/CD中有runner的安裝步驟。
安裝:
sudo curl --output /usr/local/bin/gitlab-runner https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-darwin-amd64
sudo chmod +x /usr/local/bin/gitlab-runner
cd ~
gitlab-runner install
gitlab-runner start
注冊:
sudo gitlab-runner register --url http://xxx.com/ --registration-token xxx_token
注冊過程中需要注意的:
1. Enter tags for the runner (comma-separated):yy-runner
此處需要填寫tag,後續設定yaml的tags需要保持一緻
2. Enter an executor: instance, kubernetes, docker-ssh, parallels, shell, docker-ssh+machine, docker+machine, custom, docker, ssh, virtualbox:shell
此處我們隻需要shell即可
最後會在磁盤下etc/gitlab-runner下生成一個config.toml。gitlab的需要識别,需要将此檔案中的配配置copy到使用者目錄下的.gitlab-runner/config.toml中,如多個工程中用到直接添加到末尾即可,如:
最終在Setting-CI/CD-Runners下能看到runner得tag為active即可。
3.2 Stage:pre
這裡由于我們需要一些環境的依賴,是以我這裡做了一下幾個環境的check,我們配置了對幾個依賴項的版本check,當然這裡也可以增加一些校驗為安裝的情況下補充安裝的步驟等。
3.3 Stage:build
這個stage我們主要做build,并把build後的産物copy到臨時目錄,供後續stage使用。
這裡還需要注意就是由于gradle的項目中存在的local.properties是本地生成的,git上不會存放,是以這裡我們需要做一個建立local.properties,并且設定Android SDK DIR的操作,我這裡使用的shell檔案來做了操作。build的stage:
buildKMM:
stage: build
tags:
- yy-runner
script:
- sh ci/createlocalfile.sh
- ./gradlew shared:build
- cp -r -f shared/build/fat-framework/release/ ../tempframework
createlocalfile.sh:
#!/bin/sh
scriptDir=$(cd "$(dirname "$0")"; pwd)
echo $scriptDir
cd ~
rootpath=$(echo `pwd`)
cd "$scriptDir/.."
touch local.properties
echo "sdk.dir=$rootpath/Library/Android/sdk" > local.properties
3.4 Stage:test
這一步我們将做的操作是執行UT,包括AndroidTest,CommonTest,iOSTest,并最終把執行Test後的産物copy到指定的臨時目錄,供後續stage使用。
具體腳本如下:
stage: test
tags:
- yy-runner
script:
- ./gradlew shared:iosX64Test
- rm -rf ../reporttemp
- mkdir ../reporttemp
- cp -r -f shared/build/reports/ ../reporttemp/${CI_PIPELINE_ID}_${CI_JOB_STARTED_AT}
如果我們隻有CommonTest對在CommonMain中寫了UT,沒有使用到平台相關的API,那麼這一步是相對輕松很多,隻需要執行 ./gradlew shared:allTest 即可。在普通的iOS工程中,需要UT我們隻需建立一個UT的Target,增加UTCase執行就很容易做到這一點。
但在實際在我們的KMM項目中,已經有依賴iOS平台以及自己項目中的API,如果在iOSTest正常編寫了一些UTTestCase,當實際執行iOSX64Test時,是無法執行通過的,因為這裡并不是在iOS系統環境下執行的。是以要先fix這個問題。
而這裡要做到在KMM内部執行iOSTest中的TestCase,官方暫時沒有對外公布解決方法,是以隻能自己探索。
搜尋到了一個可行的方案,讓其Test的Task依賴iOS模拟器在iOS環境中來執行,那麼就可以順利實作了KMM内部直接執行iOSTest。
官方也有考慮到UT執行,但是苦于沒有完整對iOSTest的配置的方法。通過文檔檢視build目錄下的産物,在build/bin/iosX64/debugTest目錄下就有可執行UT的test.kexe檔案,我們就是通過它來實作在KMM内部執行iOS的UTCase。
除了編寫UTCase外,當然還需要iOS的模拟器,借助iOS系統才可以完整的執行UTCase。
解決方案步驟如下:
1)在KMM項目共享代碼的module的同級目錄下增加一個module,并配置build.gradle.kts,如下:
plugins {
`kotlin-dsl`
}
repositories {
jcenter()
}
2)增加一個DefaultTask的子類,利用Task的TaskAction來執行iOSTest,内部能執行終端指令,擷取模拟器裝置資訊,并執行Test。
open class SimulatorTestsTask: DefaultTask() {
@InputFile
val testExecutable = project.objects.fileProperty()
@Input
val simulatorId = project.objects.property(String::class.java)
@TaskAction
fun runTests() {
val device = simulatorId.get()
val bootResult = project.exec { commandLine("xcrun", "simctl", "boot", device) }
try {
print(testExecutable.get())
val spawnResult = project.exec { commandLine("xcrun", "simctl", "spawn", device, testExecutable.get()) }
spawnResult.assertNormalExitValue()
} finally {
if (bootResult.exitValue == 0) {
project.exec { commandLine("xcrun", "simctl", "shutdown", device) }
}
}
}
}
```
3)将上述Task配置為shared工程中的check的dependsOn項。如下:
kotlin{
...
val testBinary = targets.getByName<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget>("iosX64").binaries.getTest("DEBUG")
val runIosTests by project.tasks.creating(SimulatorTestsTask::class) {
dependsOn(testBinary.linkTask)
testExecutable.set(testBinary.outputFile)
simulatorId.set(deviceName)
}
tasks["check"].dependsOn(runIosTests)
...
}
如需單獨執行,可自行單獨配置。
val customIosTest by tasks.creating(Sync::class)
group = "custom"
val (deviceName,deviceUDID) = SimulatorHelp.getDeviceNameAndId()
kotlin.targets.withType(org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTargetWithSimulatorTests::class.java) {
testRuns["test"].deviceId = deviceUDID
}
val testBinary = kotlin.targets.getByName<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget>("iosX64").binaries.getTest("DEBUG")
val runIosTests by project.tasks.creating(SimulatorTestsTask::class) {
dependsOn(testBinary.linkTask)
testExecutable.set(testBinary.outputFile)
simulatorId.set(deviceName)
}
如上gradle配置中的testExecutable 和 simulatorId 都是來自外部傳值。
testExecutable這個擷取可從binaries中getTest擷取,如:
val testBinary = targets.getByName<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget>("iosX64").binaries.getTest("DEBUG")
simulatorId 可通過如下指令檢視。
xcrun simctl list runtimes --json
xcrun simctl list devices --json
為了減少手動查找和在其他人機器上執行的操作,我們可以利用同樣的原理,增加一個Task來擷取執行機器上可用的simulatorId,具體可參見我的Demo中的此檔案。
遇到的小問題:如果直接執行,大機率會遇到一個預設模拟器為iPhone 12的問題。可以通過上面的SimulatorHelp輸出的deviceUDID來指定預設執行的模拟器。
val (deviceName,deviceUDID) = SimulatorHelp.getDeviceNameAndId()
targets.withType(org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTargetWithSimulatorTests::class.java) {
testRuns["test"].deviceId = deviceUDID
}
執行完iOSTest的Task之後,可以在build的日志中看到一些Case的執行輸出。
3.5 Stage:upload
此步驟主要是上傳前面的測試産物,可以線上檢視UT報告。
這裡需要額外建立一個工程,用于存放Test的report産物,同時利用gitlab-pages上來檢視UT的測試報告。通過前面執行stage:test後,我們已經把test的産物reports下面的全部檔案Copy到了臨時目錄,我們這一步隻需将臨時目錄下的内容上傳到testreport倉庫。
這裡我們做了如下幾個操作:
1)首先将testreport倉庫,并配置開放成gitlab-pages,具體yaml配置如下:
pages:
stage: build
script:
- yum -y install git
- git status
artifacts:
paths:
- public
only:
refs:
- branches
changes:
- public/index.html
tags:
- official
2)上傳檔案時以當次的pipelineid作為檔案夾目錄名
3)建立一個index.html檔案,内容為執行每次測試報告目錄下的index.html,每次上傳新的測試結果後,增加指向新傳測試報告的超鍊
pages的首位址,效果如下:
通過連結即可檢視實際測試結果,以及執行時間等資訊。
3.6 Stage:deploy
此步驟我們主要是将fat-framework下的framework上傳為pods源代碼倉庫 & push spec到specrepo倉庫。
主要借鑒KMMBridge的思想,但其内部多處和github挂鈎,并不适合公司項目,如果本身就是在github上的項目,也可直接用kmmbridge的模版直接建立項目,也是非常友善,詳見kmmbridge建立的demo。
需要建立2個倉庫:
- pods源代碼倉庫,用于管理每次上傳的framework産物,做版本控制。
初始pods可以自己利用 pod lib create 指令建立。後續的上傳隻需覆寫s.vendored_frameworks中的shared.framework即可,如果有對其他pods的依賴需要添加s.dependency的配置
- podspec倉庫,管理通過pods源碼倉庫中的spec的版本
其中最關鍵的是podspec的版本不能重複,這裡需做自增處理,主要借鑒了KMMBridge中的邏輯,我這裡是通過腳本處理,最終修改掉podlib中的.podspec檔案中的version,并同步替換pods參考下的framework,進行上傳,然後添加給pods倉庫打上和podspec中version一樣的tag。
釋出到單獨的specrepo,deploy可分為下面幾大步:
- 拉取pods源碼倉庫,替換framework
- 修改pods源碼倉庫中的spec檔案的version字段
- 送出修改檔案,給pods倉庫打上tag,和2中的version一緻
- 将.podspec檔案push到spec-repo
在攜程app中用的是自己内部的打包釋出平台,我們隻需将framework送出統一的pods源碼倉庫即可,其他步驟隻需借助内部打包釋出平台統一處理。最終的deploy流程目前可以做到如下效果:
四、常見內建問題的解決方法
4.1 配置了pods依賴,但是出現framework無法找到符号的問題
當依賴的pods中為靜态庫(.framework/.a)時,執行linkDebugTestIosX64時會遇到如下錯誤。
這個問題也是連接配接器的問題,需要增加framework的相關路徑才可以。pods是依賴Framework,需要的linkerOpts配置如下:
linkerOpts("-framework", "XXXFramework","-F${XXXFrameworkPath}")//.framework
pods是依賴Library,linkerOpts配置如下:
(如果.a前面本身是lib開頭,在這配置時需去除lib,如libAAA.a,隻需配置-lAAA)
linkerOpts("-L${LibraryPath}","-lXXXLibrary")//.a
4.2 iOSTest中OC的Category無法找到的問題
不論直接調用Category中的方法,或者間接調用,隻要調用堆棧中的方法内部有OC Category的方法,都會導緻UT無法Pass。(此問題并不會影響build出fat-framework,同時LinkiOSX64Test也會成功,隻牽涉到UTCase的通過率)
其實這個問題其實在正常的iOS項目中也會遇到,根本原因和OC Category的加載機制有關,Category本身是基于runtime的機制,在build期間不會将category中方法加到Class的方法清單中,如果我們需要支援這個調用,那麼在iOS項目中我們隻需要在Build Setting中的Others Link Flags中增加-ObjC、 -force_load xxx、-all_load的配置,來告知連接配接器,将OC Category一起加載進來。
同樣在KMM中,我們也需要配置這個屬性,隻不過這裡沒有顯式Others Link Flags的設定,需要在KotlinNativeTarget的binaries中增加linkerOpts的配置。
如果配置整個iOS Target都需要,可将此屬性配置到binaries.all中,具體如下:
kotlin {
...
targets.withType<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget> {
binaries.all {
linkerOpts("-ObjC")
}
}
...
}
如果隻需在Test中配置,那麼将Test的target挑選出來進行設定,如下:
binaries{
getTest(org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType.DEBUG).apply{
linkerOpts("-ObjC")
}
}
4.3 依賴中含有swift,出現ld: symbol(s) not found for architecture x86_64
如果KMM依賴的項目含有swift相關引用時,按照正常的配置,會遇到無法找到swift相關代碼的符号表,并伴随出現一系列swift庫無法自動link的warning。具體如下:
這裡主要是swift庫無法自動被Link,需要手動配置好swift的依賴runpath,即可解決類似問題。
getTest(org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType.DEBUG).apply {
linkerOpts("-L/usr/lib/swift")
linkerOpts("-rpath","/usr/lib/swift")
linkerOpts("-L/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/${platform}")
linkerOpts("-rpath","/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/${platform}")
}
除了上面提到的KMM邏輯層的共享代碼外,UI方面Jetbrains最近正在着力研發Compose Multiplatform,我們團隊已在調研探索中,歡迎有興趣的同學一起加入我們,一起探索,相信不久的将來就會迎來KMM的春天。
【作者簡介】
Derek,攜程資深研發經理,關注Native技術、跨平台領域。