本文作者:尚堯
一、背景
心遇作為一款社交產品,訊息會話頁必定是使用者使用量最大的頁面之一,因而會話頁的使用者體驗將尤為重要。同時,心遇有著陌生人社交屬性,使用者的會話量動輒上萬,會話頁也面臨著較大的效能挑戰。因此,會話頁的效能最佳化既是重點,也是難點。
本文將舉例會話頁已知的效能問題,分析實現弊端,最後透過引入 ReactiveObjC 來更優雅的解決問題。
二、 ReactiveObjC 簡介
ReactiveObjC 是一個基於響應式程式設計 (Reactive Programming) 正規化的開源框架了,它結合了函數語言程式設計、觀察者模式、事件流處理等多種程式設計思想,從而讓開發者更加高效地處理非同步事件和資料流。其核心思路是將事件抽象成一個個訊號,再根據需求對訊號進行組合操作,最後訂閱處理訊號。透過使用 ReactiveObjC ,寫法上由命令式改為宣告式,使得程式碼的邏輯變得更緊湊清晰。
三、實踐
場景一:會話資料來源處理存在的問題
問題分析
心遇會話頁如圖所示:
會話頁的資料來源來源於 DataSource
。DataSource
維護著一個有序的會話陣列,內部監聽著各種事件,比如會話更新、會話草稿更新、置頂會話變更等等。當觸發事件後, DataSource
可能會重新繫結會話外顯訊息、過濾、排序會話陣列,最後通知最上層業務側重新整理頁面。結構圖如下:
部分實現程式碼如下:
// 會話變更的IM回撥
- (void)didUpdateRecentSession:(NIMRecentSession *)recentSession {
// 更新會話的外顯訊息
[recentSession updateLastMessage];
// 過濾非自己家族的會話
[self filterFamilyRecentSession];
// 重新排序
[self customSortRecentSessions];
// 通知觀察者資料變更
[self dispatchObservers];
}
// 置頂資料變更
- (void)stickTopInfoDidUpdate:(NSArray *)infos {
self.stickTopInfos = infos;
[self customSortRecentSessions];
[self dispatchObservers];
}
// 草稿箱變更
- (void)dartDidUpdate {
[self customSortRecentSessions];
[self dispatchObservers];
}
// 家族資料變更
- (void)familyInfoDidUpdate {
[self filterFamilyRecentSession];
[self customSortRecentSessions];
[self dispatchObservers];
}
這裡需要解釋的是 [recentSession updateLastMessage]
的呼叫。由於心遇的業務需要,部分訊息是不需要外顯到會話頁的。因此當收到一條新訊息時,需要重新更新該會話的外顯訊息。外顯訊息的更新邏輯如下:
- 第1步、透過 IMSDK 的介面同步獲取會話最新的訊息列表
- 第2步、倒敘遍歷訊息陣列,找到最新的可外顯的訊息
- 第3步、更新會話的外顯訊息
其中,由於第一步的訊息列表獲取是同步 DB 操作,因此有阻塞當前執行緒的風險。當頻繁接收到新訊息時,可能會引起嚴重掉幀的問題。
同時, filterFamilyRecentSession
和 customSortRecentSessions
方法在內部會遍歷會話陣列,雖然時間複雜度是 O(n) ,但是當會話量大且回撥進入頻繁時,也會有一定的效能問題。
而在寫法上,這裡大量採用委託的方式,邏輯分散在各個回撥中,可讀性較差。同時每個回撥中的邏輯又是類似的,程式碼冗餘。
總結一下問題關鍵點:
- 主執行緒存在大量的耗效能操作,造成卡頓。
- 事件回撥多,邏輯分散,可讀性差,不好維護。
解決方案
解決方案:
- 將各種事件回撥抽象成訊號,進行
combine
組合操作,解決邏輯分散問題。 - 將耗效能操作移到子執行緒中,並抽象成非同步訊號,解決卡頓問題。
- 對組合訊號使用
flattenMap
運算子,內部返回非同步訊號,最終生成結果訊號供業務使用。
下面將按照方案,透過 ReactiveObjC 來一步步解決問題。
首先按照其核心思想,將上述的事件抽象成訊號。以 familyInfoDidUpdate
回撥為例,可以透過庫提供的 - (RACSignal<RACTuple *> *)rac_signalForSelector:(SEL)selector
方法將委託方法轉換成訊號。當然,更好的做法是家族資料管理類直接提供一個訊號給外部使用,這樣外部就不需要再去封裝訊號了。
RACSignal <RACTuple *> *familyInfoUpdateSingal = [self rac_signalForSelector:@selector(familyInfoDidUpdate)];
再以會話陣列為例,考慮到外顯訊息的更新是個耗時操作,因此先不處理,將源資料的變更先封裝成訊號 originalRecentSessionSignal
。
- (void)didUpdateRecentSession:(NIMRecentSession *)recentSession {
NSArray *recentSessions = [self addRecentSession:recentSession];
self.recentSessions = recentSessions;
}
RACSignal <NSArray <NIMRecentSession *> *> *originalRecentSessionSignal = RACObserve(self, recentSessions);
現在,所有的回撥事件都已經抽成訊號了。由於這些訊號均會觸發過濾、排序等一系列操作,因此可以將訊號進行組合 combine
處理。
RACSignal <RACTuple *> *familyInfoUpdateSingal = [self rac_signalForSelector:@selector(familyInfoDidUpdate)];
RACSignal <NSArray <NIMRecentSession *> *> *originalRecentSessionSignal = RACObserve(self, recentSessions);
...
RACSignal <RACTuple *> *combineSignal = [RACSignal combineLatest:@[originalRecentSessionSignal, stickTopInfoSignal, familyInfoUpdateSingal, stickTopInfoSignal, draftSignal, ...]];
[combineSignal subscribeNext:^(RACTuple * _Nullable value) {
// 響應訊號
// 更新外顯訊息、過濾、排序等操作
}];
combine
後的新訊號 combineSignal
將會在任一回撥事件觸發時,通知訊號的訂閱者。同時該訊號的型別為 RACTuple
型別,裡面是各個子訊號上一次觸發的值。
到目前為止,已經將分散的邏輯集中到了 combineSignal
的訂閱回撥裡。但是效能問題依舊沒有解決。解決效能問題最方便的操作就是將耗時操作放到子執行緒中,而 ReactiveObjC 提供的 flattenMap
函式能讓這一非同步操作的實現更為優雅。
透過龍珠圖不難發現, flattenMap
可以將一個原始訊號 A 透過訊號 B 轉換成一個 新型別的訊號 C 。在上面的例子中, combineSignal
作為原始訊號 A ,非同步處理資料訊號作為訊號 B ,最終轉換成了結果訊號 C ,即 recentSessionSignal
。具體程式碼如下:
RACSignal <NSArray <NIMRecentSession *> *> *recentSessionSignal = [[combineSignal flattenMap:^__kindof RACSignal * _Nullable(RACTuple * _Nullable value) {
// 從tuple中拿出最新資料,傳入
return [[self flattenSignal:orignalRecentSessions stickTopInfo:stickTopInfo] deliverOnMainThread];
}];
- (RACSignal *)flattenSignal:(NSArray *)orignalRecentSessions stickTopInfo:(NSDictionary *)stickTopInfo {
RACSignal *signal = [RACSignal createSignal:^RACDisposable * _Nullable(id<RACSubscriber> _Nonnull subscriber) {
dispatch_async(self.sessionBindQueue, ^{
// 先處理:更新外顯訊息、過濾排序
NSArray *recentSessions = ...
// 後吐出最終結果
[subscriber sendNext:recentSessions];
[subscriber sendCompleted];
});
return nil;
}];
return signal;
}
至此,該場景下的問題已最佳化完畢。再簡單總結下訊號鏈路:每當任一事件回撥,都會觸發訊號,進而派發到子執行緒處理結果,最終透過結果訊號 recentSessionSignal
吐出。完整訊號龍珠圖如下:
場景二:會話業務資料處理存在的問題
問題分析
由於業務隔離,會話的業務資料(比如使用者資料)需要請求業務介面去獲取。
對於這段業務資料的獲取邏輯,心遇是透過 BusinessBinder
去完成的,結構圖如下:
BusinessBinder
監聽著資料來源變更的回撥,在回撥內部做兩件事:
- 過濾出記憶體池中沒有業務資料的會話,嘗試從 DB 中獲取資料並載入到記憶體池。
- 過濾出沒有請求過業務資料的會話,批次請求資料,在介面回撥中更新記憶體池並快取。
業務層在重新整理時,透過 id 從記憶體池中獲取對應的業務資料:
部分實現程式碼如下:
- (void)recentSessionDidUpdate:(NSArray *)recentSessions {
// 嘗試從DB中載入沒有記憶體池中沒有的Data
NSArray *unloadRecentSessions = [recentSessions bk_select:^BOOL(id obj) {
return ![MemoryCache dataWithKey:obj.session.sessionId];
}];
for (recentSession in unloadRecentSessions) {
Data *data = [DBCache dataWithKey:recentSession.session.sessionId];
[MemoryCache cache:data forKey:recentSession.session.sessionId];
}
// 批次拉取未請求過的Data
NSArray *unfetchRecentSessionIds = [[recentSessions bk_select:^BOOL(id obj) {
return obj.isFetch;
}] bk_map:^id(id obj) {
return obj.session.sessionId;
}];
[self fetchData:unfetchRecentSessionIds ];
}
- (void)dataDidFetch:(NSArray *)datas {
// 在介面響應回撥中快取
for (data in datas) {
[MemoryCache cache:data forKey:data.id];
[DataCache cache:data forKey:data.id];
}
}
由於和場景一類似,這裡不做過多分析。簡單總結下問題關鍵點:
DataCache
的讀寫操作以及多處遍歷操作均在主執行緒執行,存在效能問題。
解決方案
由於場景二中的運算子在場景一中已詳細介紹過,因此場景二會跳過介紹直接使用。場景二的核心思路和一類似:
- 將耗時操作非同步處理,並抽象成訊號。
- 將源訊號、中間訊號組合、操作,最終生成符合預期的結果訊號。
首先, DataCache
的讀取操作以及介面的拉取操作其實可以理解為同一行為,即資料獲取。因此可以將這一行為抽象成一個非同步訊號,訊號的型別為業務資料陣列。觸發該訊號的時機為會話資料來源變更。龍珠圖如下:
圖中的新訊號 Data Signal
即為業務資料獲取訊號。該訊號由場景一中的 Sessions Signal
透過 flattenMap
運算子轉變而來,在 flattenMap
內部去非同步讀取 DataCache
,請求介面。由於可能存在DB無資料或介面未獲取到資料的情況,因此可以給 Data Signal
進行一次 filter
操作,過濾掉資料為空情況。
其次按照上述分析的邏輯,當會話變更時,會從 DataCache
中獲取資料並更新記憶體池;當業務資料獲取到時,也需要更新記憶體池。因此,可以將 Sessions Signal
和 Data Signal'
進行組合操作。
現在,每當會話變更或業務資料獲取到,都會觸發組合後的新訊號 Combine Signal
。最後,透過 flattenMap
非同步獲取 DataCache
資料並更新記憶體池,生成結果訊號 Result Signal
。
至此,最終訊號 Result Signal
即為業務資料資料獲取完畢並更新記憶體池後的訊號。上層業務透過訂閱該訊號即可獲取到業務資料獲取完畢的時機。完整的龍珠圖如下:
四、小結
上述場景對於 ReactiveObjC 的使用只不過是冰山一角。它的強大之處在於透過它可以將任意的事件抽象成訊號,同時它又提供了大量的運算子去轉換訊號,從而最終得到你想要的訊號。
不可否認,諸如此類的框架的學習曲線是較陡的。但當真正理解了響應式程式設計思想並熟練運用後,開發效率必定會事半功倍。
五、參考文獻
[1] https://github.com/ReactiveCocoa/ReactiveObjC
[2] https://reactivex.io/documentation/operators.html
本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!