灵活、现代的Android应用架构:完整分步指南

本文译自「A flexible, modern Android app architecture: complete step-by-step」,原文链接proandroiddev.com/a-flexible-...,由Tom Colvin发布于2023年7月4日。

我最近写了一篇关于优秀 Android 应用架构背后的理论的文章。这篇 文章成为了我迄今为止最受欢迎的文章,许多人都慷慨地表示它对他们很有帮助。

最常见的问题之一是:"但是 X 呢?它不太符合规则。" 这就是为什么我一直说:

要学习原则,而不是盲目遵循规则。

本文旨在展示实践的一面:通过示例来教授 Android 架构。最重要的是,这意味着展示各种架构决策是如何制定的。我们会遇到有多种可能答案的情况,在每种情况下,我们都会依靠原则,而不是死记硬背一套规则。

所以,让我们一起构建一个应用程序吧。

介绍我们将要构建的应用程序

我们将为行星观测者构建一款应用程序。它看起来会像这样:

我们的应用程序将具有以下功能:

  • 列出你之前发现的所有行星
  • 添加新发现的行星
  • 删除行星(以防你意识到你的发现实际上只是望远镜镜头上的一小块痕迹)
  • 添加一些示例行星,以便用户了解应用程序的工作原理

它将具有离线数据缓存以及在线数据库访问功能。

与我的演示一样,我鼓励你尝试不同的做法:添加额外功能,考虑未来可能出现的规格变更,挑战自我。在这里,学习的重点在于_代码背后的思考过程_,而不是代码本身。所以,如果你想从本教程中获得最大收获,不要盲目地复制代码。

这是我们最终的仓库:github.com/tdcolvin/Pl...

介绍我们将要使用的架构原则

我们将参考 SOLID 原则、整洁架构原则以及 Google 自己的现代应用架构原则。

我们不会将这些原则视为硬性规定,因为我们足够聪明,能够构建更适合我们应用(尤其是更符合我们预期应用增长方式)的架构。例如,如果你严格遵循整洁架构,你将开发出稳定、可靠、可扩展的软件,但对于单一用途的应用来说,你的代码可能会过于复杂。Google 的原则可以生成更简单的代码,但如果有一天该应用可能由多个大型开发团队维护,则这些原则就不太适用了。

我们将从 Google 的拓扑结构开始,并在此过程中参考整洁架构。

Google 的拓扑结构如下:

我们将逐步实现这些功能,我的上一篇文章 对每个部分都进行了更深入的介绍。这里再简单地概述一下:

UI 层

UI 层实现用户界面。它分为:

  • UI 元素 ,即用于在屏幕上绘制内容的所有专有代码。在 Android 中,主要选择是 Jetpack Compose(此处使用 @Composable)或 XML(此处包含你的 XML 文件和资源)。
  • 状态持有者,用于实现你偏好的 MVVM / MVC / MVP / ... 拓扑结构。在此应用中,我们将使用视图模型。

领域层

领域层用于包含高级业务逻辑的用例。例如​​,当我们想要添加一个星球时,AddPlanetUseCase 将描述执行此操作所需的一系列步骤。它只是"做什么"的列表,而不是"怎么做"的列表:例如,我们会说"保存 Planet 对象的数据"。这是一个高级指令。我们不会说"将其保存到本地缓存",更不用说"使用 Room 数据库将其保存到本地缓存"了------这些底层实现细节应该放在其他地方。

数据层

Google 敦促我们为应用中的所有数据提供单一可信来源;也就是说,一种获取最终"正确"数据版本的方法。这就是数据层将要提供的内容(涵盖除描述用户刚刚输入内容的数据结构之外的所有数据结构)。它分为:

  • 存储库,用于管理各种类型的数据。例如,我们将有一个行星数据存储库,它将提供对已发现行星的 CRUD(创建、读取、更新、删除)操作。它还将处理数据存储在本地缓存和远程缓存中的情况,为不同类型的操作选择合适的数据源,并管理当两个数据源包含不同数据副本时的处理方式。这里我们将讨论本地缓存,但我们不会讨论我们将使用哪些第三方技术来实现它。
  • 数据源,用于管理数据存储的具体细节。当存储库请求"远程存储 X"时,它会请求数据源执行此操作。数据源仅包含驱动专有技术所需的代码------可能是 Firebase、HTTP API 或其他技术。

良好的架构允许延迟决策

