一个灵敏、现代的Android运用架构

一个灵活、现代的Android应用架构

学习Android架构的准则:学习准则,不要盲目遵从规矩。

本文旨在经过示例演示实际运用:经过演示Android架构来进行教学。最重要的是,这意味着展示出如何做出各种架构决议计划。在某些状况下,咱们会遇到几个或许的答案,而在每种状况下,咱们都会依靠准则而不是机械地记住一套规矩。

因而,让咱们一同构建一个运用。

介绍咱们要构建的运用

咱们要为行星观测者构建一个运用。它将大致如下所示:

一个灵活、现代的Android应用架构

咱们的运用将具有以下功用:

  1. 已发现的一切行星的列表
  2. 增加刚刚发现的新行星的方式
  3. 删去行星的办法(以防你意识到你的发现实际上仅仅望远镜镜头上的污迹)
  4. 增加一些示例行星,让用户了解运用的作业方式
    它将具有离线数据缓存以及在线拜访数据库的功用。

像往常相同,在我的过程辅导中,我鼓舞你偏离常规:增加额定的功用,考虑或许的未来规格改动,挑战自己。在这里,学习的重点是代码背后的考虑进程,而不仅仅是代码自身。因而,假如你想从这个教程中获得最佳效果,请不要盲目仿制代码。

这是咱们终究将得到的代码库链接:

github.com/tdcolvin/Pl…

介绍咱们即将运用的架构准则

咱们将遭到SOLID准则、明晰架构准则和谷歌的现代运用架构准则的启示。

咱们不会将这些准则视为硬性规矩,由于咱们满足聪明,能够构建适合咱们运用的东西(特别是对应咱们预期的运用增长)。例如,假如你对明晰架构如宗教般追随,你会产生稳固、牢靠、可扩展的软件,但你的代码或许关于一个单一用途的运用来说会过于复杂。谷歌的准则产生了更简略的代码,但假如某天该运用或许由多个大型开发团队保护,那就不太合适了。

咱们将从谷歌的拓扑结构开端,途中会遭到明晰架构的启示。

谷歌的拓扑结构如下:

一个灵活、现代的Android应用架构

让咱们逐渐完结这一架构,而且在我最近的一篇文章中,对每个部分进行更深化的探讨。可是作为扼要概述:

UI层(UI Layer)

UI层完结了用户界面。它分为:

  • UI元素,这是用于在屏幕上绘制内容的一切专有代码。在Android中,主要挑选是Jetpack Compose(在这种状况下,@Composables放在这里)或XML(在这种状况下,这里包含XML文件和资源)。
  • 状况持有者,这是您完结首选MVVM / MVC / MVP等拓扑的当地。在这个运用程序中,咱们将运用视图模型。

范畴层(Domain Layer)

范畴层用于包含高档事务逻辑的用例。例如,当咱们想要增加一个行星时,AddPlanetUseCase将描绘完结此操作所需的过程。它是一系列的“what”,而不是“how”:例如,咱们会说“保存行星目标的数据”。这是一个高档指令。咱们不会说“将其保存到本地缓存”,更不用说“运用Room数据库将其保存到本地缓存”——这些较低级别的完结细节放在其他当地。

数据层(Data Layer)

谷歌敦促咱们在运用程序中拥有一个数据的单一真相来历;也便是说,获得数据绝对“正确”版别的办法。这便是数据层将为咱们供给的内容(除了描绘用户刚刚输入的内容的数据结构之外的一切数据)。它分为:

  • 存储库,办理数据类型。例如,咱们将有一个行星数据的存储库,它将为发现的行星供给CRUD(创立、读取、更新、删去)操作。它还将处理数据存储在本地缓存以及长途拜访的状况,挑选适当的来历来履行不同种类的操作,并办理两个来历包含不同副本数据的状况。在这里,咱们会谈论本地缓存的状况,但咱们依然不会谈论咱们将运用什么第三方技能来完结它。
  • 数据源,办理数据的存储方式。当存储库要求“长途存储X”时,它会恳求数据源履行此操作。数据源仅包含驱动专有技能所需的代码——或许是Firebase,或许是HTTP API,或其他什么技能。

