RxJava 沉思錄(二):空間維度

prototypez發表於2018-09-05

本文是 "RxJava 沉思錄" 系列的第二篇分享。本系列所有分享:

在上一篇分享中,我們澄清了目前有關 RxJava 的幾個最流行的誤解,它們分別是:“鏈式程式設計是 RxJava 的厲害之處”,“RxJava 等於非同步加簡潔”,“RxJava 是用來解決 Callback Hell 的”。在上一篇的最後,我們瞭解了 RxJava 其實給我們最基礎的功能就是幫我們統一了所有非同步回撥的介面。但是 RxJava 並不止於此,本文我們將首先介紹 Observable 在空間維度上重新組織事件的能力

從一個簡單的例子說起

情景:有一個相簿應用,從網路獲取當前使用者的照片列表,展示在 RecyclerView 裡:

public interface NetworkApi {
    @GET("/path/to/api")
    Call<List<Photo>> getAllPhotos();
}
複製程式碼

上面是使用 Retrofit 定義的從網路獲取照片的 API 的介面。大家都知道,如果我們使用 Retrofit 的 RxJavaCallAdapter 就可以把介面中的返回型別從 Call<List<Photo>> 轉為 Observable<List<Photo>>:

public interface NetworkApi {
    @GET("/path/to/api")
    Observable<List<Photo>> getAllPhotos();
}
複製程式碼

那麼我們使用這個介面展示照片的程式碼應該長下面這樣:

NetworkApi networkApi = ...
networkApi.getAllPhotos()
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(photos -> {
        adapter.setData(photos);
        adapter.notifyDataSetChanged();
    });
複製程式碼

現在新加一個需求,請求當前使用者照片列表這個網路請求,需要加入快取功能(快取的是網路響應中的圖片的URL,圖片的 Bitmap 快取交給專門的圖片載入框架,例如 Glide),也就是說,當使用者希望展示圖片列表時,先去快取讀取使用者的照片列表進行載入(如果快取裡有這個介面的上次訪問的資料),同時發起網路請求,待網路請求返回之後,更新快取,同時使用使用最新的返回資料重新整理照片列表。如果我們選擇使用 JakeWhartonDiskLruCache 作為我們的快取介質,那麼上面的程式碼將變為:

DiskLruCache cache = ... 
DiskLruCache.Snapshot snapshot = cache.get("getAllPhotos");
if (snapshot != null) {
    // 讀取快取資料並反序列化
    List<Photo> cachedPhotos = new Gson().fromJson(
        snapshot.getString(VALUE_INDEX),
        new TypeToken<List<Photo>>(){}.getType()
    );
    // 重新整理照片列表
    adapter.setData(photos);
    adapter.notifyDataSetChanged();
}
NetworkApi networkApi = ...
networkApi.getAllPhotos()
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(photos -> {
        adapter.setData(photos);
        adapter.notifyDataSetChanged();

        // 更新快取
        DiskLruCache.Editor editor = cache.edit("getAllPhotos");
        editor.set(VALUE_INDEX, new Gson().toJson(photos)).commit();
    });
複製程式碼

上面的程式碼就是最直觀的可以解決需求的程式碼,我們進一步思考一下,讀取檔案快取也屬於耗時操作,我們最好把它封裝為非同步任務,既然網路請求已經被封裝成 Observable 了,我們嘗試把讀取檔案快取也封裝為 Observable :

Observable<List<Photo>> cachedObservable = Observable.create(emitter -> {
    DiskLruCache.Snapshot snapshot = cache.get("getAllPhotos");
    if (snapshot != null) {
        List<Photo> cachedPhotos = new Gson().fromJson(
            snapshot.getString(VALUE_INDEX),
            new TypeToken<List<Photo>>(){}.getType()
        );
        emitter.onNext(cachedPhotos);
    } 
    emitter.onComplete();
});
複製程式碼