在此阶段,我们已经了解了应用的功能,以及一些关于如何管理数据的基本想法。

还有一些事情我们尚未确定。我们还不知道 UI 的具体外观,也不知道将使用什么技术来构建它(Jetpack Compose、XML 等)。我们不知道本地缓存将采用何种形式。我们不知道将使用哪种专有解决方案来访问在线数据。我们不知道是否支持手机、平板电脑或其他设备。

_问题:我们需要了解以上任何内容才能制定架构吗?

_答案:不需要!

以上都是底层考虑因素(在整洁架构中,它们的代码位于最外层)。它们是_实现细节_,而不是_逻辑_。SOLID 的依赖倒置原则告诉我们,任何代码都不应依赖于它们。

换句话说,我们应该能够在不了解上述任何知识的情况下编写(并测试!)应用程序的其余代码。当我们了解上述问题的答案时,我们已经编写的任何代码都无需更改。

这意味着代码生产阶段可以在设计师完成设计之前以及利益相关者决定使用第三方技术之前开始。因此,良好的架构允许延迟决策。(并且能够灵活地撤销任何此类决策,而不会导致严重的代码问题)。

我们项目的架构图

这是我们将行星观测员的应用程序融入 Google 拓扑结构的初步尝试。

数据层

我们将有一个用于行星数据的存储库 ,以及两个数据源:一个用于本地缓存,一个用于远程数据。

UI 层

将​​有两个状态持有者 ,一个用于行星列表页面,另一个用于添加行星页面。每个页面还会有一组UI 元素,这些元素将使用目前尚待确定的技术编写。

领域层

有两种非常有效的领域层架构方法:

  1. 我们可以只在重复业务逻辑的地方添加用例。在我们的应用中,唯一重复的逻辑是添加行星:用户添加示例行星列表和手动输入自己的行星详细信息时都需要它。因此,我们只创建一个用例:AddPlanetUseCase。在其他情况下(例如删除行星),状态持有者将直接与存储库交互。
  2. 我们可以为与存储库的每次交互添加用例,这样状态持有者和存储库之间就不会有任何直接联系。在这种情况下,我们将有添加行星、删除行星和列出行星的用例。

第二种方法的好处是它遵循了整洁架构的规则。我个人认为这种方法对于大多数应用来说有点太重了,所以我倾向于选择第一种。这就是我们要做的。

这给了我们以下架构图:

我们应该从哪些代码开始?

规则是:

从高层代码开始,然后逐步向下。

这意味着首先要写出用例,因为这样做可以告诉我们存储库层有哪些需求。一旦我们知道存储库需要什么,我们就可以写出数据源需要什么来支持它。

同样,由于用例告诉我们用户可能采取的所有操作,我们就可以了解 UI 的所有输入和输出。由此,我们可以知道 UI 需要包含哪些内容,从而可以编写状态持有者(视图模型)。有了状态持有者,我们就知道需要编写哪些 UI 元素。

当然,一旦高级工程师和项目利益相关者就将要使用的技术达成一致,我们就可以无限期地推迟编写 UI 元素和数据源(即所有底层代码)。

理论到此结束。现在让我们开始构建应用程序。我会向你们详细介绍我们做出的决定。

步骤 0:创建项目

打开 Android Studio 并创建一个"无活动"项目:

在下一个屏幕上,将其命名为 PlanetSpotters,其他内容保持不变:

添加依赖注入

我们需要一个依赖注入框架,它有助于应用 SOLID 的依赖倒置原则。 Hilt 是我最喜欢的选择,而且值得庆幸的是,它也是 Google 特别推荐的。

添加 Hilt,请在根 Gradle 文件中添加以下内容:

groovy 复制代码
plugins {
  ...
  id 'com.google.dagger.hilt.android' version '2.44.2' apply false
}

并在 app/build.gradle 文件中添加以下内容:

groovy 复制代码
plugins {
  id 'kotlin-kapt'
  id 'com.google.dagger.hilt.android'
}

android {
  ...
  compileOptions {
    sourceCompatibility JavaVersion.VERSION_17
    targetCompatibility JavaVersion.VERSION_17
  }
  
  kotlinOptions {
    jvmTarget = '17'
  }
}

dependencies {
  implementation "com.google.dagger:hilt-android:2.44.2"
  kapt "com.google.dagger:hilt-compiler:2.44.2"
}

