基于Android Architecture Components的应用架构指南

catsuo

catsuo 2017.06.29

  • 13759
  • 42
  • 5

这是一篇 Android Architecture Components 的简单使用指南,目的是向大家介绍这么一种新的架构方案。Android Architecture Components 是一个由官方推出的新库,它能够帮助你去构建一个健壮,易测,可维护的应用。目前它还未正式发布(Now available in preview)。所以抱着强烈的好奇心去了解了一下。

本文译自 Guide to App Architecture,并结合自己的理解后记录下来。链接中有更多的细节可以参考。如果有认识错误的地方欢迎指出以修正。

Note:
这篇指南适合于已经有 Android 应用开发经验的工程师。如果你才刚入坑,查看入门文档可能会更有帮助。

现存问题

移动开发不同于传统的桌面 PC 开发,Android 应用的结构更加复杂。一个典型的 Android 应用由多个应用组件组合构建而成,其中包括 ActivitiesFragmentsServicesContentProviders 和 BroadcastReceivers

大部分这些应用组件被定义在 AndroidManifest.xml 文件中,这个文件被 Android 系统用于决定怎么去整合构建你的应用程序给用户带来一个好的用户体验。Android 应用的使用场景非常灵活,用户往往是为了完成一个动作而在不同的应用之间频繁的切换跳转。

试想一下当你想用社交应用分享一张照片时会发生什么。首先这个社交应用会使用 Android 的拍照接口直接使用系统中的相机应用完成拍照的请求。在这个时候,用户已经离开了这个社交应用但是这个体验是完全无缝的。这个相机应用可能又会使用 Android 的其他接口,比如打开一个文件选择器,这时候会跳转到系统中的另一个实现了文件选择功能的应用。最终,用户又回到了社交应用去分享之前操作中最后选定的一张照片。在这个过程中用户还可能随时被一个来电打断,在通话完成后又继续分享照片。

没错,在 Android 中,应用间的这种跳跃切换行为很普遍,所以你的应用程序必须把这些问题都正确的处理好。要知道移动设备的硬件资源是很有限的,在任何时候系统都可能杀掉一些应用去释放一些资源给新的应用。

所有的这些都说明你的应用组件的创建和销毁是不完全可控的,它可能在任何时候由于用户或者系统的行为而触发。应用组件的生命周期不是完全由你掌控的,所以你不应该存储一些数据或者状态在你的应用组件中,应用组件之间也不应该彼此依赖。

架构原则

如果不能用应用组件去存储应用数据和状态,那应该怎样去设计应用的架构呢?

首先,通常的架构原则有几个重点:

第一个重点是在应用中的关注点分离。比如在一个 Activity 或者 Fragment 中写所有的代码这明显是错误的。任何与 UI 或者交互无关的代码都不应该存在这些类中。保证他们尽可能的职责单一化将会使我们避免很多生命周期相关的问题。Android 系统可能会随时由于用户的行为或者系统状态(比如剩余内存过低)而销毁你的应用组件。所以应该最小化应用组件之间的依赖以提供一个健壮的体验。

第二个重点是我们应该采用数据模型驱动 UI 的方式,最好是一个可持久化的模型。持久化被建议的原因有两个:

  1. 用户不会因为系统销毁我们的应用而导致丢失数据。
  2. 我们的应用可以在网络状况不好甚至断网的情况下继续工作。

这里说的模型其实也是一种组件,他们就是专门负责为我们的应用处理和存储数据的。他们完全独立于 Views 和其他应用中的组件,所以他们不存在生命周期相关的问题。保证 UI 部分的代码足够简单,没有业务逻辑,使代码更容易去管理。

建议架构

在这部分,会用一个例子去说明怎么使用新的 Android Architecture Components 去构建一个应用。

Note:
不可能找到一个完美的方案使用于所有场景。这里的建议架构也只是对于大部分场景来说应该是一个好的开始。但是如果你已经有一个更好的方案去设计你的应用,你可以继续你的方案。

设想我们正在开发一个界面,界面是展示一个用户信息。用户信息会从我们自己的后台服务器通过一个 REST API 拉取。

构建用户界面

用户界面将由一个 Fragment UserProfileFragment.java 和对应的布局文件 user_profile_layout.xml 组成。

我们的数据模型需要持有两个数据元素。

User ID: 要展示信息的用户的 id。最好是通过一个 Fragment 的参数传递给 Fragment。如果 Android 系统销毁我们的进程,这个信息将可以被存储起来,所以在下一次我们的应用重新启动时这个 id 是可以得到的。

User Object:一个普通的 POJO,里面封装了 User 的信息属性。

我们创建一个派生于 ViewModelUserProfileViewModel 来保存上面提到的两个数据元素。

