推荐使用Android architecture components 来构建你的应用。
假设我们正在构建一个显示用户信息的UI,用户信息通过REST API 从私人后端获取。
构建用户界面
这个用户界面由UserProfileFragment.java Fragment 和相应的Layout 文件user_profile_layout.xml组成。
驱动用户界面,我们的数据模型需要保存两个数据元素。
- 用户的ID
- 用户的对象
我们将创建一个基于ViewModel类的UserProfileViewModel来保存这些信息。
ViewModel为特定的UI组件提供数据,比如Fragment或者Activity,并且负责数据处理业务的通信,比如调用其他组件加载数据或者转发用户变更。ViewModel与View无关,不受配置变更的影响,比如Activity的重建或者旋转。
现在我们有3个文件:
- user_profile.xml:UI布局文件。
- UserProfileViewModel.java:UI数据类。
- UserProfileFragment.java: 显示ViewModel中的数据并响应用户交互的UI控制器。
下面是我们的实现,简单起见,Layout文件省略。
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 Fragment {
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);
}
}
现在,我们有了三个模块的代码,如何把他们关联起来呢?毕竟,当ViewModel中的user被赋值后,我们需要一个方法来通知用户界面刷新。这时候就要用到LiveData了。
LiveData是一个可被观察的数据持有者。它允许APP中的组件观察LiveData对象的改变,而不会在他们之间创建明确的或者严格的依赖路径。LiveData也会遵循APP组件的生命周期状态,并做出正确的响应来防止内存泄漏,以降低内存的使用。
Note:如果你已经在使用RxJava或者Agera等类库,你可以继续使用他们来替代LiveData。
现在我们使用LiveData<User>替换UserProfileViewModel中的User,这样当数据更新的时候Fragment中就可以被通知到。LiveData最棒的在于它具有生命周期感知能力,当不再需要的时候会自动清除引用。
public class UserProfileViewModel extends ViewModel {
...
private User user;
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
});
}
每次user数据被更新的时候,onChange回调方法就会被调用来刷新UI。
如果你熟悉其他使用可观察回调的库,你可能已经意识到我们不需要重载onStop()函数来停止观察数据。对于LiveData来说这并不需要,因为它能自动感知生命周期,也就是说除非Fragment处于活动状态(收到onStart(),但是还没有收到onStop()),否则他不会调用Callback。LiveData会在收到onDestory()时自动移除观察者。
我们对配置的变化(如屏幕旋转)并没有做特殊处理。当配置改变时,ViewModel会自动恢复。所以一旦新的Fragment激活,它就会收到同一个ViewModel的实例,并且会立即回调最新数据。这就是ViewModel不能直接引用Views的原因;他们存活比View的生命周期更长。请参阅ViewModel的生命周期。
Fetching Data 获取数据
现在我们已经把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对象,是一种幼稚的ViewModel实现。虽然他是可行的,但是随着业务的增长APP会变得难于管理。在ViewModel上职责太多,这也违背了之前提到的关注点分离原则。此外,ViewModel的作用域与Activity或者Fragment的生命周期关联,所以当生命周期结束的时候丢失全部数据是不好的用户体验,相反,我们的ViewModel将这部分工作委托给一个新的Repository模块。
Repository模块负责处理数据操作。它为应用程序的REST部分提供了干净的api。他们知道从哪里获取数据以及在数据更新时调用什么API。你可以将他们看作是不同数据源(persistent model,web service, cache,etc...)的中介。
UserRepository 类使用WebService 来获取user数据
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模块看上去不是必须的,使用他有一个重要的目的:他从应用的REST中抽象了数据源。现在我们的ViewModel并不知道数据是从Webservice中获取的,意味着在需要的时候我们可以更换数据源。
Note:为了简单起见,我们忽略了网络错误的情况。有关网络加载错误和加载状态的显示实现,请查阅附录:显示网络状态。
管理组件依赖
上面的UserRepository类需要一个Webservice的实例来完成他的工作。它可以简单创建,但是他也需要Webservice类的依赖关系来构造它。这会使代码变得复杂和冗余(比如,每个需要Webservice实例的类都需要知道如何利用它的依赖关系来构造它)。此外,UserRepository可能不是唯一需要Webservice的类,如果每个地方都创建一个新的Webservice,这将会是非常沉重的资源。
有两种模式你可以用来解决这个问题:
依赖注入:依赖注入允许类在不构造的情况下定义他们的依赖关系。在运行时,另外一个类负责提供这些依赖关系。我们推荐google 的Dagger2类库来在anroid应用中实现依赖注入。Dagger2通过遍历依赖关系树来自动构造对象,并为依赖关系提供编译时间的保证。
服务定位器: Service Locator提供一个注册表,类可以获取他们的依赖关系而不是构建它们。比依赖注入(DI)实现起来更容易,所以,如果你不熟悉DI,使用Service Locator。
这些模式允许你扩展你的代码,因为他们提供了清晰的模式来管理依赖关系,无需增加代码的冗余和复杂性。他们两者之间也可以为测试切换实现方式;这是使用他们的主要好处之一。
在这个例子中,我们将使用Dagger2来关系依赖关系。
连接ViewModel和Repository
现在用repository来修改我们的UserProfileViewModel。
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并又返回来,应用需要重新获取数据。这是不好的体验,有两个原因:它浪费了宝贵的带宽并强制让用户等待新的查询完成。为了解决这,我们添加一个新的数据源到UserRepository中,用来缓存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;
}
}
持久化数据
在我们当前实现中,如果用户旋转屏幕或者离开并返回当前app,当前的UI可以立即可得因为Repository会从内存缓存中恢复数据。但是,如果用户退出APP并在数小时后又回来,或者在Android系统杀死该进程后回来,会发生什么?
在当前的实现中,我们需要从网络上重新获取数据。这不仅是一个糟糕的用户体验,而且会浪费移动数据流量来重新获取相同的数据。你可以通过缓存Web请求来简单的解决这个问题,但是这会带来新的问题。如果相同的用户数据显示来自另外一种类型的请求(比如,获取朋友列表),会发生什么情况?那么你的APP可能会显示不一致的数据,这是一个让用户混乱的体验。为此,相同的用户数据可能会显示不一致,是因为用户对朋友列表的请求可以在不同的时间执行。APP需要合并他们避免显示不一致的数据。
用持久化模式来处理这个问题是一个正确的方式。这就到了使用Room持久化库的时候了。
Room是一个对象映射库,使用非常少量的代码实现本地数据的持久化。在编译时,它会根据schema来检查验证每个查询,用编译时的SQL查询错误中断来替换运行时失败。Room抽象出一些使用原始的SQL表和查询的底层实现细节。他也允许对数据库数据(包括集合和连接查询)的变更进行观察,并通过LiveData对象来暴露这些变更。另外,他明确的定义了线程约束,来解决常见的问题,比如在主线程上访问存储。
Note:如果你的APP已经使用了另外的持久化解决方案,比如Sqlite对象关系映射(ORM),你不必用Room替换当前的方案。但是,如果你重新写一个APP或者重构当前的APP,我们推荐你使用Room来持久化你的App数据。这样,你可以体验到Room库的抽象化和查询验证带来的好处。
使用Room,我们需要定义本地的schema。首先,用@Entity注释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是abstract的。Room自动提供它的实现。详细参阅Room文档。
现在我们需要一个插入user数据的到数据库的方法。为此,我们创建了一个数据访问对象(DAO)。
@Dao
public interface UserDao {
@Insert(onConflict = REPLACE)
void save(User user);
@Query("SELECT * FROM user WHERE id = :userId")
LiveData<User> load(String userId);
}
然后,从我们的database类引用DAO。
@Database(entities = {User.class}, version = 1)
public abstract class MyDatabase extends RoomDatabase {
public abstract UserDao userDao();
}
注意,load方法返回的是LiveData<User>。当数据发生改变的时,Room知道什么时候数据库被修改并且会自动的通知所有活跃状态的观察者。因为它使用了LiveData,会非常高效,而且只有当前至少有一个活跃的观察者时才会更新数据。
Note:Room的无效检查是基于表格的修改,这意味着它可能发出错误的通知。
现在我们可以修改我们的UserRository来合并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.这就是抽象的灵活性。这样也更有益于测试,因为你可以构造一个UserRepository来测试你的UserProfileModel。
至此我们的代码已经完成。如果用户在几天后返回到相同的界面,他们会立即看到用户信息,因为我们持久化了数据。同时,如果数据已经过期,我们的Repository会在后台更新数据。当然,根据你的情况,如果数据太旧你可能不希望显示持久化的数据了。
在一些使用的情况中,比如下拉刷新,如果当前正在进行网络操作,给用户交互的UI是非常重要的。将UI操作和实际数据分开是一种很好的实践,因为数据可能因为各种原因被更新(例如,我们获取朋友列表,同一用户可能再次被获取,触发LiveData<User>的更新)。从用户交互的角度来看,有一个请求正在进行的是情况是另外一个数据点,类似于其他数据块(比如用户对象)。
这种情况有2种常见的解决方案:
在repository类中提供另外一个公共的功能,可以返回User的刷新状态。这个选项更好一些,如果你只想在UI中显示网络状态来明确响应用户操作(比如下拉刷新)。
单一数据来源
经常不同的REST API终端返回相同的数据。例如,假如我们的后端有另外一个返回朋友列表的入口,同一个user对象可能来自不同的两个API入口,也可能来自不同的粒度。如果UserRepository原样返回来自Webservice请求的响应,那么我们的UI可能会显示不一样的数据,因为可能这些数据在服务端不同的请求之间发生了更改。这就是为什么在UserRepository实现中,web服务回调只保存数据到数据库中。然后,数据库的改变触发活动的LiveData对象上的回调。
在这个模式中,数据库充当单一数据来源,APP的其他部分通过repository访问它。无论你是使用磁盘缓存,我们都建议你的repository将数据源指定为单一来源,作为你的应用的REST。
测试
我们已经提到分离的好处之一就是可测试性。让我们看看我们如何测试每个代码模块。
用户界面和交互:这将是你唯一需要Android UI Instrumentation的测试。测试UI 代码最好的方式是创建一个Espresso测试。你可以创建Fragment并为其提供一个模拟的ViewModel。由于这个Fragment仅仅同这个ViewModel交互,模拟它足于完全测试这个UI。
ViewModel:可以使用JUnit来测试ViewModel。你只需要模拟UserRepository来测试它。
UserRepository: 你同样可以用JUnit来测试UserRepository。你需要模拟Webservice和DAO。你可以测试它是否做出正确的web服务调用、结果保存到数据库和不做任何不必要的请求假如数据已经缓存且更新。既然Webservice和UserDao都是接口,你可以模拟他们或者创建副本来实现更复杂的测试用例。
UserDao: 测试DAO类的推荐方法是使用instrumentation 测试。由于这些instrumentation测试不要求任何UI,他们会运行的很快。对于每个测试,你可以创建一个在内存中的数据库来确保测试没有任何边界效应影响(如更改磁盘上的数据库文件)。
Room还允许制定数据库实现,以便你可以通过提供SupportSQLiteOpenHelper的JUnit实现来测试它。通常不推荐这种方式,因为设备的SQlite的版本可能与主机上的SQLite版本不一致。
Webservice:让测试独立于外部是非常重要的,所以即使是你的Webservice测试,也应该避免对你的后端进行网络调用。有很多的库可以帮你做这些事。比如,MockWebServer是一个非常棒的库,可以帮助你的测试创建一个本地模拟服务器。
Testing Artifacts Architecture Components 提供一个maven 生成来控制它的后台线程。在android.arc.core:core-testing artifcat中,有2个JUnit规则:
InstantTaskExecutorRule:此规则可用于强制Architecture Compononets在调用的线程上立即执行任何后台操作。
CountingTaskExecutorRule:此规则可用于instrumentation test,来等待Architecture Components的后台 操作或者作为待机资源连接到Espresso。
最终架构
下图显示了我们推荐的架构的全部模块以及他们之间的交互。
使用原则
编程是一个创造性领域,构建Andorid 应用也不例外。解决问题的方式有很多,在多个activity或者fragment之间通信数据,恢复远程数据并且持久化到本地用于离线模式,或者许多其他常见应用程序碰到的情况。
尽管下面的建议不是强制性的,但是我们的经验是,遵循这些建议将使你的代码基础更加健壮、可测试以及可维护。
你在终端的manifest中定义的组件(activitys,services,broadcast receivers,等)不是数据来源。相反,他们只是配合数据子集的终端响应。因为每个应用的组件存活非常短,取决于用户与设备的交互和当前运行时的整体情况,你不希望任何这些入口点作为数据源。
在应用中创建时,明确界定各个模块的职责。比如,不要将从网络加载数据的代码分散在多个类或者包中。同样,不要把不相关额职责放到同一个类中。比如数据缓存和数据绑定。
每个模块尽可能少的暴露。不要视图创建“仅有那个”的便捷路径去暴露模块的内部实现细节。你可能短期内节省一些时间,但是随着代码的发展,你就需要偿还更多的技术债务。
当你在多个模块中定义交互时,请考虑如何让每个模块独立可测试。例如,有一个定义良好的从网络获取数据的API,将数据持久化在本地会使得模块测试更容易。反过来,如果你将两个模块的逻辑混淆在一起,或者将你的网络部分代码遍布于整个代码库,那么测试就更加困难,甚至不可能测试。
你的APP的核心是什么因素让它能够脱颖而出。不要浪费时间去重复造轮子,或者一次次地写雷同的样板代码。相反,应该集中精力在让你的APP的特色上,让Android Architecture Components和其他推荐库来处理重复样板的问题。
坚持尽可能多的掌握新的数据,这样当你的设备离线时你的应用也是可用的。虽然你可以享用稳定高速的网络连接,但是你的用户可能没有。
附录:显示网络状态
在前面的recommend app architecture章节中,为了使例子看起来更简洁,我们故意省略了网络请求错误和加载状态。在这个章节,我们演示如何使用一个封装了网络数据和它的状态的Resource类来显示网络状态。
实现如下:
//a generic class that describes a data with a status
public class Resource<T> {
@NonNull public final Status status;
@Nullable public final T data;
@Nullable public final String message;
private Resource(@NonNull Status status, @Nullable T data, @Nullable String message) {
this.status = status;
this.data = data;
this.message = message;
}
public static <T> Resource<T> success(@NonNull T data) {
return new Resource<>(SUCCESS, data, null);
}
public static <T> Resource<T> error(String msg, @Nullable T data) {
return new Resource<>(ERROR, data, msg);
}
public static <T> Resource<T> loading(@Nullable T data) {
return new Resource<>(LOADING, data, null);
}
}
由于从网络上加载数据到磁盘上显示是一个常见的例子,所以我们要创建一个可以在多个地方复用的帮助类NetworkBoundResource。NetworkBoundResoutce的决策树如下:
从资源观察的数据库开始,当实体第一次从数据库加载时,NetworkBoundResource会检查结果是否足够好用于分发,还是从网络重新获取,注意,如果你希望在获取网络数据的时候显示缓存数据,这两种情况可能同时发生。
如果网络调用成功完成,将响应数据保存到数据库并且重新开始数据流。如果网络请求失败,直接发送失败。
Note:把新数据保存到磁盘后,我们从数据库重新初始化数据流。但是通常我们不需要这样做,因为数据库会分发数据的变更。另一方面,依赖数据库来分发调度变更会有不好的依赖副作用,因为在数据没有改变的时候数据库可以避免调度分发,这样流程会被中断。我们也不希望分发网络获取的结果,因为这违背了我们单一数据源原则(也许在数据库中有触发器会改变保存的数值)。如果没有新的数据,我们也不想分发SUCCESS消息,它会向客户端发送错误的信息。
下面是NetworkBoundResource类为其子类提供的public API。
// ResultType: Type for the Resource data
// RequestType: Type for the API response
public abstract class NetworkBoundResource<ResultType, RequestType> {
// Called to save the result of the API response into the database
@WorkerThread
protected abstract void saveCallResult(@NonNull RequestType item);
// Called with the data in the database to decide whether it should be
// fetched from the network.
@MainThread
protected abstract boolean shouldFetch(@Nullable ResultType data);
// Called to get the cached data from the database
@NonNull @MainThread
protected abstract LiveData<ResultType> loadFromDb();
// Called to create the API call.
@NonNull @MainThread
protected abstract LiveData<ApiResponse<RequestType>> createCall();
// Called when the fetch fails. The child class may want to reset components
// like rate limiter.
@MainThread
protected void onFetchFailed() {
}
// returns a LiveData that represents the resource, implemented
// in the base class.
public final LiveData<Resource<ResultType>> getAsLiveData();
}
请注意上面的类定义了2种类型的参数(ResultType,RequestType)因为从API返回的数据类型可能与本地使用的数据类型不匹配。
另外也需要注意上面的代码使用ApiResponse作为网络请求。ApiResponse是Retrofit2.Call的一个简单的封装类,调用他将response转换成一个LiveData。
以下是NoteworkBoundResource的其余实现:
public abstract class NetworkBoundResource<ResultType, RequestType> {
private final MediatorLiveData<Resource<ResultType>> result = new MediatorLiveData<>();
@MainThread
NetworkBoundResource() {
result.setValue(Resource.loading(null));
LiveData<ResultType> dbSource = loadFromDb();
result.addSource(dbSource, data -> {
result.removeSource(dbSource);
if (shouldFetch(data)) {
fetchFromNetwork(dbSource);
} else {
result.addSource(dbSource,
newData -> result.setValue(Resource.success(newData)));
}
});
}
private void fetchFromNetwork(final LiveData<ResultType> dbSource) {
LiveData<ApiResponse<RequestType>> apiResponse = createCall();
// we re-attach dbSource as a new source,
// it will dispatch its latest value quickly
result.addSource(dbSource,
newData -> result.setValue(Resource.loading(newData)));
result.addSource(apiResponse, response -> {
result.removeSource(apiResponse);
result.removeSource(dbSource);
//noinspection ConstantConditions
if (response.isSuccessful()) {
saveResultAndReInit(response);
} else {
onFetchFailed();
result.addSource(dbSource,
newData -> result.setValue(
Resource.error(response.errorMessage, newData)));
}
});
}
@MainThread
private void saveResultAndReInit(ApiResponse<RequestType> response) {
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... voids) {
saveCallResult(response.body);
return null;
}
@Override
protected void onPostExecute(Void aVoid) {
// we specially request a new live data,
// otherwise we will get immediately last cached value,
// which may not be updated with latest results received from network.
result.addSource(loadFromDb(),
newData -> result.setValue(Resource.success(newData)));
}
}.execute();
}
public final LiveData<Resource<ResultType>> getAsLiveData() {
return result;
}
}
现在我们可以在repository的实现中使用NetworkBoundResource来写入数据到我们的磁盘和网络状态绑定。如下:
class UserRepository {
Webservice webservice;
UserDao userDao;
public LiveData<Resource<User>> loadUser(final String userId) {
return new NetworkBoundResource<User,User>() {
@Override
protected void saveCallResult(@NonNull User item) {
userDao.insert(item);
}
@Override
protected boolean shouldFetch(@Nullable User data) {
return rateLimiter.canFetch(userId) && (data == null || !isFresh(data));
}
@NonNull @Override
protected LiveData<User> loadFromDb() {
return userDao.load(userId);
}
@NonNull @Override
protected LiveData<ApiResponse<User>> createCall() {
return webservice.getUser(userId);
}
}.getAsLiveData();
}
}