Administrator
发布于 2026-09-03 / 9,004 阅读
0
0

鸿蒙内存管理进阶:Allocation 分析、泄漏排查与生命周期治理

系列文章《鸿蒙开发从入门到专家》第 57 篇 · 高级

第 8 篇讲性能优化时提到内存优化是"三大战役"之一,但那篇只给了原则。这篇讲具体怎么查、怎么治。

鸿蒙应用最常见的内存问题不是 OOM(系统有 LMK 兜底),而是内存泄漏导致的列表卡顿和反复 GC。一个被泄漏的图片对象占 2MB,10 个就 20MB,GC 频繁触发,帧率掉到 40fps 以下用户就能感知到。

一、ArkTS 内存模型

ArkTS 运行在 JS 引擎上,依赖 GC(垃圾回收)管理内存。和 Android 的 JVM 不同,JS 引擎的 GC 策略是分代的:

  • 新生代(Young Generation):短命对象,Minor GC 频繁回收。大部分临时对象在这里被回收。

  • 老生代(Old Generation):存活时间长的对象,Major GC 不定期回收。如果对象被引用持有,会从新生代晋升到老生代,长期驻留。

内存泄漏的本质:对象本该被回收,但被某个长期存活的引用持有,无法被 GC 标记为可回收。

二、常见泄漏模式

2.1 闭包捕获

最隐蔽的泄漏。闭包持有外部作用域的变量引用,如果闭包被长期持有,变量引用的对象也不会被回收。

// 泄漏:定时器回调持有组件引用
@Component
struct TimerLeakDemo {
  private timerId: number = -1;
  @State count: number = 0;

  aboutToAppear(): void {
    this.timerId = setInterval(() => {
      // 这个箭头函数捕获了 this
      // 只要定时器没清除,this(组件)就不会被 GC
      this.count++;
    }, 1000);
  }

  // 忘了清除定时器
  aboutToDisappear(): void {
    // 必须清除!
    if (this.timerId !== -1) {
      clearInterval(this.timerId);
      this.timerId = -1;
    }
  }
}

这在列表项组件中尤其危险。列表项被回收时,如果 aboutToDisappear 没有清理定时器、事件监听器或异步回调,组件实例就泄漏了。

2.2 全局单例持有

// 泄漏:全局事件总线持有组件引用
class EventBus {
  private static instance: EventBus;
  private listeners: Map<string, Function[]> = new Map();

  static getInstance(): EventBus {
    if (!EventBus.instance) {
      EventBus.instance = new EventBus();
    }
    return EventBus.instance;
  }

  on(event: string, callback: Function): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, []);
    }
    this.listeners.get(event)!.push(callback);
  }

  emit(event: string, data: any): void {
    this.listeners.get(event)?.forEach(cb => cb(data));
  }

  off(event: string, callback: Function): void {
    const callbacks = this.listeners.get(event);
    if (callbacks) {
      const index = callbacks.indexOf(callback);
      if (index >= 0) {
        callbacks.splice(index, 1);
      }
    }
  }
}

@Component
struct EventListenerComponent {
  private handler = (data: any) => {
    // 箭头函数捕获 this
    console.info('Received: ' + data);
  };

  aboutToAppear(): void {
    EventBus.getInstance().on('dataUpdate', this.handler);
  }

  aboutToDisappear(): void {
    // 必须反注册!
    EventBus.getInstance().off('dataUpdate', this.handler);
  }
}

2.3 LazyForEach 闭包

第 52 篇提到过:LazyForEach 的 itemGenerator 是一个闭包,如果在里面引用外部大对象,即使列表项被回收,大对象也无法被 GC。

// 泄漏:闭包捕获大数组
const largeData = loadLargeData(); // 100MB 数据

LazyForEach(source, (item) => {
  // largeData 被闭包持有
  // 即使列表项被回收,largeData 也不会被 GC
  processWith(largeData, item);
});

// 正确做法:只传需要的数据
const processedItems = source.map(item => extractNeeded(largeData, item));
// largeData 可以被释放

三、DevEco Profiler 内存分析

DevEco Studio 内置了 Profiler 工具,可以抓取内存快照。

3.1 录制内存快照

  1. 打开 DevEco Studio → Run → Profile

  2. 选择 Memory 标签页

  3. 点击 Record 开始录制

  4. 操作应用(进入页面 → 退出页面 → 再进入 → 再退出)

  5. 点击 Stop 停止录制

录制结果会显示内存曲线。正常曲线:进入页面内存上升,退出页面内存回落。如果退出后内存不回落,说明有泄漏。

3.2 分析堆快照

抓取堆快照后,按对象类型分组查看:

分析项

关注点

对象数量

同类型对象数量是否异常多

保留大小(Retained Size)

对象及其引用链占用的总内存

距离根节点距离

距离 GC Root 越近越难回收

引用链

找到持有该对象的引用路径

重点看:

  • 组件实例(@Component 生成的对象)数量是否随页面退出而减少。

  • 图片 Image 对象的保留大小是否异常。

  • 闭包(Closure)的保留大小。

3.3 代码定位

发现泄漏对象后,通过引用链定位到代码。典型的引用链:

GC Root
  → setInterval 的回调队列
    → 箭头函数闭包
      → Component 实例(泄漏)
        → @State 数据
          → 图片对象

这条链告诉你:定时器没清除,导致组件实例和它持有的数据全部泄漏。

四、生命周期治理

泄漏排查是亡羊补牢,生命周期治理是预防。

4.1 资源清理清单

每个组件的 aboutToDisappear 应该清理以下资源:

资源类型

清理方式

setInterval / setTimeout

clearInterval / clearTimeout

事件监听(EventBus / 自定义)

off / removeListener

Web 组件

controller 的销毁方法

数据库连接

close / dispose

文件流

close

异步任务

取消或标记为已销毁

动画

取消 animateTo

4.2 统一清理基类

// base/DisposableComponent.ts
type CleanupFn = () => void;

export abstract class DisposableComponent {
  private cleanups: CleanupFn[] = [];
  private disposed: boolean = false;

  protected registerCleanup(fn: CleanupFn): void {
    this.cleanups.push(fn);
  }

  // 子类在 aboutToDisappear 中调用
  protected dispose(): void {
    if (this.disposed) return;
    this.disposed = true;

    // 逆序执行清理
    for (let i = this.cleanups.length - 1; i >= 0; i--) {
      try {
        this.cleanups[i]();
      } catch (e) {
        console.error('Cleanup failed: ' + e);
      }
    }
    this.cleanups = [];
  }
}

使用:

@Component
struct SafeTimerComponent extends DisposableComponent {
  @State count: number = 0;

  aboutToAppear(): void {
    const timerId = setInterval(() => {
      if (this.disposed) return; // 防止已销毁后回调
      this.count++;
    }, 1000);

    this.registerCleanup(() => clearInterval(timerId));

    // 事件监听也注册
    const handler = (data: any) => { /* ... */ };
    EventBus.getInstance().on('update', handler);
    this.registerCleanup(() => EventBus.getInstance().off('update', handler));
  }

  aboutToDisappear(): void {
    this.dispose();
  }
}

4.3 异步回调保护

异步操作(网络请求、Promise)的回调可能在组件销毁后触发,导致操作已销毁的 @State 变量。

@Component
struct AsyncSafeComponent {
  @State data: string = '';
  private isAlive: boolean = true;

  aboutToAppear(): void {
    this.fetchData();
  }

  private async fetchData(): Promise<void> {
    const result = await httpClient.get('/api/data');
    // 检查组件是否还活着
    if (!this.isAlive) return;
    this.data = result;
  }

  aboutToDisappear(): void {
    this.isAlive = false;
  }
}

简单但有效。对于 TaskPool 的任务,用 @Sendable 的方式传递 isAlive 标志不可行(跨线程无法共享引用),需要在任务完成后通过消息回传时检查标志。

五、图片内存优化

图片是内存消耗大户。一张 1080p 的图片解码后占 8MB(1920×1080×4 bytes),10 张就 80MB。

5.1 按需加载尺寸

import { image } from '@kit.ImageKit';

async function loadThumbnail(context: Context, uri: string,
  targetWidth: number, targetHeight: number): Promise<PixelMap> {
  const imageSource = image.createImageSource(uri);

  // 只解码需要的尺寸
  const decodingOptions: image.DecodingOptions = {
    editable: false,
    desiredSize: { width: targetWidth, height: targetHeight }
  };

  const pixelMap = await imageSource.createPixelMap(decodingOptions);
  imageSource.release();
  return pixelMap;
}

desiredSize 让解码器只生成目标尺寸的位图,而不是解码原图再缩放。一张 4032×3024 的照片(48MB 解码后)按 200×200 解码只占 160KB。

5.2 LRU 图片缓存

class ImageCache {
  private cache: Map<string, PixelMap> = new Map();
  private maxSize: number = 50;

  get(key: string): PixelMap | null {
    const item = this.cache.get(key);
    if (item) {
      // 移到队尾(LRU 策略)
      this.cache.delete(key);
      this.cache.set(key, item);
    }
    return item || null;
  }

  put(key: string, value: PixelMap): void {
    if (this.cache.size >= this.maxSize) {
      // 淘汰最久未使用的
      const oldestKey = this.cache.keys().next().value;
      const oldest = this.cache.get(oldestKey);
      if (oldest) {
        oldest.release();
      }
      this.cache.delete(oldestKey);
    }
    this.cache.set(key, value);
  }

  clear(): void {
    this.cache.forEach(item => item.release());
    this.cache.clear();
  }
}

release() 是关键——PixelMap 持有 native 内存,不调用 release 等于泄漏。

六、内存水位监控

运行时监控内存水位,超过阈值时主动清理缓存:

import { hidumper } from '@kit.PerformanceAnalysisKit';

class MemoryMonitor {
  private threshold: number = 200; // 200MB
  private checkInterval: number = 30000; // 30秒检查一次

  start(): void {
    setInterval(() => {
      const used = this.getMemoryUsage();
      if (used > this.threshold) {
        console.warn(`Memory high: ${used}MB, clearing cache`);
        ImageCache.getInstance().clear();
      }
    }, this.checkInterval);
  }

  private getMemoryUsage(): number {
    // 通过系统接口获取当前进程内存
    // 实际项目中用 Profiler API 或系统服务
    return 0; // 示例简化
  }
}

小结

内存管理的三道防线:预防(生命周期治理 + 资源清理基类)、监控(Profiler 堆分析 + 运行时水位)、治理(按需加载图片 + LRU 缓存 + 主动清理)。泄漏排查的关键是看引用链——找到谁持有不该持有的引用,然后在该清理的地方补上清理代码。

下篇讲主题与暗黑模式。颜色令牌怎么设计、动态换肤怎么实现。

参考阅读:

  • 第 8 篇:鸿蒙性能优化实战(内存优化部分)

  • 第 52 篇:高效列表渲染(LazyForEach 闭包泄漏)

  • 官方文档:DevEco Profiler、image 模块


评论