Ctrip Air Ticket App KMM iOS Engineering Configuration Practice

Ctrip Air Ticket App KMM iOS Engineering Configuration Practice

About the Author

Derek, senior R&D manager at Ctrip, focuses on Native technology and cross-platform areas.

Preface

KMM (Kotlin Multiplatform Mobile) ushered in the beta version of KMM in October 2022. Ctrip air tickets have also been exploring since the alpha version of KMM debuted.

This article mainly focuses on the following aspects:

  • How to configure iOS dependencies in KMM projects
  • CI/CD environment construction and configuration of KMM project
  • Solutions to common integration problems

This article is suitable for iOS developers who have a certain understanding of KMM. For information about KMM, please refer to the official website of Kotlin Multiplatform .

1. Background

The Ctrip App has a long history. When introducing a new cross-end framework into such a large and mature App, the first thing to consider is the access cost. The historical cross-end frameworks and existing RN, Flutter, etc. all require a lot of infrastructure work before this cross-platform framework can be used.

Usually, when a large APP references a new framework, the communication properties are definitely fine. The most critical issue is how to deal with existing dependencies. For example, if RN and Flutter need to call iOS native APIs, they need to add access APIs from the bottom layer of RN and Flutter. For some existing APIs or third-party SDK API calls, they need to write docking interface APIs in the iOS project, which is a huge workload. The cross-end framework KMM can circumvent this problem. It only needs to call the original API directly through simple configuration, and it can be implemented without even writing additional routing code.

2. How to configure iOS dependencies in KMM projects

The project's dependency environment is different for different development stages, and can be roughly divided into the following situations:

2.1 Only rely on the system framework (the project is just starting, and a completely independent framework is developed)

According to the official introduction, directly carry out logic development, which depends on the iOS platform. When referencing the API, just import platform.xxx. For more information, please refer to the official documentation . For example:

 import platform.UIKit.UIDevice class IOSPlatform: Platform { override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion }

2.2 Dependence on some APIs (some code accumulation, but do not want to rewrite existing APIs in KMM)

In this case, KMM can directly rely on the original logic. It only needs to declare the dependent files and make them into a def file, and then convert them into an API that can be called inside KMM through the official cinterop tool.

The official website here introduces it in C interop, which can actually be used directly in Objective-C.

The method is as follows: xxx.def

 language = Objective-C headers = AAA.h BBB.h compilerOpts = -I/xxx(/xxx为h文件所在目录)

In addition, you need to inform the KMM project of the location of the def file and set the package name as follows:

 compilations["main"].cinterops.create(name) { defFile = project.file("src/nativeInterop/cinterop/xxx.def") packageName = "com.xxx.ioscall" }

Finally, when calling KMM, you only need to call it according to the normal Kotlin syntax. (The premise for normal import here is to ensure that def can be converted to klib normally through cinterop and will be added to the External Libraries in the KMM project)

 import com.xxx.ioscall.AAA

Ctrip Air Tickets also initially adopted this approach. In order to cope with the synchronization of API changes, the iOS project was used as the git submodule of KMM, so that the header files under the relative path can be referenced in the def configuration, while also avoiding the addressing error problem caused by different source file paths of different developers.

Please note that the KMM project cannot actually be called. It only does a compilation check. The real call needs to be made on the iOS platform.

2.3 Dependence on local existing/third-party framework/library

The method in this case is similar to the above one. It also requires creating a def dependency, but some link configurations for framework/library need to be added. After using the method in 2, you also need to add the static library dependency configuration item staticLibraries, as follows:

 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

As the business gradually increases, we rely more on basic APIs, so this part of the API is also in the packaged Framework/Library, so we also add the above configuration of static libraries in the second phase. (Here you also need to pay attention to the configuration path, preferably a relative path)

2.4 Dependence on private/public pods. During the development process, Ctrip Air Tickets also encountered the infrastructure department's integration transformation of iOS projects with Cocoapods, and now it is also using this method for dependency integration.

This method is relatively mature and convenient in iOS, but it is also the one where we encounter more problems during integration, especially with custom pods repositories. The pods we rely on in our project are relatively complex and diverse, covering source code, framework, library, and swift dependencies.

As mentioned on the official website, AFNetworing can be easily added to KMM, but when using a self-built pods repository, some problems will occur. The basic steps here are the same as the official website, and you need to configure specRepos, pods, etc. in cocoapods. If it is a private pods library and has a dependent static library, the specific integration steps are as follows:

1) Add the relevant configuration of cocoapods as follows:

 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() }

Note that 1.7.20 has fixed the Link of the static library .

When the version is lower than 1.7.20, you will encounter the error that the framework cannot be found: ld: framework not found XXXFrameworkName

2) Add configuration when generating Def file for cocoapods.

When we determine which classes in pods need to be referenced, we need to configure them when the KMM plugin creates the def file. This step is actually the same as the process of creating the def file ourselves. Here, we just use pods to determine the def file, and finally use cinterop to convert the API.

The difference between this and ordinary def is that the creation of def is monitored, and the name and number of def are consistent with the pods in the previous configuration of cocoapods. This step mainly configures the referenced files and the location of the referenced files. If these settings are not available, if it is a pod for a static library, then no Class will be converted into klib here, and it cannot be called in the KMM project. The path of the referenced header file here can be configured based on the relative directory of 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() ) } } } }

(When configuring here, you need to pay attention to different versions of Android Studio, KMM plug-in, and IDEA. There are differences in the cocoapods subdirectories in the build. The lower version will have an extra moduleName directory level)

After configuring these, rebuild and check whether the relevant configuration is correct through the def file in build/cocoapods/defs.

3) After the build is successful, the corresponding klib will appear in the project's External Libraries, as follows:

Call the API code, the import package name is cocoapods.xxx.xxx , as follows:

 ``` kotlin import cocoapods.yytestpod.TTDemo class IosGreeting { fun calctTwoDate() { println("Test1:" + TTDemo.callTTDemoCategoryMethod()) } } ```

For pods configuration, please refer to my Demo . Pods and def methods can be mixed, but be careful about dependency conflicts.

2.5 Dependency Release

After resolving the existing dependencies above, you can directly call the dependency API. However, if multiple KMM projects need to use this dependency or make the code and configuration more concise, you can make the existing dependency into a separate KMM project. If you have your own Maven repository environment, you can publish the built klib product to your own Maven repository. KMM itself is a gradle project, so this is easy to do.

First, you only need to add the Maven repository configuration to the KMM project:

 publishing { repositories { maven { credentials { username = "username" password = "password" } url = uri("http://maven.xxx.com/aaa/yy") } } }

Then you can see the Publish item in Gradle's tasks, and execute the publish Task to publish it to the Maven repository.

When using dependencies, the configuration dependencies are the same as those of a general Kotlin project. (The klib released above needs to distinguish between iosX64 and iosArm64 instruction sets during configuration. If the distinction is not made, klib will be missing. In fact, when looking at the product comprehensive directory in Maven, klib is also missing.)

The configuration is as follows:

 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") } }

3. CI/CD environment construction and configuration of KMM project

When the previous process is completed, the corresponding Framework product can be obtained. If the relevant CI/CD process is not configured, the framework needs to be manually added to the iOS project locally. So we have made some CI/CD configurations here to simplify the Build, Test and Release integration operations here.

Here CI/CD is mainly divided into the following stages:

  • pre: Mainly do some environment check operations
  • build: execute the build of KMM project
  • test: execute UT in KMM project
  • upload: upload UT report (manual execution)
  • deploy: publish the final integration product (manual execution)

3.1 CI/CD environment construction

Since there is no macOS image server in the company at this stage, and the KMM project needs to rely on XCode, we temporarily use our own development machine to make gitlab-runner for CI/CD (the premise of using gitlab-runner is that the project is managed by gitlab). If it is a gitlab environment, there are runner installation steps in the repository's Setting-CI/CD.

Install:

 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

register:

 sudo gitlab-runner register --url http://xxx.com/ --registration-token xxx_token

Things to note during registration:

 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即可

Finally, a config.toml file will be generated in etc/gitlab-runner on disk. To identify gitlab, you need to copy the configuration in this file to .gitlab-runner/config.toml in the user directory. If it is used in multiple projects, just add it to the end, such as:

Finally, under Setting-CI/CD-Runners, you can see that the runner tag is active.

3.2 Stage: pre

Since we need some environmental dependencies here, I have done some checks on several environments. We have configured version checks on several dependencies. Of course, we can also add some verification steps to supplement the installation when it is installed.

3.3 Stage: build

In this stage, we mainly do build and copy the built products to a temporary directory for use in subsequent stages.

It should also be noted that the local.properties in the gradle project is generated locally and will not be stored in git, so we need to create a local.properties and set the Android SDK DIR. I use the shell file to do this.

 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

