常見的模組化模式

沒有任何一種單一模組化策略可適用於所有專案。由於 Gradle 的靈活性很大,因此對於安排專案的方式幾乎沒有限制。本頁概述開發多模組 Android 應用程式時可使用的一些一般規則和常見模式。

高內聚和低耦合原則

模組程式碼集的特性之一,就是使用耦合內聚屬性。耦合功能衡量模組彼此的依賴程度。在此情況下,內聚會衡量單一模組的元素與功能的相關性。一般來說,您應盡力採用低耦合和高聚合:

  • 低耦合表示模組應盡可能各自獨立,因而在變更其中一個模組時,對其他模組產生的影響能夠減少至零或最小。模組不應知道其他模組的內部作業
  • 高內聚代表多個模組應構成程式碼集合,以執行系統的功能。這些模組應具有明確定義的責任,並保持在特定領域知識的範圍內。以電子書應用程式為例。將預訂和付款相關程式碼放在同一模組中可能不太合適,因為這些程式碼分屬兩個不同的功能領域。

模組類型

整理模組的方式主要取決於應用程式架構。以下列出遵循我們建議的應用程式架構,可引入應用程式的部分常見模組類型。

資料模組

資料模組通常包含存放區、資料來源和模型類別。資料模組的三個主要作用如下:

  1. 封裝特定領域的所有資料和商業邏輯:每個資料模組都必須負責處理代表特定領域的資料,可處理多種類型的資料,只要其具有相關性即可。
  2. 將存放區公開為外部 API:資料模組的共用 API 應做為存放區使用,因為這些 API 會負責將資料提供給應用程式的其餘部分。
  3. 對外部隱藏所有實作詳細資料和資料來源:資料來源僅供來自相同模組的存放區存取,不會對外公開。如要強制執行此操作,您可以使用 Kotlin 的 privateinternal 瀏覽權限關鍵字。
圖 1. 範例資料模組及其內容。

特色模組

「功能」是應用程式功能的獨立部分,通常對應至一個畫面或一系列密切相關的畫面,例如註冊或結帳流程。如果應用程式提供底部導覽列,則可能每個目的地都代表一項功能。

圖 2. 此應用程式的各個分頁標籤都可定義為一項功能。

功能與應用程式中的畫面或目的地相關聯,因此可能會提供相關聯的 UI 和 ViewModel處理邏輯和狀態。單一功能不必受限於單一檢視畫面或導覽目的地。功能模組依附於資料模組。

圖 3. 功能模組及其內容範例。

應用程式模組

應用程式模組是應用程式的進入點。它們依附功能模組,且通常提供根層級導覽。憑藉建構變數,單一應用程式模組可以編譯為多個不同的二進位檔。

圖 4. 「試用版」和「完整版」變種版本模組的依附元件圖表。

如果應用程式指定多種裝置類型 (例如汽車、穿戴式裝置或電視),請為每種裝置類型定義一個應用程式模組。這有助於區隔平台專用的依附元件。

圖 5. Wear 應用程式依附元件圖表。

常見模組

常見模組 (又稱為核心模組) 包含其他模組會經常使用的程式碼。這類模組可減少冗餘,而且不代表應用程式架構中的任何特定層。以下是常見模組的範例:

  • UI 模組:如果您在應用程式中使用自訂 UI 元素或精心設計品牌宣傳,應考慮將小工具集封裝至一個模組,以便重複使用所有功能。這有助於讓 UI 在不同功能之間保持一致。舉例來說,如果採用集中式設定主題,則在品牌重塑時,可避免一連貫而造成痛苦。
  • 分析模組:追蹤通常取決於業務需求,很少考慮軟體架構。分析追蹤器通常用於許多不相關的元件中。如果是這種情況,最好設定專屬的分析模組。
  • 網路模組:如有多個模組需要網路連線,您可以考慮透過專用模組來提供 HTTP 用戶端。這在用戶端需要自訂設定時特別實用。
  • 公用程式模組:公用程式 (又稱為輔助程式) 通常是可在應用程式中重複使用的一小段程式碼。公用程式範例包括測試輔助程式、貨幣格式設定函式、電子郵件驗證工具或自訂運算子。

測試模組

測試模組是指僅供測試之用的 Android 模組。這類模組包含測試程式碼、測試資源和測試依附元件,這些內容僅供執行測試所需,不會在應用程式的執行階段用到。測試模組在建立時會將測試專用的程式碼與主要應用程式分開,讓模組程式碼更易於管理及維護。

測試模組的用途

