Optimización del rendimiento de la página de sesión de Xinyu iOS: práctica de ReactiveObjC

Autor de este artículo: Shang Yao

1. Antecedentes

Xinyu es un producto social, y la página de conversación de mensajes debe ser una de las páginas más utilizadas por los usuarios, por lo que la experiencia del usuario de la página de conversación será particularmente importante. Al mismo tiempo, Xinyu tiene los atributos sociales de extraños, y la cantidad de sesiones de usuario es de decenas de miles, y la página de sesión también enfrenta mayores desafíos de rendimiento. Por lo tanto, la optimización del rendimiento de las páginas de sesión es tanto un punto clave como un punto difícil.

Este artículo dará un ejemplo de los problemas de rendimiento conocidos de la página de sesión, analizará las desventajas de la implementación y finalmente resolverá el problema de manera más elegante al presentar ReactiveObjC.

2. Introducción a ReactiveObjC

ReactiveObjC es un marco de código abierto basado en el paradigma de programación reactiva. Combina programación funcional, modo observador, procesamiento de flujo de eventos y otras ideas de programación para permitir a los desarrolladores procesar eventos asincrónicos y flujos de datos de manera más eficiente. La idea central es abstraer eventos en señales individuales, luego combinar y operar las señales de acuerdo con los requisitos y, finalmente, suscribirse y procesar las señales. Al usar ReactiveObjC, el método de escritura cambia de imperativo a declarativo, lo que hace que la lógica del código sea más compacta y clara.

3. Practica

Escenario 1: Problemas en el procesamiento de la fuente de datos de la sesión

análisis del problema

La página de sesión de Xinyu se muestra en la figura:

La fuente de datos de la página de la sesión proviene de DataSource. DataSourceSe mantiene una matriz de sesión ordenada y varios eventos se supervisan internamente, como la actualización de la sesión, la actualización del borrador de la sesión, el cambio de la sesión principal, etc. Cuando se activa un evento, DataSourcepuede volver a vincular el mensaje explícito de la sesión, filtrar y ordenar la matriz de la sesión y, finalmente, notificar al lado comercial de nivel superior para que actualice la página. El diagrama de la estructura es el siguiente:

Parte del código de implementación es el siguiente:

// 会话变更的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];
}
复制代码

Lo que necesita ser explicado aquí [recentSession updateLastMessage]es la llamada de . Debido a las necesidades comerciales de Xinyu, no es necesario mostrar algunos mensajes en la página de conversación. Por lo tanto, cuando se recibe un nuevo mensaje, el mensaje explícito de la sesión debe actualizarse nuevamente. La lógica de actualización del mensaje explícito es la siguiente:

  • Paso 1. Obtenga la última lista de mensajes de la sesión de forma síncrona a través de la interfaz de IMSDK
  • Paso 2. Recorra la matriz de mensajes en orden inverso para encontrar el último mensaje visualizable
  • Paso 3. Actualizar el mensaje explícito de la sesión

其中,由于第一步的消息列表获取是同步 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] reactivex.io/documentati…

Este artículo fue publicado por el equipo de tecnología de NetEase Cloud Music. Se prohíbe cualquier forma de reimpresión sin autorización. Reclutamos varios puestos técnicos durante todo el año. Si va a cambiar de trabajo y le gusta la música en la nube, ¡únase a nosotros en grp.music-fe(at)corp.netease.com!

Supongo que te gusta

Origin juejin.im/post/7229139006079844389
Recomendado
Clasificación