杰出的架构答应推迟决议计划

在这个阶段,咱们知道运用程序的功用将是什么,以及它将如何办理其数据的一些根本想法。

还有一些咱们没有决议的作业。咱们不知道UI将会是什么样子,或许咱们将用什么技能来构建它(Jetpack Compose,XML等)。咱们不知道本地缓存将采纳什么形式。咱们不知道咱们将运用什么专有解决方案来拜访在线数据。咱们不知道咱们是否将支撑手机、平板电脑或其他形状因素。

问题:咱们需求知道上述任何内容来拟定咱们的架构吗?
答案:不需求!

以上都是低级考虑因素(在明晰架构中,它们的代码将坐落最外层)。它们是完结细节,而不是逻辑。SOLID的依靠倒置准则告知咱们,不该该编写依靠于它们的代码。

换句话说,咱们应该能够编写(和测验!)其余的运用程序代码,而无需了解上述任何内容。当咱们切当了解上述问题的答案时,咱们现已编写的任何内容都不需求更改。

这意味着在规划师完结规划和利益相关者决议运用的第三方技能之前,代码生产阶段就能够开端。因而,杰出的架构答应推迟决议计划。(并具有灵敏性以在不引起大量代码混乱的状况下撤销任何此类决议计划)。

咱们项目的架构图

下面是咱们将行星观测者运用程序放入谷歌拓扑的第一次尝试。

数据层(Data Layer)
咱们将拥有行星数据的存储库,以及两个数据源:一个用于本地缓存,另一个用于长途数据。

UI层(UI Layer)
将有两个状况持有者,一个用于行星列表页,另一个用于增加行星页。每个页面还将有其一组UI元素,运用的技能暂时能够坚持不确认。

范畴层(Domain layer)
咱们有两种完全有用的方式来构建咱们的范畴层:

咱们只在重复事务逻辑的当地增加用例。在咱们的运用程序中,唯一重复的逻辑是增加行星的当地:用户在增加示例行星列表时需求它,手动输入自己的行星详细信息时也需求。因而,咱们只会创立一个用例:AddPlanetUseCase。在其他状况(例如删去行星)下,状况持有者将直接与存储库交互。
咱们将每个与存储库的交互都增加为用例,以便状况持有者和存储库之间永远不会直接联系。在这种状况下,咱们将有用于增加行星、删去行星和列出行星的用例。
选项#2的优点是它遵从了明晰架构的规矩。但个人以为,关于大多数运用来说,它稍显繁重,所以我倾向于挑选选项#1。这也是咱们在这里要做的。

这给咱们带来了以下的架构图:

一个灵活、现代的Android应用架构

从哪里开端编写代码

咱们应该从哪些代码开端呢?
规矩是:
从高档代码开端,逐渐向下编写。

这意味着首要编写用例,由于这样做会告知咱们对存储库层有什么要求。一旦咱们知道存储库需求什么,咱们就能够写出数据源需求满足的要求,以便进行操作。

相同,由于用例告知咱们用户或许采纳的一切行动,咱们知道一切输入和输出都来自UI。从这些信息中,咱们将了解UI需求包含什么内容,因而能够编写状况持有者(视图模型)。然后,有了状况持有者,咱们就知道需求编写哪些UI元素。

当然,咱们能够无限期地推迟编写UI元素和数据源(即一切低级代码),直到高档工程师和项目利益相关者就要运用的技能达到一致。

这样就结束了理论部分。现在让咱们开端构建运用程序。在进行决议计划时,我将引导您。

第1步:创立项目

翻开Android Studio并创立一个“No Activity”的项目:

一个灵活、现代的Android应用架构

一个灵活、现代的Android应用架构

