使用整潔架構優化你的 Gradle Module

語言: CN / TW / HK

theme: orange highlight: vs


前言

現代的 Android 專案都是 Gradle 工程,所以大家都習慣於用 Gradle Module 來劃分和組織程式碼,Module 的大量使用也帶來一個問題,一個大專案往往幾十上百的 Module,但是當數量眾多的 Module 之間的依賴關係不合理時,仍然會嚴重拖慢工程的編譯速度,如何更科學地組織 Gradle Module 是 Android 開發領域的普遍需求。

從事 Android 開發的同學可能都聽說過 Clean Architecture,即所謂整潔架構。Google 推薦大家使用它對 MVVM 進行更合理的分層。整潔架構的概念出自以下這本書(國內譯本:程式碼整潔之道),關於這本書以及作者 Bob 大叔的大名這裡就不多介紹了,說這是軟體架構方面的聖經也不為過。

除了優化 MVVM 這樣的業務架構,這本書在元件設計方面也產出了不少最佳實踐和方法論,可用來優化 Gradle 這樣的工程架構。本文就來討論如何基於整潔架構中的各種設計原則來設計我們的 Gradle Module。

文章目錄如下:

  • Module 粒度劃分
  • 複用釋出等價原則(REP)
  • 共同封閉原則(CCP)
  • 共同複用原則(CRP)
  • 三原則的權衡
  • Module 依賴關係
  • 無環依賴原則(ADP)
  • 穩定依賴原則(SDP)
    • 穩定度公式
  • 穩定抽象原則(SAP)
    • 抽象度公式
  • 不穩定度與抽象度的關係
    • 痛苦區與無用區
  • 總結


Module 粒度劃分

參考 Clean Architecture 中對元件的定義:

元件是軟體的部署單元,是整個軟體系統在部署過程中可以獨立完成部署的最小實體。例如,對於 Java 來說,它的元件是 jar 檔案。而在 Ruby 中,它們是 gem 檔案。在 .Net 中,它們則是 DLL 檔案。

Android 中 Gradle Module 是釋出 JAR 或者 AAR 的基本單元,因此 Module 可以看作是一個元件,在 Module 粒度劃分上,我們套用書中關於元件劃分的三個原則:

  1. 複用釋出等價原則(Release Reuse Equivalency Principle)
  2. 共同封閉原則(The Common Closure Principle)
  3. 共同複用原則(The Common Reuse Principle)

複用釋出等價原則(REP)

軟體複用的最小粒度應等同於其釋出的最小粒度

REP 告訴我們 Module 劃分的一個基本原則就是程式碼的可複用性,當一些程式碼有被複用的價值時,它們就應該被考慮拆分為 Module,可用來獨立釋出。此外 REP 還要求我們注意 Module 不能拆分過度。當我們釋出 AAR 時都需要為其設定釋出版本號,其中一個重要原因是如果不設定版本號就無法保證被元件之間能夠彼此相容。這在 androidx 系列元件中尤為突出,我們經常遇到因為版本不一致造成的執行時問題,產生這種不一致的一個重要原因就是,元件的拆分過度。

如果兩個可以獨立釋出的元件,它們總是作為一個整體被複用,就會出現可複用的粒度大於可釋出粒度的問題,也就增大了版本衝突的概率, 此時可以考慮將它們合二為一,同時釋出、避免版本不一致。

在小團隊中這個問題不突出,因為通過人為約定可以保證所以元件同時釋出,但是在跨團隊的大型專案中,如果一個功能的升級總是要多個團隊一起配合,那溝通成本是難以忍受的。

共同封閉原則(CCP)

元件中的所有類對於同一種性質的變化應該是共同封閉的,即一個變化的影響應該儘量侷限在單個元件內部,而避免同時影響多個元件,我們應該將那些會因為相同目的而同時修改的類放到同一個元件中,而將不會為了相同目的同時修改的那些類放到不同的元件中。

