一个相册选择页,用 photoAccessHelper.PhotoViewPicker 拉取用户选中的图片,然后在一个 Grid 里展示缩略图。第一屏正常,滑到第二屏开始掉帧,滑到第三屏手势明显跟不上,偶尔闪一下白块。你把 cachedCount 调小、把 @Reusable 加上、aboutToReuse 里也重置了 src,卡顿只是从第三屏推迟到了第五屏。你以为是图片本身太大,把 Image 的宽高压到 120,缩略图还是卡,内存曲线还在涨。

问题不在组件的复用,也不在图片的显示尺寸,在于每一张缩略图背后都有一个 PixelMap 和一个 FileAsset,它们不会因为组件被复用而自动释放。PhotoAccessHelper 返回的资源句柄、FetchResult 的结果集、以及 ImageSource.createPixelMap 产出的像素缓冲区,都是需要显式释放的原生资源。你把它们交给 Image 组件显示之后就撒手不管,Image 组件只负责渲染,它不负责释放你创建的那些句柄。句柄和像素内存在后台累积,滑得越远,累积得越多,主线程被 GC 和资源回收拖慢,表现出来就是“越滑越卡”。

FetchResult 和 FileAsset 是两层句柄,关闭顺序有讲究

先把 PhotoAccessHelper 的查询链路拆开。一次相册查询的典型流程是:photoAccessHelper.getAssets(options) 返回一个 FetchResult,遍历 FetchResult 拿到 FileAsset,从 FileAsset 打开文件描述符或者获取 URI,再交给 ImageSource 解码。

FetchResult 和 FileAsset 是两层独立的句柄。FetchResult 持有查询结果的游标和底层资源,FileAsset 持有单条记录的访问入口。遍历结束之后,FetchResult.close() 必须调用,不关闭的结果集会一直占着数据库游标和 native 内存。FileAsset 在每次遍历时获取,它的 release() 或者 close() 在不需要继续访问该条记录时调用。两者不关闭,查询次数越多,累积的游标和句柄越多。

typescript

import { photoAccessHelper } from '@kit.MediaLibraryKit';

async function loadAssetThumbnails(
  phAccessHelper: photoAccessHelper.PhotoAccessHelper,
  onThumb: (id: string, pm: image.PixelMap) => void
): Promise<void> {
  const options: photoAccessHelper.PhotoSelectOptions = {
    MIMEType: photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE,
    maxSelectNumber: 100
  };
  const fetchResult = await phAccessHelper.getAssets(options);
  try {
    let asset: photoAccessHelper.PhotoAsset | undefined;
    while ((asset = await fetchResult.getNextObject()) !== undefined) {
      const pm = await decodeThumb(asset);
      if (pm) onThumb(asset.uri, pm);
      // PhotoAsset 是查询结果里的条目,遍历完不需要显式 release
    }
  } finally {
    await fetchResult.close();   // 结果集必须关闭,否则游标和 native 资源不释放
  }
}

fetchResult.close() 放在 finally 里,确保遍历中途异常也会关闭。getNextObject 返回的 PhotoAsset 在 PhotoAccessHelper 的查询链路里不需要逐个 release,它由 FetchResult 统一管理;但如果你走的是 fs.open 打开文件描述符的路径,那个 fd 必须自己关。

缩略图解码出的 PixelMap,Image 组件不会替你 release

ImageSource.createPixelMap 返回的 PixelMap 是 native 像素缓冲区。把它交给 Image 组件显示时,组件内部会持有它并渲染,但组件不会在 src 被替换或者组件销毁时自动调用 PixelMap.release()。这和前面写过的图片内存文章是同一个边界:Image 组件管渲染,不管资源回收。

一个很自然的写法是在 aboutToReuse 里把 src 换成新的 PixelMap,旧的 PixelMap 引用被覆盖,但 native 内存还在。每复用一次,旧的那块像素缓冲区就多泄漏一份。一屏 20 张图,滑十屏,就是两百份没有被释放的 PixelMap。

typescript

@Reusable
@Component
struct ThumbCell {
  @State thumb: image.PixelMap | undefined = undefined;

  aboutToReuse(params: Record<string, Object>): void {
    const next = params.thumb as image.PixelMap;
    if (this.thumb && this.thumb !== next) {
      this.thumb.release();    // 旧图先释放,再换新图
    }
    this.thumb = next;
  }

  aboutToDisappear(): void {
    if (this.thumb) {
      this.thumb.release();
      this.thumb = undefined;
    }
  }

  build() {
    Image(this.thumb)
      .width(120).height(120)
      .objectFit(ImageFit.Cover)
  }
}

aboutToReuse 里先 release 旧图再赋新值,aboutToDisappear 里兜底释放。两个位置都要有,aboutToReuse 覆盖复用场景,aboutToDisappear 覆盖组件真正销毁的场景。只有 aboutToReuse 没有 aboutToDisappear,最后一屏的图不会被释放;只有 aboutToDisappear 没有 aboutToReuse,复用过程中旧图一直挂着。

ImageSource 用完就释放,不要跨帧持有