鄙人一个屏幕上,将其命名为PlanetSpotters,并将其他一切内容坚持不变:增加依靠注入
咱们将需求一个依靠注入框架,这有助于运用SOLID的依靠反转准则。在这里,我的首选是Hilt,幸运的是,这也是Google专门推荐的挑选。

要增加Hilt,请将以下内容增加到根Gradle文件中:然后将其增加到app/build.gradle文件中:(请留意,咱们在这里设置兼容性为Java 17,这是Kapt需求的,Hilt运用它。您将需求Android Studio Flamingo或更高版别)。

终究,经过增加@HiltAndroidApp注解来重写Application类。也便是说,在您的运用程序的包文件夹(这里是com.tdcolvin.planetspotters)中创立一个名为PlanetSpottersApplication的文件,并包含以下内容:…然后经过将其增加到清单中,告知操作体系实例化它:…一旦咱们有了主活动,咱们将需求向其增加@AndroidEntryPoint。但现在,这完结了咱们的Hilt设置。

终究,咱们将经过将这些行增加到app/build.gradle来增加对其他有用库的支撑:第一步:列出用户能够做和看到的一切内容
在编写用例和存储库之前,需求进行此过程。回想一下,用例是用户能够履行的单个使命,以高层次(what而不是how)描绘。

因而,让咱们开端写出这些使命;一份详尽的用户能够在运用程序中履行和检查的一切使命列表。

其间一些使命终究将作为用例编码。(事实上,在Clean Architecture下,一切这些使命都有必要作为用例编写)。其他使命将由UI层直接与存储库层交互。

在此需求一份书面标准。不需求UI规划,但假如您有UI规划,这当然有助于可视化。

以下是咱们的列表:

  1. 获取已发现行星的列表,该列表会自动更新
    输入:无
    输出:Flow<List<Planet>>
    动作:从存储库恳求当时已发现行星的列表,以在产生更改时坚持咱们的更新。

  2. 获取单个已发现行星的详细信息,该信息会自动更新
    输入:String-咱们要获取的行星的ID
    输出:Flow<Planet>
    动作:从存储库恳求具有给定ID的行星,并要求在产生更改时坚持咱们的更新。

  3. 增加/修改新发现的行星
    输入:

    • planetId:String?-假如非空,则为要修改的行星ID。假如为空,则咱们正在增加新行星。
    • name:String-行星的名称
    • distanceLy:Float-行星到地球的间隔(光年)
    • discovered:Date-发现日期
      输出:无(经过完结没有异常来确认成功)

    动作:依据输入创立一个Planet目标,并将其传递给存储库(以增加到其数据源)。

  4. 增加一些示例行星
    输入:无
    输出:无
    动作:要求存储库增加三个示例行星,发现日期为当时时刻:Trenzalore(300光年),Skaro(0.5光年),Gallifrey(40光年)。

  5. 删去一颗行星
    输入:String-要删去的行星的ID
    输出:无
    动作:要求存储库删去具有给定ID的行星。

现在,咱们有了这个列表,咱们能够开端编码用例和存储库。

第2步:编写用例

依据第一步,咱们有一个用户能够履行的使命列表。之前,咱们决议将其间的使命“增加行星”作为用例进行编码。(咱们决议仅在运用程序的不同区域重复使命时增加用例)。

这给了咱们一个用例

val addPlanetUseCase: AddPlanetUseCase = …
//Use our instance as if it were a function:
addPlanetUseCase(…)

下面是AddPlanetUseCase的完结代码:

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是一个列出存储库将具有的办法的接口。稍后会更多地介绍这一点(特别是为什么咱们创立接口而不是类)。但现在让咱们创立它,这样咱们的代码就能够编译经过:

interface PlanetsRepository {
    suspend fun addPlanet(planet: Planet)
}

Planet数据类型界说如下:

data class Planet(
    val planetId: String?,
    val name: String,
    val distanceLy: Float,
    val discovered: Date
)

addPlanet办法(就像在运用事例中的invoke函数相同)被声明为suspend,由于咱们知道它将涉及后台作业。咱们以后会在这个接口中增加更多的办法,但现在这就满足了。

