RxJava 沉思錄(三):時間維度

prototypez發表於2018-09-05

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

在上一篇分享中,我們應該已經對 Observable 在空間維度上重新組織事件的能力 印象深刻了,那麼自然而然的,我們容易聯想到時間維度,事實上就我個人而言,我認為 Observable 在時間維度上的重新組織事件的能力 相比較其空間維度的能力更為突出。與上一篇類似,本文接下來將通過列舉真實的例子來闡述這一論點。

點選事件防抖動

這是一個比較常見的情景,使用者在手機比較卡頓的時候,點選某個按鈕,正常應該啟動一個頁面,但是手機比較卡,沒有立即啟動,使用者就點了好幾下,結果等手機回過神來的時候,就會啟動好幾個一樣的頁面。

這個需求用 Callback 的方式比較難處理,但是相信用過 RxJava 的開發者都知道怎麼處理:

RxView.clicks(btn)
    .debounce(500, TimeUnit.MILLISECONDS)
    .observerOn(AndroidSchedulers.mainThread())
    .subscribe(o -> {
        // handle clicks
    })
複製程式碼

debounce 操作符產生一個新的 Observable, 這個 Observable 只發射原 Observable 中時間間隔小於指定閾值的最大子序列的最後一個元素。 參考資料:Debounce

雖然這個例子比較簡單,但是它很好的表達了 Observable 可以在時間維度上對其發射的事件進行重新組織 , 從而做到之前 Callback 形式不容易做到的事情。

社交軟體上訊息的點贊與取消點贊

點贊與取消點贊是社交軟體上經常出現的需求,假設我們目前有下面這樣的點贊與取消點讚的程式碼:

boolean like;

likeBtn.setOnClickListener(v -> {
    if (like) {
        // 取消點贊
        sendCancelLikeRequest(postId);
    } else {
        // 點贊
        sendLikeRequest(postId);
    }
    like = !like;
});
複製程式碼

以下圖片素材資源來自 Dribbble

Dribbble

如果你碰巧實現了一個非常酷炫的點贊動畫,使用者可能會玩得不亦樂乎,這個時候可能會對後端伺服器造成一定的壓力,因為每次點贊與取消點贊都會發起網路請求,假如很多使用者同時在玩這個點贊動畫,伺服器可能會不堪重負。

和前一個例子的防抖動思路差不多,我們首先想到需要防抖動:

boolean like;
PublishSubject<Boolean> likeAction = PublishSubject.create();

likeBtn.setOnClickListener(v -> {
    likeAction.onNext(like);
    like = !like;
});

likeAction.debounce(1000, TimeUnit.MILLISECONDS)
    .observerOn(AndroidSchedulers.mainThread())
    .subscribe(like -> {
        if (like) {
            sendCancelLikeRequest(postId);
        } else {
            sendLikeRequest(postId);
        }
    });
複製程式碼

寫到這個份上,其實已經可以解決伺服器壓力過大的問題了,但是還是有優化空間,假設當前是已贊狀態,使用者快速點選 2 下,按照上面的程式碼,還是會傳送一次點讚的請求,由於當前是已贊狀態,再傳送一次點贊請求是沒有意義的,所以我們優化的目標就是將這一類事件過濾掉:

Observable<Boolean> debounced = likeAction.debounce(1000, TimeUnit.MILLISECONDS);
debounced.zipWith(
    debounced.startWith(like),
    (last, current) -> last == current ? new Pair<>(false, false) : new Pair<>(true, current)
)
    .flatMap(pair -> pair.first ? Observable.just(pair.second) : Observable.empty())
    .subscribe(like -> {
        if (like) {
            sendCancelLikeRequest(postId);
        } else {
            sendLikeRequest(postId);
        }
    });
複製程式碼

zipWith 操作符可以把兩個 Observable 發射的相同序號(同為第 x 個)的元素,進行運算轉換,得到的新元素作為新的 Observable 對應序號所發射的元素。參考資料:ZipWith

上面的程式碼,我們可以看到,首先我們對事件流做了一次 debounce 操作,得到 debounced 事件流,然後我們把 debounced 事件流和 debounced.startWith(like) 事件流做了一次 zipWith 操作。相當於新的這個 Observable 中發射的第 n 個元素(n >= 2)是由 debounced 事件流中的第 n 和 第 n-1 個元素運算得到的(新的這個 Observable 中發射的第 1 個元素是由 debounced 事件流中的第 1 個元素和原始點贊狀態 like 運算而來)。