到目前為止,發起網路請求和讀取快取這兩個非同步操作都被我們封裝成了 Observable 的形式,前面做了這麼多鋪墊,接下來進入正題:把原先的面向 Callback 的非同步操作統一改寫為 Observable 的形式以後,首先帶來的好處就是可以對 Observable 在空間維度上進行重新組織

networkApi.getAllPhotos()
    .doOnNext(photos -> 
        // 更新快取
        cache.edit("getAllPhotos")
            .set(VALUE_INDEX, new Gson().toJson(photos))
            .commit()
    )
    // 讀取現有快取
    .startWith(cachedObservable)
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(photos -> {
        adapter.setData(photos);
        adapter.notifyDataSetChanged();
    });
複製程式碼

呼叫 startWith 操作符後,會生成一個新的 Observable,新的 Observable 會首先發射傳入的 Observable 包含的元素,而後才會發射原來的 Observable 包含的元素。例如 Observable A 包含 a1, a2 兩個元素, Observable B 包含 b1, b2 兩個元素,那麼 b.startWith(a) 返回的新 Observable 發射序列順序為: a1, a2, b1, b2。—— 參考資料:StartWith

在上面的例子中,我們連線了網路請求和讀取快取這兩個 Observable,原先需要分別處理結果的兩個非同步任務,我們現在把它們結合成了一個,指定了一個觀察者就滿足了需求。這個觀察者會被回撥 2 次,第一次是來自快取的結果,第二次是來自網路的結果,體現在介面上就是列表重新整理了兩次。

這裡引發了我們的思考,原先 Callback 的寫法,如果我們有 n 個非同步任務,我們就需要指定 n 個回撥;而如果在 n 個非同步任務都已經被封裝成 Observable 的情況下,我們就可以對 Observable 進行分類、組合、變換,經過這樣的處理以後,我們的觀察者的數量就會減少,而且職責會變的簡單而直接,只需要對它所關心的資料型別做出響應,而不需要關心資料從何而來,經歷過怎樣的變化。

我們再進一步,上面的例子再加一個需求:如果從網路請求回來的資料和快取中提前響應的資料一致,就不需要再重新整理一次了。也就是說,如果快取資料和網路資料一致,那快取資料重新整理一次列表以後,網路資料不需要再去重新整理一次列表了。

我們考慮一下,如果我們使用傳統 Callback 的形式,指定了兩個 Callback 去處理這個需求,為了保證第二次網路請求回來的相同資料不重新整理,我們勢必需要在兩個 Callback 之外,定義一個變數來儲存快取資料,然後在網路請求的回撥內,比較兩個值,來決定是否需要重新整理介面。

但如果我們用 RxJava 如何來實現這個需求,該如何寫呢:

networkApi.getAllPhotos()
    .doOnNext(photos -> 
        cache.edit("getAllPhotos")
            .set(VALUE_INDEX, new Gson().toJson(photos))
            .commit()
    )
    .startWith(cachedObservable)
    // 保證不會出現相同資料
    .distinctUntilChanged()
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(photos -> {
        adapter.setData(photos);
        adapter.notifyDataSetChanged();
    });
複製程式碼

distinctUntilChanged 操作符用來確保 Observable 發射的元素裡,相鄰的兩個元素必須是不相等的。 參考資料:Distinct

與原先的寫法相比,只多了一行 .distinctUntilChanged() ( 我們假設用於比較兩個物件是否相等的 equals 方法已經實現 ),就可以滿足,在網路資料和快取資料一致的情況下,觀察者只回撥一次。

我們比較一下使用 Callback 的寫法和使用 Observable 進行組裝的寫法,可以發現,使用 Callback 的寫法,經常會由於需求的變化,導致 Callback 內部的邏輯發生變動,而使用 Observable 的寫法,觀察者的核心邏輯則較為穩定,很少發生變化(本例中為重新整理列表)。Observable 通過內建的操作符對自身發射的元素在空間維度上重新組織,或者與其他的 Observable 一起在空間維度上進行重新組織,使得觀察者的邏輯簡單而直接,不需要關心資料從何而來,從而使觀察者的邏輯較為穩定

一個複雜的例子

