HarmonyOS保修凭证连载06:多附件缩略图与解码资源回收【共创稿事节】
第五篇把购机票据的替换拆成了新文件落地、数据库引用切换和旧文件回收。文件关系稳定以后,新的性能问题才真正露出来:一个凭证可能有发票、设备外观、维修工单、配件照片以及多次补充的签收回执。附件详情页若把每张原图都按完整像素解码,一次列表滚动就会持续扩大内存占用;用户退出页面后,已经解码的图片对象如果仍被缓存持有,下一次进入还会叠加。
这次我把附件展示改成“数据列表与像素资源分开管理”的结构。数据库负责回答有哪些附件、每张图属于哪个凭证、文件版本是多少;图片服务负责按展示尺寸解码;页面负责当前可见项与预览状态。三者之间不让文件路径直接承担缓存身份,也不让界面滚动决定是否删除真实附件。此前已归并的凭证 AB-2048-73 与设备序列号 SN2409A18 继续作为贯穿数据的业务样本。

为什么小图片也会占用大内存
压缩图片的磁盘大小与解码后的像素占用不是一个量级。假设一张票据为4032×3024像素、每像素以4字节表示,那么一个未经缩放的像素缓冲区大约需要46.5MiB。附件列表如果同时保持六张原图,光像素数据便可能占去相当可观的空间,还没有计入页面、数据库游标和图像处理的临时对象。列表里实际展示区域可能只有160×120vp,因此不应按原始像素尺寸建立所有预览。
工程中增加 model/AttachmentAsset.ets、service/ReceiptImageDecoder.ets、service/ThumbnailSession.ets 与 pages/AttachmentGalleryPage.ets。第一层仅包含主键、凭证归属、私有路径、文件修订号和类别;第二层只负责把私有路径转换成指定大小的 PixelMap;第三层掌握缓存与释放;页面仅订阅状态并决定当前展示区间。真实文件仍由上一篇的附件仓库管理,缩略图缓存丢失不改变数据库记录。
尤其要处理旧图片被替换后的版本问题。attachmentId 不变并不能说明像素内容不变;第五篇的替换操作会提高 revision。缓存键必须包含附件ID、修订号和目标尺寸。否则旧文件已经进入待回收队列,页面却可能继续拿到同一ID下的旧图片。下面的模型把显示位置和数据身份分开,滚动时按窗口切片取得候选项。
// model/AttachmentAsset.ets
export interface AttachmentAsset {
id: string
voucherId: string
privatePath: string
revision: number
kind: 'invoice' | 'device' | 'repair' | 'receipt'
title: string
}
export function thumbnailKey(item: AttachmentAsset,
width: number, height: number): string {
return `${item.id}:${item.revision}:${width}x${height}`
}
export function visibleSlice(items: AttachmentAsset[],
first: number, count: number): AttachmentAsset[] {
const from = Math.max(0, Math.min(items.length, first))
return items.slice(from, Math.min(items.length, from + Math.max(0, count)))
}
缓存键中的宽高属于目标像素尺寸,而不是组件的vp尺寸。不同屏幕密度会改变对应的像素需求,页面需要根据布局配置选择合理的目标大小,避免直接将固定vp值当作像素值。在多端宽屏环境里,卡片更大时可以请求另一档缩略图,但没有必要每次窗口微小变化都触发重新解码。我更倾向于使用几档离散尺寸,例如320×240和640×480,把缓存变成可预测的有限集合。
图片源对象负责读取,PixelMap负责展示
华为Image Kit的 image.createImageSource 与 ImageSource.createPixelMap 可以完成解码,DecodingOptions.desiredSize 用于提出目标尺寸要求。这些能力在较早API版本已有,HarmonyOS 7的API 26工程可以沿用,不能把它们写成API 26才出现的新接口。输入必须是已经存入应用私有目录、经过附件仓库确认的路径;原先从图片选择器拿到的临时URI不在这里重复使用。
图片源的寿命要覆盖异步解码全过程。调用 createPixelMap 后立即释放ImageSource,会让异步任务还没结束就失去来源对象。下面的服务在 await 完成以后再释放图片源,若失败则交给调用方呈现状态;已经返回的像素对象由上层会话持有,不能在服务返回前释放。
// service/ReceiptImageDecoder.ets
import { image } from '@kit.ImageKit'
export async function decodeReceiptThumb(path: string,
targetWidth: number, targetHeight: number): Promise<image.PixelMap> {
if (path.length === 0 || targetWidth <= 0 || targetHeight <= 0) {
throw new Error('附件路径或目标尺寸无效')
}
const source: image.ImageSource = image.createImageSource(path)
try {
const info = await source.getImageInfo()
if (info.size.width <= 0 || info.size.height <= 0) {
throw new Error('读取附件尺寸失败')
}
const ratio = Math.min(targetWidth / info.size.width,
targetHeight / info.size.height, 1)
const width = Math.max(1, Math.round(info.size.width * ratio))
const height = Math.max(1, Math.round(info.size.height * ratio))
return await source.createPixelMap({
editable: false,
desiredSize: { width, height },
desiredPixelFormat: image.PixelMapFormat.RGBA_8888
})
} finally {
await source.release()
}
}
这里先计算等比尺寸,避免票据被硬拉成横向图。若需要裁切成统一方格,应由容器的 objectFit 决定视觉裁剪,而不是为每个列表卡片生成一次不同裁切结果。遇到超大的损坏文件、来源路径不存在、格式不支持或解码分配失败,服务直接抛出异常,不把空图片伪装成正常发票。UI可以在卡片里显示文件类型图标和“重新加载”,让用户知道是预览失败而非附件关系消失。

