天天看點

攜程機票App KMM iOS工程配置實踐

前言

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 隻依賴系統架構(項目剛起步、開發完全獨立的架構)

攜程機票App KMM iOS工程配置實踐

按照官方的介紹,直接進行邏輯開發,依賴于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)

攜程機票App KMM iOS工程配置實踐

此種情況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

攜程機票App KMM iOS工程配置實踐

此種情況方法和上述類似,同樣需要依賴建立一個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內建改造,現在也是用此種方式進行的依賴內建

攜程機票App KMM iOS工程配置實踐

這種方式在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相關的配置是否正确。

攜程機票App KMM iOS工程配置實踐

3)build成功後,項目的External Libraries中就會出現對應的klib,如下:

攜程機票App KMM iOS工程配置實踐

調用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倉庫。

攜程機票App KMM iOS工程配置實踐

使用依賴時,這裡和一般的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: 釋出最終的內建産物(手動執行)
攜程機票App KMM iOS工程配置實踐

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中,如多個工程中用到直接添加到末尾即可,如:

攜程機票App KMM iOS工程配置實踐

最終在Setting-CI/CD-Runners下能看到runner得tag為active即可。

攜程機票App KMM iOS工程配置實踐

3.2 Stage:pre

這裡由于我們需要一些環境的依賴,是以我這裡做了一下幾個環境的check,我們配置了對幾個依賴項的版本check,當然這裡也可以增加一些校驗為安裝的情況下補充安裝的步驟等。

攜程機票App KMM iOS工程配置實踐

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的執行輸出。

攜程機票App KMM iOS工程配置實踐

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的首位址,效果如下:

攜程機票App KMM iOS工程配置實踐

通過連結即可檢視實際測試結果,以及執行時間等資訊。

攜程機票App KMM iOS工程配置實踐
攜程機票App KMM iOS工程配置實踐
攜程機票App KMM iOS工程配置實踐

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流程目前可以做到如下效果:

攜程機票App KMM iOS工程配置實踐

四、常見內建問題的解決方法

4.1 配置了pods依賴,但是出現framework無法找到符号的問題

當依賴的pods中為靜态庫(.framework/.a)時,執行linkDebugTestIosX64時會遇到如下錯誤。

攜程機票App KMM iOS工程配置實踐

這個問題也是連接配接器的問題,需要增加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。具體如下:

攜程機票App KMM iOS工程配置實踐

這裡主要是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的春天。

【作者簡介】

Derek,攜程資深研發經理,關注Native技術、跨平台領域。

繼續閱讀