HarmonyOS趣味相机实战第7篇:Preferences相册缓存、旧数据兼容与异常兜底
HarmonyOS趣味相机实战第7篇Preferences相册缓存、旧数据兼容与异常兜底摘要本地相册看起来只是一个列表但真正上线后会遇到很多细节第一次启动没有数据怎么办Preferences 里的 JSON 损坏怎么办老版本缺少新字段怎么办滤镜强度出现 NaN 怎么办保存太多记录会不会拖慢启动页面拿到列表后会不会直接改坏服务内部缓存。本文继续基于D:/APP/1quweixiangjiHarmonyOS ArkTS 水印相机项目专门复盘PhotoAlbumService.ets的相册缓存设计。第 2 篇讲过水印快照和本地相册闭环本文更聚焦“数据兼容和异常兜底”initTask防重复初始化parsePhotos()解析失败回空列表clonePhotos()给旧数据字段填默认值safeNumber()限制异常数值persistPhoto()新记录置顶并限制 60 条deletePhoto()删除后 flush。这篇文章适合拿来做 HarmonyOS 小型本地缓存服务的模板数据不大用 Preferences字段会演进就做 clone 和默认值读取可能失败就兜底页面不要直接碰内部缓存。工程背景与源码定位文件作用entry/src/main/ets/service/PhotoAlbumService.ets本文主角相册缓存、Preferences 读写、照片克隆、旧数据兼容entry/src/main/ets/model/DecorationModels.ets定义CapturedPhoto、WatermarkSnapshot、CaptureSourceentry/src/main/ets/pages/Index.ets拍照后调用persistPhoto()相册页调用listPhotos()和deletePhoto()entry/src/main/ets/entryability/EntryAbility.etsAbility 创建时初始化PhotoAlbumServiceentry/src/test/LocalUnit.test.ets已有照片快照和保存态测试entry/src/main/module.json5相机权限声明本地相册元数据不新增敏感权限环境与版本边界项目当前值说明当前复盘日期2026-07-14按当前工程源码复盘工程路径D:/APP/1quweixiangji本文只引用该项目已有源码工程类型HarmonyOS Stage 模型EntryAbility初始化本地服务target SDK6.0.2(22)升级 SDK 后要回归 Preferences 行为本地存储kit.ArkDataPreferences适合轻量 JSON 元数据集合名watermark_camera_album / captured_photos相册元数据存储位置数据上限60 条照片记录防止 Preferences JSON 长期膨胀图片文件当前不长期保存 PixelMapPreferences 只存元数据不存图片二进制本地构建命令cd D:\APP\1quweixiangji$env:JAVA_HOMED:\Program Files\Huawei\DevEco Studio\jbr$env:Path$env:JAVA_HOME\bin;$env:PathD:\Program Files\Huawei\DevEco Studio\tools\hvigor\bin\hvigorw.bat--mode module-p moduleentrydefault-p productdefault assembleHap--no-daemon一、缓存服务先定义存储边界PhotoAlbumService.ets顶部定义了 Preferences 名称和 keyconstPREF_NAME:stringwatermark_camera_album;constPREF_KEY_PHOTOS:stringcaptured_photos;服务内部状态privatestaticprefs:preferences.Preferences|nullnull;privatestaticinitTask:Promisevoid|nullnull;privatestaticcachedPhotos:CapturedPhoto[][];三者职责不同字段职责prefsPreferences 实例负责本地读写initTask防止重复初始化调用方可等待首次读取完成cachedPhotos内存缓存页面操作先更新缓存再 flush把它们封在服务层里页面只调用listPhotos()、persistPhoto()、deletePhoto()不会直接读写 Preferences。二、初始化任务防止重复读取初始化入口staticinit(context:common.UIAbilityContext):Promisevoid{if(PhotoAlbumService.initTask!null){returnPhotoAlbumService.initTask;}PhotoAlbumService.initTaskPhotoAlbumService.initInternal(context);returnPhotoAlbumService.initTask;}这个设计避免了多个页面或 Ability 生命周期重复调用时反复创建 Preferences 实例。真正读取逻辑在initInternal()privatestaticasyncinitInternal(context:common.UIAbilityContext):Promisevoid{try{PhotoAlbumService.prefsawaitpreferences.getPreferences(context,PREF_NAME);constraw:preferences.ValueTypePhotoAlbumService.prefs.getSync(PREF_KEY_PHOTOS,[]);if(typeofrawstring){PhotoAlbumService.cachedPhotosPhotoAlbumService.parsePhotos(raw);}}catch(error){hilog.error(DOMAIN,TAG,init album failed: %{public}s,JSON.stringify(error));PhotoAlbumService.cachedPhotos[];}}初始化失败时回退为空数组不让相册缓存问题拖垮拍照主流程。三、读取列表时永远返回克隆结果读取照片staticasynclistPhotos():PromiseCapturedPhoto[]{awaitPhotoAlbumService.waitForInit();returnPhotoAlbumService.clonePhotos(PhotoAlbumService.cachedPhotos);}这里没有直接返回cachedPhotos而是返回克隆结果。这样页面拿到列表后即使修改某个对象也不会绕过服务层污染内部缓存。等待初始化privatestaticasyncwaitForInit():Promisevoid{if(PhotoAlbumService.initTask!null){awaitPhotoAlbumService.initTask;}}这让listPhotos()、persistPhoto()、deletePhoto()都能在初始化未完成时安全等待。四、创建照片只生成元数据不保存大图拍照成功后页面会调用PhotoAlbumService.createPhoto()生成预览态记录staticcreatePhoto(sequence:number,_layers:DecorationLayer[],_filterName:string,_frameName:string,_beautySummary:string,captureSource:CaptureSourcesimulated,captureSummary:string真实相机照片,_filterIntensity:number0,_beautyFeature:string标准,_beautyIntensity:number0,resolutionLabel:string12MP (4:3),watermark?:WatermarkSnapshot):CapturedPhoto{return{id:photo_${Date.now()}_${sequence},title:水印照片${sequence},createdAt:PhotoAlbumService.formatNow(),layerCount:0,layerSummary:PhotoAlbumService.watermarkSummary(watermark),filterName:无滤镜,frameName:无相框,beautySummary:标准模式,resolutionLabel,captureSource,captureSummary,status:preview,watermark:PhotoAlbumService.cloneWatermark(watermark)};}注意这里创建的是照片元数据不是图片文件。PixelMap 用于预览保存或关闭后释放相册缓存只保存标题、时间、分辨率、来源和水印快照。五、保存态转换放在服务层保存照片时不是页面直接改status而是调用staticsavePhoto(photo:CapturedPhoto):CapturedPhoto{return{id:photo.id,title:photo.title,createdAt:photo.createdAt,layerCount:photo.layerCount,layerSummary:photo.layerSummary,filterName:photo.filterName,filterIntensity:PhotoAlbumService.safeNumber(photo.filterIntensity,0),frameName:photo.frameName,beautySummary:photo.beautySummary,beautyFeature:photo.beautyFeature?photo.beautyFeature:标准,beautyIntensity:PhotoAlbumService.safeNumber(photo.beautyIntensity,0),resolutionLabel:photo.resolutionLabel?photo.resolutionLabel:12MP (4:3),captureSource:photo.captureSource,captureSummary:photo.captureSummary,status:saved,watermark:PhotoAlbumService.cloneWatermark(photo.watermark)};}这一步同时做三件事preview状态变为saved。可选字段补默认值。水印对象做克隆避免引用串改。六、persistPhoto新记录置顶并限制 60 条持久化保存staticasyncpersistPhoto(photo:CapturedPhoto):PromiseCapturedPhoto[]{awaitPhotoAlbumService.waitForInit();constsavedPhoto:CapturedPhotoPhotoAlbumService.savePhoto(photo);constnextPhotos:CapturedPhoto[][savedPhoto].concat(PhotoAlbumService.cachedPhotos);PhotoAlbumService.cachedPhotosnextPhotos.slice(0,60);awaitPhotoAlbumService.flushPhotos();returnPhotoAlbumService.clonePhotos(PhotoAlbumService.cachedPhotos);}关键策略策略价值waitForInit()防止覆盖尚未读完的旧数据savePhoto()统一保存态转换新记录置顶相册列表默认最新在前slice(0, 60)控制 Preferences JSON 大小flush 后返回 clone页面拿到的是安全快照Preferences 不适合无限增长。60 条元数据对轻量相册足够后续如果要保存真实图片文件应迁移到沙箱文件或媒体库。七、删除记录也要 flush 并返回克隆列表删除逻辑staticasyncdeletePhoto(photoId:string):PromiseCapturedPhoto[]{awaitPhotoAlbumService.waitForInit();PhotoAlbumService.cachedPhotosPhotoAlbumService.cachedPhotos.filter((photo:CapturedPhoto)photo.id!photoId);awaitPhotoAlbumService.flushPhotos();returnPhotoAlbumService.clonePhotos(PhotoAlbumService.cachedPhotos);}删除后返回最新列表页面直接替换State album。这比页面自己 filter 更稳因为服务层和 Preferences 始终同步。八、flushPhotos写入失败不打断主流程写入 PreferencesprivatestaticasyncflushPhotos():Promisevoid{if(PhotoAlbumService.prefsnull){return;}try{awaitPhotoAlbumService.prefs.put(PREF_KEY_PHOTOS,JSON.stringify(PhotoAlbumService.cachedPhotos));awaitPhotoAlbumService.prefs.flush();}catch(error){hilog.error(DOMAIN,TAG,flush album failed: %{public}s,JSON.stringify(error));}}这里捕获异常并记录日志不让页面崩溃。对拍照类 App 来说保存失败要提示用户但不应该让相机预览和拍照入口一起不可用。九、parsePhotos缓存损坏时回退空列表解析逻辑privatestaticparsePhotos(raw:string):CapturedPhoto[]{try{constparsed:CapturedPhoto[]JSON.parse(raw)asCapturedPhoto[];if(!parsed||parsed.length0){return[];}returnPhotoAlbumService.clonePhotos(parsed);}catch(error){hilog.warn(DOMAIN,TAG,parse album failed: %{public}s,JSON.stringify(error));return[];}}如果 Preferences 中的 JSON 损坏服务直接返回空列表。这是一个合理降级用户可能丢失本地相册元数据但拍照主功能仍可继续使用。后续如果要做更强恢复可以增加备份 keycaptured_photos captured_photos_backup每次 flush 前先写 backup解析主 key 失败时尝试 backup。不过当前项目作为轻量相册回退空列表已经能保证主流程稳定。十、clonePhotos旧数据字段兼容集中处理克隆逻辑staticclonePhotos(photos:CapturedPhoto[]):CapturedPhoto[]{returnphotos.map((photo:CapturedPhoto){constclonedPhoto:CapturedPhoto{id:photo.id,title:photo.title,createdAt:photo.createdAt,layerCount:photo.layerCount,layerSummary:photo.layerSummary,filterName:photo.filterName?photo.filterName:无滤镜,filterIntensity:PhotoAlbumService.safeNumber(photo.filterIntensity,0),frameName:photo.frameName?photo.frameName:无相框,beautySummary:photo.beautySummary?photo.beautySummary:标准模式,beautyFeature:photo.beautyFeature?photo.beautyFeature:标准,beautyIntensity:PhotoAlbumService.safeNumber(photo.beautyIntensity,0),resolutionLabel:photo.resolutionLabel?photo.resolutionLabel:12MP (4:3),captureSource:photo.captureSource?photo.captureSource:simulated,captureSummary:photo.captureSummary?photo.captureSummary:真实相机照片,status:photo.status,watermark:PhotoAlbumService.cloneWatermark(photo.watermark)};returnclonedPhoto;});}这里是兼容旧数据的核心。比如老版本没有resolutionLabel新版本读取时自动补12MP (4:3)老版本没有captureSource新版本按simulated兜底强度字段异常时走safeNumber()。十一、safeNumber限制 NaN 和越界值数值兜底privatestaticsafeNumber(value:number|undefined,fallback:number):number{if(valueundefined||Number.isNaN(value)){returnfallback;}returnMath.max(0,Math.min(100,value));}这个函数很小但很实用输入输出undefinedfallbackNaNfallback-2001801003535滤镜强度、美颜强度这类 UI 数值一旦越界可能导致滑块显示异常或渲染参数失真。服务层读取时裁剪页面会更稳。十二、水印快照也要克隆水印摘要privatestaticwatermarkSummary(watermark:WatermarkSnapshot|undefined):string{if(!watermark||!watermark.enabled){return未添加水印;}constlocationText:stringwatermark.locationText.length0?watermark.locationText:未填写地点;return${watermark.title}·${locationText};}水印克隆privatestaticcloneWatermark(watermark?:WatermarkSnapshot):WatermarkSnapshot|undefined{if(!watermark){returnundefined;}return{enabled:watermark.enabled,template:watermark.template,title:watermark.title,locationText:watermark.locationText,note:watermark.note,timeText:watermark.timeText};}克隆的意义是保存“拍照当时”的水印信息。用户之后修改地点、备注或模板旧照片的水印不应该被污染。十三、建议的数据迁移方案如果后续相册要从纯元数据升级到真实图片文件可以增加版本字段interfaceCapturedPhotoV2extendsCapturedPhoto{schemaVersion:2;imageUri:string;thumbnailUri:string;fileSize?:number;}迁移策略旧字段新字段处理无schemaVersionschemaVersion: 1读取时补默认版本无imageUri空字符串继续展示元数据卡片无thumbnailUri空字符串使用渐变缩略图兜底watermark保留继续作为历史快照captureSource缺失simulatedclone 时兜底不要一次性强制把所有旧照片变成 V2 文件记录。更稳的做法是旧记录继续可读新拍照走 V2列表根据字段决定展示真实缩略图还是元数据卡片。十四、常见问题排查现象可能原因排查方式App 重启后相册为空Preferences 解析失败或 key 不一致查PREF_NAME、PREF_KEY_PHOTOS和parsePhotos()保存后列表顺序不对没有新记录置顶查[savedPhoto].concat(cachedPhotos)相册越用越慢没有限制记录数量查slice(0, 60)旧照片字段缺失崩溃读取时没做默认值查clonePhotos()强度数值异常NaN 或越界查safeNumber()水印被当前输入污染保存了引用查cloneWatermark()删除后重启又出现删除后没 flush查deletePhoto()页面改坏缓存直接返回内部数组确认listPhotos()返回 clone保存大图导致卡顿把图片二进制写入 Preferences只保存元数据文件另存初始化重复执行没有initTask查init()十五、上线前验收清单首次安装时相册为空但不报错。init()多次调用不会重复初始化。Preferences key 稳定不随版本随意改名。JSON 解析失败时回退空列表。listPhotos()返回克隆数组。persistPhoto()会等待初始化完成。新照片保存后排在第一位。相册最多保留 60 条记录。删除照片后能 flush 到 Preferences。老数据缺少filterName、resolutionLabel、captureSource时能正常展示。数值字段 NaN、负数、超过 100 时能被裁剪。水印快照保存和读取都使用克隆。Preferences 不保存 PixelMap、Base64 或图片二进制。后续真实文件化存储要增加schemaVersion、imageUri、thumbnailUri。2026-07-14 之后升级 HarmonyOS SDK 时回归 Preferences 读写、flush 和数据迁移。总结PhotoAlbumService的价值不是“把数组写进 Preferences”这么简单而是给本地相册建立了一套稳定边界初始化只做一次读取先等初始化返回永远是克隆保存时统一从 preview 转 saved新记录置顶并限制 60 条解析失败回空列表旧字段有默认值异常数值被裁剪水印快照不被引用串改。对 HarmonyOS 小型工具类 App 来说这套模式很实用。Preferences 适合保存轻量元数据但一定要把兼容、兜底和上限写清楚。等未来接真实图片文件、系统图库或云同步时也能在这个稳定的数据模型上继续演进。