DevEco Studio 内存泄漏排查实战:从 TTS 轮询到引擎互斥的完整修复

本文是《React Native x HarmonyOS NEXT 创新能力接入方案》系列番外篇。记录了一个 HarmonyOS React Native 项目在 DevEco Studio 中运行时出现内存泄漏的完整排查与修复过程。根因涉及 ArkTS 侧 setInterval 轮询防重入缺失、TTS 引擎初始化并发无互斥保护、RN 组件卸载未释放资源、以及构建配置缺失等问题。修复后内存占用趋于稳定,timer 和引擎实例不再堆积。

源码: https://atomgit.com/huqi/RNHarmonySkuAssistant

一、问题背景

RNHarmonySkuAssistant 是一个基于 React Native OpenHarmony(RNOH) 的示例应用,演示 RN 调用 HarmonyOS 原生 TurboModule 的最小闭环,包含两个核心模块:

  • SkuModule:SKU 格式化 TurboModule,验证 JS → ArkTS 的基础调用

  • TTSModule:TTS 语音播报 TurboModule,对接 HarmonyOS Core Speech Kit,将商品卖点文字转为语音播放

DevEcho Studio 中调试运行时,出现以下现象:

  • 反复进入/退出 TTS 页面后 IDE 内存持续增长

  • 多次点击"开始播报"后内存不回落

  • 长时间运行后 IDE 出现卡顿甚至 OOM

本文将逐一定位这些问题的根因,并给出完整修复方案。

二、根因分析

2.1 setInterval 轮询无防重入 --- Timer 堆积

TTSModule 使用 setInterval 每 200ms 轮询 ttsEngine.isBusy() 来检测播报是否完成。问题在于:

Timer 无法被可靠清除 :当 speak() 被快速重复调用时,waitForPlaybackComplete 会先调用 clearPlaybackPollTimer() 清除旧 timer,再创建新 timer。但旧 setInterval 的回调可能恰好在 clearInterval 和重新赋值 playbackPollTimer 之间的时间窗口内已经进入事件队列,clearInterval 无法取消已入队的回调。

结果:旧 timer 的回调继续执行,新 timer 也在执行,timer 堆积 。每次循环还会调用 emitTTSStatus,事件也会被重复发送到 RN 侧。

typescript 复制代码
private waitForPlaybackComplete(utteranceId: string): void {
  this.clearPlaybackPollTimer();

  this.playbackPollTimer = setInterval(() => {
    // 没有任何防重入保护------旧 timer 回调仍在执行
    if (!this.ttsEngine) {
      this.clearPlaybackPollTimer();
      this.emitTTSStatus({ type: 'complete', utteranceId });
      return;
    }
    try {
      if (!this.ttsEngine.isBusy()) {
        this.clearPlaybackPollTimer();
        this.emitTTSStatus({ type: 'complete', utteranceId });
      }
    } catch (error) {
      this.clearPlaybackPollTimer();
      // ...
    }
  }, 200);
}

2.2 initEngine 并发无互斥 --- 引擎实例重复创建

isAvailable()speak() 各自独立调用 initEngine(),没有任何互斥保护:

并发创建引擎实例 :TTSPage 组件挂载时立即调用 isAvailable(),用户同时点击"开始播报"触发 speak()。如果 isAvailable() 正在 await this.initEngine() 中,speak() 也会调用 initEngine(),此时 this.engineReady 还是 false(第一个初始化尚未完成),于是 textToSpeech.createEngine 被调用两次,产生两个引擎实例。

第二个 createEngine 返回的引擎会覆盖第一个引用,但第一个引擎的底层资源(音频通道、合成缓冲区等)永远不会被 shutdown(),形成泄漏。

typescript 复制代码
private async initEngine(): Promise<void> {
  if (this.engineReady && this.ttsEngine) {
    return; // 快速返回,但不防并发
  }
  // 并发调用时,两个调用都会走到这里
  return new Promise<void>((resolve, reject) => {
    textToSpeech.createEngine(initParams, (err, engine) => {
      // 第二次调用的 engine 覆盖第一次
      this.ttsEngine = engine;
      this.engineReady = true;
      resolve();
    });
  });
}

2.3 组件卸载未停止 TTS --- 资源未释放

TTSPage 组件的 useEffect 注册了 NativeEventEmitter 监听器,清理函数只移除了事件订阅,没有停止 TTS 引擎

typescript 复制代码
useEffect(() => {
  const subscription = ttsEventEmitter.addListener(...);
  return () => {
    subscription.remove(); // ✅ 移除事件订阅
    // ❌ 没有调用 TTSModule.stop() 停止播报
    // ❌ TTS 引擎仍在运行,音频资源未释放
  };
}, []);

双重泄漏链路

  • 引擎资源未释放:用户在播报进行中返回上一页,组件卸载但引擎仍在播放,音频通道和合成缓冲区持续占用内存

  • NativeEventEmitter 模块级引用new NativeEventEmitter(TTSModule) 在文件模块作用域创建,是模块级单例。即使组件卸载,事件发射器仍存活并持有 TurboModule 引用,阻止 GC 回收

2.4 构建配置与文件格式问题

两个次生问题:

问题 影响
entry/build-profile.json5buildOptionSet 只有 release 段,缺少 debug DevEcho Studio 调试模式下使用隐式默认配置,与 Ark 编译器的混淆规则处理可能不一致,导致内存占用异常
oh-package.json5 文件末尾有 26 行冗余空行(11 行有效内容 + 26 行空行) Hvigor 构建系统解析时可能因文件格式异常反复触发缓存失效和重新解析

三、修复方案

3.1 Timer ID 防重入机制

核心思路:引入递增的 playbackTimerId 计数器,每次创建新 timer 时递增 ID,回调执行时校验当前 ID 是否与自己创建时一致,不一致则静默退出。

修复要点 :即使旧 timer 的回调已入队无法被 clearInterval 取消,它也会因为 ID 不匹配而立即退出,不再执行 isBusy()emitTTSStatus,彻底消除 timer 堆积。

typescript 复制代码
private playbackTimerId: number = 0;

private clearPlaybackPollTimer(): void {
  this.playbackTimerId++; // 递增 ID,使旧回调失效
  if (this.playbackPollTimer !== null) {
    clearInterval(this.playbackPollTimer);
    this.playbackPollTimer = null;
  }
}

private waitForPlaybackComplete(utteranceId: string): void {
  this.clearPlaybackPollTimer();

  const currentTimerId = this.playbackTimerId; // 闭包捕获当前 ID
  this.playbackPollTimer = setInterval(() => {
    // 防重入:如果 ID 已被更新请求递增,说明自己是 stale callback
    if (currentTimerId !== this.playbackTimerId) {
      return; // 静默退出
    }
    if (!this.ttsEngine) { ... }
    try {
      if (!this.ttsEngine.isBusy()) { ... }
    } catch (error) { ... }
  }, 200);
}

3.2 initEngine Promise 互斥锁

核心思路:引入 initEnginePromise 字段作为互斥锁,并发调用时复用同一个 Promise,避免重复创建引擎。

修复要点isAvailable()speak() 同时调用 initEngine() 时,第二个调用发现 initEnginePromise 不为 null,直接 return this.initEnginePromise 等待同一个初始化完成,不再触发第二次 createEngine。初始化完成后 finally 块清除锁,允许后续引擎重置。

typescript 复制代码
private initEnginePromise: Promise<void> | null = null;

private async initEngine(): Promise<void> {
  if (this.engineReady && this.ttsEngine) {
    return;
  }
  // 互斥锁:如果已有正在进行的初始化,复用其 Promise
  if (this.initEnginePromise) {
    return this.initEnginePromise;
  }

  this.initEnginePromise = new Promise<void>((resolve, reject) => {
    textToSpeech.createEngine(initParams, (err, engine) => {
      if (!err && engine) {
        this.ttsEngine = engine;
        this.engineReady = true;
        resolve();
      } else {
        this.engineReady = false;
        reject(...);
      }
    });
  });

  try {
    await this.initEnginePromise;
  } finally {
    this.initEnginePromise = null; // 无论成功失败,清除锁
  }
}

3.3 组件卸载时主动停止 TTS

useEffect 清理函数中添加 TTSModule.stop() 调用,确保组件卸载时主动停止播报、释放引擎资源:

typescript 复制代码
useEffect(() => {
  const subscription = ttsEventEmitter.addListener(...);
  return () => {
    subscription.remove();
    // 组件卸载时主动停止 TTS 播报,释放引擎资源
    TTSModule.stop().catch(() => {});
  };
}, []);

修复要点TTSModule.stop() 内部会调用 ttsEngine.stop() 停止当前播报,并清理 playbackPollTimer。即使 stop() 返回 Promise 被 catch 静默处理(引擎可能尚未初始化),也不会影响其他逻辑。

3.4 补齐 debug 构建配置

两项简单但有效的修复:

build-profile.json5

补充 debugbuildOptionSet,确保 DevEcho Studio 调试模式使用一致的构建配置

json5 复制代码
"buildOptionSet": [
  {
    "name": "release",
    ...
  },
  // ⚠️ 缺少 debug 段
]

oh-package.json5

清理文件末尾 26 行冗余空行,避免 Hvigor 构建缓存异常

json5 复制代码
{
  "dependencies": {...},
  "devDependencies": {...}
}
// ⚠️ 此后还有 26 行空行

四、修复验证

所有修复已提交至 commit c440fdc 并推送到远端:

bash 复制代码
fix: 修复 DevEcho Studio 内存泄漏 --- TTS timer 防重入 + 
initEngine 互斥锁 + 组件卸载释放

## 修复内容
- TTSModule.ets: 新增 playbackTimerId 计数器防 stale callback 泄漏;
  initEnginePromise 互斥锁防止 isAvailable/speak 并发重复创建引擎实例
- TTSPage.tsx: useEffect 清理函数中调用 TTSModule.stop() 释放引擎资源
- entry/build-profile.json5: 补充 debug 段 buildOptionSet
- oh-package.json5: 清理文件末尾 26 行空行

修改涉及 4 个文件,核心改动 2 个:

文件 改动行数 类型
TTSModule.ets +21 / -8 核心修复
TTSPage.tsx +2 / -1 核心修复
entry/build-profile.json5 +4 / -1 配置补齐
oh-package.json5 +0 / -25 格式清理

五、总结与最佳实践

ArkTS 侧最佳实践

  1. setInterval/setTimeout 必须配合 ID 防重入 :ArkTS 的 timer API 不像浏览器的 requestAnimationFrame 那样天然防重入。在 timer 回调中执行状态检查和副作用时,务必使用递增计数器或 version 标记,确保 stale callback 不会产生副作用

  2. 异步初始化加互斥锁 :任何 async 初始化函数都可能被并发调用。使用 Promise 缓存字段(如 initPromise)实现"单飞"模式,避免重复创建重量级资源

  3. 引擎/客户端类资源遵循"谁创建谁销毁"textToSpeech.createEngine() 创建的引擎必须配对调用 shutdown()。建议在 TurboModule 的 __onDestroy__ 中统一清理


React Native 侧最佳实践

  1. 组件卸载必须清理所有原生资源 :包括事件监听、定时器、引擎实例等。useEffect 清理函数中调用对应 TurboModule 的 stop()/destroy()/removeAllListeners()

  2. 警惕模块级单例的引用持有NativeEventEmitterTurboModuleRegistry.getEnforcing 等模块级引用会阻止 TurboModule 被 GC。如果页面频繁进出,考虑在组件级管理 emitter 生命周期


DevEco Studio 调试建议

  1. 补齐 debug 构建配置 :确保 buildOptionSet 同时包含 debugrelease 段,避免 IDE 使用隐式默认值导致行为不一致

  2. 使用 DevEcho Profiler 监控内存 :在反复操作前后采样内存快照,对比 Heap Snapshot 可以快速定位泄漏对象

总得来说:HarmonyOS RN 项目的内存泄漏,根因往往不是某一个大 bug,而是 ArkTS timer 防重入、异步初始化互斥、组件卸载清理这三个"小问题"叠加的结果。每个单独看都不致命,组合起来就会让 IDE 内存持续增长直到 OOM。