把解码结果绑定到一次页面会话
缩略图列表最难处理的是“页面已经离开,旧请求却完成了”。例如用户打开附件页后立即返回,异步解码正在运行;如果它完成后仍把PixelMap写入页面状态,可能产生无效引用。我的做法是给会话维护递增的代数标识:开始加载时读取当前代数,任何切换凭证、刷新列表或退出页面都让代数失效。过期任务取得PixelMap后立即释放,不继续交给UI。
缓存本身也要有持有者。这里把它限定在附件页的一次会话中,不跨页面保存;页面退出时先清空组件里的图片引用,再调用 clear() 释放资源。为防止同一张缩略图被同时解码多次,加载入口应当串行或增加正在处理的键集合;对于手机上不大的可视列表,先串行处理当前可见区更容易控制峰值。
// service/ThumbnailSession.ets
import { image } from '@kit.ImageKit'
import { AttachmentAsset, thumbnailKey } from '../model/AttachmentAsset'
import { decodeReceiptThumb } from './ReceiptImageDecoder'
export class ThumbnailSession {
private generation: number = 0
private maps: Map<string, image.PixelMap> = new Map()
begin(): number { this.generation += 1; return this.generation }
async load(item: AttachmentAsset, generation: number): Promise<image.PixelMap | undefined> {
if (generation !== this.generation) { return undefined }
const key = thumbnailKey(item, 320, 240)
const old = this.maps.get(key)
if (old) { return old }
const result = await decodeReceiptThumb(item.privatePath, 320, 240)
if (generation !== this.generation) {
await result.release()
return undefined
}
this.maps.set(key, result)
return result
}
async clear(): Promise<void> {
this.begin()
const old = Array.from(this.maps.values())
this.maps.clear()
for (const map of old) { await map.release() }
}
}
这份实现将 clear 视为所有权交接点,页面在调用前应撤掉绑定在Image组件上的PixelMap。一个特别容易忽略的细节是缓存淘汰与页面渲染可能发生在不同时间,不能因为某个对象离开视口就马上释放它,而忽视界面仍在短暂转场。若要引入LRU,可以增加正在展示的引用标记,只有不在展示集合中的对象才允许回收。当前阶段先以会话范围控制资源,已经能够避免跨页面持续积累。
页面状态变化只负责驱动可见项
示例附件页加载数据库元信息后维护 items、thumbs、加载标识和出错的附件ID。进入页面只请求起始窗口的若干项,滚动到后续位置再申请新的窗口;切换凭证或更新附件修订号时,先移除旧图,然后开启新的代数。下面展示一段页面与资源会话的衔接方式,真正的附件查询由之前的Repository提供。
// pages/AttachmentGalleryPage.ets中的状态与加载方法
import { image } from '@kit.ImageKit'
import { AttachmentAsset, visibleSlice } from '../model/AttachmentAsset'
import { ThumbnailSession } from '../service/ThumbnailSession'
@Component
export struct AttachmentGallery {
@Prop items: AttachmentAsset[] = []
@State previews: Array<image.PixelMap | undefined> = []
@State errorText: string = ''
private session: ThumbnailSession = new ThumbnailSession()
private active: boolean = false
aboutToAppear(): void { this.active = true; this.loadWindow(0) }
aboutToDisappear(): void {
this.active = false
this.previews = []
this.session.clear()
}
async loadWindow(first: number): Promise<void> {
const generation = this.session.begin()
const subset = visibleSlice(this.items, first, 6)
const result: Array<image.PixelMap | undefined> = []
try {
for (const item of subset) {
const map = await this.session.load(item, generation)
if (!this.active || !map) { return }
result.push(map)
}
if (this.active) { this.previews = result }
} catch (error) {
this.errorText = `附件预览加载失败:${JSON.stringify(error)}`
}
}
build() {
Column() {
Text('凭证附件').fontSize(22).fontWeight(FontWeight.Bold)
if (this.errorText.length > 0) { Text(this.errorText).fontColor(Color.Red) }
List() {
ForEach(this.previews, (map: image.PixelMap | undefined, index: number) => {
ListItem() {
if (map) { Image(map).width(180).height(140).objectFit(ImageFit.Contain) }
Text(`附件 ${index + 1}`).fontSize(14)
}
}, (_: image.PixelMap | undefined, index: number) => index.toString())
}.layoutWeight(1)
}.width('100%').height('100%')
}
}
上面的页面片段聚焦生命周期衔接。滚动位置通知如何转成 first,需要按照实际列表控件的可见区域变化接入;不能直接用每一帧滚动偏移触发全量解码。对频繁变化的列表,可以做轻量节流,同时只提交完整生成批次,防止先前请求较慢、后来请求较快时缩略图与标题错位。如果列表窗口跨页复用,最好同时保存对应 attachmentId,更新图片数组时进行身份比对。

