作者简介
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的春天。