自拍偷在线精品自拍偷,亚洲欧美中文日韩v在线观看不卡

工程化視角的 Kotlin Multiplatform 核心解讀及優(yōu)化

開發(fā) 前端
本文從一個(gè)較大規(guī)模的工程化的視角來解讀分析了KMP的構(gòu)建系統(tǒng),插件系統(tǒng),IDEA集成等等問題,并且提出了我們的解決方案。

本篇為KMP技術(shù)的技術(shù)及實(shí)踐系列文章的第二篇。在這篇技術(shù)文章中我們會(huì)以百人移動(dòng)研發(fā)團(tuán)隊(duì)的工程化視角,探討Kotlin Multiplatform的核心技術(shù)及優(yōu)化。

Kotlin: 語言與編譯

人們?cè)谟米匀徽Z言溝通時(shí),內(nèi)容可以不明確,甚至小的錯(cuò)誤,而聽的人仍然可能理解說的人想要說的內(nèi)容。但電腦不同,電腦“只做被告知要做的事”,無法理解程式設(shè)計(jì)者想要寫的程式。語言的定義、編程以及編程輸入的組合需完整定義程式執(zhí)行時(shí)的外部特性。 而程序語言正是人類和計(jì)算機(jī)的橋梁, 順著這個(gè)邏輯,我們把我們?nèi)粘5木幊坦ぷ骱鸵恍┖诵母拍罱Y(jié)合起來。

  • 人控制計(jì)算機(jī),所以編程語言是給人寫的。那么自然就要符合人類的思維習(xí)慣,例如面向?qū)ο螅瘮?shù)式編程等。這也是為什么有那么多的編程語言的不同之處。
  • 計(jì)算機(jī)世界只有0/1,而交付0/1對(duì)人類來說在現(xiàn)代實(shí)在太困難了,所以我們發(fā)明了了指令集架構(gòu),發(fā)明了匯編,發(fā)明了各種例如JVM的字節(jié)碼。而這些也就是計(jì)算機(jī)所需要的輸入。
  • 人類的思維和計(jì)算機(jī)的所需要的輸入之間有一個(gè)翻譯的過程,這個(gè)過程就是編譯器。編譯器的目的就是把人類的思維翻譯成不同level的計(jì)算機(jī)所需要的輸入。
  • 我們通過編程語言與計(jì)算機(jī)溝通,那么自然希望這個(gè)語言是一種擴(kuò)展性強(qiáng)的,讓我們不被語言語法本身所限制,通常對(duì)人類抽象層面越高的語言他們的表達(dá)能力反而更弱,這也是插件系統(tǒng)的重要性所在。插件主要的作用就是在不增加原語言復(fù)雜度的前提下擴(kuò)展出更強(qiáng)的能力,當(dāng)然并不是所有的語言都通過插件體系來實(shí)現(xiàn)核心能力(例如類似的compose&swiftui,前者是通過插件生態(tài)擴(kuò)展能力,后者(雖然在swift5.9 之后支持了macro的插件體系)依然是通過為語言本身增加更多的表達(dá)能力來達(dá)成。

接下來我們就結(jié)合KMP從編程語言的角度來刨析實(shí)際工程化中的實(shí)踐。

語言(language)

默認(rèn)可見性為public??

可見性/Visibility modifiers(https://kotlinlang.org/docs/visibility-modifiers.html)在工程化上是一件至關(guān)重要的事,因?yàn)榭梢娦缘目刂浦苯佑绊懙搅舜a的復(fù)用性和維護(hù)性。Kotlin 的設(shè)計(jì)哲學(xué)是你不需要它時(shí),它不會(huì)打擾你;你需要它時(shí),它就在那里 。默認(rèn)的 public 可見性體現(xiàn)了這一哲學(xué),即不強(qiáng)制開發(fā)者在不需要時(shí)使用可見性修飾符。但是當(dāng)Kotlin進(jìn)化從JVM進(jìn)化到Multiplatform后,針對(duì)不同平臺(tái)的可見性就有自己的特點(diǎn)了。例如當(dāng)鏈接到Native時(shí),哪些符號(hào)需要做C/Objc binding ?例如編譯到JS后什么需要導(dǎo)出到模塊?這些都是需要開發(fā)者自己去考慮的。導(dǎo)致的結(jié)果就是有些是通過Kotlin自身的一些annotation例如@JsExport有些是通過編譯器參數(shù)-Xexport-library來實(shí)現(xiàn)的。例如語言層面的Visibility叉乘無論是annotation還是編譯參數(shù)的組合,這會(huì)要求開發(fā)者更多的感知這些語言層面的細(xì)節(jié),從而違反了他原來的設(shè)計(jì)哲學(xué)。

編譯器(compilers)

三個(gè)編譯器

上文我們提到編譯器的工作是交付計(jì)算機(jī)可以理解的輸入,而大多數(shù)傳統(tǒng)跨平臺(tái)語言的做法主要有兩種。1. 類似C++、Rust直接輸出不同CPU架構(gòu)的機(jī)器碼。2. 類似Java、C#輸出字節(jié)碼,然后通過不同的Runtime來解釋執(zhí)行。前者的最大問題就是和原生平臺(tái)的語言交互不暢,畢竟是兩個(gè)層面的語言(例如JVM平臺(tái)上通過JNI與Native交互),后者的問題就是性能問題,無論如何都是過了一層中間層。KMP在這的選型是通過三個(gè)編譯器來實(shí)現(xiàn)的,分別是Kotlin-Jvm、Kotlin-Native、Kotlin-Js??梢灾苯虞敵鲈脚_(tái)的機(jī)器交付產(chǎn)物,在原生友好性與一致性之間達(dá)到了一個(gè)平衡點(diǎn)。優(yōu)點(diǎn)的同時(shí)也一定會(huì)伴隨痛點(diǎn),例如不同平臺(tái)的不同行為定義,經(jīng)常需要通過在common代碼中定義一些其他平臺(tái)毫無關(guān)心的annotation來實(shí)現(xiàn)。

圖片圖片

Kotlin/Native

  • ${KOTLIN}/bin/kotlin-native是一個(gè)把Kotlin Code轉(zhuǎn)化成不需要虛擬機(jī)從而直接運(yùn)行在目標(biāo)平臺(tái)上的編譯器。他主要包括了基于LLVM的Frontend的實(shí)現(xiàn)以及對(duì)Kotlin的標(biāo)準(zhǔn)庫的Native實(shí)現(xiàn)。
  • ${KOTLIN}/bin/cinterop是一個(gè)把C/ObjC語言的頭文件轉(zhuǎn)化成可被Kotlin/Native調(diào)用的 .klib 的工具。
  • ${KOTLIN}/klib/platform是Kotlin/Native SDK中幫助開發(fā)者默認(rèn) binding 的所有平臺(tái)能力,例如android/ndk ios/Foundation linux/posix等。

優(yōu)點(diǎn):

  1. 在這兩個(gè)工具以及默認(rèn)binding的情況下,我們發(fā)現(xiàn)在實(shí)際開發(fā)的工程化中,語言之間的互操作變得無比的流暢!

缺點(diǎn):

  1. 由于平臺(tái)的binding是放在kotlin中的,導(dǎo)致平臺(tái)的能力層面與kotlin版本之間產(chǎn)生了綁定,例如ios/UIKit是內(nèi)置在Xcode的,也變相產(chǎn)生了kotlin版本與Xcode版本之間的綁定。
  2. 由于編譯到機(jī)器碼的原因編譯時(shí)間相對(duì)JVM較長,同時(shí)由于目前編譯器層需要完整的依賴鏈,導(dǎo)致類似implementation deps這種特性無法實(shí)現(xiàn)。從而降低了增量編譯速度,劣化了體驗(yàn)。

Kotlin/JS ??

雙向interop

由于Kotlin和TypeScript/JavaScript這兩個(gè)語言在語言層面有非常大的差距,例如literal types(https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#literal-types) 在Kotlin中是沒有對(duì)等的, 例如Long類型在標(biāo)準(zhǔn)庫中是沒有對(duì)應(yīng)的JS類型的,例如Error的處理在兩個(gè)語言中是完全不同的,這就導(dǎo)致了在Interop的時(shí)候需要通過大量的轉(zhuǎn)換手段來解決這個(gè)問題。而社區(qū)上也并沒有一個(gè)很好的解決方案。目前相對(duì)(基本不)可用的兩個(gè)工具分別為dukat(https://github.com/Kotlin/dukat) 和 karakum(https://github.com/karakum-team/karakum)。K/JS在B站目前主要用于鴻蒙,而鴻蒙自身也不太穩(wěn)定,導(dǎo)致兩個(gè)不穩(wěn)定因素疊加更不可控。目前我們的做法就是基于 karakum 進(jìn)行問題的修修補(bǔ)補(bǔ),做到勉強(qiáng)可用。

產(chǎn)物缺少精準(zhǔn)strip能力

在K/N中提供了類似-Xexport-library的能力供開發(fā)者決策toplevel模塊的導(dǎo)出,從而基于這棵樹來做unused strip。但是在K/JS中并沒有這樣的能力, K/JS會(huì)導(dǎo)出所有依賴樹中聲明為@JsExport的頂層模塊的樹,看似也是提供了Export的聲明但其實(shí)是并無法解決問題。例如在不同variants的情況下針對(duì)相同模塊有不同的依賴樹, 例如a->b在項(xiàng)目A中需要導(dǎo)出a ,在項(xiàng)目B中需要導(dǎo)出b ,這就導(dǎo)致我們不得不在兩個(gè)模塊中同時(shí)聲明JSExport而這會(huì)導(dǎo)致在A項(xiàng)目中看到不關(guān)心的b模塊。又由于b模塊作為了TopLevel會(huì)導(dǎo)出大量與A無關(guān)的體積代碼。

同symbol name的沖突

  • JsExport-declaration-name-clash-with-ES-modules(https://youtrack.jetbrains.com/issue/KT-60140/MPP-JS-conflicting-JS-signatures-are-not-reported-and-invalid-JS-code-is-emitted)

無法以同ir粒度的模塊切分.d.ts

當(dāng)前K/JS 的TS的生成是基于binary也即是一個(gè)產(chǎn)物一個(gè).d.ts ,把Kotlin所有的模塊合并在了一起,即使通過-Xir-per-module拆分了 .js 但是 .ts缺依然沒拆分。導(dǎo)致K/JS 的復(fù)雜工程無法以模塊的方式提供給其他前端項(xiàng)目使用(例如鴻蒙),只能以聚合產(chǎn)物的方式交付。

產(chǎn)物(artifacts)

klib

圖片圖片

在KMP世界中和過去基JVM的的產(chǎn)物發(fā)生了一些變化,即定義了一種新的 lib 結(jié)構(gòu).klib 我們基本可以把.klib 對(duì)等為一組.class或者一個(gè)class.jar文件。不同的是.klib存放的是 Kotlin IR。

skiko.klib
└── default # compiler/util-klib/src/org/jetbrains/kotlin/library/KotlinLibraryLayout.kt
    ├── ir # compiler/ir/serialization.common/src/KotlinIr.proto 當(dāng)前kotlin編譯產(chǎn)物類似.h+.a
    │     ├── bodies.knb
    │     ├── debugInfo.knd
    │     ├── files.knf
    │     ├── irDeclarations.knd
    │     ├── signatures.knt
    │     ├── strings.knt
    │     └── types.knt
    ├── linkdata # kotlin types 用于鏈接 & 代碼索引提示等
    │     ├── module
    │     ├── package_org
    │     │     └── 0_org.knm
    │     ├── package_org.jetbrains
    │     │     └── 0_jetbrains.knm
    │     ├── package_org.jetbrains.skia
    │     │     ├── 0_skia.knm
    │     │     └── 7_skia.knm
    │     ├── package_org.jetbrains.skia.icu
    │     │     ├── 0_icu.knm
    │     │     └── 1_icu.knm
    │     └── root_package
    │         └── 0_.knm
    ├── manifest
    ├── resources
    └── targets # 編譯目標(biāo)平臺(tái)
        └── ios_arm64
            ├── included # native artifacts
            │     ├── libskia.a
            │     └── skiko-native-bridges-ios-arm64.a
            ├── kotlin
            └── native # .bc

我們可以很明顯看到一個(gè)問題,因?yàn)槠脚_(tái)相關(guān),所以交付的產(chǎn)物是區(qū)分 platform target的,所以Jetbrains的設(shè)計(jì)是通過gradle module(https://docs.gradle.org/current/userguide/publishing_gradle_module_metadata.html) 中的variant來區(qū)分產(chǎn)物,這也導(dǎo)致了下載klib產(chǎn)物時(shí)我們必須遵守 gradle metadata 的協(xié)議,從而沒法使用一些更通用的例如POM(https://maven.apache.org/pom.html)這樣的規(guī)范。(在后文會(huì)提到我們不得已實(shí)現(xiàn)了一個(gè)基于Gradle的產(chǎn)物下載的bazel rule)

universal klib

圖片圖片

在基于variants的情況下,往往我們需要下載(${platform target} + 1(meta)) * lib 個(gè)數(shù)的 klib ,又因?yàn)榇蠖鄶?shù)klib其實(shí)并不像skiko那樣有平臺(tái)相關(guān)的產(chǎn)物, 所以很大一部分都是冗余的,在KotlinConf'24(https://www.youtube.com/watch?v=Ar73Axsz2YA)中也提到了這個(gè)問題。對(duì)于universal klib的后續(xù)launch,我們可以拭目以待。

插件(plugins)

我們可以從Kotlin/KEEP(https://github.com/Kotlin/KEEP)的proposals的數(shù)量看到其實(shí)Kotlin是一種對(duì)語法特別克制的語言。(可以通過與swift對(duì)比 swift/evolution(https://github.com/apple/swift-evolution) )。而Kotlin這么選擇的的原因我個(gè)人認(rèn)為是設(shè)計(jì)了KCP(Kotlin Compiler Plugin)插件系統(tǒng) 來進(jìn)行語言層面能力的彌補(bǔ)。例如 compose compiler 之于 compose生態(tài)、 serialization之于序列化生態(tài)、 ksp之于代碼生成生態(tài)等等。

KCP(Kotlin Compiler Plugin)

KCP的定位就是為Kotlin本身能力進(jìn)行補(bǔ)充,所以他會(huì)與Kotlin編譯器高度綁定,對(duì)ABI向前兼容會(huì)造成非常大的負(fù)擔(dān),這也是幾乎所有的KCP插件(compose、serialization...)都是以Kotlin的版本作為版本號(hào)的原因。這就會(huì)造成大量的維護(hù)以及迭代更新成本。我們同時(shí)也可以看到在K2發(fā)布后,Jetbrains也做了些變化,例如把這些kcp compiler都merge到了kotlin/plugins 也是因?yàn)檫@一點(diǎn)。

KSP (Kotlin Symbol Processor)

由于KCP的問題,所以google為之開發(fā)了ksp生態(tài), ksp的定位是為了解決KCP的問題,他是基于KCP的一個(gè)子插件,用來抽象一層穩(wěn)定的API給開發(fā)者使用。在我們應(yīng)用過程中會(huì)發(fā)現(xiàn)KSP的確能力非常強(qiáng)大,但是他有一個(gè)缺點(diǎn),就是KSP會(huì)要求對(duì)應(yīng)的子依賴進(jìn)行完整編譯,也就是說KSP Processor感知到的上下文是和程序開發(fā)者完全一致的。乍看其實(shí)這是一個(gè)非常好的優(yōu)點(diǎn),但是代價(jià)就是我們需要完整編譯他的子依賴作為他的類似classpath傳入。這個(gè)會(huì)導(dǎo)致一些特別糟糕的體驗(yàn),例如至少編譯一次,才能把KSP的結(jié)果(模塊的編譯輸入)產(chǎn)出。

在這里我們開放一下腦洞,看一下隔壁Swift的Macros(https://docs.swift.org/swift-book/documentation/the-swift-programming-language/macros/) 。他是完全基于當(dāng)前AST分析的,可以非常快速的生成, 當(dāng)然代價(jià)就是喪失了感知依賴模塊的能力。在我們的真實(shí)實(shí)踐過程中,例如類Dagger2(https://dagger.dev/)的Swift實(shí)現(xiàn),我們會(huì)發(fā)現(xiàn)是完全不會(huì)有任何問題的。我們通過Macros來做依賴注入的代碼生成,通過Bazel自定義的rules來Collect所以需要依賴注入的Metadata,最后通過Codegen來生成依賴注入的代碼。所以也暢想是否KSP生態(tài)可以進(jìn)行這塊的能力配置,為開發(fā)者提供更多的選擇配置能力。

Gradle: 構(gòu)建系統(tǒng)

由于上文提到的Kotlin核心層其實(shí)是一個(gè)相對(duì)low level的存在,從易用性和復(fù)雜度兩個(gè)角度來看都是不適合直接被開發(fā)者所接觸的。而KMP世界選擇了Gradle這個(gè)構(gòu)建工具來組織Kotlin的內(nèi)核。接下去我們篇幅會(huì)從工程角度著重討論Gradle這個(gè)構(gòu)建工具及生態(tài)的一些實(shí)踐及問題。

構(gòu)建系統(tǒng)

構(gòu)建系統(tǒng)(build system)的定義為從輸入文件(代碼、資源等)生成輸出文件(可執(zhí)行文件、庫文件等)的一系列步驟的自動(dòng)化構(gòu)建過程。也是一個(gè)構(gòu)建工具的最核心存在。看到這個(gè)定義,給定的輸出得到給定的輸入,我們可以很自然的聯(lián)想到output = f(input) ,而構(gòu)建過程一般都伴隨著拆解和組合, 例如binary = package(link(compile(source))) ,compile典型的是各種編譯器例如gcc、konanc,link典型的是各種鏈接器例如ld,package典型的是打包成apk、ipa等最終產(chǎn)物的過程。那么我們接下來看看Gradle是如何實(shí)現(xiàn)這個(gè)過程的(由于筆者比較熟悉Bazel(https://bazel.build/)構(gòu)建工具,會(huì)較多以Bazel舉例子來說明問題), 我們先復(fù)習(xí)一下Gradle的build phases。

圖片圖片

我們可以看到在Gradle的構(gòu)建過程中,他的設(shè)計(jì)核心是Task的依賴關(guān)系一個(gè)簡(jiǎn)單的帶依賴的Gradle Task如下:

tasks.register('hello') {
    doLast {
        println 'Hello world!'
    }
}
tasks.register('intro') {
    dependsOn tasks.hello
    doLast {
        println "I'm Gradle"
    }
}

回到我們剛才的output = f(input) ,我們可以看到Gradle的構(gòu)建過程并沒有還原最原本的被依賴任務(wù)的輸出作為依賴他的任務(wù)的輸入,而是變相通過了一種Dependency tree的方式描述這個(gè)關(guān)系。

這樣的好處是把依賴關(guān)系從"強(qiáng)類型"變成了"弱類型",intro不需要關(guān)系hello具體交付什么只需要知道hello已經(jīng)交付了他需要的東西。這樣的設(shè)計(jì)帶來的好處是簡(jiǎn)單易懂,但是也帶來了一些問題。如何假設(shè)hello是沒有sideEffects的?這些都是需要插件開發(fā)者自己去保證的。而在Bazel中,他是通過output = f(input)的方式同時(shí)通過starlark這個(gè)有限級(jí)語來限制SideEffects的產(chǎn)生, 從而實(shí)現(xiàn)的可靠編譯,當(dāng)然這樣的取舍就是放棄了靈活的"弱類型"。

雖然Gradle也可以通過定義task.inputs & task.outputs來隱式的還原依賴關(guān)系從而還原依賴樹,但是在復(fù)雜的帶Variants(https://docs.gradle.org/current/userguide/cross_project_publications.html#sec:variant-aware-sharing)的情況下我們需要實(shí)現(xiàn)大量的Configuration以及在Tasks中通過Variants做邏輯分支判斷。只是有通路,但是并沒有從工具層面解決這個(gè)構(gòu)建工具的核心問題。

以下是從編譯工具角度看到的Gradle作為編譯工具一些核心能力的缺失。

一些核心能力的缺失

Toolchains

若我們需要實(shí)現(xiàn)一個(gè)函數(shù)例如 artifacts = compile(input_file) ,我們會(huì)很明顯發(fā)現(xiàn)一個(gè)問題,他不怎么"functional"。因?yàn)楹苊黠@光一個(gè)input_file是遠(yuǎn)遠(yuǎn)不夠的, 至少他還需要一個(gè)編譯器例如fun compile(compile_c, input_file) ,當(dāng)然我們普世的實(shí)踐一般不會(huì)那么"functional",通常做法是通過一個(gè)更大的上下文來獲取這些每個(gè)任務(wù)都需要的輸入, 例如fun compile(input_file, context) { compile_c = context.get_compiler(KOTLIN_COMPILER)} 。在構(gòu)建系統(tǒng)中,這些全局上下文的也即是Toolchains, 而在Gradle的世界里除了JVM作為一等公民的Toolchains是有直接支持的,其他所有的Toolchains很可惜都是沒有的,這就導(dǎo)致了我們?cè)跇?gòu)建過程中需要自己去維護(hù)這些全局上下文。例如檢查環(huán)境,配置,安裝環(huán)境等等。這導(dǎo)致的結(jié)果有兩個(gè)。

  1. 在Initialization階段過重的工作量(無效檢查和運(yùn)行)
  2. 不穩(wěn)定的環(huán)境,導(dǎo)致在執(zhí)行(Execution)階段的千瘡百孔
  3. 沒有官方的支持,開發(fā)者自己實(shí)現(xiàn)的Toolchains往往是不可靠的良莠不齊(含社區(qū)中的一些gradle plugins)

在這里我們可以參考借鑒隔壁的bazel toolchains(https://bazel.build/extending/toolchains)。

Platforms

Java是一門虛擬機(jī)語言,他的平臺(tái)是非常穩(wěn)定的,所以在Gradle中沒有Platforms這個(gè)概念。但是在KMP&跨平臺(tái)世界里,我們不得不面臨交叉編譯這個(gè)問題。而交叉編譯需要針對(duì)不同平臺(tái)環(huán)境的大量上下文做配置支持,例如需要為Host與Execution及Target上不同cpu類型、操作系統(tǒng)的近百種的組合情況做適配。我們舉一個(gè)在Bazel世界非常典型的編譯場(chǎng)景,我們的觸發(fā)是一臺(tái)Apple silicon的電腦,通過分布式編譯把編譯任務(wù)調(diào)度了一組Linux的X86Runner上,最終要編譯一個(gè)Android的包, 那么對(duì)應(yīng)的Host = Darwin arm64, Execution = Linux x86_64, Target = Android arm64。再疊加上文提到的Toolchains能力缺失,這個(gè)問題再一次被指數(shù)級(jí)放大。這只是一種組合關(guān)系,我們?cè)O(shè)想一下在Gradle世界中這件事幾乎是不可能完成的,所以也可以同樣借鑒隔壁的bazel platforms(https://bazel.build/extending/platforms)。

Sandboxing

當(dāng)我們?cè)趯?shí)現(xiàn)fun compile(input_file)的時(shí)候,如果在一個(gè)sandboxing的環(huán)境,我們就可以很好的保證這個(gè)函數(shù)的純粹性,也就是說這個(gè)函數(shù)的輸出只和輸入有關(guān)。如果不在,我們需要寫更多的corner case的處理邏輯,使得這個(gè)函數(shù)變得更加復(fù)雜。在處理輸入時(shí),如果input_file的同級(jí)目錄存在一個(gè)另外一個(gè)文件,在clangc的默認(rèn)行為是可以感知到那個(gè)文件的, 那么錯(cuò)誤的include是否應(yīng)該編譯成功?在處理輸出時(shí),當(dāng)我們沒有正確的設(shè)置 -O他很有可能就污染了我們的工作倉庫,從而讓整個(gè)編譯過程變的難以預(yù)測(cè)和問題追溯。這樣我們也可以同樣借鑒隔壁的bazel sandboxing(https://bazel.build/docs/sandboxing)。

Task based dependency management(基于任務(wù)的依賴管理)

在Gradle中,任務(wù)是構(gòu)建的基本單元,任務(wù)之間的依賴關(guān)系是通過任務(wù)之間的依賴關(guān)系來實(shí)現(xiàn)的。這種方式的優(yōu)點(diǎn)是簡(jiǎn)單易懂,但是有非常大的缺點(diǎn), 他把f = f1(f2(f3(input))) 變成了 f = f1(f2(f3()))也既把所有任務(wù)的參數(shù)全部抹去了,通過依賴關(guān)系(遞歸順序)來執(zhí)行每個(gè)任務(wù)。這樣導(dǎo)致的結(jié)果就是 所有的任務(wù)共享一個(gè)上下文(操作系統(tǒng)及一次Gradle build的Context),也同時(shí)意味著他們每個(gè)人都有能力破壞干預(yù)整個(gè)系統(tǒng)的行為。這就導(dǎo)致Gradle的構(gòu)建過程充滿了意外和不可預(yù)知性。(緩存錯(cuò)誤,環(huán)境錯(cuò)誤,環(huán)狀依賴(非gradle task維度)等等) 同時(shí)這也是他無法實(shí)現(xiàn)上文提到的 #Platforms #Toolchains #Sandboxing的原因。至于哪個(gè)是因哪個(gè)是果,這個(gè)就見仁見智了,筆者的觀點(diǎn)還是Gradle為了易用性犧牲了可靠性。

Directory based source set (基于文件夾維度的源碼集)

在Gradle的source set都是基于文件夾維度的,這個(gè)設(shè)計(jì)可能是從 javac 也是基于文件夾維度的編譯器繼承過來的。這個(gè)設(shè)計(jì)的優(yōu)點(diǎn)是簡(jiǎn)單易懂,但是缺點(diǎn)也是顯而易見的。比起文件,文件夾有更多的不可預(yù)測(cè)性,例如里面的深度,結(jié)構(gòu),決策真正參與編譯的文件等等。如果我們是通過文件維度的可能也會(huì)更方便的實(shí)現(xiàn)基于文件輸入的依賴管理,可惜這一切都是基于文件夾的。相對(duì)前者我認(rèn)為基于文件夾的source set管理并不是什么問題,相反其實(shí)是一種進(jìn)步,只是Gradle自身對(duì)此的處理bug較多,例如非 java 標(biāo)準(zhǔn)的source layout都會(huì)出現(xiàn)一些異常問題。例如我們碰到的為sourceset設(shè)置一個(gè)外部文件夾作為輸入,在gradle cache的情況下他會(huì)無視對(duì)方文件夾的任何改動(dòng),依然命中cache。另外就是由于Bazel是基于文件的, 在展開Gradle project后也給我們?cè)斐梢恍┎槐阒帯?/p>

Variants (Build variants / Feature Flags)

在Gradle世界中主要有兩種處理變種的方式,一種是通過Build variants ,另一種是通過-D參數(shù)。由對(duì)應(yīng)需要分叉的地方進(jìn)行分叉。前者的一個(gè)問題是傳染性,我們需要從更高的層面去控制這個(gè)變種,而且往往我們不會(huì)像android flavor那樣去整個(gè)的分叉, 會(huì)因?yàn)橐粋€(gè)很小的分叉需要寫大量的configuration的resolve的邏輯。而后者的一個(gè)問題除了不可預(yù)知性(因?yàn)樵谶\(yùn)行階段才決策)之外還有一個(gè)問題就是他缺少一些對(duì)編譯系統(tǒng)自身的上下文例如前文提到的Platforms, 我們的variants往往其實(shí)是會(huì)結(jié)合目標(biāo)上下文的一個(gè)邏輯變種,僅僅只有一個(gè)-D我們需要分散大量的和編譯上下文相關(guān)的代碼邏輯到各個(gè)build.gradle中,使得variants幾乎不可控。

