使用mem-analyzer高效分析ArkTS内存泄漏

0 评论 1278 浏览 0 收藏 27 分钟

本原创文章帖发布在华为开发者联盟社区,欢迎开发者前往访问评论交流,更多与该内容相关讨论,请点击原帖查看:

使用mem-analyzer高效分析ArkTS内存泄漏

在ArkTS内存泄漏中,有一类是ArkUI组件泄漏,只需要有快照日志就能完成对此问题的分析,不依赖开发者做任何适配。其利用ArkUI框架自动标记已销毁组件,分析准确率很高。当前ArkUI组件相关泄漏非常普遍,建议在日常开发过程中对此类问题应改尽改,原因是:1)此类内存泄漏引用链明确,修改简单;2)快照日志获取容易,通过命令行、IDE工具和API在开发态/运维态便捷获取日志;3)ArkUI组件泄漏会导致绘制等相关的底层内存泄漏,这些底层内存泄漏分析相对比较复杂,容易造成干扰,影响其他native内存分析,可以通过修复组件泄漏减少干扰项,减少问题分析复杂度。

1. ArkUI组件泄漏检测原理

ArkUI Component组件生命周期特点是在aboutToDisappear之后,即表示相关界面消失,组件已销毁,以Component为例,其完整的生命周期如下图:

当Component生命周期结束,进入到DISAPPERED状态后,后续无法被复用,不应该被继续持有,否则可能引发内存泄漏。

根据此特点,可以制定专门针对ArkUI组件的泄漏检测。其原理如下:

在应用侧,会对组件进行代码功能的调用,当生命周期结束后(如,页面退出),系统侧的ArkUI框架会自动给组件的对象打上标记,例如将isDeleting_设置为true。当ArkTS OOM发生时,ArkTS运行时会先进行一次GC,清理掉可回收的对象,然后再导出快照。如果在导出的快照中,发现Component的isDeleting_为true,则说明快照中Component对象没有按照预期释放,可能仍被其他对象持有,导致泄漏。

在多个应用线上ArkTS OOM泄漏问题分析中,通过此原理检测出的泄漏问题,准确率高达90%+,对于内存定位非常有效。当前,在HarmonyOS7.0上,已经支持该检测的组件有:

1) BuilderNode、FrameNode、RenderNode、XComponent,这些组件生命周期结束后,ArkUI框架会将对象上的_isDisposed属性置为true。

2) Ability,生命周期结束后,ArkUI框架会将对象上的isDestroyed属性置为true。

3) 自定义组件Component,生命周期结束后,ArkUI框架会将对象上的isDeleting_属性置为true。

备注:不同类型的组件生命周期结束的函数名称不一定相同,但检测原理是一致的。

2. ArkUI组件泄漏问题分析与定位

在获取到ArkTS堆快照日志文件后,使用mem-analyzer即可高效完成ArkUI组件泄漏问题的分析与定位。mem-analyzer是一个集开发态日志抓取和运维态日志分析的命令行工具。其下载链接如下:https://gitcode.com/ancimoon/developtools_dfx_mem_analyzer/releases 。使用手册链接为:https://gitcode.com/ancimoon/developtools_dfx_mem_analyzer/blob/master/docs/usage.md 。 ArkTS堆快照日志获取方法可参考第4章节。

2.1 聚类ArkUI泄漏组件对象及引用链

开发者可通过mem-analyzer命令行工具执行分析:mem-analyzer jsleak xx.rawheap -o

在处理过程中,分析工具会找到所有标记过的对象(属性isDestroyed、_isDisposed、isDestroyed为true),并识别其是否为ArkUI组件(Component、BuilderNode、FrameNode、 RenderNode、Ability等),然后按照对象类型进行聚类,计算大小、节点个数和引用链。之后,将信息输出为一份html文件,并自动打开该文件。文件示意如下:

