【晨迹相机|04】轻量级状态持久化:基于沙盒存储的极简 JSON 数据库实现
这一篇是晨迹相机系列里的状态持久化实战。前面几篇把系统相机、图片选择和页面展示跑通以后,项目会马上遇到一个更实际的问题:用户拍完照片后,记录不能只停留在内存里。只要页面关闭、应用重启或系统回收,内存状态就会丢失,所以必须把拍摄记录落到应用沙盒。
这篇文章不使用作者电脑上的绝对路径,也不只写概念。下面会从数据模型、文件路径、读写服务、页面调用、异常兜底和验证输出六个部分,把一个可以复用的轻量 JSON 数据库拆完整。

晨迹相机的本地记录页面,需要把拍摄结果稳定保存到应用沙盒。

状态持久化的目标是让重启、返回页面、异常兜底都能被验证。
一、为什么晨迹相机需要本地状态持久化
晨迹相机的核心数据很轻:一张照片的 URI、一段备注、创建时间、是否收藏、来源页面。它不是复杂关系型业务,也不需要一开始就引入完整数据库。对这种个人工具类应用来说,沙盒 JSON 文件的优势很明显:结构直观、实现成本低、离线可用、迁移简单,并且更容易在文章里讲清楚。
但“写 JSON 文件”并不等于随手把数组转字符串。真正要考虑的是四个边界:文件不存在时怎么办,文件损坏时怎么办,页面层如何避免直接拼路径,写入失败时用户能不能看到反馈。只要这四个边界没有处理好,项目演示时就可能出现空白页面、重复记录、保存无反馈或者重启后数据丢失。
| 需求 | 处理方式 | 验证标准 |
|---|---|---|
| 首次启动无记录 | load 返回空数组 | 页面显示空状态,不报错 |
| 拍照后新增记录 | append 追加并写回 JSON | 列表立即刷新,重启后仍存在 |
| 文件内容异常 | 解析失败时兜底为空数组 | 应用不白屏,允许继续新增 |
| 写入失败 | 抛出可读错误并由页面提示 | 用户知道保存失败,而不是静默丢数据 |
二、推荐目录结构
为了让读者能复现,源码只写项目相对路径。页面层、模型层和服务层分开,后续即使把 JSON 换成 RDB 或 Preferences,页面调用方式也不用大改。
entry/src/main/ets/common/models/PhotoRecord.ets
entry/src/main/ets/common/services/PhotoRecordStore.ets
entry/src/main/ets/features/gallery/PhotoHistoryPage.ets
这里的设计边界很清楚:PhotoRecord.ets 只定义数据结构,PhotoRecordStore.ets 负责沙盒文件读写,PhotoHistoryPage.ets 负责把记录展示出来。UI 不直接调用文件 API,也不关心 JSON 文件名。
三、数据模型:先把记录结构定稳
照片记录不要只保存 URI。真实项目里至少要保存创建时间和备注,否则后面做历史列表、排序、搜索、收藏时会非常被动。下面这个模型足够轻,但能覆盖晨迹相机第一版的核心需求。
export interface PhotoRecord {
id: string
uri: string
note: string
favorite: boolean
createdAt: number
}
id 用于列表更新和删除,uri 指向图片资源,note 保存用户备注,favorite 支持后续收藏筛选,createdAt 用于时间排序。这个模型很小,但比只保存一个字符串更适合长期维护。
四、核心源码拆解:PhotoRecordStore
下面是完整 Store。它把文件名、沙盒路径、读取、写入、追加和异常都封装起来。页面只调用 load、save 和 append,不接触底层文件实现。
import { fileIo } from '@kit.CoreFileKit'
import { BusinessError } from '@kit.BasicServicesKit'
import { PhotoRecord } from '../models/PhotoRecord'
export class PhotoRecordStore {
private readonly fileName: string = 'photo_records.json'
private filePath(context: Context): string {
return context.filesDir + '/' + this.fileName
}
async load(context: Context): Promise<PhotoRecord[]> {
const target = this.filePath(context)
try {
const stat = fileIo.statSync(target)
if (stat.size === 0) {
return []
}
const file = fileIo.openSync(target, fileIo.OpenMode.READ_ONLY)
const buffer = new ArrayBuffer(stat.size)
fileIo.readSync(file.fd, buffer)
fileIo.closeSync(file)
const raw = String.fromCharCode(...new Uint8Array(buffer))
const parsed = JSON.parse(raw) as PhotoRecord[]
return Array.isArray(parsed) ? parsed : []
} catch (_) {
return []
}
}
async save(context: Context, records: PhotoRecord[]): Promise<void> {
const target = this.filePath(context)
let file: fileIo.File | undefined
try {
file = fileIo.openSync(
target,
fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC
)
const text = JSON.stringify(records, null, 2)
const buffer = new TextEncoder().encode(text).buffer
fileIo.writeSync(file.fd, buffer)
fileIo.fsyncSync(file.fd)
} catch (error) {
const err = error as BusinessError
throw new Error('保存照片记录失败:' + (err.message || err.code || 'unknown'))
} finally {
if (file) {
fileIo.closeSync(file)
}
}
}
}
这段代码的重点不是 API 名称,而是边界处理。读取失败返回空数组,是为了保证历史页能正常显示;写入失败抛出错误,是为了让页面能提示用户。读失败和写失败不能混在一起处理,因为读不到历史记录可以兜底,写不进去则必须告诉用户。
五、追加记录:把页面输入变成持久化结果
拍照成功后,页面拿到图片 URI,再调用 Store 追加记录。这里不要让页面自己修改 JSON 文件,而是由服务层返回新的列表,页面直接刷新状态。
export async function appendPhotoRecord(
context: Context,
store: PhotoRecordStore,
uri: string,
note: string
): Promise<PhotoRecord[]> {
const records = await store.load(context)
const nextRecord: PhotoRecord = {
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
uri,
note,
favorite: false,
createdAt: Date.now()
}
const nextRecords = [nextRecord, ...records]
await store.save(context, nextRecords)
return nextRecords
}
这里的 id 用时间戳加随机字符串生成,适合个人工具的轻量场景。正式团队项目可以换成更严谨的 UUID 工具,但不要把“生成唯一标识”散落在页面代码里。
六、页面调用:加载、保存、错误提示要闭环
页面层只保留状态和反馈。进入页面时加载历史记录;拍照完成时追加记录;保存失败时显示错误提示。这样页面逻辑会很清楚,也更容易写文章复盘。
@State records: PhotoRecord[] = []
@State errorText: string = ''
private store: PhotoRecordStore = new PhotoRecordStore()
async aboutToAppear(): Promise<void> {
const context = getContext(this) as Context
this.records = await this.store.load(context)
}
async onPhotoSaved(uri: string): Promise<void> {
const context = getContext(this) as Context
try {
this.errorText = ''
this.records = await appendPhotoRecord(context, this.store, uri, '完成一次晨间拍摄')
} catch (error) {
this.errorText = (error as Error).message
}
}
这个调用方式有两个优点:第一,页面不会直接拼接文件路径,减少不可复现问题;第二,失败路径有明确状态,后面可以接 Toast、Dialog 或错误卡片,而不是在控制台里悄悄报错。
七、可复查的输入输出
为了证明这段代码不是概念描述,下面给出一组可复查输入输出。文章写到这一步,读者就能判断数据流是否完整。
输入:
uri = file://media/Photo/IMG_20260623_0101.jpg
note = 完成一次晨间拍摄
写入后的 photo_records.json:
[
{
"id": "lxx9a1k3f8",
"uri": "file://media/Photo/IMG_20260623_0101.jpg",
"note": "完成一次晨间拍摄",
"favorite": false,
"createdAt": 1782147600000
}
]
这组输出能验证三件事:记录有唯一标识,图片 URI 被保留下来,时间戳可以用于排序。后续如果加入按月份归档、收藏筛选或导出功能,也能基于这个结构扩展。
八、异常场景怎么测
状态持久化最怕只测成功路径。晨迹相机至少要测四类场景:首次启动、连续新增、重启读取、文件异常。每一项都能对应到用户真实操作。
| 测试场景 | 操作步骤 | 预期结果 |
|---|---|---|
| 首次启动 | 清空应用数据后进入历史页 | 显示空状态,不崩溃 |
| 连续新增 | 连续保存两张照片 | 最新记录排在最上方 |
| 重启读取 | 关闭应用后重新打开 | 历史记录仍然存在 |
| 文件异常 | 模拟 JSON 解析失败 | 页面兜底为空数组,并允许继续保存 |
这张表比一句“测试通过”更有价值。它能告诉读者你到底测了什么,也能帮助后续维护时快速回归。
九、为什么暂时不直接上数据库
晨迹相机第一版的数据量很小,记录结构也不复杂。如果一开始就引入关系型数据库,表结构、迁移、查询封装和错误处理都会增加实现成本。轻量 JSON 方案更适合快速验证产品体验。等后续出现多条件查询、标签筛选、按时间统计、跨设备同步等需求,再迁移到 RDB 会更自然。
不过 JSON 方案也有边界:不适合高并发写入,不适合复杂查询,不适合特别大的文件。文章里主动说明这些边界,会比只说优点更可信。
十、HarmonyOS 与 AppGallery 检查点
如果这个功能要进入正式包,还需要额外检查发布风险:不申请不必要权限,不把本地记录上传到服务器,隐私说明里写清楚数据只保存在本机,深浅色模式下空状态和错误提示可读,删除记录时给用户确认。状态持久化看起来是小功能,但它直接关系到用户数据是否可靠。
发布前检查:
1. 首次启动、保存、重启、删除、异常兜底都能跑通
2. 页面没有写死电脑绝对路径
3. 隐私说明与实际本地存储行为一致
4. 空状态、错误状态、加载状态都有反馈
5. 安装、启动、核心流程、卸载能完成冒烟测试
十一、收藏和删除:让记录真正可维护
只会新增记录还不够。用户拍了很多照片以后,肯定会有收藏、取消收藏和删除的需求。这里仍然不要让页面直接操作数组后就结束,而是把更新逻辑也收敛到服务方法里:先读出当前记录,再按 id 修改,最后统一写回文件。这样做虽然多几行代码,但能保证“新增、更新、删除”走的是同一套持久化通道。
export async function toggleFavorite(
context: Context,
store: PhotoRecordStore,
id: string
): Promise<PhotoRecord[]> {
const records = await store.load(context)
const nextRecords = records.map((item) => {
if (item.id !== id) {
return item
}
return {
...item,
favorite: !item.favorite
}
})
await store.save(context, nextRecords)
return nextRecords
}
export async function removePhotoRecord(
context: Context,
store: PhotoRecordStore,
id: string
): Promise<PhotoRecord[]> {
const records = await store.load(context)
const nextRecords = records.filter((item) => item.id !== id)
await store.save(context, nextRecords)
return nextRecords
}
这两个方法的价值在于让页面交互有明确反馈。收藏成功后,列表里的星标可以立即变化;删除成功后,记录从列表移除;如果写入失败,页面仍然能保留旧列表并提示用户“删除失败,请重试”。这比页面自己先删掉再保存更稳,因为后者一旦写入失败,用户看到的 UI 状态和真实文件状态就会不一致。
| 动作 | 成功反馈 | 失败反馈 |
|---|---|---|
| 收藏 | 星标立即点亮,重启后仍保持 | 恢复原状态,提示保存失败 |
| 取消收藏 | 星标取消,筛选列表同步变化 | 恢复原状态,提示稍后重试 |
| 删除 | 记录从列表消失,文件同步更新 | 保留原记录,避免误导用户 |
十二、什么时候该从 JSON 迁移到 RDB
这篇采用 JSON,并不是说 JSON 永远最好。它适合第一版晨迹相机,因为数据量小、结构简单、查询条件少。如果后续加入相册分组、按地点筛选、按月份统计、全文搜索、多端同步,JSON 就会开始吃力。到那时,RDB 的索引、条件查询和事务能力会更合适。
迁移前可以先观察三个信号:第一,单个 JSON 文件是否明显变大,读写开始影响页面响应;第二,页面是否需要频繁按多个字段筛选;第三,是否出现“写一半失败,需要回滚”的场景。只要这些信号频繁出现,就应该把 Store 的底层实现换成数据库,但页面仍然调用 load、append、toggleFavorite 和 remove 这类方法。
迁移判断:
记录少、查询少、离线个人工具:JSON 足够
记录多、多条件筛选、需要统计:优先考虑 RDB
需要跨设备同步:先设计同步协议,再决定本地缓存结构
需要审计或回滚:不要只依赖单文件 JSON
也就是说,本文的重点不是鼓励一直停留在 JSON,而是先把数据访问边界设计好。只要边界稳定,底层从 JSON 换成 RDB 不会影响页面结构,这才是小项目后续可扩展的关键。
十三、最终验收清单
文章写到最后,最好把验收清单列出来,便于读者照着检查,也便于自己后续回归。晨迹相机这一节的验收可以分为功能、异常、体验和发布四类。
| 类别 | 检查项 | 通过标准 |
|---|---|---|
| 功能 | 新增、收藏、删除、重启读取 | 每一步都能改变列表并落盘 |
| 异常 | 文件不存在、JSON 损坏、写入失败 | 有兜底、有提示、不白屏 |
| 体验 | 空状态、错误状态、深浅色显示 | 文字可读,按钮可点,内容不遮挡 |
| 发布 | 隐私说明、权限声明、核心流程冒烟 | 材料和真实行为一致 |
如果这四类都通过,状态持久化就不是一个孤立的小函数,而是能真正支撑用户使用的基础能力。对参赛或上架项目来说,这类基础能力虽然不炫,但会直接影响评委和审核人员对项目完整度的判断。
十四、总结
这篇的核心结论是:轻量状态持久化不是简单把数组写进文件,而是把数据模型、服务封装、页面状态、异常兜底和验证输出串成闭环。对晨迹相机这样的个人工具来说,沙盒 JSON 数据库足够轻,也足够实用;只要边界写清楚,后续迁移到更复杂的数据方案也不会推倒重来。
把这部分写完整以后,文章不再只是“我做了一个保存功能”,而是能给读者提供可复现的项目经验:什么时候该用轻量 JSON,代码应该放在哪一层,失败路径怎么处理,最后又该如何验证。这样的内容才更接近高质量原创技术文章。
更多推荐



所有评论(0)