情景:實現一個具有多種型別的 RecyclerView,如圖所示:

RxJava 沉思錄(二):空間維度

假設列表中有 3 種型別的資料,這 3 種型別共同填充了一個 RecyclerView,簡單起見,我們定義 Retrofit 介面如下:

public interface NetworkApi {
    @GET("/path/to/api")
    Observable<List<ItemA>> getItemListOfTypeA();
    @GET("/path/to/api")
    Observable<List<ItemB>> getItemListOfTypeB();
    @GET("/path/to/api")
    Observable<List<ItemC>> getItemListOfTypeC();
}
複製程式碼

到目前為止,情況還是簡單的, 我只要維護 3 個 RecyclerView 並分別各自更新即可。但是我們現在接到新加需求,這 3 種型別的資料在列表中出現的順序是可配置的,而且 3 種型別資料不一定全部需要展示,也就是說可能展示 3 種,也可能只展示其中 2 種。我們定義與之對應的介面:

public interface NetworkApi {
    @GET("/path/to/api")
    Observable<List<ItemA>> getItemListOfTypeA();
    @GET("/path/to/api")
    Observable<List<ItemB>> getItemListOfTypeB();
    @GET("/path/to/api")
    Observable<List<ItemC>> getItemListOfTypeC();
    // 需要展示的資料順序
    @GET("/path/to/api")
    Observable<List<String>> getColumns();
}
複製程式碼

新加的 getColumns 介面,返回的資料形如:

  • ["a", "b", "c"]
  • ["b", "a"]
  • ["b", "c"]

首先考慮使用普通的 Callback 形式如何來實現這個需求。由於 3 種資料現在順序可變,數量也無法確定,如果還是考慮由多個 RecyclerView 來維護的話需要在佈局中呼叫 addView, removeView 來新增移除 RecyclerView,這樣的話效能上不夠好,我們考慮把所有資料填充到一個 RecyclerView 中,不同型別的資料通過不同 ItemType 進行區分。下面的程式碼中我依然使用了 Observable ,只是我僅僅把它當成普通的 Callback 功能使用:

private NetworkApi networkApi = ...
// 不同型別資料出現的順序
private List<String> resultTypes;
// 這些型別對應的資料的集合
private LinkedList<List<? extends Item>> responseList;

public void refresh() {
    networkApi.getColumns().subscribe(columns -> {
        // 儲存配置的欄目順序
        resultTypes = columns;
        responseList = new LinkedList<>(Collections.nCopies(columns.size(), new ArrayList<>()));
        for (String type : columns) {
            switch (type) {
                case "a":
                    networkApi.getItemListOfTypeA().subscribe(data -> onOk("a", data));
                    break;
                case "b":
                    networkApi.getItemListOfTypeB().subscribe(data -> onOk("b", data));
                    break;
                case "c":
                    networkApi.getItemListOfTypeC().subscribe(data -> onOk("c", data));
                    break;
            }
        }
    });
}

private void onOk(String type, List<? extends Item> response) {
    // 按配置的順序,更新對應位置上的資料
    responseList.set(resultTypes.indexOf(type), response);
    // 把當前已返回的資料填充到一個 List 中
    List<Item> data = new ArrayList<>();
    for (List<? extends Item> itemList: responseList) {
        data.addAll(itemList);
    }
    // 更新列表
    adapter.setData(data);
    adapter.notifyDataSetChanged();
}
複製程式碼

上面的程式碼,為了避免 Callback Hell 出現,我已經提前把 onOk 提到了外部層次,使程式碼便於從上往下閱讀。但是不知道你有沒有和我相同的感覺,就是類似這樣的程式碼總給人一種不是很 “內聚” 的感覺,就是為了把 Callback 展平,導致一些中間變數被暴露到了外層空間。

帶著這個問題,我們先分析一下資料流動:

  1. refresh 方法發起第一次請求,得到需要被展示的 n 種資料的型別以及順序。
  2. 根據第一次請求的結果,發起 n 次請求,分別得到每種資料的結果。
  3. onOk 方法作為觀察者, 會被回撥 n 次,按照第一個介面裡返回的順序正確的彙總 2 中每個資料介面返回的結果,並且通知介面更新。

