2

携程机票App KMM iOS工程配置实践

 1 year ago
source link: https://www.51cto.com/article/754492.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

携程机票App KMM iOS工程配置实践

作者:Derek 2023-05-12 10:14:38
本文适合于对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
}

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可分为下面几大步:

  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}")//.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的春天。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK