“把归档票据也塞进 Preferences,省得维护数据库”,这个建议听起来很有吸引力。毕竟前四篇已经实现了筛选配置的版本迁移、校验、串行写入和前后台恢复,再往 JSON 里加一个票据数组,似乎马上就能显示列表。然而数据模型的边界不是以“能不能保存”为准,而是要看查询、修改、生命周期与失败恢复的职责是否相同。筛选偏好是用户的选择,票据是业务事实。两者即使出现在同一个界面,也不应该使用同一种存储协议。

本篇继续使用已有的 ReceiptFilterStore、ReceiptWriteQueue、ReceiptPrefSession,原来的偏好文件 receipt_filter_settings 和键 receipt_filter_v2 均不更名。其值仍是 FilterPrefsV2{version, onlyPending, category, sort},其中 version=2;旧字段 only_pending 仅用于迁移兼容。新引入的关系型数据库只保存真实票据,不会重复保存用户当前的筛选开关。两条数据通道在页面查询时汇合,在存储和写入阶段分离。

票据数据与个人偏好分层封面

先用职责矩阵否决容易失控的方案

偏好配置具备几个明显特点:每个用户通常只有一份、数据量小、全量读取即可、改变排序不等于修改票据。这很适合 Preferences 的键值读写模式。票据则需要按分类、日期、归档状态查询,可能存在上千行,还需要保证主键唯一、字段校验以及未来迁移表结构的能力。关系型数据库能提供结构化查询,更适合承载这部分业务事实。二者不是“哪个接口更高级”的比较,而是数据职责不同。

如果把筛选状态直接写在每条票据上,用户在界面上切换“只看待归档”就可能触发大批业务记录更新;如果把票据数组整体写成一个偏好 JSON,改一张票据的状态又要读取、合并、重写整个数组。更麻烦的是,一旦业务记录和偏好版本同时升级,就会把本来互不相关的迁移逻辑绑在一起。这是架构复杂度的来源,而不只是响应速度的问题。

偏好存储与业务数据库的职责对照

约定一条明确的数据契约:receipt_id 是票据身份;category 在 invoice、travel 两种业务类型中选择;archived=0 表示待归档,archived=1 表示已归档;created_at 是毫秒时间戳。筛选偏好的 category='all' 是查询条件,不是票据可以写入的业务类型。页面的“只看待归档”只应生成 SQL 条件 archived=0,不能修改数据库行本身。这样才能让审核界面、归档统计和列表展示共享同一套业务事实。

将关系型访问封装在仓库内部

以下模型与仓库构成一个最小可复用的本地业务访问层。它使用 @kit.ArkData 的 relationalStore,通过 getRdbStore 获取数据库,executeSql 初始化表,再以绑定参数查询数据。数据库名和建表 SQL 固定,不从 UI 文本拼接表名。安全级别应按产品的数据分类要求选择:示例用 S1 展示调用结构,涉及个人或企业敏感票据时应重新评估数据分级、加密与访问控制策略。

// entry/src/main/ets/model/ReceiptRepository.ets
import { relationalStore } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';

export type ReceiptCategory = 'invoice' | 'travel';
export interface ReceiptRow {
  id: string;
  title: string;
  category: ReceiptCategory;
  archived: boolean;
  createdAt: number;
}
export interface ReceiptQuery {
  onlyPending: boolean;
  category: 'all' | ReceiptCategory;
  sort: 'newest' | 'oldest';
}

export class ReceiptRepository {
  private store: relationalStore.RdbStore | null = null;
  private readonly context: common.UIAbilityContext;
  constructor(context: common.UIAbilityContext) { this.context = context; }