在“ArkUI组件/ArkTS对象泄漏分析报告”这个tab中,详情列表中,按照size,由大到小依次列出所有识别到的、可能发生泄漏的ArkUI组件。在表中,点击“详情”,可以查看此类型相关的所有引用链。引用链是一条从Root出发,经由中间对象,达到目标对象的链条,其中A->B,说明A引用(持有)B。

mem-analyzer对引用链里的特定类型进行了展开处理,提取出相关符号的业务特征信息,具体如下:

1)JSObject对象,分析工具会读取此对象的属性总数、以及最多10个非系统的属性名字,以方便用户对应到真实的代码。系统公共属性,如hclass、__proto__、ArkInternalHash、HashField等会被直接过滤掉,不会显示。假设某个对象一共有7个属性,用户自定义的属性名为id, alive, instance,则会显示为JSObject(len=7, prop=id,alive,instance)。

2) string对象,分析工具会截取真实值的前20个字符,并显示字符串长度的区间(长度<10KB, 长度>=10KB并且<1MB,以及长度>=1MB)。例如,字符串“abc”会显示为string(size:<10KB,content=abc)。在线上实际运行时,快照导出时会丢弃字符内容,因此会显示为string(size:<10KB, content=)。

3) 装饰器对象,分析工具会找到此装饰器对象真实的ArkTS类名。例如,类名为Foo的对象被类装饰器(如@Observed, @State等)处理后,日志中引用链里名字会变成declarative_frontend/stateMgmt.js#observedClass(line:xx),丢失业务特征,分析工具会将其还原,输出名字为stateMgmt.js#observedClass(Foo)。

4) SourceTextModule对象,是模块(Module)的运行时记录对象,其中包含了导出对象,堆快照中名字为SourceTextModule,缺少业务信息。分析工具会找出此类对象的模块名,导出个数,导出名等信息,方便开发者直接找到相关的业务代码。输出结果示例为SourceTextModule(nums=1, module=xx.ts),其中nums为导出条目的个数,module为模块名。

在“引用链总览图中”这个tab中,对引用链进行聚合展示,可以看到关键的引用节点。由于原始的详情列表中的引用链可能会非常多,平铺展示,不利于交叉分析,因此按照应用链进行聚合。聚合后,可清楚看到不同泄漏点的相同中间节点,释放此中间节点可修复多个泄漏问题。

此分析还支持其他参数,以实现更多、更详细的分析:

jsleak常用命令行选项相 说明
–sourcemap PATH 指定工程 sourceMap 文件或目录(含 sourceMaps.map),用于将混淆的编译产物路径还原为源码路径
–namecache PATH 指定工程 nameCache 文件或目录(含 nameCache.json),用于将混淆的函数/属性名还原为原始名
-o, –auto-open 分析完成后自动打开结果文件

2.2 手动分析ArkUI组件引用链

ArkTS对象无法释放的核心原因是至少有一条从GCRoot到该对象的引用链,常见的GCRoot包含GlobalHandleRoot、LocalHandleRoot、VMRoot、FrameRoot,参考链接。在实际场景中,从GCRoot到对象可能有多条不同的引用链,如果需要释放该对象,那么需要断开从GCRoot到该对象的所有引用链。以如下的引用链为例:

GlobalHandleRoot[4225]->com.example.memoryleakapplication/entry@1.0.0/src/main/ets/entryability/LeakAbility.ts#LeakAbility(line:45)[entry]->LexicalEnv->JSArray->ArkInternalArray->JSObject(len=7, prop=id,alive,instance)->com.example.memoryleakapplication/entry@1.0.0/src/main/ets/entryability/LeakAbility.ts#LeakAbility(line:45)[entry]

此引用链中,相关的符号是已经编译过的ts文件。如果本地有编译过的.ts文件,可以直接对应到代码。如果没有,则可以通过sourceMap.map 进行反混淆。使用如下命令:

mem-analyzer jsleak xx.rawheap -o --source-map /path/to/sourceMaps.map

直接反混淆。得到如下的引用链:

GlobalHandleRoot[4225]|->entry/src/main/ets/entryability/LeakAbility.ets#LeakAbility(line:53)[entry]|->LexicalEnv|->JSArray|->ArkInternalArray
->JSObject(len=7, prop=id,alive,instance)|->entry/src/main/ets/entryability/LeakAbility.ets#LeakAbility(line:53)[entry]。

得到真实的源文件LeakAbility.ets。在此引用链中可以看到:

1)泄漏对象为LeakAbility,其持有者为JSObject,此JSObject有名为id, alive, instance的属性。通过该属性定位到,其类型为AbilityLeakEntry。

2) JSObject(len=7, prop=id,alive,instance)的持有者为JSArray,也就是有个AbilityLeakEntry数组。

3)数组的持有者为LexicalEnv,也就是一个词法环境。其上游为LeakAbility.ets#LeakAbility(line:53)[entry]。也就是说词法环境属于LeakAbility.ets文件。在LeakAbility.ets中直接搜索AbilityLeakEntry数组,找到对象leakedAbilityRegistry,其在Ability的onCreate()函数中存储LeakAbility的this指针。

由此还原出完整的引用链。GlobalHandleRoot持有LeakAbility对象,LeakAbility对象持有LeakAbility.ets模块级词法环境LexicalEnv,此环境中有个全局变量leakedAbilityRegistry数组,数组中的对象又引用了LeakAbility对象自身,构成循环引用:instance:this->LeakAbility->LexicalEnv->leakedAbilityRegistry->entry.instance->LeakAbility。

// LeakAbility.ets
interface AbilityLeakEntry {
  id: number;
  alive: boolean;
  instance: object;
}
const leakedAbilityRegistry: AbilityLeakEntry[] = [];
export default class LeakAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    …
    leakedAbilityRegistry.push({
      id: abilityLeakId,
      alive: true,
      instance: this as object
    });
  }
}

为了不影响代码的正常功能,选择在LeakAbility生命周期结束(onDestroy())时,取消引用。

// LeakAbility.ets
export default class LeakAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    …
  }
  onDestroy(): void {
    for (let i = leakedAbilityRegistry.length - 1; i >= 0 ; i--) {
      leakedAbilityRegistry.splice(i, 1);
    }
  }
}

根据之前的引用链总览图,可以看到LeakAbility只有这一条从GCRoot出发引用链。断开后,对象没有其他链引用,也就可以正常释放。

2.3 自动分析ArkUI组件引用链

通过DevEco AI + DFX Skills可以自动化完成分析、定位与修复。步骤:

       1. 下载DFX Skills,下载链接:https://gitcode.com/openharmony-sig/developtools_dfx_skills

       2. 通过DevEco Studio打开项目。

       3. 在DevEco Studio右边栏找到并登录CodeGenie。安装arkts代码修复Skill,名字为memoryleak–arkts-code-patch。

       4. 将引用链给到AI

       5. 得到修复建议

3. 常见导致ArkUI组件泄漏的案例

3.1 事件注册/注销不对称

OpenHarmony API中存在有很多的注册/注销监听的接口,如使用不当可能导致ArkUI组件泄漏。

例如,组件通过 mediaquery.matchMediaSync()注册横屏/竖屏切换监听,传入的回调(通常是箭头函数属性)绑定了组件 this。系统 mediaquery是 GC Root 可达的,如果 aboutToDisappear 中没有调用 listener.off(‘change’),组件实例无法被 GC 回收,导致组件泄漏。示例代码如下:

❌ 修复前

struct OrientationPage {
  …
  private listener: mediaquery.MediaQueryListener | null = null;
  private onOrientationChange = (result: mediaquery.MediaQueryResult): void => {
    // 箭头函数属性,绑定this
    this.isLandscape = result.matches;
  }

  aboutToAppear(): void {
    const mediaQuery = this.getUIContext().getMediaQuery();
    this.listener = mediaQuery.matchMediaSync('(orientation: landscape)');
    this.isLandscape = this.listener.matches;
    this.listener.on('change', this.onOrientationChange); // 注册到系统mediaquery
  }

