理解Android Architecture Components系列(二)

第一期的文章比较匆忙,遗留了好多问题。最明显的一个是ViewModel如何获取详细的个人信息。假设用户信息是从网络获取,那么我们调用后台接口即可获取数据。如果后台是REST API的,使用Retrofit作为网络请求就再合适不过了。

至于什么是REST API,简言之就是把后台所有请求看成是一种对资源的操作。比如关注一个人就是@Post把被关注的人的信息放到我的关注列表里,取关一个人就是@Delete从我的关注列表里删除这个人的资源。而这些接口的api都是相同,参数也是一样的,不同的访问方式不同,产生不同的效果。更好的的解释可以点击链接

获取数据

那好,让我们用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里面获取数据,然后根据LiveData展示数据呢?这样做是没有错的,但是随着项目的增长,这会让项目变得难以维护。比如现在的问题来了(这个例子不一定合理),如果在查询网络之前我想先查询下本地数据库中有没有这个用户的信息,那我就要在ViewModel中这段代码之前添加查询数据库的操作,这样ViewModel的代码就会变得非常臃肿。更重要的是这违反了上面提到的Soc原则,ViewModel承担了太多的功能。此外ViewModel的作用域是和Activity/Fragment绑定的,如果Activity/Fragment正常退出,相应的ViewModel就会释放掉相关的数据,当用户再次进入这个页面就会再次进行网络请求,这显然在某种程度上是浪费的。所以View的生命周期一结束就失去了所有数据,这在用户体验上会造成困扰。那怎么办呢?

把这部分功能通过一个新的Repository类代理执行。这个Repository类的作用就是获取并提供各种来源的数据(数据库中的数据,网络数据,缓存数据等),并且在来源数据更新时通知数据的获取方。

可以这样来写UserRepository

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;
    }
}

目前来看就是把网络请求的代码从ViewModel中移到了UserRepository中而已,似乎显得非常多余。但是考虑到数据来源的多重性,这样做可以简化ViewModel中的代码。更重要的是他抽象出了数据来源这一层,使更换数据来源变得非常简单(参考上面那个不一定合理的请求用户信息的例子),对于ViewModel来说只是获取到了数据,并不知道数据的是从网络获取的还是从本地获取的。

上面的代码中为了简单起见忽略了错误处理的情况,这在实际应用中是不合理的。详细的解决方案在后面的文章中有提供,先让我们跳过这一部分。

管理组件间的依赖关系

从上面的代码不难看出UserRepository里需要WebService这个类来完成网络请求。所以就要在UserRepository里创建WebService这个类,这就会带来很多麻烦,假如WebService的构造函数变了,那么用到WebService的地方都需要更改,这会让代码变得难以维护。

有两个解决这个问题的办法:

连接ViewModel和Repository

接下来使用Dagger2和UserRepository来提供数据来源。代码如下:

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;
    }
}

缓存数据

UserRepository很好的解决了数据层抽象的问题,但是每次请求数据都只有网络请求一种方法,会显得功能上有点单一。而且ViewModel每请求一次用户信息都会重新请求网络一次,即使是已经请求过的用户信息。这会造成两个问题:

  • 浪费流量
  • 让用户有不必要的等待

为了解决这个问题,可以在UserRepository里添加内存级的缓存UserCache来解决这个问题。
代码如下:

@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,那么这些数据也就被销毁了。隔了一段时间用户重新打开相同的页面仍然需要从网络获取相同的数据。解决这个问题的最好办法就是提供本地持久化的数据Model。说了这么多,终于到了Android Architecture Components的另一重要内容:Room

Room is an object mapping library that provides local data persistence with minimal boilerplate code. At compile time, it validates each query against the schema, so that broken SQL queries result in compile time errors instead of runtime failures. Room abstracts away some of the underlying implementation details of working with raw SQL tables and queries. It also allows observing changes to the database data (including collections and join queries), exposing such changes via LiveData objects. In addition, it explicitly defines thread constraints that address common issues such as accessing storage on the main thread.

