How NetEase Cloud Music Achieved Seamless RN Upgrades with a Dual‑Dynamic‑Library Gray Release
This article details NetEase Cloud Music's engineering solution for gradually upgrading its iOS React Native version using a dual dynamic‑library gray‑release strategy, covering background, challenges, symbol handling, code modifications, and practical issues to enable zero‑impact, data‑driven rollouts.
Background
NetEase Cloud Music has over 100 business modules built with React Native (RN), accounting for more than 30% of its iOS codebase. Upgrading to a new RN version (0.70) is critical for stability, and TestFlight no longer supports unlimited distribution via email deletion, necessitating a non‑intrusive gray‑release mechanism.
Problem Statement and Challenges
To perform a progressive upgrade, two RN versions must coexist, controlled by an AB test that routes the C group to the old version and the T group to the new one. The main challenges are:
Static linking cannot handle duplicate symbols, making manual symbol renaming impractical for a large codebase.
Dynamic linking requires loading separate RN dynamic libraries at runtime, but static linking still fails when business code references RN symbols.
Proposed Dual‑Dynamic‑Library Solution
The team introduced an intermediate layer called NEReactNative that declares all RN symbols needed by business code, ensuring static linking succeeds while remaining invisible to the business layer. At runtime, the appropriate RN dynamic library is loaded based on AB test results, and placeholder symbols are bound to real symbols.
Symbol Retrieval
A utility class collects addresses of global variables, functions, and class symbols when building the new and old RN dynamic libraries.
@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: populate symbols and classes
});
}
+ (Class _Nullable)getClass:(NSString *)name {
[self prepare];
return (__bridge Class)[classes[name] pointerValue];
}
+ (void *_Nullable)getSymbol:(NSString *)name {
[self prepare];
return [symbols[name] pointerValue];
}
@endGlobal symbols are declared with extern to let the linker bind them automatically.
#define INCLUDE_SYMBOL(NAME) \
do { \
__attribute__((visibility("hidden"))) extern void NAME; \
symbols[@(#NAME)] = [NSValue valueWithPointer:&NAME]; \
} while (0)
INCLUDE_SYMBOL(RCTJavaScriptDidLoadNotification);
INCLUDE_SYMBOL(RCTBridgeModuleNameForClass);Global Variable/Function Replacement
Placeholder variables are defined and later replaced with real addresses:
#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);
}
@endFunctions use a naked assembly stub that jumps to the real implementation:
#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);
}
@endClass Symbol Binding
Placeholder Objective‑C classes are created and at runtime swapped with real classes. The process handles:
Method forwarding for class methods.
Overriding +alloc, -init, and +new to instantiate real objects.
Injecting Category methods and Protocol lists into the real class.
Special handling for subclasses to ensure overridden methods call the subclass implementation rather than the base class.
When a subclass object is created, a proxy subclass inheriting from the real class is generated, and the proxy object is stored in brokerObject. Method calls are forwarded appropriately, preserving super semantics.
Implementation Issues Encountered
Direct Instance Variable Access
Some UIKit methods (e.g., -addSubview:) and RN classes ( RCTShadowView) directly access instance variables, which the method‑forwarding approach cannot intercept. The team swizzled these methods to replace the passed‑in object with the real class instance.
API Differences Between RN Versions
New RN versions expose functions such as RCTPLLabelForTag that older versions lack. The solution adds bridge functions that jump to the new implementation when available and guards calls with version checks to avoid crashes on older RN builds.
Conclusion
The intermediate layer enables a zero‑impact, data‑driven RN version switch. Business code remains unchanged, and the upgrade proceeds via AB testing: traffic is gradually shifted, metrics are collected, issues are resolved, and the new RN 0.70 version is eventually rolled out to all users.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