相對於 REP 關注的可複用性,CCP 強調的是可維護性,很多場景下可維護性相對於可複用性更加重要。CCP 要求我們將所有可能被一起修改的類集中在一起,兩個總是被一起修改的類應該放入同一組件。這和大家熟知的 SOLID 設計原則中的 SRP (單一職責)很類似,SRP 要求總是一起修改的函式應該放在同一個類,CCP 可以看作是元件版本的 SRP。

順便一提:SOLID 設計原則也是出自 Clean Architecture 一書,SOLID 針對的是 OOP 的類和介面,而本文討論的是更大粒度的元件。

有的人在 Android 專案中喜歡按照程式碼的功能屬性劃分 Module,比如一個 MVVM 架構的工程,目錄可能如下劃分: + UI + Logic + Repository + API + DB

但實際開發中,很少只修改 UI 或者只修改 Logic,大多是圍繞某個 feature 進行垂直修改,這種跨 Module 修改顯然違反了 CCP 原則,因此以業務屬性為單位來進行 Module 劃分可能更加合理,比如一個短影片應用的目錄結構應該是 + VideoPlay + ui + data + ... + VideoCreation + Account + ... 這樣,我們的修改可以在單個元件中閉環完成,在 Gradle 編譯中,減少受影響的模組,提升編譯速度。

共同複用原則(CRP)

元件中的類應該同時被複用,即元件中不要依賴不參與複用的類

REP 要求我們將緊密相關的類放在一個元件中一同釋出,而 CRP 要求我們強調的是不要把不相關的類放進來,要用大家就一起用。要注意所謂“共同”複用並不意味著所有的類都能被外部訪問,有些類可能是服務於內部其他類的,但也是必不可少的。我們雖然不希望元件過度拆分,但是同時要求元件的類不能過度冗餘,不應該出現別人只需要依賴它的某幾個類而不需要其他類的情況。

+ VideoPlay + VideoCreation + Account + ...

比如 VideoCreation 承載了短影片創作相關的功能,短影片創作鏈路分為拍攝和編輯兩部分,某些場景下使用者通過相簿選取素材後直接進入編輯,此時可能不需要拍攝模組,所以拍攝和編輯兩個模組共存一個元件不符合 CRP 原則,當拍攝部分程式碼發生變動時,會連累那些只依賴編輯模組的元件一同參與編譯。此時應該考慮拆分 VideoCreationVideoRecordVideoEdit 兩個 Module

CRP 與 SOLID 的 ISP(介面隔離原則)有點像,ISP 指的是對外不暴露不需要的介面,從這點來看,CRP 可以稱為元件版的 ISP。

三原則的權衡

上述三個原則有著互斥關係,REP 和 CCP 是粘合性原則,告訴我們哪些類要放在一起,這會讓元件變得更大。CRP 是排除性原則,不需要的類要從元件中移除出去,這會使元件變小。元件設計的重要任務就是在這三個原則之間做出均衡

REP,CCP,CRP 的中心思想都是追求元件內部合理的內聚性,但是它們的側重點不同,三者很難同時兼顧,如果考慮不周會落入按下葫蘆浮起瓢的窘境。如果只遵守 REP、CCP 而忽略 CRP ,就會依賴了太多沒有用到的元件和類,而這些元件或類的變動會導致你自己的元件進行太多不必要的釋出;遵守 REP 、CRP 而忽略 CCP,因為元件拆分的太細了,一個需求變更可能要改 n 個元件,帶來的成本也是巨大的,如果只遵守 CCP 和 CRP 而忽略 REP 可能因為元件的能力太過於垂直而犧牲了底層能力的可複用性

Gradle Module 粒度如何劃分很難找到一個普適的結論,應該綜合專案型別、專案階段等各種因素,在三原則中做出取捨和權衡。例如在專案早期我們更加關注業務開發和維護效率,所以 CCP 比 REP 更重要,但隨著專案的發展,可能就要考慮底層能力的可複用性,REP 變得重要起來,隨著專案的持續迭代, 元件能力越發臃腫,此時需要藉助 CRP 對元件進行合理的拆分和重構。