在這個(gè)上面,我個(gè)人認(rèn)為Bazel做的也做的比較差,但是這里也提供bazel configuration-attributes(https://bazel.build/docs/configurable-attributes)作為參考。

Kotlin Gradle plugin 和 KMP的開發(fā)體驗(yàn)

KGP

插件系統(tǒng)是社區(qū)對(duì)Gradle生態(tài)的擴(kuò)展,在這一點(diǎn)上Gradle的插件系統(tǒng)是非常強(qiáng)大的,社區(qū)也是非?;钴S的。無論是大廠背書的Kotlin Gradle Plugin、Android Gradle Plugin等等, 又或者是一些庫支持的Compose Gradle Plugin、Serialization Gradle Plugin等等。這些插件都是為了擴(kuò)展Gradle的能力,使得Gradle可以更好的適應(yīng)不同的場(chǎng)景。也是Gradle生命力的核心體現(xiàn)。在這方面上簡(jiǎn)直吊打了其他友商。但是Gradle的插件系統(tǒng)有一點(diǎn)就是他是基于Java這種需要編譯的語言,上文也提到過由于Gradle自身的很多設(shè)計(jì)原因,會(huì)要求插件開發(fā)者承擔(dān)更多的工作, 也對(duì)插件質(zhì)量有更高的要求,這就導(dǎo)致了插件的質(zhì)量良莠不齊,往往我們寫上plugins { xxx }}背后面臨的大量的心智負(fù)擔(dān),特別是當(dāng)plugins有問題時(shí)debug也相對(duì)麻煩。在這里如果有更輕量的腳本語言可能會(huì)有更好的體驗(yàn)。