運算的結果是得到一個 Pair 物件,它是一個雙布林型別二元組,二元組第一個元素為 true 代表這個事件不該被忽略,應該被觀察者觀察到;若為 false 則應該被忽略。二元組的第二個元素僅在第一個元素為 true 的情況下才有意義,true 表示應該發起一次點贊操作,而 false 表示應該發起一次取消點贊操作。上面提到的“運算”具體運算的規則是,比較兩個元素,若相等,則把二元組的第一個元素置為 false,若不相等,則把二元組的第一個元素置為 true, 同時把二元組的第二個元素置為 debounced 事件流發射的那個元素。

隨後的 flatMap 操作符完成了兩個邏輯,一是過濾掉二元組第一個元素為 false 的二元組,二是把二元組轉化回最初的 Boolean 事件流。其實這個邏輯也可由 filtermap 兩個操作符配合完成,這裡為了簡單用了一個操作符。

雖然上面用了不少篇幅解釋了每個操作符的意義,但其實核心思想是簡單的,就是在原先 debounce 操作符的基礎上,把得到的事件流裡每個元素和它的上一個元素做比較,如果這個元素和上個元素相同(例如在已贊狀態下再次發起點贊操作), 就把這個元素過濾掉,這樣最終的觀察者裡只會在在真正需要改變點贊狀態的時候才會發起網路請求了。

我們考慮用 Callback 實現相同邏輯,雖然比較本次操作與上次操作這樣的邏輯通過 Callback 也可以做到,但是 debounce 這個操作符完成的任務,如果要使用 Callback 來實現就非常複雜了,我們需要定義一個計時器,還要負責啟動與關閉這個計時器,我們的 Callback 內部會摻雜進很多和觀察者本身無關的邏輯,相比 RxJava 版本的純粹相去甚遠。

檢測雙擊事件

首先,我們需要定義雙擊事件,不妨先規定兩次點選小於 500 毫秒則為一次雙擊事件。我們先使用 Callback 的方式實現:

long lastClickTimeStamp;

btn.setOnClickListener(v -> {
    if (System.currentTimeMillis() - lastClickTimeStamp < 500) {
        // handle double click
    }
});
複製程式碼

上面的程式碼很容易理解,我們引入一箇中間變數 lastClickTimeStamp, 通過比較點選事件發生時和上一次點選事件的時間差是否小於 500 毫秒,來確認是否發生了一次雙擊事件。那麼如何通過 RxJava 來實現呢?就和上一個例子一樣,我們可以在時間維度對 Observable 發射的事件進行重新組織,只過濾出與上次點選事件間隔小於 500 毫秒的點選事件,程式碼如下:

Observable<Long> clicks = RxView.clicks(btn)
    .map(o -> System.currentTimeMillis())
    .share();
    
clicks.zipWith(clicks.skip(1), (t1, t2) -> t2 - t1)
    .filter(interval -> interval < 500)
    .subscribe(o -> {
        // handle double click
    });
複製程式碼

我們再一次用到了 zipWith 操作符來對事件流自身相鄰的兩個元素做比較,另外這次程式碼中使用了 share 操作符,用來保證點選事件的 Observable 被轉為 Hot Observable

RxJava中,Observable可以被分為Hot ObservableCold Observable,引用《Learning Reactive Programming with Java 8》中一個形象的比喻(翻譯後的意思):我們可以這樣認為,Cold Observable在每次被訂閱的時候為每一個Subscriber單獨傳送可供使用的所有元素,而Hot Observable始終處於執行狀態當中,在它執行的過程中,向它的訂閱者發射元素(傳送廣播、事件),我們可以把Hot Observable比喻成一個電臺,聽眾從某個時刻收聽這個電臺開始就可以聽到此時播放的節目以及之後的節目,但是無法聽到電臺此前播放的節目,而Cold Observable就像音樂 CD ,人們購買 CD 的時間可能前後有差距,但是收聽 CD 時都是從第一個曲目開始播放的。也就是說同一張 CD ,每個人收聽到的內容都是一樣的, 無論收聽時間早或晚。