简略一下就是:Room是一个对象映射库,(姑且按GreenDAO的功能来理解吧)可以在在编译的时候就能检测出SQL语句的异常。还能够在数据库内容发生改变时通过LiveData的形式发出通知(想想这有什么用?一个例子就是通知列表,比如App调某个接口可以返回消息通知,App需要把返回的数据存储在本地,方便以后查看。那么问题来了,在显示服务器返回的数据之前,还需要把之前的数据展示在界面上。之前用过的做法是先查数据库展示->请求网络->把网络请求的数据插到最前面->把网络请求的数据存储在数据库中,光是看这个做法就知道这里的代码多到想吐,用Room就可以很好的解决这个问题,当然其他ORM框架有类似的解决方案。Room可以使过程大大简化,查询显示->请求网络->本地存储->收到改变通知,显示新的数据(自动发生,不需要人为操作)。

那么来看看如何使用Room吧。以上面用户信息为例:

  1. 定义User类,并且使用@Entity注解:
@Entity
class User {
  @PrimaryKey
  private int id;
  private String name;
  private String lastName;
  // getters and setters for fields
}
  1. 创建RoomDatabase:
@Database(entities = {User.class}, version = 1)
public abstract class MyDatabase extends RoomDatabase {
}
  1. 创建DAO
@Dao
public interface UserDao {
    @Insert(onConflict = REPLACE)
    void save(User user);
    @Query("SELECT * FROM user WHERE id = :userId")
    LiveData<User> load(String userId);
}
  1. 在RoomDatabase中访问DAO
@Database(entities = {User.class}, version = 1)
public abstract class MyDatabase extends RoomDatabase {
    public abstract UserDao userDao();
}

注意UserDaoload()方法返回的是LiveData<User>。这就是为什么Room能够在数据库内容发生改变的时候通知对数据库内容感兴趣的类。

现在可以把Room和UserRepository结合起来:

@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.(从数据库中直接返回LiveData)
        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作任何更改。这就是抽象出了数据层的灵活性。

现在基本上就解决之前提到的一些问题。如果已经查看过某个用户的数据,那么再次打开这个用户的页面时,数据可以瞬间被展示。如果数据过期的话,还可以立刻去更新数据。当然,如果你规定了数据过期就不允许展示,也可以展示从网络获取的数据。

在某些情况下,需要显示是否本次网络请求的进度(是否完成),比如下拉刷新,需要根据网络请求的进度展示相应的UI。最好把UI显示和实际的数据来源分开,因为数据可能会因为各种原因被更新。(实际上我也没有很好的理解这段话,下面贴下这段话的原文:In some use cases, such as pull-to-refresh, it is important for the UI to show the user if there is currently a network operation in progress. It is good practice to separate the UI action from the actual data since it might be updated for various reasons (for example, if we fetch a list of friends, the same user might be fetched again triggering a LiveData<User> update). From the UI's perspective, the fact that there is a request in flight is just another data point, similar to any other piece data (like the User object).

数据来源的唯一性

在上面提供的代码中,数据库是App数据的唯一来源。Google推荐采用这种方式。

最终的架构形态

下面这张图展示了使用Android Architecture Components来构建的App整体的架构:


image.png

一些App架构设计的推荐准则

  • 不要把在Manifest中定义的组件作为提供数据的来源(包括Activity、Services、Broadcast Receivers等),因为他们的生命周期相对于App的生命周期是相对短暂的。
  • 严格的限制每个模块的功能。比如上面提到的不要再ViewModel中增加如何获取数据的代码。
  • 每个模块尽可能少的对外暴露方法。
  • 模块中对外暴露的方法要考虑单元测试的方便。
  • 不要重复造轮子,把精力放在能够让App脱颖而出的业务上。
  • 尽可能多的持久化数据,因为这样即使是在网络条件不好的情况下,用户仍然能够使用App
  • 保证数据来源的唯一性(即:提供一个类似UserRepository的类)

附加建议:在网络请求过程中提供网络请求的状态

在上面的文章中,刻意忽略了网络错误和处于loading状态的处理。下面的代码将会提供一个包含网络状态的示例代码。

//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);
    }
}

下面这幅图展示请求数据的通用流程

image.png

总体的思路就是:请求数据之前先查看本地有没有数据,再根据本地数据的状态决定是否进行网络操作。网络操作回来的数据缓存在本地。

根据流程图的思路可以抽象出NetworkBoundResource这个类:

// 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.(创建API)
    @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.(获取LiveData形式的数据)
    public final LiveData<Resource<ResultType>> getAsLiveData();
}

和数据获取相关的类只要继承这个类,就能按照上面流程图高效获取数据。值得注意的是NetworkBoundResource<ResultType, RequestType>有两种数据类型,这是因为服务端返回的数据类型和本地存储的数据类型可能是不一致的。(再来一个不一定合理的例子,以用户信息为例,服务器为了扩展考虑返回的User会包含一个年龄字段,但是本地对这个字段并没有任何用处,就没有必要存储,这样一来就会出现不一致的情况

代码中的ApiResponse是对Retrofit2.Call的包装,主要的作用是把call类型转换成LiveData。

下面是NetworkBoundResource的详细实现:

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;
    }
}

现在我们就可以在UserRepository里通过NetworkBoundResource来获取并且缓存User信息:

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();
    }
}

通过上面的文章,大致可以建立起对整个Android Architecture Components的认识,同时也能窥见Google在设计这套架构后面的思想。整体介绍的文章已经结束了,在接下来的文章中,会详细的介绍涉及的各个类。

相关文章:
理解Android Architecture Components系列(一)
理解Android Architecture Components系列(二)
理解Android Architecture Components系列之Lifecycle(三)
理解Android Architecture Components系列之LiveData(四)
理解Android Architecture Components系列之ViewModel(五)
理解Android Architecture Components系列之Room(六)
理解Android Architecture Components系列之Paging Library(七)
理解Android Architecture Components系列之WorkManager(八)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,602评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,442评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,878评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,306评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,330评论 5 373
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,071评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,382评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,006评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,512评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,965评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,094评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,732评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,283评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,286评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,512评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,536评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,828评论 2 345

推荐阅读更多精彩内容