KMP的開發(fā)體驗(yàn)

IDEA由于自己的插件系統(tǒng)很強(qiáng)大并沒有綁定某個(gè)編譯工具,可以很好的支持Gradle、Maven、Bazel等等。但是從目前社區(qū)的實(shí)踐來看,也只有Jetbrains自家的插件是最好的,其他插件往往是不可靠的。例如Bazel的插件,就算在純JVM上也有很大的問題,更別說其他平臺(tái)的語言或者是Multiplatform了。雖然IDEA在使用過程中會(huì)有內(nèi)存怪獸的現(xiàn)象,但是不得不承認(rèn),在研發(fā)體驗(yàn) 可用性角度上都完爆友商。上文提到的無論是編譯系統(tǒng),插件系統(tǒng),對(duì)于一線研發(fā)而言基本都是不感知的,唯獨(dú)IDEA是工作的日日夜夜的核心效率工具,我們?cè)谏衔囊部梢钥吹剑?Gradle從工程化視角來看是有種種問題的,但是就因?yàn)槭呛诵男使ぞ哌@一個(gè)原因,我們花了將近1/3的工作在適配Gradle之上。

KMP在工程化里的問題

Cache的不可信

雖然我們一直緊跟著Gradle的升級(jí),目前我們也已經(jīng)使用了Gradle8.4。我們依然會(huì)遇到大量的Build Cache的問題,例如上文提到的source set文件變更沒有觸發(fā)rebuild等。導(dǎo)致研發(fā)經(jīng)常需要在本地研發(fā)環(huán)境進(jìn)行clean build等操作。同時(shí)公司Android基于Gradle的構(gòu)建的正式發(fā)布永遠(yuǎn)是clean build,而做對(duì)比公司iOS基于Bazel的構(gòu)建(無論是Debug還是Release)永遠(yuǎn)是incremental build。

