1. 程式人生 > >RxJava2.0在安卓中的二級快取策略

RxJava2.0在安卓中的二級快取策略

前言

在上一篇 安卓網路資料快取策略 中,介紹了安卓中資料的快取策略,這篇將用RxJava2.0 實現 Json/Xml 資料的二級快取。
對於 RxJava2.0 不瞭解的,可以看一下這篇入門教程 從零開始的RxJava2.0教程1-4

彷彿有一段時間沒寫部落格了,嚇得我都祭出了神圖。

資料實時性高

為了便於沒有看過上一篇教程的同學理解,我先把虛擬碼再貼一次。

如果 (存在快取) {
    讀取快取並顯示
}
請求網路
寫入快取
顯示網路資料

上篇提到過,如果快取可用,請求網路的時候,不應該顯示正在載入的介面,網路請求失敗的時候,也不應該顯示錯誤介面。

為了優雅的實現這樣一個多分支邏輯,我們需要用到 concat

操作符,和 1.x 中一樣,將兩個發射源按順序連線成一個,這樣先顯示快取,後顯示網路資料的需求就完美的解決了。
不過需要注意的是,RxJava2.0 和 1.x 不一樣, 所有的操作符都不能接收 null,所以,需要對快取發射源和網路發射源進行一些額外的處理。

1. concat 連線快取和網路資料

先給出最簡單的程式碼,這段程式碼能實現基本功能,但在介面顯示上會有一些邏輯問題,這個問題我們後面再解決。

Flowable.concat(localRepo.getHome(index), remoteRepo.getHome(index))
        .subscribe(new
Consumer<AppListBean>() { @Override public void accept(AppListBean appListBean) throws Exception { getView().setData(appListBean); } }, new Consumer<Throwable>() { @Override public void accept(Throwable throwable) throws
Exception { getView().showError(throwable, pullToRefresh); } });

可以看到,通過 concat 連線了本地和遠端的資料來源。成功或失敗就通知介面顯示。
我們在跟進 localReporemoteRepo 看一下如何處理 null 問題。

對於本地的源,是一個很簡單的從檔案讀取資料,然後生成一個 Flowable,但需要注意的是,但檔案不存在或資料有問題時,不能返回 null,相應的,我們返回一個空的發射源,也就是什麼都不會發射的 Flowable。一個是避免 concat 收到 null 而丟擲異常,另一個是方便後面邏輯判斷。

public Flowable<AppListBean> getHome(@Query("index") int index) {
    return RxUtils.fromCache(cacheDir, "home" + index, AppListBean.class)
            .compose(RxUtils.<AppListBean>netScheduler());
}

// 從檔案讀取資料,並生成 Flowable
public static <T> Flowable<T> fromCache(final File dir, final String name, Class<T> type) {
    try {
        Gson gson = new Gson();
        T t = gson.fromJson(new FileReader(FileUtils.getJson(dir, name)), type);
        return Flowable.just(t);
    } catch (FileNotFoundException e) {
        e.printStackTrace();
    }
    return Flowable.empty();
}

對於遠端的源,主要是對 retrofit 轉換生成的 Flowable 進行出錯攔截。

@Override
public Flowable<AppListBean> getHome(@Query("index") final int index) {
    return api.getHome(index)// retrofit轉換得到的Flowable
            .compose(RxUtils.<AppListBean>netScheduler())// subscribeOn io observeOn mainThread
            .compose(RxUtils.<AppListBean>cache(FileUtils.getJson(cacheDir, "home" + index), 0 == index));// 快取到本地,以及出錯攔截。
}

// 快取到本地,以及出錯攔截
public static <T> FlowableTransformer<T, T> cache(final File file, final boolean isCache) {
    return new FlowableTransformer<T, T>() {
        @Override
        public Publisher<T> apply(Flowable<T> upstream) {
            return upstream.doOnNext(new Consumer<T>() {//獲取資料成功時,快取到本地
                @Override
                public void accept(T t) throws Exception {
                    if (isCache) {
                        Gson gson = new Gson();
                        String json = gson.toJson(t, t.getClass());
                        FileUtils.saveFileWithString(file, json);
                        Logger.d("cache success " + file);
                    }
                }
            }).onErrorResumeNext(new Function<Throwable, Publisher<? extends T>>() {// 出錯攔截,當出現錯誤時,返回一個新的源而不是呼叫onError
                @Override
                public Publisher<? extends T> apply(Throwable throwable) throws Exception {
                    return Flowable.empty();// 這裡返回一個空的發射源
                }
            });
        }
    };
}

