ArkData + HarmonyOS 账本快照:三人分摊的版本迁移与数据恢复 · 05【共创稿事节】
旅费账本已经会算账:第四篇加入陈以后,李、周、陈三人能够按参与名单均摊、固定分配和比例分配,再根据“已付减应承担”给出转账建议。但一个实际交付问题一直没有解决:这些账目能不能安全地跨启动保存,以及升级后还能不能按原来的规则算出同样结果? 如果应用退出就丢掉五笔费用,前面精心处理的余分守恒便只存在于一次运行中;如果把整个页面状态随意序列化,明天修改模型字段又可能导致无法解析或悄悄算错。
第五篇继续使用 TravelLedger 项目、Member{id,name}、Expense{id,title,cents,payer,allocation,revision} 和 Allocation{mode,participantIds,fixedCents,weights};示例仍是李、周、陈三人,五笔费用分别为住宿128元、交通48元、饮品26元、停车18元、共同晚餐90元,总额310元。它不改变上一轮的分摊算法,也不把本地 Preferences 伪装成云端数据库。要做的是在数据入口加入“版本号—校验—恢复—发布”的一条封闭路径,保存失败后保持最后一份可信快照。

一、先选择持久化边界:整个账本一份快照
Preferences 非常适合本项目这种体积小、单设备、低频修改的配置型数据,但它并不提供关系数据库的多表事务。若把成员分别存 members、把费用存 expenses、再把当前账期存 period,需要连续写三个键;其中一个写入失败,下一次启动可能读取到不同时间点的数据。设计上更稳妥的做法是把一个账本作为单份 JSON 快照,统一写入一个键,所有派生余额在加载之后重算,绝不持久化容易过期的余额和转账建议。
快照字段包括 schemaVersion、revision、members、expenses。版本是格式版本而不是每笔账的修改版本:schemaVersion=2 表示多人模式,记录明确的 allocation;revision 是整个账本的逻辑修订序号,每成功提交一次加一。费用自己的 revision 仍沿用前篇的局部编辑版本,用于发现编辑覆盖。这两种版本不能混用:同一账本格式可以有很多次业务修改,数据格式升级也不意味着用户增加了一条新费用。
第一轮和第二轮只使用李、周两个人时,旧资料里曾出现 share=both/li/zhou 的描述;第四篇换成了明确的参与成员清单。本篇可以兼容一个明确声明为 schemaVersion=1 的历史导出结构,将 both 映射为 ['li','zhou'],将 li 或 zhou 映射为单一参与人。但以前的章节没有声称已经把那个旧结构写入 Preferences,因此这里的迁移是可用于导入旧版备份的兼容规则,不能叙述成所有用户手机里已经存在的老文件。

二、所有写入之前先通过模型校验
后续任何版本升级都不能把 JSON.parse 成功当成“数据有效”。Expense.cents 必须是安全的正整数分,付款人属于已声明成员;allocation.participantIds 不能重复且必须存在;固定金额要求和精确等于该笔费用;比例权重必须是正整数;同一账本的费用 ID 应唯一。校验应该站在持久化边界,而不是散落在按钮点击函数里,否则下次加入导入功能时很容易绕过校验。原来的 splitExpense 和 balances 已具备核心算术守恒检验,读取前还需要结构层面的格式检查。
下面把新格式的关键类型和校验入口收在同一处。示例采用明确字段,便于以后写测试,不用通用的动态映射器隐藏业务错误。需要注意的是:静态 as LedgerSnapshot 类型断言只帮助编辑器识别字段,并不能保证外部 JSON 完全可靠,因此读取后必须继续调用 validateSnapshot()。对于不可信文件导入,生产项目还应在对象属性访问前逐层检查基本结构及字段类型,严禁直接执行解析结果中的字符串或任意字段。
// model/LedgerSnapshot.ets
import { Expense, Member } from './LedgerModel';
export interface LedgerSnapshot {
schemaVersion: number;
revision: number;
members: Member[];
expenses: Expense[];
}
export function validateSnapshot(data: LedgerSnapshot): void {
if (data.schemaVersion !== 2 || !Number.isSafeInteger(data.revision) ||
data.revision < 0 || !Array.isArray(data.members) ||
!Array.isArray(data.expenses) || data.members.length === 0) {
throw new Error('账本版本或基本结构无效');
}
const memberIds: string[] = data.members.map((m: Member) => m.id);
if (new Set(memberIds).size !== memberIds.length ||
memberIds.some((id: string) => id.trim().length === 0)) {
throw new Error('成员标识重复或为空');
}
const expenseIds: string[] = [];
data.expenses.forEach((e: Expense) => {
if (expenseIds.includes(e.id) || !memberIds.includes(e.payer) ||
!Number.isSafeInteger(e.cents) || e.cents <= 0 ||
e.cents > 100000000 || !Number.isSafeInteger(e.revision) ||
e.revision < 0) { throw new Error('费用字段不合法'); }
expenseIds.push(e.id);
const ids: string[] = e.allocation.participantIds;
if (!Array.isArray(ids) || ids.length === 0 ||
new Set(ids).size !== ids.length ||
ids.some((id: string) => !memberIds.includes(id))) {
throw new Error('参与列表无效');
}
});
}
这段基础检验不是上一轮的 splitExpense() 的替代品:fixedCents 总和是否等于金额、比例权重是否合法和余分如何分配,仍需逐条调用前一篇的算法验证。在真实项目中推荐仓储加载后对每笔费用执行 splitExpense(e,members),再运行 balances 并检查余额总和为零,任何一个环节失败都拒绝发布新状态。这样同一份账本无论来自用户输入还是磁盘恢复,都经过相同的业务不变量。

