Android MVI架构之UI开发指南

发布时间:2023年12月22日

Android MVI架构之UI开发指南

在整个应用程序架构中,UI层并不是唯一的层级。除了UI层之外,您还可以找到数据层,有时还有领域层。根据Android架构文档:

  • UI层在屏幕上显示数据。
  • 数据层暴露应用程序数据,并包含大部分业务逻辑。
  • 领域层是一个可选的层,旨在简化和重用UI层的潜在业务逻辑复杂性。不多也不少。

注意:业务逻辑赋予应用程序价值。它是实现产品需求的方式,决定了应用程序如何获取、存储和修改数据。

典型的Android应用程序架构中的层级结构:UI层、数据层和领域层

UI层中的实体

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流向状态持有者,状态从状态持有者流向UI

UI

文档和本博文中的指导适用于View系统和Jetpack Compose。无论您选择哪种UI工具包,UI在UI层中的角色保持独立。

在考虑UI层时,开发人员往往将UI层仅仅看作屏幕级别的一部分 - 即在可用显示区域的大部分地方显示应用程序数据的UI树的那一部分。通常,开发人员使用androidx.ViewModel作为状态持有者的实现细节。

然而,就像为处理不同类型的数据(例如PaymentsRepositoryUserRepository等)创建“多个”数据层一样,您可以灵活地在需要的UI树或UI层次结构的任何位置引入UI层实体。这个决策的粒度取决于您的UI的复杂性。

您可以在UI树的任何部分引入UI层中的不同实体。例如,在屏幕、导航栏或仅仅一个按钮中。

正如我们将在状态持有者部分看到的那样,您可以在UI树的任何位置引入状态持有者以简化UI。实际上,在某些情况下,这是推荐的。

UI状态

UI状态描述了要在屏幕上显示的信息。在本节中,我们将看到如何建模、生成和观察UI状态。

UI状态的类型*
通常需要特殊处理的一种UI状态子类型是屏幕UI状态。这通常来自于数据层公开的应用程序状态。之所以特别提到它,是因为它包含了大部分在屏幕上显示的信息,这与用户通常最感兴趣的内容相符。
作为一种特殊类型的UI状态,屏幕UI状态通常包含数据层公开的应用程序数据

作为稍后即将介绍的内容的预览,重要的是要注意,屏幕UI状态应该在配置更改时持久化或缓存。

如何生成UI状态

生成UI状态是状态持有者处理某些输入的输出。这些输入可以是:1)事件,2)本地状态变化的源,或者3)外部状态变化的源。

UI状态是状态持有者处理某些输入后的输出

在不同情况下应该使用哪些API?

  • UI状态应该作为可观察的数据持有类公开(例如StateFlowCompose State<T>LiveData)。这种类型确保UI始终具有要在屏幕上呈现的UI状态。
  • 输入可以采用各种形式,主要是作为数据流或一次性API。
    让我们来看几个例子!

使用本地状态变化源生成UI状态

想象一下,我们正在一个屏幕上,允许用户掷两个骰子。除了显示骰子的值之外,我们还想跟踪用户掷骰子的次数。我们的UI状态可能如下所示:

data class DiceRollUiState(
  val firstDiceValue: Int? = null
  val secondDiceValue: Int? = null
  val 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的类型总结要公开的类型:

  1. 如果您使用的是一次性API或本地业务逻辑,请使用MutableStateFlowCompose MutableState<T>在状态持有者中存储状态。然后,将其公开为StateFlowCompose State<T>
  2. 当源类型是作为Flow提供的外部流时,您应该公开一个StateFlow
  3. 如果您同时处理两种类型的输入,例如至少有一个外部流,请组合所有输入,并将UI状态公开为StateFlow

Summary of the UI state exposed type given the source type and API

如何建模 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 : DiceRollUiState

  data class DiceRoll(
    val username: String,
    val numberOfRolls: Int,
    val firstDiceValue: Int? = null,
    val secondDiceValue: Int? = null
  ) : DiceRollUiState

  data object LogUserIn : DiceRollUiState

}

UI 状态可以是加载状态(Loading),表示用户需要登录状态(LogUserIn),或者在屏幕上显示带有用户名的骰子掷出值(DiceRoll)。

何时使用数据类、密封接口/类或两者结合使用?

当屏幕可能处于多个互斥状态时,请使用密封接口/类。
当其中的数据可能发生变化时,请使用数据类。这在采用离线优先方法的屏幕中特别有用,因为屏幕可能同时显示加载指示、数据和错误消息。

如何建模复杂的 UI 状态

在处理复杂的屏幕时,您需要确保不会创建 UI 不一致性。作为练习,让我们尝试对 Jetnews 的主屏幕进行建模,Jetnews 是一个 Compose 示例应用程序的主屏幕。

https://github.com/android/compose-samples/tree/main/JetNews
Home screen of Jetnews displayed on a tablet

屏幕的主要内容显示了一系列文章以及一个打开的文章详情部分,用户可以在其中阅读文章。作为建模整个 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: Boolean
  val errorMessages: List<ErrorMessage>
  val searchInput: String

  data class NoPosts(
    override val isLoading: Boolean = false,
    override val errorMessages: List<ErrorMessage> = emptyList(),
    override val searchInput: String = ""
  ) : HomeUiState

  data 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 可能会显示 HasPostsNoPosts。在 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 生命周期中,这是当生命周期处于 STARTEDSTOPPED 状态之间时。有不同的 API 可以方便地实现这一点。

对于 Android Views,您可以使用位于 androidx.lifecycle.lifecycle-runtime-ktx 组件中的 repeatOnLifecycleflowWithLifecycle 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 状态。

文章来源:https://blog.csdn.net/u011897062/article/details/135154319
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。