所有的build.gradle 都是通過標(biāo)準(zhǔn)模版統(tǒng)一輸出,僅僅使用到了 KGP & AGP最新版本的標(biāo)準(zhǔn)配置,KAPT&KSP 等CodeGen任務(wù)全部靜態(tài)展開,沒有任何Side Effects任務(wù)。

環(huán)境(Toolchains)的不可信

目前bilibili在KMP上的實(shí)踐,并不是通過Kotlin來實(shí)現(xiàn)平臺(tái)的一切,而是依然會(huì)使用原來積累的模塊,提供給KMP interop使用。這個(gè)問題極具加重了Gradle自身的缺點(diǎn), 例如我們?cè)邙櫭傻墓ぞ哝溨幸蕾嚵瞬簧貼ode的環(huán)境,例如我們的iOS工程大量依賴了例如Swift buf Xcode等環(huán)境。這就導(dǎo)致了我們?cè)贕radle的構(gòu)建過程中需要大量的環(huán)境檢查, 同時(shí)涉及到交叉編譯(simulator_x86_64, simulator_arm64,ios_arm64)的interop等,這相當(dāng)于猛攻Gradle的七寸,導(dǎo)致了例如K/N,K/JS的構(gòu)建過程不可信。

最關(guān)鍵的是,在iOS體系中,相對(duì)Java所有的編譯任務(wù)至少有5-10倍的編譯時(shí)長的差距,作為跨平臺(tái)依賴層,我們絕不可因?yàn)橐粋€(gè)不可靠的編譯,從而破壞iOS自身的增量編譯(例如關(guān)閉發(fā)布時(shí)的增量編譯)。

