基于android architecture components的应用架构指南
基于Android Architecture Components的应用架构指南
这是一篇Android Architecture Components的简单使用指南,目的是向大家介绍这么一种新的架构方案。Android Architecture Components是一个由官方推出的新库,它能够帮助你去构建一个健壮,易测,可维护的应用。目前它还未正式发布(Now available in preview)。所以抱着强烈的好奇心去了解了一下。
本文译自Guide to App Architecture,并结合自己的理解后记录下来。链接中有更多的细节可以参考。如果有认识错误的地方欢迎指出以修正。
Note:
这篇指南适合于已经有Android应用开发经验的工程师。如果你才刚入坑,查看入门文档可能会更有帮助。
现存问题
移动开发不同于传统的桌面PC开发,Android应用的结构更加复杂。一个典型的Android应用由多个应用组件组合构建而成,其中包括Activities,Fragments,Services,ContentProviders和BroadcastReceivers。
大部分这些应用组件被定义在AndroidManifest.xml文件中,这个文件被Android系统用于决定怎么去整合构建你的应用程序给用户带来一个好的用户体验。Android应用的使用场景非常灵活,用户往往是为了完成一个动作而在不同的应用之间频繁的切换跳转。
试想一下当你想用社交应用分享一张照片时会发生什么。首先这个社交应用会使用Android的拍照接口直接使用系统中的相机应用完成拍照的请求。在这个时候,用户已经离开了这个社交应用但是这个体验是完全无缝的。这个相机应用可能又会使用Android的其他接口,比如打开一个文件选择器,这时候会跳转到系统中的另一个实现了文件选择功能的应用。最终,用户又回到了社交应用去分享之前操作中最后选定的一张照片。在这个过程中用户还可能随时被一个来电打断,在通话完成后又继续分享照片。
没错,在Android中,应用间的这种跳跃切换行为很普遍,所以你的应用程序必须把这些问题都正确的处理好。要知道移动设备的硬件资源是很有限的,在任何时候系统都可能杀掉一些应用去释放一些资源给新的应用。
所有的这些都说明你的应用组件的创建和销毁是不完全可控的,它可能在任何时候由于用户或者系统的行为而触发。应用组件的生命周期不是完全由你掌控的,所以你不应该存储一些数据或者状态在你的应用组件中,应用组件之间也不应该彼此依赖。
架构原则
如果不能用应用组件去存储应用数据和状态,那应该怎样去设计应用的架构呢?
首先,通常的架构原则有几个重点:
第一个重点是在应用中的关注点分离。比如在一个Activity或者Fragment中写所有的代码这明显是错误的。任何与UI或者交互无关的代码都不应该存在这些类中。保证他们尽可能的职责单一化将会使我们避免很多生命周期相关的问题。Android系统可能会随时由于用户的行为或者系统状态(比如剩余内存过低)而销毁你的应用组件。所以应该最小化应用组件之间的依赖以提供一个健壮的体验。
第二个重点是我们应该采用数据模型驱动UI的方式,最好是一个可持久化的模型。持久化被建议的原因有两个:
- 用户不会因为系统销毁我们的应用而导致丢失数据。
- 我们的应用可以在网络状况不好甚至断网的情况下继续工作。
这里说的模型其实也是一种组件,他们就是专门负责为我们的应用处理和存储数据的。他们完全独立于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的信息属性。
我们创建一个派生于 ViewModel 的 UserProfileViewModel 来保存上面提到的两个数据元素。
ViewModel 为特定的UI Components提供数据,比如Fragment或者Activity,而且还负责与数据的业务逻辑通信,比如调用其他的组件去加载数据。ViewModel 与 View 解耦,并且不受配置改变的影响,比如由于旋转屏幕导致的重新创建Activity。
现在我们有3个文件:
- user_profile.xml: 布局文件。
- UserProfileViewModel.java: 负责给UI准备数据。
- UserProfileFragment.java: 展示ViewModel提供的数据,并且负责与用户的界面交互。
下面我们开始实现代码:
UserProfileViewModel.java
public class UserProfileViewModel extends ViewModel {
private String userId;
private User user;
public void init(String userId) {
this.userId = userId;
}
public User getUser() {
return user;
}
}
UserProfileFragment.java
public class UserProfileFragment extends LifecycleFragment {
private static final String UID_KEY = "uid";
private UserProfileViewModel viewModel;
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
String userId = getArguments().getString(UID_KEY);
viewModel = ViewModelProviders.of(this).get(UserProfileViewModel.class);
viewModel.init(userId);
}
@Override
public View onCreateView(LayoutInflater inflater,
@Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.user_profile, container, false);
}
}
Note:
上面的例子派生的是LifecycleFragment而不是Fragment。在 Android Architecture Components 稳定后,Fragment将直接实现LifecycleOwner。
现在我们怎么去将他们之间联系起来呢?毕竟当UserProfileViewModel的user被设置的时候,需要有方法去通知UI。这时候就是LiveData大显身手的时候了。
LiveData持有可被观察的数据(其实就是我们这里的UserProfileViewModel中持有的数据)。它使应用中的组件能够在不与其存在明显依赖关系的前提下观察LiveData对象的改变。LiveData遵从应用组件的生命周期状态,并且能够做一些事情去阻止对象内存泄漏。详情参阅LiveData。
Note:
如果你已经用了类似RxJava或Agera的库,你可以继续使用他们。但是你得确保你正确的处理生命周期问题。
现在我们将UserProfileViewModel中的user成员修改为LiveData,目的在于当User的数据被更新时Fragment能够被通知到。关于LiveData最棒的就是它是可感知生命周期的,它会自动清理引用当不在需要的时候。
UserProfileViewModel.java
public class UserProfileViewModel extends ViewModel {
...
private User user;
private LiveData<User> user;
public LiveData<User> getUser() {
return user;
}
}
现在修改UserProfileFragment,让它可以观察数据的改变并更新UI。
UserProfileFragment.java
Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
viewModel.getUser().observe(this, user -> {
// update UI
});
}
当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
public interface Webservice {
/**
* @GET declares an HTTP GET request
* @Path("user") annotation on the userId parameter marks it as a
* replacement for the {user} placeholder in the @GET path
*/
@GET("/users/{user}")
Call<User> getUser(@Path("user") String userId);
}
ViewModel内部可以直接调用WebService去获取数据并且将数据分配到User对象中。虽然这能够正常工作,但应用将再扩展后变得很难维护。这么做将太多的事情放到了ViewModel中,违背了关注点分离的原则。另外,上面提到ViewModel的生命周期和Activity或者Fragment是绑定在一起的,所以这么做将在生命周期结束后丢失所有数据,这是一个很糟糕的体验。所以正确的做法应该是,ViewModel把这部分工作交给一个新的模块去完成,Repository。
Repositore模块负责处理数据操作。它提供一套简介清晰的API去简化你的应用。它知道从哪去获取数据,也知道当数据更新时调用什么API。你可以认为它是一个不同数据源之间的中间人(比如数据库数据源,网络数据源,Memory Cache数据源等等)。
接下来我们就定义UserRepository类,它将通过WebService来获取数据:
UserRepository.java
public class UserRepository {
private Webservice webservice;
// ...
public LiveData<User> getUser(int userId) {
// This is not an optimal implementation, we'll fix it below
final MutableLiveData<User> data = new MutableLiveData<>();
webservice.getUser(userId).enqueue(new Callback<User>() {
@Override
public void onResponse(Call<User> call, Response<User> response) {
// error case is left out for brevity
data.setValue(response.body());
}
});
return data;
}
}
虽然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
public class UserProfileViewModel extends ViewModel {
private LiveData<User> user;
private UserRepository userRepo;
@Inject // UserRepository parameter is provided by Dagger 2
public UserProfileViewModel(UserRepository userRepo) {
this.userRepo = userRepo;
}
public void init(String userId) {
if (this.user != null) {
// ViewModel is created per Fragment so
// we know the userId won't change
return;
}
user = userRepo.getUser(userId);
}
public LiveData<User> getUser() {
return this.user;
}
}
缓存数据
上面的Repository很好的抽象了WebService的调用,但是它只有一个数据源(WebService),所以不是很实用。
它的问题在于,在本地获取到数据后,并没有将数据保存到任何地方。如果用户离开UserProfileFragment接着又回到该界面,应用将通过WebService重新获取数据,这非常糟糕:
- 浪费了网络带宽。
- 强制用户等待新的请求完成。
为了解决这个问题,我们加入一个新的数据源到UserRepository,目的是给获取到的User做Memory Cache。
UserRepository.java
Singleton // informs Dagger that this class should be constructed once
public class UserRepository {
private Webservice webservice;
// simple in memory cache, details omitted for brevity
private UserCache userCache;
public LiveData<User> getUser(String userId) {
LiveData<User> cached = userCache.get(userId);
if (cached != null) {
return cached;
}
final MutableLiveData<User> data = new MutableLiveData<>();
userCache.put(userId, data);
// this is still suboptimal but better than before.
// a complete implementation must also handle the error cases.
webservice.getUser(userId).enqueue(new Callback<User>() {
@Override
public void onResponse(Call<User> call, Response<User> response) {
data.setValue(response.body());
}
});
return data;
}
}
持久化数据
我们在上面做了Memory Cache,所以如果用户旋转屏幕或者离开之后返回应用,只要应用没有被kill,那么界面将立马展现出来。因为Repository能够从Memory Cache中将数据恢复出来。但是如果用户离开应用很久,在Android系统杀了应用后才返回会发生什么呢?
如果按照当前的实现,我们将重新从网络获取数据。这不只是一个糟糕的体验,也是一种浪费,因为它可能会用移动流量去重新获取同样的数据。
正确的方法去处理这种问题是使用一个持久化模型,Room持久化库能够拯救你。Room详细信息可参阅Room。
为了使用Room,我们需要定义一些本地的规则。首先,用@Entity注解去标注User类,表明它将作为数据库中的一张表。
User.java
Entity
class User {
@PrimaryKey
private int id;
private String name;
private String lastName;
// getters and setters for fields
}
然后派生RoomDatabase类创建一个我们的数据库类:
MyDatabase.java
Database(entities = {User.class}, version = 1)
public abstract class MyDatabase extends RoomDatabase {
}
注意MyDatabase是一个抽象类,Room会为它自动提供一个实现。详细可参阅Room文档。
现在我们需要一个方法插入数据到数据库中。因此我们创建一个数据访问对象(DAO)。
UserDao.java
@Dao
public interface UserDao {
@Insert(onConflict = REPLACE)
void save(User user);
@Query("SELECT * FROM user WHERE id = :userId")
LiveData<User> load(String userId);
}
然后从我们的数据库类中引用上面定义的DAO类:
MyDatabase.java
@Database(entities = {User.class}, version = 1)
public abstract class MyDatabase extends RoomDatabase {
public abstract UserDao userDao();
}
注意,加载数据的方法返回的是一个LiveData类型。Room知道数据库什么时候被修改,当数据改变的时候它将自动的通知激活状态的观察者们。因为它使用了LiveData,这将会很高效,因为只有当前存在激活状态的观察者时它才会更新数据。
Note:在目前的版本中,Room基于数据表修改的检查是无效的,这意味这意味着它可能会派发一些错误的通知。
现在我们修改UserRepository去加入Room数据源:
UserRepository.java
@Singleton
public class UserRepository {
private final Webservice webservice;
private final UserDao userDao;
private final Executor executor;
@Inject
public UserRepository(Webservice webservice, UserDao userDao, Executor executor) {
this.webservice = webservice;
this.userDao = userDao;
this.executor = executor;
}
public LiveData<User> getUser(String userId) {
refreshUser(userId);
// return a LiveData directly from the database.
return userDao.load(userId);
}
private void refreshUser(final String userId) {
executor.execute(() -> {
// running in a background thread
// check if user was fetched recently
boolean userExists = userDao.hasUser(FRESH_TIMEOUT);
if (!userExists) {
// refresh the data
Response response = webservice.getUser(userId).execute();
// TODO check for error etc.
// Update the database.The LiveData will automatically refresh so
// we don't need to do anything else here besides updating the database
userDao.save(response.body());
}
});
}
}
这里虽然我们改变了数据获取的来源,但我们不需要修改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。
- ViewModel:ViewModel可以通过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(比如Activity,Service,BroadcastReceiver)都不应该作为数据源。他们应该只去使用与他们的相关数据子集。因为这些组件的生命周期太短了,如果让他们持有完整的数据源,在他们被销毁后整个数据源将全部销毁。
- 严格的定义好各个模块间的边界。比如,不要将网络请求相关的代码覆盖到多个包或者类中,类似的,也不要将不相关的一些责任揉在一起。比如不要将数据的缓存和绑定放到同一个类里面。做到模块职责单一化。
- 模块间要尽量做到低耦合,不要尝试为了一点点的方便而将一个模块的内部实现细节暴露出来。你可能在短时间内会觉得很方便,但你的代价是在你代码的迭代演进过程中将花费更多的时间去维护它。
- 当你在定义模块间的交互行为时,应该去思考怎么样设计他们,彼此之间才可以独立的完成单元测试。
- 不要把你的时间花在重复造轮子或者重复写同样的模板代码上,相反的,你的主要经历应该聚焦在怎么使你的应用变得独一无二。让Android Architecture Components和其他建议的库来处理这些重复的工作。
- 尽可能多的存留一些相关的新的数据在本地,为了让你的应用在设备处于离线状态下时仍然可用。要知道你可能享受着持续稳定高速的网络连接,但是你的用户不一定。
- 你的Repository应该指明一个数据源作为单一数据源。不管什么时候你的应用需要访问一些数据,它应该都可以从单一数据源中找到。