本文原作者:張可, 釋出於:張可
Clean 架構是 Uncle Bob 提出的一種軟體架構,Bob 大叔同時也是 SOLID 原則的命名者。
Clean 架構圖如下:
這張圖描述的是整個軟體系統的架構,而不是單體軟體,其中至少包括伺服端以及客戶端。
對於 Android 單體套用開發來說應該還需要一個更貼切更精確的 Clean 架構圖。
我大概總結了一下過往的開發經驗,找出了套用架構中的重要部份,然後繪制了下面這張 Clean 架構指導下的 Android 套用架構圖:
以及一般數據流向圖:
依賴關系
Clean 架構基本準則是源碼級別的內層不依賴外層,依賴關系永遠是單向的,外層向內層依賴。
如上,Model 層是沒有任何依賴的,UseCase 可以依賴 Model 和 Repo 等,但絕不能依賴 ViewModel,UI 層依賴 ViewModel,但 ViewModel 絕不能依賴 UI 層。
為了達到這種源碼級別的依賴關系,我們必須借助一些工具來實作依賴註入,一般可以使用 Hilt 或者 Koin 這樣的框架來實作。
另外,依賴註入不應該被濫用,不是所有的物件都適合用依賴註入,只有那些有明確層次關系的模組,互相有著明確的依賴關系的才需要。對於一些工具類,顯然是沒必要註入的。
Model (領域模型)
業務模型,或者叫領域模型,是根據軟體業務設計出來的具體模型,一般來說會是個 data class ,其中不包含任何業務邏輯,只是個單純的模型物件。
由於是在整個架構的最內層,所以 不依賴任何其他模組,並且相對穩定 ,設計的時候需要考慮這點。如果模型發生變化,那意味著整個上層的依賴方都可能發生變化,需要重新測試。
在命名和包結構上,領域模型不需要帶 Entity 之類的字尾,直接命名為像 User 一樣即可,但考慮到這是在軟體的最內層,可能會被所有模組依賴到,所以要盡可能貼近其設計目標,並且不能太過寬泛。在包結構上,需要被存放在 model 包下面。
Adapter (數據介面卡)
數據介面卡層主要用來做數據轉換,主要有兩個職責:
轉換網路介面實體數據類和領域模型;
領域模型之間的互相轉換。
Adapter 層也比較純粹, 只負責簡單的數據轉換,而且對外暴露的函式都是冪等函式 。
如果數據轉換過程中涉及到復雜的業務邏輯,可以考慮先用 UseCase 處理完成後再交給 Adapter。但因為 Adapter 層比 UseCase 層更靠內,所以 Adapter 不能依賴 UseCase。
習慣上,我們會以待轉換類為開頭,Adapter 結尾命名,例如我們要把 UserEntity 轉換為 User ,那麽應該這麽寫:
classUserEntityAdapter{
funtoUser(entity: UserEntity): User {
//...
}
}
Repo
對於我們 Android 開發來說,Repo 層應該是對網路介面或本地磁盤的數據讀寫的封裝,對於 Repo 的使用者來說,不需要關註具體的實作,且 Repo 中一般不具備復雜的業務邏輯,只能包含簡單的數據處理邏輯。
Repo 應當隱藏具體的實作細節,不僅包括獲取方式是網路還是本地數據,也應該隱藏對應的實體數據類,這意味著 Repo 層對外暴露的函式的入參和出參不能包含介面返回的實體類,也不應該包含資料庫表實體類,只能包含領域模型或者基本型別 。我們給 Room 設計的資料庫表的 data class 應該限制在 Repo 內部,我們給 Retrofit 設計的介面返回數據 data class 也同樣應該限制在 Repo 內部。
data classUserEntity(val name: String, val avatar: String)
interfaceUserService{
@GET("/user")
suspendfungetUserInfo(@query("id") id: String): UserEntity
}
data classUser(val name: String, val avatar: String)
classUserEntityAdapter@Injectconstructor() {
funtoUser(entity: UserEntity): User {
return User(name = entity.name, avatar = entity.avatar)
}
}
classUserRepo@Injectconstructor(
privateval userEntityAdapter: UserEntityAdapter,
) {
privateval userService: UserService by lazy {
retrofit.create(UserService:: class.java)
}
suspendfungetUser(id: String): User {
return userService.getUserInfo(id).let(userEntityAdapter::toUser)
}
}
除了上面說的相應數據的轉換之外, 請求數據也需要在 Repo 層轉換 ,對於 Post 請求來說,可能會存在一個請求實體,這個實體數據類最好也不要對外暴露,可以在 Repo 層的請求方法入參那裏做一些轉換,最好能讓入參更簡單友好。
Repo 層還有一個作用就是 負責把從介面或者資料庫中出來的不友好的數據模型轉換成友好的數據模型 。
另外,現在由於有了 BFF 的存在,在某些比較簡單的業務場景下我們可以為了方便做一些妥協,也就是介面的響應數據實體類可以穿透 Repo 層,直接給到 ViewModel,甚至是 UiState 使用,但應該明白這 只是為了方便的妥協,並不是最佳實踐,需要嚴格控制影響範圍 。
UseCase (用例)
UseCase 一般是指特定套用場景下的業務邏輯 ,用例引導了數據在模型之間的輸入輸出,並且指揮著業務實體利用其中的關鍵業務邏輯來實作用例的設計目標。
因此,一個 UseCase 往往 只包含一段具體的業務邏輯,他的輸入是基本型別或者領域模型,輸出也是,並且是冪等函式,也就是純函式 ,所以 Google 建議我們每個 UseCase 只包含一個公開的函式,類似於下面這種寫法:
classDoSomethingUseCase{
operatorfuninvoke(xxx: Foo): Bar {
// ...
}
}
透過利用 Kotlin 特性來使 UseCase 在使用的時候達到直接使用函式的體感。
但考慮到依賴以及管理問題,UseCase 最好還是不要直接使用函式來實作,應當按照上面的方式,定義一個類,然後再暴露一個透過操作符多載的函式。
在使用 UseCase 時可以這麽用:
classLoginViewModel@Injectconstructor(
privateval doSomething: DoSomethingUseCase,
): ViewModel(){
funonLoginClick(){
doSomething()
}
}
UseCase 的問題
UseCase 的粒度非常細,基本上每個 UseCase 就是一個函式,在復雜的業務背景下將會存在非常多的 UseCase,隨著業務的增加,對他們的管理將難以為繼。
因此,UseCase 需要一個有效的手段來進行管理,首先,應當按功能對他們的包名進行劃分。同一個業務的 UseCase 最好具備相同的包名。
其次,我們不能陷入所有業務都用 UseCase 的極端情況中,很多時候,我們可以將一些極度類似的功能組織在一個類中,其中提供多個公開的方法,這樣的寫法在以前很常見,比如各種 Manager,Helper,Resolver 等,他們能有效減少 UseCase 數量,並且相對簡單。
UiState
UiState 是用來描述當前 UI 狀態的集合類,一般來說應該是個 data class。
UiState 一定是不可變類,如果希望更改其中的某個值,應當重新建立一個物件,直接透過 data class 提供的 copy 方法即可,例如:
data classLoginUiState(
val name:String,
val avatar: String,
val consentAgreed: Boolean
)
funonAgreeChecked(){
uistate = uiState.copy(
consentAgreed = true,
)
}
UiState 中的數據應當盡可能地方便給 UI 直接使用 ,因為 UiState 本身就是為了 UI 設計的,例如對於一個需要顯示的格式化後的時間,格式化的邏輯最好放在 ViewModel 或者更內層,而不是直接給 UiState 一個時間戳,讓 UI 層去格式化。很多時候看起來簡單的邏輯也可能犯錯誤,UI 層沒有能力處理異常。
在 ViewModel 中如果需要更新 UiState,可以直接透過 update 方法。
_uiState.update {
it.copy(
name = "zhangke"
)
}
ViewModel
ViewModel 負責管理 UI 狀態,執行對應的業務邏輯。
因此 ViewModel 的生命周期與頁面是一致的。
一般來說我們會透過直接使用 Jetpack 提供的 ViewModel,但也可以自己建立其他型別的 ViewModel,只要控制好生命周期即可。
ViewModel 主要負責兩件事情:
對外提供當前 UI 狀態;
接收 UI 事件並作出響應。
當前 UI 狀態我們透過將 UiState 包裝在 StateFlow 裏對外提供。
privateval _uiState = MutableStateFlow()
val uiState:StateFlow<UserUiState> = _uiState.asStateFlow()
接受 UI 事件這點需要註意,ViewModel 需要做的是接收 UI 事件,例如使用者手勢輸入,至於使用者點選之後要做什麽事情這是 ViewModel 的內部邏輯,不應該對外暴露。
UI 層
我們這裏說的 UI 層就是指一個頁面,除了常規的 Activity/Fragment 之外,對於 Compose 來說一個頁面可能對應的是一個 Composable 函式,這取決於 UI 層的實作。
UI 層應該完全是數據驅動的,UI 層的作用就是百分之百的將 UiState 渲染出來,UiState 發生變化,UI 也跟著變化,這一點聲明式 UI 框架做的很好。
UI 層雖然也可以處理一些簡單的事件,但大部份的事件都還是要交給 ViewModel 來處理。
結語
以上就是 Android 整潔架構中的一些關鍵概念的介紹,我已經按照這個架構開發了一年多了,目前看下來確實會讓架構很整潔,但對於一些復雜的業務場景,尤其是可能需要穿透多個層級,跨越常規生命周期的模組就需要更精細的設計了。
長按右側二維碼
檢視更多開發者精彩分享
"開發者說·DTalk" 面向
中國開發者們征集 Google 行動應用 (apps & games) 相關的產品/技術內容。歡迎大家前來分享您對行動應用的行業洞察或見解、移動開發過程中的心得或新發現、以及套用出海的實戰經驗總結和相關產品的使用反饋等。我們由衷地希望可以給這些出眾的中國開發者們提供更好展現自己、充分發揮自己特長的平台。我們將透過大家的技術內容著重選出優秀案例進行谷歌開發技術專家 (GDE) 的推薦。