一個(gè)靈活、現(xiàn)代的Android應(yīng)用架構(gòu)
學(xué)習(xí)Android架構(gòu)的原則:學(xué)習(xí)原則,不要盲目遵循規(guī)則。
本文旨在通過示例演示實(shí)際應(yīng)用:通過示范Android架構(gòu)來進(jìn)行教學(xué)。最重要的是,這意味著展示出如何做出各種架構(gòu)決策。在某些情況下,我們會(huì)遇到幾個(gè)可能的答案,而在每種情況下,我們都會(huì)依靠原則而不是機(jī)械地記住一套規(guī)則。
因此,讓我們一起構(gòu)建一個(gè)應(yīng)用。
介紹我們要構(gòu)建的應(yīng)用
我們要為行星觀測者構(gòu)建一個(gè)應(yīng)用。它將大致如下所示:
我們的應(yīng)用將具有以下功能:
- 已發(fā)現(xiàn)的所有行星的列表
- 添加剛剛發(fā)現(xiàn)的新行星的方式
- 刪除行星的方法(以防你意識(shí)到你的發(fā)現(xiàn)實(shí)際上只是望遠(yuǎn)鏡鏡頭上的污跡)
- 添加一些示例行星,讓用戶了解應(yīng)用的工作方式
它將具有離線數(shù)據(jù)緩存以及在線訪問數(shù)據(jù)庫的功能。
像往常一樣,在我的步驟指導(dǎo)中,我鼓勵(lì)你偏離常規(guī):添加額外的功能,考慮可能的未來規(guī)格變化,挑戰(zhàn)自己。在這里,學(xué)習(xí)的重點(diǎn)是代碼背后的思考過程,而不僅僅是代碼本身。因此,如果你想從這個(gè)教程中獲得最佳效果,請(qǐng)不要盲目復(fù)制代碼。
這是我們最終將得到的代碼庫鏈接:
https://github.com/tdcolvin/PlanetSpotters
介紹我們將要使用的架構(gòu)原則
我們將受到SOLID原則、清晰架構(gòu)原則和谷歌的現(xiàn)代應(yīng)用架構(gòu)原則的啟發(fā)。
我們不會(huì)將這些原則視為硬性規(guī)則,因?yàn)槲覀冏銐蚵斆?,可以?gòu)建適合我們應(yīng)用的東西(特別是對(duì)應(yīng)我們預(yù)期的應(yīng)用增長)。例如,如果你對(duì)清晰架構(gòu)如宗教般追隨,你會(huì)產(chǎn)生穩(wěn)固、可靠、可擴(kuò)展的軟件,但你的代碼可能對(duì)于一個(gè)單一用途的應(yīng)用來說會(huì)過于復(fù)雜。谷歌的原則產(chǎn)生了更簡單的代碼,但如果某天該應(yīng)用可能由多個(gè)大型開發(fā)團(tuán)隊(duì)維護(hù),那就不太合適了。
我們將從谷歌的拓?fù)浣Y(jié)構(gòu)開始,途中會(huì)受到清晰架構(gòu)的啟示。
谷歌的拓?fù)浣Y(jié)構(gòu)如下:
讓我們逐步實(shí)現(xiàn)這一架構(gòu),并且在我最近的一篇文章中,對(duì)每個(gè)部分進(jìn)行更深入的探討。但是作為簡要概述:
UI層(UI Layer)
UI層實(shí)現(xiàn)了用戶界面。它分為:
-
UI元素,這是用于在屏幕上繪制內(nèi)容的所有專有代碼。在Android中,主要選擇是Jetpack Compose(在這種情況下,
@Composables
放在這里)或XML(在這種情況下,這里包含XML文件和資源)。 - 狀態(tài)持有者,這是您實(shí)現(xiàn)首選MVVM / MVC / MVP等拓?fù)涞牡胤健T谶@個(gè)應(yīng)用程序中,我們將使用視圖模型。
領(lǐng)域?qū)?Domain Layer)
領(lǐng)域?qū)佑糜诎呒?jí)業(yè)務(wù)邏輯的用例。例如,當(dāng)我們想要添加一個(gè)行星時(shí),AddPlanetUseCase
將描述完成此操作所需的步驟。它是一系列的“what”,而不是“how”:例如,我們會(huì)說“保存行星對(duì)象的數(shù)據(jù)”。這是一個(gè)高級(jí)指令。我們不會(huì)說“將其保存到本地緩存”,更不用說“使用Room數(shù)據(jù)庫將其保存到本地緩存”——這些較低級(jí)別的實(shí)現(xiàn)細(xì)節(jié)放在其他地方。
數(shù)據(jù)層(Data Layer)
谷歌敦促我們?cè)趹?yīng)用程序中擁有一個(gè)數(shù)據(jù)的單一真相來源;也就是說,獲得數(shù)據(jù)絕對(duì)“正確”版本的方法。這就是數(shù)據(jù)層將為我們提供的內(nèi)容(除了描述用戶剛剛輸入的內(nèi)容的數(shù)據(jù)結(jié)構(gòu)之外的所有數(shù)據(jù))。它分為:
- 存儲(chǔ)庫,管理數(shù)據(jù)類型。例如,我們將有一個(gè)行星數(shù)據(jù)的存儲(chǔ)庫,它將為發(fā)現(xiàn)的行星提供CRUD(創(chuàng)建、讀取、更新、刪除)操作。它還將處理數(shù)據(jù)存儲(chǔ)在本地緩存以及遠(yuǎn)程訪問的情況,選擇適當(dāng)?shù)膩碓磥韴?zhí)行不同種類的操作,并管理兩個(gè)來源包含不同副本數(shù)據(jù)的情況。在這里,我們會(huì)談?wù)摫镜鼐彺娴那闆r,但我們?nèi)匀徊粫?huì)談?wù)撐覀儗⑹褂檬裁吹谌郊夹g(shù)來實(shí)現(xiàn)它。
- 數(shù)據(jù)源,管理數(shù)據(jù)的存儲(chǔ)方式。當(dāng)存儲(chǔ)庫要求“遠(yuǎn)程存儲(chǔ)X”時(shí),它會(huì)請(qǐng)求數(shù)據(jù)源執(zhí)行此操作。數(shù)據(jù)源僅包含驅(qū)動(dòng)專有技術(shù)所需的代碼——可能是Firebase,或者是HTTP API,或其他什么技術(shù)。
良好的架構(gòu)允許延遲決策
在這個(gè)階段,我們知道應(yīng)用程序的功能將是什么,以及它將如何管理其數(shù)據(jù)的一些基本想法。
還有一些我們尚未決定的事情。我們不知道UI將會(huì)是什么樣子,或者我們將用什么技術(shù)來構(gòu)建它(Jetpack Compose,XML等)。我們不知道本地緩存將采取什么形式。我們不知道我們將使用什么專有解決方案來訪問在線數(shù)據(jù)。我們不知道我們是否將支持手機(jī)、平板電腦或其他形態(tài)因素。
問題:我們需要知道上述任何內(nèi)容來制定我們的架構(gòu)嗎?
答案:不需要!
以上都是低級(jí)考慮因素(在清晰架構(gòu)中,它們的代碼將位于最外層)。它們是實(shí)現(xiàn)細(xì)節(jié),而不是邏輯。SOLID的依賴倒置原則告訴我們,不應(yīng)該編寫依賴于它們的代碼。
換句話說,我們應(yīng)該能夠編寫(和測試?。┢溆嗟膽?yīng)用程序代碼,而無需了解上述任何內(nèi)容。當(dāng)我們確切了解上述問題的答案時(shí),我們已經(jīng)編寫的任何內(nèi)容都不需要更改。
這意味著在設(shè)計(jì)師完成設(shè)計(jì)和利益相關(guān)者決定使用的第三方技術(shù)之前,代碼生產(chǎn)階段就可以開始。因此,良好的架構(gòu)允許延遲決策。(并具有靈活性以在不引起大量代碼混亂的情況下撤銷任何此類決策)。
我們項(xiàng)目的架構(gòu)圖
下面是我們將行星觀測者應(yīng)用程序放入谷歌拓?fù)涞牡谝淮螄L試。
數(shù)據(jù)層(Data Layer)
我們將擁有行星數(shù)據(jù)的存儲(chǔ)庫,以及兩個(gè)數(shù)據(jù)源:一個(gè)用于本地緩存,另一個(gè)用于遠(yuǎn)程數(shù)據(jù)。
UI層(UI Layer)
將有兩個(gè)狀態(tài)持有者,一個(gè)用于行星列表頁,另一個(gè)用于添加行星頁。每個(gè)頁面還將有其一組UI元素,使用的技術(shù)暫時(shí)可以保持不確定。
領(lǐng)域?qū)?Domain layer)
我們有兩種完全有效的方式來構(gòu)建我們的領(lǐng)域?qū)樱?/p>
我們只在重復(fù)業(yè)務(wù)邏輯的地方添加用例。在我們的應(yīng)用程序中,唯一重復(fù)的邏輯是添加行星的地方:用戶在添加示例行星列表時(shí)需要它,手動(dòng)輸入自己的行星詳細(xì)信息時(shí)也需要。因此,我們只會(huì)創(chuàng)建一個(gè)用例:AddPlanetUseCase
。在其他情況(例如刪除行星)下,狀態(tài)持有者將直接與存儲(chǔ)庫交互。
我們將每個(gè)與存儲(chǔ)庫的交互都添加為用例,以便狀態(tài)持有者和存儲(chǔ)庫之間永遠(yuǎn)不會(huì)直接聯(lián)系。在這種情況下,我們將有用于添加行星、刪除行星和列出行星的用例。
選項(xiàng)#2的好處是它遵循了清晰架構(gòu)的規(guī)則。但個(gè)人認(rèn)為,對(duì)于大多數(shù)應(yīng)用來說,它稍顯繁重,所以我傾向于選擇選項(xiàng)#1。這也是我們?cè)谶@里要做的。
這給我們帶來了以下的架構(gòu)圖:
從哪里開始編寫代碼
我們應(yīng)該從哪些代碼開始呢?
規(guī)則是:
從高級(jí)代碼開始,逐步向下編寫。
這意味著首先編寫用例,因?yàn)檫@樣做會(huì)告訴我們對(duì)存儲(chǔ)庫層有什么要求。一旦我們知道存儲(chǔ)庫需要什么,我們就可以寫出數(shù)據(jù)源需要滿足的要求,以便進(jìn)行操作。
同樣,由于用例告訴我們用戶可能采取的所有行動(dòng),我們知道所有輸入和輸出都來自UI。從這些信息中,我們將了解UI需要包含什么內(nèi)容,因此可以編寫狀態(tài)持有者(視圖模型)。然后,有了狀態(tài)持有者,我們就知道需要編寫哪些UI元素。
當(dāng)然,我們可以無限期地延遲編寫UI元素和數(shù)據(jù)源(即所有低級(jí)代碼),直到高級(jí)工程師和項(xiàng)目利益相關(guān)者就要使用的技術(shù)達(dá)成一致。
這樣就結(jié)束了理論部分?,F(xiàn)在讓我們開始構(gòu)建應(yīng)用程序。在進(jìn)行決策時(shí),我將引導(dǎo)您。
第1步:創(chuàng)建項(xiàng)目
打開Android Studio并創(chuàng)建一個(gè)“No Activity”的項(xiàng)目:
在下一個(gè)屏幕上,將其命名為PlanetSpotters
,并將其他所有內(nèi)容保持不變:添加依賴注入
我們將需要一個(gè)依賴注入框架,這有助于應(yīng)用SOLID的依賴反轉(zhuǎn)原則。在這里,我的首選是Hilt
,幸運(yùn)的是,這也是Google專門推薦的選擇。
要添加Hilt,請(qǐng)將以下內(nèi)容添加到根Gradle文件中:然后將其添加到app/build.gradle
文件中:(請(qǐng)注意,我們?cè)谶@里設(shè)置兼容性為Java 17,這是Kapt需要的,Hilt使用它。您將需要Android Studio Flamingo或更高版本)。
最后,通過添加@HiltAndroidApp
注解來重寫Application
類。也就是說,在您的應(yīng)用程序的包文件夾(這里是com.tdcolvin.planetspotters
)中創(chuàng)建一個(gè)名為PlanetSpottersApplication
的文件,并包含以下內(nèi)容:…然后通過將其添加到清單中,告訴操作系統(tǒng)實(shí)例化它:…一旦我們有了主活動(dòng),我們將需要向其添加@AndroidEntryPoint
。但目前,這完成了我們的Hilt設(shè)置。
最后,我們將通過將這些行添加到app/build.gradle
來添加對(duì)其他有用庫的支持:第一步:列出用戶可以做和看到的所有內(nèi)容
在編寫用例和存儲(chǔ)庫之前,需要進(jìn)行此步驟。回想一下,用例是用戶可以執(zhí)行的單個(gè)任務(wù),以高層次(what而不是how)描述。
因此,讓我們開始寫出這些任務(wù);一份詳盡的用戶可以在應(yīng)用程序中執(zhí)行和查看的所有任務(wù)列表。
其中一些任務(wù)最終將作為用例編碼。(事實(shí)上,在Clean Architecture下,所有這些任務(wù)都必須作為用例編寫)。其他任務(wù)將由UI層直接與存儲(chǔ)庫層交互。
在此需要一份書面規(guī)范。不需要UI設(shè)計(jì),但如果您有UI設(shè)計(jì),這當(dāng)然有助于可視化。
以下是我們的列表:
-
獲取已發(fā)現(xiàn)行星的列表,該列表會(huì)自動(dòng)更新
輸入:無
輸出:Flow<List<Planet>>
動(dòng)作:從存儲(chǔ)庫請(qǐng)求當(dāng)前已發(fā)現(xiàn)行星的列表,以在發(fā)生更改時(shí)保持我們的更新。 -
獲取單個(gè)已發(fā)現(xiàn)行星的詳細(xì)信息,該信息會(huì)自動(dòng)更新
輸入:String-我們要獲取的行星的ID
輸出:Flow<Planet>
動(dòng)作:從存儲(chǔ)庫請(qǐng)求具有給定ID的行星,并要求在發(fā)生更改時(shí)保持我們的更新。 -
添加/編輯新發(fā)現(xiàn)的行星
輸入:-
planetId:String?
-如果非空,則為要編輯的行星ID。如果為空,則我們正在添加新行星。 -
name:String
-行星的名稱 -
distanceLy:Float
-行星到地球的距離(光年) -
discovered:Date
-發(fā)現(xiàn)日期
輸出:無(通過完成沒有異常來確定成功)
動(dòng)作:根據(jù)輸入創(chuàng)建一個(gè)Planet對(duì)象,并將其傳遞給存儲(chǔ)庫(以添加到其數(shù)據(jù)源)。
-
-
添加一些示例行星
輸入:無
輸出:無
動(dòng)作:要求存儲(chǔ)庫添加三個(gè)示例行星,發(fā)現(xiàn)日期為當(dāng)前時(shí)間:Trenzalore(300光年),Skaro(0.5光年),Gallifrey(40光年)。 -
刪除一顆行星
輸入:String-要?jiǎng)h除的行星的ID
輸出:無
動(dòng)作:要求存儲(chǔ)庫刪除具有給定ID的行星。
現(xiàn)在,我們有了這個(gè)列表,我們可以開始編碼用例和存儲(chǔ)庫。
第2步:編寫用例
根據(jù)第一步,我們有一個(gè)用戶可以執(zhí)行的任務(wù)列表。之前,我們決定將其中的任務(wù)“添加行星”作為用例進(jìn)行編碼。(我們決定僅在應(yīng)用程序的不同區(qū)域重復(fù)任務(wù)時(shí)添加用例)。
這給了我們一個(gè)用例
val addPlanetUseCase: AddPlanetUseCase = …
//Use our instance as if it were a function:
addPlanetUseCase(…)
下面是AddPlanetUseCase
的實(shí)現(xiàn)代碼:
class AddPlanetUseCase @Inject constructor(private val planetsRepository: PlanetsRepository) {
suspend operator fun invoke(planet: Planet) {
if (planet.name.isEmpty()) {
throw Exception("Please specify a planet name")
}
if (planet.distanceLy < 0) {
throw Exception("Please enter a positive distance")
}
if (planet.discovered.after(Date())) {
throw Exception("Please enter a discovery date in the past")
}
planetsRepository.addPlanet(planet)
}
}
在這里,PlanetsRepository
是一個(gè)列出存儲(chǔ)庫將具有的方法的接口。稍后會(huì)更多地介紹這一點(diǎn)(特別是為什么我們創(chuàng)建接口而不是類)。但現(xiàn)在讓我們創(chuàng)建它,這樣我們的代碼就可以編譯通過:
interface PlanetsRepository {
suspend fun addPlanet(planet: Planet)
}
Planet
數(shù)據(jù)類型定義如下:
data class Planet(
val planetId: String?,
val name: String,
val distanceLy: Float,
val discovered: Date
)
addPlanet
方法(就像在使用案例中的invoke
函數(shù)一樣)被聲明為suspend
,因?yàn)槲覀冎浪鼘⑸婕昂笈_(tái)工作。我們以后會(huì)在這個(gè)接口中添加更多的方法,但目前這就足夠了。
順便說一下,你可能會(huì)問為什么我們費(fèi)力地創(chuàng)建了一個(gè)如此簡單的使用案例。答案在于它未來可能會(huì)變得更加復(fù)雜,并且外部代碼可以與該復(fù)雜性隔離開來。
第2.1步:測試使用案例
我們現(xiàn)在已經(jīng)編寫了使用案例,但我們無法運(yùn)行它。首先,它依賴于PlanetsRepository
接口,而我們還沒有它的實(shí)現(xiàn)。Hilt不知道如何處理它。
但是我們可以編寫測試代碼,提供一個(gè)偽造的PlanetsRepository
實(shí)例,并使用我們的測試框架運(yùn)行它。這就是你現(xiàn)在應(yīng)該做的。
由于這是關(guān)于架構(gòu)的教程,測試的細(xì)節(jié)不在范圍內(nèi),所以這一步留給你作為練習(xí)。但請(qǐng)注意,良好的架構(gòu)設(shè)計(jì)讓我們將組件拆分為易于測試的部分。
第3步:數(shù)據(jù)層,編寫PlanetsRepository
記住,倉庫的工作是整合不同的數(shù)據(jù)源,處理它們之間的差異,并提供CRUD操作。
使用依賴倒置和依賴注入
根據(jù)干凈架構(gòu)和依賴倒置原則(在我上一篇文章中有更多信息),我們希望避免外部代碼依賴于倉庫實(shí)現(xiàn)內(nèi)部代碼。這樣一來,使用案例或視圖模型(例如)就不會(huì)受到倉庫代碼的更改影響。
這解釋了為什么我們之前將PlanetsRepository
創(chuàng)建為接口(而不是類)。調(diào)用代碼將只依賴于接口,但它將通過依賴注入接收實(shí)現(xiàn)。所以現(xiàn)在我們將向接口添加更多方法,并創(chuàng)建其實(shí)現(xiàn),我們將稱之為DefaultPlanetsRepository
。
(另外:有些開發(fā)團(tuán)隊(duì)遵循調(diào)用實(shí)現(xiàn)為<interface name>Impl
的約定,例如PlanetsRepositoryImpl
。我認(rèn)為這種約定不利于閱讀:類名應(yīng)該告訴你為什么要實(shí)現(xiàn)一個(gè)接口。所以我避免使用這種約定。但我提及它是因?yàn)樗粡V泛使用。)
使用Kotlin Flows使數(shù)據(jù)可用
如果你還沒有接觸過Kotlin Flows,請(qǐng)停下手頭的工作,立即閱讀相關(guān)資料。它們將改變你的生活。
https://developer.android.com/kotlin/flow
它們提供了一個(gè)數(shù)據(jù)“管道”,隨著新的結(jié)果變得可用而改變。只要調(diào)用方訂閱了管道,他們將在有變化時(shí)收到更新。因此,現(xiàn)在我們的UI可以在數(shù)據(jù)更新時(shí)自動(dòng)更新,幾乎不需要額外的工作。相比起過去,我們必須手動(dòng)向UI發(fā)出數(shù)據(jù)已更改的信號(hào)。
雖然存在其他類似的解決方案,比如RxJava
和MutableLiveData
,它們做類似的事情,但它們不如Flows靈活和易于使用。
添加常用的WorkResult
類
WorkResult
類是數(shù)據(jù)層常用的返回類型。它允許我們描述一個(gè)特定請(qǐng)求是否成功,其定義如下:
//WorkResult.kt
package com.tdcolvin.planetspotters.data.repository
sealed class WorkResult<out R> {
data class Success<out T>(val data: T) : WorkResult<T>()
data class Error(val exception: Exception) : WorkResult<Nothing>()
object Loading : WorkResult<Nothing>()
}
調(diào)用代碼可以檢查給定的WorkResult
是Success
、Error
還是Loading
對(duì)象(后者表示尚未完成),從而確定請(qǐng)求是否成功。
第4步: 實(shí)現(xiàn)Repository接口
讓我們把上面的內(nèi)容整合起來,為構(gòu)成我們的PlanetsRepository
的方法和屬性制定規(guī)范。
它有兩個(gè)用于獲取行星的方法。第一個(gè)方法通過其ID獲取單個(gè)行星:
fun getPlanetFlow(planetId: String): Flow<WorkResult<Planet?>>
第二個(gè)方法獲取表示行星列表的Flow:
fun getPlanetsFlow(): Flow<WorkResult<List<Planet>>>
這些方法都是各自數(shù)據(jù)的單一來源。每次我們都將返回存儲(chǔ)在本地緩存中的數(shù)據(jù),因?yàn)槲覀冃枰幚磉@些方法被頻繁運(yùn)行的情況,而本地?cái)?shù)據(jù)比訪問遠(yuǎn)程數(shù)據(jù)源更快、更便宜。但我們還需要一種方法來刷新本地緩存。這將從遠(yuǎn)程數(shù)據(jù)源更新本地?cái)?shù)據(jù)源:
suspend fun refreshPlanets()
接下來,我們需要添加、更新和刪除行星的方法:
suspend fun addPlanet(planet: Planet)
suspend fun deletePlanet(planetId: String)
因此,我們的接口現(xiàn)在看起來是這樣的:
interface PlanetsRepository {
fun getPlanetFlow(planetId: String): Flow<WorkResult<Planet?>>
fun getPlanetsFlow(): Flow<WorkResult<List<Planet>>>
suspend fun refreshPlanets()
suspend fun addPlanet(planet: Planet)
suspend fun deletePlanet(planetId: String)
}
邊寫代碼邊編寫數(shù)據(jù)源接口
為了編寫實(shí)現(xiàn)該接口的類,我們需要注意數(shù)據(jù)源將需要哪些方法?;叵胍幌?,我們有兩個(gè)數(shù)據(jù)源:LocalDataSource
和RemoteDataSource
。我們還沒有決定使用哪種第三方技術(shù)來實(shí)現(xiàn)它們——我們現(xiàn)在也不需要。
讓我們現(xiàn)在創(chuàng)建接口定義,準(zhǔn)備好在需要時(shí)添加方法簽名:
//LocalDataSource.kt
package com.tdcolvin.planetspotters.data.source.local
interface LocalDataSource {
//Ready to add method signatures here...
}
//RemoteDataSource.kt
package com.tdcolvin.planetspotters.data.source.remote
interface RemoteDataSource {
//Ready to add method signatures here...
}
現(xiàn)在準(zhǔn)備填充這些接口,我們可以編寫DefaultPlanetsRepository
了。讓我們逐個(gè)方法來看:
編寫getPlanetFlow()
和getPlanetsFlow()
這兩個(gè)方法都很簡單;我們返回本地源中的數(shù)據(jù)。(為什么不是遠(yuǎn)程源?因?yàn)楸镜卦吹拇嬖谑菫榱丝焖?、資源輕量級(jí)地訪問數(shù)據(jù)。遠(yuǎn)程源可能始終是最新的,但它較慢。如果我們嚴(yán)格需要最新的數(shù)據(jù),那么在調(diào)用getPlanetsFlow()
之前,我們可以使用下面的refreshPlanets()
。)
//DefaultPlanetsRepository.kt
override fun getPlanetsFlow(): Flow<WorkResult<List<Planet>>> {
return localDataSource.getPlanetsFlow()
}
override fun getPlanetFlow(planetId: String): Flow<WorkResult<Planet?>> {
return localDataSource.getPlanetFlow(planetId)
}
因此,這取決于LocalDataSource
中的getPlanetFlow()
和getPlanetsFlow()
函數(shù)。我們現(xiàn)在將它們添加到接口中,以便我們的代碼可以編譯。
//LocalDataSource.kt
interface LocalDataSource {
fun getPlanetsFlow(): Flow<WorkResult<List<Planet>>>
fun getPlanetFlow(planetId: String): Flow<WorkResult<Planet?>>
}
編寫refreshPlanets()
方法
為了更新本地緩存,我們從遠(yuǎn)程數(shù)據(jù)源獲取當(dāng)前的行星列表,并將其保存到本地?cái)?shù)據(jù)源中。(然后,本地?cái)?shù)據(jù)源可以“感知”到更改,并通過getPlanetsFlow()
返回的Flow
發(fā)出新的行星列表。)
//DefaultPlanetsRepository.kt
override suspend fun refreshPlanets() {
val planets = remoteDataSource.getPlanets()
localDataSource.setPlanets(planets)
}
這需要在每個(gè)數(shù)據(jù)源接口中添加一個(gè)新的方法,現(xiàn)在這些接口如下所示:
//LocalDataSource.kt
interface LocalDataSource {
fun getPlanetsFlow(): Flow<WorkResult<List<Planet>>>
fun getPlanetFlow(planetId: String): Flow<WorkResult<Planet?>>
suspend fun setPlanets(planets: List<Planet>)
}
//RemoteDataSource.kt
interface RemoteDataSource {
suspend fun getPlanets(): List<Planet>
}
編寫addPlanet()
和deletePlanet()
函數(shù)時(shí),它們都遵循相同的模式:在遠(yuǎn)程數(shù)據(jù)源上執(zhí)行寫操作,如果成功,就將更改反映到本地緩存中。
我們預(yù)計(jì)遠(yuǎn)程數(shù)據(jù)源會(huì)為Planet對(duì)象分配唯一的ID,一旦它進(jìn)入數(shù)據(jù)庫,RemoteDataSource的addPlanet()
函數(shù)將返回帶有非空ID的更新后的Planet對(duì)象。
//PlanetsRepository.kt
override suspend fun addPlanet(planet: Planet) {
val planetWithId = remoteDataSource.addPlanet(planet)
localDataSource.addPlanet(planetWithId)
}
override suspend fun deletePlanet(planetId: String) {
remoteDataSource.deletePlanet(planetId)
localDataSource.deletePlanet(planetId)
}
我們最終的數(shù)據(jù)源接口如下:
//LocalDataSource.kt
interface LocalDataSource {
fun getPlanetsFlow(): Flow<WorkResult<List<Planet>>>
fun getPlanetFlow(planetId: String): Flow<WorkResult<Planet?>>
suspend fun setPlanets(planets: List<Planet>)
suspend fun addPlanet(planet: Planet)
suspend fun deletePlanet(planetId: String)
}
//RemoteDataSource.kt
interface RemoteDataSource {
suspend fun getPlanets(): List<Planet>
suspend fun addPlanet(planet: Planet): Planet
suspend fun deletePlanet(planetId: String)
}
第5步:狀態(tài)持有者,編寫PlanetsListViewModel
回想一下,UI層由UI元素和狀態(tài)持有者層組成:
此時(shí)我們?nèi)匀徊恢牢覀儗⑹褂檬裁醇夹g(shù)來繪制UI,所以我們還不能編寫UI元素層。但這沒有問題;我們可以繼續(xù)編寫狀態(tài)持有者,確信一旦我們做出決定,它們就不必改變。這就是優(yōu)秀架構(gòu)的更多好處!
編寫PlanetsListViewModel的規(guī)范
UI將有兩個(gè)頁面,一個(gè)用于列出和刪除行星,另一個(gè)用于添加或編輯行星。 PlanetsListViewModel負(fù)責(zé)前者。這意味著它需要向行星列表屏幕的UI元素公開數(shù)據(jù),并且必須準(zhǔn)備好接收來自UI元素的事件,以便用戶執(zhí)行操作。
具體而言,我們的PlanetsListViewModel需要公開:
- 描述頁面當(dāng)前狀態(tài)的Flow(關(guān)鍵是包括行星列表)
- 刷新列表的方法
- 刪除行星的方法
- 添加一些示例行星的方法,以幫助用戶了解應(yīng)用程序的功能
PlanetsListUiState
對(duì)象:頁面的當(dāng)前狀態(tài)
我發(fā)現(xiàn)將頁面的整個(gè)狀態(tài)封裝在一個(gè)單獨(dú)的數(shù)據(jù)類中非常有用:
//PlanetsListViewModel.kt
data class PlanetsListUiState(
val planets: List<Planet> = emptyList(),
val isLoading: Boolean = false,
val isError: Boolean = false
)
注意我已經(jīng)在與視圖模型相同的文件中定義了這個(gè)類。它僅包含簡單的對(duì)象:沒有Flows等,只有原始類型,數(shù)組和簡單的數(shù)據(jù)類。請(qǐng)注意,所有字段都有默認(rèn)值-這在后面會(huì)有幫助。
(有一些很好的原因,你可能甚至不希望在上面的類中出現(xiàn)Planet對(duì)象。Clean Architecture的純粹主義者會(huì)指出,在定義Planet的位置和使用它的位置之間有太多的層級(jí)跳轉(zhuǎn)。狀態(tài)提升原則告訴我們只提供我們需要的確切數(shù)據(jù)。例如,現(xiàn)在我們只需要Planet的名稱和距離,所以我們應(yīng)該只有這些,而不是整個(gè)Planet對(duì)象。個(gè)人認(rèn)為這樣做會(huì)不必要地使代碼復(fù)雜化,并且會(huì)使將來的更改更加困難,但你可以自由選擇不同意見!)
因此,定義了這個(gè)類后,我們現(xiàn)在可以在視圖模型內(nèi)部創(chuàng)建一個(gè)狀態(tài)變量來公開它:
//PlanetsListViewModel.kt
package com.tdcolvin.planetspotters.ui.planetslist
...
@HiltViewModel
class PlanetsListViewModel @Inject constructor(
planetsRepository: PlanetsRepository
): ViewModel() {
private val planets = planetsRepository.getPlanetsFlow()
val uiState = planets.map { planets ->
when (planets) {
is WorkResult.Error -> PlanetsListUiState(isError = true)
is WorkResult.Loading -> PlanetsListUiState(isLoading = true)
is WorkResult.Success -> PlanetsListUiState(planets = planets.data)
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = PlanetsListUiState(isLoading = true)
)
}
注意在.stateIn(...)
中使用的scope
和started
參數(shù)可以安全地限制此StateFlow的生命周期。
添加示例行星
為了添加我們的3個(gè)示例行星,我們重復(fù)調(diào)用了為此目的創(chuàng)建的用例。
//PlanetsListViewModel.kt
fun addSamplePlanets() {
viewModelScope.launch {
val planets = arrayOf(
Planet(name = "Skaro", distanceLy = 0.5F, discovered = Date()),
Planet(name = "Trenzalore", distanceLy = 5F, discovered = Date()),
Planet(name = "Galifrey", distanceLy = 80F, discovered = Date()),
)
planets.forEach { addPlanetUseCase(it) }
}
}
刷新和刪除
刷新和刪除函數(shù)非常類似,只需調(diào)用相應(yīng)的存儲(chǔ)庫函數(shù)即可。
//PlanetsListViewModel.kt
fun deletePlanet(planetId: String) {
viewModelScope.launch {
planetsRepository.deletePlanet(planetId)
}
}
fun refreshPlanetsList() {
viewModelScope.launch {
planetsRepository.refreshPlanets()
}
}
第6步:編寫AddEditPlanetViewModel
AddEditPlanetViewModel
用于管理用于添加新行星或編輯現(xiàn)有行星的屏幕。
與之前的做法一樣——實(shí)際上,對(duì)于任何視圖模型來說,這都是一個(gè)很好的實(shí)踐——我們將為UI顯示的所有內(nèi)容定義一個(gè)數(shù)據(jù)類,并為其創(chuàng)建單一的數(shù)據(jù)來源。
//AddEditPlanetViewModel.kt
data class AddEditPlanetUiState(
val planetName: String = "",
val planetDistanceLy: Float = 1.0F,
val planetDiscovered: Date = Date(),
val isLoading: Boolean = false,
val isPlanetSaved: Boolean = false
)
@HiltViewModel
class AddEditPlanetViewModel @Inject constructor(): ViewModel() {
private val _uiState = MutableStateFlow(AddEditPlanetUiState())
val uiState: StateFlow<AddEditPlanetUiState> = _uiState.asStateFlow()
}
如果我們正在編輯一個(gè)行星(而不是添加新行星),我們希望視圖的初始狀態(tài)反映該行星的當(dāng)前狀態(tài)。
作為良好的實(shí)踐,這個(gè)屏幕只會(huì)傳遞我們要編輯的行星的ID。(我們不傳遞整個(gè)行星對(duì)象——它可能會(huì)變得過于龐大和復(fù)雜)。Android的生命周期組件提供了SavedStateHandle
,我們可以從中獲取行星ID并加載行星對(duì)象。
//AddEditPlanetViewModel.kt
@HiltViewModel
class AddEditPlanetViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val planetsRepository: PlanetsRepository
): ViewModel() {
private val planetId: String? = savedStateHandle[PlanetsDestinationsArgs.PLANET_ID_ARG]
private val _uiState = MutableStateFlow(AddEditPlanetUiState())
val uiState: StateFlow<AddEditPlanetUiState> = _uiState.asStateFlow()
init {
if (planetId != null) {
loadPlanet(planetId)
}
}
private fun loadPlanet(planetId: String) {
_uiState.update { it.copy(isLoading = true) }
viewModelScope.launch {
val result = planetsRepository.getPlanetFlow(planetId).first()
if (result !is WorkResult.Success || result.data == null) {
_uiState.update { it.copy(isLoading = false) }
}
else {
val planet = result.data
_uiState.update {
it.copy(
isLoading = false,
planetName = planet.name,
planetDistanceLy = planet.distanceLy,
planetDiscovered = planet.discovered
)
}
}
}
}
}
注意我們?nèi)绾问褂靡韵履J礁耈I狀態(tài):
_uiState.update { it.copy( ... ) }
在一行簡單的代碼中,它創(chuàng)建一個(gè)新的AddEditPlanetUiState
,其值是從先前的狀態(tài)復(fù)制過來的,并通過uiState
Flow發(fā)送出去。
這里是我們用該技術(shù)更新行星的各種屬性的函數(shù):
//AddEditPlanetViewModel.kt
fun setPlanetName(name: String) {
_uiState.update { it.copy(planetName = name) }
}
fun setPlanetDistanceLy(distanceLy: Float) {
_uiState.update { it.copy(planetDistanceLy = distanceLy) }
}
最后,我們使用AddPlanetUseCase保存行星對(duì)象:
//AddEditPlanetViewModel.kt
class AddEditPlanetViewModel @Inject constructor(
private val addPlanetUseCase: AddPlanetUseCase,
...
): ViewModel() {
...
fun savePlanet() {
viewModelScope.launch {
addPlanetUseCase(
Planet(
planetId = planetId,
name = _uiState.value.planetName,
distanceLy = uiState.value.planetDistanceLy,
discovered = uiState.value.planetDiscovered
)
)
_uiState.update { it.copy(isPlanetSaved = true) }
}
}
...
}
第7步:編寫數(shù)據(jù)源和UI元素
現(xiàn)在我們已經(jīng)建立了整個(gè)架構(gòu),可以編寫最低層的代碼,即UI元素和數(shù)據(jù)源。對(duì)于UI元素,我們可以選擇使用Jetpack Compose來支持手機(jī)和平板電腦。對(duì)于本地?cái)?shù)據(jù)源,我們可以編寫一個(gè)使用Room數(shù)據(jù)庫的緩存,而對(duì)于遠(yuǎn)程數(shù)據(jù)源,我們可以模擬訪問遠(yuǎn)程API。
這些層應(yīng)該盡可能保持薄。例如,UI元素的代碼不應(yīng)包含任何計(jì)算或邏輯,只需純粹地將視圖模型提供的狀態(tài)顯示在屏幕上。邏輯應(yīng)該放在視圖模型中。
對(duì)于數(shù)據(jù)源,只需編寫最少量的代碼來實(shí)現(xiàn)LocalDataSource
和RemoteDataSource
接口中的函數(shù)。
特定的第三方技術(shù)(如Compose和Room)超出了本教程的范圍,但您可以在代碼存儲(chǔ)庫中看到這些層的示例實(shí)現(xiàn)。
將低級(jí)部分留在最后
請(qǐng)注意,我們能夠?qū)⑦@些應(yīng)用的最低級(jí)部分留到最后。這非常有益,因?yàn)樗试S利益相關(guān)者有足夠的時(shí)間來做出關(guān)于使用哪些第三方技術(shù)以及應(yīng)用應(yīng)該如何展示的決策。即使在我們編寫了這些代碼之后,我們也可以更改這些決策,而不會(huì)影響應(yīng)用的其余部分。
Github地址
完整的代碼存儲(chǔ)庫在:文章來源:http://www.zghlxwxcb.cn/news/detail-605773.html
https://github.com/tdcolvin/PlanetSpotters。文章來源地址http://www.zghlxwxcb.cn/news/detail-605773.html
到了這里,關(guān)于一個(gè)靈活、現(xiàn)代的Android應(yīng)用架構(gòu)的文章就介紹完了。如果您還想了解更多內(nèi)容,請(qǐng)?jiān)谟疑辖撬阉鱐OY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!