趁便说一下,你或许会问为什么咱们费力地创立了一个如此简略的运用事例。答案在于它未来或许会变得更加复杂,而且外部代码能够与该复杂性阻隔开来。

第2.1步:测验运用事例

咱们现在现已编写了运用事例,但咱们无法运转它。首要,它依靠于PlanetsRepository接口,而咱们还没有它的完结。Hilt不知道如何处理它。

可是咱们能够编写测验代码,供给一个假造的PlanetsRepository实例,并运用咱们的测验框架运转它。这便是你现在应该做的。

由于这是关于架构的教程,测验的细节不在范围内,所以这一步留给你作为练习。但请留意,杰出的架构规划让咱们将组件拆分为易于测验的部分。

第3步:数据层,编写PlanetsRepository

记住,库房的作业是整合不同的数据源,处理它们之间的差异,并供给CRUD操作。

运用依靠倒置和依靠注入

依据干净架构和依靠倒置准则(在我上一篇文章中有更多信息),咱们期望避免外部代码依靠于库房完结内部代码。这样一来,运用事例或视图模型(例如)就不会遭到库房代码的更改影响。

这解释了为什么咱们之前将PlanetsRepository创立为接口(而不是类)。调用代码将只依靠于接口,但它将经过依靠注入接纳完结。所以现在咱们将向接口增加更多办法,并创立其完结,咱们将称之为DefaultPlanetsRepository

(别的:有些开发团队遵从调用完结为<interface name>Impl的约定,例如PlanetsRepositoryImpl。我以为这种约定不利于阅览:类名应该告知你为什么要完结一个接口。所以我避免运用这种约定。但我提及它是由于它被广泛运用。)

运用Kotlin Flows使数据可用

假如你还没有触摸过Kotlin Flows,请停下手头的作业,当即阅览相关资料。它们将改动你的日子。

developer.android.com/kotlin/flow

它们供给了一个数据“管道”,随着新的成果变得可用而改动。只要调用方订阅了管道,他们将在有改动时收到更新。因而,现在咱们的UI能够在数据更新时自动更新,几乎不需求额定的作业。相比起过去,咱们有必要手动向UI发出数据已更改的信号。

虽然存在其他相似的解决方案,比方RxJavaMutableLiveData,它们做相似的作业,但它们不如Flows灵敏和易于运用。

增加常用的WorkResult

WorkResult类是数据层常用的回来类型。它答应咱们描绘一个特定恳求是否成功,其界说如下:

//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>()
}

调用代码能够检查给定的WorkResultSuccessError仍是Loading目标(后者表明没有完结),然后确认恳求是否成功。

第4步: 完结Repository接口

让咱们把上面的内容整合起来,为构成咱们的PlanetsRepository的办法和属性拟定标准。

它有两个用于获取行星的办法。第一个办法经过其ID获取单个行星:

fun getPlanetFlow(planetId: String): Flow<WorkResult<Planet?>>

第二个办法获取表明行星列表的Flow:

fun getPlanetsFlow(): Flow<WorkResult<List<Planet>>>

这些办法都是各自数据的单一来历。每次咱们都将回来存储在本地缓存中的数据,由于咱们需求处理这些办法被频繁运转的状况,而本地数据比拜访长途数据源更快、更便宜。但咱们还需求一种办法来刷新本地缓存。这将从长途数据源更新本地数据源:

suspend fun refreshPlanets()

接下来,咱们需求增加、更新和删去行星的办法:

suspend fun addPlanet(planet: Planet)
suspend fun deletePlanet(planetId: String)

因而,咱们的接口现在看起来是这样的:

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)
}

边写代码边编写数据源接口

为了编写完结该接口的类,咱们需求留意数据源将需求哪些办法。回想一下,咱们有两个数据源:LocalDataSourceRemoteDataSource。咱们还没有决议运用哪种第三方技能来完结它们——咱们现在也不需求。

