界面的作用是在屏幕上显示应用数据。界面也是用户互动的主要入口。 每当数据发生变化时,无论是因为用户互动(例如按了某个按钮),还是因为外部输入(例如网络响应),界面都会随之更新,以反映这些变化。 实际上,界面是从数据层获取的应用状态的直观呈现。
不过,从数据层获取的应用数据的格式通常不同于您需要显示的信息的格式。例如,您可能只需要在界面中显示部分数据,或者可能需要合并两个不同的数据源,以便提供切合用户需求的信息。无论您应用的是什么逻辑,都需要向界面传递完全呈现界面所需的所有信息。界面层是一个流水线,它会将应用数据更改转换为界面可呈现的形式,然后显示出来。
基本案例研究
让我们以一个可获取新闻报道供用户阅读的应用为例。该应用有一个报道屏幕,用于显示可供阅读的报道;另外,该应用允许已登录的用户为真正出众的报道添加书签。由于任何时候都可能有大量文章,因此读者必须能够按类别浏览文章。总的来说,该应用可让用户执行以下操作:
- 查看可供阅读的报道。
- 按类别浏览报道。
- 登录账号并为特定报道添加书签。
- 使用部分收费功能(如果符合相应条件)。
以下几个部分使用此示例作为案例研究,以便介绍单向数据流的原则,并展示在界面层的应用架构上下文中,这些原则有助于解决的问题。
界面层架构
界面一词是指用于显示数据的界面元素,例如容器和可组合函数。对于构建 Android 界面,Jetpack Compose 是推荐的工具包。由于数据层的作用是存储和管理应用数据,以及提供对应用数据的访问权限,因此界面层必须执行以下步骤:
- 使用应用数据,并将其转换为界面可以轻松呈现的数据。
- 使用界面可呈现的数据,并将其转换为用于向用户呈现的界面元素。
- 使用来自这些组合在一起的界面元素的用户输入事件,并根据需要反映它们对界面数据的影响。
- 根据需要重复第 1-3 步。
本指南的其余部分展示了如何实现用于执行这些步骤的界面层。具体来说,本指南涵盖以下任务和概念:
- 如何定义界面状态
- 单向数据流 (UDF) 可作为提供和管理界面状态的方式
- 如何根据 UDF 原则使用可观测数据类型公开界面状态
- 如何实现使用可观察界面状态的界面
其中最基本的便是定义界面状态。
定义界面状态
在前面概述的案例研究中,界面会显示文章列表以及每篇文章的一些元数据。该应用向用户显示的这些信息便是界面状态。
换言之,如果界面是相对用户而言的,那么界面状态就是相对应用而言的。这就像同一枚硬币的两面,界面是界面状态的直观呈现。对界面状态所做的任何更改都会立即反映在界面中。
以案例研究为例,为了满足“新闻”应用的要求,可以将完全呈现界面所需的信息封装在如下定义的 NewsUiState 数据类中:
data class NewsUiState( val isSignedIn: Boolean = false, val isPremium: Boolean = false, val newsItems: List<NewsItemUiState> = listOf(), val userMessages: List<Message> = listOf() ) data class NewsItemUiState( val title: String, val body: String, val bookmarked: Boolean = false, // ... )
如需详细了解界面状态,请参阅状态和 Jetpack Compose。
不变性
上一个示例中的界面状态定义是不可变的。这样的主要好处是,不可变对象可保证即时提供应用的状态。这样,界面就可以专注于发挥其主要作用:读取状态并相应地更新其界面元素。 切勿直接在界面中修改界面状态,除非界面本身是其数据的唯一来源。违反这个原则会导致同一条信息有多个可信来源,从而导致数据不一致和轻微的 bug。
例如,请考虑前面的案例研究。
如果界面状态中 NewsItemUiState 对象内的 bookmarked 标志在 Activity 类中更新,那么该标志会与数据层展开竞争,以争取成为报道的“已添加书签”状态的来源。不可变数据类对于防止此类不一致非常有用。
本指南中的命名惯例
在本指南中,界面状态类是根据其描述的屏幕或部分屏幕的功能命名的。具体命名惯例如下:
功能 + UiState。
例如,用于显示新闻的屏幕的状态可以称为 NewsUiState,新闻报道列表中的新闻报道的状态可以为 NewsItemUiState。
使用单向数据流管理状态
上一部分中指出,界面状态是呈现界面所需的详细信息的不可变快照。不过,应用中数据的动态特性意味着状态可能会随时间而变化。这可能是因为用户互动,也可能是因为其他事件修改了用于填充应用的底层数据。
这些互动可以受益于中介器来处理它们,定义要应用于每个事件的逻辑,并转换后备数据源以创建界面状态。虽然这些互动及其逻辑可以容纳在界面本身中,但随着界面承担过多责任,这很快就会变得难以管理。此外,这可能会影响可测试性,因为生成的代码是紧密耦合的。除非界面状态非常简单,否则请确保界面的唯一职责是使用和显示界面状态。
本部分介绍了单向数据流 (UDF),这是一种架构模式,有助于强制实施这种健康的职责分离。
状态容器
状态容器是负责生成界面状态以及生成该状态所需逻辑的类。 状态容器的大小不等,具体取决于所管理的界面元素的范围(从底部应用栏等单个 widget 到整个界面或导航目的地)。
在后一种情况下,典型的实现是 ViewModel 的实例,不过根据应用的要求,使用简单的类可能就足够了。例如,案例研究中的“新闻”应用使用 NewsViewModel 类作为状态容器,以便为该部分显示的屏幕画面提供界面状态。
您可以通过多种方式为界面与其状态提供方之间的互相依赖关系建模。不过,由于界面与其 ViewModel 类之间的互动在很大程度上可以理解为事件输入及其随后的状态输出,因此这种关系可以按下图所示来表示:
状态向下流动、事件向上流动的这种模式称为单向数据流 (UDF)。这种模式对应用架构的影响如下:
- ViewModel 会存储并公开界面要使用的状态。界面状态是经过 ViewModel 转换的应用数据。
- 界面会向 ViewModel 发送用户事件通知。
- ViewModel 会处理用户操作并更新状态。
- 更新后的状态将反馈给界面以进行呈现。
- 系统会对导致状态更改的所有事件重复上述操作。
对于导航目的地或屏幕,ViewModel 会使用存储库或用例类来获取数据并将其转换为界面状态,同时纳入可能会导致状态更改的事件的影响。前面提到的案例研究包含一个报道列表,其中每篇报道都有标题、说明、来源、作者名称、发布日期,以及是否添加了书签。每篇报道的界面如下所示:
用户请求为报道添加书签就是一个可能会导致状态更改的事件示例。作为状态提供方,ViewModel 的职责是定义所有必需的逻辑,以便填充界面状态中的所有字段,并处理界面完全呈现所需的事件。
以下几个部分更详细地介绍了导致状态变化的事件,以及如何使用 UDF 处理这些事件。
逻辑类型
为报道添加书签就是一个业务逻辑示例,因为这能够为应用带来价值。如需了解详情,请参阅数据层页面。不过除此之外,还有其他类型的重要逻辑需要定义:
- 业务逻辑决定着应用数据的产品要求的实现。如前面所述,一个例子是在案例研究应用中为报道添加书签。业务逻辑通常位于网域层或数据层中,但绝不能位于界面层中。
- 界面行为逻辑(即界面逻辑)决定着如何在屏幕上显示状态变化。示例包括:使用 Android
Resources获取要在屏幕上显示的正确文本、在用户点击某个按钮时前往特定屏幕,或使用消息框或信息提示控件在屏幕上向用户显示消息。
将界面逻辑保留在界面中,而不是 ViewModel 中,尤其是在涉及 Context 等界面类型时。
如果界面变得越来越复杂,并且您希望将界面逻辑委托给另一个类,以便有利于进行测试和关注点分离,您可以创建一个简单的类作为状态容器。在界面中创建的简单类可以采用 Android SDK 依赖项,因为它们遵循界面的生命周期;ViewModel 对象具有更长的生命周期。
如需详细了解状态容器以及如何利用它们更好地构建界面,请参阅 Jetpack Compose 状态指南。
为何使用 UDF?
UDF 可为状态提供周期建模(如图 4 所示)。它还可以将以下位置分离开来:状态变化来源位置、转换位置以及最终使用位置。这种分离可让界面只发挥其名称所表明的作用:通过观察状态变化来显示信息,并通过将这些变化传递给 ViewModel 来传递用户意图。
换句话说,UDF 有助于实现以下几点:
- 数据一致性。界面只有一个单一事实来源。
- 可测试性。状态来源是独立的,因此可独立于界面进行测试。
- 可维护性。状态的更改遵循明确定义的模式,即状态更改是用户事件及其数据拉取来源共同作用的结果。
公开界面状态
在定义界面状态并确定如何管理该状态的生成后,下一步是将生成的状态呈现给界面。
使用 UDF 管理状态的提供时,您可以将提供的状态视为数据流,换句话说,随着时间的推移,将提供状态的多个版本。在可观察的数据容器(如 StateFlow)中公开界面状态。这样,界面就可以对状态中所做的任何更改做出响应,而无需直接从 ViewModel 手动拉取数据。这样做还有一个好处,那就是始终缓存最新版本的界面状态,这有助于在配置更改后快速恢复状态。
class NewsViewModel( // ... ) : ViewModel() { val uiState: NewsUiState = /* ... */ }
如需了解 Kotlin 数据流,请参阅 Android 上的 Kotlin 数据流。
如需了解如何将 StateFlow 用作可观测数据容器,请参阅 Jetpack Compose 中的高级状态和副作用 Codelab。
如果向界面公开的数据相当简单,通常值得将数据封装在界面状态类型中,因为它能传达状态容器的发出与其关联的屏幕或界面元素之间的关系。随着界面元素变得越来越复杂,您可以直接向界面状态的定义中添加内容,以便容纳呈现界面元素所需的额外信息。
创建 UiState 流的一种常用方法是,公开具有 private set 的 mutableStateOf 属性,使状态在 ViewModel 中可变,但对界面而言是只读的。
class NewsViewModel( // ... ) : ViewModel() { var uiState by mutableStateOf(NewsUiState()) private set // ... }
这样一来,ViewModel 便可以公开在内部更改状态的方法,以便发布供界面使用的更新。例如,假设您需要执行异步操作。
您可以使用 viewModelScope 启动协程,然后在协程完成后更新可变状态。
class NewsViewModel( private val repository: NewsRepository, // ... ) : ViewModel() { var uiState by mutableStateOf(NewsUiState()) private set private var fetchJob: Job? = null fun fetchArticles(category: String) { fetchJob?.cancel() fetchJob = viewModelScope.launch { try { val newsItems = repository.newsItemsForCategory(category) uiState = uiState.copy(newsItems = newsItems) } catch (ioe: IOException) { // Handle the error and notify the UI when appropriate. val messages = getMessagesFromThrowable(ioe) uiState = uiState.copy(userMessages = messages) } } } }
在前面的示例中,NewsViewModel 类会尝试提取特定类别的文章,然后在界面状态中反映尝试的结果(成功或失败),界面可以对此做出适当的反应。
如需详细了解错误处理,请参阅在屏幕上显示错误部分。
其他注意事项
除了前面的指南之外,公开界面状态时还要考虑以下事项:
使用单个界面状态对象来处理彼此相关的状态。 这样可以减少不一致的情况,并让代码更易于理解。如果您在两个不同的数据流中分别公开新闻报道列表和书签数量,可能会发现其中一个已更新,但另一个没有更新。当您使用单个数据流时,这两个元素都会保持最新状态。此外,某些业务逻辑可能需要组合使用数据源。例如,可能只有在用户已登录并且是付费新闻服务订阅者时,您才需要显示书签按钮。您可以按如下方式定义界面状态类:
data class NewsUiState( val isSignedIn: Boolean = false, val isPremium: Boolean = false, val newsItems: List<NewsItemUiState> = listOf() ) val NewsUiState.canBookmarkNews: Boolean get() = isSignedIn && isPremium
在此声明中,书签按钮的可见性是两个其他属性的派生属性。随着业务逻辑变得越来越复杂,拥有单个
UiState类,并且其中的所有属性都是立即可用的,变得越来越重要。界面状态:单个数据流还是多个数据流?选择在单个数据流还是多个数据流中公开界面状态的关键指导原则是所发出项之间的关系。单流公开的最大优势在于便利性和数据一致性:状态的使用方始终可以在任何给定时间获得最新信息。不过,在有些情况下,可能适合使用来自 ViewModel 的单独的状态流:
不相关的数据类型:呈现界面所需的某些状态可能是完全相互独立的。在这种情况下,将这些不同的状态捆绑在一起的成本可能会超过其带来的好处,尤其是当其中一个状态的更新频率高于另一个状态时。
UiState差异比较:UiState对象中的字段越多,数据流就越有可能因其中一个字段更新而发出。由于界面元素没有 diffing 机制来了解连续发出的数据流是否相同,因此每次发出都会导致界面元素更新。这意味着,可能必须要使用FlowAPI 方法(例如distinctUntilChanged())来缓解这个问题。
如需详细了解渲染和界面状态,请参阅可组合项的生命周期。
使用界面状态
如需在界面中使用 UiState 对象流,请使用您正在使用的可观测数据类型的终端运算符。例如,对于 Kotlin flow,请使用 collect() 方法或其变体。
在界面中使用可观测的数据容器时,请务必考虑界面的生命周期。当可组合项未向用户显示时,请勿让界面观察界面状态。如需详细了解此主题,请参阅这篇博文。使用 flow 时,最好使用适当的协程作用域和 collectAsStateWithLifecycle API 来处理生命周期问题:
@Composable private fun ConversationScreen( conversationViewModel: ConversationViewModel = viewModel() ) { val messages by conversationViewModel.messages.collectAsStateWithLifecycle() ConversationScreen( messages = messages, onSendMessage = { message: Message -> conversationViewModel.sendMessage(message) } ) } @Composable private fun ConversationScreen( messages: List<Message>, onSendMessage: (Message) -> Unit ) { MessagesList(messages, onSendMessage) /* ... */ }
显示正在执行的操作
在 UiState 类中表示加载状态的一种简单方法是使用布尔值字段:
data class NewsUiState( val isFetchingArticles: Boolean = false, // ... )
此标记的值表示界面中是否存在进度条。
@Composable fun LatestNewsScreen( modifier: Modifier = Modifier, viewModel: NewsViewModel = viewModel() ) { Box(modifier.fillMaxSize()) { if (viewModel.uiState.isFetchingArticles) { CircularProgressIndicator(Modifier.align(Alignment.Center)) } // Add other UI elements. For example, the list. } }
在屏幕上显示错误
在界面中显示错误与显示正在执行的操作类似,因为无论是错误,还是正在执行的操作,都能通过用于表明它们是否存在的布尔值来轻松表示。不过,错误可能还包括要传回给用户的关联消息,或包含与其关联的操作(旨在重试失败的操作)。因此,无论正在执行的操作是否正在加载,可能都需要使用托管以下数据的数据类对错误状态进行建模:适合错误上下文的元数据。
以前面的示例为例,该示例在提取文章时显示进度条。如果此操作导致错误,您可能希望向用户显示一条或多条消息,详细说明出现了什么错误。
data class Message(val id: Long, val message: String) data class NewsUiState( val userMessages: List<Message> = listOf(), // ... )
然后,您可以通过界面元素(例如信息提示控件)向用户显示错误消息。 如需详细了解界面事件的生成和使用方式,请参阅界面事件。
线程处理和并发
确保在 ViewModel 中执行的所有工作都具有主线程安全性(即从主线程调用是安全的)。数据层和网域层负责将工作移至其他线程。
如果 ViewModel 执行长时间运行的操作,则还要负责将相应逻辑移至后台线程。Kotlin 协程是管理并发操作的绝佳方式,Jetpack 架构组件则为其提供内置支持。如需详细了解如何在 Android 应用中使用协程,请参阅 Android 上的 Kotlin 协程。
导航
应用导航的变化通常是由类似于事件的发出操作驱动的。例如,在 SignInViewModel 类执行登录后,UiState 可能会有一个 isSignedIn 字段被设为 true。使用此类触发器的方式与前面使用界面状态部分中介绍的方式类似,但应将使用实现推迟到Navigation 组件。
如需详细了解界面导航,请参阅导航 3。
Paging
Paging 库通过一个称为 PagingData 的类型在界面中使用。由于 PagingData 表示并包含可以随时间变化的内容(换句话说,它不是不可变类型),因此请勿以不可变界面状态表示它。而是应该在 ViewModel 中通过其自己的数据流单独公开。
以下示例展示了 Paging 库的 Compose API:
@Composable fun MyScreen(flow: Flow<PagingData<String>>) { val lazyPagingItems = flow.collectAsLazyPagingItems() LazyColumn { items( lazyPagingItems.itemCount, key = lazyPagingItems.itemKey { it } ) { index -> val item = lazyPagingItems[index] Text("Item is $item") } } }
动画
为了提供流畅的顶级导航过渡,您可能需要等待第二个屏幕加载数据,然后再开始动画。
如需详细了解导航过渡,请参阅 Navigation 3 和 Compose 中的共享元素过渡。
其他资源
查看内容
示例
以下 Google 示例演示了如何使用界面层。请查看这些示例,了解如何实际运用本指南:
为你推荐
- 注意:当 JavaScript 处于关闭状态时,系统会显示链接文字
- 界面状态生成
- 状态容器和界面状态 {:#mad-arch}
- 应用架构指南