用边界场景检查资源和数据是否分离
我会按五组情况制定检查点。其一,连续进入和离开附件页,观察创建和释放数是否能够收敛。其二,把一张图片从版本3替换到版本4,旧缓存键不应命中;若替换失败,旧版本仍应可读。其三,快速切换凭证后再返回,过期解码结果不能覆盖新凭证的列表。其四,放入尺寸异常或损坏的票据文件,只让当前附件显示错误,不影响其他记录。其五,在小窗口与大窗口之间调整图片尺寸档位,确认UI布局改变不触发无上限的缓存扩张。
日志适合记录附件ID、目标尺寸、生成代数、解码耗时和释放数量,不记录图片原始字节或用户购机票据内容。真正分析峰值时,应同时看解码并发数和缓存持有量,不能只统计文件大小。文件清理也不应由缩略图释放触发:PixelMap释放的是内存图像资源,附件文件是否删除仍由第五篇的回收队列决定。
从第四篇的“图片入库”到第五篇的“可替换”,再到这一篇的“按需解码”,附件链路的责任逐步清楚了。文件关系由数据库保持一致,像素对象由页面会话明确持有,过期任务自行回收结果。下一篇可以继续处理凭证附件的可控导出,既保留原始归属,又避免把私有路径直接交给外部应用。
相关资料:Image Kit常见崩溃报错问题、ImageSource与PixelMap转换问题排查、图文混排与PixelMap使用。
更多推荐


所有评论(0)