HarmonyOS趣味相机实战第25篇:Preferences相册Schema归一化与水印快照隔离
HarmonyOS趣味相机实战第25篇:Preferences相册Schema归一化与水印快照隔离
摘要
本地相册看似只是把数组 JSON.stringify 后写进 Preferences,但真正上线后会遇到旧版本缺字段、异常 JSON、并发初始化、对象引用被页面修改、数字越界、缓存无限增长等问题。若读取层直接把历史数据交给 ArkUI,升级一次字段就可能造成列表空白或水印内容被意外联动修改。
本文基于 D:/APP/1quweixiangji 的 PhotoAlbumService.ets,完整复盘初始化任务复用、缓存副本、Schema 归一化、WatermarkSnapshot 深拷贝、摘要脱敏、容量上限和写入顺序。重点是让本地数据层成为稳定边界:页面拿到的数据可用,持久化失败可定位,旧数据可以安全降级。
环境与数据边界
| 项目 | 当前实现 |
|---|---|
| 开发语言 | ArkTS |
| 数据组件 | @kit.ArkData Preferences |
| Preferences 名称 | watermark_camera_album |
| 数据键 | captured_photos |
| 内存缓存 | CapturedPhoto[] |
| 最大记录数 | 60 |
| 水印字段 | WatermarkSnapshot |
| 异常日志 | @kit.PerformanceAnalysisKit hilog |