In this step, we will execute UT, including AndroidTest, CommonTest, and iOSTest, and finally copy the product after executing Test to the specified temporary directory for use in subsequent stages.

The specific script is as follows:

 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}

If we only have CommonTest and write UT in CommonMain, and do not use platform-related APIs, then this step is relatively easy, just execute ./gradlew shared:allTest. In ordinary iOS projects, if UT is needed, we only need to create a UT Target and add UTCase execution to easily achieve this.

But in reality, our KMM project already relies on the iOS platform and APIs in our own project. If we write some UTTestCase normally in iOSTest, it will not pass when we actually execute iOSX64Test, because it is not executed in the iOS system environment. So we need to fix this problem first.

Here we need to execute the TestCase in iOSTest within KMM. The official has not yet announced a solution, so we can only explore it ourselves.

I searched for a feasible solution , which is to let the Test Task rely on the iOS simulator to execute in the iOS environment, so that I can successfully implement the direct execution of iOSTest within KMM.

The official also considered UT execution, but there is no complete configuration method for iOSTest. Looking at the products in the build directory through the document, there is a test.kexe file that can execute UT in the build/bin/iosX64/debugTest directory. We use it to implement iOS UTCase inside KMM.

In addition to writing UTCase, of course, you also need an iOS simulator to fully execute UTCase with the help of the iOS system.

The solution steps are as follows:

1) Add a module to the same directory as the module of the KMM project shared code, and configure build.gradle.kts as follows:

 plugins { `kotlin-dsl` } repositories { jcenter() }

2) Add a subclass of DefaultTask, use TaskAction of Task to execute iOSTest, which can execute terminal commands internally, obtain simulator device information, and execute 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) Configure the above Task as the dependsOn item of check in the shared project as follows:

 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) ... }

If you need to execute it separately, you can configure it separately.

 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) }

As shown above, testExecutable and simulatorId in the gradle configuration are both passed from external sources.

testExecutable can be obtained from getTest in binaries, such as:

 val testBinary = targets.getByName<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget>("iosX64").binaries.getTest("DEBUG")

The simulatorId can be viewed using the following command.

 xcrun simctl list runtimes --json xcrun simctl list devices --json

In order to reduce manual searches and operations performed on other people's machines, we can use the same principle to add a Task to obtain the simulatorId available on the execution machine. For details, please refer to this file in my Demo.

Small problems encountered: If you execute directly, you will most likely encounter a problem where the default simulator is iPhone 12. You can specify the default simulator by using the deviceUDID output by SimulatorHelp above.

 val (deviceName,deviceUDID) = SimulatorHelp.getDeviceNameAndId() targets.withType(org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTargetWithSimulatorTests::class.java) { testRuns["test"].deviceId = deviceUDID }

After executing the iOSTest Task, you can see some case execution output in the build log.

3.5 Stage: upload

This step is mainly to upload the previous test products, and you can view the UT report online.

Here, we need to create an additional project to store the test report product and use gitlab-pages to view the UT test report. After executing stage:test, we have copied all the files under the test product reports to the temporary directory. In this step, we only need to upload the contents of the temporary directory to the testreport warehouse.

Here we have done the following operations:

1) First, open the testreport repository and configure it to gitlab-pages. The specific yaml configuration is as follows:

 pages: stage: build script: - yum -y install git - git status artifacts: paths: - public only: refs: - branches changes: - public/index.html tags: - official

2) When uploading files, use the current pipelineid as the folder directory name

3) Create an index.html file, the content of which is the index.html in the directory of each test report. After uploading a new test result each time, add a hyperlink pointing to the newly uploaded test report.

The first address of pages, the effect is as follows:

You can view the actual test results, as well as information such as execution time, by clicking the link.

3.6 Stage: deploy

In this step, we mainly upload the framework under fat-framework as a pods source code repository & push spec to the specrepo repository.

It mainly draws on the idea of ​​KMMBridge , but it is linked to GitHub in many places, so it is not suitable for company projects. If the project itself is on GitHub, you can also directly create a project using the kmmbridge template, which is also very convenient. For details, see the demo created by kmmbridge .

You need to create 2 warehouses:

  • The pods source code repository is used to manage each uploaded framework product and perform version control.

Initial pods can be created by yourself using the pod lib create command. Subsequent uploads only need to overwrite the shared.framework in s.vendored_frameworks. If there is a dependency on other pods, you need to add the s.dependency configuration.

  • Podspec repository, manages the version of spec in the pods source repository

