探讨跨平台技术与跨平台UI框架及Kotlin Multiplatform在bilibili的实践

跨平台语言benchmark大横评

在这个部分,我们将对几种主要的跨平台语言进行比较,主要从执行效率、引入testcase前后app体积变化、运行内存峰值和运行内存的overhead这几个方面进行考察。

对比的平台在iOS、AndroidOS、HarmonyOS这三个平台上进行测试,由于不同平台的硬件设备无法做到一致,所以我们会在各平台选一款设备作为测试标准。

对比的语言在目前bilibili实际生产环境中使用到的语言,分别为Kotlin、JavaScript、Dart、C++、Swift。

测试方式

测试集

关于测试集,考虑到真实研发场景在研发过程中其实多数在于Model的操作,又由于bilibili大多使用grpc作为通信协议,积累了较多的真实业务场景的protobuf文件, 所以我们会使用这些proto文件作为测试集,并以最常见的序列化&反序列化作为测试用例,分别在这几种语言上进行测试。在本文中选取了当前B站的较常使用的一组protobuf, .proto个数为237个, .proto体积为2.2MB的测试集。

测试流程

1. 基于每个平台 X 每种测试语言的环境构造测试工程

  • 拥有最基本的调用testcase的UI及统计能力。

  • 防止testcase自身的依赖被strip,最基简单的对应的protobuf的序列化反序列化的调用。

部分语言的工程环境支持多平台,例如Flutter、Kotlin Multiplatform等我们会在多平台使用同一份测试工程。

2. testcase 构造

  • 基于以下protoc插件生成对应语言代码,在这我们假设各个protoc compiler尽可能以最优的方式生成代码。

语言

编译器

protoc compiler

备注

Kotlin

1.9.23

基于streem/pbandk自研

TypeScript

/

bufbuild/protobuf-es

在只可运行JS runtime的环境中通过

webpack(mode=production)进行打包

Dart

3.3.3

protocolbuffers/protobuf

C++

Apple clang version 15.0.0 (clang-1500.1.0.2.5)

protocolbuffers/protobuf

  • 我们自研了测试中的所有语言的testcase的protoc插件,通过这个插件我们可以生成每个proto文件对应的序列化反序列化的testcase,具体行为为遍历所有的Message, 并构造这个Message,后序列化成ByteArray后再反序列化回去。在各语言构造对应protobuf的序列化反序列化代码举例如下:

// fission.proto 对应的 cpp 测试模块
namespace bilibili_::account_::fission_::v1_::fission_::proto {

    template<typename Message>
    inline void TestMessage() {
        auto str = Message().SerializeAsString();
        auto msg = Message().ParseFromString(str);
    }

    inline void Test() {
        TestMessage<::bilibili::account::fission::v1::EntranceReq>();
        TestMessage<::bilibili::account::fission::v1::EntranceReply>();
        TestMessage<::bilibili::account::fission::v1::AnimateIcon>();
        TestMessage<::bilibili::account::fission::v1::WindowReq>();
        TestMessage<::bilibili::account::fission::v1::WindowReply>();
        TestMessage<::bilibili::account::fission::v1::PrivacyReq>();
        TestMessage<::bilibili::account::fission::v1::PrivacyReply>();
    }
}
// fission.proto 对应的 kotlin 测试模块
@file:OptIn(ExperimentalSerializationApi::class)

package bilibili_account_fission_v1_fission

import kotlinx.serialization.*
import kotlinx.serialization.protobuf.*
import kotlinx.serialization.protobuf.ProtoBuf.*
import com.bapis.bilibili.account.fission.v1.*

fun doTest() {
    ProtoBuf.decodeFromByteArray<KEntranceReq>(ProtoBuf.encodeToByteArray(KEntranceReq()))
    ProtoBuf.decodeFromByteArray<KEntranceReply>(ProtoBuf.encodeToByteArray(KEntranceReply()))
    ProtoBuf.decodeFromByteArray<KAnimateIcon>(ProtoBuf.encodeToByteArray(KAnimateIcon()))
    ProtoBuf.decodeFromByteArray<KWindowReq>(ProtoBuf.encodeToByteArray(KWindowReq()))
    ProtoBuf.decodeFromByteArray<KWindowReply>(ProtoBuf.encodeToByteArray(KWindowReply()))
    ProtoBuf.decodeFromByteArray<KPrivacyReq>(ProtoBuf.encodeToByteArray(KPrivacyReq()))
    ProtoBuf.decodeFromByteArray<KPrivacyReply>(ProtoBuf.encodeToByteArray(KPrivacyReply()))
}
import Foundation
import SwiftProtobuf

