Android MVI架构之UI开发指南
在整个应用程序架构中,UI层并不是唯一的层级。除了UI层之外,您还可以找到数据层,有时还有领域层。根据Android架构文档:
- UI层在屏幕上显示数据。
- 数据层暴露应用程序数据,并包含大部分业务逻辑。
- 领域层是一个可选的层,旨在简化和重用UI层的潜在业务逻辑复杂性。不多也不少。
注意:业务逻辑赋予应用程序价值。它是实现产品需求的方式,决定了应用程序如何获取、存储和修改数据。
UI层中的实体
UI层包括三个具有明确定义责任的独立实体。这种区分有助于关注点分离,增强可测试性,并促进可重用性。
- UI或UI元素,在屏幕上呈现数据。
- UI状态描述要在屏幕上呈现的数据。如果UI代表用户所见的内容,那么UI状态就是应用程序指定用户应该看到的内容。
- 引入了一个可选的状态持有者,以简化UI,管理部分逻辑,保存其UI状态,并将其暴露给UI。当UI内部状态和逻辑的复杂性增加,导致更难以理解时,就会使用状态持有者。
单向数据流
然而,应用程序并不显示静态信息。用户经常与其进行交互,执行可能修改应用程序状态的操作。用户事件通常由状态持有者处理,并在处理后可能导致UI状态的变化。在这种情况下,UI状态不是静态的。状态持有者将公开一个UI状态的流,其发射的数据会立即反映在UI上。文档中也将此概念称为单向数据流(Unidirectional Data Flow,UDF)。
https://developer.android.com/topic/architecture#unidirectional-data-flow
UI
文档和本博文中的指导适用于View系统和Jetpack Compose。无论您选择哪种UI工具包,UI在UI层中的角色保持独立。
在考虑UI层时,开发人员往往将UI层仅仅看作屏幕级别的一部分 - 即在可用显示区域的大部分地方显示应用程序数据的UI树的那一部分。通常,开发人员使用androidx.ViewModel
作为状态持有者的实现细节。
然而,就像为处理不同类型的数据(例如PaymentsRepository
、UserRepository
等)创建“多个”数据层一样,您可以灵活地在需要的UI树或UI层次结构的任何位置引入UI层实体。这个决策的粒度取决于您的UI的复杂性。
正如我们将在状态持有者部分看到的那样,您可以在UI树的任何位置引入状态持有者以简化UI。实际上,在某些情况下,这是推荐的。
UI状态
UI状态描述了要在屏幕上显示的信息。在本节中,我们将看到如何建模、生成和观察UI状态。
UI状态的类型*
通常需要特殊处理的一种UI状态子类型是屏幕UI状态。这通常来自于数据层公开的应用程序状态。之所以特别提到它,是因为它包含了大部分在屏幕上显示的信息,这与用户通常最感兴趣的内容相符。
作为稍后即将介绍的内容的预览,重要的是要注意,屏幕UI状态应该在配置更改时持久化或缓存。
如何生成UI状态
生成UI状态是状态持有者处理某些输入的输出。这些输入可以是:1)事件,2)本地状态变化的源,或者3)外部状态变化的源。
在不同情况下应该使用哪些API?
- UI状态应该作为可观察的数据持有类公开(例如
StateFlow
、Compose State<T>
或LiveData
)。这种类型确保UI始终具有要在屏幕上呈现的UI状态。 - 输入可以采用各种形式,主要是作为数据流或一次性API。
让我们来看几个例子!
使用本地状态变化源生成UI状态
想象一下,我们正在一个屏幕上,允许用户掷两个骰子。除了显示骰子的值之外,我们还想跟踪用户掷骰子的次数。我们的UI状态可能如下所示:
data class DiceRollUiState(val firstDiceValue: Int? = nullval secondDiceValue: Int? = nullval numberOfRolls: Int = 0
)
掷骰子的业务逻辑是通过一次性调用Random
API 实现的。
firstDiceValue = Random.nextInt(1..6),
secondDiceValue = Random.nextInt(1..6),
numberOfRolls = currentUiState.numberOfRolls + 1
那么,我们如何在状态持有者中保存这个UI状态呢?创建一个可观察的数据持有类!在这个例子中,我们使用MutableStateFlow
API来实现这一点。为了避免直接依赖于Random
API,这可能会影响可重用性和可测试性,我们引入了一个更通用的RandomProvider
接口,其实现是Random API。
class DiceRollStateHolder(private val randomProvider: RandomProvider
) {private val _uiState = MutableStateFlow(DiceRollUiState())val uiState: StateFlow<DiceRollUiState> = _uiState.asStateFlow()fun rollDice() {_uiState.update { currentState ->currentState.copy(firstDiceValue = randomProvider.nextInt(1..6),secondDiceValue = randomProvider.nextInt(1..6),numberOfRolls = currentState.numberOfRolls + 1)}}
}
生成这个UI状态的业务逻辑是在状态持有者内部实现的。为了防止暴露可变版本的可观察状态持有者,从而允许直接修改UI状态并违反单一真相来源原则,我们将UI状态作为StateFlow公开。uiState
是我们可变状态的只读版本,我们使用.asStateFlow
操作符进行转换。
注意:除了MutableStateFlow
,我们还可以使用Compose State<T>
或LiveData来建模我们的UI状态。有关在这种上下文中使用Compose State<T>
的模式和最佳实践,请参阅状态生成文档。
使用外部状态变化源生成UI状态
应用程序数据以数据流的形式来自层次结构的其他层。为了将这些数据适应UI状态,我们必须将其转换为可观察的数据持有类型。在下面的示例中,我们通过在屏幕上显示用户的姓名来向用户打招呼。
class DiceRollViewModel(userRepository: UserRepository
) : ViewModel() {val userUiState: StateFlow<String> =userRepository.userStream.map { user -> user.name }.stateIn(scope = viewModelScope,started = SharingStarted.WhileSubscribed(5_000),initialValue = "")
}
状态持有者作为依赖项获取数据层的实例(例如UserRepository
)。然后,它将userStream: Flow
映射为我们感兴趣的特定信息,这在本例中是用户的姓名。由于map
操作符返回一个Flow
,我们使用.stateIn
操作符将Flow转换为StateFlow
,即可观察的数据持有类型。
在处理来自层次结构其他层的Flows和/或组合多个数据流时,.stateIn
是状态持有者中常用的操作符。它的方法定义包含以下内容:
scope
:定义结果StateFlow
的生命周期。started
:确定启动和停止共享的策略。在代码片段中,我们使用WhileSubscribed(5_000)
来停止从上游流(例如来自UserRepository
的流)收集数据,当特定时间内没有收集器/观察者时,例如5秒。通过这种方式,如果UI对用户不可见超过5秒,我们可以取消那些数据层流的收集,并节省资源以保护设备的健康。initialValue
:指定StateFlow
的初始值。如前所述,使用可观察的状态持有类型可以确保UI始终有UI状态可以在屏幕上呈现,而该参数在实现此目标方面起着关键作用。
生成UI状态的总结
让我们根据输入类型和源API的类型总结要公开的类型:
- 如果您使用的是一次性API或本地业务逻辑,请使用
MutableStateFlow
或Compose MutableState<T>
在状态持有者中存储状态。然后,将其公开为StateFlow
或Compose State<T>
。 - 当源类型是作为Flow提供的外部流时,您应该公开一个
StateFlow
。 - 如果您同时处理两种类型的输入,例如至少有一个外部流,请组合所有输入,并将UI状态公开为
StateFlow
。
如何建模 UI 状态
UI 状态描述了特定时间点的用户界面。UI 是 UI 状态的可视表示。我们之前在上面的 DiceRollUiState
代码片段中定义了一个数据类作为 UI 状态。这里是它的定义:
data class DiceRollUiState(val firstDiceValue: Int? = null,val secondDiceValue: Int? = null,val numberOfRolls: Int = 0
)
UI 状态中的字段非常重要的是不可变的(即 val),以确保时间和一致性保证。通常,UI 状态字段具有合理的默认值以便于创建和复制。然而,并非所有的 UI 状态都像前面那个例子那样简单明了。
让我们考虑另一种情况,即当用户登录后才能掷骰子的场景。当用户进入界面时,我们会检查用户状态并做出决定。以下是这种情况下可能的 UI 状态:
sealed interface DiceRollUiState {data object Loading : DiceRollUiStatedata class DiceRoll(val username: String,val numberOfRolls: Int,val firstDiceValue: Int? = null,val secondDiceValue: Int? = null) : DiceRollUiStatedata object LogUserIn : DiceRollUiState}
UI 状态可以是加载状态(Loading
),表示用户需要登录状态(LogUserIn
),或者在屏幕上显示带有用户名的骰子掷出值(DiceRoll
)。
何时使用数据类、密封接口/类或两者结合使用?
当屏幕可能处于多个互斥状态时,请使用密封接口/类。
当其中的数据可能发生变化时,请使用数据类。这在采用离线优先方法的屏幕中特别有用,因为屏幕可能同时显示加载指示、数据和错误消息。
如何建模复杂的 UI 状态
在处理复杂的屏幕时,您需要确保不会创建 UI 不一致性。作为练习,让我们尝试对 Jetnews 的主屏幕进行建模,Jetnews 是一个 Compose 示例应用程序的主屏幕。
https://github.com/android/compose-samples/tree/main/JetNews
屏幕的主要内容显示了一系列文章以及一个打开的文章详情部分,用户可以在其中阅读文章。作为建模整个 UI 屏幕的初步步骤,我们可以定义以下 UI 状态:
private data class HomeViewModelState(val postsFeed: PostsFeed? = null,val selectedPostId: String? = null,val isArticleOpen: Boolean = false,val favorites: Set<String> = emptySet(),val isLoading: Boolean = false,val errorMessages: List<ErrorMessage> = emptyList(),val searchInput: String = ""
)
然而,这里存在一个问题。你能发现它吗?由于默认值的存在,我们可能会创建出不一致的 UI 状态!我们可能会有一个具有 selectedPostId
但没有 postsFeed
的 UI 状态。这是不现实的,不应该发生。为了解决这个问题,我们需要引入更强类型的状态来防止出现这些问题。考虑到我们的业务需求允许在屏幕上显示帖子或者什么都不显示,我们可以在该状态之上引入一个密封接口:
sealed interface HomeUiState {val isLoading: Booleanval errorMessages: List<ErrorMessage>val searchInput: Stringdata class NoPosts(override val isLoading: Boolean = false,override val errorMessages: List<ErrorMessage> = emptyList(),override val searchInput: String = "") : HomeUiStatedata class HasPosts(val postsFeed: PostsFeed,val selectedPost: Post,val isArticleOpen: Boolean,val favorites: Set<String>,override val isLoading: Boolean = false,override val errorMessages: List<ErrorMessage> = emptyList(),override val searchInput: String = "") : HomeUiState
}
现在,我们的 UI 可能会显示 HasPosts
或 NoPosts
。在 HasPosts
变体中,不可能有一个 selectedPost
而没有现有的 postsFeed
。问题解决了!虽然我们对 UI 状态的初始近似可能对于私下建模整个 UI 状态仍然有用,但这种类型永远不会公开。最终,您将把该状态映射到 HomeUiState
:
private data class HomeViewModelState(...) {fun toUiState(): HomeUiState =if (postsFeed == null) {HomeUiState.NoPosts(...)} else {HomeUiState.HasPosts(...)}
}
公开单个与多个 UI 状态流
关于状态持有者是否应该公开单个或多个数据流,我们经常进行讨论。
到目前为止,我们一直建议如果字段之间存在依赖关系,那么应该公开单个 UI 状态流。另一方面,如果这些字段彼此独立,不会导致 UI 不一致性,那么可以公开多个流是可以接受的。
有人可能会争辩说,如果它们完全独立,那意味着它们影响 UI 的不同部分,并且每个部分都可以拥有自己的状态持有者。当然,我同意。但如果你不想创建多个状态持有者,并从更高级别的状态持有者中公开多个 UI 状态,那也是可以接受的。
如何消费 UI 状态
理想情况下,应该以生命周期感知的方式从 UI 中消费 UI 状态。也就是说,只有当 UI 在屏幕上可见时才进行消费。在 Android 生命周期中,这是当生命周期处于 STARTED
和 STOPPED
状态之间时。有不同的 API 可以方便地实现这一点。
对于 Android Views,您可以使用位于 androidx.lifecycle.lifecycle-runtime-ktx
组件中的 repeatOnLifecycle
或 flowWithLifecycle
API。以下是使用 repeatOnLifecycle
的示例:
class SomeActivity : AppCompatActivity() {override fun onCreate(savedInstanceState: Bundle?) {// ...lifecycleScope.launch {repeatOnLifecycle(Lifecycle.State.STARTED) {viewModel.uiState.collect {// 新的 UI 状态!更新 UI}}}}
}
在 repeatOnLifecycle
协程块内,您从 UI 状态中进行收集。repeatOnLifecycle
会自动创建一个新的协程,在生命周期达到该状态时执行该块,并在生命周期低于该状态时取消正在运行该块的协程。
在 Compose 中,可以使用 collectAsStateWithLifecycle
API,该 API 在内部使用 repeatOnLifecycle
API。它位于 androidx.lifecycle.lifecycle-runtime-compose 组件中。此 API 根据给定的生命周期 State 收集底层 flow,并将 flow 的最新值表示为 Compose State<T>
。这允许可组合函数在发出新元素时进行重新组合。
@Composable
fun SomeScreen(modifier: Modifier = Modifier,viewModel: SomeViewModel = viewModel()
) {val uiState: SomeUiState by viewModel.uiState.collectAsStateWithLifecycle()// 根据 uiState 发射 UI。SomeScreen 将在 `viewModel.uiState` 发出新值时重新组合。
}
现在您已经阅读了 UI 层速成课程, 应该对该层中存在的不同实体有了一个大致的理解,以及如何有效地考虑 UI 和 UI 状态。