RxJava2 實戰知識梳理(8) 使用 publish + merge 優化先載入快取,再讀取網路資料的請求過程

澤毛發表於2017-12-21

一、前言

在很多資訊應用當中,當我們進入一個新的頁面,為了提升使用者體驗,不讓頁面空白太久,我們一般會先讀取快取中的資料,再去請求網路。

今天這篇文章,我們將實現下面這個效果:同時發起讀取快取、訪問網路的請求,如果快取的資料先回來,那麼就先展示快取的資料,而如果網路的資料先回來,那麼就不再展示快取的資料。

為了讓大家對這一過程有更深刻的理解,我們介紹"先載入快取,再請求網路"這種模型的四種實現方式,其中第四種實現可以達到上面我們所說的效果,而前面的三種實現雖然也能夠實現相同的需求,並且可以正常工作,但是在某些特殊情況下,會出現意想不到的情況:

  • 使用concat實現
  • 使用concatEager實現
  • 使用merge實現
  • 使用publish實現

二、示例

2.1 準備工作

我們需要準備兩個Observable,分別表示 快取資料來源網路資料來源,在其中填入相應的快取資料和網路資料,為了之後演示一些特殊的情況,我們可以在建立它的時候指定它執行的時間:

   //模擬快取資料來源。
    private Observable<List<NewsResultEntity>> getCacheArticle(final long simulateTime) {
        return Observable.create(new ObservableOnSubscribe<List<NewsResultEntity>>() {
            @Override
            public void subscribe(ObservableEmitter<List<NewsResultEntity>> observableEmitter) throws Exception {
                try {
                    Log.d(TAG, "開始載入快取資料");
                    Thread.sleep(simulateTime);
                    List<NewsResultEntity> results = new ArrayList<>();
                    for (int i = 0; i < 10; i++) {
                        NewsResultEntity entity = new NewsResultEntity();
                        entity.setType("快取");
                        entity.setDesc("序號=" + i);
                        results.add(entity);
                    }
                    observableEmitter.onNext(results);
                    observableEmitter.onComplete();
                    Log.d(TAG, "結束載入快取資料");
                } catch (InterruptedException e) {
                    if (!observableEmitter.isDisposed()) {
                        observableEmitter.onError(e);
                    }
                }
            }
        });
    }
    //模擬網路資料來源。
    private Observable<List<NewsResultEntity>> getNetworkArticle(final long simulateTime) {
        return Observable.create(new ObservableOnSubscribe<List<NewsResultEntity>>() {
            @Override
            public void subscribe(ObservableEmitter<List<NewsResultEntity>> observableEmitter) throws Exception {
                try {
                    Log.d(TAG, "開始載入網路資料");
                    Thread.sleep(simulateTime);
                    List<NewsResultEntity> results = new ArrayList<>();
                    for (int i = 0; i < 10; i++) {
                        NewsResultEntity entity = new NewsResultEntity();
                        entity.setType("網路");
                        entity.setDesc("序號=" + i);
                        results.add(entity);
                    }
                    observableEmitter.onNext(results);
                    observableEmitter.onComplete();
                    Log.d(TAG, "結束載入網路資料");
                } catch (InterruptedException e) {
                    if (!observableEmitter.isDisposed()) {
                        observableEmitter.onError(e);
                    }
                }
            }
        });
    }
複製程式碼

在最終的下游,我們接收資料,並在頁面上通過RecyclerView進行展示:

    private DisposableObserver<List<NewsResultEntity>> getArticleObserver() {
        return new DisposableObserver<List<NewsResultEntity>>() {

            @Override
            public void onNext(List<NewsResultEntity> newsResultEntities) {
                mNewsResultEntities.clear();
                mNewsResultEntities.addAll(newsResultEntities);
                mNewsAdapter.notifyDataSetChanged();
            }

            @Override
            public void onError(Throwable throwable) {
                Log.d(TAG, "載入錯誤, e=" + throwable);
            }

            @Override
            public void onComplete() {
                Log.d(TAG, "載入完成");
            }
        };
    }
複製程式碼

2.2 使用 concat 實現

concat是很多文章都推薦使用的方式,因為它不會有任何問題,實現程式碼如下:

   private void refreshArticleUseContact() {
        Observable<List<NewsResultEntity>> contactObservable = Observable.concat(
                getCacheArticle(500).subscribeOn(Schedulers.io()), getNetworkArticle(2000).subscribeOn(Schedulers.io()));
        DisposableObserver<List<NewsResultEntity>> disposableObserver = getArticleObserver();
        contactObservable.observeOn(AndroidSchedulers.mainThread()).subscribe(disposableObserver);
    }
複製程式碼

上面這段程式碼的執行結果為:

RxJava2 實戰知識梳理(8)   使用 publish + merge 優化先載入快取,再讀取網路資料的請求過程
從控制檯的輸出可以看到,整個過程是先取讀取快取,等快取的資料讀取完畢之後,才開始請求網路,因此整個過程的耗時為兩個階段的相加,即2500ms

RxJava2 實戰知識梳理(8)   使用 publish + merge 優化先載入快取,再讀取網路資料的請求過程
它的原理圖如下所示:
concat 原理圖
從原理圖中也驗證了我們前面的現象,它會連線多個Observable,並且必須要等到前一個Observable的所有資料項都傳送完之後,才會開始下一個Observable資料的傳送。

那麼,concat操作符的缺點是什麼呢?很明顯,我們白白浪費了前面讀取快取的這段時間,能不能同時發起讀取快取和網路的請求,而不是等到讀取快取完畢之後,才去請求網路呢?

2.3 使用 concatEager 實現

為了解決前面沒有同時發起請求的問題,我們可以使用concatEager,它的使用方法如下:

    private void refreshArticleUseConcatEager() {
        List<Observable<List<NewsResultEntity>>> observables = new ArrayList<>();
        observables.add(getCacheArticle(500).subscribeOn(Schedulers.io()));
        observables.add(getNetworkArticle(2000).subscribeOn(Schedulers.io()));
        Observable<List<NewsResultEntity>> contactObservable = Observable.concatEager(observables);
        DisposableObserver<List<NewsResultEntity>> disposableObserver = getArticleObserver();
        contactObservable.observeOn(AndroidSchedulers.mainThread()).subscribe(disposableObserver);
    }
複製程式碼

它和concat最大的不同就是多個Observable可以同時開始發射資料,如果後一個Observable發射完成後,前一個Observable還有發射完資料,那麼它會將後一個Observable的資料先快取起來,等到前一個Observable發射完畢後,才將快取的資料發射出去。

上面程式碼中,請求快取的時長改為500ms,而請求網路的時長改為2000ms,執行結果為:

RxJava2 實戰知識梳理(8)   使用 publish + merge 優化先載入快取,再讀取網路資料的請求過程
那麼這種實現方式的缺點是什麼呢?就是在某些異常情況下,如果讀取快取的時間要大於網路請求的時間,那麼就會導致出現“網路請求的結果”等待“讀取快取”這一過程完成後才能傳遞給下游,白白浪費了一段時間。

我們將請求快取的時長改為2000ms,而請求網路的時長改為500ms,檢視控制檯的輸出,可以驗證上面的結論:

RxJava2 實戰知識梳理(8)   使用 publish + merge 優化先載入快取,再讀取網路資料的請求過程

2.4 使用 merge 實現

下面,我們來看一下merge操作符的示例:

    private void refreshArticleUseMerge() {
        Observable<List<NewsResultEntity>> contactObservable = Observable.merge(
                getCacheArticle(500).subscribeOn(Schedulers.io()), getNetworkArticle(2000).subscribeOn(Schedulers.io()));
        DisposableObserver<List<NewsResultEntity>> disposableObserver = getArticleObserver();
        contactObservable.observeOn(AndroidSchedulers.mainThread()).subscribe(disposableObserver);
    }
複製程式碼

merge的原理圖如下所示:

merge 原理圖
它和concatEager一樣,會讓多個Observable同時開始發射資料,但是它不需要Observable之間的互相等待,而是直接傳送給下游。

當快取時間為500ms,而請求網路時間為2000ms時,它的結果為:

RxJava2 實戰知識梳理(8)   使用 publish + merge 優化先載入快取,再讀取網路資料的請求過程
在讀取快取的時間小於請求網路的時間時,這個操作符能夠很好的工作,但是反之,就會出現我們先展示了網路的資料,然後又被重新整理成舊的快取資料。
RxJava2 實戰知識梳理(8)   使用 publish + merge 優化先載入快取,再讀取網路資料的請求過程
發生該異常時的現象如下所示:
RxJava2 實戰知識梳理(8)   使用 publish + merge 優化先載入快取,再讀取網路資料的請求過程

2.5 使用 publish 實現

使用publish的實現如下所示:

    private void refreshArticleUsePublish() {
        Observable<List<NewsResultEntity>> publishObservable = getNetworkArticle(2000).subscribeOn(Schedulers.io()).publish(new Function<Observable<List<NewsResultEntity>>, ObservableSource<List<NewsResultEntity>>>() {

            @Override
            public ObservableSource<List<NewsResultEntity>> apply(Observable<List<NewsResultEntity>> network) throws Exception {
                return Observable.merge(network, getCacheArticle(500).subscribeOn(Schedulers.io()).takeUntil(network));
            }

        });
        DisposableObserver<List<NewsResultEntity>> disposableObserver = getArticleObserver();
        publishObservable.observeOn(AndroidSchedulers.mainThread()).subscribe(disposableObserver);
    }