fileprivate func testMessage<T:SwiftProtobuf.Message>(type: T.Type) {
    let message = T()

    do {
       let encoded = try message.serializedData()
       let decoded = try T(serializedData: encoded)
    } catch {
       print("Error: \(error)")
    }
}

func testBilibiliPmmsV1PmmsProto() {
    testMessage(type: Bilibili_Pmms_V1_GetPullMessagesReq.self)
    testMessage(type: Bilibili_Pmms_V1_GetPullMessagesResponse.self)
    testMessage(type: Bilibili_Pmms_V1_Position.self)
    testMessage(type: Bilibili_Pmms_V1_ControlParams.self)
    testMessage(type: Bilibili_Pmms_V1_Message.self)
}
  • 构造TestEntry入口,收集并调用刚才生成所有的TestCase代码。

3. 编译&链接&运行

  • 在各个平台上Cpp上编译&链接参数分别为-Os -fvisibility=hidden -fvisibility-inlines-hidden -dead_strip

  • 其他语言基本使用默认的Release编译方式

  • 由于Dart runtime和Flutter engine绑定较深,所以我们直接使用flutter并使用Dart aot进行测试

  • 由于Android平台并没有原生系统自带的JsRuntime,故我们在iOS中使用的是JavaScriptCore ,在Harmony中使用的是默认的ArkRuntime

  • 由于精力有限我们并没有测试Flutter在鸿蒙的数据表现

4. 通过各个平台的Profile工具进行数据的人工收集

测试结果

标🌟的为同机型下最优 

图片

备注:

  1. 取运行50次testcase时间的平均值。

  2. 由于编译环境问题, Huawei X5的kotlin只使用了[总size*56%]的 proto 文件。

图片

备注:

理论上cpp还有一些的strip空间(例如mangled name)但是我们主要采用了对应工具链的默认relase行为进行构建打包,并未做极限的体积优化。

图片

备注:

  1. js执行效率相对较低, 同时内存footprint较大且GC不及时, 导致峰值内存比较高。

  2. 相对GC语言所有MMM语言都几乎没有内存波动。

为什么语言双向调用interop能力至关重要

跨平台语言and跨平台UI框架

在跨平台开发中,我们经常会听到跨平台语言和跨平台UI框架这两个概念,甚至很多同学会潜意识里认为跨平台就是跨平台UI框架。在相对成熟的跨平台UI框架中(JavaSwing,ReactNative,Flutter)大部分追求的都是在框架内自闭环,而自闭环的一个特点就是,UI框架有能力调用平台的能力,而基本不考虑平台如何调用进UI框架的实现。

而跨平台语言的引入更多是为了解决工程化的一些例如代码复用、开发效率等问题,这也就意味着跨平台语言需要有能力调用平台的能力,而平台也需要有能力调用跨平台语言的能力。而在一个健康的工程结构下,我们基本可以认为越底层的代码越稳定,变更越小,互调用的变更频率也越低,对于一次性代码的实现更友好,例如播放器内核、网络内核、渲染内核等。同时我们可以简单认为越上层的业务模块,双向interop能力在跨平台开发中越重要。

我们会对主流的一些跨平台语言的interop能力进行一个主观评价。评价项会从按照以下标准评分:

  • 🌟🌟接近原生体验

  • 🌟可以用但是需要大量的boilerplate code

  • 😈 又不是不能用

⚠️ 叠甲保护!主观评价!

图片

跨平台语言如何实现双向interop

没有独立的运行时(runtime)的语言