让咱们现在创立接口界说,准备好在需求时增加办法签名:

//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...
}

现在准备填充这些接口,咱们能够编写DefaultPlanetsRepository了。让咱们逐个办法来看:

编写getPlanetFlow()getPlanetsFlow()
这两个办法都很简略;咱们回来本地源中的数据。(为什么不是长途源?由于本地源的存在是为了快速、资源轻量级地拜访数据。长途源或许始终是最新的,但它较慢。假如咱们严厉需求最新的数据,那么在调用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()函数。咱们现在将它们增加到接口中,以便咱们的代码能够编译。

//LocalDataSource.kt
interface LocalDataSource {
    fun getPlanetsFlow(): Flow<WorkResult<List<Planet>>>
    fun getPlanetFlow(planetId: String): Flow<WorkResult<Planet?>>
}

编写refreshPlanets()办法

为了更新本地缓存,咱们从长途数据源获取当时的行星列表,并将其保存到本地数据源中。(然后,本地数据源能够“感知”到更改,并经过getPlanetsFlow()回来的Flow发出新的行星列表。)

//DefaultPlanetsRepository.kt 
override suspend fun refreshPlanets() {
    val planets = remoteDataSource.getPlanets()
    localDataSource.setPlanets(planets)
}

这需求在每个数据源接口中增加一个新的办法,现在这些接口如下所示:

//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()函数时,它们都遵从相同的形式:在长途数据源上履行写操作,假如成功,就将更改反映到本地缓存中。

咱们预计长途数据源会为Planet目标分配唯一的ID,一旦它进入数据库,RemoteDataSource的addPlanet()函数将回来带有非空ID的更新后的Planet目标。

//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)
}

咱们终究的数据源接口如下:

//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步:状况持有者,编写PlanetsListViewModel

回想一下,UI层由UI元素和状况持有者层组成:

一个灵活、现代的Android应用架构

此时咱们依然不知道咱们将运用什么技能来绘制UI,所以咱们还不能编写UI元素层。但这没有问题;咱们能够继续编写状况持有者,坚信一旦咱们做出决议,它们就不用改动。这便是优异架构的更多优点!

编写PlanetsListViewModel的标准
UI将有两个页面,一个用于列出和删去行星,另一个用于增加或修改行星。 PlanetsListViewModel负责前者。这意味着它需求向行星列表屏幕的UI元素揭露数据,而且有必要准备好接纳来自UI元素的事件,以便用户履行操作。

具体而言,咱们的PlanetsListViewModel需求揭露:

  • 描绘页面当时状况的Flow(关键是包含行星列表)
  • 刷新列表的办法
  • 删去行星的办法
  • 增加一些示例行星的办法,以协助用户了解运用程序的功用

PlanetsListUiState目标:页面的当时状况

我发现将页面的整个状况封装在一个单独的数据类中十分有用:

//PlanetsListViewModel.kt
data class PlanetsListUiState(
    val planets: List<Planet> = emptyList(),
    val isLoading: Boolean = false,
    val isError: Boolean = false
)

留意我现已在与视图模型相同的文件中界说了这个类。它仅包含简略的目标:没有Flows等,只要原始类型,数组和简略的数据类。请留意,一切字段都有默认值-这在后边会有协助。

(有一些很好的原因,你或许甚至不期望在上面的类中出现Planet目标。Clean Architecture的朴实主义者会指出,在界说Planet的方位和运用它的方位之间有太多的层级跳转。状况提升准则告知咱们只供给咱们需求的切当数据。例如,现在咱们只需求Planet的名称和间隔,所以咱们应该只要这些,而不是整个Planet目标。个人以为这样做会不用要地使代码复杂化,而且会使将来的更改更加困难,但你能够自由挑选不同意见!)

因而,界说了这个类后,咱们现在能够在视图模型内部创立一个状况变量来揭露它:

//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(...)中运用的scopestarted参数能够安全地约束此StateFlow的生命周期。

增加示例行星

