HarmonyOS趣味相机实战第30篇:相册PixelMap内存缓存、所有权转移与淘汰释放
HarmonyOS趣味相机实战第30篇:相册PixelMap内存缓存、所有权转移与淘汰释放
摘要
Preferences 可以恢复照片元数据,却不能直接恢复内存中的 PixelMap。为了让刚保存的照片在相册卡片与详情中显示真实图像,趣味相机新增 Map<string, PixelMap> 缓存,并在保存时把拍照预览的 PixelMap 转交给相册服务。这一改动解决了“保存后只剩文字占位”,也带来了新的资源责任:谁释放 PixelMap、相册超过60条时如何淘汰、flush失败是否回滚、页面关闭能否继续使用,以及进程重启后为什么图片消失。
本文基于 PhotoAlbumService.ets 与 Index.ets 的最新实现,完整拆解 PixelMap 的所有权转移、按 photoId 查找、删除释放、缓存上限和持久化边界,并给出可落地的缓存条目模型、LRU淘汰、故障补偿与测试方法。
源码定位
| 文件 | 责任 |
|---|---|
service/CameraPreviewService.ets |
从 PhotoAvailable 产出 PixelMap |
pages/Index.ets |
预览持有并把资源转交给相册 |
service/PhotoAlbumService.ets |
元数据持久化与 PixelMap Map缓存 |
model/DecorationModels.ets |
CapturedPhoto 元数据结构 |
service/PhotoDocumentService.ets |
转文档时复制轻量快照 |
环境与当前边界
| 项目 | 当前实现 |
|---|---|
| 图像类型 | image.PixelMap |
| 缓存容器 | Map<string, image.PixelMap> |
| Key | CapturedPhoto.id |
| 元数据上限 | 60条 |
| 元数据持久化 | Preferences JSON |
| 图像持久化 | 当前没有,仅内存缓存 |
| 删除行为 | release后从Map移除 |