這裡我重點解釋一下出錯攔截,如果這裡不呼叫 onErrorResumeNext 操作符,那麼,當網路訪問出錯時,就會走 getView().showError(throwable, pullToRefresh); 這段 onError 邏輯,這樣,只要網路出錯,無論是否有本地快取,介面都將顯示一個錯誤,這顯然不是我們要的,所以,這裡對遠端資料來源進行出錯攔截,一旦出錯,就返回一個空的發射源。

2. 本地和遠端都為空發射源的邏輯處理

上面那段程式碼似乎能起到我們想要的效果了,但一測試就會發現,當本地沒有快取,網路請求失敗時,兩者返回的都是空的發射源,也就是,介面既不顯示資料,也不會顯示出錯。這顯然不行,所以,我們還需要對 連線後的源進行非空檢測。

Flowable.concat(localRepo.getHome(index), remoteRepo.getHome(index))
        .switchIfEmpty(new Flowable<AppListBean>() {// 空資料檢測
            @Override
            protected void subscribeActual(Subscriber<? super AppListBean> s) {
                s.onError(new NoSuchElementException());
            }
        })
        .subscribe(new Consumer<AppListBean>() {
            @Override
            public void accept(AppListBean appListBean) throws Exception {
                getView().setData(appListBean);
            }
        }, new Consumer<Throwable>() {
            @Override
            public void accept(Throwable throwable) throws Exception {
                getView().showError(throwable, pullToRefresh);
            }
        });

這段程式碼與上段程式碼相比,只多了一個 switchIfEmpty 操作符,這個操作符的作用是,當發射源沒有傳送任何資料時,就會進入到該邏輯。在這個邏輯中,我們呼叫 s.onError(new NoSuchElementException()); 來進入到錯誤分支,這樣就可以使介面顯示出錯資訊了。

資料定期更新或不頻繁變化

同樣先把虛擬碼貼出來。

如果 (存在快取 且 快取未過期) {
    讀取快取並顯示
    返回
}
請求網路
更新快取
顯示最新資料

有了上面一類快取的基礎,處理這個就容易多了。

Flowable.concat(localRepo.getHome(index), remoteRepo.getHome(index))
.firstOrError()// 最多發射一個數據,如果沒有資料,則走 onError
.subscribe(new Consumer<AppListBean>() {
    @Override
    public void accept(AppListBean appListBean) throws Exception {
        getView().setData(appListBean);
    }
}, new Consumer<Throwable>() {
    @Override
    public void accept(Throwable throwable) throws Exception {
        getView().showError(throwable, pullToRefresh);
    }
});

與上面明顯區別是, switchIfEmpty 換成了 firstOrError
註釋上已經解釋清楚了,這裡詳細介紹一下 localReporemoteRepo 裡面的一些不同之處。

先看本地的發射源,與之前不同的是,多了一個 filter 操作符,這個是用過濾過期資料的,為了便於記錄資料的過期時間,我在 bean 中加了一個 cacheTime 表示快取的時間戳。

public Flowable<AppListBean> getHome(@Query("index") final int index) {
    return RxUtils.fromCache(cacheDir, "home" + index, AppListBean.class)
            .compose(RxUtils.<AppListBean>netScheduler())
            .filter(new Predicate<AppListBean>() {// 遮蔽過期資料
                @Override
                public boolean test(AppListBean appListBean) throws Exception {
                    if (appListBean.cacheTime + CACHE_TIME < System.currentTimeMillis()) {// 已經過期
                        // clean cache
                        RxUtils.cleanCache(FileUtils.getJson(cacheDir, "home" + index));
                        return false;
                    }
                    return true;
                }
            });
}

然後再看遠端資料來源,多了一個 doOnNext 操作符,這個是在寫入快取前,把當前的時間存到 bean 中去。

@Override
public Flowable<AppListBean> getHome(@Query("index") final int index) {
    return api.getHome(index)
            .doOnNext(new Consumer<AppListBean>() {
                @Override
                public void accept(AppListBean appListBean) throws Exception {
                    appListBean.cacheTime = System.currentTimeMillis();
                }
            })
            .compose(RxUtils.<AppListBean>netScheduler())
            .compose(RxUtils.<AppListBean>cache(FileUtils.getJson(cacheDir, "home" + index), 0 == index));
}

