本文适合于对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可分为下面几大步:

  1. 拉取pods源码仓库,替换framework

  2. 修改pods源码仓库中的spec文件的version字段

  3. 提交修改文件,给pods仓库打上tag,和2中的version一致

  4. 将.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的春天。

责任编辑:张燕妮来源: 携程技术