Feature Flags的后置(從編譯時(shí)延后到運(yùn)行時(shí))

bilibili采取的是monorepo的項(xiàng)目組織,即不同個(gè)App都是一個(gè)倉庫交付的,這也導(dǎo)致了我們構(gòu)建過程會(huì)需要海量的Feature flag來控制編譯行為。在歷史經(jīng)驗(yàn)中,由于我們Android項(xiàng)目是Gradle組織的,雖然有flavors但是依然會(huì)有一些App長期處于遠(yuǎn)遠(yuǎn)落后主干的情況出現(xiàn),追其原因其實(shí)是業(yè)務(wù)訴求對(duì)條件編譯的復(fù)雜度非常高,而Variants來實(shí)現(xiàn)條件編譯太困難了。導(dǎo)致的另外一個(gè)問題是,很多研發(fā)同學(xué)會(huì)把Feature Flag搬到運(yùn)行時(shí),例如通過運(yùn)行時(shí)來判斷一些當(dāng)前的環(huán)境從而走不同的邏輯分支,這樣無論對(duì)工程化,而是App runtime來說都是一個(gè)問題。雖然我們的KMP工程復(fù)雜度規(guī)模并沒有到這一步,但是從Android的經(jīng)驗(yàn)來看這件事必定會(huì)發(fā)生,即我們需要一個(gè)更可用的編譯期Feature Flags的能力。

