这一篇是晨迹相机系列里的状态持久化实战。前面几篇把系统相机、图片选择和页面展示跑通以后,项目会马上遇到一个更实际的问题:用户拍完照片后,记录不能只停留在内存里。只要页面关闭、应用重启或系统回收,内存状态就会丢失,所以必须把拍摄记录落到应用沙盒。

这篇文章不使用作者电脑上的绝对路径,也不只写概念。下面会从数据模型、文件路径、读写服务、页面调用、异常兜底和验证输出六个部分,把一个可以复用的轻量 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。它把文件名、沙盒路径、读取、写入、追加和异常都封装起来。页面只调用 loadsaveappend,不接触底层文件实现。

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 的底层实现换成数据库,但页面仍然调用 loadappendtoggleFavoriteremove 这类方法。

迁移判断:
记录少、查询少、离线个人工具:JSON 足够
记录多、多条件筛选、需要统计:优先考虑 RDB
需要跨设备同步:先设计同步协议,再决定本地缓存结构
需要审计或回滚:不要只依赖单文件 JSON

也就是说,本文的重点不是鼓励一直停留在 JSON,而是先把数据访问边界设计好。只要边界稳定,底层从 JSON 换成 RDB 不会影响页面结构,这才是小项目后续可扩展的关键。

十三、最终验收清单

文章写到最后,最好把验收清单列出来,便于读者照着检查,也便于自己后续回归。晨迹相机这一节的验收可以分为功能、异常、体验和发布四类。

类别 检查项 通过标准
功能 新增、收藏、删除、重启读取 每一步都能改变列表并落盘
异常 文件不存在、JSON 损坏、写入失败 有兜底、有提示、不白屏
体验 空状态、错误状态、深浅色显示 文字可读,按钮可点,内容不遮挡
发布 隐私说明、权限声明、核心流程冒烟 材料和真实行为一致

如果这四类都通过,状态持久化就不是一个孤立的小函数,而是能真正支撑用户使用的基础能力。对参赛或上架项目来说,这类基础能力虽然不炫,但会直接影响评委和审核人员对项目完整度的判断。

十四、总结

这篇的核心结论是:轻量状态持久化不是简单把数组写进文件,而是把数据模型、服务封装、页面状态、异常兜底和验证输出串成闭环。对晨迹相机这样的个人工具来说,沙盒 JSON 数据库足够轻,也足够实用;只要边界写清楚,后续迁移到更复杂的数据方案也不会推倒重来。

把这部分写完整以后,文章不再只是“我做了一个保存功能”,而是能给读者提供可复现的项目经验:什么时候该用轻量 JSON,代码应该放在哪一层,失败路径怎么处理,最后又该如何验证。这样的内容才更接近高质量原创技术文章。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