Module 依賴關係

在粒度劃分上我們追求的是元件如何保持合理的內聚性,元件間依賴關係的梳理有助於更好地維持外部的耦合。 Clean Architecture 中關於元件耦合設計也有三個原則:

  1. 無環依賴原則(The Acyclic Dependencies Principle)
  2. 穩定依賴原則(The Stable Dependencies Principle)
  3. 穩定抽象原則(The Stable Abstractions Principle)

無環依賴原則(ADP)

元件依賴關係圖中不應該出現環,關係圖應該必須是一個有向無環圖(DAG) Module 之間出現環形依賴會擴大元件變更帶來的影響範圍,增加整體編譯成本。

比如 A -> B -> C -> A 這樣的環形依賴中,由於 C 依賴了 A ,B 又依賴了 C,A 的變化對 B ,C 都會帶來影響,依賴環中的任何一點發生變更都會影響環上的其他節點。設想一下如果沒有 C -> A 的依賴,C 的變化只會影響 B,B 只會影響 A,A 的變化將不再影響任何人。

所幸我們不必擔心 Gradle 中出現環形依賴的 Module。Gradle 需要根據 Module 依賴關係決策編譯順序,如果 Module 之間有環存在,Gradle 在編譯期會報錯提醒,因此我們需要關心的是發現環形依賴後該如何解決,消除環形依賴一般有兩種方法:

  1. 依賴倒置

藉助 SOLID 中的 依賴倒置原則(DIP),把 C > A 的依賴內容,抽象為 C 中的介面,C 面向介面程式設計,然後讓 A 實現這些介面,依賴關係發生反轉

  1. 增加元件

新增 D 元件,C > A 的依賴部分下沉到 D ,讓 C 和 A 共同依賴 D ,類似於中介者設計模式。

當然,這種方式如果濫用會導致工程的元件膨脹,所以是否真的要從 A 中下沉一個 D 元件,還要結合前文介紹的 REP 和 CCP 原則綜合考慮。

穩定依賴原則(SDP)

依賴關係要趨於穩定的方向,例如 A 依賴 B,則被依賴方 B 應該比依賴方 A 更穩定。

SDP 原則很好理解,如果 A 是一個公共元件需要保持較高穩定度,而它如果依賴一個經常變更的元件 B,則會因為 B 的變更變得不穩定,若要保證 A 的穩定 B 的修改就會變得畏手畏腳,難以進行。 一個預期會經常變更的元件是一個不穩定的元件,這個定義過於太主觀,如何客觀衡量一個元件的穩定性呢?

穩定度公式

穩定度的衡量方式可以看一個元件依賴了多少元件(入向依賴度)和被多少元件所依賴(出向依賴度)這兩個指標: - 入向(Fan-in):依賴這個元件的反向依賴的數量,這個值越大,說明這個元件的職責越大。 - 出向(Fan-out):這個元件正向依賴的其他元件的數量,這個值越大,說明這個元件越不獨立,自然越不穩定。 - 不穩定度:I(Instability) = Fan-out / (Fan-in+Fan-out)

這個值越小,說明這個元件越穩定: - 當 Fan-out == 0 時,這個元件不依賴其他任何元件,但是有其他元件依賴它。此時它的 I = 0,是最穩定的元件,我們不希望輕易地改動它,因為它一旦改動了,那麼依賴它的其他元件也會受到影響。 - 當 Fan-in == 0 時,這個元件不被其他任何元件依賴,但是會依賴其他元件。此時它的 I = 1,是最不穩定的元件,它所依賴的元件的改動都可能影響到自身,但是它自身可以自由地改動,不對其他元件造成影響

注意:入向、出向有時也被稱為反向依賴度(Ca)和正向依賴度(Ce),本質是一回事: https://en.wikipedia.org/wiki/Software_package_metrics

仍然以短影片應用的工程結構為例:

+ app #宿主 + play #影片播放 + ui + data + creation #影片創作 + ui + data + common #公共能力 + db + net + camera + cache + infra #基礎框架等

  • infra 的不穩定度 ```
  • Fan-in = 5
  • Fan-out = 0
  • I = 0/(5+0) = 0 ```