  async open(): Promise<void> {
    const config: relationalStore.StoreConfig = {
      name: 'receipts.db', securityLevel: relationalStore.SecurityLevel.S1
    };
    this.store = await relationalStore.getRdbStore(this.context, config);
    await this.store.executeSql(
      'CREATE TABLE IF NOT EXISTS receipt (' +
      'receipt_id TEXT PRIMARY KEY, title TEXT NOT NULL, ' +
      'category TEXT NOT NULL, archived INTEGER NOT NULL, ' +
      'created_at INTEGER NOT NULL)');
  }

  async insert(item: ReceiptRow): Promise<void> {
    if (this.store === null) { throw new Error('数据库尚未打开'); }
    if (!item.id || item.title.trim().length === 0 ||
      (item.category !== 'invoice' && item.category !== 'travel') ||
      !Number.isFinite(item.createdAt)) { throw new Error('票据字段无效'); }
    const values: relationalStore.ValuesBucket = {
      receipt_id: item.id, title: item.title, category: item.category,
      archived: item.archived ? 1 : 0, created_at: item.createdAt
    };
    await this.store.insert('receipt', values);
  }

  async list(filter: ReceiptQuery, limit: number, offset: number): Promise<ReceiptRow[]> {
    if (this.store === null) { throw new Error('数据库尚未打开'); }
    const size: number = Math.max(1, Math.min(limit, 100));
    const start: number = Math.max(0, offset);
    const where: string[] = [];
    const args: relationalStore.ValueType[] = [];
    if (filter.onlyPending) { where.push('archived = ?'); args.push(0); }
    if (filter.category === 'invoice' || filter.category === 'travel') {
      where.push('category = ?'); args.push(filter.category);
    }
    const direction: string = filter.sort === 'oldest' ? 'ASC' : 'DESC';
    const sql: string = 'SELECT receipt_id, title, category, archived, created_at ' +
      'FROM receipt ' + (where.length > 0 ? 'WHERE ' + where.join(' AND ') : '') +
      ` ORDER BY created_at ${direction}, receipt_id ${direction} LIMIT ? OFFSET ?`;
    args.push(size);
    args.push(start);
    const result: relationalStore.ResultSet = await this.store.querySql(sql, args);
    const rows: ReceiptRow[] = [];
    try {
      while (result.goToNextRow()) {
        const category: string = result.getString(result.getColumnIndex('category'));
        if (category !== 'invoice' && category !== 'travel') { continue; }
        rows.push({
          id: result.getString(result.getColumnIndex('receipt_id')),
          title: result.getString(result.getColumnIndex('title')),
          category: category as ReceiptCategory,
          archived: result.getLong(result.getColumnIndex('archived')) === 1,
          createdAt: result.getLong(result.getColumnIndex('created_at'))
        });
      }
    } finally { result.close(); }
    return rows;
  }
}

这里刻意没有提供 sortSql 或 whereSql 字符串让页面直接传入。用户只能选择受控枚举,仓库再把它们映射为 SQL 条件;筛选值始终作为绑定参数进入数据库。排序方向虽然属于 SQL 字面结构,也被 oldest/newest 白名单限制,不能使用任意输入。每次查询的 ResultSet 放在 finally 中关闭,避免成功分支和异常分支的资源处理行为不一致。

页面协调层如何同时使用两套存储

已有的 ReceiptFilterStore 负责加载迁移并验证 FilterPrefsV2,ReceiptWriteQueue 负责串行保存修改;这一篇不改写它们。新增的 ReceiptScreenService 仅负责汇合:读取偏好后据此查询票据。如果偏好读取失败,业务票据仍然存在于数据库中,页面可使用默认过滤条件继续展示;如果数据库查询失败,也不应删除或重置已经保存的偏好。这两种错误的责任边界要在用户提示中表现出来。

// entry/src/main/ets/model/ReceiptScreenService.ets
import { common } from '@kit.AbilityKit';
import { FilterPrefsV2, defaultFilters, ReceiptFilterStore } from './ReceiptFilterStore';
import { ReceiptRepository, ReceiptRow } from './ReceiptRepository';