一、相册服务需要明确责任边界
PhotoAlbumService 负责的不是页面展示,而是五件事:
- 建立 Preferences 连接。
- 把持久化字符串解析为领域对象。
- 修复缺失或越界字段。
- 保存、删除并限制缓存容量。
- 向调用方返回隔离后的副本。
页面只调用稳定接口:
await PhotoAlbumService.init(context);
const photos: CapturedPhoto[] = await PhotoAlbumService.listPhotos();
const next: CapturedPhoto[] = await PhotoAlbumService.persistPhoto(photo);
这样 Preferences 的名字、键和序列化格式不会散落在多个 ArkUI 组件里。
二、用initTask合并并发初始化
Ability 启动、页面出现和测试代码可能同时触发初始化。若每次都调用 getPreferences 并读取数据,会产生重复 I/O 和状态覆盖。
项目用一个 Promise 复用正在进行的任务:
private static prefs: preferences.Preferences | null = null;
private static initTask: Promise<void> | null = null;
private static cachedPhotos: CapturedPhoto[] = [];
static init(context: common.UIAbilityContext): Promise<void> {
if (PhotoAlbumService.initTask !== null) {
return PhotoAlbumService.initTask;
}
PhotoAlbumService.initTask =
PhotoAlbumService.initInternal(context);
return PhotoAlbumService.initTask;
}
这是一种单次初始化门闩。调用者共享同一个结果,不会出现后发初始化先覆盖缓存的竞态。
需要注意:若产品希望初始化失败后允许重试,应在失败路径把 initTask 设回 null,同时保留错误状态;否则当前进程内后续调用会继续复用已经完成但失败的 Promise。
三、所有公开操作都等待初始化
读取接口不能假设页面一定先调用过 init():
private static async waitForInit(): Promise<void> {
if (PhotoAlbumService.initTask !== null) {
await PhotoAlbumService.initTask;
}
}
static async listPhotos(): Promise<CapturedPhoto[]> {
await PhotoAlbumService.waitForInit();
return PhotoAlbumService.clonePhotos(
PhotoAlbumService.cachedPhotos
);
}
更严格的版本可以在 initTask === null 时抛出领域错误,避免静默返回空列表:
if (PhotoAlbumService.initTask === null) {
throw new Error('PhotoAlbumService is not initialized');
}
选择抛错还是空数据取决于产品降级策略,但行为必须明确并可测试。
四、CapturedPhoto是持久化契约
照片模型包含标识、展示和来源信息:
export interface CapturedPhoto {
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。显式列出字段能阻止页面临时状态或未知属性进入持久化:
static savePhoto(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同时处理缺省和越界
强度类字段应限制在领域范围:
private static safeNumber(
value: number | undefined,
fallback: number
): number {
if (value === undefined || Number.isNaN(value)) {
return fallback;
}
return Math.max(0, Math.min(100, value));
}
典型输入与结果:
| 输入 | 结果 | 原因 |
|---|---|---|
undefined |
0 | 旧数据缺字段 |
NaN |
0 | 非法计算结果 |
| -20 | 0 | 下界收敛 |
| 45 | 45 | 合法值保留 |
| 130 | 100 | 上界收敛 |
如果数据来自不可信导入,还应检查 Number.isFinite,防止 Infinity 进入页面计算。
八、嵌套水印对象必须深拷贝
浅拷贝数组并不能隔离嵌套对象。若页面修改 photo.watermark.note,缓存中的同一对象也可能被修改,下一次 flush 就会把临时编辑写回。
项目逐字段克隆:
private static cloneWatermark(
watermark?: WatermarkSnapshot
): WatermarkSnapshot | undefined {
if (!watermark) {
return undefined;
}
return {
enabled: watermark.enabled,
template: watermark.template,
title: watermark.title,
locationText: watermark.locationText,
note: watermark.note,
timeText: watermark.timeText
};
}
listPhotos()、savePhoto()、createPhoto() 都通过这条路径,形成双向隔离:输入对象不会被服务保存引用,输出对象也不会暴露内部缓存引用。
九、水印快照保存的是拍摄时事实
页面当前模板会变化,但历史照片不应跟着变化。拍照瞬间构造快照:
private watermarkSnapshot(): WatermarkSnapshot {
const template: WatermarkTemplate = this.selectedTemplate;
return {
enabled: this.watermarkEnabled,
template,
title: this.templateTitle(template),
locationText: this.customPlace.length > 0 ?
this.customPlace : this.templateLocation(template),
note: this.customNote.length > 0 ?
this.customNote : this.templateNote(template),
timeText: this.currentTimeText
};
}
这里保存渲染后的标题、地点和备注,而不只是模板 ID。即使后续版本修改模板默认文案,历史照片仍能还原拍摄时内容。
十、用户摘要与内部诊断信息分离
相机服务返回的消息可能包含“真实照片”“目标对齐”等实现细节,不适合长期显示在相册卡片。项目统一转换:
private static safeCaptureSummary(
summary: string | undefined
): string {
if (!summary || summary.length === 0) {
return DEFAULT_CAPTURE_SUMMARY;
}
if (summary.indexOf('真实照片已捕获') >= 0 ||
summary.indexOf('预览目标对齐') >= 0) {
return summary
.replace('真实照片已捕获,正在使用预览目标对齐',
DEFAULT_CAPTURE_SUMMARY)
.replace('个人物目标对齐', '个取景目标');
}
return summary;
}
更可扩展的方案是从源头分开字段:
interface CaptureResult {
userMessage: string;
diagnosticCode: string;
targetCount: number;
}
页面显示 userMessage,hilog 记录 diagnosticCode。这样无需依赖文案替换,也不会误改正常用户文本。
十一、读取旧数据时统一经过clonePhotos
解析成功不代表字段完整:
private static parsePhotos(raw: string): CapturedPhoto[] {
try {
const parsed: CapturedPhoto[] =
JSON.parse(raw) as CapturedPhoto[];
if (!parsed || parsed.length === 0) {
return [];
}
return PhotoAlbumService.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,原损坏数据会被空数组覆盖,排查证据消失。
可以引入恢复状态:
interface AlbumLoadResult {
photos: CapturedPhoto[];
recovered: boolean;
reason?: string;
}
处理策略:
- 读取失败时保留原始字符串的哈希和错误码。
- UI 显示“本地相册数据需要恢复”,不要暴露技术栈。
- 在用户产生新保存动作前,不主动覆盖损坏值。
- 若业务重要,保留一份受控备份键并设置迁移期限。
日志不得记录完整水印地点、备注或完整 JSON。
十三、容量上限必须在写入前生效
项目把新照片放在最前,再截取 60 条:
const savedPhoto: CapturedPhoto =
PhotoAlbumService.savePhoto(photo);
const nextPhotos: CapturedPhoto[] =
[savedPhoto].concat(PhotoAlbumService.cachedPhotos);
PhotoAlbumService.cachedPhotos = nextPhotos.slice(0, 60);
await PhotoAlbumService.flushPhotos();
这个顺序保证最新照片不会因上限被丢弃。还需明确:Preferences 中保存的是元数据,不宜保存图片 Base64;真实媒体应放在适合的文件或媒体资产存储中,Preferences 只记录引用和轻量展示信息。
十四、内存先更新还是落盘先更新
当前流程先更新缓存,再执行 flush。优点是页面响应快,缺点是落盘失败后内存与磁盘不一致。可以返回结构化结果:
interface PersistPhotoResult {
photos: CapturedPhoto[];
persisted: boolean;
errorCode?: string;
}
若产品承诺“保存成功”,应只在 flush 完成后显示成功状态;失败时恢复旧缓存或把记录标记为待重试。不要捕获错误后仍然让页面显示“照片已保存”。
十五、删除操作也需要一致性语义
当前删除逻辑:
static async deletePhoto(photoId: string): Promise<CapturedPhoto[]> {
await PhotoAlbumService.waitForInit();
PhotoAlbumService.cachedPhotos =
PhotoAlbumService.cachedPhotos.filter(
(photo: CapturedPhoto) => photo.id !== photoId);
await PhotoAlbumService.flushPhotos();
return PhotoAlbumService.clonePhotos(
PhotoAlbumService.cachedPhotos);
}
至少需要验证三种情况:存在的 ID、重复删除、空字符串 ID。若照片还有对应文档或媒体文件,需要由更高层用事务式流程协调,而不是让两个服务互相隐式调用。
十六、建议增加Schema版本
字段继续增长后,仅靠默认值难以表达复杂迁移。可以把存储结构升级为:
interface AlbumStoreV2 {
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 () => {
const first: CapturedPhoto[] =
await PhotoAlbumService.listPhotos();
first[0].watermark!.note = 'changed by page';
const second: CapturedPhoto[] =
await PhotoAlbumService.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 页面就不必到处判断缺字段;当用户文案与诊断信息分离,数据层也能兼顾可读性和隐私。这样的本地相册才能承受真实升级、异常退出和长期使用。
更多推荐
所有评论(0)