架构概述

单向数据流

Firefox for Android 的表示层架构基于“单向数据流”的概念。这在客户端开发中,尤其是在 Web 开发中,是一种流行的方法,并且是 Redux、MVI、Elm 架构和 Flux 的核心。我们的架构与这些架构中的任何一个都不完全相同(它们彼此之间也不完全相同),但基本概念是相同的。要了解基本动机和方法,请参阅官方 Redux 文档。有关单向数据流何时是好方法以及何时不是好方法的文章,请参阅这篇文章。这两篇文章都是从 React.js 开发人员的角度编写的,但概念基本相同。

我们与这些架构的最大不同之处在于,尽管它们都推荐一个大型的全局数据存储,但我们每个屏幕都有一个存储,以及其他几个全局存储。这既有优点也有缺点,将在本文档后面介绍。

重要类型

存储

**概述**

状态的存储。

请参阅 mozilla.components.lib.state.Store

保存应用程序状态。

接收动作,这些动作用于使用Reducer计算新状态,并且可以附加中间件来响应和操作动作。

**描述**

维护一个状态、一个Reducer以计算新状态,以及中间件。每当存储通过store.dispatch(action)接收新的动作时,它首先会将该动作传递给其中间件链。这些中间件可以响应启动副作用,甚至可以消耗或更改动作。最后,存储使用Reducer中的先前状态和新动作计算新状态。然后将结果存储为新状态,并发布到存储的所有使用者。

建议使用者尽可能依赖于观察存储中的状态更新,而不是直接读取状态。这确保始终使用最新的状态。这可以防止围绕调用顺序的细微错误,因为所有观察者在应用新的更改之前都会收到相同的状态更改通知。

有几个全局存储,例如AppStoreBrowserStore,以及作用域到各个屏幕的存储。基于屏幕的存储可以在配置更改中持久化,但通常在片段事务期间创建和销毁。这意味着必须在存储之间共享的数据应提升到全局存储,或作为参数传递给新片段。

基于屏幕的存储应该使用StoreProvider.get创建。


状态

**概述**

屏幕或应用程序其他区域状态的描述。

请参阅 mozilla.components.lib.state.State

**描述**

简单的不可变数据对象,包含显示屏幕所需的所有后备数据。这理想情况下只应包含可以轻松测试的 Kotlin/Java 数据类型,避免 Android 平台类型。对于大型、昂贵的类型(如ContextView)尤其如此,这些类型绝不应包含在状态中。

尽可能使状态对象成为屏幕上实际显示内容的准确 1:1 表示。也就是说,无论之前发生了什么更改,只要发出具有相同值的 State,屏幕的外观都应该完全相同。由于 Android UI 元素非常有状态,因此这并非总是可行,但这是一个值得追求的目标。

基于 State 对象呈现屏幕的一大好处是它对测试的影响。UI 测试以其难以构建和维护而闻名。如果我们能够构建一个简单、可重现的视图(即,如果我们可以相信该视图将按预期呈现),那么我们可以通过验证 State 对象的正确性来测试我们的 UI。

这在调试时也为我们提供了很大的优势。如果 UI 看起来不对,请检查 State 对象。如果正确,则问题出在 View 中。如果不是,请检查是否发送了正确的动作。如果是,则问题出在 Reducer 中。如果不是,请检查发送动作的组件。这有助于我们快速缩小问题的范围。


动作

**概述**

状态更改或用户交互的简单描述。分派到存储。

请参阅 mozilla.components.lib.state.Action

**描述**

简单的数据对象,用于将有关状态更改的信息传递到存储。动作描述了发生的事情,并携带与该更改相关的任何数据。例如,HistoryFragmentAction.ChangeEmptyState(isEmpty = true)捕获了历史记录片段的状态已变为空。


Reducer

**概述**

用于创建新的状态对象的纯函数。

请参阅 mozilla.components.lib.state.Reducer

参考:存储

**描述**

一个函数,它接受先前的状态和一个动作,然后将它们组合以返回新状态。所有 Reducer 保持非常重要。这使我们能够仅根据 Reducer 的输入来测试它们,而无需考虑应用程序其余部分的状态。

请注意,Reducer 始终按顺序调用,因为如果并行执行,状态可能会丢失。


中间件

**概述**