// Allow references to generated code
kapt {
  correctErrorTypes true
}

(请注意,我们在此处将兼容性设置为 Java 17,这是 Hilt 使用的 Kapt 的要求。你需要 Android Studio Flamingo 或更高版本)。

最后,添加 Application 类的重写,其中包含 @HiltAndroidApp 注解。也就是说,在应用的包文件夹(此处为 com.tdcolvin.planetspotters)中创建一个名为 PlanetSpottersApplication 的文件,内容如下:

kotlin 复制代码
package com.tdcolvin.planetspotters

import android.app.Application
import dagger.hilt.android.HiltAndroidApp

@HiltAndroidApp
class PlanetSpottersApplication: Application()

......然后,通过将文件添加到清单中,告诉操作系统实例化它:

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <application
        ....
        android:name=".PlanetSpottersApplication"
        ...
    />
  
    ...
</manifest>

一旦我们有了主 Activity,我们就需要为其添加 @AndroidEntryPoint。但现在,我们的 Hilt 设置就完成了。

最后,我们将通过在 app/build.gradle 中添加以下代码来添加对其他有用库的支持:

groovy 复制代码
dependencies {
    ...

    //Coroutines
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'

    //viewModelScope
    implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
}

步骤 1:列出用户可以执行和查看的所有功能

此步骤是编写用例和存储库的准备工作。回想一下,用例是用户可以执行的单个任务,并以高层次描述(描述"什么",而不是"如何")。

因此,让我们从写出这些任务开始;列出用户可以在应用中执行和查看的所有功能的详尽列表。

其中一些任务最终会被编码为用例。(事实上,在清晰架构下,所有此类任务都必须以用例的形式编写)。其他任务将由 UI 层直接与存储库层通信来完成。

这里需要一份书面规范。它不需要 UI 设计,但如果有的话,它无疑有助于可视化。

我们的列表如下:

获取已发现行星的列表,该列表会自动更新

输入:无

输出:Flow<List>

操作:从存储库请求当前已发现行星的列表,该列表必须以表单形式提供,以便在发生变更时及时通知我们。

获取单个已发现行星的详细信息,该列表会自动更新

输入:String --- 我们要获取的行星的 ID

输出:Flow

操作:从存储库请求具有指定 ID 的行星,并在发生变更时通知我们。

添加/编辑新发现的行星

输入

  • planetId: String? --- 如果非空,则为要编辑的行星的 ID。如果为空,则表示我们正在添加一颗新行星。
  • name:字符串 --- 行星名称
  • distanceLy:浮点型 --- 行星与地球的距离(光年)
  • discover:日期 --- 发现日期

输出:无(完成即成功,无异常)

操作:根据输入创建一个 Planet 对象,并将其传递给存储库(以添加到其数据源)

添加一些示例行星

输入:无

输出:无(出错时抛出)

操作:请求存储库添加三颗示例行星,其发现日期为当前时间:Trenzalore(300 光年)、Skaro(0.5 光年)、Gallifrey(40 光年)。

删除行星

输入:字符串 --- 待删除行星的 ID

输出:无(出错时抛出)

操作:请求存储库删除具有指定 ID 的行星。

现在我们有了这个列表,我们就可以开始编写用例和存储库了。

步骤 2:编写用例(Usec ases)

从步骤 1 开始,我们得到了一个用户可以执行的任务列表。之前我们决定,在这些任务中,唯一要编写为用例的任​​务是"添加星球"。(我们决定只添加那些在应用的不同区域重复执行任务的用例)。

这样我们就有了一个可以在这里编写的用例:AddPlanetUseCase

一个很棒的 Kotlin 技巧是将用例的逻辑放在 operator fun invoke(...) 函数中。这样就可以像调用函数一样调用代码来"调用"类实例,如下所示:

kotlin 复制代码
val addPlanetUseCase: AddPlanetUseCase = ...//Use our instance as if it were a function:
addPlanetUseCase(...)

这是我们使用该技巧编写的 AddPlanetUseCase 代码:

kotlin 复制代码
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 是一个接口,它列出了存储库将拥有的方法。稍后会详细介绍(特别是为什么我们要创建接口而不是类)。但现在我们先创建它,这样我们的代码就能编译了:

kotlin 复制代码
interface PlanetsRepository {
    suspend fun addPlanet(planet: Planet)
}

描述 Planet 的数据类型:

kotlin 复制代码
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

(补充:一些开发团队习惯将实现命名为 <接口名称>Impl;例如 PlanetsRepositoryImpl。我认为这种约定不利于代码可读性:类名应该能够说明实现接口的原因。所以我避免使用这种方式。但我还是提到了它,因为它的使用非常广泛。)

使用 Kotlin Flows 实现数据可用

如果你还没有接触过 Kotlin Flows,那就赶紧停下手头的工作,现在就去了解一下吧。它们将改变你的生活。

它们提供了一个数据"管道",会随着新结果的出现而变化。只要调用者注册了该管道,他们就会在发生变更时收到更新。现在,我们的 UI 可以随着数据更新而自动更新,几乎无需任何额外操作。相比之下,过去我们必须手动向 UI 标记数据已更改。

其他解决方案,例如 RxJava 和 MutableLiveData,它们具有类似的功能,但它们不如 Flows 灵活易用。

添加无处不在的 WorkResult 类

WorkResult 类是数据层的常见返回值。它允许我们描述特定请求是否成功,如下所示:

kotlin 复制代码
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>()
}

调用代码可以检查给定的 WorkResult 是"Success"、"Error"还是"Loading"对象(后者表示请求尚未完成),从而确定请求是否成功。

我们的存储库接口

让我们将以上所有内容结合起来,制定构成 PlanetsRepository 的方法和属性的规范。

它有两种获取行星的方法。第一个方法通过 ID 获取单个行星:

kotlin 复制代码
fun getPlanetFlow(planetId: String): Flow<WorkResult<Planet?>>

第二个方法获取一个代表行星列表的 Flow:

kotlin 复制代码
fun getPlanetsFlow(): Flow<WorkResult<List<Planet>>>

这两个方法都是各自数据的唯一真实来源。每次我们都会返回存储在本地缓存中的数据,因为我们需要处理这些方法的频繁运行,而且本地数据比访问远程数据源更快、更便宜。但我们需要一个方法来刷新本地缓存。这将从远程数据源更新本地数据源:

kotlin 复制代码
suspend fun refreshPlanets()

接下来我们需要添加、更新和删除行星的方法:

kotlin 复制代码
suspend fun addPlanet(planet: Planet)suspend fun deletePlanet(planetId: String)

所以我们的界面现在看起来像这样:

kotlin 复制代码
package com.tdcolvin.planetspotters.data.repository

...

interface PlanetsRepository {
    fun getPlanetsFlow(): Flow<WorkResult<List<Planet>>>
    fun getPlanetFlow(planetId: String): Flow<WorkResult<Planet?>>
    suspend fun refreshPlanets()
    suspend fun addPlanet(planet: Planet)
    suspend fun deletePlanet(planetId: String)
}

边写边写数据源接口

为了编写实现接口的类,我们需要关注数据源需要哪些方法。回想一下,我们有两个数据源:LocalDataSource 和 RemoteDataSource。我们还没有决定使用哪种第三方技术------而且现在也不需要。

现在让我们创建接口定义,以便我们边写边添加方法签名:

kotlin 复制代码
package com.tdcolvin.planetspotters.data.source.local

interface LocalDataSource {
  //Ready to add method signatures here...
}
kotlin 复制代码
package com.tdcolvin.planetspotters.data.source.remote

interface RemoteDataSource {
  //Ready to add method signatures here...
}

准备好填充这些接口后,我们现在可以编写 DefaultPlanetsRepository 了。让我们逐一调用这些方法:

编写 getPlanetFlow() 和 getPlanetsFlow()

这两个方法都很简单;我们返回本地数据源中的数据。 (为什么不使用远程数据源?因为本地数据源的存在是为了快速、轻量地访问数据。远程数据源可能始终是最新的,但速度很慢。如果我们确实需要最新的数据,那么我们可以在调用 getPlanetsFlow() 之前使用下面的 refershPlanets()。)

kotlin 复制代码
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() 函数。我们现在将它们添加到接口中,以便代码能够编译。

kotlin 复制代码
interface LocalDataSource {
    fun getPlanetsFlow(): Flow<WorkResult<List<Planet>>>
    fun getPlanetFlow(planetId: String): Flow<WorkResult<Planet?>>
}

编写 refreshPlanets()

