译者:Android的新出架构系列指南还是很有意义的,在API层为MVVM架构提供了支持。也为追求更清晰的项目架构提供了更低门槛的指导。正好国庆无事可做,因为特别喜欢这几篇指南,所以抽几天时间翻译一下,英文水平不是很好,各位就将就着看,欢迎指正。以下是正文。
本系列其他翻译
==正文==
本篇指南适用于有过一些开发基础,但现在想了解用更好架构来构建健壮的,高质量的app的人。
注:本篇指南假设读者已经熟悉Android Framework. 如果你是Android新手,那你应该先 Getting Started 系列文章,那里有本篇指南的预备知识。
在大部分情况下,传统桌面应用只一个入口(在Launcher中的快捷方式中),并且只运行在一个进程中。但Android app 不一样,它有着更复杂的架构。一个典型的Android app 是由多个app components 组成,包括多个activity,fragment,service,content provider 以及 broadcast receiver.
这些app组件大部分都在 manifest 中声明,Android 系统 通过 manifest 中的信息来决定如何将app整合到设备中,以便统一用户体验。然而正如上文提到的,传统桌面应用只在单个进程中运行,但一个优秀规范的Android App需要更加的灵活,因为用户经常在各个app之间跳转、切换工作流和任务。
举个例子,你想在最喜欢的社交app上分享一张照片,这个app 发起一个打开相机的 Intent,Android 系统接收并处理 这个Intent请求,然后打开相机应用。 在这时,虽然用户离开了该社交app,但是仍然保证了无缝衔接的用户体验。反过来,相机也可能触发其他Intent,比如打开图片选择器,这又可能打开其他app。 最后的最后,用户又可以回到社交app并分享照片。这个过程中,如果来了一个电话,用户可能被迫中断去接电话,接完电话,然后再返回app分享图片。
在Android 中,这种App之间跳转的行为很常见,所以你的app要能正确地处理这种流程。而且我们要记住,手机app的资源是有限的,所以系统随时都可能杀掉一些app,为打开新的app提供空间。
说到底就是,你的app组件能够单独被启动,不应有顺序限制,并且能够随时被系统或用户安全地销毁。因为app组件生命是短暂的,它们的生命周期(何时创建、何时销毁)并不受你的控制,所以不应该在app组件中存储任何的数据和状态 ,且你的app组件不应该彼此依赖。
如果不能在 app components 中保存数据和状态,那应该怎样构建呢?
注意,最重要的原则是分离关注点, 举一个常见的反例,把所有的代码都写在Activity和Fragment中。其实,所有非UI操作的代码、系统交互的代码都不应该写在这些类中。让Activity、Fragment尽可能地保持简洁,这样能避免很多生命周期相关的问题。因为你没有真正“拥有”这些类(因为Activity 和Fragment 属于Android系统的),它们只是衔接类,用来连接OS和你App代码,Android OS 可能基于用户的操作和其他因素(比如内存情况)来销毁它们。所以最好减少对这些类的依赖,以便为用户提供稳定的用户体验。
第二个重要原则是用Model 驱动UI,最好是可持久化的model。为什么要持久化呢?有两个原因: 1. 如果系统杀死了你的app,用户不会丢失数据;2. 就算网络环境很差,你的app也可以正常运行。 Model负责处理App的数据,它应独立于app 组件的生命周期,这样才能避免生命周期导致的数据问题。而且,让UI相关代码保持简洁且与app的逻辑分离,这样将更容易管理。如果App 的基础Models 有清晰的数据管理职责,那你的app也会易于测试,并且更加稳健。
在本节,我们会通过一个用例来展示如何使用 Architecture Components 来构建app。
注:世界上没有什么架构是适用于任何场景的。也就是说,这个推荐的架构应该只是一个开始,它适用于常见的应用场景。如果你已经有一个好的架构了,那就无须再改了。
想象一下,我们将要构建UI来展示一个用户的基本信息。并通过REST接口从我们私有的后台获取用户基本信息。
UI将会由一个fragment(UserProfileFragment.java) 和 它相关的layout文件(user_profile_layout.xml)组成。
为了驱动UI,我们的数据模型需要持有两个数据元素:
我们将会创建基于ViewModel
的UserProfileViewModel
类来保持这些信息。
ViewModel
为指定的UI组件提供数据(比如一个fragment或者一个activity),并且负责和业务数据处理的交互,比如调用其他组件加载数据或者传递用户数据的修改。ViewModel
不与View接触,也不受configuration 变化的影响,比如旋转屏幕导致的Activity重新创建。
现在我们有3个文件:
ViewModel
中的数据并且响应用户交互。下面是我们的实现(为了简化,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
的数据发生改变,我们需要用某种方式通知UI。 这就是LiveData
类发挥作用的时候啦。
LiveData 就是一个被观察的数据Holder。app 组件可以观察LiveData中的数据变化。而且不需要为它们建立显式的、死板的依赖关系。LiveData 自动适配app组件的生命周期(activities,fragments,services),并且会做一些操作来防止对象泄漏,这样你的app就不会消耗更多的内存。
注: 如果你已经用了RxJava 或者Agera这样的库,你可以继续使用他们而不是LiveData。但是,当你使用它们或者其他类似的方法时,请保证你合适地处理生命周期,这样在相关的LifecycleOwner 停止(stop)时,数据流也会暂停。当它销毁(destroyed)时,数据流也应该销毁。你可以添加android.arch.lifecycle:reactivestreams工具结合这些响应库使用(比如RxJava2)。
现在我们将UserProfileViewModel
中User成员替换为LiveData
这样当数据更新时,就能通知Fragment
显示。LiveData
还有个优势就是生命周期敏感,在数据无用时会自动清除引用。
public class UserProfileViewModel extends ViewModel {
...
//private User user;
private LiveData user;
public LiveData getUser() {
return user;
}
}
现在我们修改UserProfileFragment
,以便观察数据并更新UI。
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
viewModel.getUser().observe(this, user -> {
// 更新 UI
});
}
每次user 更新,onChange
方法都会调用,然后UI会刷新
如果熟悉其他的有使用观察回调的库,你可能已经注意到,我们并没有重写fragment的 onStop()
方法来终止对数据的观察。因为使用LiveData
不需要这么做,因为它本身就是生命周期相关的,也就是说它只会在Fragment
处于active state(即在onStart
到onStop
之间的状态)时才会回调onChange
方法。当Fragment
调用onDestroy
后,LiveData
也会自动移除外部的观察者。
不仅如此,我们也不需要处理configuration changes(比如屏幕旋转)的情况,因为ViewMode
会自动重置数据。只要新的Fragment
重新初始化,它会接收一个相同的ViewModel
实例,并且立即回调方法,将当前的数据传入。这也是ViewModel
不应该直接引用View 的原因。因为ViewModel
可以在View的生命周期之外存活。详细请看The lifecycle of a ViewModel。
现在,我们已经将Fragment和ViewModel
联系起来了,但是ViewModel
该如何获取user 数据呢?在这个例子中,假设后台提供一个REST 接口,我们使用Retrofit库来获取后台数据(你也可以使用其他库,只要行得通就可以)。
这是和我们后台交互的retrofit Webservice
:
public interface Webservice {
/**
* @GET 声明此Http请求为Get请求
* @Path("user") 注解: userId参数上的标的注解表明,userId将替换 @GET 路径中的{user}
*/
@GET("/users/{user}")
Call getUser(@Path("user") String userId);
}
不成熟的ViewModel
实现可能会直接调用WebService
来获取数据,然后通过回调给user 对象赋值。尽管这样能实现功能,但是随着app业务的成长,这样会很难维护。因为ViewModel
承担了太多的职责,而这违反了之前提到的“分离关注点”原则。而且,ViewModel
的作用域与Activity
和Fragment
生命周期关联,这样一旦生命周期结束数据就会丢失,用户体验不好。 我们的ViewModel
应当将获取数据的逻辑代理给一个新的Repository module.
Repository modules负责处理数据操作。它们为app其他部分提供纯粹的API。它们知道从哪儿获取数据,当数据更新时应该调用什么API。你可以将它们视为不同数据源(持久化模块,web service,缓存等)之间的调解器。
下面的UserRepository
类使用WebService
来获取user data item.
java
public class UserRepository {
private Webservice webservice;
// ...
public LiveData
// 这不是最优的实现,我们下面会修复它
final MutableLiveData
webservice.getUser(userId).enqueue(new Callback
@Override
public void onResponse(Call
// 为了简洁,这里忽略了错误情况
data.setValue(response.body());
}
});
return data;
}
}
尽管repository module看起来多余,但是它存在有着重要目的。它将app的数据源抽象处理,独立于其他模块。现在我们ViewModel
不依赖于WebService,这样的好处就是,必要时可以替换成另外一个实现。
注:为了简化例子,我们忽略了网络错误的情况。提供一个暴露网络错误和加载状态的实现,see Addendum: exposing network status.
管理组件之间的依赖
上面的UserRepository
类需要一个Webservice
实例,如果简单地创建它,那也需要知道Webservice
的依赖。这会显著地让代码变复杂且重复(e.g. 每个需要Webservice
的类需要知道如何创建它,以及它的依赖),并且,可能除了UserRepository
之外,还有其他类需要Webservice
。如果每个类都创建一个新的WebService
那会很耗资源。
解决这个问题,你可以使用以下两种模式:
这些模式让你的代码更容易扩展,因为它们提供一种清晰的依赖管理模式,这种模式避免了重复代码和 增加复杂性。使用这两种模式,就可以为了测试更改实现,这是使用它们的主要的好处。
在这个例子中,我们将使用 Dagger 2 来管理依赖。
现在我们修改UserProfileViewModel
,使用repository。
public class UserProfileViewModel extends ViewModel {
private LiveData user;
private UserRepository userRepo;
@Inject // UserRepository 参数由 Dagger 2 提供
public UserProfileViewModel(UserRepository userRepo) {
this.userRepo = userRepo;
}
public void init(String userId) {
if (this.user != null) {
// 每个Fragment都会创建一个ViewModel
// 所以我们知道userId不会改变
return;
}
user = userRepo.getUser(userId);
}
public LiveData getUser() {
return this.user;
}
}
上面的repository 实现利于将web 服务的调用抽象出来,但是由于它只依赖一个数据源,所以看起来并不是特别有作用。
上面UserRepository
的实现的问题在于在获取数据后,它没有保存数据。 如果用户离开UserProfileFragment
然后再返回,那app会重新获取数据。这有两个坏处:1.浪费了宝贵的网络带宽。 2.用户被强制等待新的网络查询完成。为了解决这个,我们会加一个新的数据源到UserRepository
中 ,这个数据源会在内存中缓存User
对象 。
@Singleton // 告诉 Dagger 这个类应该值被创建一次
public class UserRepository {
private Webservice webservice;
// 简单的内存缓存实现,细节就略过了
private UserCache userCache;
public LiveData getUser(String userId) {
LiveData cached = userCache.get(userId);
if (cached != null) {
return cached;
}
final MutableLiveData 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() {
@Override
public void onResponse(Call call, Response response) {
data.setValue(response.body());
}
});
return data;
}
}
在就当前实现而言,如果用户旋转屏幕或者离开再返回app,现有的UI将会立即展示数据,因为repository 是从内存cache中获取数据。但是如果用户离开app数小时,然后Android OS杀死进程,这时用户才返回app,那会发生什么呢?
在当前的实现中,我们需要重新联网获取数据。这样不仅用户体验不好,而且还浪费用户手机流量。虽然简单地将Web请求缓存起来也可以解决这个问题,但是它会引入新的问题。你想,如果同一个user数据从另一个接口(eg 请求好友列表)请求下来呢?那可能会让用户感到迷惑,甚至更严重的问题。比方说:我这会儿请求好友列表,过会儿又请求user的数据,这样由于请求时间的不同,同一个user的数据可能有差异(比如被修改)。
针对上面的问题,有一个比较解决方案:就是使用一个持久化模块。这就轮到Room持久化库的show time了。
Room是一个对象映射库,只需要编写很少的模板代码,就能完成据持久化功能。早在编译期间,它就会将每一个query 和 schema做检查,所以错误的SQL查询在编译时就会不通过,而不是到运行时才发现问题。Room将SQL表操作和查询底层的实现细节抽象出来,同时,它也允许外部观察数据库中的变化(包括集合和联表查询),并通过LiveData对象暴露这些变化。而且,它显式的约束了执行线程,这样就解决了一些常见问题。比如在主线程获取内部存储数据。
注: 如果你熟悉其他的持久化解决方案(比如SQLite ORM) 或者其他不同的数据库(比如 Realm),也没必要用Room替换它们,除非Room的特性更适合你的情况。
为了使用Room,我们需要定义本地的数据模板。首先,给User
类添加 @Entity
注解,代表着这个类映射到一个数据库表
@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对象到数据库的方式,下面我们创建一个 data access object (DAO)
@Dao
public interface UserDao {
@Insert(onConflict = REPLACE)
void save(User user);
@Query("SELECT * FROM user WHERE id = :userId")
LiveData load(String userId);
}
然后,在我们数据库类中引用这个DAO
@Database(entities = {User.class}, version = 1)
public abstract class MyDatabase extends RoomDatabase {
public abstract UserDao userDao();
}
注意,load 方法返回一个LiaveData
。Room知道数据库何时被修改,并且会通知所有的观察者。因为使用LiveData,所以效率比较高,因为至少要有一个观察者处于active状态,才会更新数据。
注:Room根据表操作来检查数据是否过期,这意味它可能发出误报修改通知(即明明没有修改数据,但是却触发了数据更新的通知)。
现在,我们修改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 getUser(String userId) {
refreshUser(userId);
// return a LiveData directly from the database.
return userDao.load(userId);
}
private void refreshUser(final String userId) {
executor.execute(() -> {
// 在工作线程中执行
// 检查user是否最近被获取过
boolean userExists = userDao.hasUser(FRESH_TIMEOUT);
if (!userExists) {
// 刷新数据
Response response = webservice.getUser(userId).execute();
// TODO 检查错误等.
// 更新数据库. LiveData会自动刷新,只需要更新数据库就行了,其他什么都不用做
userDao.save(response.body());
}
});
}
}
注意,尽管我们改变了UserRepository
中数据的来源,但是我们不需要修改UserProfileViewModel
和 UserProfileFragment
这两个类。这就体现了抽象化的灵活之处。这样也有利于测试,比如你在测试UserProfileViewModel
时,你可以写一个假的UserRepository
。
现在代码已经完成了。如果用户几天后重返相同的界面上,用户信息会立即显示出来,因为我们做了持久化存储。同时,如果数据变化,repository会在后台更新数据。当然这都取决于你的业务场景,也有可能持久化保存的数据太旧了,你不想要显示它们。
在一些场景中,比如下拉刷新,如果有网络操作在进行,需要通过UI告诉用户。这也是一个分离UI行为与实际数据的实践场景,因为数据可能因为多种原因被更新(比如,下拉刷新好友列表,user信息可能会重新获取从而触发LiveData\更新)。从UI的角度,一个正在进行的请求只是一个数据点,和其他的数据点相似(比如 user 对象)
针对这种情况,有2个常见的解决方案:
getUser
方法,让它返回一个包含网络操作状态的LiveData。 在 Addendum: exposing network status这一节中提供了一个实现示例。单一数据源(source of truth)
不同的REST API端点返回相同的数据,这种现象很常见。以此举例,如果我们的后台有另一个端点(endPoint)也返回好友列表,那同一个用户数据就可能来自这两个不同的API端点,可能只会有细微的差别。如果UserRepository
只是原样返回Webservice请求的响应,我们不同UI界面就可能显示不一致的数据,因为这两次请求有时间差,服务器的数据可能已经改变。这就是为什么在UserRepository
的实现中,web service的回调只将数据保存至数据库,然后,数据库一旦被修改,就会触发LiveData的回调 。
在这个模型中,数据库作为单一数据源,并且app的其他部分通过repository获取数据。不管是否用到磁盘缓存,我们建议你的repository指定一个数据源作为单一数据源。
下面的示例图展示了我们推荐的架构,包括所有的Modules以及彼此交互。
编程是一个创造性的领域,编写Android app 也不例外。解决同一个问题有许多方式,不管是多个activities 或者 fragments中间的数据交互,还是远程获取数据并存入本地,或者稍微重量级的app会遇到任何常见的场景。
当下面的建议并不是强制性的,它是我们经验的结晶,从长远看来,按照这些建议编程会让你的代码更加稳健,方便测试和维护。
在上面推荐app架构一节,为了让示例简洁,我们故意省略网络错误和加载状态的处理。在这一节中,我们展示一种暴露网络数据的方法:用Resource
来将数据和网络状态封装起来。
下面是一个示例:
//一个描述数据和状态的泛型类
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 Resource success(@NonNull T data) {
return new Resource<>(SUCCESS, data, null);
}
public static Resource error(String msg, @Nullable T data) {
return new Resource<>(ERROR, data, msg);
}
public static Resource loading(@Nullable T data) {
return new Resource<>(LOADING, data, null);
}
}
因为显示磁盘上的数据的同时,从网络上加载数据是一种常见的场景。所以我们将创建一个可在多处复用的辅助类NetworkBoundResource
。下面是NetworkBoundResource
的决策树
它从观察一个作为源数据库开始。当实体第一次从数据库加载出来,NetworkBoundResource
检查这个结果是否能否被分发出去,并(/或)是否应改从网络上获取。注意,这两两者可能同时发生,因为你可能想在更新网络数据的同时,显示数据库的缓存数据。
如果网络请求完全成功,它会将网络响应结果存入数据库并且重新初始化数据流。如果网络请求失败,我们直接分发失败消息出去。
注:在将新数据保存到磁盘后,我们重新初始化来自数据库的流,虽然通常我们不需要做这些,因为数据库会将这个改变分发出去。另一方面,依赖数据库来分发数据变化也可能引入一些坏的副作用。因为如果在数据没有变化的情况下,数据库可以避免分发变化,这样分发就中断了。 我们也不想将从网络上获取的数据分发出去,因为这样违背了数据单一原则(万一数据库有一些触发器,当有数据存入时,值已经被改变了呢)。我们也不想在没有新数据的时候将
SUCCESS
分发出去,因为可能会发送错误的信息到客户端。
下面是NetworkBoundResource
类为它的子类提供的一些公共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 loadFromDb();
// Called to create the API call.
@NonNull @MainThread
protected abstract LiveData> 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> getAsLiveData();
}
注意上面这个类定义了两个泛型参数(ResultType
, RequestType
) 因为从API中返回的数据类型可能与本地的数据类型不匹配。
尽管注意到上面的代码使用ApiResponse
来进行网络请求。ApiResponse
是Retrofit2.Call
的一个简单的包裹类,主要将它的返回转换成一个LiveData。
下面是NetworkBoundResource
这个类实现的剩余部分
public abstract class NetworkBoundResource<ResultType, RequestType> {
private final MediatorLiveData> result = new MediatorLiveData<>();
@MainThread
NetworkBoundResource() {
result.setValue(Resource.loading(null));
LiveData 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 dbSource) {
LiveData> 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 response) {
new AsyncTask() {
@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> getAsLiveData() {
return result;
}
}
现在,我们可以使用NetworkBoundResource
来实现我们的磁盘和网络绑定user 了。
class UserRepository {
Webservice webservice;
UserDao userDao;
public LiveData> loadUser(final String userId) {
return new NetworkBoundResource() {
@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 loadFromDb() {
return userDao.load(userId);
}
@NonNull @Override
protected LiveData> createCall() {
return webservice.getUser(userId);
}
}.getAsLiveData();
}
}