中间件位于存储和 Reducer 之间。它在分派动作和动作到达 Reducer 的那一刻之间提供了一个扩展点。

**描述**

中间件通过执行副作用来响应动作,并且还可以用于重写动作、拦截动作或分派其他动作。

存储将创建一个中间件实例链,并按顺序调用它们。每个中间件都可以决定继续链或中断链。中间件不知道链中其之前或之后的任何内容。


视图

**概述**

初始化 UI 元素,然后响应状态更改更新它们

观察:存储

**描述**

视图定义了状态到 UI 的映射。这可能包括 XML 绑定、Composable 或介于两者之间的任何内容。

视图应尽可能简单,并且应包含很少或根本不包含条件逻辑,除非根据状态确定要显示视图树的哪个分支。理想情况下,状态对象中的每个原始值都设置在 UI 元素的某个字段上。

视图为 UI 元素设置侦听器,这些侦听器触发将动作分派到存储

在某些情况下,在观察来自存储的状态更新时,从视图启动副作用可能是合适的。例如,可能会显示一个菜单。


重要说明

  • 与单向数据流的其他常见实现不同,后者通常具有一个全局数据存储,我们为每个屏幕维护较小的存储以及多个全局存储。

    • 对于被销毁的视图,通常不需要维护 UI 状态,这使我们能够在 Android 开发中提出的物理硬件约束内运行,例如拥有更有限的内存资源。

  • 通常,本地于功能或屏幕的存储应通过使用StoreProvider.get在 ViewModel 中跨配置更改持久化。


简化示例

在阅读尝试理解架构的实时代码时,可能很难找到规范示例,并且通常很难找到最重要的方面。这是一个基本历史记录屏幕的简化示例,其中包含历史记录项目的列表,并且可以打开、多选和删除这些项目。

以下是上面列出的架构组件的示例版本的链接。


历史架构组件

有一些过时的架构组件可能仍然可以在整个应用程序中看到。这些组件的原始文档在下面捕获,以保留上下文,但在这些组件被重构后应将其删除。

有关何时以及为何删除这些组件的更多上下文,请参阅提议删除它们的 RFC

已知限制(历史组件,从上面复制)

  • 许多 交互器(Interactor) 只有一个依赖项,即单个 控制器(Controller)。在这些情况下,它们通常只是转发每个方法调用并充当一个很大程度上不必要的层。但是,它们确实 1) 保持与架构其余部分的一致性,以及 2) 便于将来添加新的控制器。


交互器(Interactor)

概述

响应直接用户操作而调用。委托给其他对象

被调用方: 视图(View)

调用方: 控制器(Controllers)、其他交互器

描述

这是用户执行任何操作时调用的第一个对象。通常,这会导致 视图(View) 中的代码类似于 some_button.onClickListener { interactor.onSomeButtonClicked() } 。交互器的任务是将此按钮点击委托给应该处理它的任何对象。

交互器可以保存对多个其他交互器和控制器的引用,在这种情况下,它们将特定方法委托给其相应的处理程序。这有助于防止控制器既执行逻辑又委托给其他对象,从而变得臃肿。

有时交互器只会引用单个控制器。在这些情况下,交互器将简单地将调用转发到控制器上的等效调用。在这些情况下,交互器的工作量很少,并且仅为了与应用程序的其余部分保持一致而存在。

请注意,在引入控制器之前,交互器处理了这两个对象的所有职责。您可能仍会在代码库的某些部分找到此模式,但它正在被积极地重构。


控制器(Controller)

概述

确定每当发生某些事情时应用程序应如何更新

被调用方: 交互器(Interactor)

调用方: 存储(Store)、库代码(例如,将后退按下转发到 Android、触发 FxA 登录、导航到新的 Fragment、使用 Android 组件 UseCase 等)

描述

这是应用程序的大部分业务逻辑所在。每当被交互器调用时,控制器将执行以下三件事之一

  • 创建一个新的 操作(Action) 来描述必要的更改,并将其发送到存储

  • 通过 NavController 导航到新的片段。可以选择包含创建此新片段所需的任何状态

  • 与某些第三方管理器交互。这些通常会更新自己的内部状态,然后向观察者发出更改,这些更改将用于更新我们的存储

控制器可能会变得非常复杂,并且每当其方法执行的操作不仅仅是将简单调用委托给其他对象时,都应对其进行彻底的单元测试。