列表性能优化居然会导致曝光错误?RN FlatList removeClippedSubviews 踩坑实录

导语

在 React Native 开发中,我们经常会使用 FlatListremoveClippedSubviews 属性来优化列表性能。这个看似简单的优化属性,却在我们团队中引发了一场数据统计的"灾难":列表中的元素明明没有出现在视口中,却总是被错误地统计为"已曝光"。经过深入排查,我们发现这背后涉及到了 RN 的视图树管理机制、iOS 和 Android 的平台差异,以及新旧架构的演进。本文将详细讲述这个问题的发现过程、原因分析,以及最终的解决方案。

一、属性简介

React Native 官方文档在如何优化 FlatList 性能 这篇博客,提到过一个名为 removeClippedSubviews 的属性,说是可以减轻主线程的计算负担,从而减少掉帧的出现。

其原理是: 把一些没出现在视图里的 view 从它的 superview 上卸载下来(但仍然保留在内存中)

二、背景

我们业务有一个 IntersectionObserverView 组件,它使用一个 View 包裹一个目标组件(此处简称 target),使用 RN 的 RCTUIManager 提供的 measure 方法来测量 target 出现在 RCTRootView 中的面积是否达到指定的要求,从而判定此组件是否「曝光」,然后触发对应的回调,主要用于业务曝光埋点需求。

测量原理如下图,measure 方法会返回 target 相对于 RCTRootView 四边的距离。

根据这四个距离,我们可以计算出 target 与 RCTRootView 相交的面积,通过「暴露面积比例」这个指标,判定元素有百分之多少暴露在视口内,从而判定它曝光。

问题来了:在使用过程中,发现该组件在某些情况下会出现误报的情况。

这里所讲的问题,只有在 iOS 上才会出现,所以我们主要先讨论 iOS 上为什么会有问题,至于 Android 为什么是好的,先按下不表,留到文末对比叙述。

三、问题表现

我们有一个横向滑动列表(结构见下方例子),每个元素都套了一个 IntersectionObserverView,用于曝光埋点。

视图结构的简要 RN 代码表示如下:

tsx 复制代码
const CustomComponent = ({ item }) => {
    return (
        <IntersectionObserverView>
            <Book>{item}</Book>
        </IntersectionObserverView>
    );
};

const App = () => {
    const data = ['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5', 'Item 6', 'Item 7'];

    return (
        <FlatList
            horizontal
            removeClippedSubviews
            data={data}
            renderItem={({ item }) => <CustomComponent item={item} />}
            keyExtractor={(item, index) => index.toString()}
        />
    );
}

这个列表大概有 10+ 个元素,一般来说,手机屏幕下,如果用户不滑动,只能看到 3~4 个

但我们发现:

第 4 个及它之后的元素,总是会上报曝光,而且曝光率是 100%

到这里,数据分析师们就不乐意了:你这上报都是错的,我要怎么统计!抓狂!😤

于是他们提出: 再补充一个滑动动作埋点,简单验证一下,如果滑动的 UV 远远少于后面书本的曝光 UV,就说明曝光是有问题的。

我们通过 onScrollBeginDrag 添加埋点后发现:

上述问题是我们的 bug,因为通过 onScrollBeginDrag 反映的滑动上报数量和「需要滑动才能看到的」元素曝光数量是对不上的。

那就要查一查了。

四、分析排查

通过一波打日志 debug,发现后面那几个视觉不可见的元素, 计算出来 「与视口相交的面积比」 竟然是 1,说明计算面积部分的代码有问题。

我 Review 了一下计算逻辑,没有看到明显的漏洞,说明是 left/right/top/bottom 四个测量值有问题。

而这四个值是谁给出的呢?是 RN 官方提供的 measure API 给出的。

这就要看看源码里的实现了。

measure 的具体实现

我们业务中的使用方式,是通过调用某个 View 的 ref 上面的 measure 方法来测量的,使用的方式如下?

为什么能够在 JS 层直接调用 viewRef.current.measure 方法呢?

那方法的逻辑在 JS 层还是 Native 层呢?

实际上,RN 框架给 React 加了一种类型,叫 HostComponent(宿主组件类型),并往上面挂了一些方法,例如 blur, focus, measure, measureInWindow 等方法,供 JS 直接调用,这些方法在 RN 文档关于 Direct Manipulation 的部分有介绍。

我们找到挂载方法的地方,发现实际上调用的是 RCTUIManager 上面暴露的 native 方法。

这下知道源码在哪了,我们打开 XCode, 到 RCTUIManager.m 看看实现。

