
HarmonyOS趣味相机实战第30篇相册PixelMap内存缓存、所有权转移与淘汰释放摘要Preferences 可以恢复照片元数据却不能直接恢复内存中的 PixelMap。为了让刚保存的照片在相册卡片与详情中显示真实图像趣味相机新增Mapstring, PixelMap缓存并在保存时把拍照预览的 PixelMap 转交给相册服务。这一改动解决了“保存后只剩文字占位”也带来了新的资源责任谁释放 PixelMap、相册超过60条时如何淘汰、flush失败是否回滚、页面关闭能否继续使用以及进程重启后为什么图片消失。本文基于PhotoAlbumService.ets与Index.ets的最新实现完整拆解 PixelMap 的所有权转移、按 photoId 查找、删除释放、缓存上限和持久化边界并给出可落地的缓存条目模型、LRU淘汰、故障补偿与测试方法。源码定位文件责任service/CameraPreviewService.ets从 PhotoAvailable 产出 PixelMappages/Index.ets预览持有并把资源转交给相册service/PhotoAlbumService.ets元数据持久化与 PixelMap Map缓存model/DecorationModels.etsCapturedPhoto 元数据结构service/PhotoDocumentService.ets转文档时复制轻量快照环境与当前边界项目当前实现图像类型image.PixelMap缓存容器Mapstring, image.PixelMapKeyCapturedPhoto.id元数据上限60条元数据持久化Preferences JSON图像持久化当前没有仅内存缓存删除行为release后从Map移除一、元数据和像素资源必须分开建模CapturedPhoto适合序列化exportinterfaceCapturedPhoto{id:string;title:string;createdAt:string;captureSummary:string;status:preview|saved;watermark?:WatermarkSnapshot;}PixelMap 是原生图像资源有显式release()生命周期不能跟随 JSON 写进 Preferences。当前服务分别保存privatestaticcachedPhotos:CapturedPhoto[][];privatestaticphotoPixelMaps:Mapstring,image.PixelMapnewMapstring,image.PixelMap();数组负责“有哪些照片”Map负责“当前进程还能展示哪些真实像素”。两者不是同一种持久化承诺。二、拍照预览是最初所有者CameraPreviewService 返回真实照片interfaceCameraCaptureState{realPhotoReceived:boolean;photoPixelMap?:image.PixelMap;message:string;}页面接管后this.previewPhotoPixelMapcaptureState.photoPixelMap;this.previewPhotoPhotoAlbumService.createPhoto(...);此时页面是资源所有者。用户重拍或转文档页面调用releasePreviewPixelMap()用户保存到相册则所有权必须转给相册服务不能再由页面释放。三、保存时先从页面状态取走资源最新实现privateasyncsavePreviewPhoto():Promisevoid{if(!this.previewPhoto){return;}constsavedPixelMap:image.PixelMap|nullthis.previewPhotoPixelMap;this.previewPhotoPixelMapnull;this.albumawaitPhotoAlbumService.persistPhoto(this.previewPhoto,savedPixelMapnull?undefined:savedPixelMap);this.previewPhotonull;this.activeTab1;}把成员先设为 null 是所有权转移信号从这一行之后页面的关闭逻辑不能再释放该对象。局部变量暂时拥有资源调用persistPhoto后由相册服务接管。四、服务按photoId保存PixelMapstaticasyncpersistPhoto(photo:CapturedPhoto,pixelMap?:image.PixelMap):PromiseCapturedPhoto[]{awaitPhotoAlbumService.waitForInit();constsavedPhoto:CapturedPhotoPhotoAlbumService.savePhoto(photo);if(pixelMap!undefined){PhotoAlbumService.photoPixelMaps.set(savedPhoto.id,pixelMap);}constnextPhotos:CapturedPhoto[][savedPhoto].concat(PhotoAlbumService.cachedPhotos);PhotoAlbumService.cachedPhotosnextPhotos.slice(0,60);awaitPhotoAlbumService.flushPhotos();returnPhotoAlbumService.clonePhotos(PhotoAlbumService.cachedPhotos);}ID 同时连接元数据和像素资源。页面不把 PixelMap 塞进CapturedPhoto避免复制领域对象时误复制原生资源引用。五、读取缓存只返回借用引用staticgetPhotoPixelMap(photoId:string):image.PixelMap|undefined{returnPhotoAlbumService.photoPixelMaps.get(photoId);}页面调用privatealbumPhotoPixelMap(photo:CapturedPhoto|null):image.PixelMap|undefined{if(photonull){returnundefined;}returnPhotoAlbumService.getPhotoPixelMap(photo.id);}这里返回的是借用引用不是新 PixelMap。调用者可用于 Image 展示但不能擅自 release释放权仍属于 PhotoAlbumService。接口命名或注释应明确这一点// Borrowed reference. Caller must not release it.staticborrowPhotoPixelMap(photoId:string):image.PixelMap|undefined六、删除照片时释放资源constpixelMap:image.PixelMap|undefinedPhotoAlbumService.photoPixelMaps.get(photoId);if(pixelMap!undefined){try{awaitpixelMap.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离开作用域资源可能无人释放。可以定义接管契约interfacePersistPhotoResult{photos:CapturedPhoto[];pixelMapAccepted:boolean;persisted:boolean;}或者页面用 catch 回收consttransferringthis.previewPhotoPixelMap;this.previewPhotoPixelMapnull;try{this.albumawaitPhotoAlbumService.persistPhoto(this.previewPhoto,transferring??undefined);}catch(error){if(transferring!null){this.previewPhotoPixelMaptransferring;}throwerror;}若服务已接管后再抛错页面恢复同一引用会造成双重所有权。因此更推荐服务保证“传入即接管无论后续持久化成功与否都负责释放或保留”并通过接口文档写清。八、Map覆盖同一ID会泄漏旧资源photoPixelMaps.set(savedPhoto.id,pixelMap);若相同 ID 重复保存新 PixelMap 会覆盖旧值但 Map 不会自动 release 旧对象。写入前应替换释放privatestaticasyncreplacePixelMap(photoId:string,next:image.PixelMap):Promisevoid{constpreviousPhotoAlbumService.photoPixelMaps.get(photoId);if(previous!undefinedprevious!next){try{awaitprevious.release();}catch(_){}}PhotoAlbumService.photoPixelMaps.set(photoId,next);}即使正常 ID 很少重复恢复、重试或测试都可能触发覆盖资源容器应自行防护。九、slice淘汰元数据但没有淘汰PixelMap当前代码PhotoAlbumService.cachedPhotosnextPhotos.slice(0,60);第61张照片保存后最旧元数据从数组消失但对应 PixelMap 仍留在 Map。这是隐蔽内存泄漏UI看不到它也没有 photoId 入口触发删除。保存前后应计算被淘汰 IDconstpreviousIds:SetstringnewSet(PhotoAlbumService.cachedPhotos.map(photophoto.id));constkeptPhotosnextPhotos.slice(0,60);constkeptIds:SetstringnewSet(keptPhotos.map(photophoto.id));for(constidofpreviousIds){if(!keptIds.has(id)){awaitPhotoAlbumService.releaseCachedPixelMap(id);}}PhotoAlbumService.cachedPhotoskeptPhotos;容量淘汰必须同时作用于元数据和资源缓存。十、60张原图仍可能占用大量内存假设每张解码后为 3000×4000 RGBA理论像素内存约3000 × 4000 × 4 48,000,000 bytes60张接近数GB显然不能全部作为 PixelMap 常驻。元数据上限不能直接当图像缓存上限。应独立设置缓存预算例如最多 6 张缩略图或 64MBinterfacePixelMapCacheEntry{photoId:string;pixelMap:image.PixelMap;estimatedBytes:number;lastAccessAt:number;}缓存按内存预算与访问时间淘汰而不是和 Preferences 记录数绑定。十一、相册应缓存缩略图而不是原始大图Grid 卡片只有约 160×190 vp没有必要长期持有完整拍照 PixelMap。保存时可生成缩略图拍照PixelMap - 结果页展示原图 - 保存媒体文件/资产 - 生成小尺寸缩略图PixelMap - 相册Map缓存缩略图 - 释放原始PixelMap详情页需要高清图时再从持久化 URI 解码。这样列表滚动内存更稳定进程重启后也能重新生成缓存。十二、当前缓存无法跨进程恢复Preferences 只保存CapturedPhotophotoPixelMaps在进程重启后为空。因此重启应用会看到照片元数据但没有真实图像。这是当前方案必须公开的边界状态元数据PixelMap保存后同一进程有有页面切换有有Ability重建但进程未杀视静态对象而定可能有进程重启从Preferences恢复无清除应用数据无无要成为真正本地相册必须把图像编码到应用文件或媒体资产中并在元数据中保存 URI。十三、持久化模型建议interfaceCapturedPhotoAsset{mediaUri:string;thumbnailUri?:string;width:number;height:number;byteSize:number;format:jpeg|png;}interfaceCapturedPhoto{id:string;asset?:CapturedPhotoAsset;// metadata fields}PixelMap 是运行时缓存URI 才是跨进程事实来源。读取列表时先展示固定占位再按需加载 thumbnailUri。十四、实现LRU缓存访问时更新时间staticborrowPhotoPixelMap(photoId:string):image.PixelMap|undefined{constentryPhotoAlbumService.cache.get(photoId);if(entryundefined){returnundefined;}entry.lastAccessAtDate.now();returnentry.pixelMap;}超预算时从最久未使用条目开始释放while(totalBytesMAX_CACHE_BYTES){constvictimfindLeastRecentlyUsedEntry();if(!victim)break;awaitvictim.pixelMap.release();cache.delete(victim.photoId);totalBytes-victim.estimatedBytes;}正在被页面展示的条目需要 pin 或引用计数避免 Image 仍在渲染时被后台淘汰。十五、借用资源需要作用域协议简单返回 PixelMap 无法知道 UI 何时用完。更严格接口interfaceBorrowedPixelMap{pixelMap:image.PixelMap;releaseBorrow():void;}缓存条目保存borrowCount。详情打开时 1关闭时 -1LRU只淘汰borrowCount0的条目。这里的releaseBorrow()不调用 PixelMap.release只释放借用计数真正资源仍由缓存管理。十六、页面退出与全量清理服务应提供staticasyncclearPixelMapCache():Promisevoid{constentriesArray.from(PhotoAlbumService.photoPixelMaps.values());PhotoAlbumService.photoPixelMaps.clear();for(constpixelMapofentries){try{awaitpixelMap.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删除后内存不降只删元数据未releasedeletePhoto资源分支第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不是普通对象所有权协议比缓存容器本身更重要。