对于没有独立的运行时的语言,例如C++、Rust、Kotlin,互操作性主要依赖于语言的编译器和链接器的能力,以及对应平台操作系统提供的接口的binding能力。我们会以C++语言为基线,其他的语言只列出优化及劣化点

  • C++

  • 调用iOS平台

  • 通过extern "C"的方式调用iOS平台的C代码 (降级为C)

  • Swift5.9 之后一定程度的直接调用能力

  • 被iOS平台调用

  • 通过Objective-C++的方式调用C++代码

  • 通过extern "C"的方式导出后,被iOS平台作为C调用 (降级为C)

  • Swift5.9 之后一定程度的直接调用能力

  • 调用Android平台

  • 通过JNI的方式反射调用Java代码

  • 被Android平台调用

  • 通过JNIEXPORT的方式导出后,在平台代码声明native后被调用

  • 调用Harmony平台

  1. C++层实现一个接受Callback的函数并通过napi注册

  2. 在JS层把Callback传入C++层

  3. 后续在C++层调用Callback来调用Harmony平台代码

  • 被Harmony平台调用

  • napi导出后,在平台代码声明对应的binding.d.ts

  • Rust(相对C++)

  • 调用iOS平台

  • 额外需要类似rust-bindgen之类的工具来生成rust对C的binding代码

  • 无法与Swift5.9 之后进行直接调用

  • 被iOS平台调用

  • 通过#[no_mangle]及externC导出

  • 通过类似cbindgen的工具生成对应的C的extern声明

  • 无法与Swift5.9 之后进行直接调用

Rust相对C++虽然更复杂一些但是生态上会有一些友好性的例如mozilla/uniffi-rs之类的工具来简化这个过程

  • Kotlin (相对C++)

    • 通过cinterop生成Objective-C 与 Swift(Objc)的binding代码

    • 调用iOS平台

