相册缩略图刷了两屏就卡,PhotoAccessHelper的句柄不是查完就完
一个相册选择页,用 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 出来的东西,到底有没有人负责送回去。

更多推荐

所有评论(0)