首页 / 文章

React Native 内存泄漏:追踪 JS 堆内存与原生内存的所有者

了解为何垃圾回收机制无法防止 React Native 应用出现原生层内存泄漏,以及如何找出导致回调函数、JSI 对象和解码后的图像持续存在的原因。

发布者:REACTAPP.TOP阅读约 11 分钟2614 词

来源: The Stack, the Heap, and Garbage Collection: Why Your React Native App Still Runs Out of Memory

当一个 React Native 屏幕被卸载时,人们通常会认为其占用的内存也会随之释放。然而,在该屏幕被打开和关闭十次之后,应用却变得运行迟缓;内存不足的安卓手机甚至会直接导致应用崩溃,而 Xcode 也会提示应用已超出内存限制。既然 JavaScript 有垃圾回收机制,为何还会出现这种情况?

答案是 React Native 应用存在于两个内存空间中,而垃圾回收机制仅能管理其中一个空间。本指南将解释栈、堆以及垃圾回收的实际运作方式,介绍三种特定于 React Native 的内存泄漏模式以及一个关于 hooks 的常见误解,并展示如何利用 Hermes、Xcode 和 Android Studio 等工具来找出那些持续占用内存的组件。

相互维持存在的两个内存空间

一方是 JavaScript 的世界:对象、数组、函数、React 的状态与属性,以及回调函数捕获的闭包。另一方则是原生世界:平台视图、解码后的图像像素、摄像头和视频缓冲区、文件句柄以及由原生模块持有的数据。

这两者是相互关联的,彼此都能维持对方的运行。整洁的 JavaScript 堆信息并不能反映应用程序的总内存使用情况,而组件卸载本身也不会释放它所使用的任何原生资源。牢记这种分离关系是后续所有分析的基础。

栈、堆和垃圾回收的工作原理

