干货 | 携程机票 App KMM 跨端生产实践
作者简介
禹昂,携程移动端资深工程师,Kotlin 中文社区核心成员,图书《Kotlin 编程实践》译者。
Derek,携程资深研发经理,专注于移动端开发,热衷于各种跨端技术的研究和实践。
一. 背景与选型
移动端跨平台技术自移动开发诞生以来一直是个热门话题,一是持续关注研发效率,降本提效;二是一套代码多端运行可以提升多端业务逻辑的一致性;三是跨端技术方案通常意味着更佳的高效运维和缺陷修复。
跨平台开发框架经过多年的发展,目前被行业采用率最广的应属 Facebook 的 React Native,而当前最被大家寄与厚望的则是 Google 的 Flutter。这两者虽然在设计及原理上区别很大,但设计思想上都是采用非原生开发语言在 Android 与 iOS 系统框架之上搭建的“阁楼”上运行,每个采用这些框架的 App 在打包时需要集成语言的 Runtime、框架的底层组件等许多重量级的包与库。并且 JavaScript 或 Dart 与原生开发语言(Java/Kotlin、Objective-C/Swift)之间的交互需要通过“桥接通讯”实现,导致每当需要系统框架层面的改动支持时,必须双方模块架构上共同协调处理。
作为移动端开发人员,我们希望找到一种性能与原生代码相媲美、与原生代码互操作能力强、开发思想与原生开发接近的跨平台开发框架。
JetBrains 提出了不同于 RN 与 Flutter 的跨端解决方案,即使用不同的编译器编译同一份代码生成各端的不同产物来达到跨平台的目的,这就是 Kotlin Multiplatform。Kotlin 依据其运行的平台不同拥有不同的名字,例如编译为 class 字节码运行于 JVM 及 Android 平台的称为 Kotlin/JVM,编译为原生二进制码无虚拟机环境直接运行于操作系统上的则称为 Kotlin/Native,此外还有 Kotlin/JS 等等(关于 Kotlin Multiplatform 的官方介绍请详见参考链接 1)。
Kotlin 在不同平台均可与该平台的原生开发语言直接相互调用,在 Android 平台 Kotlin 是官方支持的一等开发语言,与 Java 的互操作自不用说。而在 Kotlin/Native 中 Kotlin 也可以像与 Java 互操作般在 iOS 平台直接与 C 以及 Objective-C 代码互操作(函数、类、接口互相可见、基本类型与集合类型等可互相映射)。不过其他语言如 Swift 与 Kotlin/Native 的互操作能力较为受限,官方正逐步改进。
Kotlin 在移动端的跨平台框架子集叫做 Kotlin Multiplatform Mobile,简称为 KMM。KMM 的架构设计理念如下图所示:
开发人员编写的代码主要分为三个 source set(源集),其中与平台直接交互的代码位于以平台命名的 source set 中,例如在 Android source set 中的 Kotlin 代码可以调用 JDK、Android SDK、以及其他 Android/Java 开源库,而在 iOS source set 中的 Kotlin 代码则可以直接调用 iOS 平台支持的 Posix C API、Foundation、以及其他 C/Objective-C 开源库。两端通用代码则位于 Common source set。
整个工程的构建由 Gradle 驱动,在编译打包时,通过将 Common 与 Android 两个 source set 的 Kotlin 代码合并编译打包为 Android 平台产物(aar 文件)。而将 Common 与 iOS 两个 source set 的 Kotlin 代码合并编译打包为 iOS 平台的产物(framework 文件)。
与 RN 及 Flutter 等跨平台框架相比,KMM 的主要优势有:
1)移动端原生技术栈开发人员上手更快。
2)无额外的运行时环境,性能与原生代码基本持平。
3)可无缝对接现有原生基础库,基础架构改造成本较小。
4)可沿用现有的原生插件化、内存监控、崩溃/卡顿监控等基础技术,无需额外开发支持。
不过 KMM 是语言层面跨平台的技术与框架,且当前处于 alpha 阶段,所以仍有一些缺点,包括:
1)Kotlin/JVM 与 Kotlin/Native 的异步并发模型不同。
2)KMM 社区生态环境仍在建设中,没有成熟的 UI 框架,因此无法用于编写 UI。Kotlin 编译器仍然处于快速迭代升级阶段,因此元编程相关的 API 不稳定。
2020 年携程机票 Android 团队将核心业务的历史 Java 代码迁移至 Kotlin + Coroutines + Jetpack AAC 技术栈获得了不错的成效,详见《携程机票 Android Jetpack 与 Kotlin Coroutines 实践》。Kotlin、Coroutines、MVVM 等新型架构模式在 Android 平台经受住了千万量级访问量的生产考验,因此我们决定于 2021 年初开始尝试 KMM,将 Kotlin 的应用范围逐步扩大至 iOS 平台。
二. 总体设计与集成
由于 KMM 尚处于 alpha 阶段,初期主要定位是——实现业务逻辑代码的跨平台共享,包括:数据模型、网络请求、本地数据存储、业务逻辑处理。
如果要从零搭建一个 KMM 工程,IntelliJ IDEA 或 Android Studio 的 KMM 模版插件可以辅助创建,整体工程就是一个常规 Gradle 工程,内部包含两个 Gradle module 子模块,分别是 Android app 与 KMM module。Android app 通过工程依赖直接引用 KMM module,此外还包含一个 iOS Xcode 工程。
但我们的场景是在现有且彼此独立的携程 Android 与 iOS App工程中引入 KMM,所以我们需要将 KMM 作为一个独立子工程模块进行集成。携程的 Android 与 iOS App 工程结构大体相似,底层是公共基础团队负责的公共库及框架,上层是依赖公共框架层的各个业务团队的 bundle。KMM 作为一个独立的工程需要依赖基础库,且机票业务 bundle 依赖 KMM 跨端共享业务逻辑工程。
机票业务工程集合的 KMM、Android、iOS 三个子工程的简化版依赖关系如下图:
Android 工程依赖机票 KMM 工程,通过 Gradle 构建并发布至公司内部 Maven 源的 aar 文件;iOS 工程通过本地集成 KMM 构建生成的 framework(目前正在调研迁移至 CocoaPods 集成方案)。
我们希望复用并扩展之前 Android Jetpack AAC 的优化升级成果,因此业务代码架构继续使用 MVVM 模式,整体分为三部分:View、ViewModel、Model。KMM 目前尚缺成熟可靠的 UI 框架,UI 层暂且保留原生开发方式,由平台各自实现,Model 层与 ViewModel 层由 KMM 工程承载。
2.1 Android 集成
KMM Android 端集成非常简单,与普通的 Android 第三方库集成无异。使用 IntelliJ IDEA 或 Android Studio 的 KMM 插件创建的 KMM 工程默认生成 Android source set,Gradle Build Task 执行生成 AAR 文件。当然,如果想创建一个泛 JVM 平台共享库(不涉及调用任何 Android SDK 和第三方库 API),我们可以把 Android source set 修改为 JVM source set,Gradle Build Task 就会生成 JAR 文件。
无论是新建独立 KMM App工程,还是基于现有 App工程集成 KMM 模块,KMM 子工程模块生成的 AAR 或 JAR 文件产物,均可发布上传至指定的 Maven 源仓库,进行集中依赖管理。调用方通过 Gradle/Maven 的 api 或 implementation等语句添加依赖。这对于 Java/Kotlin 开发人员非常友好,没有增加额外学习认知成本。当然对于小型个人项目,也可以使用简单的 Local Module Project 本地直接依赖方式。
KMM Module 工程集成与常规 Android Libraray Module 工程集成一脉相承,整理实践过程中遇到的若干常见问题:
1)在设置 KMM 工程的 target Java 版本时,尽量与需要集成的主工程保持一致,否则 KMM 的 target Java 版本如果过高可能会导致主工程构建失败。
2)主工程在集成 KMM 工程之后,注意设置混淆策略,否则运行时容易触发 NoClassDefFoundError 异常。
3)在使用新版 Gradle 构建时注意正确设置 duplicates strategy,否则主工程可能会集成失败。
2.2 iOS 集成
iOS 集成相比 Android 稍显复杂。iOS 开发者需要首先学习 Gradle 配置以及 Intellij IDEA 或 Android Studio IDE的基础知识。
iOS 集成的两点关键:
1)配置 KMM 工程依赖所需的 Objective-C 工程,使得 Kotlin 代码可以访问调用 Objective-C 代码,正确编译打包。
2)配置 KMM 工程编译打包生成的产物导入至 Xcode 工程,使得 Objective-C 代码可以访问调用 Kotlin 代码。
Kotlin Native SDK 已经预先内置了 iOS 系统所有的 API,开发人员需要手工处理的是将 Kotlin 代码与自行编写的 Objective-C 代码或其他第三方库代码进行桥接。这部分工作并不复杂,因为 KMM 的最终产物文件都是 iOS 系统常规的 .framework/.a 文件,原理遵循 iOS 平台开发常识,学习曲线对于 iOS 开发人员较为友好。
这里仅列举 iOS 集成过程中的若干场景问题:
2.2.1 cinterop
官方提供的 cinterop 工具可以将指定的 C/Objective-C 库的所有公开 API 封装转译为 Kotlin API,生成 klib 文件格式,供 KMM 工程调用。处理之后,开发人员就可以在 KMM Project 的 iOS source set 代码中实现调用这些 API。
简述为,通过定义一个 def 描述文件,声明被依赖的 .h,.a 工程配置,并配置 Gradle 工程依赖。
def 文件示例:
language = Objective-C
headers = AAA.h
libraryPaths = /Users/xxx/extFramework
staticLibraries = FA.framework
compilerOpts = -I/Users/xxx/extFramework/FA.framework/Headers
Gradle iOS Target 配置示例:
target.compilations["main"].cinterops.create(name) {
defFile = project.file("src/nativeInterop/cinterop/xxx.def")
packageName = "xxx"
}
def 文件示例中 libraryPaths 和 compilerOpts 参数涉及到跨工程模块的文件路径引用,因此当大型项目多人协作和自动化构建集成时,需要定制适配引用路径。
基于 Git SubModule 特性,我们先把被依赖的 iOS 原生工程仓库设置为引用方 KMM 工程仓库的 SubModule,然后增加一个动态获取引用路径的自定义 Gradle Task,通过 Gradle API 获取绝对路径后,写入 def 文件,该 Task 的触发时机需要设置为 build task 运行之前。
2.2.2 双指令集合并问题
KMM Module 编译生成的 framework 文件最终是运行在真机设备上,即 arm64 格式,而开发阶段需要支持模拟器设备,即 x84_64 格式。官方版本(1.4.x)最初并未支持同时编译和运行 arm64 与 x86_64 两套指令集,只能手工切换,分别单独构建。官方版本1.5.21开始,KMM plugin 通过生成 fat-framework 的 Gradle task 解决指令集合并问题。
当 KMM Module 仅包含 Koltin 代码,或者所依赖的 iOS ObjC 库文件是单指令集格式时,官方 fat-framework 方案可以正确构建。但是当所依赖的 iOS ObjC 库文件是多指令集格式时,官方方案就会报错异常。因此我们屏蔽了官方方案 Task,使用自定义指令集合并 Task 实现。
屏蔽默认 fat-framework 配置如下:
gradle.taskGraph.whenReady {
tasks.forEach { task ->
if (task.name.endsWith("fat", true)) {
task.enabled = false
}
}
}
总结 iOS ObjC 原生库,KMM 库,桥接和双指令集的流程如图所示。
2.2.3 代码注释
KMM 低版本,Kotlin 代码文件的注释不能自动导出到 *.framework,无法在 Xcode IDE中查看。Kotlin 1.5.20 起,官方已经支持注释导出,配置示例:
targets.withType<KotlinNativeTarget> {
compilations["main"].kotlinOptions.freeCompilerArgs += "-Xexport-kdoc"
}
2.3 基础框架的 KMM 化搭建
在编写业务代码之前,KMM 工程需要一些底层基础框架的支持。我们首先选择了两个官方库:kotlinx.coroutines 与 kotlinx.serialization,当前 Kotlin 生态中的绝大部分第三方库都只能支持 Kotlin/JVM,能用于 KMM 的极少。而这两者是目前为数不多可用的 Kotlin 多平台库。kotlinx.coroutines 我们选用了 multi-thread 分支版本而不是默认主线版本,原因是主线版本在 native target 下是单线程实现,即所有异步协程任务均运行在主线程中,而我们希望其真正运行在多线程环境,避免对 UI 主线程造成影响。
kotlinx.serialization 包含两部分,分别是 kotlinx.serialization-json 与 kotlinx.serialization-protobuf,其中 kotlinx.serialization-json 已经是 release 状态,是目前极少数能用于 KMM 的 JSON 序列化库,但 kotlinx.serialization-protobuf 目前还处于 beta 阶段,使用时需加强自动化测试场景覆盖,性能评测,以及线上监控。
携程 App 包含公共框架团队提供的众多自研框架、协议,例如:网络服务、ABTest、增量配置读取、埋点上报系统、日期时间系统、用户账户系统等等。这些基础库通常是由 Android 与 iOS 两端分别实现,编程语言不同,但 API 的设计、命名、参数数量与类型定义都高度相似。我们需要将这些已有的基础库通过桥接、封装后包装出 KMM API,提供给 Kotlin Common source set 调用,而这些库本身的相似设计给我们提供了极大的封装便利。
目前携程 App 中采用腾讯微信团队开源的 MMKV(详见参考链接 2)用于本地键值对存储,它使用 C++ 编写核心代码,并分别提供 Java 与 Objective-C 等多种语言的上层 API,携程的公共基础团队基于 MMKV 原本的 API 又进行了一层封装,可以使业务团队无缝的从 SharedPreference 与 NSUserDefaults 迁移至 MMKV,不过由于要兼容旧代码导致两端的 API 设计有所不同。机票 KMM 工程作为一个无需兼容旧代码的新工程,决定直接封装 MMKV API 来作为工程的底层存储框架,这里作为一个简单的 demo 来说明如何桥接封装现有的 Android、iOS 库。
我们先在 common source set 中定义抽象的 MMKV 类型:
expect class MMKV
当然它是待实现的,我们希望它在 Android 平台直接表示 Java 的 MMKV 类型,在 iOS 平台直接表示 Objective-C 的 MMKV 类型。
在 Android 平台如下:
actual typealias MMKV = com.tencent.mmkv.MMKV
直接使用类型别名即可桥接,无论是在编译期还是运行时,它们都是同一种类型。
在 iOS 平台如下:
actual typealias MMKV = xxx.xxx.ios.MMKV
iOS 上没有包名的概念,xxx.xxx.ios 是使用 cinterop 等工具生成 Kotlin wrapper 时自定义的包名。
接着使用一些顶层函数来桥接 MMKV 的静态函数,用扩展函数来桥接 MMKV 在不同平台的成员函数,Android 如下:
internal actual fun defaultMMKV(): MMKV = MMKV.defaultMMKV()
internal actual fun getMMKVByDomain(domain: String): MMKV = MMKV.mmkvWithID(domain)
internal actual fun MMKV.closeMMKV() = close()
internal actual fun MMKV.set(key: String, value: String): Boolean = encode(key, value)
internal actual fun MMKV.set(key: String, value: Boolean): Boolean = encode(key, value)
// ......
internal actual fun MMKV.takeString(key: String, default: String): String = decodeString(key, default) ?: default
internal actual fun MMKV.takeBoolean(key: String, default: Boolean): Boolean = decodeBool(key ,default)
// ......
iOS 如下:
internal actual fun defaultMMKV(): MMKV = MMKV.defaultMMKV()!!
internal actual fun getMMKVByDomain(domain: String): MMKV = MMKV.mmkvWithID(domain)!!
internal actual fun MMKV.closeMMKV() = close()
internal actual fun MMKV.set(key: String, value: String): Boolean = setString(value, key)
internal actual fun MMKV.set(key: String, value: Boolean): Boolean = setBool(value, key)
// ......
internal actual fun MMKV.takeString(key: String, default: String): String = getStringForKey(key, default) ?: default
internal actual fun MMKV.takeBoolean(key: String, default: Boolean): Boolean = getBoolForKey(key, default)
// ......
封装桥接的基础理念是,在 common source set 中定义它的抽象,然后在平台相关的 source set 中编写实现直接调用需要被桥接的库函数。我们可以看到,Android 与 iOS 两个版本的 MMKV 的部分 API 命名是有区别的,例如在 Android 中 set 一个值,函数的命名是 encode,而在 iOS 中则是 setXXX, 且在 Android 中参数通常是 key 在前 value 在后,而 iOS 的习惯则是 value 在前 key 在后,但它们的设计没有根本性的区别,小差异基本都可以在我们的封装中抹平,从而在 common source set 提供统一的 API。
上面关于 MMKV 的封装是一种常规且较为简单的封装,在我们的实际工作内容中,对网络框架的封装与改造值得一提。
携程自研的网络框架并非标准的 HTTP 协议,底层有大量定制的协议等内容。框架上层分别以 Java 以及 Objective-C 实现,不仅仅包含网络请求本身,还封装了对包括 Protobuf2 在内的各类数据的序列化与反序列化代码。原网络框架的设计对于业务团队的使用十分便捷,请求时只需要将 request entity 以及 response entity 类的 class 对象(Java 与 Objective-C 都有 class 对象)作为参数传入,然后在回调中拿到 response entity 即可处理网络返回结果。
由于框架是根据 class 对象来生成 Java 对象或 Objective-C 对象,而在 KMM 工程中我们无法拿到 Kotlin 类的 class 对象(问题的根源将在3.3 小节讨论),因此当前的网络框架无法支持生成 Kotlin 定义的 request 或 response entity。我们将原有的网络框架做微小的改动,提供一个不进行序列化与反序列化的选项,框架用户可直接将序列化好的 request entity 二进制数据传递给框架,而框架也会将反序列化前的 response entity 二进制数据返回给框架用户,这样我们就可以在 KMM 工程内使用 kotlinx.serialization 进行序列化或反序列化。此外 Kotlin 中表示二进制数据的 ByteArray 与 Java 中的 byte[] 是完全等价的,但与 Objective-C 的 NSData不兼容,在 iOS 端的处理上还需要对 ByteArray 与 NSData 通过手动声明内存区域进行互相转换。
KMM 的网络框架设计如图下图所示:
// 原 Java API
public static String sendJavaRequest(BaseRequestEntity requestEntity, NetworkCallback callback) {
// ......
}
// Kotlin 封装
suspend fun sendRequest(requestEntity: BaseRequestEntity): BaseResponseEntity = suspendCancellableCoroutine { continuation -> // 调用库函数,挂起协程
val requestID = NetworkUtil.sendSOTPRequest(requestEntity) { baseResponseEntity, error ->
if (baseResponseEntity?.isSuccess == true && error == null) {
// 请求成功,恢复协程并将结果带回
continuation.resume(baseResponseEntity)
} else {
// 请求失败,以异常的方式恢复协程并将异常带回
continuation.resumeWithException(NetworkException(error.message))
}
}
// 取消逻辑
continuation.invokeOnCancellation {
NetworkUtil.cancelTask(requestID)
}
}
2.4 业务 Model 模块
data class CityModel(
val cityName: String,
val cityId: Int,
)
object CityManager {
// 获取所有城市
suspend fun getAllCity(): Map<String, CityModel> {
// ......
}
// 查询单个城市
suspend fun queryCity(cityId: Int): CityModel {
// ......
}
}
2.5 跨端的架构模式组件尝试——MVIKotlin
1)已经进入 release 版本,对于 KMM 社区中各色 dev、alpha、beta 的版本号来说,已经 release 的开源库颇为难得。
2)计相对完善,MVIKotlin 还提供了针对 Reaktive 与 Coroutines 的绑定。
三. 挑战与对策
3.1 Kotlin/JVM 与 Kotlin/Native 异步并发模型不兼容
1)每个对象都与其诞生时所在的线程绑定,一旦在其他线程访问该对象,即监测到该对象的对象子图中记录的线程 id 与当前线程不一致,程序立刻 crash。
2)要在多线程中访问同一个对象,只能将该对象做对象子图分离与重新绑定。
3)冻结对象,冻结对象可以在任意线程访问,但冻结对象不可进行“写”操作,一但进行“写”操作立刻 crash,且冻结对象不可解冻。
1)协程在 Kotlin/Native 上没有调度器 Dispatchers.IO。
2)协程调度器 Dispatchers.Default 在 Kotlin/JVM 上是线程池实现,而在 Kotlin/Native 上是单后台线程实现(multi-thread 版本)。
3)我们在 Kotlin/Native 上也无法自己编写基于池化技术的协程调度器,因为它可能会因为挂起时与恢复时所在线程不同而 crash。
4)此前协程挂起锁 Mutex 在 Kotlin/Native 上有 bug,无法正常生效(kotlinx.coroutines 1.4.2 版本后已修复)。
// 无参数版本
@OptIn(ExperimentalUnsignedTypes::class)
actual suspend inline fun <reified R> calculateOnBackground(crossinline block: () -> R): R = suspendCoroutine { continuation ->
val queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND.toLong() ,0)
val continuationGraph = continuation.wrapToDetachedObjectGraph()
dispatch_async(queue, { // 该 lambda 运行在子线程
val resultGraph = block().wrapToDetachedObjectGraph()
val tempContinuationGraph = continuationGraph.attach().wrapToDetachedObjectGraph()
dispatch_async(dispatch_get_main_queue(), { // 该 lambda 运行在主线程,用于在主线程内调用 resume 函数恢复协程,避免 IncorrectDereferenceException
tempContinuationGraph.attach().resume(resultGraph.attach())
}.freeze())
}.freeze())
}
// 单参数版本
actual suspend inline fun
<reified P0, reified R>
calculateOnBackground(p0: P0, crossinline block: (P0) -> R): R {
val p0Graph = p0.wrapToDetachedObjectGraph()
return calculateOnBackground {
block(p0Graph.attach())
}
}
// 更多参数版本依此类推……
1)传统的移动开发人员一时间无法适应。
2)Kotlin 并非纯函数式编程语言,完全抛弃可变状态将导致编程风格非常别扭,且不适用于 UI 编程。
3)与 Kotlin/JVM 差异过大,导致代码复用受阻。
3.2 Kotlin/Native 调用非虚函数使用静态分派
fun main() {
val data = Data<A>(B())
val a = data.getSomething()
a?.print()
}
interface Base {
fun print()
}
class A : Base {
override fun print() = println("123")
}
class B : Base {
override fun print() = println("456")
}
class Data<T : Base>(val b: Base) {
fun getSomething(): T? = b as T
}
3.3 Kotlin 类的根级超类与 Objective-C 类的根级超类不兼容
open class KotlinBase : NSObject {
open class func initialize()
}
3.4 Kotlin/Native object 定义的作用域内的隐式可变状态会在运行时抛出 InvalidMutabilityException
object MyObject {
var index = 0
}
object MyObject {
val hashMap = HashMap<String, String>()
}
3.5 协程异常处理器抛出 NoClassDefFoundError
四. 生态环境
五. 参考链接
【1】Kotlin 多平台官方介绍
https://kotlinlang.org/docs/mpp-intro.html
【2】MMKV
https://github.com/Tencent/MMKV
【3】MVIKotlin
https://arkivanov.github.io/MVIKotlin/
【4】《KMM 求生日记二:跨端的 MVI 框架 —— MVIKotlin》
https://juejin.cn/user/3844312374718221
【5】《Kotlin/Native 异步并发模型初探》
https://mp.weixin.qq.com/s/JDYixvkoaJLBac6CaEw09Q
【6】Kotlin/Native 非虚函数静态分派调用的 bug 在 YouTrack 上的讨论(KT-42903)
https://youtrack.jetbrains.com/issue/KT-42903
【7】YouTrack 上关于协程异常处理器 NoClassDefFoundError 的报障
https://youtrack.jetbrains.com/issue/IDEA-277886
【8】Github kotlinx.coroutines 仓库关于 NoClassDefFoundError 的 issues
https://github.com/Kotlin/kotlinx.coroutines/issues/1300
【9】JetBrains 官方博客《Try the New Kotlin/Native Memory Manager Development Preview》
【10】Kotlin Roadmap
https://kotlinlang.org/docs/roadmap.html
【11】SQLDelight
https://cashapp.github.io/sqldelight/
【12】workflow-kotlin
https://github.com/square/workflow-kotlin
团队招聘信息
我们是携程机票研发团队,负责携程APP/PC端机票业务开发及创新。机票研发在搜索引擎、数据库、深度学习、高并发等方向持续不断地深入探索,持续优化用户体验,提高效率。
在机票研发,你可以和众多技术顶尖大牛一起,真实的让亿万用户享受你的产品和代码,提升全球旅行者的出行体验和幸福指数。
如果你热爱技术,并渴望不断成长,携程机票研发团队期待与你一起腾飞。目前我们前端/后台/数据/测试开发等领域均有开放职位。
简历投递邮箱:tech@trip.com,邮件标题:【姓名】-【携程机票】-【投递职位】。
【推荐阅读】
“携程技术”公众号
分享,交流,成长