三、用一个 Repository 串行完成读取和提交
Preferences 的 put 更新当前实例的值,再调用 flush 进行持久化,适合较小的单文件配置。由于仓储写入是异步的,如果用户连续点击“保存”,两次操作可能交错完成:后一次请求先写入、前一次随后落盘,界面显示了更新的账目,磁盘却保留旧版本。为避免这种情况,页面必须在提交期间禁用再次提交,或者仓储建立串行队列;这里采用更容易核查的前者。saving 不是营销式加载动画,而是对事务边界的真实控制。
// data/LedgerStore.ets
import { preferences } from '@kit.ArkData';
import { LedgerSnapshot, validateSnapshot } from '../model/LedgerSnapshot';
import { splitExpense } from '../utils/AllocationEngine';
export class LedgerStore {
private db: preferences.Preferences;
private readonly KEY: string = 'ledger_snapshot_v2';
constructor(context: Context) {
this.db = preferences.getPreferencesSync(context, { name: 'travel_ledger' });
}
load(): LedgerSnapshot | undefined {
const content: string = this.db.getSync(this.KEY, '') as string;
if (content.length === 0) { return undefined; }
const parsed: LedgerSnapshot = JSON.parse(content) as LedgerSnapshot;
validateSnapshot(parsed);
parsed.expenses.forEach(e => { splitExpense(e, parsed.members); });
return parsed;
}
async save(next: LedgerSnapshot): Promise<void> {
validateSnapshot(next);
next.expenses.forEach(e => { splitExpense(e, next.members); });
await this.db.put(this.KEY, JSON.stringify(next));
await this.db.flush();
}
}
该仓储只负责单个版本键。首次启动时若 load() 返回 undefined,页面才初始化本篇的三人五笔示例账本。若存在键但解析失败,不能一概覆盖成空账本,因为那会把“存储损坏”伪装成“新用户”;应显示“账本无法恢复”,提供明确的恢复或手动重置动作。若后续支持历史备份,应先迁移到临时对象、运行完整校验,再写入新键。尤其不要在尚未成功写入 v2 时删除 v1,迁移的失败路径比成功路径更能反映数据安全性。
页面不应该边显示新余额边等待存储结果。实现上可以先构造 next,验证并保存成功后才替换 @State expenses;如果失败就保留原先的视图。对于已在界面提前展示草稿的交互,则必须显示“未保存”状态,不能假装成功。继续保留前篇已有的“编辑、删除、单步撤销”机制,但提交成功后撤销栈也要与当前 revision 同步,否则用户可能撤销一个早已迁移的数据版本。
// pages/Index.ets 的提交核心;其余布局复用连载04/06
private async commit(nextExpenses: Expense[]): Promise<void> {
if (this.saving) { return; }
this.saving = true;
const next: LedgerSnapshot = {
schemaVersion: 2,
revision: this.ledgerRevision + 1,
members: this.members,
expenses: nextExpenses
};
try {
await this.store.save(next);
this.expenses = [...nextExpenses];
this.ledgerRevision = next.revision;
this.notice = '保存成功';
} catch (err) {
this.notice = '保存失败,当前账本未提交';
} finally {
this.saving = false;
}
}
其中 store 是从页面 UIAbility 上下文初始化的 LedgerStore,this.members 和 this.expenses 来自上一篇的三人模型,ledgerRevision、saving、notice 为页面自己的状态字段。实际接入时应确保初始化完成再允许操作;如果页面离开后仍有异步保存进行,离开逻辑需要处理保存结果与页面销毁之间的关系。范例特别将 nextExpenses 复制后再发布,保证 ArkUI V1 的数组状态更新能够触发依赖组件重新计算。
四、迁移数据与回滚策略如何落地
旧备份的 share='both' 只能表达李和周两人,它不能自动扩大为李、周、陈均摊。迁移应按旧数据实际语义映射:both 为 ['li','zhou'],li 为 ['li'],zhou 为 ['zhou'];allocation.mode 统一设为 equal,fixedCents 和 weights 置空,同时保留原本的 payer、cents 和 revision。迁移后计算不变量仍应成立,随后才允许加入新成员和新费用。这样不会让一次应用升级悄悄改变历史欠款。下面把“旧版导出记录转换为新版参与成员模型”的最小可执行部分写清楚。导入动作应先在临时对象上完成,不应把字段映射过程中生成的半成品直接赋值到页面 @State。
// data/LegacyMigration.ets:仅供显式导入旧版备份
import { Allocation, Expense, Member } from '../model/LedgerModel';
import { LedgerSnapshot, validateSnapshot } from '../model/LedgerSnapshot';
export type OldShare = 'both' | 'li' | 'zhou';
export interface OldExpense {
id: string; title: string; cents: number; payer: string;
share: OldShare; revision: number;
}
export interface OldLedger { schemaVersion: number; expenses: OldExpense[]; }
export function migrateLegacy(input: OldLedger): LedgerSnapshot {
if (input.schemaVersion !== 1 || !Array.isArray(input.expenses)) {
throw new Error('不是可导入的旧版账本');
}
const members: Member[] = [
{ id: 'li', name: '李' }, { id: 'zhou', name: '周' }
];
const expenses: Expense[] = input.expenses.map((old: OldExpense) => {
const ids: string[] = old.share === 'both' ? ['li', 'zhou'] :
old.share === 'li' ? ['li'] : old.share === 'zhou' ? ['zhou'] : [];
if (ids.length === 0) { throw new Error('旧版承担范围不合法'); }
const allocation: Allocation = { mode: 'equal', participantIds: ids,
fixedCents: [], weights: [] };
return { id: old.id, title: old.title, cents: old.cents,
payer: old.payer, allocation: allocation, revision: old.revision };
});
const next: LedgerSnapshot = { schemaVersion: 2, revision: 0,
members: members, expenses: expenses };
validateSnapshot(next);
return next;
}
单键快照不是万无一失的数据库事务。写入前在内存中保留最后成功的快照,可以保护 UI 不误发布;但如果系统在写盘期间崩溃,仍要依据系统 API 的文件一致性保障以及恢复时的校验机制判断能否读取。要提供可恢复的历史版本,可以另外维护经过验证的备份或采用真正的数据库事务,而不是自称“把 flush 放在 try 里就保证绝对不丢数据”。一个有用的升级验证方法是准备三份输入:合法 v2、可迁移的 v1 以及故意缺少参与成员的损坏数据,分别确认能读取、能转换和能拒绝。