以下舉例說明在哪些情況下實作測試模組可能特別有助益:

  • 共用的測試程式碼:如果您的專案中有多個模組,且部分測試程式碼適用於多個模組,那麼您就可以建立測試模組來共用程式碼。這樣有助於減少重複情形,讓測試程式碼更易於維護。共用的測試程式碼可包含公用程式類別或函式 (例如自訂斷言或比對器),以及模擬的 JSON 回應這類測試資料。

  • 更簡潔的建構設定:測試模組可讓您的建構設定更為簡潔,因為這類模組有自身專屬的 build.gradle 檔案。因此,您就不必採用僅供測試之用的設定,應用程式模組的 build.gradle 檔案也就不會過於雜亂。

  • 整合測試:您可以使用測試模組儲存整合測試,以便測試應用程式不同部分 (包括使用者介面、商業邏輯、網路要求和資料庫查詢) 的互動情形。

  • 大型應用程式:測試模組特別適合具有複雜程式碼集和多個模組的大規模應用程式。在這種情況下,測試模組有助於改善程式碼的整理及維護方式。

圖 6. 測試模組可用來分隔可能會彼此依附的模組。

模組到模組通訊

模組鮮少完全獨立存在,彼此間通常會有依附關係,也會互相通訊。但請注意,即使模組能搭配運作並經常交換資訊,也務必要保持低耦合狀態。有時候,就像架構受限的情況一樣,在兩個模組之間直接通訊並不適合,也可能無法實現,例如帶有循環依附元件。

圖 7. 由於循環依附元件的關係,模組無法直接雙向通訊,必須使用調節模組協調其他兩個獨立模組之間的資料流。

如要解決這個問題,您可加入在兩個其他模組之間調節的第三個模組。調節模組可以監聽兩個模組的訊息,並視需要轉送訊息。在範例應用程式中,即使事件是源自於另一個屬於不同功能的畫面,結帳畫面也必須知道要購買的是哪一本書。在這種情況下,中介者是擁有導覽圖的模組 (通常是應用程式模組)。在此範例中,我們使用導覽元件將資料從首頁功能傳送至結帳功能。

navController.navigate("checkout/$bookId")

結帳目的地會收到書籍 ID 做為引數,用來擷取書籍相關資訊。您可以使用已儲存的狀態控制代碼,在目的地功能的 ViewModel 內擷取導覽引數。

class CheckoutViewModel(savedStateHandle: SavedStateHandle, …) : ViewModel() {

   val uiState: StateFlow<CheckoutUiState> =
      savedStateHandle.getStateFlow<String>("bookId", "").map { bookId ->
          // produce UI state calling bookRepository.getBook(bookId)
      }
      …
}

請勿傳送物件做為導覽引數。請改用簡單的 ID,讓功能可用來從資料層存取及載入所需資源。如此一來,您可以保持低耦合狀態,而且不會違反單一可靠來源的原則。

在以下範例中,兩個功能模組都依附於同一個資料模組。因此,您可以盡量減少調解模組轉發所需的資料量,並使模組之間保持低耦合狀態。模組應交換原始 ID 並從共用的資料模組載入資源,而不是傳遞物件。

圖 8. 兩個依賴共用資料模組的功能模組。

依附元件反轉

依附元件反轉是指整理程式碼,使抽象與具體實作各自獨立。

  • 抽象:定義應用程式中元件或模組如何互動的合約。抽像模組會定義系統的 API,包括介面和模型。
  • 具體實作:此為需依附抽像模組,並實作抽象行為的模組。

依附抽像模組中定義行為的模組,應該只依賴抽像模組,而非特定實作模組。

圖 9. 高階模組和實作模組是依附於抽像模組,而非高階模組直接依附於低階模組。

範例

假設有一個功能模組需要資料庫才能運作。功能模組與資料庫的實作方式無關,不管是本機 Room 資料庫或遠端 Firestore 執行個體都是如此。它只需要儲存及讀取應用程式資料。

為了達到這個目的,該功能模組會依附於於抽像模組,而非特定資料庫的實作。此抽象合約定義了應用程式的資料庫 API。換句話說,它會設定與資料庫互動的規則。這可讓功能模組使用任何資料庫,而不必瞭解其基礎的實作詳細資料。

具體的實作模組會提供在抽像模組中定義的 API 實作。為此,實作模組也會依附於抽像模組。

插入依附元件

現在,您可能想瞭解功能模組如何與實作模組連接。答案是插入依附元件。功能模組不會直接建立所需的資料庫執行個體,而是指定需要的依附元件。接著,這些依附元件會從外部提供,通常位於應用程式模組中。

releaseImplementation(project(":database:impl:firestore"))

debugImplementation(project(":database:impl:room"))

androidTestImplementation(project(":database:impl:mock"))

好處