不可預(yù)知的編譯過程

由于Gradle的設(shè)計(jì),每個(gè)Project自己的build.gradle是可以由研發(fā)自己書寫的,而正如前文所提到的感知全文(Context)的問題,我們根本無法控制開發(fā)者的行為。因此我們需要大量的后驗(yàn)動(dòng)作來保證整個(gè)編譯過程的可靠性??墒侵挥性贕radle的build phases中提到的三個(gè)階段中Initialization和Configuration完成后我們才可以確定Execution phase的圖結(jié)構(gòu),在有了圖結(jié)構(gòu)之后 我們才可以做一些服務(wù)工程健康的工作,例如依賴檢查(是否存在不合理的依賴),編譯預(yù)測(cè)(某個(gè)模塊的是否會(huì)造成更大的增量從而導(dǎo)致整個(gè)編譯過程變慢)等等。然而這一切都是要在這兩個(gè)階段之后才可以做到。以我們公司的Android項(xiàng)目為例,在非增量情況下這兩步往往需要數(shù)分鐘之久的執(zhí)行(不含下載),這會(huì)導(dǎo)致我們要把剛才提到的旁支檢查鏈路通過插件等形式插入到主流程之中,從而拖慢整個(gè)的pipeline效率。

iOS上"一廂情愿"的推薦工作流

CocoaPods有著先發(fā)優(yōu)勢(shì),在iOS的社區(qū)中有著較好的庫支持的基礎(chǔ),所以KMP會(huì)選擇通過CocoaPods來CInterop原生庫以及通過CocoaPods中的XcodeProj來生成用來把KMP for iOS產(chǎn)物的XcodeEmbed工程。另一種方式是通過embedAndSignAppleFrameworkForXcode來通過Xcode的環(huán)境變量來把.framework 產(chǎn)物送進(jìn)編譯以及集成resource,重簽等一系列工作。

無論哪種方式都有些一廂情愿,首先是CocoaPods他并不是蘋果官方的推薦包管理方式,僅僅是出得早,而出得早自然也有一堆歷史的負(fù)擔(dān),這里不展開細(xì)說,但是我們無論從社區(qū)上例如tuist XcodeGen struct Carthage, 亦或是一些商業(yè)公司自己自研的包管理工具,無疑例外都不會(huì)把CocoaPods放在首位。使得這條集成鏈路不可使用。

另外一種方式是通過embedAndSignAppleFrameworkForXcode ,這個(gè)方式是通過Xcode的環(huán)境變量來控制上下文, 嚴(yán)重假設(shè)了Xcode本身,例如bilibili就完全不使用xcodebuild,那么對(duì)應(yīng)的上下文就完全失效了。同時(shí)他作為插在Xcode build phase的一個(gè)環(huán)節(jié),對(duì)Xcode的日志等集成也不夠友好,經(jīng)常導(dǎo)致忙等,卡死等問題。