一、元数据和像素资源必须分开建模
CapturedPhoto 适合序列化:
export interface CapturedPhoto {
id: string;
title: string;
createdAt: string;
captureSummary: string;
status: 'preview' | 'saved';
watermark?: WatermarkSnapshot;
}
PixelMap 是原生图像资源,有显式 release() 生命周期,不能跟随 JSON 写进 Preferences。当前服务分别保存:
private static cachedPhotos: CapturedPhoto[] = [];
private static photoPixelMaps:
Map<string, image.PixelMap> =
new Map<string, image.PixelMap>();
数组负责“有哪些照片”,Map负责“当前进程还能展示哪些真实像素”。两者不是同一种持久化承诺。
二、拍照预览是最初所有者
CameraPreviewService 返回真实照片:
interface CameraCaptureState {
realPhotoReceived: boolean;
photoPixelMap?: image.PixelMap;
message: string;
}
页面接管后:
this.previewPhotoPixelMap = captureState.photoPixelMap;
this.previewPhoto = PhotoAlbumService.createPhoto(...);
此时页面是资源所有者。用户重拍或转文档,页面调用 releasePreviewPixelMap();用户保存到相册,则所有权必须转给相册服务,不能再由页面释放。
三、保存时先从页面状态取走资源
最新实现:
private async savePreviewPhoto(): Promise<void> {
if (!this.previewPhoto) {
return;
}
const savedPixelMap: image.PixelMap | null =
this.previewPhotoPixelMap;
this.previewPhotoPixelMap = null;
this.album = await PhotoAlbumService.persistPhoto(
this.previewPhoto,
savedPixelMap === null ? undefined : savedPixelMap
);
this.previewPhoto = null;
this.activeTab = 1;
}
把成员先设为 null 是所有权转移信号:从这一行之后,页面的关闭逻辑不能再释放该对象。局部变量暂时拥有资源,调用 persistPhoto 后由相册服务接管。
四、服务按photoId保存PixelMap
static async persistPhoto(
photo: CapturedPhoto,
pixelMap?: image.PixelMap
): Promise<CapturedPhoto[]> {
await PhotoAlbumService.waitForInit();
const savedPhoto: CapturedPhoto =
PhotoAlbumService.savePhoto(photo);
if (pixelMap !== undefined) {
PhotoAlbumService.photoPixelMaps.set(
savedPhoto.id, pixelMap);
}
const nextPhotos: CapturedPhoto[] =
[savedPhoto].concat(PhotoAlbumService.cachedPhotos);
PhotoAlbumService.cachedPhotos = nextPhotos.slice(0, 60);
await PhotoAlbumService.flushPhotos();
return PhotoAlbumService.clonePhotos(
PhotoAlbumService.cachedPhotos);
}
ID 同时连接元数据和像素资源。页面不把 PixelMap 塞进 CapturedPhoto,避免复制领域对象时误复制原生资源引用。
五、读取缓存只返回借用引用
static getPhotoPixelMap(
photoId: string
): image.PixelMap | undefined {
return PhotoAlbumService.photoPixelMaps.get(photoId);
}
页面调用:
private albumPhotoPixelMap(
photo: CapturedPhoto | null
): image.PixelMap | undefined {
if (photo === null) {
return undefined;
}
return PhotoAlbumService.getPhotoPixelMap(photo.id);
}
这里返回的是借用引用,不是新 PixelMap。调用者可用于 Image 展示,但不能擅自 release;释放权仍属于 PhotoAlbumService。接口命名或注释应明确这一点:
// Borrowed reference. Caller must not release it.
static borrowPhotoPixelMap(photoId: string): image.PixelMap | undefined
六、删除照片时释放资源
const pixelMap: image.PixelMap | undefined =
PhotoAlbumService.photoPixelMaps.get(photoId);
if (pixelMap !== undefined) {
try {
await pixelMap.release();
} catch (error) {
hilog.warn(DOMAIN, TAG,
'release photo pixelMap failed: %{public}s',
JSON.stringify(error));
}
PhotoAlbumService.photoPixelMaps.delete(photoId);
}
即使 release 失败也从 Map 删除,避免后续页面继续借用状态未知的对象。然后过滤元数据并 flush。
更稳妥的顺序取决于产品语义:若 Preferences 删除失败,当前实现已经释放图像、元数据内存也已删除,但重启后旧元数据可能重新出现。需要让 flushPhotos() 返回成功状态,失败时决定回滚还是标记待删除。
七、保存失败会产生所有权悬空
页面在 await 前已经把 previewPhotoPixelMap 清空。如果 persistPhoto() 在真正接管前抛错,局部 savedPixelMap 离开作用域,资源可能无人释放。
可以定义接管契约:
interface PersistPhotoResult {
photos: CapturedPhoto[];
pixelMapAccepted: boolean;
persisted: boolean;
}
或者页面用 catch 回收:
const transferring = this.previewPhotoPixelMap;
this.previewPhotoPixelMap = null;
try {
this.album = await PhotoAlbumService.persistPhoto(
this.previewPhoto,
transferring ?? undefined
);
} catch (error) {
if (transferring !== null) {
this.previewPhotoPixelMap = transferring;
}
throw error;
}
若服务已接管后再抛错,页面恢复同一引用会造成双重所有权。因此更推荐服务保证“传入即接管,无论后续持久化成功与否都负责释放或保留”,并通过接口文档写清。
八、Map覆盖同一ID会泄漏旧资源
photoPixelMaps.set(savedPhoto.id, pixelMap);
若相同 ID 重复保存,新 PixelMap 会覆盖旧值,但 Map 不会自动 release 旧对象。写入前应替换释放:
private static async replacePixelMap(
photoId: string,
next: image.PixelMap
): Promise<void> {
const previous = PhotoAlbumService.photoPixelMaps.get(photoId);
if (previous !== undefined && previous !== next) {
try {
await previous.release();
} catch (_) {
}
}
PhotoAlbumService.photoPixelMaps.set(photoId, next);
}
即使正常 ID 很少重复,恢复、重试或测试都可能触发覆盖,资源容器应自行防护。
九、slice淘汰元数据但没有淘汰PixelMap
当前代码:
PhotoAlbumService.cachedPhotos =
nextPhotos.slice(0, 60);
第61张照片保存后,最旧元数据从数组消失,但对应 PixelMap 仍留在 Map。这是隐蔽内存泄漏:UI看不到它,也没有 photoId 入口触发删除。
保存前后应计算被淘汰 ID:
const previousIds: Set<string> = new Set(
PhotoAlbumService.cachedPhotos.map(photo => photo.id)
);
const keptPhotos = nextPhotos.slice(0, 60);
const keptIds: Set<string> = new Set(
keptPhotos.map(photo => photo.id)
);
for (const id of previousIds) {
if (!keptIds.has(id)) {
await PhotoAlbumService.releaseCachedPixelMap(id);
}
}
PhotoAlbumService.cachedPhotos = keptPhotos;
容量淘汰必须同时作用于元数据和资源缓存。
十、60张原图仍可能占用大量内存
假设每张解码后为 3000×4000 RGBA,理论像素内存约:
3000 × 4000 × 4 = 48,000,000 bytes
60张接近数GB,显然不能全部作为 PixelMap 常驻。元数据上限不能直接当图像缓存上限。
应独立设置缓存预算,例如最多 6 张缩略图或 64MB:
interface PixelMapCacheEntry {
photoId: string;
pixelMap: image.PixelMap;
estimatedBytes: number;
lastAccessAt: number;
}
缓存按内存预算与访问时间淘汰,而不是和 Preferences 记录数绑定。
十一、相册应缓存缩略图而不是原始大图
Grid 卡片只有约 160×190 vp,没有必要长期持有完整拍照 PixelMap。保存时可生成缩略图:
拍照PixelMap
-> 结果页展示原图
-> 保存媒体文件/资产
-> 生成小尺寸缩略图PixelMap
-> 相册Map缓存缩略图
-> 释放原始PixelMap
详情页需要高清图时再从持久化 URI 解码。这样列表滚动内存更稳定,进程重启后也能重新生成缓存。
十二、当前缓存无法跨进程恢复
Preferences 只保存 CapturedPhoto,photoPixelMaps 在进程重启后为空。因此重启应用会看到照片元数据,但没有真实图像。
这是当前方案必须公开的边界:
| 状态 | 元数据 | PixelMap |
|---|---|---|
| 保存后同一进程 | 有 | 有 |
| 页面切换 | 有 | 有 |
| Ability重建但进程未杀 | 视静态对象而定 | 可能有 |
| 进程重启 | 从Preferences恢复 | 无 |
| 清除应用数据 | 无 | 无 |
要成为真正本地相册,必须把图像编码到应用文件或媒体资产中,并在元数据中保存 URI。
十三、持久化模型建议
interface CapturedPhotoAsset {
mediaUri: string;
thumbnailUri?: string;
width: number;
height: number;
byteSize: number;
format: 'jpeg' | 'png';
}
interface CapturedPhoto {
id: string;
asset?: CapturedPhotoAsset;
// metadata fields
}
PixelMap 是运行时缓存,URI 才是跨进程事实来源。读取列表时先展示固定占位,再按需加载 thumbnailUri。
十四、实现LRU缓存
访问时更新时间:
static borrowPhotoPixelMap(photoId: string): image.PixelMap | undefined {
const entry = PhotoAlbumService.cache.get(photoId);
if (entry === undefined) {
return undefined;
}
entry.lastAccessAt = Date.now();
return entry.pixelMap;
}
超预算时从最久未使用条目开始释放:
while (totalBytes > MAX_CACHE_BYTES) {
const victim = findLeastRecentlyUsedEntry();
if (!victim) break;
await victim.pixelMap.release();
cache.delete(victim.photoId);
totalBytes -= victim.estimatedBytes;
}
正在被页面展示的条目需要 pin 或引用计数,避免 Image 仍在渲染时被后台淘汰。
十五、借用资源需要作用域协议
简单返回 PixelMap 无法知道 UI 何时用完。更严格接口:
interface BorrowedPixelMap {
pixelMap: image.PixelMap;
releaseBorrow(): void;
}
缓存条目保存 borrowCount。详情打开时 +1,关闭时 -1;LRU只淘汰 borrowCount===0 的条目。这里的 releaseBorrow() 不调用 PixelMap.release,只释放借用计数,真正资源仍由缓存管理。
十六、页面退出与全量清理
服务应提供:
static async clearPixelMapCache(): Promise<void> {
const entries = Array.from(
PhotoAlbumService.photoPixelMaps.values());
PhotoAlbumService.photoPixelMaps.clear();
for (const pixelMap of entries) {
try {
await pixelMap.release();
} catch (error) {
// log sanitized error
}
}
}
何时调用取决于产品:进程常驻时可保留小缓存;Ability 后台可降低缓存预算;应用退出或内存压力时全量释放。不要在普通 Tab 切换时清空,否则相册来回切换会频繁解码。
十七、并发删除与展示
若详情正在显示某 PixelMap,用户同时删除照片,服务立即 release 可能让 Image 使用失效对象。页面删除前应先关闭详情,或缓存使用借用计数延迟释放:
标记pendingDelete
-> UI关闭详情并releaseBorrow
-> borrowCount归零
-> PixelMap.release
-> 删除Map条目和元数据
异步操作期间按钮应禁用,避免重复删除同一资源。
十八、错误处理与日志
释放失败日志只记录错误类型与 photoId 的安全短标识,不打印图片、文件URI或水印内容:
hilog.warn(DOMAIN, TAG,
'release cached image failed id=%{public}s error=%{public}s',
shortId(photoId),
JSON.stringify(error));
缓存未命中是正常状态,不应每次都输出错误。只有解码失败、资源状态异常和预算无法收敛才需要告警。
十九、测试矩阵
| 场景 | 期望 |
|---|---|
| 保存带PixelMap照片 | Map按ID新增一条 |
| 保存不带PixelMap照片 | 元数据存在,Map无条目 |
| 页面保存后关闭预览 | 不释放已转交资源 |
| 用户重拍 | 页面释放预览资源 |
| 删除照片 | PixelMap只释放一次 |
| 重复删除 | 不重复release、不崩溃 |
| 相同ID替换 | 旧PixelMap先释放 |
| 保存第61张 | 被淘汰条目资源同步释放 |
| 超内存预算 | LRU释放未借用条目 |
| 详情正在展示 | 不被LRU提前释放 |
| 进程重启 | 缓存为空,从URI按需恢复 |
| flush失败 | 所有权不会悬空或双重释放 |
可以用 FakePixelMap 记录 releaseCount,对每个对象断言最终恰好一次。
二十、常见问题排查
| 现象 | 原因 | 修复方向 |
|---|---|---|
| 保存后相册仍是占位 | PixelMap未转交或ID不一致 | persistPhoto入参与Map Key |
| 删除后内存不降 | 只删元数据未release | deletePhoto资源分支 |
| 第61张后内存持续涨 | slice未同步淘汰Map | 计算evicted IDs |
| 重启后照片图消失 | 仅内存缓存无URI | 持久化媒体资产 |
| 详情偶发空白 | 展示期间被LRU释放 | pin/borrowCount |
| 保存失败后泄漏 | 转移中异常无接管协议 | 明确传入即接管 |
二十一、发布前验收清单
- CapturedPhoto只保存可序列化元数据与资产URI。
- 页面保存前把PixelMap所有权明确转给服务。
- 借用接口声明调用者不得直接release。
- 删除、替换、淘汰和全量清理都释放资源。
- 元数据条数上限与图像内存预算分开。
- 相册优先缓存缩略图,不常驻大量原图。
- 第61条淘汰时同步清除资源条目。
- 保存失败不会造成无人拥有或双重拥有。
- 展示中的PixelMap通过pin/借用计数保护。
- 进程重启可从媒体URI恢复缩略图。
- 测试断言每个PixelMap最终只release一次。
总结
把 PixelMap 放进 Map 只解决了“同一进程内按 ID 找到图像”,并没有自动形成可靠缓存。真正的工程闭环需要明确所有权:拍照预览最初拥有资源,保存时转交相册服务,UI只借用,删除、替换和淘汰统一由缓存释放。尤其要修复 slice(0,60) 只淘汰元数据、不淘汰 PixelMap 的隐蔽泄漏。
进一步把原图保存为媒体资产、Map只缓存缩略图,并用内存预算、LRU与借用计数管理运行时资源,才能同时解决列表显示、内存峰值、页面并发和进程重启问题。PixelMap不是普通对象,所有权协议比缓存容器本身更重要。
更多推荐



所有评论(0)