Xinyu iOS 세션 페이지 성능 최적화 - ReactiveObjC 실습

이 글의 저자: Shang Yao

1. 배경

Xinyu는 소셜 제품이며 메시지 세션 페이지는 사용자가 가장 자주 사용하는 페이지 중 하나이므로 세션 페이지의 사용자 경험이 특히 중요합니다. 동시에 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의 비즈니스 요구로 인해 일부 메시지는 대화 페이지에 표시할 필요가 없습니다. 따라서 새로운 메시지가 수신되면 세션의 Explicit 메시지를 다시 업데이트해야 합니다. 명시적 메시지의 업데이트 논리는 다음과 같습니다.

  • Step 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] github.com/ReactiveCoc…

[2] reactx.io/documentati…

이 기사는 NetEase 클라우드 음악 기술 팀에서 게시했으며 승인 없이 모든 형태의 전재를 금지합니다. 연중무휴로 다양한 기술직을 모집하고 있습니다. 이직을 준비 중이시거나 클라우드 음악을 좋아하신다면 grp.music-fe(at)corp.netease.com에서 함께하세요!

추천

출처juejin.im/post/7229139006079844389