示例仓库:https://atomgit.com/dijun_520/react-native-ohos-leaky-guard
社区引导:框架源码与版本发布见 https://atomgit.com/CPF-RN/ohos_react_native/tree/0.84-main
CPF-RN 鸿蒙社区:https://atomgit.com/CPF-rn
环境与版本
前提先交代一下文章使用的各种工具的版本,因为 RN 鸿蒙这条链路上,版本差一个补丁号就可能是另一种编译错误。
| 项目 | 版本 |
|---|---|
| 开发工具 | DevEco Studio 26.0(内置 SDK:DevEco Studio/sdk) |
| 系统 API | HarmonyOS 7.0.0 / API 26(compatibleSdkVersion: 26.0.0) |
| React Native | 0.84.1 |
| RN 鸿蒙框架 | @react-native-oh/react-native-harmony 0.84.5(RNOH) |
| JS 引擎 | Hermes V1(HERMES_V1_ENABLED=true,产物 hermes_bundle.hbc) |
| codegen | v2(harmony.codegenConfig version 2) |
| Node.js | ≥ 22.11.0 |
| 实测设备 | 华为(nova 14) |
素材说明:
demo_photo.jpg为真实实拍风景照(吕瑟峡湾/Lysefjord),经 picsum.photos(图片来源 Unsplash,适用 Unsplash License,可免费商用)获取并重采样为 4000×3000,仅作内存测试用途,不含可识别人物肖像。
1. 真实痛点:图片列表不是"能滑"就行
去年做的一个 RNOH 商城项目,商品瀑布流页上线两周后收到一类非常稳定的崩溃反馈:用户连续滑动浏览两三分钟,应用被系统直接杀掉,没有 JS 异常、没有红屏、没有 catch 得到的错误。
这类问题的第一现场往往不在 JS。我们把页面接到 DevEco Studio Profiler 上看,现象是这样的:
- 冷启动进入列表,进程 PSS 约 380MB;
- 每滑过一屏(约 6 张图),PSS 上抬 25~45MB;
- 滑到第 60 秒,PSS 逼近 1.2GB;
- 退出页面回到首页,PSS 只回落不到 10%;
- 触发一次 ArkTS GC,曲线掉一点点,然后继续爬。
这是典型的图片内存泄漏,而且是三个错误叠加的结果:
- 解码尺寸失控:4000×3000 的照片按原尺寸解码成 RGBA8888 的
PixelMap,一张就 45.78MB,而不是它在屏幕上显示的 360×480(约 0.66MB); - 缓存没有预算:业务自己写了个
Map做图片缓存,按"条数"限制(比如 50 条),看起来有上限,实际上 50 条 × 45.78MB = 2.2GB,条数上限在大图面前毫无意义; - 引用消失但内存没还:页面卸载后 JS 侧的对象确实被回收了,但原生侧
PixelMap没有调用release(),像素缓冲区仍挂在 native 堆上,等 finalizer 是一件不可控的事。
需要说明的是,这三个问题都不是"代码写错了某个 API",而是认知层面的空缺:在 Web 里 img 的内存由浏览器管,在 iOS/Android 上图片内存有成熟的经验值,而在 RNOH 上,"这张图占多少内存、什么时候还回去"必须由开发者自己负责。
1.1 四个很常见的错误认知
写这篇文章之前我在几个 RNOH 群里问过,下面四条几乎人人中过至少一条:
误区一:“RN 的 Image 组件会自动帮我缩略。”
不会。Image 的默认行为是按原图尺寸解码,再交给绘制层缩放。写 style={{width: 120, height: 160}},只是告诉布局它占多大,并没有告诉解码器"按 120×160 解"。要影响解码尺寸,必须走解码参数(鸿蒙侧是 DecodingOptions.desiredSize)。
误区二:“组件卸载了,内存自然就释放了。”
JS 侧的组件树、state、闭包会被回收;原生侧 PixelMap 是独立持有像素缓冲区的对象。JS 引用计数归零后,native 对象的释放依赖引用关系与 GC,没有任何保证在下一次滑屏前发生。列表页高频创建/销毁,很快就会把未释放的缓冲区堆到 OOM 线上。
误区三:“明明已经调了 gc(),内存会掉下来。”
global.gc() 只处理 ArkTS 堆里可回收的对象。已经分配出去、仍被原生缓存或绘制管线持有的 PixelMap 不在回收范围内。GC 之后内存不降,恰恰是"原生侧仍持有"的信号,而不是"GC 没用"。
误区四:“Profiler 里能看到曲线,说明问题已经定位了。”
曲线能告诉你"在涨",但不能告诉你"是哪张图、哪次解码、哪个页面漏的"。真正定位需要归属信息:谁申请了、多大、有没有还。这也是本文这个库存在的理由——把"归属"和"归还状态"记下来。
1.2 为什么不用现成方案
- DevEco Profiler / hidumper:能看总量与调用栈采样,但采样有开销、粒度是函数级,且无法回答"这个 45MB 的 PixelMap 是哪个业务 key 的";
- 纯 JS 侧的内存统计:
global.gc+ JS 堆快照只能看到 JS 对象,图片像素压根不在 JS 堆里,结论必然是"没泄漏"; - 业务里散落的工具函数:一次性排查有用,无法回归。三个月后同样的问题会以另一种形态回来。
所以,可以把它做成了一个 RNOH 三方库:原生侧负责量化与持有关系,JS 侧负责业务语义与生命周期,示例工程负责回归。
2. 先看效果:改造前后的数据对比
2.1 指标定义与测量条件
| # | 指标 | 口径与工具 | 测量条件 |
|---|---|---|---|
| 1 | 单图解码内存 | PixelMap.getPixelBytesNumber()(精确字节,非估算) | 仓库内置 4000×3000 JPEG(demo_photo.jpg),RGBA8888 解码 |
| 2 | 列表 24 图解码峰值 | 24 张图全部驻留时的字节累加(同上口径) | 同一批图,缓存不设上限,全部未 release |
| 3 | 重复解码增量(第 2 轮) | 第二次加载同批图新增的字节数 | 首轮结束后不清缓存,直接再加载一轮 |
| 4 | 缓存常驻内存(24 图后) | ImageCache.stats().bytes,预算 64MB / 16 项 | 加载 24 张后读缓存统计 |
| 5 | 进程 PSS(交叉验证) | DevEco Studio Profiler → Memory → PSS 曲线,采样 1s;应用内 hidebug.getPss() 每 2s 打点 | 冷启动进入列表,匀速滑 60s,退出后观察 30s |
| 6 | 单图解码耗时 | Date.now() 包住一次 decodeImage,取 10 次中位数 | 同上图片,冷缓存 |
说明:1~4 是字节级精确值,由
getPixelBytesNumber()直接给出,可用仓库里的"跑基准"按钮一键复现,不依赖设备性能;5~6 依赖真机,需你用自己的设备采一遍填表。
2.2 数据对比
| 指标 | 改造前(原图解码 / 无预算缓存 / 不 release) | 改造后(降采样 + 字节预算 LRU + 卸载 release) | 变化 |
|---|---|---|---|
| 单图解码内存 | 46875 KB(4000×3000 RGBA8888) | 675 KB(480×640 降采样) | -98.6% |
| 列表 24 图解码峰值 | 1125000 KB ≈ 1098.6 MB(必被系统杀) | 16200 KB ≈ 15.8 MB | -98.6% |
| 重复解码增量(第 2 轮) | 46875 KB | 0 KB(命中缓存) | -100% |
| 缓存常驻内存(24 图后) | 1125000 KB(无预算,随图数线性增长) | 10800 KB(16 项 LRU,预算封顶 64MB) | -99% |
| 单图解码耗时(真机) | 缓存命中 1 ms;未命中走 4000×3000 全尺寸解码 | 83 ms(512×384 降采样,新解码) | 见 §2.1 条件 |
| 进程 PSS(hidebug 打点) | 基线 147275 KB | 基准运行中采样至 197970 KB* | 见注 * |
* PSS 行的说明:本次基准前先在「泄漏雷达」Tab 复现过 25 个泄漏资源并一键修复,随后立即跑基准——PSS 基线包含这一整轮操作的累积内存(泄漏对象虽已 release,但堆顶尚未归还系统),因此基线偏高;四个核心指标(单图内存/峰值/重复解码/常驻)由库内字节统计得出,不受其影响。干净的 PSS 曲线建议用 DevEco Profiler 单独采。
如果你用的原图尺寸不同,换算很简单:字节数 = 宽 × 高 × 每像素字节数,RGBA8888 是 4,RGB565 是 2,NV21 是 1.5。本文库的 imageMemory.memorySize(w, h, format) 就是这个公式的原生实现,可以直接在业务里做准入判断(比如"解码前先估算,超过预算就换缩略图 URL")。
四项核心指标画在一张对数刻度图里,降幅一目了然(条越短越好,图 1):