ViewModel 为特定的 UI Components 提供数据,比如 Fragment 或者 Activity,而且还负责与数据的业务逻辑通信,比如调用其他的组件去加载数据。ViewModelView 解耦,并且不受配置改变的影响,比如由于旋转屏幕导致的重新创建 Activity

现在我们有3个文件:

user_profile.xml: 布局文件。

UserProfileViewModel.java: 负责给 UI 准备数据。

UserProfileFragment.java: 展示 ViewModel 提供的数据,并且负责与用户的界面交互。

下面我们开始实现代码:

UserProfileViewModel.java

UserProfileFragment.java

Note:
上面的例子派生的是 LifecycleFragment 而不是 Fragment。在 Android Architecture Components 稳定后,Fragment 将直接实现 LifecycleOwner

现在我们怎么去将他们之间联系起来呢?毕竟当 UserProfileViewModel 的 user 被设置的时候,需要有方法去通知 UI。这时候就是 LiveData 大显身手的时候了。

LiveData 持有可被观察的数据(其实就是我们这里的 UserProfileViewModel 中持有的数据)。它使应用中的组件能够在不与其存在明显依赖关系的前提下观察 LiveData 对象的改变。LiveData 遵从应用组件的生命周期状态,并且能够做一些事情去阻止对象内存泄漏。详情参阅 LiveData

Note:
如果你已经用了类似 RxJava 或 Agera 的库,你可以继续使用他们。但是你得确保你正确的处理生命周期问题。

现在我们将 UserProfileViewModel 中的 user 成员修改为 LiveData<User>,目的在于当 User 的数据被更新时 Fragment 能够被通知到。关于 LiveData 最棒的就是它是可感知生命周期的,它会自动清理引用当不在需要的时候。

UserProfileViewModel.java

现在修改 UserProfileFragment,让它可以观察数据的改变并更新 UI。

UserProfileFragment.java

当 User 数据更新,onChanged 回调将会被调用进而刷新 UI。

如果你对其他使用观察者回调的库比较熟悉,可能你已经意识到我们没有重写 Fragment 的 onStop() 方法去停止观察数据。这不是必要的对于 LiveData,因为 LiveData 是可感知生命周期的,这意味着它不会调用回调方法除非 Fragment 是处于激活状态的(即收到 onStart() 但没有收到 onStop())。LiveData 也会自动的删除观察者当 Fragment 调用 onDestory() 时。

我们也不用做任何事情去处理配置改变事件(比如屏幕旋转)。ViewModel 将在配置改变的时候自动存储,当一个新的 Fragment 到来,它将收到与配置改变前的 ViewModel 同样的一个实例,而且 ViewModel 的回调方法将用该 ViewModel 内部持有数据做参数立马调用。这就是为什么 ViewModel 不应该直接引用 View,因为他的生命周期超出 View 的生命周期。

到这里可能会有人分不太清 LiveData 与 ViewModel 的区别,我稍微总结一下。

ViewModel:它是一个组件模块,是专门用来保存数据的,由 ViewModelProvider 来管理的。它的生命周期如图:

如图所述,它将一直保存在内存中除非 Activity 主动的 finish 或者 Fragment 被 detached。它不会受配置改变(如屏幕旋转)的影响。

LiveData:它是一个可以让数据具备可观察功能的类,它不存在生命周期一说,只是当 Activity 或者 Fragment 作为一个观察者向它注册后,它能够感知 Activity 或 Fragment 的生命周期,并在相应的状态下做相应的处理。

获取数据

现在我们已经将 ViewModel 和 Fragment 联系起来了,但是 ViewModel 怎么去获取数据呢?在我们这个例子中,我们假设我们的后台服务器提供了一套 REST API。我们可以使用 Retrofit 库来访问我们的后台服务器,你也可以选择不同的库实现同样的目的。

下面是我们基于 Retrofit 的用于和后台通信的 WebService

Webservice.java

ViewModel 内部可以直接调用 WebService 去获取数据并且将数据分配到 User 对象中。虽然这能够正常工作,但应用将再扩展后变得很难维护。这么做将太多的事情放到了 ViewModel 中,违背了关注点分离的原则。另外,上面提到 ViewModel 的生命周期和 Activity 或者 Fragment 是绑定在一起的,所以这么做将在生命周期结束后丢失所有数据,这是一个很糟糕的体验。所以正确的做法应该是,ViewModel 把这部分工作交给一个新的模块去完成,Repository

Repositore 模块负责处理数据操作。它提供一套简介清晰的 API 去简化你的应用。它知道从哪去获取数据,也知道当数据更新时调用什么 API。你可以认为它是一个不同数据源之间的中间人(比如数据库数据源,网络数据源,Memory Cache 数据源等等)。