The most critical thing is that the version of podspec cannot be repeated. Self-increment processing is required here, which mainly draws on the logic in KMMBridge. I use script processing here to finally modify the version in the .podspec file in podlib, and synchronously replace the framework under the pods reference, upload it, and then add it to the pods repository with the same tag as the version in podspec.

Published to a separate specrepo, deployment can be divided into the following steps:

  1. Pull the pods source code repository and replace the framework
  2. Modify the version field of the spec file in the pods source repository
  3. Submit the modified file and tag the pods repository to match the version in 2
  4. Push the .podspec file to spec-repo

Ctrip app uses its own internal packaging and publishing platform. We only need to submit the framework to the unified pods source code repository, and the other steps can be handled uniformly with the help of the internal packaging and publishing platform. The final deployment process can currently achieve the following effects:

IV. Solutions to common integration problems

4.1 Pods dependency is configured, but the framework cannot find symbols

When the dependent pods are static libraries (.framework/.a), the following error will be encountered when executing linkDebugTestIosX64.

This problem is also a connector problem. You need to add the relevant path of the framework. Pods depends on the Framework, and the required linkerOpts configuration is as follows:

 linkerOpts("-framework", "XXXFramework","-F${XXXFrameworkPath}")//.framework

Pods depends on Library, and linkerOpts is configured as follows:

(If .a itself starts with lib, you need to remove lib when configuring, such as libAAA.a, just configure -lAAA)

 linkerOpts("-L${LibraryPath}","-lXXXLibrary")//.a

4.2 Problem of not being able to find the Category in OC in iOSTest

Regardless of calling methods in the Category directly or indirectly, as long as there are OC Category methods in the call stack, UT will fail to pass. (This problem does not affect the build of fat-framework , and LinkiOSX64Test will also succeed, it only affects the pass rate of UTCase)

In fact, this problem can also be encountered in normal iOS projects. The root cause is related to the loading mechanism of OC Category . Category itself is based on the runtime mechanism. During the build, the methods in the category will not be added to the method list of the Class. If we need to support this call, then in the iOS project we only need to add -ObjC, -force_load xxx, -all_load configurations in Others Link Flags in Build Setting to inform the connector to load the OC Category together.

Similarly in KMM, we also need to configure this property, but there is no explicit setting for Others Link Flags here, and we need to add the linkerOpts configuration in the binaries of KotlinNativeTarget.

If you need to configure the entire iOS Target, you can configure this property in binaries.all as follows:

 kotlin { ... targets.withType<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget> { binaries.all { linkerOpts("-ObjC") } } ... }

If you only need to configure in Test, select the target of Test and set it as follows:

 binaries{ getTest(org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType.DEBUG).apply{ linkerOpts("-ObjC") } }

4.3 If the dependency contains Swift, ld: symbol(s) not found for architecture x86_64 appears

If the project that KMM depends on contains Swift-related references, according to normal configuration, you will encounter the problem of not being able to find the symbol table of Swift-related code, and a series of warnings will appear that the Swift library cannot be automatically linked. The details are as follows:

The main problem here is that the Swift library cannot be linked automatically. You need to manually configure the Swift dependency runpath to solve similar problems.

 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}") }

In addition to the shared code of the KMM logic layer mentioned above, Jetbrains is currently focusing on the development of Compose Multiplatform in terms of UI. Our team is already conducting research and exploration. We welcome interested students to join us and explore together. We believe that KMM will usher in its spring in the near future.

<<:  iOS 16.4 has a new hidden feature: iPhone can finally use Face ID to unlock apps!

>>:  Let’s talk about what is WebView2?

Recommend

315 is here! Don’t turn crisis PR into a PR crisis!

The annual 315 Gala is coming soon, and for many ...

Did Lenovo make money by selling 50 million mobile phones?

Yesterday, Lenovo announced its 2013-2014 fiscal y...

To tell a good brand story, you must learn the four golden principles

Why do brands need to tell stories? Stories are a...

What is the advertising alliance’s collection behavior?

Many webmasters and channels that do CPA advertis...

Holiday Tips: A summary of the first batch of user source methods for 24 apps!

Regarding the promotion of Apps, there are a lot ...

Shapers of Earth's history: Are meteorites "destroyers" or "midwives"?

Tuchong Creative In the long history of the Earth...

The "smallest sun of the year" appeared in the sky today, at 15:11!

At 15:11 on the 4th, the Earth reaches its orbita...

Essential tools for new media operations, save it!

If you want to really master new media, you must ...