不穩定度 0 ,infra 是一個極為穩定的 Module,它不能擅自改動

  • app 的不穩定度 ```
  • Fan-in = 0
  • Fan-out = 3
  • I = 3 / ( 0 + 3 ) = 1 `` 不穩定度 1,app` 是一個極度不穩定的 Module,任何一個 Module 的變動都會影響它。

一個相對健康的工程結構,箭頭的走勢一定是符合 SDP 的原則,從不穩定的元件流向穩定的元件,app 和 infra 在整體結構中符合這一原則。從 Module 內部來看 :ui 的不穩定度高於 :data 也符合 UI 側需求更容易變更的客觀顯示。

再分析一下 VideoPlayVideoCreation 這兩個 Module 的依賴關係, 假設此應用為了鼓勵使用者創作在影片播放時增加了創作入口,所以 VideoPlayVideoCreation 產生依賴

  • VideoPlay 不穩定度 ```
  • Fan-in = 1
  • Fan-out = 2
  • I = 2 / (1+2) = 0.66 ```
  • VideoCreation 不穩定度 ```
  • Fan-in = 2
  • Fan-out = 5
  • I = 5 / ( 2+5) = 0.7 ``VideoCreatioin的不穩定度反而略高於VideoPlay,這是與 SDP 原則相違背的。在產品層面為了迎合需求我們讓VideoPlay直接依賴了VideoCreation` ,但是在工程層面這並非一個好設計,關於解決方案可以參考後文內容。

穩定抽象原則(SAP)

一個元件的抽象化程度應該與其穩定性保持一致,越穩定的元件應該越抽象。

SOLID 中最核心的當屬開閉原則(OCP):程式碼應該對擴充套件開放,對修改關閉。我們常常通過面向抽象類/介面程式設計的方法去實踐 OCP,即用抽象構建框架,用實現擴充套件細節。抽象層包含程式的基礎協議和頂層設計等,這些程式碼不應該經常變更,所以應該放在穩定元件(I=0)中,而不穩定元件(I=1)中適合放那些能夠快速和方便修改的實現部分。

SAP 為元件的穩定性和抽象化程度建立了一種關聯,穩定元件需要變更時應該避免修改自己,而是通過其派生類的擴充套件來實現變更,這就要求穩定元件具備良好的抽象能力。而至於抽象類的實現部分,應該從穩定元件中剝離,放到不穩定元件中,這樣可以無壓力的對其程式碼進行修改而不必擔心影響他人。

抽象度公式

  • Nc:元件中類的數量
  • Na:元件中抽象類和介面的數量
  • A:抽象程度, A = Na / Nc

A 的取值範圍從 0 到 1,值越大表示元件內的抽象化程度越高,0 代表元件中沒有任何抽象類,1 代表元件只有抽象沒有任何實現。

如上圖中,由於 infra 處於極度穩定狀態,它應該有與之匹配的抽象化程度。以資料層的能力為例,我們將 infra 中所有的關於資料層的實現抽離到 Common,只留下抽象介面,其他 Module 的 :data 只依賴依賴穩定的 infra,而 app 負責全域性注入 dbnetcache 等具體實現。

此外,由於 VideoCreation 中沒有剝離抽象和實現,對 VideoCreation 實現的修改可能會破壞其應有的穩定性。

基於 SAP 原則,我們新增一個高度抽象化的 creation:api,它具有高穩定性和高抽象度,而 VideoCreation 的穩定性降低,負責同時為 api 提供具體實現。

不穩定度與抽象度的關係

元件的不穩定度(I)和抽象度(A)關係可見下圖:

縱軸為 A 值(數值越大越抽象),橫軸為 I 值(數值越大越不穩定)。基於 SAP 原則,一個健康的元件應該儘量靠近主序列(Main Sequence),我們用 Distance from the Main Sequence (D) 來評價元件的抽象度與穩定性之間的平衡關係:D = abs((A+I) - 1)。這個值越小,說明這個元件的抽象度與穩定性是越平衡的。位於(A = 1, I = 0)的元件是極度穩定並完全抽象,位於(A = 0,I = 1)的元件是完全具象且極度不穩定的元件。

