本文适合于对KMM有一定的了解的iOS开发者,KMM相关资料可参阅Kotlin Multiplatform官网介绍。
作者简介
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 }1.2.3.4.5.6.
2.2 有部分API的依赖(一定的代码积累,但又不想在KMM中重写已有的API)
此种情况KMM可以直接依赖原始逻辑,只需要将依赖的文件声明,做成一个def文件,通过官方提供的cinterop工具将其转换为KMM内部能调用的API即可。
这里官网是在C interop中介绍的,而这其实也可以直接用到Objective-C中。
方法如下:xxx.def
language = Objective-Cheaders = AAA.h BBB.h compilerOpts = -I/xxx(/xxx为h文件所在目录)1.2.3.
另外需要将def文件位置告知KMM工程,同时设置包名,具体如下:
compilations["main"].cinterops.create(name) { defFile = project.file("src/nativeInterop/cinterop/xxx.def") packageName = "com.xxx.ioscall"}1.2.3.4.
最终,在KMM调用时,只需要按照正常的kotlin语法调用。(这里能正常import的前提是需要保证def能正常通过cinterop转换为klib,并会被添加到KMM项目中的External Libraries中)
import com.xxx.ioscall.AAA1.
携程机票最开始的做法也是这种方式,同时为了应对API的变更同步,将iOS工程作为KMM的git submodule,这样def的配置中就可以引用相对路径下的头文件,同时也避免了不同的开发人员源文件路径不同导致的寻址错误问题。
这里注意KMM项目中实际无法真实调用,只是做了编译检查,真实调用需要到iOS平台上才可以。
2.3 依赖本地现有/第三方的framework/library
此种情况方法和上述类似,同样需要依赖创建一个def,但需要添加一些对framework/library的link配置才可以。有了2中的方式后,还需要增加静态库的依赖配置项staticLibraries,如下:
language = Objective-Cpackage = com.yy.FAheaders = /xxx/TestLocalLibraryCinterop/extframework/FA.framework/Headers/AAA.h libraryPaths = /xxx/TestLocalLibraryCinterop/extframework/staticLibraries = FA.framework FB.framework1.2.3.4.5.
由于业务的逐渐增多,我们对基础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.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.
这里注意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() ) } } }}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.
(这里配置时,需要注意不同版本的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()) } }```1.2.3.4.5.6.7.8.
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") }}}1.2.3.4.5.6.7.8.9.10.11.
然后可以在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")}}1.2.3.4.5.6.7.8.9.10.11.12.
三、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 start1.2.3.4.5.
注册:
sudo gitlab-runner register --url http://xxx.com/ --registration-token xxx_token1.
注册过程中需要注意的:
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即可1.2.3.4.5.6.
最后会在磁盘下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/ ../tempframework1.2.3.4.5.6.7.8.
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.properties1.2.3.4.5.6.7.8.
3.4 Stage:test
这一步我们将做的操作是执行UT,包括AndroidTest,CommonTest,iOSTest,并最终把执行Test后的产物copy到指定的临时目录,供后续stage使用。
具体脚本如下:
stage: testtags: - yy-runnerscript: - ./gradlew shared:iosX64Test - rm -rf ../reporttemp - mkdir ../reporttemp - cp -r -f shared/build/reports/ ../reporttemp/${CI_PIPELINE_ID}_${CI_JOB_STARTED_AT}1.2.3.4.5.6.7.8.
如果我们只有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()}1.2.3.4.5.6.7.8.
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) } } } } } ```1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.
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) ... }1.2.3.4.5.6.7.8.9.10.11.
如需单独执行,可自行单独配置。
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) }1.2.3.4.5.6.7.8.9.10.11.12.13.14.
如上gradle配置中的testExecutable 和 simulatorId 都是来自外部传值。
testExecutable这个获取可从binaries中getTest获取,如:
val testBinary = targets.getByName<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget>("iosX64").binaries.getTest("DEBUG")1.
simulatorId 可通过如下命令查看。
xcrun simctl list runtimes --json xcrun simctl list devices --json1.2.
为了减少手动查找和在其他人机器上执行的操作,我们可以利用同样的原理,增加一个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 }1.2.3.4.
执行完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: - official1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.
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}")//.framework1.
pods是依赖Library,linkerOpts配置如下:
(如果.a前面本身是lib开头,在这配置时需去除lib,如libAAA.a,只需配置-lAAA)
linkerOpts("-L${LibraryPath}","-lXXXLibrary")//.a1.
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") }}...}1.2.3.4.5.6.7.8.9.
如果只需在Test中配置,那么将Test的target挑选出来进行设置,如下:
binaries{getTest(org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType.DEBUG).apply{ linkerOpts("-ObjC")}}1.2.3.4.5.
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}")}1.2.3.4.5.6.
除了上面提到的KMM逻辑层的共享代码外,UI方面Jetbrains最近正在着力研发Compose Multiplatform,我们团队已在调研探索中,欢迎有兴趣的同学一起加入我们,一起探索,相信不久的将来就会迎来KMM的春天。
责任编辑:张燕妮来源: 携程技术