ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

React Native原生UI封装全解析:UIManager、ViewManager与ShadowNode深度拆解

React Native原生UI封装全解析:UIManager、ViewManager与ShadowNode深度拆解 React Native的界面最终呈现给用户的其实就是一整棵原生View树。平时我们用JSX写的View /、Text /经过RN的渲染管线逐步变成iOS的UIView或Android的ViewGroup。这个过程中最容易被忽视、又最值得研究的环节正是Native UI的封装和管理——从JS组件到原生组件的注册、创建、挂载、更新每一步都有清晰的设计逻辑。这篇源码分析文章我从一个既写业务、又被迫下到原生层的工程师视角把UIManager、ViewManager、ShadowNode这三个角色的分工以及从JS到原生View的完整生命周期讲清楚。当你项目出现启动白屏、首屏渲染慢、自定义原生组件接入困难时这篇文章能帮你快速定位问题出在JS侧还是Native侧。1. 先搞清楚RN渲染管线的整体轮廓1.1 三条线程到底在干嘛很多同学刚接触React Native时看到网上的架构图就发怵觉得三线程模型特别抽象。我用一句话归纳JS线程负责“算”Shadow线程负责“量”UI线程负责“画”。JS线程运行你的业务JS代码处理逻辑、状态、事件同时跑着React的reconciler。setState之后新的虚拟DOM树在这里生成。Shadow线程接收JS线程已经处理好的节点信息把这些节点转成ShadowNode交给Yoga做布局计算。计算完的产物是一棵树这棵树上的节点带有每个View在父容器里的x、y、width、height等精确布局信息。UI线程也就是主线程负责真正创建UIView/ViewGroup设置frame/布局参数执行addSubview、removeFromSuperview这类操作最终把View画到屏幕上。为什么一定要有Shadow线程直接让JS线程算完布局丢给主线程不香吗这里有一个现实原因主线程承载着滚动、手势、系统动画如果布局计算频繁占用主线程用户划两下就卡。Yoga的纯CPU计算并不轻尤其是复杂列表和深层嵌套布局放单独线程能有效隔离压力。RN早期版本踩过的坑都在这里后来他们把所有纯计算逻辑全部挪出主线程才保证滚动不掉帧。理解了这个基础你就能明白一个很反直觉的现象JS侧执行了一段创建View的代码UI线程并不会立刻同步出现对应控件所有操作都是“发消息”过去的。这个异步设计是所有跨线程框架的第一课。1.2 数据是怎么从JS流到原生的如果你想在调试器里追踪一个View从无到有的过程在JS侧断点能看到的第一站是UIManager。JS侧调用的UIManager.createView、UIManager.updateView、UIManager.setNativeProps等方法这些方法通过Bridge旧架构或者JSI新架构发送到Native模块。拿老架构讲Bridge的本质是一个异步消息队列。JS侧调用Native方法时方法名和参数会被序列化排到队列里等批量发送。Native侧收到一批消息后由RCTUIManageriOS或UIManagerModuleAndroid分发。UI操作被设计成批量执行JS一次交互中多次修改UI最终合并成一次Native调用避免跨线程通信带来的性能损耗。用一句大白话总结整个渲染管线是“JS端排单——Shadow端出图——UI端上墙”并且每一步都是异步批量完成的。下面我们就进入核心部分看看各个角色具体怎么工作。2. 拆一拆Native UI封装中的三个核心角色2.1 UIManager——所有UI操作的入口UIManager在源码里是一个“大总管”角色。JS侧几乎所有的原生UI操作都要经过它所以你会在node_modules/react-native/Libraries/ReactNative/UIManager.js里看到一批方法createView setChildren manageChildren updateView setNativeProps removeSubviewsFromContainerWithID dispatchViewManagerCommand先看最常用的createView。JS侧创建一个原生View时会传入四个参数reactTag: 这个View在整棵虚拟树里的唯一标识 viewName: 原生组件名比如RCTView、RCTText rootTag: 根节点标识 props: 初始属性Native端的UIManager拿到这些参数后会进行几件关键的事。第一根据viewName到已注册的ViewManager列表里找到对应的Manager第二创建对应的ShadowNode塞进Shadow树第三把真正的UIView创建动作放到一个批处理block里等待合适的时机在UI线程执行。这里有一个值得注意的细节createView返回给JS侧的不是一个对象而就是一个数字reactTag。JS侧不持有任何原生的View引用手里只有一串ID。这种设计避免了JS对象和原生对象之间的强引用关系也减少了内存泄漏风险但代价是JS侧想直接操作某个View时必须通过这个tag去绑定。很多新手写setNativeProps报错“node不存在”本质是tag对应的原生View已经被销毁或者压根没有走到Native创建流程。2.2 ViewManager——真正认识原生组件的“注册表”ViewManager是纯原生的概念每一个可被JS引用的原生组件在Native侧都必须有一个对应的ViewManager子类。以iOS为例RCTViewManager的子类会通过宏RCT_EXPORT_MODULE(name)把自己注册到Bridge中。这个注册表里存放的是“组件名 - Manager实例”的映射关系当UIManager.createView被调用时就是靠这张表找到真正能创建UIView的对象。每个ViewManager里定义了几个关键方法view返回这个Manager管理的原生View实例。set_xxx:forView:由RCT_EXPORT_VIEW_PROPERTY宏自动生成的setterJS属性变化时运行时通过NSInvocation动态调用对应setter。shadowView返回对应的ShadowNode实例用于布局阶段。Android侧逻辑类似ViewManager是一个接口常用实现是BaseViewManager和SimpleViewManager。子类重写getName()返回组件名重写createViewInstance()创建原生View重写ReactProp注解的方法解析props。ViewManager的意义其实可以类比成一个“翻译官”它既知道怎样把JS props填成原生View的属性也知道怎样把一个原生能力暴露成JS接口。你在业务层自定义原生组件时本质上就是在写一个新ViewManager这是后面实战部分的核心。2.3 ShadowNode——布局计算的影子层Shadow树是RN架构里相对低调但极重要的一层。每个原生View在Shadow线程上都有一个对应的ShadowNodeiOS端是RCTShadowViewAndroid端是ReactShadowNodeImpl。ShadowNode不直接持有UIView或ViewGroup而是专属于布局引擎的节点记录着父子关系、样式属性、Yoga计算出的布局结果。为什么需要一套“影子”结构因为React的虚拟DOM树不能直接驱动原生UI原因很简单原生View对象重、创建成本高不宜在每次布局前被创建出来。ShadowNode很轻量只是一些数值和引用Yoga可以在上面反复计算、脏检查只有真正需要上屏时才生成最终的原生View。对齐关系也很明确JS虚拟DOM树的一棵子节点对应Shadow树的一个节点Shadow树节点有符合条件的布局后才对应Native树里的一个UIView。三者不是一一对应的关系存在异步时间差这正是前端框架和原生UI之间的“调和reconciliation”过程。3. 从JSX到原生View的完整生命周期3.1 JS侧如何发起创建请求当你在JS里写出View style{{ width: 100, height: 50, backgroundColor: red }} /React Native在commit阶段会把这个类型的组件识别为“需要由原生层渲染的宿主组件host component”。接着React reconciler调用UIManager.createView把这个View的tag、组件名、rootTag和props全部打包缀在待发送消息队列里。老架构下JS到Native的这一批调用最终会在JS事件循环末尾通过BatchedBridge.flushQueueToNative一起发给Native层。这里有一个非常重要的机制createView并不会马上执行所有操作都在一批里头。所以如果你在JS中并行创建1000个ViewNative侧不会创建1000次原生View而是收到一个包含1000条创建指令的消息包再逐条消费。3.2 Shadow线程完成布局Native侧收到创建指令后UIManager不会直接在UI线程干活而是先进入Shadow线程。UIManagerModule.onBatchComplete触发时会调用calculateRootLayout让Yoga基于Shadow树重新计算布局。每个ShadowNode会跑一遍Yoga的layout算法算出该节点在父容器内的精确位置和尺寸这些值最终会写回到节点的frame或布局数据里。布局完成后RCTUIManager会把这些布局结果和待创建的View信息打包成一个UIBlock通过addUIBlock插入到UI线程执行队列里。UIBlock是本批UI操作的“施工单”里面包含要创建的View、要设置的属性、要执行的addSubview操作以及布局坐标。这个设计保证了UI线程只需按工单执行不用处理复杂的数据转换。3.3 UI线程创建View并挂载UIBlock被调度到主线程后UIManager通过createView:viewName:rootTag:props:创建真正的View。它首先找到对应的ViewManager调用manager.view拿到一个空的原生View实例。然后遍历props把每个键值对交给ViewManager对应的setter设置frame、颜色、层级这类参数。最后一步是挂载UIManager调用原生UIView/ViewGroup的addSubview或addView把刚创建的View加入父View。注意这里的父子关系是由JS侧虚拟DOM树的父子关系决定的但真正生效是在UI线程执行UIBlock时一次性递归完成的。所以你在屏幕上看到一整个页面出现往往是大量UIBlock在主线程快速执行后的结果。3.4 更新、复用与Native交互View创建之后并不代表后续的更新也走同样的重流程。JS侧props变化后updateView被调用Native层会比较新旧props能复用的View绝不重建只调用对应setter更新属性。这就是为什么原生组件性能上限高——它天然支持细粒度更新不需要每次都重建。setNativeProps则是另一种更新路径用于高频直接操作某个已有View的属性比如快速修改透明度、位移等不需要走完整的协调器流程。更新会直接通过Bridge发送到UI线程让原生View立即改变。但它也有副作用跳过React渲染流程意味着状态不同步业务代码里要谨慎使用否则容易引发视图与状态不一致的bug。原生向JS方向的通信则靠事件机制。原生View通过eventDispatcher或EventDispatcher发送一个事件名和参数JS侧通过onChange这类props接收。这是一个从“原生封装”到“JS反馈”的闭环。4. 一个自定义Native组件封装的实战4.1 iOS侧实现ViewManager源码看得再多不如自己动手封装一个原生组件。我用一个横向进度条组件举例业务场景是下载进度展示RN自带组件无法满足精细样式需求。iOS侧先创建一个继承RCTViewManager的Manager类#import React/RCTViewManager.h #import React/RCTView.h interface RNProgressView : RCTView property (nonatomic, strong) UIProgressView *progressView; property (nonatomic, strong) NSNumber *progressValue; end implementation RNProgressView - (instancetype)initWithFrame:(CGRect)frame { if (self [super initWithFrame:frame]) { _progressView [[UIProgressView alloc] initWithProgressViewStyle:UIProgressViewStyleDefault]; _progressView.autoresizingMask UIViewAutoresizingFlexibleWidth; [self addSubview:_progressView]; } return self; } - (void)setProgressValue:(NSNumber *)progressValue { _progressValue progressValue; _progressView.progress [progressValue floatValue] / 100.0f; } end interface RNProgressViewManager : RCTViewManager end implementation RNProgressViewManager RCT_EXPORT_MODULE(RNProgressView) RCT_EXPORT_VIEW_PROPERTY(progressValue, NSNumber) - (UIView *)view { return [[RNProgressView alloc] init]; } end这段代码里RCT_EXPORT_MODULE(RNProgressView)是把Manager注册到BridgeJS侧以后就用RNProgressView这个名字来引用。RCT_EXPORT_VIEW_PROPERTY是关键它自动把JS的progressValue属性映射到原生View的setProgressValue:方法上所以JS传的progressValue变化时原生View会自动收到新值并刷新进度条。4.2 Android侧实现ViewManagerAndroid侧的实现思路一模一样只是用Java/Kotlin。继承SimpleViewManagerProgressBar管理一个原生ProgressBarimport android.widget.ProgressBar; import androidx.annotation.NonNull; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.uimanager.SimpleViewManager; import com.facebook.react.uimanager.annotations.ReactProp; public class RNProgressViewManager extends SimpleViewManagerProgressBar { Override NonNull public String getName() { return RNProgressView; } Override NonNull protected ProgressBar createViewInstance(NonNull ReactApplicationContext reactContext) { ProgressBar progressBar new ProgressBar(reactContext, null, android.R.attr.progressBarStyleHorizontal); progressBar.setMax(100); return progressBar; } ReactProp(name progressValue) public void setProgressValue(ProgressBar view, int value) { view.setProgress(value); } }注册进ReactPackageOverride protected ListViewManager createViewManagers(ReactApplicationContext reactContext) { ListViewManager managers new ArrayList(); managers.add(new RNProgressViewManager()); return managers; }Android端还要在MainApplication.java里把包加进getPackages()这一步很多新手会忘导致JS侧报“RNProgressView is not registered”。4.3 JS侧声明并接入JS侧很简单用requireNativeComponent把原生组件名注册成一个React组件import { requireNativeComponent } from react-native; const RNProgressView requireNativeComponent(RNProgressView); export default function ProgressView({ value, style }) { return RNProgressView progressValue{value} style{style} /; }这样业务里就能直接用ProgressView value{downloadedPercent} style{{ height: 6, width: 200 }} /如果你用的是新架构强烈建议用codegenNativeComponent它能自动生成类型声明避免JS和Native属性类型不一致的坑。4.4 完整示例封装一个自定义WebView我再举一个更务实的例子当第三方WebView库不满足需求时你会需要自己封装。原生端需要提供WebView的加载方法、加载进度回调、JSBridge消息透传。这时候除了props你还需要命令Commands机制让JS主动调用原生方法比如reload()、goBack()。iOS侧在Manager里用RCT_EXPORT_METHOD导出方法但要在Manager方法里通过tag找到对应View再执行操作RCT_EXPORT_METHOD(reload:(nonnull NSNumber *)reactTag) { [self.bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionaryNSNumber *, UIView * *viewRegistry) { RNWebView *view (RNWebView *)viewRegistry[reactTag]; [view reload]; }]; }JS侧调用命令的完整路径是UIManager.dispatchViewManagerCommand( viewTag, UIManager.getViewManagerConfig(RNWebView).Commands.reload, [] );这就是为什么在源码里你会看到dispatchViewManagerCommand这个API——它解决了“JS需要主动让原生View执行动作”的问题。很多业务层的疑难bug比如“WebView没触发刷新”“地图组件不重绘”最后都可能定位到命令调用时机不对。5. 新架构Fabric下的变化以及老项目怎么平滑理解5.1 Fabric vs Paper核心变化RN的新架构叫Fabric它没有推翻“JS - 布局 - 原生View”的流水线但对“封装修饰”做了大幅简化。旧架构的问题是JS线程、Shadow线程、UI线程三者各管一摊通讯全靠异步消息队列性能虽可接受但链路长、延迟高。Fabric引入了C核心层把Shadow树整体下沉到C并且把数据类型统一成HostInstance之类的通用结构避免每次JS/Native通信都做序列化/反序列化。还有一个革命性变化Fabric的mounting操作创建、更新、删除View是在一个独立的Mounting线程上生成的批量发给UI线程执行但整个过程的目标是尽量将工作放在后台线程完成UI线程只做最终提交。旧版的Shadow线程和UI线程之间的UIBlock其实已经是一套类似机制但实现上C层面的调度更精细。5.2 JSI、TurboModule与codegen旧架构通过Bridge传递JSON序列化消息新架构用JSI直接暴露C方法给JS调用。UIManager这个模块在Fabric里并没有消失而是实现被替换成了C版本JS侧依然调用同样的API。TurboModule替代了RCTBridge里的模块加载机制改成了按需懒加载不再初始化所有原生模块这对启动性能有实打实的提升。codegen是配套产物它在编译期检查JS侧组件声明自动生成给Native调用的C/Java/Objective-C代码。自定义原生组件在新架构下的接入方式不再依赖运行时字符串查找而是由codegen生成强类型接口编译期就能发现属性拼写错误。5.3 对白屏优化和首屏渲染的影响白屏问题本质上是JS bundle还没执行完原生View树构建不出来用户只看到空白背景。老架构下首屏必须经历“加载JS包——执行JS代码——遍历虚拟DOM——发消息到Shadow——布局——UIBlock上屏”链路长任何一环慢都会白屏。Fabric引入的懒加载、同步布局、更高效的批量mount会缩短首屏链路。但注意Fabric并不能解决JS bundle体积过大、执行时间过长的问题那是另一个层面的瓶颈。所以我看到很多人在新架构下依然遇到白屏第一反应“是不是Fabric的Bug”其实多数是JS执行被阻塞了。排查时先看JS侧再看原生侧不要一上来就怀疑框架。6. 常见问题与排查技巧实录6.1 调用setNativeProps提示node不存在这个问题我接手过好几个项目都有发生。通常原因是你拿到的reactTag对应的View已经被移除了但仍然在JS侧执行setNativeProps。比如列表滚动时复用了组件旧tag失效此时setNativeProps就会落空。排查思路很直接在Native侧给addUIBlock里的viewRegistry加日志看看查不到对应View时JS上报的是什么tagJS侧打印一下调用setNativeProps前后的ref.current是否一致。业务层的正确姿势是尽量用state驱动UI少用setNativeProps。如果确实需要高频调用要结合onLayout回调把动态数据绑定到原生侧而不是在JS侧反复操作一个可能已销毁的节点。6.2 Native组件无法响应JS侧更新的状态常见场景是你通过requireNativeComponent(RNProgressView)封装了一个组件JS侧更新progressValue时原生进度条不刷新。十有八九是RCT_EXPORT_VIEW_PROPERTY没写对或者Android端的ReactProp属性名与JS props大小写不一致。还有一类隐蔽情况是自定义ViewManager没有正确设置view的sizing。iOS侧如果自定义View内部有子控件需要在layoutSubviews里更新子控件frameAndroid侧则要注意setLayoutParams是否正确设置。6.3 大量子View挂载导致主线程卡顿嵌套层级深、一次性创建大量原生View时UI线程要连续执行大量addSubview、setFrame主线程就会顶不住。这里有几个实践建议减少不必要的JSX深层嵌套使用FlatList而不是一次性渲染大列表如果单个View内部需要密集绘制考虑直接封装一个自定义原生View用painter/drawRect一次性绘制而不是几千个View叠加。6.4 启动白屏如何定位定位白屏先分阶段打点。第一阶段bundle加载完成第二阶段React app根组件mount完成第三阶段UIManager收到createView第四阶段UIBlock在主线程执行。我在真实项目中定位过一个白屏问题最终发现是某个原生模块在AppDelegate的didFinishLaunchingWithOptions里同步执行了网络请求和UI初始化导致主线程被阻塞了400ms以上。这种情况在JS侧怎么查都查不到因为卡点在Native启动逻辑。反过来说如果Native启动很快但首帧迟迟不出现就要回头检查JS bundle的加载路径和模块初始化是否太慢。6.5 排查技巧与调试工具调试Native UI封装问题时我常用的工具组合iOS用Xcode调试RCTUIManager在addUIBlock或createView方法里打断点查看每次创建的viewName和propsAndroid用adb logcat过滤ReactNativeJS和ReactNative标签看onBatchComplete时序通用打开React Native的showFullPerformanceMonitor观察UI线程与JS线程的帧耗时。另外一个很实用的做法是在自定义ViewManager里临时加日志打印出每次setter调用的参数值。别小看这几行日志它能快速告诉你JS属性到达Native时的真实值排除掉JS解析层的偏移。从源码到业务最后分享一点体会React Native的Native UI封装和管理说复杂确实涉及三线程、Bridge/JSI、大量Native类说简单核心只有三件事注册组件通过ViewManager暴露给JS、创建UIManager根据tag和viewName生成原生View、更新props/命令/事件三条通道维持双向同步。把这套机制理解透你再看RN的任何UI相关报错心里都会有底。我自己从业务开发转到底层适配时最大的转变就是不再把自定义原生组件当成玄学而是当成一个普通的“Manager注册 属性映射 命令分发”流程。如果你也想踩一遍坑再加深印象建议先从封装一个最简单的Text样式组件开始把iOS和Android两个平台的注册流程各走一遍很快就会摸清RN这套设计里真正的边界在哪里。
返回列表