痛苦區與無用區

位於座標左下角的元件由於其穩定性要求高不能被隨意修改,但是由於其程式碼抽象度很低又無法通過擴充套件進行修改,一旦有升級要求只能修改自身。這種既需要修改又不能修改的矛盾使得這個區域被稱為痛苦區(Zone Of Pain)。

比如之前例子中的 Common 部分,如果作為公共模組被直接依賴、需要具備極高的穩定性,但是由於其內部充滿具體實現,當我們要升級 db 或者 net 等公共庫時由於影響範圍太大往往需要對程式進行全面迴歸測試。所以我們不不允許 Common 被過多地依賴,降低其穩定性,也降低了其發生變更時的負擔,當發生變更時,只要針對其依賴的 infra 介面完成單元測試即可,避免了迴歸測試成本。

位於座標右上角的元件,不穩定度很高意味著沒有被其他元件依賴,所以在這部分做任何抽象都是無意義的,因此也被稱為無用區(Zone Of Useless)。一般這種程式碼都是歷史原因造成的,例如我們經常看到某個角落裡遺留了一些沒有被實現的抽象類,像這樣的無用程式碼應該被移除。

一個健康的元件應該儘量遠離痛苦區和無用區,並儘量靠近主序列。


總結

最終總結之前,再看一下我們這個短影片應用經過整潔架構優化之後的效果

除了前文敘述過的通過新增 creation:api,讓 VideoPlay 的穩定性和抽象度趨於一致以外,我們還對 camera 的位置做了調整,調整前的 camera 處於 Common 中,但它的修改比較獨立且僅僅被 VideoCreation 所依賴,首先這不符合 CRP 原則,其次 camera 經常伴隨 VideoCreation 的需求而升級,也不符合 CCP 的要求,因此我們把 cameraCommon 抽出並移動到 VideoCreation

最後我們還新增了一個 creation:common。由於 VideoCreation 中有多處對 infra 的依賴,雖然 infra 是極度穩定的元件,但是作為外部元件仍然不可信賴,一旦 infra 發生變動對 VideoCreation 的穩定性造成影響,我們新增 creation:common 收斂對 infra 的依賴,提升 VideoCreation 的穩定性。 優化之後各元件的不穩定度如下表:

app - Fan-in = 0 - Fan-out = 7 - I = 7 / (0 + 7) = 1 VideoCreation - Fan-in = 1 - Fan-out = 2 - I = 2 / (1 + 2)= 0.66 VideoPaly - Fan-in = 1 - Fan -out = 1 - I = 1 / (1 + 1) = 0.5 Common - Fan-in = 3 - Fan-out = 3 - I = 3 / (3 + 3) = 0.5 infra - Fan-in = 6 - Fan-out = 0 - I = 0 / (0 + 6) = 0

不穩定度(I)逐層遞減,抽象度也隨之逐漸遞增。文章中的例子十分簡單,肯定有人會覺得這種程度的優化僅憑直覺就可完成,沒必要套用公式。但是實際專案往往要複雜得多,瞭解這些公式能夠在複雜場景中發揮引導作用,避免我們迷失方向。

最後做一個總結,Gradle Module 作為 Android 工程的元件單元,我們可以基於整潔架構中關於元件設計的原則對其進行治理: 1. 所有且僅有緊密相關的類或模組應該放入同一組件 2. 因為同樣目的需要同時修改的元件應該儘量放到一起 3. 元件粒度應該如何劃分,需要根據實際情況進行權衡 4. 元件之間不應該存在迴圈依賴,可以通過依賴倒置或者增加元件的方式解決 5. 被依賴的元件總是比依賴它的元件更穩定 6. 元件的穩定性和抽象度應該保持一致,越穩定的組織抽象度越高