接下来我们就定义 UserRepository 类,它将通过 WebService 来获取数据:

UserRepository.java

虽然 Repository 模块好像不是必要的,但是它的存在有重要的意义。针对应用上层来说它抽象了数据源。现在我们使用 ViewModel 的时候并不知道数据的获取是通过 WebService 获取的,上层也不需要关心。这意味着我们能够在必要的时候使用其他的实现来获取数据而不用修改上层代码(比如添加了 Cache 数据源,持久化数据源)。

我们这里忽略了网络异常的情况。

管理组建间的依赖

上面提到的 UserRepository 需要一个 WebService 的实例去完成它的工作。它可能实现起来比较简单,但是这会带来更多的依赖,比如在 UserRepository 内部去构造 WebService 时需要知道 WebService 构造函数的参数有哪些,也就是需要知道 WebService 依赖了哪些模块,导致 WebService 依赖的模块间接也与 UserRepository 产生了依赖,这将会很复杂并且带来很多重复的代码。另外,UserRepository 可能不是唯一的需要 WebService 的类,如果每一个需要 WebService 的类都这么去创建使用它,这个工作将非常恶心。

这里有两个方案去解决这个问题:

依赖注入(Dependency Injection)
依赖注入可以让类去定义他们的依赖实例,但不用构造它们。在运行时其他的类将负责提供这些依赖。这里建议 Google 的 Dagger 2 库去实现依赖注入。

服务定位器(Service Locator):服务定位器提供了一个注册表,类能够在其中获取他们的依赖,所以不用去构造他们。服务定位器相对依赖注入来说要简单一些,所以如果你对依赖注入不是太熟悉,可以使用服务定位器。具体可参阅 Service Locator

在这个例子中我们使用 Dagger 2 来管理依赖关系。

ViewModel和Repository

现在我们修改 UserProfileViewModel 去使用 Repository

UserProfileViewModel.java

缓存数据

上面的 Repository 很好的抽象了 WebService 的调用,但是它只有一个数据源(WebService),所以不是很实用。
它的问题在于,在本地获取到数据后,并没有将数据保存到任何地方。如果用户离开 UserProfileFragment 接着又回到该界面,应用将通过 WebService 重新获取数据,这非常糟糕:

  1. 浪费了网络带宽。
  2. 强制用户等待新的请求完成。
    为了解决这个问题,我们加入一个新的数据源到 UserRepository,目的是给获取到的 User 做 Memory Cache。

UserRepository.java

持久化数据

我们在上面做了 Memory Cache,所以如果用户旋转屏幕或者离开之后返回应用,只要应用没有被 kill,那么界面将立马展现出来。因为 Repository 能够从 Memory Cache 中将数据恢复出来。但是如果用户离开应用很久,在 Android 系统杀了应用后才返回会发生什么呢?

如果按照当前的实现,我们将重新从网络获取数据。这不只是一个糟糕的体验,也是一种浪费,因为它可能会用移动流量去重新获取同样的数据。

正确的方法去处理这种问题是使用一个持久化模型,Room 持久化库能够拯救你。Room 详细信息可参阅 Room

为了使用 Room,我们需要定义一些本地的规则。首先,用 @Entity 注解去标注 User 类,表明它将作为数据库中的一张表。

User.java

然后派生 RoomDatabase类创建一个我们的数据库类:

MyDatabase.java

注意 MyDatabase 是一个抽象类,Room 会为它自动提供一个实现。详细可参阅 Room文档
现在我们需要一个方法插入数据到数据库中。因此我们创建一个数据访问对象(DAO)。

UserDao.java

然后从我们的数据库类中引用上面定义的 DAO 类:

MyDatabase.java

注意,加载数据的方法返回的是一个 LiveData<User> 类型。Room 知道数据库什么时候被修改,当数据改变的时候它将自动的通知激活状态的观察者们。因为它使用了 LiveData,这将会很高效,因为只有当前存在激活状态的观察者时它才会更新数据。

Note: 在目前的版本中,Room 基于数据表修改的检查是无效的,这意味这意味着它可能会派发一些错误的通知。

现在我们修改UserRepository 去加入 Room数据源:

UserRepository.java

这里虽然我们改变了数据获取的来源,但我们不需要修改 UserProfileViewModel 或者 UserProfileFragment。这是抽象带来的灵活性。这也是一个很棒的一点针对测试来说,因为我们可以提供一个测试用的 UserRepository 来测试 UserProfileViewModel。这也是面对抽象(面对接口)编程的优势。