到此為止,RxJava2.0 的快取實現已經介紹完了,這裡給出的只是鄙人的一些見解,如果你有更好的方案,隨時歡迎交流。

關於RxCache

RxCache 是一個很優秀的安卓資料快取庫,用在實際專案中,可以節省不少開發時間。我不推崇重複造輪子,但原理性的東西不能完全不知道,所以在最後給大家推薦這樣一個庫,希望能給你的開發帶來幫助。

1. 不適合資料實時性高的策略

需要注意的是,RxCache 並不適合 資料實時性高 的快取策略,因為它的載入機制如下:

請求資料(使用快取) {
    如果 (快取可用) {
        使用快取資料
        返回  
    }
    請求網路資料
    儲存快取
}

請求資料(不使用快取) {
    刪除快取
    請求網路資料
    儲存快取(如果請求失敗,就沒有快取了)
}

所以,要實現請求本地資料後,再請求網路資料就不是那麼容易。
當然也不是完全不行。經過我一下午的除錯,最終整合出一個勉強可用的實現。

對於 Providers 的定義,返回的內容用 Reply 包裹,這樣就可以知道返回的資料是來自網路還是快取。

Observable<Reply<AppListBean>> getHome(Observable<AppListBean> home, DynamicKey index, EvictDynamicKey update);

使用快取請求資料來源,然後是利用 flatMap 中途修改發射源。
如果資料來自快取,則給發射源連線一個網路請求的發射源。

public Observable<AppListBean> getHome(final int index) {
    Observable<Reply<AppListBean>> local = providers.getHome(api.getHome(index), new DynamicKey(index), new EvictDynamicKey(false))
            .flatMap(new Function<Reply<AppListBean>, ObservableSource<Reply<AppListBean>>>() {// 中途根據情況修改發射源
                @Override
                public ObservableSource<Reply<AppListBean>> apply(Reply<AppListBean> appListBeanReply) throws Exception {
                            Logger.d("get cache success");
                    Observable<Reply<AppListBean>> cache = Observable.just(appListBeanReply);
                    if (appListBeanReply.getSource() != Source.CLOUD// 資料來自快取,則需要再加一個網路的請求
                                    && NetworkUtils.isAvailableByPing(BaseApplication.getContext())) {// 網路可用時才請求
                        // concat a remote request
                        Observable<Reply<AppListBean>> remote = providers.getHome(api.getHome(index), new DynamicKey(index), new EvictDynamicKey(true))
                                .onErrorResumeNext(new Function<Throwable, ObservableSource<? extends Reply<AppListBean>>>() {// 網路請求出錯時,返回一個空發射源,而不是走onError
                                    @Override
                                    public ObservableSource<? extends Reply<AppListBean>> apply(Throwable throwable) throws Exception {
                                        return Observable.empty();
                                    }
                                });
                        return Observable.concat(cache, remote).distinct();
                    }
                    return cache;
                }
            });
    return local.map(new Function<Reply<AppListBean>, AppListBean>() {// 轉換成資料
        @Override
        public AppListBean apply(Reply<AppListBean> appListBeanReply) throws Exception {
            return appListBeanReply.getData();
        }
    }).compose(RxUtils.<AppListBean>netScheduler());
}

這樣處理之後,正常情況下,無論快取是否可用,都會請求一次網路。這樣就達到了我們想要的目的。
但有個特殊情況,當快取可用時,我們附加了更新資料的請求,雖然網路已經被驗證過可用,但並不能保證一定訪問成功,一旦出錯,我們就會失去快取。因為在請求網路前,快取就被刪除了,而請求失敗時,不會生成快取。

但這種特殊情況很少出現,可能伺服器異常,又或者網路請求還未完成時,突然沒網了。
所以我稱之為勉強可以接受的實現。

從這修改的工作量來看,自己實現快取策略或許更加合適。

2. 適合資料定期更新或不頻繁變化

對於這類快取策略,RxCache支援的非常好,你可以通過註解設定過期時間,是否加密等。
你可以放心的呼叫不使用快取的請求方法,當過期或者沒有快取的時候,會自動請求網路資料。
簡直不要太舒服→_→