ArkData + HarmonyOS 票据归档:业务数据与筛选偏好分层(连载05/06)【共创稿事节】
“把归档票据也塞进 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 负责“用户想怎么看”,关系型业务库负责“实际有哪些票据”,界面协调层负责“怎样把这两者组合起来”。前四篇关于偏好可靠性的工作因此可以保留,不必推翻重做。下一篇将从真实工程的选型角度,对比偏好、关系型数据库及其他本地存储能力,并完成票据归档系列的收尾检查。
更多推荐

所有评论(0)