容易看出,其原理是通过 view.superview 向上找到最顶部(最多到达 RCTRootView 结束)的祖先 view,然后返回 target 在祖先 view 坐标下的 bounds。

看起来似乎没啥问题,但是这几行代码让我联想到了 removeClippedSubviews

objc 复制代码
UIView *rootView = view;
while (rootView.superview && ![rootView isReactRootView]) {
    rootView = rootView.superview;
}

如果,我是说如果,target 一直往上找,到达的终点不是 RCTRootView,是不是就有可能出现测量不准的情况?

例如某一坨节点被从整个 View 树上卸载了下来。

既然有这个疑问,再来看看 removeClippedSubviews 的实现。

removeClippedSubviews 的逻辑

大致原理就是,如果对一个 View 设置了 removeClippedSubviews={true},就会走下面的逻辑:

  • 如果父 view 完全包含子 view,就把子 view 完整挂载上来
  • 如果父 view 和子 view 有相交部分,就递归调用此方法
  • 如果父 view 和子 view 完全不相交,就对子 view 调用 [view removeFromSuperview]

一言以蔽之:如果一个 View 开启了 removeClippedSubviews 选项,它的「视口内不可见的」子 View 都会被调用 [subview removeFromSuperview]

两者结合产生的问题

由上面可以知道,如果子 view 与其父容器已经不相交,就会被从它的 superview 上卸载下来。

那么很容易想到,此时如果对这个 view 调用 measure 方法,在通过 superview 往上走的过程中,并不能够到达 RCTRootView,而是在到达之前就会停在中途某个 view 上(具体中间有多少层级,取决于用户的具体布局)

这样一来,measure 得到的 bounds,就不是 target 相对于 RCTRootView 的 bounds。

用数学一点的语言来讲,连相对的坐标系都是错的,测量的结果肯定是错的。

此时 IntersectionObserverView 内部逻辑计算得到的相交比例就是错误的。

有一种很典型的情况:

假设 measure 方法在 target 往上爬的时候,只爬了一层就停下来了,那么此时通过返回的 bounds 计算出来的相交比例,很有可能接近于 1。

为什么呢,因为在日常布局中,我们往往会有一两层 bounds 几乎是一样的容器。

此时,就会被误判为「target 和根视图的相交比例很高,可以认为 target 几乎整个完全暴露在视图内了」,从而误认为target 曝光,然后上报错误的数据。

五、问题的本质

  • 对列表设置 removeClippedSubviews 属性,导致列表中不可见的 view 被从 superview 上卸载
  • measure 得到了不是相对于 RCTRootView 的 bounds,导致相交比例是错的,并误认为曝光
  • 从 measure 的返回值中,我们无法得知最终测量的相对祖先到底是不是 RCTRootView

六、解决方法

(临时) 使用 measureInWindow 替代

业务上因为这个问题关联到数据,所以希望能尽快热更新出去,不受版本限制,所以想了个临时的办法------使用 measureInWindow 来代替。

使用此方法的前提是,RCTRootView 和 window 在视觉尺寸上几乎等价,或者换句话说,RCTRootView 不是以部分形式存在的,而是几乎占满整个屏幕。

measureInWindow 也是 RCTUIManager 提供的 API,不过它的逻辑和 measure 有点不同,它不会通过 superview 来一直往上爬来获取 RCTRootView,而是直接通过 [view.window] 获取 view 关联的 window 对象,然后使用 convertRect 来转换坐标。

而我们参考 UIView/removeFromSuperview - Apple 文档 可以知道:当一个 view 被通过调用该方法移除时,它不再连接到任何 window。

所以,在这种情况下,我们使用 measureInWindow 方法去测量一个 target 时,如果 target(或target 的某个祖先/父亲)已经被从它的父 View 移除,则我们通过 [view window] 拿到的是 nil ,从而转换坐标得到的 bounds 是 (0,0,0,0),由此得出的相交面积必然为 0,从而相交比例也是 0,恰好符合「未曝光」的性质。

(彻底) 自定义 measure

比较彻底的解决方案是:原生桥一个自定义的 measure 方法给 RN,多带一个信息,告诉我们相对祖先是不是 RCTRootView,然后交由业务侧自己判断是否要采用 or 相信它的返回结果。

背景知识补充

  • 业务初始化 RN bundle 的时候,会使用 MYRCTBundleLoader 类,这个类负责初始化 RCTBridge
  • 在 RN 框架内部,RN 的每个 View 都通过 reactTag 来标识
  • RN 在 UIView+React.m 这个类别里为 UIView 增加了 isReactRootView 方法,用来判断一个 View 是不是 RCTRootView(其实逻辑很简单,就是看 self.reactTag 尾数是否为 1)
  • RCTUIManager 是 RN 的一个核心模块,它使用 viewRegistry 来维护 JS 端组件 reactTag 与原生视图实例之间的映射关系,根据 JS 端传递的组件类型(如 RCTView、RCTText)和属性,在主线层执行 JS 发送过来的创建/更新视图的命令,在原生端创建对应的视图实例(当组件从 React 树中移除时,调用 removeSubview 或类似方法释放原生资源)。 。

