歡迎光臨
每天分享高質量文章

知乎 Android 客戶端元件化實踐

作者:老邢Thierry

連結:https://www.jianshu.com/p/f1aeb0369746

背景

知乎 Android 客戶端最早使用的是最常見的單工程 MVC 架構,所有業務邏輯都放在了主工程 Module 裡,網路層和一些公共程式碼分別被抽成了一個 Module。現在看來,當時的業務線、產品功能及研發團隊都比不上現在的體量和豐富度,遇到的問題隨時組內溝通就可以解決。所以在知乎穩步發展的前幾年,並沒有遇到什麼大的問題。

早期架構圖極簡版圖:

     

後來公司發展速度加快,拆分了多個獨立的事業部,每個事業部有獨立的 Android 開發團隊,每個團隊都有獨立開發、測試和部署的需求;隨著業務規模的擴大,早期的程式碼耦合導致的問題也逐漸顯現出來;開發人員也越來越多,單工程的架構在人員協作方面也顯得越來越力不從心。同時考慮到對未來可能出現的多應用的支援,我們開始了工程的元件化重構。今天我們會在這篇文章中分享我們元件化過程中的一些實踐。

元件化實踐

我們使用的是多工程多倉庫的方案,即每個元件都有自己的獨立倉庫,均可獨立於主工程單獨執行;主工程透過 aar 依賴各個元件,自身則逐漸被拆成一個殼的狀態,不包含業務邏輯程式碼。經過一年多的不斷迭代,現在是這個樣子:

它包含 4 個層次:
主工程:除了一些全域性配置和主 Activity 之外,不包含任何業務程式碼。
業務元件:最上層的業務,每個元件表示一條完整的業務線,彼此之間互相獨立。
基礎元件:支撐上層業務元件執行的基礎業務服務。
基礎 SDK:完全業務無關的基礎程式碼。
各層次職責清晰獨立,可以很方便的進行拆解和組合;由於都有自己的版本,業務線可以獨立發版,隨時升級、回滾。

基本解耦方案

元件化的第一步就是對要拆出去的元件進行解耦,常見解耦方式有以下幾種:

(1) 公用程式碼處理:
基礎業務邏輯分別拆成基礎元件
自身邏輯完整、用於完成某一特定功能、不含業務邏輯的一組程式碼,獨立成 SDK
程式碼量很小不足以拆分成單獨拆分的程式碼和資源,我們統一放在一個專門建立的 common 元件中,並且嚴格限制 common 元件的增長。隨著元件化的逐漸進行,common 應該逐漸變小而不是增大。
碰巧被共同使用的一些程式碼和資源片段,通常它們被覆用只是因為被開發人員搜尋到而直接使用了,很多時候某個資源已經被 A 業務宣告了字首,但是由於沒有隔離,仍然會不可避免的被他人在 B 業務中強行復用,這時候如果 A 業務方要進行一些修改,B 業務就會受到影響 —— 這種情況我們允許直接複製

(2) 初始化:有些元件有在應用啟動時初始化服務的需求,而且很多服務還是有依賴關係的,最初我們為每個元件都添加了一個 init() 方法,但是並不能解決依賴順序問題,需要每個元件都在 app 工程中按順序新增初始化程式碼才能正常執行,這使得不熟悉整套元件業務的人很難建立起一個可以獨立執行的元件 app。因此我們開發了一套多執行緒初始化框架,每個元件只要新建若干個啟動 Task 類,併在 Task 中宣告依賴關係即可:

啟動順序示例圖:                                  

                                          

這樣就解決了元件在主工程中堆積初始化程式碼的問題,在簡化了程式碼的同時還有加快啟動速度的功效。

(3) 路由:介面間使用 Url 進行跳轉,不但實現瞭解耦,也統一了各端的頁面開啟方式。我們實現了一套靈活小巧的路由框架 ZRouter,它支援多元件、路由攔截、AB Test 、引數正則匹配、降級策略、任意引數傳遞以及自定義跳轉等功能,可以自定義路由的各個階段,完全滿足了我們的業務需求。