複製程式碼

這裡面一共涉及到了三個操作符,publishmergetakeUnti,我們先來看一下它能否解決我們之前三種方式的缺陷:

  • 讀取快取的時間為500ms,請求網路的時間為2000ms

RxJava2 實戰知識梳理(8)   使用 publish + merge 優化先載入快取,再讀取網路資料的請求過程

  • 讀取快取的時間為2000ms,請求網路的時間為500ms

RxJava2 實戰知識梳理(8)   使用 publish + merge 優化先載入快取,再讀取網路資料的請求過程
可以看到,在讀取快取的時間大於請求網路時間的時候,僅僅只會展示網路的資料,顯示效果為:
RxJava2 實戰知識梳理(8)   使用 publish + merge 優化先載入快取,再讀取網路資料的請求過程
並且讀取快取和請求網路是同時發起的,很好地解決了前面幾種實現方式的缺陷。

這裡要感謝簡友 無心下棋 在評論裡提到的問題:如果網路請求先返回時發生了錯誤(例如沒有網路等)導致傳送了onError事件,從而使得快取的Observable也無法傳送事件,最後介面顯示空白。

針對這個問題,我們需要對網路的Observable進行優化,讓其不將onError事件傳遞給下游。其中一種解決方式是通過使用onErrorResume操作符,它可以接收一個Func函式,其形參為網路傳送的錯誤,而在上游發生錯誤時會回撥該函式。我們可以根據錯誤的型別來返回一個新的Observable,讓訂閱者映象到這個新的Observable,並且忽略onError事件,從而避免onError事件導致整個訂閱關係的結束。

這裡為了避免訂閱者在映象到新的Observable時會收到額外的時間,我們返回一個Observable.never(),它表示一個永遠不傳送事件的上游。

    private Observable<List<NewsResultEntity>> getNetworkArticle(final long simulateTime) {
        return Observable.create(new ObservableOnSubscribe<List<NewsResultEntity>>() {
            @Override
            public void subscribe(ObservableEmitter<List<NewsResultEntity>> observableEmitter) throws Exception {
                try {
                    Log.d(TAG, "開始載入網路資料");
                    Thread.sleep(simulateTime);
                    List<NewsResultEntity> results = new ArrayList<>();
                    for (int i = 0; i < 10; i++) {
                        NewsResultEntity entity = new NewsResultEntity();
                        entity.setType("網路");
                        entity.setDesc("序號=" + i);
                        results.add(entity);
                    }
                    //a.正常情況。
                    //observableEmitter.onNext(results);
                    //observableEmitter.onComplete();
                    //b.發生異常。
                    observableEmitter.onError(new Throwable("netWork Error"));
                    Log.d(TAG, "結束載入網路資料");
                } catch (InterruptedException e) {
                    if (!observableEmitter.isDisposed()) {
                        observableEmitter.onError(e);
                    }
                }
            }
        }).onErrorResumeNext(new Function<Throwable, ObservableSource<? extends List<NewsResultEntity>>>() {

            @Override
            public ObservableSource<? extends List<NewsResultEntity>> apply(Throwable throwable) throws Exception {
                Log.d(TAG, "網路請求發生錯誤throwable=" + throwable);
                return Observable.never();
            }
        });
    }
複製程式碼

當發生錯誤時,控制檯的輸出如下,可以看到快取仍然正常地傳送給了下游:

RxJava2 實戰知識梳理(8)   使用 publish + merge 優化先載入快取,再讀取網路資料的請求過程

下面,我們就來分析一下它的實現原理。

2.5.1 takeUntil

takeUntil的原理圖如下所示:

RxJava2 實戰知識梳理(8)   使用 publish + merge 優化先載入快取,再讀取網路資料的請求過程
這裡,我們給sourceObservable通過takeUntil傳入了另一個otherObservable,它表示sourceObservableotherObservable發射資料之後,就不允許再發射資料了,這就剛好滿足了我們前面說的“只要網路源傳送了資料,那麼快取源就不應再發射資料”。

之後,我們再用前面介紹過的merge操作符,讓兩個快取源和網路源同時開始工作,去取資料。

2.5.2 publish

但是上面有一點缺陷,就是呼叫mergetakeUntil會發生兩次訂閱,這時候就需要使用publish操作符,它接收一個Function函式,該函式返回一個Observable,該Observable是對原Observable,也就是上面網路源的Observable轉換之後的結果,該Observable可以被takeUntilmerge操作符所共享,從而實現只訂閱一次的效果。

publish的原理圖如下所示:

publish 原理圖


更多文章,歡迎訪問我的 Android 知識梳理系列:

相關文章