bilibili的優(yōu)化及實(shí)踐

KMP有一整套基于Gradle的完整生態(tài),雖然很不想……但是我們還是要面對(duì)一些工程化中不可解決的問題……從而自己造一套輪子……(就像當(dāng)年不爭(zhēng)氣的xcodebuild那樣,我們需要的不是自行車而是戰(zhàn)車)。

我們會(huì)發(fā)現(xiàn)KMP的核心層(Kotlin Core)是非常健壯且完善的,在編譯工具的生態(tài)建設(shè)方面,還有改進(jìn)空間,特別是在支持大型企業(yè)級(jí)大規(guī)模、多團(tuán)隊(duì)協(xié)作的項(xiàng)目時(shí)。同時(shí)Gradle的設(shè)計(jì)也更多的從易用性的角度出發(fā),而犧牲了工程的上限。所以 我們的選擇是直面Kotlin-Core層,為其重新做一層面向開發(fā)者的編譯工具(Bazel)的封裝, 從而解決我們碰到的KMP Gradle插件和 Gradle 本身設(shè)計(jì)的問題。通過這種封裝,希望改善跨平臺(tái)開發(fā)工程結(jié)構(gòu),減少百萬行工程規(guī)模的(KMP+原生代碼)構(gòu)建時(shí)間,同時(shí)提供更加穩(wěn)定和可靠的構(gòu)建過程。

在上文 Kotlin Gradle plugin 和 KMP的開發(fā)體驗(yàn)(http://uat-kntr.bilibili.co/docs/kmp-core.html#KMP%E7%9A%84%E5%BC%80%E5%8F%91%E4%BD%93%E9%AA%8C) 中有提到我們最不可割舍的就是IDEA與Gradle的集成時(shí)的研發(fā)體驗(yàn),同時(shí)又因?yàn)槲覀傾ndroid項(xiàng)目是用Gradle組織的, 我們并不想通過產(chǎn)物的方式集成(雖然產(chǎn)物的方式集成可以解耦編譯系統(tǒng)之間環(huán)境耦合,但是會(huì)大幅度增加代碼的碎片化和代碼切片同步的問題),所以我們完整實(shí)現(xiàn)了一個(gè)由Bazel組織且可自行編譯的KMP工程,同時(shí)也實(shí)現(xiàn)了一套Bazel to Gradle的秒級(jí)轉(zhuǎn)化工具,從而使得我們可以在IDEA中使用Gradle的研發(fā)體驗(yàn), 同時(shí)可以作用于 iOS.bazel 和 Android.gradle 和 KMP.bazel 的CI體系。

構(gòu)建系統(tǒng)替換為Bazel

相對(duì)Gradle插件生態(tài)而言,Bazel的Rules生態(tài)只能說慘不忍睹,唯一相對(duì)可用的rules_kotlin 也僅僅只能服務(wù)于kotlin.jvm,并且未對(duì)kmp的能力做任何支持。所以我們的主要工作就在建設(shè)這些rules之上。

圖片

  • kmp.ci 全量開啟incremental build (含所有模塊的三個(gè)平臺(tái)編譯 + 三個(gè)平臺(tái)的產(chǎn)物編譯dry run) 平均控制在1分鐘內(nèi)。

圖片圖片

  • 靈活的Feature flags

kmp 模塊(RPC,日志,圖片,AB等等)通過各種feature flags分別支持原平臺(tái)的provider能力注入,由kt實(shí)現(xiàn)的default注入,mock app等多種變化,使得kmp倉庫獨(dú)立運(yùn)行成為可能。

  • 靜態(tài)化的編譯過程

所有build.gradle自動(dòng)生成中沒有任何DSL配置之外的動(dòng)態(tài)化能力。DSL只有KGP及AGP??呻S時(shí)應(yīng)對(duì)上游KMP生態(tài)發(fā)生的變更。同時(shí)由于bazel query的高性能做到快速針對(duì)研發(fā)workspace的自定義配置。從而加速優(yōu)化IDEA sync等緩慢的體驗(yàn)問題。

  • 更優(yōu)的雙向interop能力

無需對(duì)CInterop或者反向集成平臺(tái)書寫額外任何配置(只需書寫對(duì)應(yīng)語言的$Language_library ),通過Bazel aspect 自動(dòng)化生成每個(gè)平臺(tái)的雙向interop bridge層,節(jié)省大量的boilerplate config的工作。

  • Gradle的plugin生態(tài) 對(duì)于社區(qū)上的大量的Gradle Kotlin生態(tài)的插件,我們需要去解刨出他內(nèi)部的核心實(shí)現(xiàn),例如 KSP KCP,通過更low level的層來接入。對(duì)于非Kotlin生態(tài)的插件,我們需要閱讀實(shí)現(xiàn)后重新用Bazel進(jìn)行包裹。
  • 學(xué)習(xí)曲線 由于替換了構(gòu)建工具,配置需要從build.gradle轉(zhuǎn)成 BUILD.bazel。雖然.bazel 遠(yuǎn)比 .gradle簡(jiǎn)潔,但是礙于生態(tài),IDE等問題,書寫較為困難(無法在IDEA體系里寫,需要在vscode中通過bazel插件來高亮) 同時(shí)對(duì)于Bazel rules開發(fā)需要接受遠(yuǎn)比gradle體系更多的知識(shí),從而導(dǎo)致rules的開發(fā)曲線較為陡峭。
  • Bazel自身的缺陷 我們?cè)谏厦妗度 分姓f到了通過Bazel解決掉的Gradle問題,但同樣Bazel自身也有一些問題。
  1. 靜態(tài)化, Bazel的高效其實(shí)是通過他的靜態(tài)化換來的,而靜態(tài)化會(huì)出現(xiàn)一些問題,例如apple_support。(https://github.com/bazelbuild/apple_support)在macos下的cctoolchains是假設(shè)在xcode存在的情況下的, 而單純的Android開發(fā)是沒有對(duì)應(yīng)環(huán)境的,而在bazel.WORKSPACE 中我們是沒有辦法為他們set不同的toolchains,雖然apple_support 正在實(shí)現(xiàn)通過command line tools來實(shí)現(xiàn)CCToolcahins ,但是我們可能并沒有等他的條件。
  2. 與Kotlin-Core生態(tài)的融合,Bazel會(huì)要求輸入輸出是絕對(duì)明確的,然而Kotlin-Core中很多任務(wù)是曖昧的,即產(chǎn)物是什么是有可能不一樣的。在這種情況下Bazel去兼容他們是相對(duì)復(fù)雜的。例如KSP任務(wù),他的產(chǎn)物大概率是可有可無的。

付出

  • 對(duì)Kotlin-Core的能力復(fù)原 (KAPT,KCP,KSP,OPTIN,Multiplatform,...)
  • 對(duì)Gradle resolve能力的復(fù)原 (rules_gradle_external)
  • 對(duì)KGP的能力的復(fù)原 (rules_kotlin_mobile)
  • 對(duì)其他生態(tài)能力的復(fù)原 (compose.resource.compiler,...)

值得嗎?

從上文我們可以看到,由于Bazel生態(tài)的不太給力,我們的Bazel化工作量是不小的,我自己作為這個(gè)項(xiàng)目的發(fā)起及負(fù)責(zé)人其實(shí)也是有很多猶豫和糾結(jié)的。在項(xiàng)目早期我自己也多次在復(fù)刻實(shí)現(xiàn)Gradle生態(tài)的一些能力時(shí)質(zhì)疑自己的決策,是不是只是因?yàn)槲以谑孢m區(qū)更了解Bazel而已?重復(fù)再干一遍是不在自high?