(4) 介面:除了頁面間的跳轉,不同業務之間不可避免的會有一些呼叫,為了避免元件的直接通訊,通常都是使用介面依賴的方式。我們實現了一個 Interface Provider 來支援介面通訊,它可以透過執行時在動態註冊一個介面,同時也實現了對於 ServiceLoader 的支援。只要一方元件將通訊介面暴露出來,使用方就可以直接使用介面進行呼叫。

動態註冊介面

Provider.register(AbcInterface.class,new AbcInterfaceImpl())

獲取實體並呼叫

Provider.get(AbcInterface.class).doSomething()

(5) EventBus:這個自不必說,雖然說濫用是一個問題,但是有些場景下,使用事件還是最為方便簡單的方式

(6) 元件 API 模組:上面提到的介面和事件以及一些跨元件使用的 Model 放到哪裡好呢?如果直接將這些類下沉到一個公共元件中,由於業務的頻繁更新,這個公共元件可能會更新得十分頻繁,開發也十分的不方便,所以使用公共元件是行不通的,於是我們採取了另一種方式——元件 API :為每個有對外暴露需求的元件新增一個 API 模組,API 模組中只包含對外暴露的 Model 和元件通訊用的 Interface 與 Event。有需要取用這些類的元件只要依賴 API 即可。

相互獨立的元件,其實可能是藕斷絲連的

一個典型的元件工程結構是這個樣子:

以上圖為例,它包含三個模組:
template :元件程式碼,它包含了這個元件所有業務程式碼
template-api:元件的介面模組,專門用於與其他元件通訊,只包含 Model、Interface 和 Event,不存在任何業務和邏輯程式碼
app 模組:用於獨立執行 app,它直接依賴元件模組,只要新增一些簡單的配置,即可實現元件獨立執行

元件半自動拆分

有瞭解耦的方法,剩下的就是採取行動拆分元件了,拆元件是一個很頭疼的問題,它非常考慮一個人的細心與耐心,由於無法準確知道有哪些程式碼要被拆走,也不能直觀的知曉依賴關係,移動變得非常的困難且容易出錯,一旦不能一次性拆分成功,到處都是編譯錯誤,便只能靠人肉一點一點的挪。

工欲善其事,必先利其器。為瞭解決這個問題,我們開發了一個輔助工具 RefactorMan: 它可以遞迴的解析出工程中所有原始碼的取用和被取用情況,同時會根據預設規則自動分析出所有不合理的依賴,在開發人員根據提示解決了不合理依賴之後,即可將元件一鍵移出,大大減少了拆元件的工作量。我們在元件化初期曾經走過一些彎路,最初拆出的八個元件工程的的部分原始碼經歷了幾次的反覆移動才得出最優解,而有了 RefactorMan,我們可以面對反覆的拆分和組合元件有恃無恐

Bonus :由於可以分析和移動資源,所以額外獲得了清理無用資源的功能

聯合編譯完整包

單獨執行元件 app 並不能完整的改寫所有的 case,尤其是在給 QA 測試的時候,還是需要編譯完整的主工程包的,所以我們需要一個直接編譯完整包的方案:
最初我們的實現方式只針對元件,比較簡單:
首先在 setting.gradle 中動態引入元件 module:

def allComponents = ["base""account" ... "template" ...]
allComponents.forEach({ name ->
    if (shouldUseSource(name)) {
        // 動態引入外部模組
        include ":${name}"
        project(":${name}").projectDir = getComponentDir(name);
    }
})

然後在 app/build.gradle 中切換依賴,需要將所有被間接依賴的元件全部 exclude 以防止同時依賴了一個元件的 module 和 aar:

allComponents.forEach({ name ->
    if (shouldUseSource(name)) {
        implementation(project(":${name}")) { exclude group: COMPONENT_GROUP }
    } else {
        implementation("${COMPONENT_GROUP}:${name}:${versions[name]}") { exclude group: COMPONENT_GROUP }
    }
})

由於所有元件的 group 都是一樣的,所以這樣做並沒有什麼問題,但是後來一些基礎 SDK 也出現了這種需求,這時候就需要一種通用的原始碼依賴方案,因此做了一下修改,直接使用 gradle 提供的依賴替換功能,只需要修改 setting.gradle 即可:

// ... 忽略讀取配置程式碼 ...
configs.forEach { artifact, prj ->
    include ":${prj.name}"
    project(":${prj.name}").projectDir = new File(prj.dir)
}
gradle.allprojects { project ->
    if (project == project.rootProject) {
        return
    }
    project.configurations.all {
        resolutionStrategy.dependencySubstitution {
            configs.forEach { artifact, prj ->
                // 在這裡進行替換
                substitute module(artifact) with project(":${prj.name}")
            }
        }
    }
}

而 build.gradle 的依賴寫法與普通的工程完全一樣。

普通狀態下的的主工程:

原始碼取用 template 元件後的主工程:

這樣我們就可以像之前在單工程中一樣寫程式碼了。

得益於原始碼取用,我們直接在提交元件程式碼的時候,CI 會自動聯合主工程編譯出完整包,QA 會根據完整包進行測試,在測試透過後即可自動釋出到公司的倉庫,並透過內部的整合平臺整合到主工程。

小 tip :工程 .idea/vcs.xml 中定義了當前工程關聯的 Git 倉庫,可以在聯合編譯的同時透過修改 vcs.xml 來把元件目錄也關聯到主工程 Git 配置中,在開發過程中就可以使用 Android Studio 的內建 Git 功能了。

包含子業務線的元件

我們當前的元件,絕大部分是一個元件一個倉庫的,對於一般的元件來說,並沒有什麼問題,但是對於有的業務線,本身規模比較大,包含了若干個子業務,比如知乎大學,電子書、live 和私家課等子業務,這些子業務本身功能獨立,但是共享整個業務線的基礎程式碼,同時大業務線也有會一些彙總所有子業務的頁面,它們的關係是這個樣子:

這幾個業務如果都要拆分出去獨立成元件,然後抽離公共部分成為也成為一個業務線基礎元件,這時候會面臨一個很大的問題:由於幾條業務線都屬於同一個主業務線,做活動或者上新 Feature 的時候,這幾個元件經常會發生聯動,需要先更新 base 再更新其他業務線,提交 mr 也要同時提多個倉庫,出現頻繁的連鎖更新;而如果不拆的話,業務線程式碼本身就已經很龐大,即使是單獨編譯元件 app 也會很慢,並且隨著時間的推移,各個業務線的程式碼邊界會像元件化之前的主工程一樣逐漸劣化,耦合會越來越嚴重。

所以現在需求變成了這個樣子:

對外保持只有一個元件:有聯動需求的時候,元件仍然只釋出一次更新
各個子業務仍舊保持互相獨立和隔離,可以獨立執行
我們曾經試圖使用 sourceSets 的方式將不同的業務程式碼放到不同的檔案夾,但是 sourceSets 的問題在於,它並不能限制各個 sourceSet 之間互相取用,base 模組甚至可以直接取用最上層的程式碼,雖然可以在編譯期進行檢查,但是總有一些後知後覺的意味,並且使用 sourceSets 想讓各個模組單獨跑起來配置也比較麻煩。而 Android Studio 的 module 天然具有隔離的優勢。所以我們的解決方案是在元件工程中使用多 Module 結構:

各個子業務線分別拆成同一個工程中不同的 Module:它們共同依賴 base ,同時各個業務線互相不依賴,這些子業務又在一個主 Module 中彙集起來,正如上面圖片所示那樣