代码逻辑

这段代码主要是模仿 RN 原来 RCTUIManager.m 中的实现,主要步骤如下:

  1. 先通过 bundleLoader 单例拿到 bridge 实例,从而拿到 RCTUIManager 实例
  2. RN 的每个 View 都通过 reactTag 来标识,从参数里拿到 reactTag
  3. 调用 uiManager 的 addUIBlock 方法,在 UI 线程添加一个任务
  4. 根据 reactTag 从 viewRegistry 中拿到 View 实例
  5. 沿着拿到的 view 的父 view 一直往上查找,找到最顶部的祖先 view
  6. 调用 [view isReactRootView] 方法判断祖先 view 是否为 RCTRootView
  7. 给回调增加一个返回值,用于表明此测量结果是否相对于 RCTRootView
objc 复制代码
- (void)customMeasureReactView:(NSDictionary *)params
                      resolver:(MYBridgeResolveBlock)resolve
                      rejecter:(MYBridgeRejectBlock)reject
{
    MYRCTBundleLoader *bundleLoader = [[MYRCTBundleLoaderManager sharedInstance] bundleLoaderWithKey:BundleLoader_Default];
    RCTBridge *bridge = bundleLoader.bridge;
    id tagObj = [params objectForKey:@"reactTag"];
    if (![tagObj isKindOfClass:NSNumber.class]) {
        if (reject) {
            reject(@"0", @"Type Error: expected parameter reactTag to be a number", nil);
        }
        return;
    }
    NSNumber *targetReactTag = (NSNumber*) tagObj;
    dispatch_async([bridge.uiManager methodQueue] , ^{
        [bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *,UIView *> *viewRegistry) {
            UIView *view = viewRegistry[targetReactTag];
            if (!view) {
                NSLog(@"invalid empty view pointer");
                if (reject) {
                    reject(@"0", @"Cannot retrieve view from viewRegistry by the given reactTag", nil);
                }
                return;
            }
                
            UIView *rootView = view;
            while (rootView.superview && ![rootView isReactRootView]) {
                rootView = rootView.superview;
            }
            BOOL relativeToRootView = [rootView isReactRootView];
            CGRect frame = view.frame;
            CGRect globalBounds = [view convertRect:view.bounds toView:rootView];
            
            NSDictionary *result = @{
                @"x": @(frame.origin.x),
                @"y": @(frame.origin.y),
                @"width": @(globalBounds.size.width),
                @"height": @(globalBounds.size.height),
                @"pageX": @(globalBounds.origin.x),
                @"pageY": @(globalBounds.origin.y),
                @"isRelativeToReactRootView": @(relativeToRootView),
            };
            if (resolve) {
                resolve(result);
            }
        }];
    });
}

七、为什么 Android 没有这个问题?

还记得我们前文说过,本文所叙述的问题只存在于 iOS 上,Android 没有这个问题。

下面让我们一探究竟。

RN 的 Android 部分封装层数比较多,measure 的入口在 UIManagerModule 的 measure 方法,实际调用链路是

js 复制代码
UIManagerModule.measure(reactTag, callback)
-> mUIImplementation.measure(reactTag, callback);
-> mOperationsQueue.enqueueMeasure(reactTag, callback);
-> mOperations.add(new MeasureOperation(reactTag, callback));
-> MeasureOperation.execute()
-> mNativeViewHierarchyManager.measure(mReactTag, mMeasureBuffer);

最终是在 NativeViewHierarchyManager 类 measure 方法中完成的:

可以看到,大致逻辑和 RN iOS 的 measure API 差不多,先根据 reactTag 拿到 View,然后拿到祖先 view,然后计算相对坐标。

核心在这个位置,这里有一个保护性的判断,如果 getRootView 方法拿不到 rootView,就会直接抛异常,不会返回结果

kotlin 复制代码
View rootView = (View) RootViewUtil.getRootView(v);
// It is possible that the RootView can't be found because this view is no longer on the screen
// and has been removed by clipping
if (rootView == null) {
    throw new NoSuchNativeViewException("Native view " + tag + " is no longer on screen");
}

但这样足够了吗?还没 100% 确认呢,我们看看 getRootView 的实现