为了增加咱们的3个示例行星,咱们重复调用了为此目的创立的用例。

//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) }
    }
}

刷新和删去

刷新和删去函数十分相似,只需调用相应的存储库函数即可。

//PlanetsListViewModel.kt
fun deletePlanet(planetId: String) {
    viewModelScope.launch {
        planetsRepository.deletePlanet(planetId)
    }
}
fun refreshPlanetsList() {
    viewModelScope.launch {
        planetsRepository.refreshPlanets()
    }
}

第6步:编写AddEditPlanetViewModel

AddEditPlanetViewModel用于办理用于增加新行星或修改现有行星的屏幕。

与之前的做法相同——实际上,关于任何视图模型来说,这都是一个很好的实践——咱们将为UI显现的一切内容界说一个数据类,并为其创立单一的数据来历。

//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()
}

假如咱们正在修改一个行星(而不是增加新行星),咱们期望视图的初始状况反映该行星的当时状况。

作为杰出的实践,这个屏幕只会传递咱们要修改的行星的ID。(咱们不传递整个行星目标——它或许会变得过于庞大和复杂)。Android的生命周期组件供给了SavedStateHandle,咱们能够从中获取行星ID并加载行星目标。

//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
                    )
                }
            }
        }
    }
}

留意咱们如何运用以下形式更新UI状况:

_uiState.update { it.copy( ... ) }

在一行简略的代码中,它创立一个新的AddEditPlanetUiState,其值是从先前的状况仿制过来的,并经过uiState Flow发送出去。

这里是咱们用该技能更新行星的各种属性的函数:

//AddEditPlanetViewModel.kt
fun setPlanetName(name: String) {
    _uiState.update { it.copy(planetName = name) }
}
fun setPlanetDistanceLy(distanceLy: Float) {
    _uiState.update { it.copy(planetDistanceLy = distanceLy) }
}

终究,咱们运用AddPlanetUseCase保存行星目标:

//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步:编写数据源和UI元素

现在咱们现已建立了整个架构,能够编写最低层的代码,即UI元素和数据源。关于UI元素,咱们能够挑选运用Jetpack Compose来支撑手机和平板电脑。关于本地数据源,咱们能够编写一个运用Room数据库的缓存,而关于长途数据源,咱们能够模仿拜访长途API。

这些层应该尽或许坚持薄。例如,UI元素的代码不该包含任何计算或逻辑,只需朴实地将视图模型供给的状况显现在屏幕上。逻辑应该放在视图模型中。

关于数据源,只需编写最少数的代码来完结LocalDataSourceRemoteDataSource接口中的函数。

特定的第三方技能(如Compose和Room)超出了本教程的范围,但您能够在代码存储库中看到这些层的示例完结。

将低级部分留在终究

请留意,咱们能够将这些运用的最低级部分留到终究。这十分有益,由于它答应利益相关者有满足的时刻来做出关于运用哪些第三方技能以及运用应该如何展示的决议计划。即便在咱们编写了这些代码之后,咱们也能够更改这些决议计划,而不会影响运用的其余部分。

Github地址

完好的代码存储库在:

github.com/tdcolvin/Pl…

重视公众号:Android老皮
解锁 《Android十大板块文档》 ,让学习更靠近未来实战。已形成PDF版

内容如下

1.Android车载运用开发体系学习攻略(附项目实战)
2.Android Framework学习攻略,助力成为体系级开发高手
3.2023最新Android中高档面试题汇总+解析,告别零offer
4.企业级Android音视频开发学习路线+项目实战(附源码)
5.Android Jetpack从入门到通晓,构建高质量UI界面
6.Flutter技能解析与实战,跨渠道首要之选
7.Kotlin从入门到实战,全方面提升架构根底
8.高档Android插件化与组件化(含实战教程和源码)
9.Android 功能优化实战+360全方面功能调优
10.Android零根底入门到通晓,高手进阶之路

敲代码不易,重视一下吧。ღ( ・ᴗ・` )