export interface ReceiptViewState {
  filters: FilterPrefsV2;
  rows: ReceiptRow[];
  filterWarning: string;
}

export class ReceiptScreenService {
  private readonly filters: ReceiptFilterStore;
  private readonly repo: ReceiptRepository;
  constructor(context: common.UIAbilityContext) {
    this.filters = new ReceiptFilterStore(context);
    this.repo = new ReceiptRepository(context);
  }
  async open(): Promise<ReceiptViewState> {
    let value: FilterPrefsV2 = defaultFilters();
    let warning: string = '';
    try {
      const result = await this.filters.load();
      value = result.value;
      if (result.source === 'repair') { warning = '偏好配置需重新核对'; }
    } catch (e) { warning = '偏好读取失败,使用默认筛选'; }
    await this.repo.open();
    const rows: ReceiptRow[] = await this.repo.list(value, 30, 0);
    return { filters: value, rows: rows, filterWarning: warning };
  }
}

实际业务写操作应分别进入各自的仓库:归档票据调用 ReceiptRepository 的业务更新方法,修改“仅看待归档”则调用此前的 ReceiptPrefSession.update()。不要在一次 UI 点击中假装拥有跨 Preferences 与 RDB 的原子事务。例如用户改了筛选偏好而列表查询失败,应保留已提交的偏好并允许重试列表;不能简单把筛选值回滚,因为这不一定符合用户意图。反过来,业务数据写入成功但偏好写入失败,也不应该撤销真实票据的业务变更。

筛选结果生成与错误隔离时序

从设计评审到可以执行的检查

这个方案的主要风险不在于建表语句,而在于谁拥有数据的真相。可以按下面的顺序做功能回归:先插入 invoice 与 travel 两类测试票据,确认默认条件能查到两类;再选择 onlyPending=true,确认只查询 archived=0;随后分别切换类别和排序,确认数据库记录数量与内容没有变化。最后手动把 receipt_filter_v2 改为非法值,验证偏好层能够提示修复,而票据仓库仍能独立工作。

并发和异常分支还要单独检查。UI 点击可以连续触发两次筛选修改,此时仍应由上篇的串行队列决定保存顺序,而不是新增第二条写入队列。数据库查询分页要使用稳定排序键,示例把 created_at 和 receipt_id 组合在一起;同一时间创建的票据依靠后者消除排序歧义。业务记录较大时可进一步为常用过滤字段建立索引,但索引取舍应结合实际查询计划和数据规模,而不应预先声称获得某个性能收益。

存储职责与回归验收清单

还需要注意两种容易混淆的配置:数据库结构版本由业务库迁移策略维护,偏好 JSON 的 version=2 则用于兼容旧筛选格式,两者没有共同递增的要求。修改票据表字段并不意味着用户筛选格式必然升级。每项迁移应先保证自己的向后兼容,再确定交互层是否需要改变。如果把所有组件绑在一个统一版本号上,一次小小的字段调整就会牵动不相干的存储文件,这与本系列强调的稳定边界相矛盾。

对于审计要求更高的项目,归档状态变更还应记录变更原因、操作时间和操作者标识,这些都是业务记录的一部分;但这里不在筛选偏好中增加审计字段,也不把敏感票据原件以字符串形式写入 Preferences。文本示例展示的是本地记录模型;原件附件需要独立的文件管理、访问权限与生命周期方案,不能由简单数据库行直接替代。

最终的设计结论是:Preferences 负责“用户想怎么看”,关系型业务库负责“实际有哪些票据”,界面协调层负责“怎样把这两者组合起来”。前四篇关于偏好可靠性的工作因此可以保留,不必推翻重做。下一篇将从真实工程的选型角度,对比偏好、关系型数据库及其他本地存储能力,并完成票据归档系列的收尾检查。

官方资料:Preferences 用户首选项接口、关系型数据库查询谓词 RdbPredicates。

Logo

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

更多推荐