僅僅是上面這個雙擊檢測的例子,還不能體現 RxJava 的優越性,我們把需求改得更復雜一點:如果使用者在“短時間”內連續多次點選,只能算一次雙擊操作。這個需求是合理的,因為如果按照上面 Callback 的寫法,雖然可以檢測出雙擊操作,但是如果使用者快速點選 n 次(間隔均小於 500 毫秒,n >= 2), 就會觸發 n - 1 次雙擊事件,假設雙擊處理函式裡需要發起網路請求,會對伺服器造成壓力。要實現這個需求其實也簡單,和上一個例子類似,我們用到了 debounce 操作符:

Observable<Object> clicks = RxView.clicks(btn).share()

clicks.buffer(clicks.debounce(500, TimeUnit.MILLISECONDS))
    .filter(events -> events.size >= 2)
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(o -> {
        // handle double click
    });
複製程式碼

buffer 操作符接受一個 Observable 為引數,這個 Observable 所發射的元素是什麼不重要,重要的是這些元素髮射的時間點,這些時間點會在時間維度上把原來那個 Observable 所發射的元素劃分為一系列元素的組,buffer 操作符返回的新的 Observable 發射的元素即為那些“組”。
參考資料: Buffer

上面的程式碼通過 bufferdebounce 兩個操作符很巧妙的把點選事件流轉化為了我們關心的 “短時間內點選次數超過 2 次” 的事件流,而且新的事件流中任意兩個相鄰事件間隔必定大於 500 毫秒。

在這個例子中,如果我們想要使用 Callback 去實現相似邏輯,程式碼量肯定是巨大的,而且魯棒性也無法保證。

搜尋提示

我們平時使用的搜尋框中,常常是當使用者輸入一部分內容後,下方就會顯示對應的搜尋提示,以支付寶為例,當在搜尋框輸入“螞蟻”關鍵詞後,下方自動重新整理和關鍵詞相關的結果:

RxJava 沉思錄(三):時間維度

為了簡化這個例子,我們不妨定義根據關鍵詞搜尋的介面如下:

public interface Api {
    @GET("path/to/api")
    Observable<List<String>> queryKeyword(String keyword);
}
複製程式碼

查詢介面現在已經確定下來,我們考慮一下在實現這個需求的過程中需要考慮哪些因素:

  1. 防止使用者輸入過快,觸發過多網路請求,需要對輸入事件做一下防抖動。
  2. 使用者在輸入關鍵詞過程中可能觸發多次請求,那麼,如果後一次請求的結果先返回,前一次請求的結果後返回,這種情況應該保證介面展示的是後一次請求的結果。
  3. 使用者在輸入關鍵詞過程中可能觸發多次請求,那麼,如果後一次請求的結果返回時,前一次請求的結果尚未返回的情況下,就應該取消前一次請求。

綜合考慮上面的因素以後,我們使用 RxJava 實現的對應的程式碼如下:

RxTextView.textChanges(input)
    .debounce(300, TimeUnit.MILLISECONDS)
    .switchMap(text -> api.queryKeyword(text.toString()))
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(results -> {
        // handle results
    });
複製程式碼

switchMap 這個操作符與 flatMap 操作符類似,但是區別是如果原 Observable 中的兩個元素,通過 switchMap 操作符都轉為 Observable 之後,如果後一個元素對應的 Observable 發射元素時,前一個元素對應的 Observable 尚未發射完所有元素,那麼前一個元素對應的 Observable 會被自動取消訂閱,尚未發射完的元素也不會體現在 switchMap 操作符呼叫後產生的新的 Observable 發射的元素中。 參考資料:SwitchMap

我們分析上面的程式碼,可以發現: debounce 操作符解決了問題 1switchMap 操作符解決了問題 23。這個例子可以很好的說明,RxJava 的 Observable 可以通過一系列操作符從時間的維度上重新組織事件,從而簡化觀察者的邏輯。這個例子如果使用 Callback 來實現,肯定是十分複雜的,需要設定計時器以及一堆中間變數,觀察者中也會摻雜進很多額外的邏輯,用來保證事件與事件的依賴關係。

(未完待續)

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


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

RxJava 沉思錄(三):時間維度

相關文章