本文是《React Native x HarmonyOS NEXT 创新能力接入方案》系列番外篇。记录了一个 HarmonyOS React Native 项目在 DevEco Studio 中运行时出现内存泄漏的完整排查与修复过程。根因涉及 ArkTS 侧
setInterval轮询防重入缺失、TTS 引擎初始化并发无互斥保护、RN 组件卸载未释放资源、以及构建配置缺失等问题。修复后内存占用趋于稳定,timer 和引擎实例不再堆积。
一、问题背景
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.json5 的 buildOptionSet 只有 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
补充 debug 段 buildOptionSet,确保 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 侧最佳实践
-
setInterval/setTimeout 必须配合 ID 防重入 :ArkTS 的 timer API 不像浏览器的
requestAnimationFrame那样天然防重入。在 timer 回调中执行状态检查和副作用时,务必使用递增计数器或 version 标记,确保 stale callback 不会产生副作用 -
异步初始化加互斥锁 :任何
async初始化函数都可能被并发调用。使用 Promise 缓存字段(如initPromise)实现"单飞"模式,避免重复创建重量级资源 -
引擎/客户端类资源遵循"谁创建谁销毁" :
textToSpeech.createEngine()创建的引擎必须配对调用shutdown()。建议在 TurboModule 的__onDestroy__中统一清理
React Native 侧最佳实践
-
组件卸载必须清理所有原生资源 :包括事件监听、定时器、引擎实例等。
useEffect清理函数中调用对应 TurboModule 的stop()/destroy()/removeAllListeners() -
警惕模块级单例的引用持有 :
NativeEventEmitter、TurboModuleRegistry.getEnforcing等模块级引用会阻止 TurboModule 被 GC。如果页面频繁进出,考虑在组件级管理 emitter 生命周期
DevEco Studio 调试建议
-
补齐 debug 构建配置 :确保
buildOptionSet同时包含debug和release段,避免 IDE 使用隐式默认值导致行为不一致 -
使用 DevEcho Profiler 监控内存 :在反复操作前后采样内存快照,对比
Heap Snapshot可以快速定位泄漏对象
总得来说:HarmonyOS RN 项目的内存泄漏,根因往往不是某一个大 bug,而是 ArkTS timer 防重入、异步初始化互斥、组件卸载清理这三个"小问题"叠加的结果。每个单独看都不致命,组合起来就会让 IDE 内存持续增长直到 OOM。