有三个概念需要准确界定。

  • 调用栈用于记录当前正在执行的函数。每次调用都会压入一个小型帧,而函数返回时则会弹出该帧。
  • 堆是JavaScript通常用来存储那些可能超出单次调用生命周期的对象、数组、函数和字符串的地方。
  • 垃圾回收器会查找那些不再有任何活跃引用能够访问的堆数据,并回收这些内存以供重用。
  • 一个简单的例子可以具体说明这种关系:

    function showName(user) {
      return user.name.toUpperCase();
    }
    
    showName({ name: "Ada" });
    

    当toUpperCase()执行时,栈中会保存几个帧:对showName的调用以及它所调用的字符串方法。用户对象本身则存储在堆上。当showName返回后,它的帧会被清除,但只有在没有其他引用该对象时,它才会被纳入回收范围。在这段代码中没有任何其他引用,因此该对象可以被回收;如果存在缓存、监听器或模块级变量保留了对它的引用,那么它就不会被回收。

    这就引出了支配其他所有情况的规则:垃圾回收机制会回收无法访问的数据。组件被卸载或局部变量超出作用域时,并不会自动释放任何资源;它们只是移除了访问该数据的一条可能路径而已。

    时机也很重要。垃圾回收器并不保证会在何时执行回收操作。Hermes使用了名为Hades的并发收集器,其设计目的是缩短暂停时间,从而保持用户界面的流畅性(详见Hermes版本发布记录)。因此,导航后内存并未立即下降并不能证明存在内存泄漏。正如Hermes的工程师在关于垃圾回收行为的讨论中所解释的,那些无法被访问的对象可能会一直存在,直到收集器判断内存压力大到值得进行回收为止。所谓内存泄漏,是指在重复执行相同操作时内存持续上升的情况,而非仅指内存下降速度较慢。

    React Native应用中内存为何会持续存在

    如今大多数 React Native 应用都使用 Hermes 引擎运行,不过具体使用的引擎取决于应用及其配置。该引擎会影响堆内存数值和垃圾回收暂停时间的表现,但不会改变第一个调试问题:增长的是 JavaScript 内存还是原生内存?

    下图展示了该边界两侧的典型关系:

    JavaScript runtime                 Native runtime
    ------------------                 --------------------
    state and props       ─────►       views and view layers
    arrays and objects    ─────►       decoded image pixels
    hooks and callbacks   ─────►       camera/video buffers
    JSI values            ◄─────►      TurboModules / C++ data
    

    状态和属性用于驱动原生视图,数组和对象则引用解码后的图像,钩子与回调函数保存着摄像头或视频缓冲区,而 JSI 值则能与 TurboModules 以及 C++ 数据实现双向交互。应将这一边界视为所有权分界线。每当有资源跨越此边界时,都应能够回答三个问题:

    1. 目前谁拥有该资源?
    2. 是什么让它保持活跃状态?
    3. 是哪次具体调用释放了它?

    如果第三个问题的答案是“由垃圾回收器处理”,那应将其视为一个警告信号。对于原生资源而言,这种方式往往并不安全。

    泄漏类型1:持有JavaScript回调的原生模块

    这种模式在浏览器JavaScript中并无真正对应的实现。原生模块可以存储从JavaScript接收到的回调函数,而该回调会使其整个闭包保持活跃状态:包括状态、属性、庞大的草稿对象,甚至是已不存在的页面模型。

    此处有一个效应函数负责监听上传进度,并返回一个用于取消订阅的清理函数:

    useEffect(() => {
      const sub = UploadManager.onProgress((done) => {
        reportProgress(draft.id, done);
      });
      return () => sub.remove();
    }, [draft.id]);
    

    只有当remove()函数确实能指示原生层释放该回调时,这种方式才是安全的。如果缺少清理函数,或者相关库存在缺陷,就会形成类似这样的内存保留链:

    native UploadManager → JS callback → closure → draft → image list
    

    原生的 UploadManager 存储着回调函数,该回调函数又包含其闭包,闭包中保存着 draft 对象,而 draft 则存储着完整的图片列表。卸载界面并不会打破这一数据链,只有取消订阅操作才能做到这一点,因此有必要确认第三方订阅确实已在原生层解除关联,而不仅仅是在 JavaScript 层。

    泄漏问题 2:一个较小的 JSI 对象掌控着庞大的原生资源

    JSI使JavaScript能够以比旧桥接方式更直接的方式与原生对象交互,这不仅提升了性能,还使得对象的所有权问题变得更加重要。想象这样一个场景:某个库打开了一个原生视频解码器、数据库游标或摄像头会话,然后向JavaScript提供一个表示它的轻量级对象。这个JavaScript对象可能非常小,垃圾回收器没有理由急于回收它,而其背后的原生资源却会占用大量内存。

    下面的示例展示了如何打开摄像头会话、启动它,以及在清理阶段停止并关闭它:

    useEffect(() => {
      const session = CameraSession.open();
      session.start();
      return () => {
        session.stop();
        session.close();
      };
    }, []);
    

    close() 方法才是关键。硬件会话、解码器、已打开的文件、光标以及 GPU 分配的资源都应被主动释放,绝不能等待垃圾回收器来处理。由于无法安排垃圾回收,因此它不能作为资源释放的计划,而且终结器可能会延迟执行或根本不执行。

    如果你在维护 JSI 或原生库,应为每个拥有资源的对象提供明确的释放方法,并确保该方法是幂等的,这样调用两次也不会产生任何问题。在应用程序代码中,应在相应的清理点调用该方法。Hermes 团队在 关于垃圾回收的讨论中也给出了相同建议:为原生资源提供类似 close() 的方法,而非依赖终结器机制。

    泄漏问题 3:图像组件已消失,但像素数据仍在

    大量内存不足导致的崩溃都与这种模式有关。一张在磁盘上体积不大的照片,在解码后会变得极为庞大,因为像JPEG这样的压缩格式必须先展开为原始像素才能显示。解码后大小的粗略估算如下:

    width × height × 4 bytes
    

    如果每像素为4字节,那么一张4,000×3,000分辨率的照片大约需要48MB的空间,这还不包括用于缩放、缓存或传输到GPU的额外副本。现在再考虑用来显示这类照片的缩略图:

    <Image
      source={{ uri: fullSizePhotoUrl }}
      style={{ width: 160, height: 160 }}
    />
    

    缩略图的显示尺寸仅为160×160像素,但设备仍可能需要下载并解码其背后的全分辨率文件。在包含许多大尺寸图片的页面中,原生内存使用量会持续上升,而JavaScript堆内存却保持不变,这正是那些只关注JS内存的开发者会感到困惑的情况。

    并非每次内存上升都是泄漏所致。用于存储最近查看图片的图像缓存正在正常发挥作用。只有当每次处理相同流程时内存都会持续增长、始终无法稳定下来,最终导致进程被终止时,才会出现问题。

    首先应解决输入到图像处理流程中的问题:

    • 请求尺寸接近实际显示大小的图片。
    • 在列表中使用缩略图,仅在详细查看页面加载全分辨率图片。
    • 通过列表虚拟化等方式限制同时显示的图片数量。
    • 了解所用图像库的缓存规则,并设置相应的限制。
    • 在真实的低内存设备上进行测试,而不仅仅是模拟器。

    将48 MB的位图问题归咎于Hermes是不对的。JavaScript引擎无法回收仍被图像视图、图像处理流程、缓存或GPU纹理占用的原生像素。

    钩子函数中的过期闭包:认清你所面对的错误

    有一种常见的说法是,错误的依赖数组会导致内存泄漏。虽然确实有可能,但更多情况下它首先会引发错误值问题,而这种区别会影响修复方法。

    闭包会保留其引用的值。如果原生监听器或定时器持有该闭包的时间超过预期,闭包就会继续保留旧数据。在这种情况下,依赖数组为空,因此进度回调只会被创建一次:

    useEffect(() => {
      const sub = player.onProgress(() => {
        savePosition(episode.id);
      });
      return () => sub.remove();
    }, []); // episode may change, but this callback will not
    

    该回调在首次渲染时捕获了episode值。当用户切换剧集时,进度仍然会基于原始ID保存下来。这就是所谓的过期闭包。如果在组件卸载时取消订阅,那就只是正确性错误而非持续的内存泄漏;但如果原生播放器在卸载后仍保留该回调,那就同时存在这两种问题。

    解决办法并非将所有对象都放入依赖数组中,而是让效应准确描述其所拥有的资源,并以定义该资源的值为键:

    useEffect(() => {
      const sub = player.onProgress(() => {
        savePosition(episode.id);
      });
      return () => sub.remove();
    }, [episode.id]);
    

    现在,每当 episode.id 发生变化时,React 会先执行清理操作移除旧的订阅,然后再创建新的订阅,同时回调函数只会获取它所需的那部分数据。通过使用原始 ID 而非整个 episode 对象,还可以避免在对象身份发生变化但剧集内容未变时仍进行重新订阅。

    进行实际的内存检测

    像“应用程序占用大量内存”这类模糊的观察结果并无实际意义。应定义一个可重复的操作流程,例如:打开信息流、滚动浏览、打开十张照片、返回页面,然后重复整个流程五次。

    接着观察测量结果呈现出的模式:

    • 每次循环后JS堆都会增长:内存泄漏很可能出在JavaScript层面,因此应先获取堆快照。
    • 虽然应用总内存在上升,但JS堆基本保持稳定:需使用平台工具查看原生内存情况。

    诊断JavaScript堆的增长问题

    使用Hermes调试工具,在你的React Native版本和构建配置支持的情况下获取堆快照。在快照中选中可疑对象,追踪其引用路径直至源头。常见的问题源通常表现为监听器、定时器、存储对象、缓存、回调函数或模块级单例等名称的元素。

    请注意不要将其与主要用作 CPU 工具的 Hermes 取样分析工具混淆。后者仅能显示哪些 JavaScript 代码处于活跃状态,而无法说明原生图像内存为何会增长。使用 Hermes 进行性能分析的 React Native 指南介绍了如何录制和查看 CPU 分析报告。

    诊断 iOS 上的原生内存问题

    在实体设备上通过 Xcode 运行该应用。首先查看内存指标以了解大致趋势,随后打开“调试内存图”来查看各个对象及其之间的引用关系。Instruments 工具,尤其是“分配”模板,可让你在重复测试过程中监控内存分配情况。苹果的 Xcode 内存使用文档介绍了内存图如何展示对象、内存分配及其关联,以及“分配”模板如何追踪堆内存和虚拟内存的使用情况。

    如果在处理大量图片时 Instruments 显示内存持续增长,而 Hermes 堆内存几乎没有变化,那就无需再寻找 JavaScript 闭包问题了——问题出在原生代码层面。

    诊断 Android 上的原生内存问题

    可以在真实设备上使用 Android Studio 内存分析工具,随时查看应用程序的内存使用情况。该工具自 Android Studio 4.1 起便具备原生内存分析功能,能够记录原生内存的分配与释放情况,并汇总相关对象信息。

    不同版本的 Android Studio 中,该分析工具的界面可能会有所不同,但其分析方法却保持一致:记录内存变化的完整过程,标记每个循环的起始点,同时监测内存是否持续上升而无法回到稳定水平。

    每个界面的清理检查清单

    无论界面启动时做了什么,最终都应进行清理。简单的检查包括:

    • 清除已启动的计时器。
    • 移除添加的监听器。
    • 取消原生订阅关系。
    • 停止并关闭相机、播放器、文件处理或数据库连接。
    • 为创建的缓存设置大小限制或淘汰策略。
  • 较大的图片会生成更小的源文件,同时还会限制可显示的视图数量。
  • 这并非 C 语言中的手动内存管理。你并不是在释放任意字节,而是在应用不再拥有这些资源时结束对其的使用并归还它们。

    总结

    垃圾回收机制从未承诺组件卸载后其涉及的所有内容都会消失。当内存占用上升时,应按以下顺序提出两个问题:增长的是 JavaScript 堆还是原生进程的内存?以及谁仍然拥有正在增长的那部分资源?

    答案直接指出了解决方案。持续的回调意味着有未取消的订阅。原生会话则表示缺少close()函数。48 MB大小的位图则说明你需要使用更小的图片或优化缓存机制。一旦停止等待垃圾回收器,开始找出内存占用的来源并结束其占用关系,React Native的内存问题就不再神秘了。

    在整个过程中都应在真实设备上进行性能分析。模拟器虽可用于重现操作流程,但可能会掩盖导致应用在用户手机上崩溃的内存压力。