成人免费xxxxx在线视频软件_久久精品久久久_亚洲国产精品久久久_天天色天天色_亚洲人成一区_欧美一级欧美三级在线观看

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

移動開發 新聞
本文適合于對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的春天。

責任編輯:張燕妮 來源: 攜程技術
相關推薦

2022-05-13 09:27:55

Widget機票業務App

2022-06-17 09:42:20

開源MMKV攜程機票

2022-06-03 09:21:47

Svelte前端攜程

2020-12-04 14:32:33

AndroidJetpackKotlin

2017-04-11 15:11:52

ABtestABT變量法

2022-06-10 08:35:06

項目數據庫攜程機票

2025-06-24 09:51:47

2023-11-13 11:27:58

攜程可視化

2023-08-25 09:51:21

前端開發

2022-08-06 08:27:41

Trace系統機票前臺微服務架構

2025-06-24 09:44:41

2017-04-11 15:34:41

機票前臺埋點

2022-07-15 12:58:02

鴻蒙攜程華為

2017-03-15 17:38:19

互聯網

2023-02-08 16:34:05

數據庫工具

2022-08-12 08:34:32

攜程數據庫上云

2022-07-08 09:38:27

攜程酒店Flutter技術跨平臺整合

2022-07-15 09:20:17

性能優化方案

2022-09-09 15:49:03

攜程火車票組件化管理優化

2022-06-03 08:58:24

APP攜程流暢度
點贊
收藏

51CTO技術棧公眾號

主站蜘蛛池模板: 天天操操| 中文字幕亚洲一区 | 婷婷精品 | 天天人人精品 | 欧美精品一区二区三区四区 在线 | av在线电影网站 | 久久久久成人精品免费播放动漫 | 黄色在线免费网站 | 91精品国产综合久久久久 | 日本三级网站在线观看 | 二区国产| 91精品国产乱码久久久 | 亚洲国产高清免费 | 91久久久久久 | 欧美高清视频在线观看 | 午夜精品久久久久久久久久久久久 | 午夜精品久久久久久久久久久久久 | 午夜91| 国产成人精品一区二区三区 | 91小视频| 成人一区二区在线 | 国产精品久久久久久二区 | 国产一区二区在线免费视频 | 中文字幕av第一页 | 国产乱码精品一区二区三区中文 | 成人欧美一区二区三区 | 成年人黄色免费视频 | 久久久日韩精品一区二区三区 | 亚洲精品一区久久久久久 | 亚洲精品在线免费播放 | 成av在线 | 日本国产精品视频 | 我要看黄色录像一级片 | 国产aⅴ精品 | 欧美日韩精品免费观看 | 亚洲欧美一区二区三区国产精品 | 亚洲精品天堂 | 99久久日韩精品免费热麻豆美女 | 精品福利av导航 | 97精品超碰一区二区三区 | 亚洲www啪成人一区二区 |