携程机票Android Jetpack与Kotlin Coroutines实践

开发 前端
Kotlin 协程很强大,是一个雄心勃勃的项目,它为许多 Java 开发者带来了新的概念以及老问题的新解决方案。虽然它已经进入 release 阶段达一年半之久,但从我们的实践结果来看,其稳定性仍然还有提升的空间。

一、前言

1.1 技术背景与选型

自 2017年 Google IO 大会以来,经过三年的发展,Kotlin 已成为 Android 平台无争议的首选开发语言。但是相比语言本身,Kotlin 1.2 版本后进入 stable 状态的协程(coroutines)的行业采用率仍然较低。

协程的优势主要有:

  • 更简单的异步并发实现方式(近似于同步写法)
  • 更便捷的任务管理
  • 更便捷的生产者-消费者模式实现
  • 更高效的 cold stream 实现(即 Flow,根据官方数据,Flow 在部分 benchmarks 场景下效率是 RxJava 的两倍,详见参考链接 1)。

Google Android 团队同时也在大力推广 Jetpack 组件库,其中 AAC 架构组件带来了全新的应用架构实现方式,可以更便捷的实现 MVVM 这一非常适用于复杂业务场景的设计模式。

1.2 业务背景

今年接到一个大需求,产品方向上希望尝试一种交通类业务融合的平台化搜索首页新体验。于是各业务研发团队经过几轮技术评估,决定联合启动开发这个新项目。借此机会,机票 App 团队决定基于 Android Jetpack AAC 组件库和 Kotlin Coroutines 技术方案进行重构实现。

机票首页的业务逻辑可以归纳抽象为以下两种场景:

  • 多个不同 View,依赖同一个数据源的变化。
  • 多个不同 View,当用户操作时,都会触发同一数据源的变更。

针对这两个场景,基于 ViewModel、LiveData 实现的 MVVM 模式非常契合,可以做到业务逻辑清晰且代码耦合度低。ViewModel 表示一个业务模块相关数据状态的总集,同时向 View 暴露诸多数据状态需要响应 View 的操作时调用的接口。而从属于 ViewModel 下的 LiveData 则表示各个数据状态本身,并提供给 View 订阅。

在代码实现中,我们在多个 View 中可以使用相同的 ViewModelStoreOwner(一般是 Fragment 或 Activity)获取到同一个 ViewModel 对象,只要多个 View 订阅同一个 ViewModel 中相同的 LiveData,并在数据状态需要响应 UI操作而更新的时候调用 ViewModel 中的同一个函数,即可清晰简洁的应对这两种场景。

同时复盘当前机票首页的代码历史债:

  • 代码冗长,没有合理的封装、拆分以及架构模式,单文件代码行数高。
  • 复杂的异步操作导致回调代码层层嵌套。
  • 不恰当的线程池配置。
  • 重复多余的 null 检查与可能暗藏的 null 安全问题。
  • 过多的 UI 层级嵌套,代码冗杂且性能不高。
  • 仍在使用一些 Google 官方淘汰的旧技术,没有及时跟进新技术。

通过合理的封装、拆分以及使用 ViewModel 与 LiveData 可以方便的解决问题 1;

Kotlin 自身的空安全特性解决了问题 4;

问题 5 与 6 主要通过合理的重构以及使用 ConstraintLayout 等新技术来解决,但不在本文的讨论范围。

那么问题 2 与 3 的解决,就需要 Kotlin 协程出场了。

二. 热身准备

2.1 抛砖引玉

在具体讲解实现之前,先通过一个小例子抛砖引玉,来说明一个小问题。

如果我们在一个 Fragment 中或 Activity 中要获取一个 ViewModel,然后订阅它内部的 LiveData,如果直接使用官方的 API 通常是这样的: 

  1. private lateinit var myViewModel: MyViewModel 
  2.  
  3.   ...... 
  4.    
  5.   myViewModel = ViewModelProvider(this)[MyViewModel::class.java] 
  6.   myViewModel.liveData1.observer(this, Observe { 
  7.     doSomething1(it) 
  8.   }) 
  9.   myViewModel.liveData2.observer(this, Observe { 
  10.     doSomething2(it) 
  11.   }) 
  12.  
  13.   ...... 