但是隨著這些一次性得付出的工作的逐漸完成,逐漸我也打消了顧慮。

  1. 極限優(yōu)化本身就是破壞再重組的過程,我們的目標(biāo)是為了更好的工程化,而不是為了更簡(jiǎn)單的KMP化。
  2. 很多不可解的或者需要等待社區(qū)解決的一個(gè)round trip的問題我們都有了解決的能力。
  3. 我們順便也解決的Android工程長期存在的一些問題(例如增量編譯,依賴版本管理,Gradle Initialization緩慢等)。

而把Kotlin-Core的能力重新組織的過程也更進(jìn)一步讓我理解了KMP的核心,兼容Gradle更讓我從原理上理解了Gradle的設(shè)計(jì),從而在兩個(gè)前沿的構(gòu)建系統(tǒng)中取長補(bǔ)短。而無疑選擇一個(gè)上限更高的構(gòu)建系統(tǒng),犧牲很小一部分社區(qū)生態(tài)是劃得來的。

對(duì)KMP生態(tài)的展望

Kotlin-Native 實(shí)現(xiàn)implementation deps

當(dāng)前種種原因,K/N下其實(shí)是不存在impl deps這個(gè)能力的,所以模塊依賴都是穿透的。這個(gè)會(huì)導(dǎo)致很嚴(yán)重地依賴穿透問題。雖然我們目前通過魔法繞過了這個(gè)問題,但是依然希望官方通過正經(jīng)的方式來解決。

Kotlin-Native 以Kotlin模塊的維度拆分

Native模塊

當(dāng)前K/N的最終產(chǎn)物是鏈接在一起的,這也一樣會(huì)導(dǎo)致不必要的incremental build的問題。同時(shí)也會(huì)讓對(duì)應(yīng)平臺(tái)的調(diào)用側(cè)感知到太多的不需關(guān)注的東西。這塊我們目前也沒有解決方案,只能希望官方可能在為Swift做interop的契機(jī)下,把K/N對(duì)平臺(tái)的可見從鏈接后的產(chǎn)物拆到每個(gè)獨(dú)立模塊。

Kotlin-Native 增強(qiáng)debugging能力

當(dāng)前K/N的產(chǎn)物中debug info都是絕對(duì)路徑,這會(huì)導(dǎo)致lldb在調(diào)試的時(shí)候找不到對(duì)應(yīng)的源碼,同時(shí)也會(huì)對(duì)分布式緩存/編譯造成影響。目前我們通過指定source-map來擦除掉了我們自己代碼的路徑信息,但是依然解決不了官方產(chǎn)物的那些問題,也希望后續(xù)可以擦除抑或是發(fā)布編譯的source-map。

Kotlin.LSP

目前Kotlin的LSP能力是集成在Kotlin源碼內(nèi)部的,這對(duì)生態(tài)例如基于VSCode的KMP開發(fā)并不友好,雖然商業(yè)因素較多,但是還是暢想下希望有朝一日可以更多的開放出來。

總結(jié)

本文從一個(gè)較大規(guī)模的工程化的視角來解讀分析了KMP的構(gòu)建系統(tǒng),插件系統(tǒng),IDEA集成等等問題,并且提出了我們的解決方案。同時(shí)也對(duì)KMP生態(tài)的未來做了一些展望。在Kotlin-core這一章節(jié)提到的所有問題也都已在issue tracker中記錄,也希望盡早被得到解決。也希望給到面臨相同問題的開發(fā)者得到一些解決思路。

責(zé)任編輯:武曉燕 來源: 嗶哩嗶哩技術(shù)
相關(guān)推薦

2021-11-22 06:17:26

npm工程化工具

2015-10-26 10:32:01

前端優(yōu)化工程化

2023-09-15 10:33:45

前端工程化commit

2024-01-19 09:21:35

攜程開源

2022-12-01 07:46:01

工程化工具

2021-05-18 19:18:50

前端工程化工程

2022-08-20 18:28:49

汽車軟件

2022-07-26 17:19:11

前端前端工程化

2022-10-09 14:50:24

前端pnpm工具

2021-07-06 10:03:05

軟件開發(fā) 技術(shù)

2022-08-17 11:33:35

前端配置

2021-06-05 18:01:05

工具Rollup前端

2018-05-18 10:08:15

人工智能移動(dòng)平臺(tái)大數(shù)據(jù)

2018-06-15 10:12:04

滴滴前端分支管理

2021-03-19 07:23:23

Go架構(gòu)Go工程化

2023-02-15 18:12:43

開發(fā)企業(yè)級(jí)CLI

2021-12-09 11:30:46

CSS技術(shù)前端

2024-05-27 13:46:16

2022-07-06 11:20:16

前端開發(fā)

2024-07-02 10:48:04

語言項(xiàng)目配置
點(diǎn)贊
收藏

51CTO技術(shù)棧公眾號(hào)