2.3 截图(HarmonyOS 7.0 / API 26)
图 2:示例首页——三个 Tab 对应三类问题:泄漏雷达(定位与复现)/ 图片内存(降采样与缓存)/ 通道协程(句柄与任务生命周期):

图 3:泄漏雷达初始页——还没注入任何泄漏时的干净基线,日志区为空,方便与图 4 的复现后状态逐项对照:

图 4:复现泄漏后——点「复现泄漏」注入 25 个泄漏资源,前后快照 diff 直接给出判定:Native堆分配增量 +9770 KB、VM堆增量 +93356 KB、PSS增量 +55207 KB → ⚠️ 可疑泄漏;素材缩略图与判定结果同屏:

图 5:一键修复后——同一个页面点「修复全部泄漏」再「检查泄漏」:登记资源 25、未释放 0、嫌疑对象 0,日志显示"已修复 25 个泄漏资源,剩余未释放 0"。与图 4 对照,就是这套库"定位 → 修复"的闭环:

图 6:图片内存基准实测——24 图 ×(原图 vs 降采样)四项核心指标与 §2.2 表格一一对应,点同一个按钮即可复现:

图 7:演示素材长这样——三个 Tab 顶部都有统一的素材展示卡片,布道石俯瞰吕瑟峡湾的实拍原图(4000×3000)以 <Image> 渲染在页面上,任何一页截图都能看到素材本尊:

图 8:单图同屏对比——同一张 4000×3000 照片两次解码同屏:原图 46875 KB / 140 ms vs 降采样 768 KB / 85 ms(512×384,✂️ 已降采样);下方缓存统计实时显示条目、字节与命中/未命中:

图 9:降采样解码结果——清空缓存后单看降采样那次解码:512×384、768 KB、84 ms,缓存从 16 项回落到 1 项 / 0.8 MB,✂️ 已降采样 标记来自库内字节统计而非估算:

图 10:通道协程初始页——通道演示 / 协程演示 / 一键治理三个分区,日志区同样是干净基线:

图 11:复现通道与协程泄漏——点「复现泄漏(开 3 个不关)」和「发起协程(含 1 个孤儿)」后,日志记录下每次注入:3 个通道故意不 close、1 个协程永不完成:

图 12:泄漏报告——点「报告」后逐条列出未关闭通道(buffered/sent 状态)与孤儿协程(标记为泄漏、已跑时长 68021ms):

图 13:一键治理后——点「关闭全部通道 + 取消全部协程」:已关闭 3 个通道、取消 1 个协程,与图 11 的注入记录一一对应:

3. 原理:一张 12MP 照片在鸿蒙里到底占多少内存
3.1 解码链路
鸿蒙的图片解码链路可以简化成三步:
JPEG 文件字节 --ImageSource.createPixelMap(opts)--> PixelMap(native 像素缓冲区) --绘制--> 屏幕
~3MB 解码 45.78MB(RGBA8888) 120×160 dp
关键点在于:第三步的显示尺寸对第二步的内存没有约束。除非你在 DecodingOptions 里显式给 desiredSize,否则解码器就按图片的原始像素尺寸分配缓冲区。
PixelMap 的像素缓冲区分配在 native 堆(不是 ArkTS 托管堆)。它的生命周期由引用关系 + release() 共同决定。这一点决定了后面所有治理手段的方向:JS 侧怎么优化都动不了这 45MB,只有"解码时少分"和"用完后显式还"两条路。
3.2 45.78MB 是怎么算出来的
4000 × 3000 × 4 字节 = 48,000,000 字节 = 45.78 MB。
这不是"大概",是精确值,因为 RGBA8888 每像素固定 4 字节,没有压缩、没有稀疏存储。你也可以用库里的方法验算:
// 12MP 原图,RGBA8888
imageMemory.memorySize(4000, 3000, 0); // 48000000
// 480×640 缩略图
imageMemory.memorySize(480, 640, 0); // 1228800 → 1.17MB
// 如果缩略图能接受 RGB565(无 alpha)
imageMemory.memorySize(480, 640, 1); // 614400 → 0.59MB
一个很直观的对照:64MB 的缓存预算,装 12MP 原图只能装 1.4 张;装 480×640 缩略图能装 54 张。 很多"缓存越做越卡"的项目,问题不在 LRU 写错了,而在于缓存里存的根本不该是这个尺寸的图。
3.3 为什么 desiredSize 是"真降采样",而不是"解码后再缩"
这是最容易混淆的一点。如果先按原尺寸解码、再缩小,峰值内存仍然是 45.78MB——你只是把大图短暂地创建了一次,GC 之前它一直在。
desiredSize 的作用发生在解码阶段:解码器在 IDCT 之后按目标尺寸重采样输出,中间虽然会有行缓冲,但不会为整张原图分配完整像素缓冲区。所以:
- 峰值内存从"原图尺寸"降到"目标尺寸 + 少量行缓冲";
- 解码耗时同步下降(解码器少写了 97% 的像素);
- 代价是画质:缩得太狠会有摩尔纹/锯齿,尤其是细纹理(商品图上的文字、格纹布料)。
实践中的取值经验:desiredSize 取"显示尺寸 × 屏幕像素密度",再向上留 20% 余量。3 列瀑布流、每列 120vp、设备 3x,则大约 360px 宽,留余量到 430px 左右即可,没必要给到 800px。
3.4 为什么"条数上限"的缓存一定会失控
列表里每张图的尺寸并不一致:详情页的 12MP 大图、列表里的 800×800、头像的 96×96。按条数限制 50 条,最坏情况下 50 × 45.78MB = 2.2GB,最好情况 50 × 0.03MB = 1.5MB,上限是不可预期的。
所以缓存必须以字节为主预算、条数为辅预算(防止大量小图把 Map 撑爆)。这是 ImageCache 的设计前提,第 4.6 节会给出实现。
4. 库的设计与核心代码
4.1 为什么做成 RNOH TurboModule 三方库
之所以选择封装成独立HAR+TS源码库,核心是这些能力本身都属于ArkTS原生能力,hidebug采样、image.createImageSource、PixelMap.release、taskpool等API无法直接在JS层调用,同时要满足跨工程复用与回归验证的需求,所有RNOH项目都可以通过file依赖快速接入,配套示例工程用作稳定回归用例,更关键的是JS侧useEffect卸载时机和原生endScope、资源释放这类跨语言生命周期语义需要精准对齐,把这部分逻辑收敛在基础库里实现,相比散落在业务代码中会更加稳妥可靠。
react-native-ohos-leaky-guard/
├── src/ # 跨端 TS:API、hooks、基准
│ ├── native.ts # TurboModule Spec(注册名 'LeakyGuard')
│ ├── LeakRadar.ts # 泄漏定位与复现
│ ├── ImageMemory.ts # 图片内存治理
│ ├── ChannelLeak.ts # 通道与协程
│ ├── benchmark.ts # 前后对比基准
│ └── hooks/
├── harmony/leak_guard/ # ArkTS HAR:TurboModule 实现
│ └── src/main/ets/
│ ├── LeakGuardPackage.ts # 包注册
│ ├── LeakGuardModule.ts # TM 门面
│ ├── tms/LeakyGuard.ts # 规格
│ └── core/ # MemorySampler / ImageCache / ChannelRegistry / CoroutineRegistry
└── example/ # 演示 App + 鸿蒙壳工程
调用链很短:useImageMemory → ImageMemory.decode → TurboModuleRegistry.get('LeakyGuard') → LeakGuardModule.decodeImage → ImageCache + image.createPixelMap。
4.2 定位:把"谁申请了、有没有还"记下来
泄漏定位的核心不是采样,而是归属。所以 JS 侧维护一张登记表,每个可能泄漏的资源进来时登记,还回去时销账:
// src/LeakRadar.ts(节选,完整实现见仓库)
/** 截取调用点提示,用于定位泄漏来源 */
private hintOf(): string {
try {
const stack = new Error().stack ?? '';
// 过滤掉雷达自身的栈帧,留下业务调用点
const lines = stack.split('\n').filter((l) => l.indexOf('LeakRadar') < 0);
return (lines[1] ?? '').trim().slice(0, 200);
} catch {
return '';
}
}
/** 登记一个待观察资源 */
retain(id: string, type: LeakKind, hint?: string, handle?: Partial<RegistryEntry>): void {
this.registry.set(id, {
id,
type,
createdAt: Date.now(),
hint: hint ?? this.hintOf(),
released: false,
...handle,
});
}
checkLeaks(options?: { minAgeMs?: number; types?: LeakKind[] }): LeakReport {
const minAgeMs = options?.minAgeMs ?? 0;
const now = Date.now();
const suspects: LeakSuspect[] = [];
let unreleased = 0;
this.registry.forEach((e) => {
if (e.released) {
return;
}
unreleased += 1;
if (now - e.createdAt >= minAgeMs) {
suspects.push({ id: e.id, type: e.type, ageMs: now - e.createdAt, hint: e.hint });
}
});
// 存活越久越可疑
suspects.sort((a, b) => b.ageMs - a.ageMs);
return { suspects, totalTracked: this.registry.size, unreleased, checkedAt: now };
}
三个设计取舍值得说清楚:
第一,为什么是"资源级"登记而不是"对象级"追踪。 有人会问:能不能用 WeakRef/FinalizationRegistry 判断对象有没有被回收?在 RN 的 Hermes 上这两个能力并不完整,而且即便可用,也只能告诉你"某个 JS 对象还在",说不清"它持有了多大的原生内存"。资源级登记(图片 key、通道 id、监听器句柄、定时器句柄)能直接挂钩到原生侧的真实占用,结论是可行动的。
第二,为什么用 Error.stack 做调用点提示。 泄漏报告如果只给 image_17 这种 id,排查仍然是盲的。截取调用栈里第一个非雷达帧,能得到类似 at ProductCard (ProductCard.tsx:42:18) 的位置,配合存活时长,基本能一眼看出是哪类组件的哪次创建忘了收尾。代价是每次 retain 有约 0.1~0.3ms 的栈采集开销,因此只建议在 debug/预发开启。
第三,快照 diff 用来回答"涨了多少",登记表用来回答"涨的是谁"。 两者缺一不可:
// 快照来自原生 hidebug;diff 在 JS 侧做阈值判定
diff(
before: MemorySnapshot,
after: MemorySnapshot,
thresholds?: { allocatedKb?: number; pssKb?: number; vmKb?: number }
): SnapshotDiff {
const tAlloc = thresholds?.allocatedKb ?? 1024;
const tPss = thresholds?.pssKb ?? 2048;
const tVm = thresholds?.vmKb ?? 1024;
const allocatedDelta = after.nativeHeapAllocatedKb - before.nativeHeapAllocatedKb;
const pssDelta = after.pssKb - before.pssKb;
const vmDelta = after.vmHeapKb - before.vmHeapKb;
return {
fromLabel: before.label,
toLabel: after.label,
durationMs: Math.max(0, after.timestamp - before.timestamp),
nativeHeapAllocatedDeltaKb: allocatedDelta,
nativeHeapSizeDeltaKb: after.nativeHeapSizeKb - before.nativeHeapSizeKb,
vmDeltaKb: vmDelta,
pssDeltaKb: pssDelta,
// 任一关键指标越阈值即判定可疑;-1 表示该设备采样不可用,不参与判断
suspected:
(allocatedDelta > tAlloc && after.nativeHeapAllocatedKb >= 0) ||
(pssDelta > tPss && after.pssKb >= 0) ||
(vmDelta > tVm && after.vmHeapKb >= 0),
};
}
注意 >= 0 这个判断:hidebug 在部分设备/权限等级下取不到值,我们会写入 -1。如果不做这个判断,-1 参与减法会算出巨大的负数或正数,直接毁掉阈值逻辑。这是我第一版踩的坑,后面 5.1 节细说。
4.3 复现:能稳定复现,才谈得上修
"泄漏"最烦的地方是不可复现。所以库里内置了一个泄漏注入器,一次点击就能稳定造出五类泄漏:监听器、定时器、闭包持有的缓冲区、未关闭通道、无降采样的图片。
// src/LeakRadar.ts —— 复现用的注入逻辑
private async reproduceOne(kind: LeakKind): Promise<string | null> {
const id = this.nextId(kind);
const hint = this.hintOf();
switch (kind) {
case 'listener': {
// 只 add 不 remove,典型的事件监听泄漏
const sub = DeviceEventEmitter.addListener('leakyguard_repro', () => undefined);
this.retain(id, kind, hint, { subscription: sub });
return id;
}
case 'timer': {
// 长周期定时器,页面销毁后仍在跑
const timer = setInterval(() => undefined, 60 * 60 * 1000);
this.retain(id, kind, hint, { timer });
return id;
}
case 'closure': {
// 登记表持有闭包引用 → 这 1MB 缓冲区无法被 GC
const payload = new Uint8Array(1024 * 1024);
payload[0] = 0x7f;
this.retain(id, kind, hint, { payload });
return id;
}
case 'channel': {
const nativeId = this.nextId('chan');
getLeakyGuardNative().channelOpen(nativeId, 16, '');
this.retain(id, kind, hint, { nativeId });
return id;
}
case 'image': {
// 关键:不传 maxWidth/maxHeight,按原图解码并持有
const info = await getLeakyGuardNative().decodeImage({
source: 'rawfile://demo_photo.jpg',
});
this.retain(id, kind, hint, { nativeId: info.key });
return id;
}
default:
return null;
}
}
这里有个反直觉的点,也是我觉得值得写下来的:检测器自身可能成为泄漏源。closure 这一类里,payload 被登记表强引用着,所以它确实不会被回收——我们是在故意制造一个可观测的泄漏。这在回归场景是对的(要验证"检测器能不能发现",就必须先有确定的泄漏),但在生产接入时必须把 reproduce() 从发布包里摘掉,否则工具本身就成了内存问题。仓库的做法是把注入器单独放在 LeakRadar 上,并在示例里显式标注"仅调试使用"。
修复侧对应的是 fix():按类型分发——clearInterval、subscription.remove()、channelClose、releaseImage,最后统一触发一次 global.gc()。这样"注入 → 检查 → 修复 → 再检查"能形成闭环,回归脚本可以断言"修复后 unreleased === 0"。
4.4 降采样解码:真正省下 97% 内存的 30 行
这是整个库最值钱的一段代码。它的职责是:给定原图与目标尺寸,让解码器只分配目标尺寸的缓冲区。
// harmony/leak_guard/src/main/ets/LeakGuardModule.ts
async decodeImage(options: TM.LeakyGuard.DecodeOptions): Promise<TM.LeakyGuard.DecodedImageInfo> {
const maxWidth = options.maxWidth;
const maxHeight = options.maxHeight;
const pixelFormat = options.pixelFormat === undefined ? 0 : options.pixelFormat;
const suffix = options.keySuffix === undefined ? '' : `#${options.keySuffix}`;
// 缓存键必须包含:源、目标尺寸、像素格式(否则不同尺寸/格式会互相顶掉)
const key = `${options.source}|${maxWidth ?? -1}x${maxHeight ?? -1}|${pixelFormat}${suffix}`;
const hit = this.imageCache.get(key);
if (hit !== undefined) {
const imgInfo = await hit.pixelMap.getImageInfo();
return {
key,
source: options.source,
decodedWidth: imgInfo.size.width,
decodedHeight: imgInfo.size.height,
requestedWidth: maxWidth === undefined ? imgInfo.size.width : maxWidth,
requestedHeight: maxHeight === undefined ? imgInfo.size.height : maxHeight,
bytes: hit.bytes,
downsampled: hit.downsampled,
cached: true,
};
}
const imageSource = await this.openSource(options.source);
const srcInfo = await imageSource.getImageInfo();
const srcW = srcInfo.size.width;
const srcH = srcInfo.size.height;
let downsampled = false;
const opts: image.DecodingOptions = {
editable: false,
pixelFormat: pixelFormat === 1 ? image.PixelMapFormat.RGB_565 : image.PixelMapFormat.RGBA_8888,
};
// contain 语义:等比缩放,且绝不放大;只有确实超出目标才降采样
if (maxWidth !== undefined && maxHeight !== undefined &&
maxWidth > 0 && maxHeight > 0 &&
(srcW > maxWidth || srcH > maxHeight)) {
const scale = Math.min(maxWidth / srcW, maxHeight / srcH);
opts.desiredSize = {
width: Math.max(1, Math.floor(srcW * scale)),
height: Math.max(1, Math.floor(srcH * scale)),
};
downsampled = true;
}
const pixelMap = await imageSource.createPixelMap(opts);
const finalInfo = await pixelMap.getImageInfo();
// 以解码器真实返回的字节为准,而不是拿公式估算
const bytes = pixelMap.getPixelBytesNumber();
this.imageCache.put(key, pixelMap, bytes, downsampled);
return {
key,
source: options.source,
decodedWidth: finalInfo.size.width,
decodedHeight: finalInfo.size.height,
requestedWidth: maxWidth === undefined ? srcW : maxWidth,
requestedHeight: maxHeight === undefined ? srcH : maxHeight,
bytes,
downsampled,
cached: false,
};
}
在缩放计算上采用Math.min(maxWidth / srcW, maxHeight / srcH)统一求取缩放系数来实现contain等比缩放,避免宽高独立缩放引发拉伸变形,同时防止传给解码器的desiredSize比例异常拉低重采样质量;缩放结果外层包裹Math.max(1, Math.floor(...))做下限保护,规避极小缩放比例下尺寸归零导致createPixelMap参数报错这类隐蔽问题,并且仅在原图尺寸超出限制时才传入desiredSize,原图本身足够小就不触发多余重采样,节省算力、保留原图画质;字节统计选择getPixelBytesNumber()而不是自行公式计算,适配解码器行对齐stride带来的额外字节,也兼容RGB565、NV21等不同像素格式,保障缓存预算精准。
缓存键纳入像素格式与尺寸信息,避免同一张图片不同格式请求时错误命中缓存、出现难以复现的颜色异常;rawfile分支处理openSource时还保留buffer引用语义,适配
getRawFileContent返回Uint8Array、而createImageSource需要ArrayBuffer的类型差异。
private async openSource(source: string): Promise<image.ImageSource> {
if (source.startsWith('rawfile://')) {
const name = source.substring('rawfile://'.length);
const context = this.ctx.uiAbilityContext;
const content = await context.resourceManager.getRawFileContent(name);
const src = image.createImageSource(content.buffer as ArrayBuffer);
if (src === undefined) {
throw new Error(`LeakGuard: createImageSource failed for ${source}`);
}
return src;
}
let path = source.startsWith('file://') ? source.substring('file://'.length) : source;
const file = await fs.open(path, fs.OpenMode.READ_ONLY);
try {
const src = image.createImageSource(file.fd);
if (src === undefined) {
throw new Error(`LeakGuard: createImageSource failed for ${source}`);
}
return src;
} finally {
// fd 必须在 createImageSource 之后立即关,否则每次解码泄漏一个句柄
await fs.close(file.fd);
}
}
fs.close 放在 finally 里是硬性要求。解码是异步的,如果中途抛错而 fd 没关,跑几百次就会出现 Too many open files——这个错误在图片列表里非常常见,但报错点往往在下一次 fs.open。
4.5 缓存:逐出的动作必须是 release()
// harmony/leak_guard/src/main/ets/core/ImageCache.ets
get(key: string): CacheEntry | undefined {
const entry = this.entries.get(key);
if (entry === undefined) {
this.misses += 1;
return undefined;
}
this.hits += 1;
// LRU 触碰:Map 的迭代顺序即插入顺序,删了再插就移到了队尾
this.entries.delete(key);
this.entries.set(key, entry);
return entry;
}
private evictIfNeeded(): void {
while (this.entries.size > 0 &&
(this.entries.size > this.maxEntries || this.totalBytes > this.maxBytes)) {
const oldestKey = this.entries.keys().next();
if (oldestKey.done === true || oldestKey.value === undefined) {
break;
}
this.remove(oldestKey.value);
this.evictions += 1;
}
}
remove(key: string): boolean {
const entry = this.entries.get(key);
if (entry === undefined) {
return false;
}
this.entries.delete(key);
this.totalBytes -= entry.bytes;
try {
// 这一行才是真正把内存还给系统的地方
entry.pixelMap.release();
} catch (e) {
// release 失败不影响簿记,但要保证 totalBytes 已经扣减
}
return true;
}
缓存LRU策略直接复用Map天然的插入顺序特性,通过delete加set将命中节点挪至队尾,借助keys().next()获取最旧条目,省去双向链表的维护成本,仅以小规模场景下可忽略的额外哈希开销作为代价;内存统计totalBytes要在release执行前或者catch外部完成扣减,防止release抛出异常导致簿记数值和实际占用不一致,进而引发缓存持续膨胀、命中率断崖下跌的问题;缓存逐出时必须显式调用release,单纯删除Map条目只能移除上层引用,PixelMap像素缓冲区依赖时机不可控的GC终结器回收,在内存紧张场景下容易造成越逐出内存压力越高的恶性循环。
4.6 卸载即释放:useImageMemory 的闭包陷阱
先看我第一版写错的版本:
// 反例:cleanup 里读 state,永远读不到最新值
useEffect(() => {
imageMemory.decode({ source }).then((d) => setInfo(d));
return () => {
// 本次 effect 执行时 info 还是 null,所以这里恒为 undefined
if (info?.key) {
void imageMemory.release(info.key);
}
};
}, [source]);
问题在于:cleanup 闭包捕获的是"本次 effect 执行时"的 info,而不是最新值。 effect 依赖是 [source],info 不在依赖里,所以首次渲染时 info 为 null,cleanup 里的 info?.key 永远是 undefined——图片一张都不会被释放,读起来是"卸载时释放当前图片",运行时是"什么也没做"。
仓库里的正确实现(src/hooks/useImageMemory.ts):
export function useImageMemory(
source: string | null,
options?: Omit<DecodeOptions, 'source'>
): UseImageMemoryResult & { refreshStats: () => ImageCacheStats } {
const [info, setInfo] = useState<DecodedImageInfo | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
/** 本次 effect 申请到的原生资源 key,供 cleanup 精确释放 */
const keyRef = useRef<string | null>(null);
const optsKey = JSON.stringify(options ?? {});
useEffect(() => {
if (!source) {
setInfo(null);
setError(null);
return;
}
let cancelled = false;
setLoading(true);
imageMemory
.decode({ source, ...JSON.parse(optsKey) })
.then((d) => {
if (cancelled) {
// 已卸载:立刻还回去,避免这次解码成为"迟到"的泄漏
void imageMemory.release(d.key);
return;
}
keyRef.current = d.key;
setInfo(d);
setError(null);
})
.catch((e: unknown) => {
if (!cancelled) {
setInfo(null);
setError(e instanceof Error ? e.message : String(e));
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
if (keyRef.current) {
void imageMemory.release(keyRef.current);
keyRef.current = null;
}
};
}, [source, optsKey]);
const refreshStats = () => imageMemory.cacheStats();
return { info, error, loading, bytes: info?.bytes ?? 0, refreshStats };
}
我们通过ref保存当前effect申请的资源,cleanup仅释放ref内托管的资源,这套模式可复用在定时器、订阅、通道、协程等所有申请释放类Hook,是React端通用的防泄漏手段;异步解码场景下即便进入cancelled分支也必须执行资源释放,避免组件提前卸载后异步回调里的资源无人回收,产生代码路径看似成对、实则难以察觉的迟到泄漏;同时effect依赖采用序列化后的optsKey而非原始options对象,规避调用方传入字面量对象时每次渲染引用更新,触发effect反复执行带来的解码风暴。
同样的思路在通道/协程上对应
useChannelScope:scope 在挂载时创建,卸载时调原生endScope,由原生侧把 scope 内未关闭的通道列出来、把仍在跑的协程取消掉,并返回一份"泄漏证据"。这样即使业务代码忘了关,退出页面时也能收口,而且留下可追溯的记录。
4.7 通道与协程:图片页也会踩到
图片页为什么会跟"通道/协程泄漏"扯上关系?因为我们常见的图片预取就是一个小型生产者-消费者:滚动事件往队列里塞 URL(生产),预取协程从队列里取(消费),解出来的 PixelMap 放进缓存。
这套结构一旦泄漏,表现和图片泄漏一模一样:内存只涨不降。典型形态:
- 页面退出,预取协程还在跑,继续解码 → 内存继续涨;
- 通道没关,缓冲区里几十个 URL 字符串 + 对应的
PixelMap引用一起被持有; taskpool任务提交后无人等待,异常被吞,任务对象常驻。
原生的做法是把生命周期登记进注册表:
// harmony/leak_guard/src/main/ets/core/CoroutineRegistry.ets(节选)
launch(taskId: string, durationMs: number, leak: boolean, useTaskPool: boolean, scopeId: string): boolean {
if (this.records.has(taskId)) {
return false;
}
this.records.set(taskId, {
taskId, startedAt: Date.now(), durationMs,
done: false, cancelled: false, leaked: leak, useTaskPool, scopeId,
});
this.totalLaunched += 1;
if (useTaskPool) {
this.launchTaskPool(this.records.get(taskId)!);
} else if (!leak) {
// leak=true 时不挂完成定时器 → 永不完成,用于复现孤儿协程
const timerId = setTimeout(() => this.complete(taskId), Math.max(1, durationMs)) as number;
this.timers.set(taskId, timerId);
}
return true;
}
report() 输出"仍在运行"和"孤儿(leaked 且未完成未取消)"两个清单,cancelAll() 做兜底。关键在 endScope:它不是简单地把所有东西关掉,而是先记录再关闭——leakedChannels 里留下"到 scope 结束都没关"的 id,这才是可沉淀的泄漏证据。只关不记,下次还会重犯。
5. 踩坑
坑一:hvigor 不是工程里那个 hvigor,java 也不在 PATH 里
第一反应是"工程里总该有 hvigorw":
$ cd harmony && ./hvigorw --mode module -p module=entry@default assembleHap
bash: ./hvigorw: No such file or directory
hvigorw / hvigorw.bat 是 DevEco Studio 新建工程时生成在根目录的包装脚本,从模板拷来的壳工程经常没有。就算手动补上,下一刀是:
hvigor ERROR: Failed to execute task 'PackageHap' in module 'entry'.
Caused by: Error: spawn java ENOENT
at ChildProcess._handle.onexit (node:internal/child_process:903:5)
spawn java ENOENT 跟 hvigor 一点关系都没有——打包阶段会 fork 一个 JVM 跑 hap 打包工具,找不到 java 就报这个。JAVA_HOME 设了也没用,hvigor 认的是 PATH。
最终能跑通的调用方式(用 DevEco 自带的 node + hvigor,不依赖全局环境):
$env:DEVECO_SDK_HOME = 'D:\Program Files\Huawei\DevEco Studio\sdk'
$env:Path = 'D:\Program Files\Huawei\DevEco Studio\jbr\bin;D:\Program Files\Huawei\DevEco Studio\tools\node;' + $env:Path
& 'D:\Program Files\Huawei\DevEco Studio\tools\node\node.exe' `
'D:\Program Files\Huawei\DevEco Studio\tools\hvigor\bin\hvigorw.js' `
--mode module -p module=entry@default -p product=default assembleHap --no-daemon
教训:命令行构建鸿蒙工程,别指望"配好 PATH 就能跑"。DevEco 自带的 hvigor、node、JBR 是一套绑定的工具链,三者版本互相匹配;用系统里的 node/java 去跑它,报错会指向毫不相干的地方(我第一次甚至去查了 Node 版本兼容性)。
坑二:签名是单独一步,报错只有一个五位码
不配签名也能 assembleHap,只是产物叫 entry-default-unsigned.hap,hdc install 时被系统直接拒绝。配了签名之后,同一份工程换了个 bundleName,又炸了:
hvigor ERROR: Failed to sign the hap.
Caused by: SignHap failed, error code: 00303074
这个错误码不会告诉你"包名和 Profile 不匹配"。真实原因是:调试用的 .p7b Profile 里写死了 bundleName,而我从模板继承来的 AppScope/app.json5 里是另一个包名。签名工具拿 Profile 去校验 HAP 的 bundleName,对不上就只回一个码。
修复就是一行:
// AppScope/app.json5
{ "app": { "bundleName": "com.rnoh.awesomeproject" } } // 必须与调试 Profile 完全一致
教训:从别人的模板拷壳工程时,第一件事是确认 bundleName 与你手上的签名 Profile 一致,而不是等构建到 90% 才看到 00303074。另外把签名材料(.p12 / .p7b / .cer 和口令)留在本机,仓库 .gitignore 里排除掉——这类文件进了 git,后面每次提交都得提心吊胆。
坑三:我改的代码根本没进包——增量缓存和三份副本
最浪费时间的一类问题:代码改了,报错行号却是旧的。查下去发现工程里同一份 ArkTS 源码存在三个副本:
$ find . -name "LeakGuardPackage.*"
./leak_guard/src/main/ets/LeakGuardPackage.ets # 我改的是这份
./oh_modules/leak_guard/src/main/ets/LeakGuardPackage.ts # 构建实际读的是这份(旧的)
./entry/oh_modules/leak_guard/src/main/ets/LeakGuardPackage.ts # 还有一份(也是旧的)
file:./leak_guard 形式的本地依赖,ohpm 在安装时会复制而不是软链。项目级 oh_modules 和 entry/oh_modules 各有一份拷贝,构建读哪个取决于依赖声明在哪一层。再加上编译缓存:
./entry/build/default/cache/default/default@CompileArkTS/esmodule/ # 滞留旧 .ts 的编译产物
改动没同步到拷贝、或者缓存没失效,表现都是"改了没用",而且不报任何错。
教训:本地 HAR 依赖的调试流程固定成三步——ohpm install 重装 → 删除 entry/build/.../CompileArkTS 缓存目录 → 重新构建。别用"改一下再 build"赌增量。仓库里那个 scripts/sync-harmony-lib.sh 就是为了让三份副本不可能漂移。
坑四:红屏 LeakyGuardExample has not been registered
签名、编译都过了,应用起来了,红屏:
Invariant Violation: "LeakyGuardExample" has not been registered. This can happen if:
* Metro (the local dev server) is run from the wrong folder. Check if Metro is running,
stop it and restart it in the current project.
* A module failed to load due to an error and `AppRegistry.registerComponent` wasn't called.
离线 bundle 场景下(真机没连 Metro),这条红屏 90% 不是模块加载失败,而是入口名对不上。RNOH 的 Index.ets 里有两处必须与 JS 侧一致:
// entry/src/main/ets/pages/Index.ets(示意,实际写法以 RNOH 模板为准)
// ① 资源路径:必须指向 rawfile 下真实存在的文件
const bundlePath = 'bundle/LeakyGuardExample.harmony.js';
// ② appKey:必须等于 AppRegistry.registerComponent 的第一个参数
RNOHCoreContext({ appKey: 'LeakyGuardExample', bundlePath, ... })
我这两处各错了一次:先是 appKey 写成 "LeakyGuardExample" 而 JS 里注册的是别的名字,改完又发现 bundle 被放在 rawfile/bundle.harmony.js,而 Index.ets 去读 bundle/LeakyGuardExample.harmony.js。资源路径错了不会报"文件不存在",只会一路走到 AppRegistry 报未注册。
教训:把"appKey、bundle 文件名、rawfile 下的实际路径"当成一组三元组一起改,改任何一个都 grep 全文确认另两个。
坑五:ArkTS 不是 TypeScript,一次编译甩七个错
把 .ts 改成 .ets 之后,编译直接吐七个错。它们看着互不相关,其实是三类问题(下面节选四条,其余是同一批连带错误):
ArkTS:ERROR File: .../ets/tms/LeakyGuard.ts:9:1
The 'namespace' keyword is not supported. (arkts-no-ns)
ArkTS:ERROR File: .../LeakGuardModule.ets:117:31
Argument of type 'bigint' is not assignable to parameter of type 'number'.
ArkTS:ERROR File: .../LeakGuardModule.ets:121:26
Property 'getVmMemorySize' does not exist on type 'typeof hidebug'.
ArkTS:ERROR File: .../LeakGuardModule.ets:163:19
Object literal may only specify known properties, and 'pixelFormat'
does not exist in type 'DecodingOptions'.
① namespace 不被支持。我原本写的是 export namespace TM { export interface LeakyGuard { ... } }——这在 TS 里很常见的命名空间分层,ArkTS 直接不支持。改成一组平铺的 interface,用谁就按名字 import type。
② bigint 不参与算术。hidebug 的内存接口返回的是 bigint(字节),我下意识写了 hidebug.getNativeHeapSize() / 1024,ArkTS 不允许 bigint 与 number 混算。必须显式转换:
// 真实 API:getNativeHeapSize / getNativeHeapAllocatedSize / getNativeHeapFreeSize 返回 bigint(字节)
snap.nativeHeapSizeKb = Number(hidebug.getNativeHeapSize()) / 1024;
snap.nativeHeapUsedKb = Number(hidebug.getNativeHeapAllocatedSize()) / 1024;
// getVss() / getPss() 返回的 bigint 单位已经是 KB,不要再除 1024
snap.vmHeapKb = Number(hidebug.getVss());
snap.pssKb = Number(hidebug.getPss());
③ 我记错了两个 API。getVmMemorySize() / getPssMemorySize() 根本不存在(文档里没有,是我按命名规律"推理"出来的);DecodingOptions 的像素格式字段叫 desiredPixelFormat,不叫 pixelFormat。这两个错误编译器都直接指出来了,代价只是十分钟;真正贵的是下面那条——编译器救不了的那种。
教训:从 TS 迁到 ArkTS,把"严格模式"当成一次真实的移植而不是改后缀。尤其是 ArkTS 的 API 类型签名比 TS 声明更窄(bigint、没有 any、对象字面量必须精确匹配),按记忆写 API 名字在这套环境里必然踩空。
坑六:纯 ArkTS TurboModule 只注册了 ArkTS 侧,调用静默失败(最大坑)
坑五那些错至少还有报错。这一条没有任何报错,卡了我两个小时。
现象:App 正常渲染,TurboModuleRegistry.get('LeakyGuard') 拿到对象,日志也打出来了:
A00000/com.rnoh.awesomeproject Creating Turbo Module: LeakyGuard
但一调用方法就石沉大海:
const mod = TurboModuleRegistry.get<any>('LeakyGuard');
console.info('module?', !!mod); // module? true
const p = mod.memorySnapshot();
console.info('called'); // called
p.then(r => console.info('snapshot', r.nativeHeapSizeKb)); // ← 永远不打印
不 reject、不 throw、不崩溃,Promise 永不 settle。hilog 里除了 Creating Turbo Module 再无下文。
排查过程(全是弯路,照抄没意义,但能看出该往哪查):先怀疑 static readonly NAME 没写 → 补上,无效;再怀疑 Promise 方法必须用 async 而不是 Promise.resolve() → 改了,无效;再怀疑模块 key 用了 namespace 常量算不出来 → 硬编码 'LeakyGuard',无效;再把 .ts 全改成 .ets,还是无效。
转折点是去翻另一个已经能在真机上跑通的同类库,发现它的 entry/src/main/cpp/ 下有一个我完全没写的文件:PackageProvider.cpp。
原因:RNOH 0.84 的 CAPI 架构(enableCAPIArchitecture: true)下,TurboModule 是双侧注册的:
- ArkTS 侧
RNPackage.getUITurboModuleFactoryByNameMap只负责"创建实例"; - JSI 层暴露哪些方法,由 C++ 侧的
methodMap_决定。
只有 ArkTS 侧时,JS 拿到的是一个"存在但没有任何方法"的空壳对象——模块对象能打印出来,调用却什么都不会发生。
修复:补 C++ 侧的包装类,逐个声明方法名与参数个数:
// entry/src/main/cpp/PackageProvider.cpp
class LeakyGuardArkTSTurboModule : public ArkTSTurboModule {
public:
LeakyGuardArkTSTurboModule(const ArkTSTurboModule::Context ctx, const std::string name)
: ArkTSTurboModule(ctx, name) {
methodMap_ = {
ARK_ASYNC_METHOD_METADATA(memorySnapshot, 0), // 返回 Promise
ARK_METHOD_METADATA(imageMemorySize, 3), // 同步返回
ARK_ASYNC_METHOD_METADATA(decodeImage, 1),
ARK_METHOD_METADATA(channelOpen, 3),
ARK_ASYNC_METHOD_METADATA(channelSend, 2),
// …… 全部 20 个方法逐一对应,名字和参数个数错一个,该方法就不可见
};
}
};
class LeakyGuardTurboModuleFactoryDelegate : public TurboModuleFactoryDelegate {
public:
SharedTurboModule createTurboModule(Context ctx, const std::string& name) const override {
if (name == "LeakyGuard") return std::make_shared<LeakyGuardArkTSTurboModule>(ctx, name);
return nullptr;
}
};
再在 PackageProvider::getPackages 里注册这个 Package,并在 entry/build-profile.json5 打开 externalNativeOptions(否则 cpp/ 根本不会被编译)。补完重新构建,pong 和真实内存数据立刻回来了。
教训:这类"静默失败"必须先做一个最小探针模块——一个只有 ping(): Promise<string> 返回 'pong' 的 TM,跑通它再迁移真实方法,能把"注册问题"和"业务问题"一刀切开;这次靠探针十分钟定位,之前盲猜了两小时。另外,对照一个确定能跑的同类工程,比读十页文档快。
坑七:hilog 里一行日志都没有,因为配额被别的模块吃光了
调 TM 那两小时里,最要命的是连日志都看不到:
$ hdc shell hilog | grep -i leakyguard
(空)
Creating Turbo Module: LeakyGuard 那行我是从 DevEco 的 Log 窗口里翻到的。命令行为什么没有?因为应用日志有流控配额,一旦被刷屏(这台机器上是 NETSTACK 的 websocket 日志),后面的应用日志会被静默丢弃——不是过滤问题,是压根没写进去。
hdc shell hilog -Q disable # 关掉流控,日志立刻出现
顺带一个更容易忽略的点:ArkTS 里 console.info 不保证进 hilog。要稳定可见,用带 domain 的 hilog:
import hilog from '@ohos.hilog';
hilog.info(0xFF00, 'LeakyGuard', 'snapshot native=%{public}dKB', snap.nativeHeapSizeKb);
教训:调试原生侧问题时,先确认"日志通道本身是通的"——打一条 hilog.info(0xFF00, 'PING', 'ping') 看能不能捞到。确认之前所有的"没有报错"都是假象。
坑八:降采样后图片糊成马赛克
现象:列表缩略图肉眼可见锯齿,图上的小字完全不可读,但内存确实降了 98%。
原因:desiredSize 的单位是像素,而布局尺寸是 vp。3x 屏上"120vp 的缩略图"实际需要 360px,我传了 120,等于把图缩到了三分之一再放大显示。
// 写法一:直接调原生层(DecodingOptions.desiredSize,单位像素)
// 错:把布局尺寸当像素
desiredSize: { width: 120, height: 160 }
// 对:vp → px,再留 20% 余量给圆角裁切和缩放动画
const scale = PixelRatio.get();
desiredSize: {
width: Math.ceil(120 * scale * 1.2),
height: Math.ceil(160 * scale * 1.2),
}
// 写法二:用本文库(对外暴露成 maxWidth / maxHeight,内部同样转成 desiredSize)
useImageMemory(uri, {
maxWidth: Math.ceil(120 * scale * 1.2),
maxHeight: Math.ceil(160 * scale * 1.2),
});
教训:库不改你的布局语义,这个换算必须由调用方做——所以 useImageMemory 的参数名写成 maxWidth / maxHeight 并在注释里明确"单位是像素"。降采样的收益和画质是一根滑杆,先算清"这块屏上到底需要多少像素",再决定拉到哪里。
坑九:release() 之后白块、花屏、颜色错乱——都是所有权没理清
三个现象,一个根因。
现象 A:滑动时偶发白块,日志里:
PixelMap is released
原因:同一个缓存 key 被两处使用——列表项 A 和预取逻辑。A 卸载时把 key release() 了,B 还在绘制。
现象 B:缓存命中后颜色/尺寸错乱,明明是 2x 图却显示成 1x 的清晰度。
原因:缓存键只用了 source,漏了目标尺寸和像素格式。同一个源文件在不同 desiredSize 下解出来的 PixelMap 完全不同,键里必须包含:
const key = `${source}|${w}x${h}|${format}`;
如果 CDN 有多域名/多清晰度,source 里还要带清晰度标识,否则 2x 请求会命中 1x 的缓存。
现象 C:rawfile 解码偶发 createImageSource failed,或者解出来是花屏。
原因:getRawFileContent 返回的 Uint8Array,如果中途被回收或被复用,ImageSource 持有的底层内存就失效了。对 buffer 做 subarray 尤其危险——它共享底层内存,看起来只是"取一段",实际生命周期还是绑在原 buffer 上。
解法:所有权唯一。缓存持有 PixelMap,业务只持有 key;业务"释放"实际是从缓存移除,绘制方下一帧用新 key 重新取图。确实需要共享时,外层加引用计数,绝不让两个组件各自 release。rawfile 场景直接传 content.buffer,在 createPixelMap 完成前不要释放引用。
教训:图片内存问题里,一半是"没释放",另一半是"释放了不该释放的"。后者更难查,因为它只在时序交叉时出现。给每个 PixelMap 一个明确的所有者,比到处加 release 有效得多。
6. 现在怎么用:从示例到依赖
6.1 鸿蒙侧接入:三步,少一步就静默失败
第一步:HAR 依赖。entry/oh-package.json5 里加一条,路径指向 HAR 目录:
{
"dependencies": {
"@rnoh/react-native-openharmony": "0.84.1",
// 示例工程里 HAR 与 entry 同级;从 npm 安装后指向包内的 harmony/leak_guard
"leak_guard": "file:../leak_guard"
// 或 "file:./../node_modules/react-native-ohos-leaky-guard/harmony/leak_guard"
}
}
第二步:ArkTS 侧注册。entry/src/main/ets/RNPackagesFactory.ts:
import { RNPackage } from '@rnoh/react-native-openharmony/ts';
import { LeakGuardPackage } from 'leak_guard';
export function createRNPackagesFactory(): RNPackage[] {
return [new LeakGuardPackage()];
}
第三步:C++ 侧双侧注册(坑六的完整做法)。三个文件缺一不可:
// entry/build-profile.json5 —— 不开这个,cpp/ 压根不参与编译
{
"buildOption": {
"externalNativeOptions": { "path": "./src/main/cpp/CMakeLists.txt" }
}
}
# entry/src/main/cpp/CMakeLists.txt
add_subdirectory("${RNOH_CPP_DIR}" ./rn)
add_library(rnoh_app SHARED
"./PackageProvider.cpp"
"${RNOH_CPP_DIR}/RNOHAppNapiBridge.cpp"
)
target_link_libraries(rnoh_app PUBLIC rnoh)
PackageProvider.cpp 的内容见坑六,核心是 methodMap_ 里把 20 个方法的名称与参数个数逐一写对,再让 PackageProvider::getPackages 返回这个 Package。方法名错一个,那一个方法就静默不可见——不是报错,是"没这个人"。
7.2 最小用法:给列表图一个字节预算
最省事的方式是 useImageMemory:按目标尺寸解码、卸载时自动释放、把真实字节回传给你做断言。
import React, { useEffect } from 'react';
import { Image, PixelRatio, Text, View } from 'react-native';
import { useImageMemory } from 'react-native-ohos-leaky-guard';
const scale = PixelRatio.get();
function Thumb({ uri }: { uri: string }) {
// maxWidth/maxHeight 的单位是「像素」,不是 vp —— 这就是坑八
const { info, bytes, error } = useImageMemory(uri, {
maxWidth: Math.ceil(120 * scale * 1.2),
maxHeight: Math.ceil(160 * scale * 1.2),
pixelFormat: 1, // 0=RGBA8888 1=RGB565 2=NV21 3=BGRA8888
});
// 把内存变成可断言的指标,而不是"感觉还行"
useEffect(() => {
if (bytes > 2 * 1024 * 1024) {
console.warn(`[oversize] ${uri} → ${(bytes / 1024).toFixed(0)}KB`);
}
}, [bytes, uri]);
return (
<View>
<Image source={{ uri }} style={{ width: 120, height: 160 }} />
<Text>
{error ? '加载失败' : info ? `${info.downsampled ? '降采样' : '原图'} ${(bytes / 1024).toFixed(0)} KB` : '加载中'}
</Text>
</View>
);
}
全局限额和基准放在应用启动处:
import { imageMemory, runImageMemoryBenchmark } from 'react-native-ohos-leaky-guard';
// 按字节设预算,条数只做防爆上限(中端机 32~64MB 是常见档位)
imageMemory.configure({ maxBytes: 48 * 1024 * 1024, maxEntries: 64 });
// 发版前跑一次,四项指标低于阈值才算过
const result = await runImageMemoryBenchmark({ source: 'rawfile://demo_photo.jpg', count: 24 });
console.log(result.rows); // 单图 / 峰值 / 重复解码 / 常驻
console.log(result.pss); // hidebug 采样的 PSS,可与 Profiler 交叉验证
source 支持 rawfile://name、file://path 和绝对路径;imageMemory.memorySize(w, h, format) 可以不解码直接算"这张图会占多少字节",用来做准入判断(超预算就换缩略图 URL)。
总结:
这套 LeakyGuard 是面向 RNOH 0.84 CAPI 架构打造的原生内存治理工具,核心解决鸿蒙 RN 长列表图片场景下,原图解码尺寸失控、缓存只按条数限流、PixelMap 资源依赖 GC 回收导致的 OOM 问题。它把降采样解码、字节预算式 LRU 缓存、资源生命周期登记、泄漏快照比对整合在 TurboModule 层,JS 侧通过配套 Hook 对齐组件卸载与原生资源释放的时序,同时内置通道、协程的泄漏追踪能力,不只用来事后排查,也能在开发阶段做基准回归。
和单纯靠 Profiler 看曲线、JS 堆快照排查的方式不同,它以像素缓冲区真实字节为统计口径,绕开 “图片内存不在 JS 堆” 的盲区,依靠 desiredSize 在解码阶段直接控制输出尺寸,从源头压低峰值内存;LRU 缓存以字节预算为主、条目上限为辅,逐出时强制调用 release(),避免 GC 时机不可控带来的内存积压。同时库内实现了资源登记、快照 diff、泄漏注入复现的闭环,方便在预发做稳定性校验,把偶发、难复现的内存问题变成可观测、可断言、可回归的指标。
整套方案落地的难点集中在 RNOH TurboModule 双侧注册、ArkTS 与 TS 的语法差异、buffer 生命周期、跨语言资源释放时序这些平台特有坑点上,也是真机调试里静默失败、花屏变色、内存越清越高的根源。接入时要注意 vp 到像素的换算、缓存键必须带上源地址 + 尺寸 + 像素格式、release 簿记顺序、异步解码取消分支的兜底释放,以及区分 debug 包和 release 包的内存基线噪声。
它不只是一个图片缓存库,而是一套 RNOH 原生资源生命周期管控范式:所有需要手动释放的资源,统一走登记、追踪、快照、兜底回收的链路,图片、通道、协程、定时器都可以复用这套思路,在业务代码收敛原生资源管理逻辑,避免同类内存泄漏反复在不同页面出现。
14



被折叠的 条评论
为什么被折叠?