由于 Kotlin 的 lambda 表达式与操作符重载,这段代码已经比对应的 Java 代码简洁多了,但是这段代码仍然不够 Kotlin style,我们稍微封装一下,定义两个新函数: 

  1. // 顶层函数版本 
  2. inline fun <reified T : ViewModel> getViewModel(owner: ViewModelStoreOwner, configLiveData: T.() -> Unit = {}): T = 
  3.         ViewModelProvider(owner)[T::class.java].apply { configLiveData() } 
  4.  
  5. // 扩展函数版本 
  6. inline fun <reified T : ViewModel> ViewModelStoreOwner.getSelfViewModel(configLiveData: T.() -> Unit = {}): T = 
  7.         getViewModel(this, configLiveData) 

为了不同的使用场景并且方便不同人的使用习惯,这里同时写了顶层函数版本与扩展函数版本,但是功能一模一样(扩展函数版本直接调用了顶层函数版本)。现在如果我们要在 Fragment 中获取 ViewModel,看看会变成什么样(这里使用扩展函数版本): 

  1. private lateinit var myViewModel: MyViewModel 
  2.  
  3. ...... 
  4.  
  5. myViewModel = getSelfViewModel { 
  6.     liveData1.observe(this@MyFragment, Observer { 
  7.         doSomething1(it) 
  8.     }) 
  9.     liveData2.observe(this@MyFragment, Observer { 
  10.         doSomething2(it) 
  11.     }) 
  12.     ...... 

这样封装的好处绝不仅仅在于让代码看起来“DSL”化。首先,内联的泛型实化函数让我们避免去编写 xxx::class.java 这样的样板式代码,而是只需要传一个泛型参数(在这个例子中由于 lateinit 属性已经声明了类型,所以根据类型推导,我们连泛型参数都不必显式写出),这样看起来会优雅的多。其次,我们配合使用了带接受者的 lambda 表达式与作用域函数 apply 使我们在获取 ViewModel 内的 LiveData 对象的时候不再需要重复写多次 myViewModel. 这样的样板代码。

最后从代码结构来看,我们通常在获取到 ViewModel 对象后会直接订阅所有需要订阅的 LiveData,我们把所有的订阅逻辑都写到了 getSelfViewModel 函数的 lambda 表达式参数的作用域内,这样我们对订阅的代码可以更加一目了然。

这里只是个抛砖引玉,在我们决定要开始使用 Kotlin 来替换 Java 的时候,最好能先打牢 Kotlin 基础,这样我们才能发挥这门语言的最大潜力。从而避免使用 Kotlin 写出 Java 风格的代码。

2.2 代码角色划分

如果把当前的代码按职责进行划分,大概有以下几种:数据类(data class,类似于 Java Bean)、工具函数(例如格式化一个日期,将其转换为可展示的字符串)、数据源(例如从网络拉取数据或从本地数据库读取数据)、核心业务逻辑(在拿到原始数据后我们可能要对它根据业务需求进行处理)、UI代码(无须多言)、状态信息(通常是一些用于表示状态的可变对象等等或者数据的当前状态)。

我们要将以上这几种代码划分为三个角色,或者划归到三个范围内,即:View、ViewModel、Model,也就是 MVVM 模式中三大角色。UI 代码划归到 View;数据类、数据源划规到 Model;而数据状态或其他状态信息划归到 ViewModel。而工具函数视情况而定,可以作为独立组件也可以放到 Model 中。

三、正式实现

3.1 协程 Channel 与 LiveData 组合实现的基本模式

在 MVVM 模式中,VM 即 ViewModel 表示数据状态。为了让业务逻辑和代码结构更加合理。我们通常将一些彼此依赖对方状态的数据(通常其表示的业务也是强相关的)拆分到同一个 ViewModel 中。而 LiveData (通常位于 ViewModel 内部)表示的是某些具体的数据状态。例如在携程机票首页的业务中,出发城市的相关数据就可以用一个 LiveData 来表示,到达城市则用另一个 LiveData 来表示,而这两个 LiveData 都位于同一个 ViewModel 中。

如果不使用 livedata-ktx 包,我们创建 LiveData 对象的方式主要是通过调用 MutableLiveData 类的构造方法,我们通过直接使用 MutableLiveData 对象来进行订阅、数据更新等操作。MutableLiveData 与普通对象一样,我们可以在任意一种异步框架下使用它。

但为了与 Kotlin 协程有更完美的配合,livedata-ktx 包提供给我们了另一种方式来创建 LiveData,即 liveData {} 函数,该函数的函数签名是这样的: 

  1. fun <T> liveData( 
  2.     context: CoroutineContext = EmptyCoroutineContext, 
  3.     timeoutInMs: Long = DEFAULT_TIMEOUT, 
  4.     @BuilderInference block: suspend LiveDataScope<T>.() -> Unit 
  5. ): LiveData<T> 

先看第三个参数 block,它是一个 suspend lambda 表达式,也就是说,它运行在协程中。第一个参数 context 通常用于指定这个协程执行的调度器,而 timeoutInMs 用于指定超时时间,当这个 LiveData 没有活跃的观察者的时候,时间如果超过超时时间,该协程就会被取消。由于第一和第二个参数都有默认值,所以大多数情况下,我们只需要传第三个参数。

liveData {} 函数在官方文档中并没有给出用例,所以并没有一个所谓标准的“官方”用法。我们观察了一下发现,block 块是一个带接收者的 lambda,而接收者类型是 LiveDataScope,且 LiveDataScope 有一个成员函数 emit,这就和 RxJava 的 create 操作符非常相似,更和 Flow 中的 flow {} 函数如出一辙。所以,如果要让我们的 LiveData 作为一个可持续发射数据的数据源,liveData {} 函数启动的这个协程需要不停的从外部取数据,这种场景正是协程中 Channel (参考链接2)的用武之地,我们用上述的技术编写一个简单的 ViewModel: 

  1. class CityViewModel : ViewModel() { 
  2.      
  3.     private val departCityTextChannel = Channel<String>(1) 
  4.     val departCityTextLiveData = liveData { 
  5.         for (result in departCityTextChannel) 
  6.             emit(result) 
  7.     } 
  8.    
  9.     // 外部的 UI 通过调用该方法来更新数据 
  10.     fun updateCityUI() = viewModelScope.launch(Dispatchers.IO) { 
  11.         val result = fetchData() // 拉取数据 
  12.         departCityTextChannel.send(result) 
  13.     } 

首先我们声明并初始化了一个 Channel ——departCityTextChannel。然后我们使用 liveData {} 函数创建了LiveData 对象,在 liveData {} 函数启动的协程内,我们通过无限循环不停的从 departCityTextChannel 中取数据,如果取不到,这个协程就会被挂起,直到有数据到来(这比用 Java 线程加 BlockQueue 实现的类似的生产者消费者模式要高效很多)。for 循环对 Channel 有一等的支持。

如果 UI 要更新数据,会调用 updateCityUI() 函数,该函数内的所有操作(通常都是耗时的)在其启动的协程内异步进行。在这里我们通过 viewmodel-ktx 包提供的 viewModelScope 来启动协程,这个协程作用域的实现与 ViewModel 的实现相结合,可以通过 ViewModel 感知到外部 UI 组件的生命周期,从而帮助我们自动取消任务。

最后注意一点,我们在初始化 departCityTextChannel 时给工厂函数 Channel(1)传入的缓冲区 size 的大小是 1。这主要是为了我们可以避免生产者协程在等待消费者从 Channel中取走数据时发生事实上的挂起,从而在一定程度上影响效率。当然如果有生产者生产的速度过快,而消费者消费的速度过慢而明显跟不上的时候,我们可以适当调大 size 的值。

我们的每个 LiveData 几乎都需要与其配合使用的 Channel,而且 liveData {} 函数做的事情也几乎都是一样的,即使用 for 循环从 Channel 拿到数据然后再使用 emit 函数发射出去。于是可以进行如下的封装: 

  1. inline val <T> Channel<T>.coroutineLiveData: LiveData<T> 
  2.     get() = liveData { 
  3.         for (entry in this@coroutineLiveData) 
  4.             emit(entry) 
  5.     } 

ViewModel 内创建 departCityTextChannel 与 departCityTextLiveData 对象的代码就变成了这样: 

  1. class CityViewModel : ViewModel() { 
  2.      
  3.     private val departCityTextChannel = Channel<String>(1) 
  4.     val departCityTextLiveData = departCityTextChannel.coroutineLiveData 
  5.      
  6.     ...... 省略其他代码 

我们封装了一个名为 coroutineLiveData 的内联扩展属性,它的 getter 已经将 LiveData 的创建逻辑封装好了,不过请注意,每次调用这个属性,实际上都返回了一个新的 LiveData 对象,所以正确的做法是在调用 coroutineLiveData 属性后,把它的结果保存下来,以此达到重复使用的目的,千万不要每次都使用 departCityTextChannel.coroutineLiveData 这样的方式来期望获取到同一个 LiveData 对象。当然,如果你觉得这样也许会有误导,也可以把 coroutineLiveData 属性改成扩展函数。

3.2 UI 代码订阅 LiveData

虽然整个机票首页的 UI 都位于一个 Fragment 内,但业务之间不相关的 UI 我们可以分别单独封装成不同的 View。假如说跟城市有关的 UI,我们可能就会像下面这样做: 

  1. class CityView : LinearLayout { 
  2.      
  3.     constructor(context: Context) : super(context) 
  4.     constructor(context: Context, attributeSet: AttributeSet) : super(context, attributeSet) 
  5.     constructor(context: Context, attributeSet: AttributeSet, defStyleAttr: Int) : super(context, attributeSet, defStyleAttr) 
  6.      
  7.     private val tvCity: TextView 
  8.      
  9.     // ...... 省略更多的 View 声明 
  10.     init { 
  11.         LayoutInflater.from(context).inflate(R.layout.flight_inquire_main_view, this).apply { 
  12.             tvCIty = findViewById(R.id.tv_city) 
  13.              // ...... 省略更多的 View 初始化 
  14.         } 
  15.     } 

如果在 Fragment 或 Activity 中,获取 ViewModel 并订阅 LiveData 很容易,我们只需要把它们自身使用 this 传入即可。但是在 View 中获取不到 Fragment 对象,所以我们不得已必须要定义一个 initObserve 函数,通过将其暴露给 Fragment 调用来将 Fragment 自身的引用传入,于是 View 的代码就变成了如下这样: 

  1. class CityView : LinearLayout { 
  2.      
  3.     constructor(context: Context) : super(context) 
  4.     constructor(context: Context, attributeSet: AttributeSet) : super(context, attributeSet) 
  5.     constructor(context: Context, attributeSet: AttributeSet, defStyleAttr: Int) : super(context, attributeSet, defStyleAttr) 
  6.      
  7.     private val tvCity: TextView 
  8.      
  9.     // ...... 省略更多的 View 声明 
  10.      
  11.     private lateinit var cityViewModel: CityViewModel 
  12.      
  13.     init { 
  14.         LayoutInflater.from(context).inflate(R.layout.city_view, this).apply { 
  15.             tvCIty = findViewById(R.id.tv_city) 
  16.              // ...... 省略更多的 View 初始化 
  17.         } 
  18.         tvCity.setOnClickListener { 
  19.             updateCityView() 
  20.         } 
  21.     } 
  22.      
  23.     fun <T> initObserver(owner: T) where T : ViewModelStoreOwner, T : LifecycleOwner { 
  24.         cityViewModel = getViewModel(owner) { 
  25.             cityLiveData.observe(owner, Observer { 
  26.                 tvCity.text = it 
  27.             }) 
  28.         } 
  29.         // ...... 省略其他 LiveData 订阅 
  30.     } 
  31.      
  32.     private fun updateCityView() = cityVIewModel.updateCityView() 

owner 实际上就是 Fragment,不过这里为了解耦,没有直接使用 Fragment,而是通过泛型,外加两个上界约束来确定 owner 的职责,一旦某天这个 View 要移植到 Activity 中,Activity 也可以将自身直接通过 initObserver 函数传入。在 Fragment 中,当我们通过 findViewById 拿到 View 对象之后就应该立即调用 initObserver 初始化订阅,代码就不赘述了。

我们用一张图来总结 3.1 小节与 3.2 小节:

我们刚才编写的示例代码之间的关系已经一目了然,MVVM 模式中的 V 与 VM 都已经有了,虽然 M 在图中没有体现,但获取数据的数据源,也就是 CityViewModel.updateCityUI() 函数中调用的 fetchData() 函数就属于 Model,它通常封装了数据库操作或网络服务拉取。

3.3 复杂场景

在开头的 1.2 小节中提到,我们有一些复杂的业务场景,比如多个独立的 View 依赖同一个数据源,或者多个 View 都可能触发同一个数据源的更新。那具体的实际情况举例就是,比如说现在有两个展示城市的 View,用户可以在其中任意一个更改城市,两个 View 中展示的城市信息都需要更新,这在实际情况中是非常典型的案例,将 1.2 小节中的场景 1 与场景 2 结合了起来。

基于以上的代码示例,也就是说除了上面的 CityView 我们还需要一个与它共享同一个数据源的 View,假如说存在一个 CityView2: 

  1. class CityView2 : LinearLayout { 
  2.      
  3.     // ...... 省略其他代码 
  4.      
  5.     private val tvCity: TextView 
  6.      
  7.     private lateinit var cityViewModel: CityViewModel 
  8.      
  9.     init { 
  10.         LayoutInflater.from(context).inflate(R.layout.city_view2, this).apply { 
  11.             tvCIty = findViewById(R.id.tv_city2) 
  12.         } 
  13.         tvCity.setOnClickListener { 
  14.             updateCityView() 
  15.         } 
  16.     } 
  17.      
  18.     fun <T> initObserver(owner: T) where T : ViewModelStoreOwner, T : LifecycleOwner { 
  19.         cityViewModel = getViewModel(owner) { 
  20.             cityLiveData.observe(owner, Observer { 
  21.                 tvCity.text = it 
  22.             }) 
  23.         } 
  24.     } 
  25.      
  26.     private fun updateCityView() = cityVIewModel.updateCityView() 

其他代码大同小异,无非是初始化 View、initObserver 函数、以及更新 UI 的函数。为了确保 CityView2 与 CityView 内的 cityViewModel 是同一个,只需确保 initObserver 函数传进来的 owner 是同一个对象就可以了。

这里我也画了一张图来描述这种关系:

四、新技术在生产环境遇到的挑战

任何一种被业界所公认且信赖的开源技术通常都经过了数百万乃至数千万级用户量的生产环境的检验。携程机票旧首页的 PV 量级在千万级别,考虑到 iOS 与 Android 双平台以及 AB 实验,新的 Android 机票平台化首页的 PV 量级也有百万级别。能否在百万级别的用户量下有优异的稳定性表现,是对本文提到的这几项技术的考验。

Kotlin 语言及其标准库本身已经迭代到 1.3.x 版本(截止文章发稿前,最新版本为 1.4.10,而携程使用的则是 1.3.71),再加上好几年的国内外生产环境的检验,已经相对稳定。而本次使用的 ViewModel、LiveData 等 Jetpack 架构组件的版本为2.2.0,经过线上数月的观测也非常稳定。但 Kotlin 协程框架 kotlinx.coroutines 最终还是出现了两个颇为棘手的问题。

4.1 集成协程的 APK 在部分国产 Android 5.x 手机上报错:INSTALL_FAILED_DEXOPT

问题描述:Android app 工程在配置了大部分版本号为 1.3.x 的 kotlinx.coroutines 库后,在部分国产的 Android 5.x 手机上安装会报错:INSTALL_FAILED_DEXOPT,导致无法安装。

在携程的编译工具链条件下,只有 1.3.0 版本的 kotlinx.coroutines 库可用,而其余 1.3.x 高版本在集成依赖后,会在 vivo X5Pro D(Android 5.0)这款机型上稳定复现这个问题。当然,能稳定复现这一问题的手机品牌和型号不止这一个。

Kotlin 中文社区的论坛中也对此有所讨论(参考链接 3)。这个帖子的博主也在 kotlinx.coroutines 库的官方 Github 仓库的 issues 中向官方提问,但 JetBrains 官方回复说,这是 Google 工具链的问题(参考链接 4)。之后这个问题又提交给了 Google 方面,但 Google 方面表示,已经了解此问题,但由于涉及到的系统版本 Android 5.x 过于老旧,因此不予修复(参考链接 5)。

两家官方的态度都已至此,我们只能抱希望由自己解决该问题。我们能尝试的方案包括:升级 Android SDK Build-Tools 版本、升级 Gradle 版本、升级至 Kotlin 1.4,并将 kotlinx.coroutines 升级至 1.3.9、使用 JDK 8 编译 kotlinx.coroutines 的 Jar 包(官方使用的是 JDK 6)。以上尝试全部无效。最终的方案是,只能暂时使用 1.3.0 版本的 kotlinx.coroutines 库,由于 1.3.1~1.3.8 版本中包含了大量对 Flow 的完善以及 Bug 修复,因此为了稳定性考虑,业务代码中只能暂时不使用Flow。

4.2 主线程调度器 Dispatchers.Main 获取失败导致 Crash

问题描述:协程主线程调度器 Dispatchers.Main 在调用时会有小概率情况发生 crash,与机型、系统版本无关。

这个问题经由线上 crash 上报被我们发现,共造成了 2000 余次的用户 crash。

该问题是 Dispatcher.Main 的实现上有缺陷导致的。在 kotlinx.coroutines 的官方 Github issues 页中已经有人提到了这个问题(参考链接 6)。官方在 1.3.3 版本中使用 Class.forName 的方式替换了原先的 ServiceLoader 实现,从而修复了该问题(参考链接 7),因此如果要避免该问题的出现最正确的解决方式是升级 kotlinx.coroutines 库的版本。

但是狗血的问题发生了,由于 4.1 小节描述的问题,除 1.3.0 版本以外,其他版本的 kotlinx.coroutines 库均会发生 5.x 手机无法集成的问题。这两个问题的同时出现近乎导致了我们的解决方案的“死锁”,进退两难。

在发现线上问题的最初,我们自定义了主线程调度器,从而代替官方的 Dispatchers.Main,并将业务代码中的所有 Dispatcher.Main 替换为自定义的调度器,但这并没有完全解决问题。由于 ktx 版本的 Jetpack 架构组件也依赖了 1.3.0 版本的 kotlinx.coroutines 库,所以即使我们不使用 Dispatchers.Main,ViewModel 和 LiveData 的内部也会使用。无奈之下我们只得试图复制使用到Dispatchers.Main 的 ViewModel 与 LiveData 的代码,并将其中的 Dispatchers.Main 替换为自定义的主线程调度器。

但以上的方案均是临时的,在不能升级 kotlinx.coroutines 库的情况下,最终我们决定 fork kotlinx.coroutines 的代码。并将官方在 1.3.3 修复该问题的 commit 通过类似 cherry-pick 的方式 merge 到 1.3.0 版本的代码上,然后更改版本号并重新编译 Jar 包,并将其放到公司内部源上以供使用。

从长远来看,随着 5.x 手机的数量越来越少,最终携程 app 的系统支持最低版本会提升到 Android 6.0,只有等到那时升级 kotlinx.coroutines 版本才算最终相对完美的解决该问题。

五、结语

Kotlin 语言本身的优势以及所解决的问题很多都是 Java 开发者所面临的痛点。经过了数年的技术积累沉淀,1.3.x 版本(1.3.x 的最后一个版本是 1.3.72)的 Kotlin 已经相对稳定和成熟。

Kotlin 协程很强大,是一个雄心勃勃的项目,它为许多 Java 开发者带来了新的概念以及老问题的新解决方案。虽然它已经进入 release 阶段达一年半之久,但从我们的实践结果来看,其稳定性仍然还有提升的空间。随着 Kotlin 1.4 以及 kotlinx.coroutines 1.3.9 的推出,无论是 Kotlin 语言本身还是协程都已经进入了下一个阶段,相信在未来不久的时间里,它们的性能、稳定性、以及功能都会真正再上一个台阶。

Google 官方近些年与 Android 开发社区的关系日益密切,他们采纳了许多 Android 开发者提出的有效建议,并将其落地,Jetpack 就是成果之一。作为真正的官方出品,它的稳定性从实际表现来看的确经受住了考验。

Jetpack 不仅包含架构组件,还包含了一系列实用的库,比如声明式 UI 框架(Compose)、SQLite 数据库操作框架(Room)、依赖注入(Hilt)、后台任务管理(WorkManager)等等,在未来的开发计划中逐渐尝试向更多的 Jetpack 相关技术迁移也会是一个重要的 Android 端技术改进方向。

 

责任编辑:未丽燕 来源: 知乎
相关推荐

2022-05-13 09:27:55

Widget机票业务App

2022-06-03 09:21:47

Svelte前端携程

2023-05-12 10:14:38

APP开发

2023-01-04 12:17:07

开源携程

2022-06-17 09:42:20

开源MMKV携程机票

2017-04-11 15:11:52

ABtestABT变量法

2022-06-10 08:35:06

项目数据库携程机票

2023-08-25 09:51:21

前端开发

2023-11-13 11:27:58

携程可视化

2022-08-06 08:27:41

Trace系统机票前台微服务架构

2023-08-18 10:49:14

开发携程

2017-04-11 15:34:41

机票前台埋点

2024-07-05 15:05:00

2022-07-15 12:58:02

鸿蒙携程华为

2014-12-25 17:51:07

2017-03-15 17:38:19

互联网

2023-11-06 09:56:10

研究代码

2022-08-12 08:34:32

携程数据库上云

2023-02-08 16:34:05

数据库工具

2022-07-08 09:38:27

携程酒店Flutter技术跨平台整合
点赞
收藏

51CTO技术栈公众号