有點像寫作文一樣,這是一種 總——分——總 的結構。

Observable 在空間維度重新組織事件

接下來我們使用 RxJava 來實現這個需求,我們會用到 RxJava 的一些操作符,來對 Observable 進行重新組織:

NetworkApi networkApi = ...

networkApi.getColumns()
    .map(types -> {
        List<Observable<? extends List<? extends Item>>> requestObservableList = new ArrayList<>();
        for (String type : types) {
            switch (type) {
                case "a":
                    requestObservableList.add(
                        networkApi.getItemListOfTypeA().startWith(new ArrayList<ItemA>())
                    );
                    break;
                case "b":
                    requestObservableList.add(
                        networkApi.getItemListOfTypeB().startWith(new ArrayList<ItemB>())
                    );
                    break;
                case "c":
                    requestObservableList.add(
                        networkApi.getItemListOfTypeC().startWith(new ArrayList<ItemC>())
                    );
                    break;
            }
        }
        return requestObservableList;
    })
    .flatMap(requestObservables -> Observable.combineLatest(requestObservables, objects -> {
        List<Item> items = new ArrayList<>();
        for (Object response : objects) {
            items.addAll((List<? extends Item>) response);
        }
        return items;
    }))
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(data -> {
        adapter.setData(data);
        adapter.notifyDataSetChanged();
    });
複製程式碼

我們一步一步分析 RxJava 處理的具體步驟。首先是第一步,獲取需要展示的欄目列表,這是最簡單的,networkApi.getColumns() 這個方法返回是一個只發射一個元素的 Observable,這個元素即為展示的欄目列表,為了方便後續討論,假設欄目的順序為 ["a", "b", "c"], 如下圖所示:

RxJava 沉思錄(二):空間維度

接下來的操作符是 map 操作符,原來的 Observable 進行了變換,變成了一個新的 Observable,新的 Observable 還是隻發射一個元素,這個元素的型別還是 List ,只不過 List 內部的資料型別從原先的字串(代表資料型別)變成了 ObservableObservable 發射的元素還可以是 “Observable 的 List ” 嗎?是的,沒有什麼不可以 : )

RxJava 沉思錄(二):空間維度

map 操作符負責把一個 Observable 裡發射的元素全部進行轉換,生成一個發射新的元素的 Observable,元素的種類會發生改變,但是發射的元素的數量不會發生改變。 參考資料:Map

這個操作,在業務上的含義是,根據上一步取回的欄目列表,即 ["a", "b", "c"],根據不同的資料型別,分別發起請求去獲取對應欄目的資料列表,例如欄目型別是 a 的話,就對應發起 networkApi.getItemListOfTypeA() 請求。這裡有一點值得注意,就是每一個具體的請求後面都跟了一個 .startWith(new ArrayList<>()),也就是說每個具體請求欄目內容的 Observable 在返回真正的資料 List 之前都會返回一個空的 List ,這裡這麼處理的原因我們會在下一步中解釋。

接下來這一步可能是最難理解的一步了,map 操作之後,緊接著是 flatMap 操作符,而 flatMap 操作符傳入的 lambda 表示式內部,又呼叫了 Observable.combineLatest 操作符,我們先從裡面的 combineLatest 操作符開始講起,請看下圖:

RxJava 沉思錄(二):空間維度

combineLatest 操作符的第一個引數 requestObservables,它的型別是 Observable 的 List,它就是上一步中 map 操作符進行變換之後,新的 Observable 發射的資料,即由

  • networkApi.getItemListOfTypeA().startWith(...)
  • networkApi.getItemListOfTypeB().startWith(...)
  • networkApi.getItemListOfTypeC().startWith(...)

3 個 Observable 組成的 List。

