HarmonyOS趣味相机实战第7篇:Preferences相册缓存、旧数据兼容与异常兜底
HarmonyOS趣味相机实战第7篇:Preferences相册缓存、旧数据兼容与异常兜底
摘要
本地相册看起来只是一个列表,但真正上线后会遇到很多细节:第一次启动没有数据怎么办,Preferences 里的 JSON 损坏怎么办,老版本缺少新字段怎么办,滤镜强度出现 NaN 怎么办,保存太多记录会不会拖慢启动,页面拿到列表后会不会直接改坏服务内部缓存。
本文继续基于 D:/APP/1quweixiangji HarmonyOS 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、CaptureSource |
entry/src/main/ets/pages/Index.ets |
拍照后调用 persistPhoto(),相册页调用 listPhotos() 和 deletePhoto() |
entry/src/main/ets/entryability/EntryAbility.ets |
Ability 创建时初始化 PhotoAlbumService |
entry/src/test/LocalUnit.test.ets |
已有照片快照和保存态测试 |
entry/src/main/module.json5 |
相机权限声明,本地相册元数据不新增敏感权限 |
环境与版本边界
| 项目 | 当前值 | 说明 |
|---|---|---|
| 当前复盘日期 | 2026-07-14 | 按当前工程源码复盘 |
| 工程路径 | D:/APP/1quweixiangji |
本文只引用该项目已有源码 |
| 工程类型 | HarmonyOS Stage 模型 | EntryAbility 初始化本地服务 |
| target SDK | 6.0.2(22) |
升级 SDK 后要回归 Preferences 行为 |
| 本地存储 | @kit.ArkData Preferences |
适合轻量 JSON 元数据 |
| 集合名 | watermark_camera_album / captured_photos |
相册元数据存储位置 |
| 数据上限 | 60 条照片记录 | 防止 Preferences JSON 长期膨胀 |
| 图片文件 | 当前不长期保存 PixelMap | Preferences 只存元数据,不存图片二进制 |

