攜程機票App KMM iOS工程配置實踐
作者簡介
Derek,攜程資深研發經理,關注Native技術、跨平臺領域。
前言
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的春天。