combineLatest 操作符的第二個引數是個 lambda 表示式,這個 lambda 表示式的引數型別是 Object[],這個陣列的長度等於 requestObservables 的長度,Object[] 陣列中每個元素即為 requestObservables 中每個 Observable 發射的元素,即:

  • Object[0] 對應 requestObservables[0] 發射的元素
  • Object[1] 對應 requestObservables[1] 發射的元素
  • Object[2] 對應 requestObservables[2] 發射的元素

那這個 lambda 表示式被呼叫的時機是什麼時候呢?當 requestObservables 中任意一個 Observable 發射一個元素時,這個元素便會和 requestObservables 中剩餘的所有 Observable 最近一次 發射的元素一起,作為引數呼叫這個 lambda 表示式。

那麼整個 combineLatest 操作符的作用就是,返回一個新的 Observable, 根據第一個引數裡輸入的一組 Obsevable,按照上面說的時機,呼叫第二個引數裡的那個 lambda 表示式,把這個 lambda 表示式的返回值,作為新的 Observable 發射的值,lambda 被呼叫幾次,就發射幾個元素。

參考資料:CombineLatest

我們這裡 lambda 表示式內部的邏輯比較簡單,就是把 3 個介面裡返回的資料進行彙總,組成一個新的 List 。我們再回過頭看上面那張圖,我們可以看到,Observable.combinLatest 返回的新的 Observable 一共發射了 4 個元素,它們分別是:

  • []
  • [{ItemB}, {ItemB}, ...]
  • [{ItemA}, {ItemA}, ..., {ItemB}, {ItemB}, ...]
  • [{ItemA}, {ItemA}, ..., {ItemB}, {ItemB}, ..., {ItemC}, {ItemC}, ...]

前面留了一個問題沒有解釋,為什麼 3 個獲取具體的欄目資料的介面需要呼叫 startWith 操作符發射一個空白列表,就像這樣:networkApi.getItemListOfTypeA().startWith(...),現在這個答案應該清晰了,如果不呼叫這個操作符,那麼 combineLatest 操作符生成的新 Observable 將會只發射一個元素, 即上面 4 個元素的最後一個,從使用者的感受來看,必須要等所有欄目全部請求成功以後才會一次性展示,而不是漸進地展示。

說完了內部的 combineLatest 操作符,現在該說外層的 flatMap 操作符了,flatMap 操作符也會生成一個新的 Observable,它會通過傳入的 lambda 表示式,把舊的 Observable 裡發射的每一個元素都對映成一個 Observable,然後把這些 Observable 發射的所有元素作為新的 Observable 發射的元素。

參考資料:FlatMap

由於我們這裡的情況,呼叫 flatMap 之前的 Observable 只發射了一個元素,所以 flatMap 之後生成的新 Observable 發射的元素,就是 flatMap 操作符傳入的那個 lambda 表示式執行完生成的那個 Observable 所發射的元素,也就是說 flatMap 操作符執行完後的那個新的 Observable 發射的元素,和我們剛剛討論的 combineLatest 操作符執行完後的 Observable 發射的元素是一致的。

到這裡為止,RxJava 實現的版本的每一步我們都解釋完了,我們回過頭重新梳理一下 RxJava 對 Observable 進行變換的過程,如下圖:

RxJava 沉思錄(二):空間維度

通過 RxJava 的操作符,我們把 networkApi 裡的 4 個介面返回的 4 個 Observable在空間維度進行了重新組織,最終把它們轉成了一個 Observable,這個 Observable 發射的元素型別是 List<Item>,而這正是我們的觀察者 -- Adapter 所關心的資料型別,觀察者只需要監聽這個 Observable ,並更新資料即可。

我們在講 RxJava 實現的這個版本之前的時候,說到過 Callback 實現的版本不夠 內聚,比較一下現在這個 RxJava 的版本,確實可以發現的確 RxJava 這個版本更內聚。但是並非 Callback 版本沒有辦法做到更內聚,我們可以把 Callback 版本里的 onOk, refreshresultTypes, responseList 這幾個方法和欄位封裝到一個物件中,對外只暴露 refresh 方法和一個設定觀察者的方法,也可以做到一樣的內聚,但是這就需要額外的工作量了。可如果我們使用 RxJava 就不一樣了,它提供了一堆現成的操作符,通過 Observable 之間的變換與重組,直接就可以寫出內聚的程式碼。

