雲音樂RN新架構升級之iOS灰度方案

2024年2月6日 19点热度 0人点赞

本文主要圍繞雲音樂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 後會導致靜態鏈接過程找不到符號而編譯失敗。所以我們必須還得解決靜態鏈接過程中符號引用問題才能讓雙動態庫方案完美落地。

我們的方案

在計算機領域有一句神聖的哲言「計算機科學領域的任何問題都可以通過增加一個間接的中間層來解決」, 從內存管理、網絡模型、並發調度甚至是軟硬件架構,都能看到這句哲言在閃爍著光芒,而我們的雙動態庫方案也是這一哲言的完美實踐之一。整體方案設計如下圖所示:

整體架構圖

  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調用的是占位符類的方法,通過方法轉發,同樣可以正確調用到基類的實現。

需要註意的是,存在子類覆蓋或者重寫了基類的方法、但是在基類中被調用的情況,這時根據上面消息轉發的機制,按照如下的繼承結構:

子類展示初版

外界拿到子類的對象調用-methodB時,會通過方法轉發,通過brokerObjectBrokerSubClassRealClass-methodB的鏈路,調用到RealClass-methodB方法,

我們期望-methodB裡面調用-methodA時,能調用到我們子類自己寫的-methodA方法,而不是RealClass-methodA方法。這就需要我們對上面的結構做一些修改,在BrokerSubClass中添加-methodA,實現為轉發到SubClass-methodA(為此還需要反向關聯SubClass的對象到brokerObject),這樣一來,brokerObject在調用-methodB(裡面調用-methodA)時,會因為自身實現了-methodA而不再走到基類的同名方法中。從而達到我們的目的。

子類展示終版

實施過程中遇到的問題

上面的方案覆蓋了大部分的使用場景,但是在實施過程中還是發現了一些遺漏點,下面逐一介紹。

使用方直接訪問實例變量的情況

系統在UIView-addSubview:等方法中,會直接訪問作為傳入參數的UIView對象的某些實例變量,這種情況是我們上面的方法轉發方法所不能覆蓋的。類似的,ReactNative中的RCTShadowViewinsertReactSubview:atIndex:等方法也會直接訪問傳入參數的實例變量。

對於這種情況,我們 swizzle 了這些方法,把傳入的對象替換成真實類的對象,這樣就能正確訪問到實例變量了。

ReactNative 不同版本 API 的差異問題

比如新版 RN 提供了 RCTPLLabelForTag 函數,而舊版本沒有提供,我們的方案對於這種情況,會統一提供橋接的 RCTPLLabelForTag 函數,在切換到新版本 RN 時 JMP 到新版本的函數地址,而使用舊版本時函數未實現。這就需要我們在使用這些函數的地方,提前對當前的 RN 版本做判斷,確保隻在新版本中使用新版本的 API。

在橋接函數的實現中也可以加上一些日志,方便我們在測試過程中發現這些問題。

小結

最終我們實現的中間層成功提供了業務方零感知的動態切換 RN 版本的能力,業務方的代碼不需要做任何修改,通過配置就能實現 RN 版本的切換。

實際應用中,通過 AB 實驗,我們在可控的范圍內逐步放量,期間收集數據、反饋,發現並解決問題,最終實現了 0.70 版本 RN 的全量升級。

作者:謝富貴、張義

來源:微信公眾號:網易雲音樂技術團隊

出處
:https://mp.weixin.qq.com/s/jZ3wfbFf2xey8aTK16MU4A