本地构建命令:
cd D:\APP\1quweixiangji
$env:JAVA_HOME='D:\Program Files\Huawei\DevEco Studio\jbr'
$env:Path="$env:JAVA_HOME\bin;$env:Path"
& 'D:\Program Files\Huawei\DevEco Studio\tools\hvigor\bin\hvigorw.bat' --mode module -p module=entry@default -p product=default assembleHap --no-daemon
一、缓存服务先定义存储边界
PhotoAlbumService.ets 顶部定义了 Preferences 名称和 key:
const PREF_NAME: string = 'watermark_camera_album';
const PREF_KEY_PHOTOS: string = 'captured_photos';
服务内部状态:
private static prefs: preferences.Preferences | null = null;
private static initTask: Promise<void> | null = null;
private static cachedPhotos: CapturedPhoto[] = [];
三者职责不同:
| 字段 | 职责 |
|---|---|
prefs |
Preferences 实例,负责本地读写 |
initTask |
防止重复初始化,调用方可等待首次读取完成 |
cachedPhotos |
内存缓存,页面操作先更新缓存再 flush |
把它们封在服务层里,页面只调用 listPhotos()、persistPhoto()、deletePhoto(),不会直接读写 Preferences。
二、初始化任务防止重复读取
初始化入口:
static init(context: common.UIAbilityContext): Promise<void> {
if (PhotoAlbumService.initTask !== null) {
return PhotoAlbumService.initTask;
}
PhotoAlbumService.initTask = PhotoAlbumService.initInternal(context);
return PhotoAlbumService.initTask;
}
这个设计避免了多个页面或 Ability 生命周期重复调用时反复创建 Preferences 实例。真正读取逻辑在 initInternal():
private static async initInternal(context: common.UIAbilityContext): Promise<void> {
try {
PhotoAlbumService.prefs = await preferences.getPreferences(context, PREF_NAME);
const raw: preferences.ValueType = PhotoAlbumService.prefs.getSync(PREF_KEY_PHOTOS, '[]');
if (typeof raw === 'string') {
PhotoAlbumService.cachedPhotos = PhotoAlbumService.parsePhotos(raw);
}
} catch (error) {
hilog.error(DOMAIN, TAG, 'init album failed: %{public}s', JSON.stringify(error));
PhotoAlbumService.cachedPhotos = [];
}
}
初始化失败时回退为空数组,不让相册缓存问题拖垮拍照主流程。
三、读取列表时永远返回克隆结果
读取照片:
static async listPhotos(): Promise<CapturedPhoto[]> {
await PhotoAlbumService.waitForInit();
return PhotoAlbumService.clonePhotos(PhotoAlbumService.cachedPhotos);
}
这里没有直接返回 cachedPhotos,而是返回克隆结果。这样页面拿到列表后,即使修改某个对象,也不会绕过服务层污染内部缓存。
等待初始化:
private static async waitForInit(): Promise<void> {
if (PhotoAlbumService.initTask !== null) {
await PhotoAlbumService.initTask;
}
}
这让 listPhotos()、persistPhoto()、deletePhoto() 都能在初始化未完成时安全等待。
四、创建照片:只生成元数据,不保存大图
拍照成功后,页面会调用 PhotoAlbumService.createPhoto() 生成预览态记录:
static createPhoto(
sequence: number,
_layers: DecorationLayer[],
_filterName: string,
_frameName: string,
_beautySummary: string,
captureSource: CaptureSource = 'simulated',
captureSummary: string = '真实相机照片',
_filterIntensity: number = 0,
_beautyFeature: string = '标准',
_beautyIntensity: number = 0,
resolutionLabel: string = '12MP (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,而是调用:
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 ? 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 条
持久化保存:
static async persistPhoto(photo: CapturedPhoto): Promise<CapturedPhoto[]> {
await PhotoAlbumService.waitForInit();
const savedPhoto: CapturedPhoto = PhotoAlbumService.savePhoto(photo);
const nextPhotos: CapturedPhoto[] = [savedPhoto].concat(PhotoAlbumService.cachedPhotos);
PhotoAlbumService.cachedPhotos = nextPhotos.slice(0, 60);
await PhotoAlbumService.flushPhotos();
return PhotoAlbumService.clonePhotos(PhotoAlbumService.cachedPhotos);
}
关键策略:
| 策略 | 价值 |
|---|---|
waitForInit() |
防止覆盖尚未读完的旧数据 |
savePhoto() |
统一保存态转换 |
| 新记录置顶 | 相册列表默认最新在前 |
slice(0, 60) |
控制 Preferences JSON 大小 |
| flush 后返回 clone | 页面拿到的是安全快照 |
Preferences 不适合无限增长。60 条元数据对轻量相册足够,后续如果要保存真实图片文件,应迁移到沙箱文件或媒体库。
七、删除记录也要 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);
}
删除后返回最新列表,页面直接替换 @State album。这比页面自己 filter 更稳,因为服务层和 Preferences 始终同步。
八、flushPhotos:写入失败不打断主流程
写入 Preferences:
private static async flushPhotos(): Promise<void> {
if (PhotoAlbumService.prefs === null) {
return;
}
try {
await PhotoAlbumService.prefs.put(PREF_KEY_PHOTOS, JSON.stringify(PhotoAlbumService.cachedPhotos));
await PhotoAlbumService.prefs.flush();
} catch (error) {
hilog.error(DOMAIN, TAG, 'flush album failed: %{public}s', JSON.stringify(error));
}
}
这里捕获异常并记录日志,不让页面崩溃。对拍照类 App 来说,保存失败要提示用户,但不应该让相机预览和拍照入口一起不可用。
九、parsePhotos:缓存损坏时回退空列表
解析逻辑:
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 [];
}
}
如果 Preferences 中的 JSON 损坏,服务直接返回空列表。这是一个合理降级:用户可能丢失本地相册元数据,但拍照主功能仍可继续使用。
后续如果要做更强恢复,可以增加备份 key:
captured_photos
captured_photos_backup
每次 flush 前先写 backup,解析主 key 失败时尝试 backup。不过当前项目作为轻量相册,回退空列表已经能保证主流程稳定。
十、clonePhotos:旧数据字段兼容集中处理
克隆逻辑:
static clonePhotos(photos: CapturedPhoto[]): CapturedPhoto[] {
return photos.map((photo: CapturedPhoto) => {
const clonedPhoto: 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)
};
return clonedPhoto;
});
}
这里是兼容旧数据的核心。比如老版本没有 resolutionLabel,新版本读取时自动补 12MP (4:3);老版本没有 captureSource,新版本按 simulated 兜底;强度字段异常时走 safeNumber()。
十一、safeNumber:限制 NaN 和越界值
数值兜底:
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 |
fallback |
NaN |
fallback |
-20 |
0 |
180 |
100 |
35 |
35 |
滤镜强度、美颜强度这类 UI 数值一旦越界,可能导致滑块显示异常或渲染参数失真。服务层读取时裁剪,页面会更稳。
十二、水印快照也要克隆
水印摘要:
private static watermarkSummary(watermark: WatermarkSnapshot | undefined): string {
if (!watermark || !watermark.enabled) {
return '未添加水印';
}
const locationText: string = watermark.locationText.length > 0 ? watermark.locationText : '未填写地点';
return `${watermark.title} · ${locationText}`;
}
水印克隆:
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
};
}
克隆的意义是保存“拍照当时”的水印信息。用户之后修改地点、备注或模板,旧照片的水印不应该被污染。
十三、建议的数据迁移方案
如果后续相册要从纯元数据升级到真实图片文件,可以增加版本字段:
interface CapturedPhotoV2 extends CapturedPhoto {
schemaVersion: 2;
imageUri: string;
thumbnailUri: string;
fileSize?: number;
}
迁移策略:
| 旧字段 | 新字段 | 处理 |
|---|---|---|
无 schemaVersion |
schemaVersion: 1 |
读取时补默认版本 |
无 imageUri |
空字符串 | 继续展示元数据卡片 |
无 thumbnailUri |
空字符串 | 使用渐变缩略图兜底 |
watermark |
保留 | 继续作为历史快照 |
captureSource 缺失 |
simulated |
clone 时兜底 |
不要一次性强制把所有旧照片变成 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 适合保存轻量元数据,但一定要把兼容、兜底和上限写清楚。等未来接真实图片文件、系统图库或云同步时,也能在这个稳定的数据模型上继续演进。
更多推荐


所有评论(0)