對於外界來說只有一個 main 元件,如果直接透過 ./gradlew :main:uploadArchives 來釋出,那麼就只能把 main Module 的程式碼釋出上去,其他 Module 的程式碼是無法釋出的,所以我們需要在釋出的時候將所有的程式碼合併到 main 中去。這時候只能使用新增 sourceSet 的方式,而一旦使用了 sourceSet,程式碼就不再隔離了。所以我們使用了一個動態的策略:編譯時使用 sourceSet 依賴,其他時候使用 module 依賴,這樣可以同時擁有兩者的優勢。

也就是說:錶面看起來,這是一個普通的多模組的工程,但是實際上,他們的關係是動態的:寫程式碼時是七個葫蘆娃,編譯時是葫蘆小金剛:

如何做到呢,可以簡單的判斷當前啟動的 Task,一般我們只在 assemble、install、upload 的時候使用合體操作,而其他時候使用普通的 project 依賴,示例程式碼如下:

boolean useSource = gradle.startParameter.taskNames.any {
    it.contains("assemble") || it.contains("install") || it.contains("upload"))
}
subProject.forEach { subProject ->
    if (useSource) {
        android.sourceSets.main {
            java.srcDirs += file("../${subProject}/src/main/java")
            res.srcDirs += file("../${subProject}/src/main/res")
        }
    } else {
        dependencies { implementation project(":$subProject") }
    }
}

其他資源例如 resources、assets、aidl、renderscript、jni、jniLibs、shaders 以及 aar 和 jar 檔案,它們都是多檔案的,可以使用與上面類似的方法新增。

但是 manifest 不同,一個 module 中只有一個 AndroidManifest.xml ,所以需要有一個方法將子業務的 manifest 合併。我們使用了官方提供的 ManifestMerger 實現了 manifest 的合併,這裡不再展開合併的具體程式碼,有興趣的同學可以自己去看原始碼。

將上面程式碼封裝了一個方法 using,主 module 就可以這樣取用子 module 了:

dependencies {
    using "base"
    using "sub1"
    using "sub2"
    using 'sub3'
    using 'sub4'
}

由於每個子業務元件都是獨立的,仍然可以單獨配置獨立編譯獨立執行,由於每個業務的程式碼量相對整個業務線來說大大減少了,所以得到了更快的編譯速度。

總結

最近兩年很多公司都開始了 App 的元件化,元件化的基礎思想都是相通的,但是並沒有一個放之四海而皆準的通用解決方案,各個公司在元件化的過程中都會根據自身的情況不斷的調整方案,適合自身發展的,才是最好的。一些元件化初期看起來不起眼的問題,可能進行到後期才會慢慢顯現出來,這時候就要及時調整方案。知乎的元件化也是在不斷的變動中逐漸完善的,並且以後肯定也會隨著業務和程式碼的變動不斷的進行最佳化,這會是一個持續的過程,後續我們也會持續分享一些元件化遇到的問題和解決方案。

以上就是我們在元件化過程中的一部分實踐,由於本人的水平有限,如有錯誤和疏漏,歡迎各位同學指正。

另外,知乎移動平臺團隊也在招人中,歡迎各位小夥伴的加入,和我們一起做一些酷事情!具體招聘資訊在這裡https://app.mokahr.com/apply/zhihu#/job/7b1b32c2-f30c-4638-93ce-09c2ac9a52d8

關於作者

潘志會,2016 年加入知乎,現為知乎 Android 基礎架構團隊負責人,有著豐富的 Android 工程化,元件化經驗,設計並主導了知乎的 Android 元件化拆分工作。


●編號373,輸入編號直達本文

●輸入m獲取到文章目錄

推薦↓↓↓

Java程式設計

更多推薦18個技術類公眾微信

涵蓋:程式人生、演演算法與資料結構、駭客技術與網路安全、大資料技術、前端開發、Java、Python、Web開發、安卓開發、iOS開發、C/C++、.NET、Linux、資料庫、運維等。

贊(0)

分享創造快樂