將 API 與其實作區隔的優點如下:

  • 互通性:藉由明確區隔 API 和實作模組,您可以為同一個 API 開發多個實作並在其中切換,不必變更使用該 API 的程式碼。如果您想在不同情境下提供不同功能或行為,這個做法特別實用。例如,用於測試的模擬實作,以及用於實際工作環境的實際實作。
  • 分離:分離的意思是使用抽像模組的模組不依賴任何特定技術。如果您之後選擇將資料庫從 Room 變更為 Firestore,可以輕鬆完成這項操作,因為變更只會在執行該作業的特定模組 (實作模組) 發生,不會影響使用資料庫 API 的其他模組。
  • 可測試性:將 API 與其實作項目區隔開來,有助於進行測試。您可以針對 API 合約編寫測試案例。您也可以使用不同的實作項目來測試各種情境和極端案例,包括模擬實作。
  • 提升建構效能:將 API 及其實作區分為不同的模組時,若實作模組中的變更,系統不會強制要求建構系統依據 API 模組來重新編譯模組。這可以加快建構時間並提高工作效率,特別是在建構時間可能特別長的大型專案中。

區隔的時機

在下列情況中,將 API 與實作區隔會較為有利:

  • 多種功能:如果您可以使用多種方式實作系統某些部分,清楚明確的 API 可讓不同實作結果彼此互通。舉例來說,您可能有採用 OpenGL 或 Vulkan 的算繪系統,或是支援 Play 或內部結帳 API 的結帳系統。
  • 多個應用程式:如果您要開發多個具有不同平台分享功能的應用程式,您可以定義常用 API,並為每個平台開發特定實作。
  • 獨立團隊:進行區隔可讓不同開發人員或團隊同時處理程式碼集的不同部分。開發人員應專注於瞭解及正確使用 API 合約,他們不用擔心其他模組的實作詳細資料。
  • 大型程式碼集:如果程式碼集較大或複雜,將 API 與實作區隔,可使程式碼更易於管理。這麼做可讓您將程式碼集細分為更精細、易於理解及維護的單元。

實作方式

如要實作依附元件反轉,請按照下列步驟操作:

  1. 建立抽像模組:此模組應包含定義功能行為的 API (介面和模型)。
  2. 建立實作模組:實作模組應依賴 API 模組,並實作抽像模組的行為。
    高階模組和實作模組是依附於抽像模組,而非高階模組直接依附於低階模組。
    圖 10. 實作模組依附於抽像模組。
  3. 讓高階模組依附於抽像模組:讓模組依附於抽像模組,不要直接依附於特定實作模組。高階模組不必瞭解實作詳細資料,只需要具備合約 (API)。
    高階模組會依附抽像模組,而非實作模組。
    圖 11. 高階模組會依附抽像模組,而非實作模組。
  4. 提供實作模組:最後,您必須提供依附元件的實際實作。具體實作方式視專案設定而定,但應用程式模組通常也是很好的地方。如要提供實作,請將其指定為所選建構變化版本或測試來源集的依附元件
    應用程式模組提供了實際實作。
    圖 12. 應用程式模組提供了實際實作。

一般最佳做法

如前文所述,開發多模組應用程式並沒有單一的正確做法。就像軟體架構有許多種,應用程式模組化的方法也有很多。然而,下列提供的一般建議可協助您讓程式碼更易讀、方便維護及測試。

保持設定一致

每個模組都會引入設定負擔。如果模組數量達到特定閾值,那麼管理一致的設定就會成為難題。例如,模組必須使用相同版本的依附元件。如果您更新大量模組的目的只是為了要觸碰依附元件版本,不僅要耗費很多心力,還可能發生潛在錯誤。為解決這個問題,您可以使用其中一項 Gradle 工具集中管理設定:

  • 版本目錄是 Gradle 在同步處理時產生的依附元件類型安全清單。這是集中宣告所有依附元件的位置,並可供專案中的所有模組使用。
  • 使用慣例外掛程式在模組之間共用建構邏輯。

盡可能只顯示常用項目

模組的公開介面應盡可能精簡,且只顯示常用項目。請勿將任何實作詳細資料外洩,建議您盡可能縮小範圍,使用 Kotlin 的 privateinternal 瀏覽權限範圍,將宣告設為不對模組公開。當您在模組中宣告依附元件時,請優先使用 implementation,而非 api。後者會向模組使用者公開轉換依附元件。由於 implementation 可以減少需要重建的模組數量,因此能夠縮短建構時間。

優先使用 Kotlin 和 Java 模組

Android Studio 支援的基本模組類型有三種:

  • 應用程式模組是應用程式的進入點。其中包含原始碼、資源、資產和 AndroidManifest.xml。應用程式模組的輸出內容是 Android App Bundle (AAB) 或 Android 應用程式套件 (APK)。
  • 程式庫模組與應用程式模組的內容相同。其他 Android 模組會使用這些模組做為依附元件。程式庫模組的輸出內容為 Android Archive (AAR),與應用程式模組的結構相同,但系統會將其編譯為 Android Archive (AAR) 檔案,以供其他模組用做依附元件。程式庫模組可讓您在多個應用程式模組間封裝並重複使用相同的邏輯和資源。
  • Kotlin 和 Java 程式庫不包含任何 Android 資源、資產或資訊清單檔案。

由於 Android 模組會產生負擔,因此最好盡可能使用 Kotlin 或 Java 類型。