云音乐RN新架构晋级之iOS灰度计划

本文作者:谢富贵张义

本文主要环绕云音乐iOS侧晋级新版别RN时用到的灰度计划进行论述。云音乐有 100+ 事务模块运用 RN 开发,占有了 30%+ 的事务模块,所以晋级的新版别RN稳定性对咱们来讲特别重要。除此之外,iOS TestFlight 现已无法经过删去邮箱来完成无限分发。因此有必要要有一个灰度计划来完成渐进式晋级,直到稳定性以及各项目标数据打平后才能全量晋级。

布景

文章《网易云音乐 RN 新架构晋级实践》整体介绍了云音乐在晋级 RN 进程中遇到的问题以及处理计划,本文主要环绕前文介绍到的 iOS 侧灰度计划进行论述。因为云音乐现已有 100+ 事务模块运用 RN 开发,占有了 30%+ 的事务模块,所以晋级后的 0.70 版别 RN的稳定性对咱们来讲特别重要。除此之外,iOS TestFlight 现已无法经过删去邮箱来完成无限分发。因此有必要要有一个事务无感知的灰度计划来完成渐进式晋级,直到稳定性以及各项目标数据打平后才能全量晋级。

思路和挑战

完成渐进式的晋级,势必就要引进两个版别的 RN 代码,然后经过AB试验进行放量控制,默许C组运用老版别代码,T组运用新版别代码。让不同版别的代码共存一般有两种计划:

计划一:静态链接,修正符号名

静态链接在编译时将一切的程序模块和库文件合并成一个单独的可履行文件,这个进程中不允许出现重复的符号,不然就无法完成符号的重定位导致链接失利。

处理符号抵触最简单的办法就是修正符号名,可是这不只要修正界说符号的源文件,而且一切引证到相关符号的源文件相同要做修正,该办法极端繁琐。关于 RN 这种庞大的工程来讲,如果人工手动更改的话,显然是要消耗极大的人力和精力并且也无法保证准确性。即便写脚本用自动化的办法进行替换也难以掩盖一切的符号,因为有宏界说、动态调用等各种写法的存在,难免会导致疏漏,再者编写脚本的工作量也不小。

计划二:动态链接

动态链接则与静态链接相反是在运行时加载库文件进行链接,iOS 中 NSBundle 模块供给了 loadAndReturnError: 办法来支撑动态的加载指定动态库的才能。因此将 RN 新老版别代码打成 2 个动态库后咱们就能够处理了不同版别代码共存问题。

除此之外,因为事务层有许多当地引证了 RN 中的符号,推迟动态加载 RN 后会导致静态链接进程找不到符号而编译失利。所以咱们有必要还得处理静态链接进程中符号引证问题才能让双动态库计划完美落地。

咱们的计划

在计算机范畴有一句崇高的哲言「计算机科学范畴的任何问题都能够经过增加一个直接的中间层来处理」, 从内存办理、网络模型、并发调度乃至是软硬件架构,都能看到这句哲言在闪烁着光芒,而咱们的双动态库计划也是这一哲言的完美实践之一。整体计划设计如下图所示:

云音乐RN新架构晋级之iOS灰度计划

  1. 将原先的React界说文件悉数剥离,只剩下头文件给事务库依赖,保证编译进程中预处理阶段不会报错。
  2. NEReactNative 是咱们引进的中间层,在这个库中界说了被事务层引证的 RN 符号(下文都以 RN 占位符号代指),保证静态链接阶段能找到相应的符号。除此之外该库是以插件的形式引进,事务层不感知。
  3. 实在 RN 的符号是运行时动态引进的,根据 AB 决定是加载新版别仍是老版别。
  4. 完成动态库加载后还需求将占位符号与实在符号绑定起来。下文将针对符号绑定进行详细叙述

符号获取

咱们在打新老版别的 RN 动态库时加入一份一致的工具类去搜集事务层用到的大局变量/函数地址以及下文的类符号地址。详细示例如下:

@interface NEReactNativeDynamicFramework : NSObject
// 获取类符号地址
+ (Class _Nullable)getClass:(NSString *)name;
// 获取大局符号地址
+ (void * _Nullable)getSymbol:(NSString *)name;
@end
@implementation NEReactNativeDynamicFramework
static NSMutableDictionary<NSString *, NSValue *> *symbols;
static NSMutableDictionary<NSString *, NSValue *> *classes;
+ (void)prepare
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        symbols = [NSMutableDictionary dictionary];
        classes = [NSMutableDictionary dictionary];
        // TODO:获取符号地址,详细内容见下方
    });
}
+ (Class _Nullable)getClass:(NSString *)name
{
    [self prepare];
    return (__bridge Class)[classes[name] pointerValue];
}
+ (void * _Nullable)getSymbol:(NSString *)name
{
    [self prepare];
    return [symbols[name] pointerValue];
}
@end

关于大局变量/函数咱们能够用 extern 符号声明的办法来获取地址,在链接阶段编译器会自动将同名符号绑定到一致的地址。

// 宏界说胶水代码
#define INCLUDE_SYMBOL(NAME) 
    do { 
        __attribute__((visibility("hidden"))) extern void NAME; 
        symbols[@(#NAME)] = [NSValue valueWithPointer:&NAME]; 
    } while (0)
// 获取实践大局变量地址
INCLUDE_SYMBOL(RCTJavaScriptDidLoadNotification);
// 获取实践大局函数地址
INCLUDE_SYMBOL(RCTBridgeModuleNameForClass);

细心的读者可能会发现,咱们在用 extern 声明符号时一致用了 void 类型,可是 RN 并不是一切的大局符号都是 void 类型,比如示例中的 RCTJavaScriptDidLoadNotificationRCTBridgeModuleNameForClass。能够这么写得益于编译器的强弱符号选择策略:出现同名符号时会优先选择强符号。如示列中 extern void RCTJavaScriptDidLoadNotification; 声明的是弱符号,而实践界说NSString *const RCTJavaScriptDidLoadNotification = @"RCTJavaScriptDidLoadNotification"; 为强符号。所以出现 RCTJavaScriptDidLoadNotification 符号的当地都会运用强符号所对应的地址进行重定位。

关于类符号地址的获取会稍微复杂点,咱们运用了 asm 汇编指令进行符号重命名,示列如下:

/**********界说胶水代码**********/
#define PASTE_HELPER(A, B) A ## B
#define PASTE(A, B) PASTE_HELPER(A, B)
#define INCLUDE_CLASS_HELPER(NAME, SYM, SYM_NAME) 
    do { 
        __attribute__((visibility("hidden"))) extern void PASTE(v, __LINE__) asm(SYM); NSValue *value = [NSValue valueWithPointer:&PASTE(v, __LINE__)]; 
        classes[@(NAME)] = value; 
        symbols[@(SYM_NAME)] = value; 
    } while (0)
#define STRINGIFY_HELPER(X) #X
#define STRINGIFY(X) STRINGIFY_HELPER(X)
#define INCLUDE_CLASS(NAME) 
    INCLUDE_CLASS_HELPER(STRINGIFY(NAME), STRINGIFY(PASTE(_OBJC_CLASS_$_, NAME)), STRINGIFY(PASTE(OBJC_CLASS_$_, NAME)))
/**********界说胶水代码**********/
// 获取实例类符号地址
INCLUDE_CLASS(RCTBridge);

关于 asm 指令详细介绍能够参阅 gcc 里边的一篇文档介绍。上述代码中心句子是 extern void PASTE(v, __LINE__) asm(SYM);, 先是动态声明了一个变量符号然后运用 asm 进行符号重写,所以咱们经过获取该变量符号的地址就能拿到类符号地址。

大局变量/符号内容替换

在获取了大局函数/变量符号地址后,咱们需求将占位符号的内容进行替换然后完成与实在符号的绑定。大局变量内容替换示列如下:

// 界说胶水代码
#define NE_VAR_SYMBOL_DECLARE(NAME) 
    extern void * NAME; 
    void * NAME;
#define NE_VAR_SYMBOL_LOAD(NAME) 
    NAME = *(void **)[NEReactNativeDynamicFramework getSymbol:@(#NAME)];
// 界说大局变量占位符号
NE_VAR_SYMBOL_DECLARE(RCTJavaScriptDidLoadNotification)
@implementation NEReactNativeGlobalSymbolLoader (variables)
+ (void)loadGlobalVariables
{   
    // 对占位符号进行内容替换
    NE_VAR_SYMBOL_LOAD(RCTJavaScriptDidLoadNotification)
}
@end

关于大局函数则能够运用汇编指令 JMP 进行跳转履行,在 ARM64 架构下对应的指令为 BR,详细示列如下:

// 界说胶水代码
#if __x86_64__
    #define _JMP_TO(PTR) __asm__ volatile("JMP *%0" : : "r"(PTR));
#elif __arm64__
    #define _JMP_TO(PTR) __asm__ volatile("BR %0" : : "r"(PTR));
#endif
#define NE_FUN_SYMBOL_DECLARE(NAME) 
    static void *SYM_ ## NAME = NULL; 
    FOUNDATION_EXPORT void NAME(void); 
    __attribute__((naked)) 
    void NAME(void) { 
        _JMP_TO(SYM_ ## NAME); 
    }
#define NE_FUN_SYMBOL_LOAD(NAME) 
    SYM_ ## NAME = [NEReactNativeDynamicFramework getSymbol:@(#NAME)];
// 界说大局函数占位符号
NE_FUN_SYMBOL_DECLARE(RCTBridgeModuleNameForClass)
@implementation NEReactNativeGlobalSymbolLoader (functions)
+ (void)loadGlobalFunctions
{   
    // 获取实在大局函数符号地址
    NE_FUN_SYMBOL_LOAD(RCTBridgeModuleNameForClass)
}
@end

类符号绑定

Objective-C 的类的处理采用了相似的思路,先是界说了一个占位符类,然后在运行时动态替换成实在的类。详细能够分为以下几种状况:

  1. 关于类办法,直接运用办法转发,把占位符类的办法转发到实在类的办法上。
  2. 关于没有子类的类,掩盖 +alloc-init+new 等办法,在调用时直接创立实在类的目标回来。
  3. 因为 Category 办法会被加到占位符类上,而实践履行进程中因为过程 2 的存在,拿到的可能是实在类的目标,这儿需求把这些 Category 办法手动增加到实在类上。
  4. 有些当地可能会在运行时去检查类或许目标是否完成了某些 Protocol,这儿就需求把实在类的 Protocol 列表增加到占位符类上。
  5. 关于有子类的类,会更复杂一些。咱们的目标是非侵入式的,所以不会去修正子类的完成;上面的过程能够掩盖非运用子类目标之外的场景,关于创立并运用子类目标的状况,需求额外的处理,下面详细分析一下。

以一个组件为例:

@interface MyViewManager : RCTViewManager <RCTUIManagerObserver>
@property (nonatomic, strong) NSString *myProperty;
@end
@implementation MyViewManager
- (void)setBridge:(RCTBridge *)bridge
{
    [super setBridge:bridge];
    [self.bridge.uiManager.observerCoordinator addObserver:self];
}
- (void)invalidate
{
  [self.bridge.uiManager.observerCoordinator removeObserver:self];
}
RCT_EXPORT_MODULE()
RCT_EXPORT_VIEW_PROPERTY(myProperty, NSString)
- (UIView *)view
{
  return [[MyView alloc] init];
}
// ...
#pragma mark - RCTUIManagerObserver
- (void)uiManagerDidPerformMounting:(__unused RCTUIManager *)manager
{
  // ...
}
@end

上面的代码掩盖了常见的运用状况:

  1. 子类能够新增属性和办法,乃至能够掩盖基类的办法。
  2. 子类的办法中能够运用super关键字调用基类的办法。
  3. 调用方在拿到子类的目标调用办法时,如果子类没有完成该办法,会去基类中查找。

在咱们的计划中,子类承继的是占位符类,需求在运行时供给机制能满意上面的要求。

这儿咱们的计划相同是在+alloc-init+new 等办法中,增加逻辑,判别到正在创立子类目标时,动态为当时子类创立一个承继自实在类的署理子类,然后创立这个署理子类的目标,保存为属性,回来正常的子类(承继自占位符类)目标。

调用方在调用这个目标的办法时,关于子类完成或许掩盖的办法,直接调用到子类的完成;关于未完成的办法,运用办法转发,转发到署理子类的目标上,这样就能正确调用到基类的完成。

关于子类办法中运用super调用基类办法的景象,因为子类承继的是占位符类,所以super调用的是占位符类的办法,经过办法转发,相同能够正确调用到基类的完成。

需求留意的是,存在子类掩盖或许重写了基类的办法、可是在基类中被调用的状况,这时根据上面音讯转发的机制,依照如下的承继结构:

云音乐RN新架构晋级之iOS灰度计划

外界拿到子类的目标调用-methodB时,会经过办法转发,经过brokerObjectBrokerSubClassRealClass-methodB的链路,调用到RealClass-methodB办法,

咱们期望-methodB里边调用-methodA时,能调用到咱们子类自己写的-methodA办法,而不是RealClass-methodA办法。 这就需求咱们对上面的结构做一些修正,在BrokerSubClass中增加-methodA,完成为转发到SubClass-methodA(为此还需求反向相关SubClass的目标到brokerObject),这样一来,brokerObject在调用-methodB(里边调用-methodA)时,会因为本身完成了-methodA而不再走到基类的同名办法中。然后到达咱们的目的。

云音乐RN新架构晋级之iOS灰度计划

实施进程中遇到的问题

上面的计划掩盖了大部分的运用场景,可是在实施进程中仍是发现了一些遗漏点,下面逐个介绍。

运用方直接拜访实例变量的状况

体系在UIView-addSubview:等办法中,会直接拜访作为传入参数的UIView目标的某些实例变量,这种状况是咱们上面的办法转发办法所不能掩盖的。 相似的,ReactNative中的RCTShadowViewinsertReactSubview:atIndex:等办法也会直接拜访传入参数的实例变量。

关于这种状况,咱们 swizzle 了这些办法,把传入的目标替换成实在类的目标,这样就能正确拜访到实例变量了。

ReactNative 不同版别 API 的差异问题

比如新版 RN 供给了 RCTPLLabelForTag 函数,而旧版别没有供给,咱们的计划关于这种状况,会一致供给桥接的 RCTPLLabelForTag 函数,在切换到新版别 RN 时 JMP 到新版别的函数地址,而运用旧版别时函数未完成。 这就需求咱们在运用这些函数的当地,提早对当时的 RN 版别做判别,保证只在新版别中运用新版别的 API。

在桥接函数的完成中也能够加上一些日志,便利咱们在测试进程中发现这些问题。

小结

终究咱们完成的中间层成功供给了事务方零感知的动态切换 RN 版别的才能,事务方的代码不需求做任何修正,经过装备就能完成 RN 版别的切换。

实践应用中,经过 AB 试验,咱们在可控的范围内逐步放量,期间搜集数据、反应,发现并处理问题,终究完成了 0.70 版别 RN 的全量晋级。

最后

云音乐RN新架构晋级之iOS灰度计划
更多岗位,可进入网易招聘官网查看 hr.163.com/