kotlin 复制代码
public static RootView getRootView(View reactView) {
    View current = reactView;
    while (true) {
        if (current instanceof RootView) {
            return (RootView) current;
        }
        ViewParent next = current.getParent();
        if (next == null) {
            return null;
        }
        Assertions.assertCondition(next instanceof View);
        current = (View) next;
    }
}

OK,现在很清晰了,getRootView 的实现,在两端都是通过在 View 树往上层遍历得到的,但 Android 的实现更严谨,往上走的过程中,对遇到的 View 都使用 instanceof RootView 来判断,所以最终要么在路径上找到一个 RootView,要么返回空

所以,Android 确实没问题。

八、同类框架实现对比

ByteDance 开源了它的内部跨端框架 Lynx

该框架原生支持简单曝光能力和自定义曝光能力 IntersectionObserver API

可以看到,它提供的几个 API 中,可以指定 measure 时的参照节点:

  • IntersectionObserver.relativeTo()
  • IntersectionObserver.relativeToViewport()
  • IntersectionObserver.relativeToScreen()
  • IntersectionObserver.observe()
  • IntersectionObserver.disconnect()

这样设计刚好解决了本文发现的痛点,API 既简单又能满足业务的常见需求。

进一步看看源码, iOS 的实现,基本就是 RN measure API 的升级版,考虑了 root 节点的可见性,对顶层节点的身份进行了更详尽的判断:

安卓的实现在 LynxIntersectionObserver.java,逻辑和 iOS 的几乎完全一样,这里就不贴代码了。

九、可以给 RN 提 PR 吗?

已经提了,不过意义已经不大,在 RN 新架构版本,RCTUIManager 暴露的 measuremeasureInWindow 方法,已经被弃用了,取而代之的是新架构下统一用 C++ 实现的 measuremeasureInWindow

用 0.78 版本的 RN 起个 demo,可以看到,新架构下的 RN 用的是 C++ 方法(不再两端各自实现一份),方法的逻辑如下:

其实跟原来的逻辑都基本类似:拿到祖先节点 ancestorNode,通过 getRelativeLayoutMetrics(ancestorNode, shadowNode) 方法进行坐标计算和转换,得到 layoutMetrics 结构,稍作转换,返回一个 RNMeasureRect{} 结构,回调给 JS

有个很重要的区别 :旧 RN 架构的 measure,实际上是 RCTUIManager 读取原生 View 的 frame 把坐标返回,而 removeClippedSubviews 的优化操作是在 Native 层面去做的,所以视图树的遍历会受到 s 的影响, 新架构的 RN 有一层 ShadowNode 存在于 C++ 层,ShadowNode 含有布局信息,测量也是发生在这一层的,所以哪怕原生对应的 view 被 remove 掉,也不影响 ShadowNode 这一层的测量结果,所以新架构已经不存在这个问题了。

十、总结

本文讲述了在 React Native 中使用 FlatListremoveClippedSubview 属性时遇到的一个性能优化与曝光统计冲突的问题:

  1. removeClippedSubview 是 RN 官方推荐的列表性能优化方案,它会将不可见的列表项从视图树中卸载。
  2. 当使用 measure API 测量被卸载的列表项时,由于视图树被截断,测量结果会相对于错误的参考节点,导致曝光统计出现误报。
  3. 这个问题仅在 iOS 上出现,因为 Android 的实现有更严格的保护机制。
  4. 临时解决方案是使用 measureInWindow 替代 measure
  5. 彻底解决方案是自定义 measure 方法,增加测量参考节点的判断。
  6. 在 RN 新架构中,由于测量发生在 ShadowNode 这一层,而不是 Native 层,这个问题已经不存在了。
相关推荐
朝阳398 小时前
React Native【实战范例】水平滚动分类 FlatList
react native
EndingCoder8 小时前
React Native 项目实战 —— 记账本应用开发指南
javascript·react native·react.js
程序员小张丶12 小时前
基于React Native的HarmonyOS 5.0房产与装修应用开发
javascript·react native·react.js·房产·harmonyos5.0
程序员小刘13 小时前
HarmonyOS 5对React Native有哪些新特性?
react native·华为·harmonyos
朝阳3913 小时前
React Native【实战范例】银行卡(含素材)
react native
EndingCoder13 小时前
React Native 构建与打包发布(iOS + Android)
android·react native·ios
William Dawson13 小时前
【React Native 性能优化:虚拟列表嵌套 ScrollView 问题全解析】
react native·react.js·性能优化
EndingCoder13 小时前
React Native 性能优化实践
react native·react.js·性能优化
朝阳391 天前
React Native【实战范例】网格导航 FlatList
react native