从 FileAsset 的 fd 或者 URI 创建 ImageSource,解码出缩略图之后,ImageSource 的使命就结束了。它持有解码器状态和源文件句柄,不释放会在后台累积。很多人的写法是把 ImageSource 和 PixelMap 一起存进一个 Map,想着“以后可能还要重新解码”,结果 ImageSource 和 PixelMap 两份资源一起泄漏。

ImageSource 的 release() 应该在 createPixelMap 成功之后立刻调用,不需要跨帧持有。缩略图已经在 PixelMap 里了,重新解码的需求应该用缓存策略解决,而不是留着 ImageSource 当缓存。

typescript

async function decodeThumb(asset: photoAccessHelper.PhotoAsset): Promise<image.PixelMap | undefined> {
  const file = await fs.open(asset.uri, fs.OpenMode.READ_ONLY);
  const source = image.createImageSource(file.fd);
  try {
    const pm = await source.createPixelMap({
      desiredSize: { width: 240, height: 240 },
      desiredPixelFormat: image.PixelMapFormat.RGB_565,
      editable: false
    });
    return pm;
  } catch (_) {
    return undefined;
  } finally {
    await source.release();   // 解码完成就释放 ImageSource
    fs.closeSync(file);       // fd 单独关闭,和 source 是两本账
  }
}

source.release() 和 fs.closeSync(file) 都在 finally 里,两者是独立的资源。ImageSource.release() 不关闭你传进去的 fd,fs.closeSync 也不释放 ImageSource 的解码器状态。只关一个,另一个会泄漏。

变更监听要在页面不可见时注销,on 和 off 成对

相册页面通常需要监听媒体库的变化,用户在系统相册里删了一张图,你的页面应该同步更新。photoAccessHelper 提供了 on('photoChange') 或者 registerChange 之类的监听接口,注册之后,媒体库的增删改会回调过来。

这个监听器持有页面闭包,不注销会在页面销毁后继续触发。和前面写过的 EventHub、传感器、定位监听是同一类问题:on 和 off 必须用同一个回调引用,页面 aboutToDisappear 时注销。

typescript

private readonly onPhotoChange = (changeData: photoAccessHelper.PhotoAssetChangeData): void => {
  this.refreshAffected(changeData);
};

aboutToAppear(): void {
  this.phAccessHelper.on('photoChange', this.onPhotoChange);
}

aboutToDisappear(): void {
  this.phAccessHelper.off('photoChange', this.onPhotoChange);   // 同一个引用
}

off 不传回调引用时,会注销该事件的所有监听,如果有多个页面或者多个模块监听了同一个事件,全量注销会误伤别人。传同一个引用精确注销,是更安全的做法。

查询分页和缓存,别让一次查询把整个相册拉进内存

getAssets 返回的 FetchResult 是惰性遍历的,getNextObject 一次取一条。但如果你在页面初始化时用 while 循环把整个 FetchResult 遍历完,把所有缩略图都解码出来存进一个数组,内存峰值会随着相册大小线性增长。一个几千张照片的相册,一次性解码几千个 PixelMap,OOM 是迟早的事。

分页加载的正确姿势是:只加载当前可视区域和预加载范围内的缩略图,滑到新的区域时再取下一页。FetchResult 支持按 offset 和 limit 查询,也可以基于游标逐页取。不管用哪种方式,页面侧维护的 PixelMap 数量应该有一个明确的上限,超出上限的旧图要释放,而不是无限缓存。

typescript

// 按页取,不一次性遍历整个结果集
const pageSize = 40;
const options: photoAccessHelper.FetchOptions = {
  fetchColumns: [],
  predicates: new dataSharePredicates.DataSharePredicates()
};
options.limit = pageSize;
options.offset = pageIndex * pageSize;
const pageResult = await phAccessHelper.getAssets(options);

limit 和 offset 的组合让每次查询只返回一页。pageResult 用完同样要 close(),每一页的 FetchResult 都是独立的句柄。

排查顺序:先数句柄,再看释放,最后看分页

下次相册页面“越滑越卡”或者“滑到后面白块”的时候,按这个顺序走。

第一步,在每次查询和解码的地方打日志,统计 FetchResult.close() 的调用次数和 getAssets 的调用次数是否匹配,ImageSource.release() 的调用次数和 createImageSource 是否匹配。数量对不上,就是句柄泄漏。

第二步,检查 aboutToReuse 里有没有释放旧的 PixelMap,aboutToDisappear 里有没有兜底释放。Image 组件不会替你 release,组件复用不会自动回收像素内存。

第三步,确认 ImageSource 在 createPixelMap 之后立刻 release,没有把它和 PixelMap 一起存进长期缓存。ImageSource 不是缓存对象,它持有解码器状态和文件句柄。

第四步,检查查询是否分页,页面侧持有的 PixelMap 数量有没有上限。一次性遍历整个相册、把所有缩略图解码进内存,是 OOM 的最短路径。

第五步,确认变更监听 on 和 off 用同一个回调引用,页面销毁时已注销。

PhotoAccessHelper 返回的 FetchResult、PhotoAsset、ImageSource、PixelMap,是四层不同的资源,各自有各自的释放时机。查询结果集要 close,解码源要 release,像素缓冲区在组件复用时手动 release,文件描述符单独 close。下一次相册滑两屏就卡的时候,先别调 cachedCount——打开代码数一下,那些 createPixelMap 出来的东西,到底有没有人负责送回去。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