这篇架构指南面向有一定Android开发基础并想了解高质量、高稳定性App最佳实践及推荐架构的开发者。
注意:这篇指南假设读者熟悉Android Framework,如果你是Android开发新手,建议先去学习入门系列课程,这些课程也是作为学习本指南的基础。
移动开发不同于传统的桌面程序开发,桌面程序一般都有唯一的快捷方式入口,并且常作为单进程存在;而Android App则拥有更加复杂的结构,一个典型的Android应用通常由多个应用组件构成,包括不同数量的Activity、Fragment、Service、Content Provider、Broadcast Receiver等。
大部分的组件都会在AndroidManifest中声明,这个配置文件被Android操作系统用来决定如何将App集成到该设备整体的用户体验中去。前文已提到,一个桌面应用程序一般作为独立进程存在,而一个App则需要更加灵活,因为用户在移动设备上,需要经常随意地在App间进行切换,以便完成不同的任务及流程。
举个例子,如果想要在一个社交App上分享一张图片,思考下会是怎样的流程?首先社交App可能会使用Intent方式通过Android系统来启动一个拍照App,此时用户虽然离开了社交App,但其体验是无缝衔接的。同样的,拍照App也可能启动其他应用,如文件选择器或其他。最终用户返回社交App然后分享图片。当然,用户操作可能在上述过程中的任何时刻被电话打断,当通话结束后,再继续进行上述图片分享流程。
在Android中,类似上述App间切换非常频繁,因此我们的App需要正确处理上述流程。需要牢记的是移动设备的资源是有限的,所以在任何时候,操作系统都可能杀死一些App来为一些新的App腾出资源。
所有这些都说明我们的App组件可能被无规则地启动,也可能随时被用户或系统销毁,其生命周期并不由我们控制,因此我们不应该在App组件中存储任何数据而且组件之间也不应该互相依赖。
如果不在App组件中存储数据及状态,那应该怎样架构App呢?
第一,在App开发中,最需要的事情是职责分离( separation of concerns)。大家通常犯的一个错误是把所有代码都写到Activity或Fragment中,实际上任何跟UI处理或系统交互无关的代码都不应该写到Activity或Fragment中。尽可能保持Activity和Fragment的简洁(职责单一化)可以避免许多生命周期相关的问题。不要忘记你并不能控制这些类,它们就像粘合剂,体现了App和操作系统之间的联系,Android系统可能根据用户操作或其他原因如低内存而随时销毁它们。我们应该最小化对App组件的依赖从而提供一个稳定的用户体验。
第二,另一个重要原则是数据模型驱动UI,最好是可持久化的数据模型。持久化数据有两个好处:
Model(数据模型)也可以看做是一种处理App数据的组件,它们独立于视图和其他App组件,因此Model不存在其他组件生命周期相关的问题。保证UI代码简单、不掺杂业务逻辑可以方便管理,数据模型具有管理数据的职责,基于数据模型的应用程序将使它们具有可测性和应用程序一致性。
在这一节,将会通过一个示例来描述如何使用架构组件(Architecture Components )架构App。
注意:并不存在一个适用于任何场景的完美架构,也就是说,这里推荐的架构对于大部分开发案例来说只是一个好的开端。如果你已经有了一个不错的Android App架构,你可以继续使用而不用改变。
假设我们正在开发一个展示用户信息的界面,用户信息将通过REST API从后台请求数据。
用户界面用一个Fragment实现,如UserProfileFragment.java
,其对应的布局文件为user_profile_layout.xml
。
为了驱动UI,数据模型需要维护两个数据元素:
接下来将基于ViewModel
来创建一个UserProfileViewModel
类来维护上述信息。
ViewModel为特定的UI组件(Fragment或Activity)提供数据并处理与业务逻辑的交互,如调用其他组件加载数据。ViewModel与View解耦且不受配置变化的影响,如方向旋转时重建Activity不会影响ViewModel。
现在我们有3个文件:
user_profile.xml
:UI布局文件UserProfileViewModel.java
:为UI提供数据的类UserProfileFragment.java
:UI Controller用来展示ViewModel中的数据并响应用户交互下面就是初步的实现(为了简单起见,忽略了布局文件):
public class UserProfileViewModel extends ViewModel {
private String userId;
private User user;
public void init(String userId) {
this.userId = userId;
}
public User getUser() {
return user;
}
}
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);
}
}
注意:上面的例子继承了LifecycleFragment而不是Fragment。当架构组件中的lifecycle API稳定之后,Android Support Library中的Fragment类将实现LifecycleOwner。
现在我们有了3个代码模块,应该怎样将它们串联起来呢?毕竟我们需要当ViewModel的user字段改变后能够通知UI,此时LiveData就派上用场了。
LiveData是一个可观察的数据容器,它不需要显式地创建依赖路径就可以被App中的组件观察。LiveData可以根据App组件(Activity、Fragment、Service)的生命周期做正确的事情,从而能够有效阻止App中对象的内存泄露。
注意:如果你正在使用RxJava或Agera或其他方案,你可以继续使用它们替代LiveData,但是必须确保正确处理生命周期逻辑:当组件stopped时应该暂停数据流;当组件destroyed时也应该销毁数据流。也可以添加
android.arch.lifecycle:reactivestreams
来配合reactive streams library(如RxJava2)使用LiveData。
现在我们将UserProfileViewModel
中的User字段用LiveData
来替代,因此当数据变化时Fragment会被通知。最妙的是LiveData是生命周期可感知的,而且会自在不需要的时候自动清除引用。
public class UserProfileViewModel extends ViewModel {
...
private LiveData<User> user;
public LiveData<User> getUser() {
return user;
}
}
接下来修改UserProfileFragment来观察数据并更新UI。
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
viewModel.getUser().observe(this, user -> {
// update UI
});
}
每当用户数据发生变化时,onChanged
回调将会触发,从而导致UI刷新。
如果你熟悉使用被观察者回调模式的其他库,你应该意识到我们不需要重写Fragment的onStop()
方法来停止观察数据。对于LiveData这是不必要的,因为它本身是生命周期可感知的,这意味着如果Fragment不在激活状态时(执行了onStart()
但没有执行onStop()
),LiveData不会触发回调,同时,LiveData会在Fragment执行了onDestroy()
时自动移除观察者。
我们也不需要做任何事情去处理配置改变(如用户旋转屏幕),当配置变化时,ViewModel将会自动恢复,所以当新的Fragment创建后,它会收到相同的ViewModel对象实例并且立即收到当前数据的回调。ViewModel比View的生命周期长,这就是为什么ViewModel不应该直接引用View。更多资料请看The lifecycle of a ViewModel。
现在我们已经将ViewModel和Fragment联系起来,那么ViewModel是如何获取用户数据的呢?在这个示例中,假设后台提供了一个REST API,我们使用Retrofit来访问后台接口,当然也可以选择其他库来达到相同目的。
下面是跟后台交互的Retrofit的 Webservice
:
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对象,虽然这是行得通的,但随着App业务逻辑的增长将变得难以维护。ViewModel承担了太多的职责,这违背了前文提到的职责分离的原则,另外ViewModel绑定了Activity和Fragment的生命周期,当生命周期结束后,ViewModel会丢失所有数据,也是一种不好的用户体验。因此,我们的ViewModel将把数据请求的工作代理给一个新的模块,叫做Repository。
Repository的职责是处理数据操作,为其他模块提供简洁的API。Repository知道从哪里获取数据,当数据更新时知道调用什么接口,可以把它想象成不同数据源(持久化的Model、Webservice、Cache等)之间的中介。
下面展示了UserRepository
类通过Webservice
类获取用户数据:
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获取的,这意味着如果有必要,我们可以将具体实现换成其他方式。
注意:为了简单,这里省略了网络异常处理逻辑。对于错误处理及loading状态的处理可以参考 原文附录:Addendum: exposing network status。
上面的UserRepository类需要一个Webservice的实例才能工作,虽然创建Webservice实例很简单,但需要创建对Webservice的依赖,这显然会使代码冗余和复杂化(e.g. 每一个需要Webservice实例的类都需要对它的依赖)。另外,UserRepository可能并非唯一需要Webservice的类,如果每一个类都创建一个Webservice,则会造成资源冗余。
有两种方式可以解决这个问题:
上述方法可以清晰地管理依赖而不会产生冗余代码,同时不会增加复杂度。
在这个示例中,我们使用Dagger 2来管理依赖。
现在来修改UserProfileViewModel
类,让其使用Repository:
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调用的抽象非常好,但由于它只是依赖于单一的数据源,因此并不是很实用。
上文中实现的UserRepository
存在的问题是当获取到数据之后,并没有做任何的持久化工作,如果用户离开了UserProfileFragment
然后再次回来,App将会重新获取数据,这很糟糕,主要表现在:
为了解决上述问题,我们来添加一个新的数据源用来在内存中缓存User数据。
@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;
}
}
在当前的实现中,如果用户旋转了屏幕或者离开再次回来,UI将会立即显示数据因为Repository会从内存缓存中获取数据。但是如果用户离开App几个小时之后再次回来,如果Android系统销毁了应用进程,将会发生什么呢?
在这种情况下,将会重新通过网络获取数据,这不仅是一个糟糕的用户体验而且还浪费了流量。解决方案是缓存网络请求的结果。
合理的缓存方法是使用一个持久化的模型,于是Room登场了。
Room是一个提供本地数据存储的对象关系映射库。在编译时,它可以校验每个查询,因此无效的SQL查询将导致编译错误而不是在运行时报错。Room抽象了底层原生的SQL表查询操作,同时允许观察数据库数据(包括集合和关联查询)的变化,并通过LiveData暴露出来。此外,Room还显式地定义了线程约束来处理常见问题,比如在主线程上访问数据。
注意:如果你熟悉其他的SQLite ORM持久化解决方案或不同的库如 Realm,你并不需要将其替换为Room,除非Room的一些特性更适合你的应用场景。
为了使用Room,需要使用@Entity注解来标记User类,此注解会将User作为一张表。
@Entity
class User {
@PrimaryKey
private int id;
private String name;
private String lastName;
// getters and setters for fields
}
然后,通过继承 RoomDatabase
为App创建一个数据库类:
@Database(entities = {User.class}, version = 1)
public abstract class MyDatabase extends RoomDatabase {
}
注意到MyDatabase
是一个抽象类,Room自动为其提供实现。可以阅读 Room文档以了解更多。
接下来需要提供一个方法将User数据插入到数据库,因此我们创建了一个DAO(data access object)对象。
@Dao
public interface UserDao {
@Insert(onConflict = REPLACE)
void save(User user);
@Query("SELECT * FROM user WHERE id = :userId")
LiveData<User> load(String userId);
}
然后,从数据库类中引用DAO:
@Database(entities = {User.class}, version = 1)
public abstract class MyDatabase extends RoomDatabase {
public abstract UserDao userDao();
}
注意到load方法返回LiveData
。Room知道数据库何时被修改且当数据变化时会自动通知所有处于激活状态的观察者。因为使用了LiveData,在至少有一个处于激活状态的观察者时才会更新数据,这将更加高效。
注意:Room还处于alpha 1版本,Room checks invalidations based on table modifications which means it may dispatch false positive notifications。
接下来为UserRepository
集成Room数据源:
@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());
}
});
}
}
可以看到,即使修改了UserRepository
中的数据源,UserProfileViewModel
和UserProfileFragment
也不需要修改,这就是抽象提供的灵活性。对于测试来说也很有用,因为你可以使用模拟的UserPrository
来测试UserProfileViewModel
。
现在我们的代码完成了,如果用户几天后再次返回页面,由于做了数据持久化,因此仍然可以立即看到用户数据。同时,如果数据变陈旧了,Repository将会在后台更新数据。当然,根据你的应用场景,也可以选择不显示太陈旧的持久化数据。
在一些需求中,如下拉刷新,将当前的网络操作进度显示给用户很重要。将UI操作与实际数据分开是一种很好的做法,因为它可能出于各种原因进行更新(例如,如果获取好友列表,相同User的获取将导致 LiveData
的更新)。
对于这个案例有两种解决方案:
不同的REST API返回相同数据的情况很常见,例如,另一个后台接口返回了好友列表数据,那么相同的User对象将来自两个不同粒度的API。如果UserRepository
按照Webservice的请求返回响应结果,UI层则可能出现潜在的数据不一致问题,因为服务端的不同数据接口可能改变。这就是为什么在UserRepository
的实现中将Webservice的callback返回的数据直接存到数据库,然后数据库数据的改变则触发LiveData的回调。
在上述模型中,数据库作为单一数据源,App通过Repository来访问数据。无论是否使用磁盘缓存,我们建议Repository需要为App指定一个单一数据源。
上文提到了职责分离的一大好处是可测性,来看一下怎么对每一个代码模块进行测试。
User Interface & Interactions:这里需要使用Android UI Instrumentation test,测试UI代码最好的方法是使用Espresso。你可以创建一个Fragment并给他提供一个模拟的ViewModel,因为Fragment只与ViewModel交互,所以模拟ViewModel可以非常高效地测试UI。
ViewModel:ViewModel可以使用 JUnit test测试,只需要mock UserRepository即可。
UserRepository:对于UserRepository也可以使用JUnit test,需要mock Webservice和DAO,需要测试它是否正确调用了web服务、是否将结果数据保存到数据库、如果数据被缓存了是否还会进行不必要的请求。因为Webservice和UserDAO都是接口,可以mock它们或实现这些接口以模拟更复杂的测试场景。
UserDao:对于UserDao,推荐的方法是instrumentation测试,因为instrumentation测试不需要任何UI,运行速度很快。对于每一个测试,可以创建一个内存数据库,以确保测试没有任何副作用(如更改磁盘上的数据库文件)。
Room允许指定数据库实现,因此可以通过提供一个SupportSQLiteOpenHelper
实现来测试。这种方法并不推荐,因为运行在设备上的SQLite版本可能跟主机上的SQLite版本不一致。
Webservice:保持测试模块之间的独立非常重要,Webservice测试应该避免进行网络调用,有许多类库可以帮我们实现。例如,MockWebServer 是一个不错的库,可用来为测试需求创建一个模拟的本地服务器。
Testing Artifacts:Android架构组件提供了一个库来控制后台线程。在android.arch.core:core-testing
中,有两条单元测试规则:
下图展示了建议架构中的所有模块以及它们之间的交互:
编程是一个创造性的领域,构建Android应用也不例外。解决问题的方法有很多,下面推荐的一些原则并非强制性的,根据我们的经验,如果遵循下列原则将会使你的应用变得稳定、可测试、可维护。
原文地址