HarmonyOS趣味相机实战第25篇Preferences相册Schema归一化与水印快照隔离摘要本地相册看似只是把数组JSON.stringify后写进 Preferences但真正上线后会遇到旧版本缺字段、异常 JSON、并发初始化、对象引用被页面修改、数字越界、缓存无限增长等问题。若读取层直接把历史数据交给 ArkUI升级一次字段就可能造成列表空白或水印内容被意外联动修改。本文基于D:/APP/1quweixiangji的PhotoAlbumService.ets完整复盘初始化任务复用、缓存副本、Schema 归一化、WatermarkSnapshot深拷贝、摘要脱敏、容量上限和写入顺序。重点是让本地数据层成为稳定边界页面拿到的数据可用持久化失败可定位旧数据可以安全降级。环境与数据边界项目当前实现开发语言ArkTS数据组件kit.ArkDataPreferencesPreferences 名称watermark_camera_album数据键captured_photos内存缓存CapturedPhoto[]最大记录数60水印字段WatermarkSnapshot异常日志kit.PerformanceAnalysisKithilog一、相册服务需要明确责任边界PhotoAlbumService负责的不是页面展示而是五件事建立 Preferences 连接。把持久化字符串解析为领域对象。修复缺失或越界字段。保存、删除并限制缓存容量。向调用方返回隔离后的副本。页面只调用稳定接口awaitPhotoAlbumService.init(context);constphotos:CapturedPhoto[]awaitPhotoAlbumService.listPhotos();constnext:CapturedPhoto[]awaitPhotoAlbumService.persistPhoto(photo);这样 Preferences 的名字、键和序列化格式不会散落在多个 ArkUI 组件里。二、用initTask合并并发初始化Ability 启动、页面出现和测试代码可能同时触发初始化。若每次都调用getPreferences并读取数据会产生重复 I/O 和状态覆盖。项目用一个 Promise 复用正在进行的任务privatestaticprefs:preferences.Preferences|nullnull;privatestaticinitTask:Promisevoid|nullnull;privatestaticcachedPhotos:CapturedPhoto[][];staticinit(context:common.UIAbilityContext):Promisevoid{if(PhotoAlbumService.initTask!null){returnPhotoAlbumService.initTask;}PhotoAlbumService.initTaskPhotoAlbumService.initInternal(context);returnPhotoAlbumService.initTask;}这是一种单次初始化门闩。调用者共享同一个结果不会出现后发初始化先覆盖缓存的竞态。需要注意若产品希望初始化失败后允许重试应在失败路径把initTask设回null同时保留错误状态否则当前进程内后续调用会继续复用已经完成但失败的 Promise。三、所有公开操作都等待初始化读取接口不能假设页面一定先调用过init()privatestaticasyncwaitForInit():Promisevoid{if(PhotoAlbumService.initTask!null){awaitPhotoAlbumService.initTask;}}staticasynclistPhotos():PromiseCapturedPhoto[]{awaitPhotoAlbumService.waitForInit();returnPhotoAlbumService.clonePhotos(PhotoAlbumService.cachedPhotos);}更严格的版本可以在initTask null时抛出领域错误避免静默返回空列表if(PhotoAlbumService.initTasknull){thrownewError(PhotoAlbumService is not initialized);}选择抛错还是空数据取决于产品降级策略但行为必须明确并可测试。四、CapturedPhoto是持久化契约照片模型包含标识、展示和来源信息exportinterfaceCapturedPhoto{id:string;title:string;createdAt:string;layerCount:number;layerSummary:string;filterName:string;filterIntensity?:number;frameName:string;beautySummary:string;beautyFeature?:string;beautyIntensity?:number;resolutionLabel?:string;captureSource:real|simulated;captureSummary:string;status:preview|saved;watermark?:WatermarkSnapshot;}接口中的可选字段就是升级兼容信号。读取旧版本记录时不能直接断言这些字段存在必须提供默认值。五、创建对象与保存对象是两个阶段拍照结束先创建预览记录return{id:photo_${Date.now()}_${sequence},title:水印照片${sequence},createdAt:PhotoAlbumService.formatNow(),layerCount:0,layerSummary:PhotoAlbumService.watermarkSummary(watermark),filterName:无滤镜,frameName:无相框,beautySummary:标准模式,resolutionLabel,captureSource,captureSummary:PhotoAlbumService.safeCaptureSummary(captureSummary),status:preview,watermark:PhotoAlbumService.cloneWatermark(watermark)};用户点击“保存到相册”后服务再生成status: saved的规范对象。区分两个阶段可以让结果预览、取消拍摄和真正持久化保持一致而不是一拍照就产生无法撤销的记录。六、savePhoto同时承担Schema归一化保存时不要原样扩展...photo。显式列出字段能阻止页面临时状态或未知属性进入持久化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||标准,beautyIntensity:PhotoAlbumService.safeNumber(photo.beautyIntensity,0),resolutionLabel:photo.resolutionLabel||12MP (4:3),captureSource:photo.captureSource,captureSummary:PhotoAlbumService.safeCaptureSummary(photo.captureSummary),status:saved,watermark:PhotoAlbumService.cloneWatermark(photo.watermark)};}显式映射也让代码评审可以直接看到落盘字段不必追踪对象上可能存在的所有属性。七、safeNumber同时处理缺省和越界强度类字段应限制在领域范围privatestaticsafeNumber(value:number|undefined,fallback:number):number{if(valueundefined||Number.isNaN(value)){returnfallback;}returnMath.max(0,Math.min(100,value));}典型输入与结果输入结果原因undefined0旧数据缺字段NaN0非法计算结果-200下界收敛4545合法值保留130100上界收敛如果数据来自不可信导入还应检查Number.isFinite防止 Infinity 进入页面计算。八、嵌套水印对象必须深拷贝浅拷贝数组并不能隔离嵌套对象。若页面修改photo.watermark.note缓存中的同一对象也可能被修改下一次 flush 就会把临时编辑写回。项目逐字段克隆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};}listPhotos()、savePhoto()、createPhoto()都通过这条路径形成双向隔离输入对象不会被服务保存引用输出对象也不会暴露内部缓存引用。九、水印快照保存的是拍摄时事实页面当前模板会变化但历史照片不应跟着变化。拍照瞬间构造快照privatewatermarkSnapshot():WatermarkSnapshot{consttemplate:WatermarkTemplatethis.selectedTemplate;return{enabled:this.watermarkEnabled,template,title:this.templateTitle(template),locationText:this.customPlace.length0?this.customPlace:this.templateLocation(template),note:this.customNote.length0?this.customNote:this.templateNote(template),timeText:this.currentTimeText};}这里保存渲染后的标题、地点和备注而不只是模板 ID。即使后续版本修改模板默认文案历史照片仍能还原拍摄时内容。十、用户摘要与内部诊断信息分离相机服务返回的消息可能包含“真实照片”“目标对齐”等实现细节不适合长期显示在相册卡片。项目统一转换privatestaticsafeCaptureSummary(summary:string|undefined):string{if(!summary||summary.length0){returnDEFAULT_CAPTURE_SUMMARY;}if(summary.indexOf(真实照片已捕获)0||summary.indexOf(预览目标对齐)0){returnsummary.replace(真实照片已捕获正在使用预览目标对齐,DEFAULT_CAPTURE_SUMMARY).replace(个人物目标对齐,个取景目标);}returnsummary;}更可扩展的方案是从源头分开字段interfaceCaptureResult{userMessage:string;diagnosticCode:string;targetCount:number;}页面显示userMessagehilog 记录diagnosticCode。这样无需依赖文案替换也不会误改正常用户文本。十一、读取旧数据时统一经过clonePhotos解析成功不代表字段完整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[];}}clonePhotos在这里不仅是复制也是兼容层。旧版本缺失的filterName、beautyFeature和resolutionLabel会获得默认值。需要进一步加固时可先判断Array.isArray(parsed)并逐条验证id、title、status的类型过滤无法恢复的记录。十二、损坏JSON要降级但不能悄悄覆盖当前实现解析失败后返回空数组保证应用可启动。这是合理的可用性兜底但若随后立刻 flush原损坏数据会被空数组覆盖排查证据消失。可以引入恢复状态interfaceAlbumLoadResult{photos:CapturedPhoto[];recovered:boolean;reason?:string;}处理策略读取失败时保留原始字符串的哈希和错误码。UI 显示“本地相册数据需要恢复”不要暴露技术栈。在用户产生新保存动作前不主动覆盖损坏值。若业务重要保留一份受控备份键并设置迁移期限。日志不得记录完整水印地点、备注或完整 JSON。十三、容量上限必须在写入前生效项目把新照片放在最前再截取 60 条constsavedPhoto:CapturedPhotoPhotoAlbumService.savePhoto(photo);constnextPhotos:CapturedPhoto[][savedPhoto].concat(PhotoAlbumService.cachedPhotos);PhotoAlbumService.cachedPhotosnextPhotos.slice(0,60);awaitPhotoAlbumService.flushPhotos();这个顺序保证最新照片不会因上限被丢弃。还需明确Preferences 中保存的是元数据不宜保存图片 Base64真实媒体应放在适合的文件或媒体资产存储中Preferences 只记录引用和轻量展示信息。十四、内存先更新还是落盘先更新当前流程先更新缓存再执行 flush。优点是页面响应快缺点是落盘失败后内存与磁盘不一致。可以返回结构化结果interfacePersistPhotoResult{photos:CapturedPhoto[];persisted:boolean;errorCode?:string;}若产品承诺“保存成功”应只在 flush 完成后显示成功状态失败时恢复旧缓存或把记录标记为待重试。不要捕获错误后仍然让页面显示“照片已保存”。十五、删除操作也需要一致性语义当前删除逻辑staticasyncdeletePhoto(photoId:string):PromiseCapturedPhoto[]{awaitPhotoAlbumService.waitForInit();PhotoAlbumService.cachedPhotosPhotoAlbumService.cachedPhotos.filter((photo:CapturedPhoto)photo.id!photoId);awaitPhotoAlbumService.flushPhotos();returnPhotoAlbumService.clonePhotos(PhotoAlbumService.cachedPhotos);}至少需要验证三种情况存在的 ID、重复删除、空字符串 ID。若照片还有对应文档或媒体文件需要由更高层用事务式流程协调而不是让两个服务互相隐式调用。十六、建议增加Schema版本字段继续增长后仅靠默认值难以表达复杂迁移。可以把存储结构升级为interfaceAlbumStoreV2{schemaVersion:2;updatedAt:number;photos:CapturedPhoto[];}读取流程识别根结构 - 读取 schemaVersion - v1 转 v2 - 逐条校验与归一化 - 写回新结构 - 更新内存缓存迁移函数应保持纯函数输入旧数据、输出新数据便于用固定样本做回归测试。十七、测试矩阵用例输入期望结果首次启动键不存在返回空数组正常恢复完整 JSON字段完整、顺序不变旧版数据缺可选字段使用默认值损坏数据非法 JSON安全降级并记录错误码数值越界-10 / 160 / NaN收敛到 0…100对象隔离修改 listPhotos 返回值内部缓存不变化容量边界连续保存 61 条保留最新 60 条重复初始化并发调用 init只执行一次真实初始化写入失败flush 抛错页面不误报成功删除不存在项未知 photoId列表不变且不崩溃深拷贝测试示例it(returns isolated watermark snapshots,0,async(){constfirst:CapturedPhoto[]awaitPhotoAlbumService.listPhotos();first[0].watermark!.notechanged by page;constsecond:CapturedPhoto[]awaitPhotoAlbumService.listPhotos();expect(second[0].watermark!.note).not().assertEqual(changed by page);});十八、常见问题排查现象原因修复方向改一张照片水印其他位置同步变化嵌套对象共享引用深拷贝 WatermarkSnapshot升级后列表空白旧数据缺字段或根结构变化归一化与版本迁移重启后刚保存的照片消失flush 失败但 UI 误报成功返回持久化状态相册越来越慢缓存和字符串无限增长限制元数据条数异常日志泄露地点备注打印完整 JSON只记录错误码和条数并发启动数据闪回多次初始化覆盖缓存复用 initTask十九、发布前验收清单Preferences 名称和键只在服务层定义。所有公开操作都等待初始化完成。旧字段通过统一归一化函数补默认值。数值字段处理 undefined、NaN 与越界。水印快照在输入和输出两侧都深拷贝。用户摘要与内部诊断字段分开。JSON 损坏时应用可启动且保留排查线索。元数据有明确容量上限不保存图片 Base64。flush 失败不会向用户误报保存成功。Schema 迁移有固定样本自动测试。总结Preferences 适合保存趣味相机的轻量元数据但不能把它当成“任意对象数组仓库”。稳定实现需要用服务层封装初始化和写入用显式字段映射完成 Schema 归一化用深拷贝隔离水印快照用容量上限控制增长并把损坏数据、写入失败和版本迁移纳入正常流程。当PhotoAlbumService对外只返回经过验证的领域对象ArkUI 页面就不必到处判断缺字段当用户文案与诊断信息分离数据层也能兼顾可读性和隐私。这样的本地相册才能承受真实升级、异常退出和长期使用。