现在我们的代码基本完成。如果用户在几天之后回到同样的界面,他们可以立即看到界面信息因为我们已经将数据持久化到数据库了。与此同时,如果数据是太老了,Repository 会在后台更新数据。当然着决定与你应用的使用场景,有可能你觉得持久化的数据太老的话不显示在界面上反而更好。

单一数据源

Single source of truth

在复杂的业务数据结构的情况下,我们常常会遇到不同的 REST API 返回了同样的数据,如果不同的 component 的显示直接依赖于 API 数据的返回,很有可能就会有不同的 component 对于同样的数据所显示的结果不一样的 bug。再加上缓存、用户修改数据等等复杂情况,显示不一致的问题可能更加严重,所以为了解决这个问题,Single source of truth(以下使用中文名称:单一数据源)的概念被提出来了。

在我们额模型中,数据库扮演了一个单一数据源的角色,应用的其他部分能够通过 Repository 去访问数据库。忽略你是否使用了磁盘缓存,我们建议你的 Repository 应该要指定一个数据源作为单一数据源。

测试

我们已经提过模块分离的一个好处是提高程序的易测性。下面就来谈谈怎么去测试我们的每一个代码模块。

界面和交互:这里你可能需要 Android UI Instrumentation test 的帮助。最好的测试UI的方法就是实用 Espresso 测试框架。你只需要创建一个 mock 的 ViewModel,因为与 Fragment 通信的模块只有 ViewModel

ViewModelViewModel 可以通过 JUnit test 来测试。你只需要 mock 一个 UserRepository 就可以完成测试。

UserRepository: 也可以实用 JUnit test 来测试。这里需要 mock 住 WebService 和 DAO 类。你可以给一个正确的网络接口让程序调用,然后去测试整个流程,包括将结果保存入库等。因为 WebService 和 UserDao 都是接口,所以除了可以 mock 它们,还可以通过实现接口去创建更多的复杂测试场景。

UserDao: 这里建议针对 DAO 类使用 instrumentation test 测试。因为 instrumentation test 不需要任何 UI,它们运行的很快。针对每一个测试,都可以创建一个 in-memory 的数据库去确保测试不会受其他方面的响(比如磁盘文件的改变)。
Room 支持指定特定的数据库实现,所以你可以提供一个 SupportSQLiteOpenHelper 的实现去完成单元测试。但是这个方法通常不建议,因为你无法保证 SQLite 的版本在运行的设备和你的主机上是一致的。

WebService: 对于测试该模块来说独立与其他的模块场景是很重要的,甚至在单元测试时你应该避免它与你的后台发生网络通信。有很多库能帮助你完成这工作。比如,MockWebServer 是一个很棒的库能帮助你创建一个虚拟的本地服务来测试。

最终架构

下面这张图展示了各个模块的架构以及它们之间是如何交互的:

指导原则

下面的建议不是强制性的,不过从经验上看,随着你的代码长期迭代,遵循下面的准则来完成你的编程工作将可以使代码在健壮性,易测试性,可维护性方面都更加优秀。

你定义在 Manifest 中的 entry points(比如 ActivityServiceBroadcastReceiver)都不应该作为数据源。他们应该只去使用与他们的相关数据子集。因为这些组件的生命周期太短了,如果让他们持有完整的数据源,在他们被销毁后整个数据源将全部销毁。

严格的定义好各个模块间的边界。比如,不要将网络请求相关的代码覆盖到多个包或者类中,类似的,也不要将不相关的一些责任揉在一起。比如不要将数据的缓存和绑定放到同一个类里面。做到模块职责单一化。

模块间要尽量做到低耦合,不要尝试为了一点点的方便而将一个模块的内部实现细节暴露出来。你可能在短时间内会觉得很方便,但你的代价是在你代码的迭代演进过程中将花费更多的时间去维护它。

当你在定义模块间的交互行为时,应该去思考怎么样设计他们,彼此之间才可以独立的完成单元测试。

不要把你的时间花在重复造轮子或者重复写同样的模板代码上,相反的,你的主要经历应该聚焦在怎么使你的应用变得独一无二。让 Android Architecture Components 和其他建议的库 来处理这些重复的工作。

尽可能多的存留一些相关的新的数据在本地,为了让你的应用在设备处于离线状态下时仍然可用。要知道你可能享受着持续稳定高速的网络连接,但是你的用户不一定。

你的 Repository 应该指明一个数据源作为单一数据源。不管什么时候你的应用需要访问一些数据,它应该都可以从单一数据源中找到。

感谢你的阅读,本文出自 Tencent CDC,转载时请注明出处,谢谢合作。
格式为:Tencent CDC(http://cdc.tencent.com/2017/06/29/基于android-architecture-components的应用架构指南/

    查看更多评论 没有更多了
    返回顶部
    返回顶部