还有一个业务陷阱:总金额相同不代表账本正确。例如把共同晚餐的付款人从李改成周,总额仍是310元,三个成员的余额却会改变。验证恢复结果时,必须比较成员 ID、每笔费用 ID、付款人、参与名单、分配模式、金额、单据修订号以及全局修订号。若仅断言总额,许多真正危险的迁移错误都会被漏掉。上线前应把这些字段组成稳定的输入样例,逐项比较旧版与迁移后结果,确保未参与消费的人不会新增欠款。
五、恢复路径的完整手工检查
在初始账本中,五笔费用合计310元。依据上一篇的均摊逻辑,李累计付款244元,周付款66元,陈付款0元;李承担153元,周承担127元,陈承担30元;余额分别是 +91、-61、-30 元,建议“周转李61元、陈转李30元”。将该快照写入、退出并重新进入页面后,余额应重新计算得到同一结果,而不是读取旧的缓存数字。改变共同晚餐付款人后重新计算,检查金额总和依旧守恒、单据修订号正确递增;再构造写入异常,核对页面继续展示最后一次成功提交的数据。
恢复页面的状态最好分成“初始化中、已恢复、保存中、保存失败、数据无效”,而不是一个布尔变量 loaded。对于第一种情况,禁用编辑按钮;对于保存失败,让用户有机会再次提交草稿;对于数据无效,停止生成结算建议,避免把无法证实的欠款显示成确定金额。测试时还应尝试空成员表、重复费用 ID、不存在的付款人、参与名单重复、负金额、固定分配差一分钱、比例权重全为零等非法输入,并断言无一进入正式账本。
这是本地单账本的持久化方案,不含登录同步、服务器冲突处理、敏感账目的加密存储或支付结算。它的价值是将“数据正确”从某个页面运行时的偶然状态,转化为可验证的输入输出契约。下一篇可以围绕导出、结算结果快照、异常恢复提示和项目收尾展开,但不能在没有真实测试的情况下声称已经通过设备兼容或金融级安全审计。
参考华为官方文档:User Preferences 持久化指南、Preferences API 参考、ArkUI 状态管理概览。
更多推荐




所有评论(0)