要更新本地缓存,我们从远程数据源获取当前的行星列表,并将其保存到本地数据源。(然后,本地数据源可以"注意到"更改,并通过 getPlanetsFlow() 返回的 Flow 发出新的行星列表。)

kotlin 复制代码
override suspend fun refreshPlanets() {
    val planets = remoteDataSource.getPlanets()
    localDataSource.setPlanets(planets)
}

这需要在每个数据源接口中创建一个新方法,现在如下所示:

kotlin 复制代码
interface LocalDataSource {
    fun getPlanetsFlow(): Flow<WorkResult<List<Planet>>>
    fun getPlanetFlow(planetId: String): Flow<WorkResult<Planet?>>
    suspend fun setPlanets(planets: List<Planet>)
}
kotlin 复制代码
interface RemoteDataSource {
    suspend fun getPlanets(): List<Planet>
}

注意所有这些方法都被声明为"suspend fun"。这将线程和协程上下文的责任转交给调用者。

编写 addPlanet() 和 deletePlanet()

这两个函数都遵循相同的模式:对远程数据源执行写入操作,如果成功,则将更改镜像到本地缓存。

我们期望远程数据源在 Planet 对象写入数据库后为其分配一个唯一的 ID,因此 RemoteDataSource 的 addPlanet() 函数返回一个更新后的 Planet 对象,该对象具有非空的ID(NonNull ID)。

kotlin 复制代码
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)
}

完成所有这些之后,最终的数据源接口如下:

kotlin 复制代码
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)
}
kotlin 复制代码
interface RemoteDataSource {
    suspend fun getPlanets(): List<Planet>
    suspend fun addPlanet(planet: Planet): Planet
    suspend fun deletePlanet(planetId: String)
}

我们稍后会编写实现这些接口的代码,但现在,我们先来看看 UI。

步骤 4:状态持有者,编写 PlanetsListViewModel

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

目前我们还不知道要使用什么技术来绘制 UI,所以还不能编写 UI 元素层。但这没关系;我们可以继续编写状态持有者,而且一旦我们做出决定,它们就无需更改。这就是优秀架构的更多优势!

编写 PlanetsListViewModel 的规范

UI 将包含两个页面,一个用于列出和删除行星,另一个用于添加或编辑行星。PlanetsListViewModel 为前者提供支持。这意味着它需要将数据暴露给行星列表屏幕的 UI 元素,并且必须准备好在用户执行操作时接收来自 UI 元素的事件。

具体来说,我们的 PlanetsListViewModel 需要暴露:

  • 描述页面当前状态的 Flow(至关重要的是,它包含行星列表)
  • 刷新列表的方法
  • 删除行星的方法
  • 添加示例行星的方法,帮助用户了解应用的功能

PlanetsListUiState 对象:页面的当前状态

我发现将页面的整个状态包含在一个数据类中很有帮助:

kotlin 复制代码
data class PlanetsListUiState(
    val planets: List<Planet> = emptyList(),
    val isLoading: Boolean = false,
    val isError: Boolean = false
)

请注意,我已将其与视图模型定义在同一个文件中。它仅包含简单对象:没有 Flow 等,只有原始类型、数组和简单的数据类。另请注意,所有字段都有默认值------这将在后面帮助我们。

(有一些很好的理由让你甚至不希望 Planet 对象出现在上面的代码中。整洁架构的纯粹主义者会指出,在 Planet 的定义和使用之间,层级跳跃太多了。状态提升原则告诉我们,只提供我们需要的精确数据。例如,现在我们只需要 Planet 的名称和距离,所以我们应该只提供这些,而不是整个 Planet 对象。我个人认为这不必要地增加了代码的复杂性,并使未来的修改更加困难,但你可以不同意!)

定义好之后,我们现在可以在视图模型中创建一个状态变量来暴露它:

kotlin 复制代码
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)
    )
}

看看如何根据刚从存储库收到的不同类型的结果来创建"下一个"UI 状态?

.stateIn(...)scopestarted 参数安全地限制了此 StateFlow 的生命周期。更多信息,请参阅 Manual Vivo 的精彩文章

添加示例行星

为了添加这 3 个示例行星,我们反复调用为此创建的用例。

kotlin 复制代码
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) }
    }
}

刷新和删除

刷新和删除函数的结构非常相似,只需调用相应的存储库函数即可。

kotlin 复制代码
fun deletePlanet(planetId: String) {
    viewModelScope.launch {
        planetsRepository.deletePlanet(planetId)
    }
}

