心遇iOS端會話頁效能最佳化 — ReactiveObjC實踐篇

雲音樂技術團隊發表於2023-05-04
本文作者:尚堯

一、背景

心遇作為一款社交產品,訊息會話頁必定是使用者使用量最大的頁面之一,因而會話頁的使用者體驗將尤為重要。同時,心遇有著陌生人社交屬性,使用者的會話量動輒上萬,會話頁也面臨著較大的效能挑戰。因此,會話頁的效能最佳化既是重點,也是難點。

本文將舉例會話頁已知的效能問題,分析實現弊端,最後透過引入 ReactiveObjC 來更優雅的解決問題。

二、 ReactiveObjC 簡介

ReactiveObjC 是一個基於響應式程式設計 (Reactive Programming) 正規化的開源框架了,它結合了函數語言程式設計、觀察者模式、事件流處理等多種程式設計思想,從而讓開發者更加高效地處理非同步事件和資料流。其核心思路是將事件抽象成一個個訊號,再根據需求對訊號進行組合操作,最後訂閱處理訊號。透過使用 ReactiveObjC ,寫法上由命令式改為宣告式,使得程式碼的邏輯變得更緊湊清晰。

三、實踐

場景一:會話資料來源處理存在的問題

問題分析

心遇會話頁如圖所示:

會話頁的資料來源來源於 DataSourceDataSource 維護著一個有序的會話陣列,內部監聽著各種事件,比如會話更新、會話草稿更新、置頂會話變更等等。當觸發事件後, 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 操作,因此有阻塞當前執行緒的風險。當頻繁接收到新訊息時,可能會引起嚴重掉幀的問題。

同時, filterFamilyRecentSessioncustomSortRecentSessions 方法在內部會遍歷會話陣列,雖然時間複雜度是 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 SignalData 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!

相關文章