在上面程式碼裡出現的所有操作符中,最核心的一個操作符就是 combineLatest 操作符,仔細比較 RxJava 版本和 Callback 版本就可以發現,combineLatest 操作符的功能其實和 Callback 版本里的 onOk 方法前半部分, resultTypes, responseList 合在一起功能是相當的,一方面負責收集多個介面返回的資料,另一方面保證收集回來的資料的順序是和上一個介面返回的應該展示的資料的順序是一致的。

一種更加函式式的寫法

從程式碼量上來看,RxJava 版本與 Callback 版本相差無幾,對函數語言程式設計比較擅長的人來說,RxJava 版本里 for 迴圈的寫法,不夠 “函式式”,我們可以把原來的寫法改成一種更緊湊、更函式式的寫法:

NetworkApi networkApi = ...

netWorkApi.getColumns()
    .flatMap(types -> Observable.fromIterable(types)
        .map(type -> {
            switch (type) {
                case "a": return netWorkApi.getItemListOfTypeA().startWith(new ArrayList<ItemA>());
                case "b": return netWorkApi.getItemListOfTypeB().startWith(new ArrayList<ItemB>());
                case "c": return netWorkApi.getItemListOfTypeC().startWith(new ArrayList<ItemC>());
                default: throw new IllegalArgumentException();
            }
        })
        .<List<Observable<? extends List<? extends Item>>>>collectInto(new ArrayList<>(), List::add)
        .toObservable()
    )
    .flatMap(requestObservables -> Observable.combineLates(requestObservables, objects -> objects))
    .flatMap(objects -> Observable.fromArray(objects)
        .<List<Item>>collectInto(new ArrayList<>(), (items, o) -> items.addAll((List<Item>) o))
        .toObservable()
    )
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(data -> {
        adapter.setData(data);
        adapter.notifyDataSetChanged();
    });
複製程式碼

這裡引入了一個新的操作符 collectInto,用於把一個 Observable 裡面發射的元素,收集到一個可變的容器內部,本例中用它來替換 for 迴圈相關邏輯,具體內容這裡不再詳細展開。 參考資料:CollectInto

小結

第二個例子花了這麼大篇幅來講,超出了我一開始的預期,這也可以看出來的確 RxJava 學習的曲線是陡峭的,不過我認為這個例子很好的表達我這一小節要闡述的觀點,即 Observable 在空間維度上對事件的重新組織,讓我們的事件驅動型程式設計更具想象力 ,因為原先的程式設計中,我們面對多少個非同步任務,就會寫多少個回撥,如果任務之間有依賴關係,我們的做法就是修改觀察者(回撥函式)邏輯以及新增資料結構保證依賴關係,RxJava 給我們帶來的新思路是,Observable 的事件在到達觀察者之前,可以先通過操作符進行一系列變換(當然變換的規則還是和具體業務邏輯有關的),對觀察者遮蔽資料產生的複雜性,只提供給觀察者簡單的資料介面。

那麼是否在這個例子中,是否 RxJava 的版本更好呢,我個人的觀點是雖然 RxJava 版本展現了其更有想象力的程式設計方式,但是就這個具體的例子,兩者並沒有太大的差距。RxJava 可以寫出更短更內聚的程式碼,但是編寫和理解的難度較大;Callback 版本雖然樸實無華,但是便於編寫以及理解,可維護性更好。對於兩者的好壞,我們也不要過於著急下結論,不妨繼續看看 RxJava 還有什麼其他的優勢。

(未完待續)

本文屬於 "RxJava 沉思錄" 系列,歡迎閱讀本系列的其他分享:


如果您對我的技術分享感興趣,歡迎關注我的個人公眾號:麻瓜日記,不定期更新原創技術分享,謝謝!:)

RxJava 沉思錄(二):空間維度

相關文章