  aboutToDisappear(): void { // 未取消监听,系统监听器持续持有回调
  }
}

✅ 修复后

aboutToDisappear(): void {
  this.listener?.off('change', this.onOrientationChange); // 取消监听,组件销毁释放内存
}

3.2 闭包捕获this的回调属性未置空

箭头函数没有自己的 this,它会从定义时的词法环境中捕获外层的 this。当组件将一个箭头函数赋值给外部对象的属性时,外部对象通过这个箭头函数的 LexicalEnv 持有了组件 this 的引用,导致组件泄漏。

❌ 修复前

struct Index {
  @Provider() player: ...; // Provider装饰器,提供数据
}
@ComponentV2
struct Child {
  @Consumer() player : ...;
  aboutToAppear(): void {
    this.player.switchContent = (contId?) => { //箭头函数属性,捕获this,外部对象间接持有
      this.switchContent(contId)
    }
    this.mouseHandler.onMouse =  (ac) => {
      if (this.playController...) {
        ...
      }
    }
  }
  aboutToDisappear(): void {
    this.playController.release(); // switchContent未置空,外部对象player仍持有引用
  }
}

✅ 修复后

aboutToDisappear(): void {
  this.playController.release();
  this.player.switchContent = undefined; // swtichContent置空,切断引用链
  this.mouseHandler. onMouse = undefined;
}

3.3 静态属性/AppStorage/单例持有捕获this的闭包

将捕获组件 this 的闭包存入 AppStorage 或类的静态属性,这些全局存储的生命周期远长于组件,组件销毁后闭包仍持有组件引用,导致组件泄漏。

❌ 修复前

class GlobalBroker {
  static handlers: Array<() => void> = []; // 静态属性存储
  static register(handler: () => void): void {
    GlobalBroker.handlers.push(handler);
  }
}
@Component
struct LeakyChild {
  @State count: number = 0;
  aboutToAppear(): void {
    GlobalBroker.register(() => { // 闭包捕获this
      this.count++;
    });
  }
  aboutToDisappear(): void {
  }
}

✅ 修复后

class GlobalBroker {
  static handlers: Array<() => void> = [];
  static register(handler: () => void): () => void {
    GlobalBroker.handlers.push(handler);
    return () => { // 返回反注册函数
      const idx = GlobalBroker.handlers.indexOf(handler);
      if (idx >= 0) {
        GlobalBroker.handlers.splice(idx, 1);
      }
    };
  }
}
@Component
struct SafeChild {
  private unregister: (() => void) | undefined = undefined;
  aboutToAppear(): void {
    this.unregister = GlobalBroker.register(() => { // 保存反注册函数
      this.count++;
    });
  }
  aboutToDisappear(): void {
    this.unregister?.(); // 执行反注册函数
    this.unregister = undefined;
  }
}

3.4 定时器未清理

定时器的回调函数通过词法环境持有组件this,组件销毁后,未正确清理定时器,导致组件泄漏。

❌ 修复前

struct SetTimeoutDemo {
  jumpIndexPage() {
    setTimeout(async () => { // 闭包捕获 this,并且返回值被丢弃,无法清理
      RouterRule.jumpWithAction(action)
    }, 2000);
  }
  aboutToDisappear() {
    clearTimeout(); // 无入参,不会取消任何定时器
  }
}

✅ 修复后

struct SetTimeoutDemo {
  private jumpTimeoutId: number = -1
  jumpIndexPage() {
    this.jumpTimeoutId = setTimeout(async () => { // 记录定时器Id
      RouterRule.jumpWithAction(action)
    }, 2000);
  }

  aboutToDisappear() {
    if (this.jumpTimeoutId !== -1) { // 取消指定的定时器
      clearTimeout(this.jumpTimeoutId)
      this.jumpTimeoutId = -1
    }
  }
}

4. ArkTS堆快照日志获取方法

在ArkUI组件泄漏泄漏检测中,依赖获取到堆快照日志。不需要再使用JsLeakWatcher相关API,系统自动埋点,应用无需适配。