fun refreshPlanetsList() {
    viewModelScope.launch {
        planetsRepository.refreshPlanets()
    }
}

步骤 5:编写 AddEditPlanetViewModel

AddEditPlanetViewModel 为用于添加新行星或编辑现有行星的屏幕提供支持。

正如我们之前所做的那样------事实上,这也是任何视图模型的良好实践------我们将为 UI 显示的所有内容定义一个数据类,并为其定义一个单一的数据源:

kotlin 复制代码
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。(我们不会传递整个 Planet 对象------这可能会变得太大太复杂)。Android 的 Lifecycle 组件为我们提供了一个 SavedStateHandle,我们可以从中获取星球 ID 并加载 Planet 对象:

kotlin 复制代码
@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 状态:

kotlin 复制代码
_uiState.update { it.copy( ... ) }

只需一行简单的代码,即可创建一个新的 AddEditPlanetUiState,其值从前一个复制而来,并通过 uiState Flow 将其发送出去。

以下是使用该技术更新行星各项属性的函数:

kotlin 复制代码
fun setPlanetName(name: String) {
    _uiState.update { it.copy(planetName = name) }
}

fun setPlanetDistanceLy(distanceLy: Float) {
    _uiState.update { it.copy(planetDistanceLy = distanceLy) }
}

最后,我们使用 AddPlanetUseCase 保存行星对象:

kotlin 复制代码
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) }
        }
    }
    
    ...
    
}

步骤 6:编写数据源和 UI 元素

现在我们已经完成了整个架构,可以编写最底层的代码了。也就是 UI 元素和数据源。对于 UI 元素,我们可以选择使用 Jetpack Compose 来支持手机和平板电脑。对于本地数据源,我们可以使用 Room DB 编写缓存;对于远程数据源,我们可以模拟访问远程 API。

这些层应该尽可能精简。例如,UI 元素代码不应包含任何计算或逻辑,而应仅包含获取视图模型给定状态并将其显示在屏幕上所需的代码。逻辑是为视图模型编写的。

对于数据源,只需编写实现 LocalDataSource 和 RemoteDataSource 接口中函数所需的最少代码即可。

具体的第三方技术(例如 Compose 和 Room)不在本教程的讨论范围内,但你可以在代码仓库 中查看这些层的示例实现。

将底层代码留到最后

请注意,我们能够将应用程序的这些部分留到最后。这非常有益,因为它为利益相关者提供了充足的时间来决定使用哪些第三方技术以及应用程序的外观。即使在编写代码之后,我们也可以撤销这些决定,而不会影响应用程序的任何其他部分。

完整的代码库位于:github.com/tdcolvin/Pl...

本教程有很多内容需要学习;祝贺你坚持到最后。希望本教程对你有所帮助。我没有徽章(更不用说文凭)可以颁发------但请随意(甚至鼓励)自己制作一个,并将结果发布在这里。

当然,如果你有任何问题或意见,或者你不同意某些观点(实际上_特别是_如果你不同意某些观点),请分享!请在此处留言,我会尽力回复所有人。

最后,我目前每周提供几节免费课程,帮助任何有 Android 开发或应用业务相关经验的人士。你可以在这里预约:calendly.com/tdcolvin/an...

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

保护原创,请勿转载!

相关推荐
LSL666_1 小时前
5 Repository 层接口
android·运维·elasticsearch·jenkins·repository
alexhilton5 小时前
在Jetpack Compose中创建CRT屏幕效果
android·kotlin·android jetpack
2501_940094027 小时前
emu系列模拟器最新汉化版 安卓版 怀旧游戏模拟器全集附可运行游戏ROM
android·游戏·安卓·模拟器
下位子7 小时前
『OpenGL学习滤镜相机』- Day9: CameraX 基础集成
android·opengl
参宿四南河三8 小时前
Android Compose SideEffect(副作用)实例加倍详解
android·app
火柴就是我9 小时前
mmkv的 mmap 的理解
android
没有了遇见9 小时前
Android之直播宽高比和相机宽高比不支持后动态获取所支持的宽高比
android
shenshizhong10 小时前
揭开 kotlin 中协程的神秘面纱
android·kotlin
vivo高启强10 小时前
如何简单 hack agp 执行过程中的某个类
android
沐怡旸10 小时前
【底层机制】 Android ION内存分配器深度解析
android·面试