この記事の著者:シャン・ヤオ
1. 背景
Xinyu はソーシャル製品であり、メッセージ会話ページはユーザーが最も頻繁に使用するページの 1 つである必要があるため、会話ページのユーザー エクスペリエンスは特に重要になります。同時に、Xinyu には見知らぬ人の社会的属性があり、ユーザー セッションの数は数万に達し、セッション ページも大きなパフォーマンスの課題に直面しています。したがって、セッション ページのパフォーマンスの最適化は重要な点であると同時に難しい点でもあります。
この記事では、セッション ページの既知のパフォーマンス問題の例を示し、実装の欠点を分析し、最終的に ReactiveObjC を導入することで問題をよりエレガントに解決します。
2. ReactiveObjC の概要
ReactiveObjC は、リアクティブ プログラミング パラダイムに基づいたオープン ソース フレームワークであり、関数型プログラミング、オブザーバー モード、イベント ストリーム処理、その他のプログラミングのアイデアを組み合わせて、開発者が非同期イベントとデータ ストリームをより効率的に処理できるようにします。中心的なアイデアは、イベントを個々の信号に抽象化し、要件に従って信号を組み合わせて操作し、最後に信号をサブスクライブして処理することです。ReactiveObjC を使用すると、記述方法が命令型から宣言型に変更され、コードのロジックがよりコンパクトかつ明確になります。
3. 練習する
シナリオ 1: セッション データ ソース処理の問題
問題分析
Xinyu セッション ページを次の図に示します。
セッション ページのデータ ソースは から取得されます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]
です。Xinyu のビジネス ニーズにより、一部のメッセージは会話ページに表示する必要がありません。したがって、新しいメッセージを受信した場合には、セッションの明示的なメッセージを再度更新する必要があります。明示的メッセージの更新ロジックは次のとおりです。
- ステップ 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 的使用只不过是冰山一角。它的强大之处在于通过它可以将任意的事件抽象成信号,同时它又提供了大量的操作符去转换信号,从而最终得到你想要的信号。
不可否认,诸如此类的框架的学习曲线是较陡的。但当真正理解了响应式编程思想并熟练运用后,开发效率必定会事半功倍。
五、参考文献
この記事は NetEase Cloud Music Technology Team によって公開されたものであり、いかなる形態であっても無断転載を禁じます。当社では年間を通じてさまざまな技術職を募集していますので、クラウド ミュージックが好きで転職を考えている方は、ぜひ grp.music-fe(at)corp.netease.com にアクセスしてください!