相对其他语言K/N为每个平台把常用的平台都进行了默认binding例如UIKit/Foundation等

  • 无法与Swift直接调用 (https://youtrack.jetbrains.com/issue/KT-64572)

  • 被iOS平台调用

  • 可直接导出ObjC 的头文件从而被iOS平台以ObjC的方式调用

  • 调用Android平台

  • Kotlin/JVM可直接调用Android平台Kotlin/Java代码

  • 被Android平台调用

  • Kotlin/JVM可直接被Android平台调用

  • 调用Harmony平台

  • 无需napi体系,基于js体系

  • 对Harmony 的@ohos/api进行binding描述至binding.kt

  • 被Harmony平台调用

  • 本身即以JS形态被调用,部分类型例如集合类型、Long这类JS没有的类型会降成Any

在iOS平台为Kotlin/Native,在Android平台为Kotlin/Jvm,在Harmony平台为Kotlin/JS

有独立的运行时(runtime)的语言

对于有独立的运行时的语言,例如JavaScript、Dart,互操作性主要依赖于语言的运行时的能力,且普遍并没有什么共性。在这里我们会举两个用的规模较多的来分析。分别为Flutter引擎的Dart、及JavaScript。

  • JavaScript

  • 通过各Runtime自己定义的Bridge能力来注册平台能力

  • 调用iOS/Android平台

//以QuickJS举例
// prepare runtime & context
QJSRuntime *runtime = [[QJSRuntime alloc] init];
QJSContext *context = [runtime newContext];

// get global object
QJSValue *globalValue = [context getGlobalValue];
// set global variable
[globalValue setObject:[TestObject new] forKey:@"testval"];
  • 被iOS/Android平台调用

  • 通过各Runtime各自的eval能力来调用JS代码

//以QuickJS举例
[context eval:@"testval.test(1, 'a', false);"]
 
  • Harmony平台

Harmony由于自身就是Arkruntime运行JavaScript代码所以基本做到完美互调,这里要注意的是我们需要为JS支持.d.ts的binding的实现。

JavaScript作为轻量级解释语言,并不会固定绑定某个运行时,目前在各平台的主流场景分别是为iOS基于JSCore,Android基于QuickJs or V8,Harmony基于Arkruntime来作为JS的运行时环境。

  • Dart

    • 调用Harmony平台

    • 通过jnigen(https://dart.dev/interop/java-interop)来生成Dart对Java的binding代码

    • 调用Android平台

    • 通过ffigen(https://dart.dev/interop/objective-c-interop)来生成Dart对Objc的binding代码

    • 调用iOS平台

⚠️ 理论可行,由于Harmony平台属于早期,并未对其可行性进行验证。

  • 被iOS/Android/Harmony平台调用

原生dart目前并没有可直接使用的对应平台的aotruntime可使用,也无法被对应平台所直接调用,目前主要的方式是借助Flutter的channel机制。

  • 通过MethodChannel(https://api.flutter.dev/flutter/services/MethodChannel-class.html)的方式来反射Dart的方法

虽然Dart有相对独立的runtime,但是作为移动领域embed runtime使用非常不成熟,如果要独立运行起dartruntime仍然需要与从flutter中把核心的运行时抽出来需要有大量的工作,所以我们在这里主要还是讨论的是在Flutter体系下的双向interop

跨平台开发是不是伪命题?

图片

移动领域产品and研发的分工

在大前端领域,产品和研发的分工是比较明确的,产品主要负责产品的设计、需求的梳理、用户体验的设计等,而研发主要负责产品的技术实现、产品的迭代、产品的维护等。

然而随着移动互联网时代来临,从基本相对单一的web浏览器进化到了终端设备的多样化,导致的结果是研发团队会更多的面对多个平台的开发,从而自然的又因为多个平台的差异性,选择No resuability自然成为了一个合理且自然的结果。但同时其实我们观察到的一个现象是,对于更高Level的复用无论是商业公司内抑或是开源社区都在共同追求的,原因其实也很简单,随着复用度的变高运营成本(人力成本)等资源消耗都会大幅度降低,而且除了资源消耗的降低,更重要的是产品的迭代速度会大幅度提升,这也是为什么大家在追求复用的路程上不断深挖的原因。

对于产品本身来说,追求不同终端的特点做差异化,在核心体验上的产品一致性也是一个很重要的目标,这也是目前主流的移动研发模式会采取偏底层的模块(网络内核、播放内核、渲染内核)等通过一些更成熟得例如C++技术来实现而在顶层业务模块通过no resuability的研发模式的最重要原因。

团队沟通成本大于技术复用节省的成本?

诚然,高内聚低耦合是每个软件工程师追求的,然而基本事实上两个是同增同减的。更高的复用度往往意味着变更范围的增大,往往意味着更高的协作复杂度。在爆发增长的互联网领域,面对变化才是每天的变化,所以很多管理者会认知技术复用节省的成本远大于团队沟通成本的并且倾向选择更低的复用度,来轻装上阵从而更迅速的应变变化(人员的变更,产品形态的变更)

看似这个逻辑在现实场景很自洽,但是其实最明显的一个矛盾就是为什么更大的变更范围会导致更高的协作复杂度?这个问题的答案不同人会有不同的答案(借口),例如工程化不够完善,团队协作不够默契,产品设计不够合理,需求梳理不够清晰,但是我认为本质有两个原因。

  1. 轻易say no

  2. 缺乏沉淀

轻易say no

在互联网公司很常见的一个现象就是倒排期,而倒排期会带来团队对于交付的焦虑(恐惧),习惯用舒适区的方式来进行实践,而这种实践通常会带来产品技术的妥协(也既是说no的原因) 妥协对上下游来说多少都是有心里间隙的,特别研发作为下游团队需要承受更多的团队沟通层面的压力,从而逐渐形成了团队沟通成本大于技术复用节省的成本的现象。类似自闭环效率最高之类的说法也是这个原因。

缺乏沉淀

在互联网公司另一个常见的现象就是人员的快速迭代,而这个迭代的速度远大于技术的进化速度(特别是移动领域相对web领域会更慢)。沉淀从小的维度可以说是公司工程化的沉淀,从更大的维度就是信息方法论的沉淀,而总是试图去搭世代进化红利的便车显而是可遇而不可求的事,然而大家都会精神寄托在抽彩票,而通过放弃了技术自身发展的客观规律。仅仅通过类似管理手段来解决没有技术沉淀积累带来的技术复杂度的问题。

破局点

经济下行周期

图片

各公司团队结构势必变得更紧凑,从而使得团队沟通成本的Overhead降低,此时技术复用的节省成本的优势会逐渐显现。而跨平台技术带来的效益主要为:

成本效益:跨平台技术允许使用单一代码库来开发适用于多个平台(如Android、iOS、Harmony)的应用,这样可以减少为每个平台单独开发和维护的成本

提高研发效率:通过最大化代码复用,减少多端差异的适配工作量,可以降低开发成本,专注于业务开发,实现快速迭代

多端一致性:跨平台技术有助于保持不同平台上应用的UI设计和用户体验的一致性,这对于品牌形象和用户体验非常重要

风险管理:在不确定的经济环境下,通过跨平台技术,公司能够分散风险,避免对单一平台的过度依赖,同时保持业务的连续性和灵活性

AI(LLM)

随着2022年11月ChatGPT的发布,大语言模型带来的威力和变化是真做到了日新月异。而AI的发展也会带来更多的自动化工具,例如自动化的代码生成工具,自动化的代码分析工具等,这些工具会大幅度的提升团队的技术复用的效率,从而降低团队的技术复用的成本。但是在AI领域里GIGO(Garbage in , garbage out)是一个很重要的原则,所以如何在为研发领域提供专家语料等信息是至关重要的,可是多平台的异化会导致专家语料的难以积累,我们思考一下一个需求的研发流程。

  1. 通过自然语言的需求描述

  2. 通过工程师的技术储备积累还原自然语言到程序语言

  3. 通过程序语言进行需求的实现

在多平台的异化下,这个自然语言到程序语言的翻译过程是成指数级上升的,因为会出现多平台的技术专有能力名次需要继续抽象更多的中间层,这个过程会导致专家语料的积累变得更加困难,从而导致AI需要学习更多的知识,降低AI辅助的工程效率。

One team

随着国内的鸿蒙的操作系统出现,公司将会面临一波移动产品对双平台(iOS、Android)到三个平台(Harmony)的变化。俗话说过一过二不过三,当到了三个平台的时候,公司的研发团队将会面临更大的挑战,而这个挑战的核心是团队的沟通成本(上下游团队、兄弟团队)。而跨平台技术的引入将会大幅度的降低团队的沟通成本,从而提高团队的效率,这也是为什么跨平台技术在这个时代会有更大的发展空间。

为什么bilibili选择了Kotlin作为跨平台语言的基石

跨什么(哪层)?

图片

包括B站内部和国内团队主要的共识是会把一些核心模块(这里的核心并不是指业务领域核心,而是能力核心)即图中绿色的第三层例如播放内核、网络内核、渲染内核、特效内核等通过C++、Rust等跨平台技术来实现,而在业务领域通过对应平台的原生技术来实现。这样的好处其实大家都已经尝到了,就是能力核心的快速稳定一致性迭代,然而事实上上层业务领域的复杂度的堆叠才是移动软件复杂度的核心,这些业务无论是代码规模还是逻辑分支的复杂度都是远远高于上述提到的能力核心模块的。甚至有些业务团队在处理复杂度不高或者在团队能力不足的情况下会有意规避掉红色这层把他们和黄色的UI层合并在一起实现,而作为产品的拍档其实我们也可以知道产品的领域模型对应的正是我们上层的业务领域的模型建模。而这层的低效既让技术层面低效也同时拖累产品变更进化低效。所以我们的跨平台设计开始就很明确了我们是要为上层业务领域(红色的层)来跨平台而做的设计。

当然产品绝不是仅仅追求同质化,平台层面的特性差异化也正是产品发光的重点。所以我们目前并没有一步登天直接去跨UI层(黄色的层),而是实现核心领域和UI数据模型,贯彻VIEW<=>DATA 的思想,让平台层面的差异化依然由平台自身来进行实现。

Why Kotlin?

双向interop

在我们决定要跨业务领域核心层之后,我们会发现双向的interop能力是一个必选项,在这个条件下也就必须选择支持Native的语言,在本文中我们剩下的选择只有Kotlin、C/C++、Rust了。

对应平台原生语言的高阶语法特性

现代的语言的高阶的语法是业务提效核心,我们会发现能使用现代化的例如异步等能力也是一个必选项,要达成这个目标我们需要大量的模版代码的CodeGen的生态,大量的Binding工作, 这个工作量必须要被消化在工具链中而不应该转嫁给业务研发!在这个筛选条件下,只剩下Kotlin还在毒圈里了。

不俗的性能表现

在上文benchmark大横评中我们可以看到在iOS平台,除了因为GC的原因导致的内存波动外,其他的性能都是和原生Swift一个数量级甚至部分项超越的。在Android平台天生就是一等公民的支持。在鸿蒙上虽然会有一些相对大(依然在一个数量级内)的性能劣势,但结合我们跨的是业务领域的核心层这样并不重度关注性能的层面,我们认为这个性能的劣势是可以接受的。

良好的社区生态

相对其他跨平台语言,Kotlin作为一个由JetBrains开发,会有Jetbrains作为生态背书和指引有着丰富的第三方库和工具,这对于任何一家公司的技术的选择是非常重要的。

地利人和

做成一件事或许天时、地利、人和都不可缺少,B站往跨平台方向的转变是在23年中(互联网整体的下行周期)发生的,这也是促成这件事发生的天时。在本章里我们更多的会来聊地利和人和。

地利

图片

自2017 Google I/O中宣布了Android’s Kotlin-first approach之后B站就在当年贯彻了Kotlin-first的响应。且从那个时候开始就全面进入了Kotlin的生态圈,在这几年中B站积累的较多的Kotlin的使用经验,基于Kotlin的公共库及工具的积累,使得B站在跨平台技术的选择上更倾向于Kotlin。

Kotlin早期的定位是better java这也是早期他作为Android平台的首荐,随着Kotlin Multiplatform Is Stable and Production-Ready(https://blog.jetbrains.com/kotlin/2023/11/kotlin-multiplatform-stable/),历史上存在的例如并发的Frozen、等易用问题也都得到了相应的解决。同时以下我们会简称Kotlin的跨平台技术为KMP技术。

相对其实B站在C的跨平台积累上也有不少,例如很早期开源的bilibili/ijkplayer 。但是服务于我们跨什么 ,考虑到更低的开发者心智负担以及更优秀的社区生态,我们会选择Kotlin作为我们的业务领域层的跨平台语言。而把C/C++作为更基础能力的跨平台语言使用。

人和

在项目调研的早期,我们拜访了多家已经在使用KMP技术的前辈公司,其中会提到一个共性点,对于Android原生技术Kotlin在其他平台的落地,其他原生平台同学来操刀这个落地会更有优势及得心应手。因为相对语言,系统本身的特性才是他们以前积累的核心优势。把Kotlin放进原来封闭生态后,也更容易出现鲶鱼效应。B站在实践过程中也映证了这个点(本来就是10多年iOS出生的研发),在实践落地过程中也可以主动打破一些工具链默认的臆想假设约束,从而更好的在原生平台中落地。

典型案例场景举例

各页面的UIStore

需求描述: 在B站推行的设计模式是类似Redux的单向数据流(unidirectional data flow)模型。在这个模型中,我们抽象了Action和State并在公共部分实现reducer来进行转换。同时产品功能上很少某个端自己的平台技术角度的独特能力。

问题:

  1. 在我们实践中基本90%以上的功能在多端的Action和State的设计上几乎都是一致的,这也导致大部分我们的业务领域不同端上的这各模块全部都是重复的。剩下不同的大多基本都是各自平台自身的需各自实现例如动态化等UE效果。

  2. 与产品设计沟通时通常由多波人员分别进行沟通,再局部各自进行对齐,这也导致了大量的沟通成本。

  3. 由于研发同学时间紧张,经常不遵守UDF范式从而使得UI与逻辑耦合,导致在需求变更迭代时候使得变更面大幅度增加。

方案:

1. 引入FlowRedux (https://github.com/freeletics/FlowRedux)状态机machine并提供基础Helper

2. 规范化模块统一输出Interface

@OptIn(ExperimentalObjCRefinement::class)
interface Store<STATE, EVENT> {
    // Input
    @HiddenFromObjC
    @NativeCoroutinesIgnore
    suspend fun dispatch(action: EVENT)

    // Output
    @HiddenFromObjC
    @NativeCoroutinesIgnore
    val state: Flow<STATE>
}

3. 最终完整实现

class XXStore(val xid: Long): Store<XState, XEvent> {
    private val _machine = StoreMachine<XState, XEvent>(
        initialState = XState.Idle,
        specBlock = {
            inState<XState> {
                on<XEvent.Dosth1> {}
                on<XEvent.Dosth2> {}
            }
        }
    )
    @NativeCoroutines
    override val state: Flow<XState> = _machine.state
    @NativeCoroutines
    override suspend fun dispatch(action: XEvent) {
        machine.dispatch(action)
    }
}

收益:

  1. UDF的范式反推iOS研发同学从过程式的UI编写进化到data-driven的UI框架/库(例如ViewDiffableDataSource、IGListKit、RxDataSources等)

  2. UDF的范式使得Android研发同学更好的过度到Jetpack.Compose

  3. UI逻辑物理隔离,总减少保守约30%的代码规模

痛点:

  1. iOS 无法基于Kotlin独立模块依赖,导致构建系统总要通过Kotlin.native.link的环节(这部分在1.9.23 开启AutoCache被大幅度优化)

  2. ObjC没有默认参数,对一些Kotlin中大量成员的场景例如DataClass的初始化会有一定的不便(期待后续与Swift直接interop后有望解决)

  3. iOS&HarmonyOS 由于Combine的高版本要求以及JS环境我们需要引入RxSwift/RxJS来与Kotlin.Flow 或协程接口包装成异步Rx的接口

  4. HarmonyOS开发环境中对jsmap支持糟糕,无法以Kotlin维度做代码调试,又由于使用大量协程最终裸的js产物里可调式性/可读性非常差

直播消息推送中间件

需求描述: 在B站的直播消息推送中间件中,我们会有一个核心的消息推送引擎,这个引擎会负责长链接消息的推送、过滤、排序分发等核心功能。由于直播的高互动高实时的特点要求,经常会出现业务场景需要夹杂轮训,从视频播放流中提取例如SEI信息等多条数据链路来合并消息。

问题:

  1. 由于大量的同质化的工作,会在每个具体的业务场景下重复实现以上的工作,这些工作不仅在业务之间重复同时也在各终端(iOS、Android、PC)之间重复

  2. 由于不同研发分别实现即使在强调一致性的前提下在日常迭代过程中依然会产生差异化(设计miss or bug实现)导致服务提供者进行大量前向兜底

  3. 由于不同终端的差异化,依赖过深,会导致在不同终端的消息推送引擎的表现不一致,从而导致在业务上的不一致性造成用户体验的不一致

方案:

  1. 接口化各个核心能力provider(长链接,播放流SEI,短链论序),部分cpp实现直接对接,部分由各自平台依赖注入

  2. 撰写唯一直播域的核心消息处理中间件对接各核心能力,并提供对应平台原生调用

  3. 统一前后端的消息通信docs(中心化protobuf),提供基于bidistreaming call的protoc插件自动完成消息的序列化反序列化工作

  4. 最终提供funsubscribeMessage(targetPath: String, deserialization: DeserializationStrategy): Flow<pair>的简单接口

收益:

  1. 通过ProtoBuf及Codegen大幅度简化基于Json弱类型的的模版代码的书写以及沟通成本(每个消息从约百行减少到0)

  2. 统一及规范化收口,使得中间件的迭代"成为可能"

痛点:

  1. iOS 无法基于Kotlin独立模块依赖,针对一些依赖书写不规范的模块(通过头文件把Kotlin依赖穿透带出去)会导致增量编译性能损坏的问题

跨UI&动态化(未来)

跨平台UI

DRI(Don't repeat yourself),Write once, run anywhere 在前端领域的终极体现就是跨平台UI,在这点上我会持有一种观望态度,因为本质上平台的差异性是其商业平台的核心价值。为其的高度抽象本身可能就是一个矛盾点。但是反过来说在通用快速起量的产品形态我还是坚信跨平台UI是高效吞吐的相对最优解。而base KMP技术下的CMP(Compose Multiplatform)也可以让我们并没有太大的试错成本进行尝试。

动态化

目前了解到行业内针对动态化主要会有两种心智

  1. 低代码化=>高度配置化后=>赋能研发能力给运营等职能

  2. 热更新、热修复 加速通过市场监管的软件发布等流程

但是我个人认为动态化的最重要目的是降低软件复杂度,目前主流的场景是,后端服务需要长时间长周期的去maintain向前兼容的服务,虽然前端体系可以拍拍屁股把老代码全部删光,但是不能忘记前端架构的演进是需要backend类似BFF层配合演进的。如果后端的长尾负担过重,会把整体负担转移到前端。

我们还是以B站举例子,相对移动体系,其实Web站产品生命周期更久,但是相对历史负担就远比移动体系轻,这也是动态化好处的最典型的例子。

而动态化的前提就是高度的组件Component化,DSL化,而这些有恰恰是目前较为流行的声明式框架所最擅长的。而base CMP技术下的例如cashapp/redwood-treehouse也是一个较好的实践者,也可以让我们未来有更多的尝试的机会。

跨平台自身的成本问题

One two three repo?

One repo

当我们从某个跨平台项目(KMP,Flutter,RN...)的Template模版从开始HelloWorld做起的时候,不会有任何犹豫那就是One repo。一旦把这个技术作为了宿主, 那么其他的问题也自然迎刃而解,都作为外挂插件support包的方式提供即可,然而作为一家有积累的公司,显然HelloWorld是不切实际的,我们需要考虑的第一个问题就是怎么把已有的沉淀与新的技术融合。

Two repo

既然我们是选择了Kotlin平台那么自然第二想法,就是把以Gradle作为构建工具, Kotlin作为研发语言的Android工程作为主工程,然后把原来基于JVM的项目改造为Multiplatform的项目,从而复用Android的基础供其他平台使用。诚然的确有很多公司的确是基于这个方案来执行的,但是Two repo的执行上会有三个显著的问题

  1. kt on jvm 是不能无缝到 kt on native的,因为往往我们的代码实现会使用到大量的jvm上的能力(例如反射),从而导致跨平台改造并不是无痛的

  2. kt on multiplatform 会对工具链,环境等复杂度要求相比android上一个档次,会大幅度影响对 KMP 不感兴趣及不关注的同学的日常研发体验

  3. 造成团队割裂,会让这个跨平台技术无限往Android研发团队亲近,从而让其他团队对此产生敌意

Three repo

在评估Two repo是一种绝不可能执行落地的条件后,自然我们就选择了Three repo的方案(这里要注意Three是狭义的三,是各自平台的个数)。即Android monorepo + iOS monorepo + harmony monorepo + kmp mono repo的方案。基于此我们的实践如下,会分出4种研发角色。

图片

在这个方案下我们会有以下的优势:

  1. 对KMP研发而言,不用背负原来平台的负担(Android.gradle、iOS.xcodeproj),可以专注于研发最适合自己的工作流。

  2. 对于原平台的开发者,只认识自己所见的方言(Android.gradle、iOS.bazel) ,不用关心其他平台的实现。

  3. 对于团队分工、我们抽离出无偏向的中立角色,可以更好得协调各个平台的需求。

在这个方案下的劣势是:

  1. 大幅度增加的工具链复杂度,对稳定性会遭到极大的挑战

  2. 破坏了原子提交等原则,在代码分支管理上(checkin、revert等)会大幅度增加团队协作复杂度

  3. 对原来平台monorepo中的代码复用会相对复杂,例如环问题解决

人性

Why Kotlin Multiplatform won't succeed(https://www.donnfelker.com/why-kotlin-multiplatform-wont-succeed/)在这篇文章中(推荐阅读)作者提到了一个很重要的问题,就是人性问题。当然我们不能说人性问题就投降了,还是要通过技术手段把复杂度降到最低,让研发更无感,但是也一样要在团队管理层面进行宣导。在这方面应该结合各团队自身的情况进行设计千人千面也没有过多的借鉴意义,在这里简单分享介绍下B站的一些实践。

  1. 收集种子研发,日常对新技术感兴趣的同学。

  2. 透明化轻量化,基于类似Scrum的形式基于Ability/Module/Enhance/Maintenance/Invesgating的角度进行计划规划管理和todo实现

  3. 强文档化,双月规划Roadmap及实现结果Review

  4. 0 -> 1 的工作由项目组直接进入对应研发团队并实现对应的需求,让对应研发团队感受到实际的效果。

One repo again 是我们终极的目标,在技术选型上我们使用了Bazel构建系统并实现了大量的与Swift、Objc、C/Cpp、双向交互的rules以及在技术层面扫清了障碍。当然可能在很远的未来我们通过KMP技术大幅度提效之后可以回到最初用One repo的方式反过来把其他的平台代码作为子依赖引入的方式集成。

总结

本文以相对客观的事实数据比对了目前移动领域的多个跨平台语言的横评,也相对主观的针对当前的大环境下的一些跨平台技术的思考及B站的选型做了一些解读。希望可以为读者开阔思路,也希望如果能拉拢到同样观点的大佬们共同一起反哺基于Kotlin跨平台的生态的建设。从而做到社区全局收益。

后续B站会持续继续输出关于KMP技术的技术及实践的深度文章,也会持续关注跨平台技术的发展,希望能够在这个领域有更多的交流。

引用

  1. Reusability Maturity for product development teams(https://www.linkedin.com/pulse/reusability-maturity-product-development-teams-sami-rehman

  2. Tech layoffs since COVID-19(https://layoffs.fyi/

-End-

作者丨Snorlax、西瓜皮

  • 27
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值