4.1开发态获取日志方法

在开发态可通过mem-analyzer命令行进行堆快照抓取。PC设备安装hdc,设置环境变量之后,通过如下命令抓取快照:

mem-analyzer profiler -c snap com.example.app

操作示意如下

由于mem-analyzer本身也支持分析,可直接使用“mem-analyzer profiler -c snapshot com.example.app -o ”,-o 意味着,抓取快照后,自动分析,并打开分析结果。

4.2 运维态获取日志方法

4.2.1 通过HiAppEvent API订阅获取快照

开发者可通过HiAppEvent API订阅获取OOM快照,然后上传到自己的云端。快照的订阅方法可参考链接 https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/hiappevent-watcher-resourceleak-events 。示例代码如下:

const config: Record<string, hiAppEvent.ParamType> = {
  'js_heap_logtype': 'event_rawheap', // 设置参数,通知系统,OOM时需要导出rawheap快照
};
hiAppEvent.setEventConfig(hiAppEvent.event.RESOURCE_OVERLIMIT, config);
hiAppEvent.addWatcher({
  name: 'resourceOverLimitWatcher',
  appEventFilters: [{
    domain: hiAppEvent.domain.OS,
    names: [hiAppEvent.event.RESOURCE_OVERLIMIT]
  }],
  onReceive: (domain: string, appEventGroups: Array<hiAppEvent.AppEventGroup>) => {
    for (const eventGroup of appEventGroups) {
      if (eventGroup.name != hiAppEvent.event.RESOURCE_OVERLIMIT){  // 过滤,找到资源泄漏事件
        continue;
      }
      for (const eventInfo of eventGroup.appEventInfos) {
        const params: Record<string, Object> = eventInfo.params as Record<string, Object>;
        if ((params[‘resource_type’] as string) === ‘js_heap’) { // 获取OOM快照地址
          const logs: Array<string> = (params[‘external_log’] as Array<string>) ?? []; 
          …
        }
      }
    }
  }
});

当应用收到泄漏事件为OOM且日志文件路径有效后,可以拷贝此文件,通过网络上传到应用自己的云端,以便进行后续分析。

4.2.2 通过APMS获取快照

通过APMS获取快照,需要几个步骤:1)接入应用灰度API; 2)在APMS上下发灰度任务;3)在APMS上下载快照日志。

接入应用灰度API:在HarmonyOS 7.0后,应用可通过应用灰度API,自动触发线上的OOM快照抓取,相关日志会自动上传到APMS平台。灰度的接入参考链接:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/hiretrieval-intro 。接入完成后,上架发布应用。

下发灰度任务:登录AppGallery Connect ,点击“开发与服务”,在项目列表中找到对应项目,在项目下的应用列表中点击对应应用/元服务。选择“质量 > APMS > 配置管理”,选择灰度采集,选择针对JS_LEAK故障发起灰度任务。系统会根据用户选择的信息,圈选目标设备,在指定的时间段内,发起OOM快照的灰度采集。

下载快照日志:灰度任务运行一段时间后,如果圈选的设备发生故障,则云端平台会收到快照,开发者可通过页面中的链接下载日志文件。参考如下链接https://developer.huawei.com/consumer/cn/doc/best-practices/bpta-arkts-leak-in-operation#section1831731688

🔗 官网开发者学堂视频:https://developer.huawei.com/consumer/cn/training/result?type2List=201783644516849879&orderBy=1&courseType=5

     🔗 社区DFX专题文章:  https://developer.huawei.com/consumer/cn/forum/subject/2101218731402391001

【扫码加入 HarmonyOS DFX 技术交流群】

本文由 @华为开发者联盟 授权发布于人人都是产品经理。未经作者许可,禁止转载

题图来自Unsplash,基于CC0协议

该文观点仅代表作者本人,人人都是产品经理平台仅提供信息存储空间服务

更多精彩内容,请关注人人都是产品经理微信公众号或下载App
海报
评论
评论请登录
  1. 目前还没评论,等你发挥!