ArkTS + HarmonyOS 多人分摊:旅费账本的整数分守恒与转账建议 · 04【共创稿事节】
三个人一起旅行,李支付了酒店费,周付了交通费,陈临时加入并和两人一起吃饭。打开旧版“旅费账本”,你会发现问题并不在加一个头像:第三篇的 share=both/li/zhou 只能表达两人的承担关系。若把每笔费用直接平均除以人数,不仅会改变原来两人时期的历史账,还会让“陈没有参加的住宿”错误地算到陈头上。更麻烦的是,金额用整数分保存后,三人分一分钱不可能均分;如果算法悄悄生成小数分,最后的转账建议一定对不上总账。
这一轮继续沿用 TravelLedger 的费用记录 id/title/cents/payer/revision、编辑删除与单步撤销入口,把第三篇的“谁付款”和“谁承担”彻底拆开。新增 Member 成员表、每笔费用的参与成员和 Allocation 分配策略,计算结果依旧是整数分。页面显示账目、每人的已付和应付、余额以及建议转账;它不是支付工具,不调用真实支付接口,也不承诺不同付款渠道之间自动清算。

一、先规定总账不可破坏的三个等式
继续保留前篇四笔初始费用:住宿 ¥128.00 由李付款,市内交通 ¥48.00 由周付款,饮品 ¥26.00 由李付款,停车 ¥18.00 由周付款。前三篇讨论过把饮品改成“仅李承担”,因此在这一篇的起始场景中,前三项的两人承担关系只有饮品归李,其他三笔仍由李、周均摊。旧四笔总额 ¥220.00,李付 ¥154.00、周付 ¥66.00;李承担 ¥123.00、周承担 ¥97.00,得出“周转李 ¥31.00”。陈被加到成员表,不应改变这个历史结果。
本篇再新增一笔“共同晚餐” ¥90.00,付款人是李,参与人是李、周、陈三人,并采用均摊。三人每人承担 ¥30.00。于是新总额 ¥310.00,李付款累计 ¥244.00、周付款 ¥66.00、陈付款 ¥0.00;李应承担 ¥153.00、周应承担 ¥127.00、陈应承担 ¥30.00。个人余额定义为“已付减去应承担”,因此李余额 +¥91.00、周 -¥61.00、陈 -¥30.00。最直接的清算建议是“周转李 ¥61.00;陈转李 ¥30.00”,两笔转账归零。这是一组可手算的样例输入,不是性能实测数据。
账本层必须一直满足三条守恒关系:所有费用的金额之和等于全员付款额之和;每笔费用分摊结果之和等于该笔费用;所有成员余额之和等于零。任何一个等式被破坏都不能继续生成转账建议。第三条来自前两条,但仍值得单独断言,因为它能快速发现成员遗漏、付款人不存在、参与名单重复或余分处理错误等问题。
本次只讨论人民币单币种账本。cents 使用非负整数分,新增金额仍要求大于零、单笔上限自定且合理;允许某人的应承担金额为零。跨币种汇率、优惠券归属、退款为负费用、已经完成的转账记录和多账期合并,均不在本轮的数据模型内。把这些复杂业务隐藏在一个叫 splitEvenly() 的函数里,只会让后续的财务核对更困难。
二、用明确的分配策略取代字符串分摊模式
最小模型由三部分组成:成员 Member{id,name},费用 Expense{id,title,cents,payer,allocation,revision},以及 Allocation{mode,participantIds,fixedCents,weights}。mode 支持 equal 均摊、fixed 固定金额、ratio 按整数权重比例。最后两个数组和参与列表一一对应:固定方式每个成员给出整数分数;比例方式每个成员给出正整数权重。生产输入要先验证数组长度、唯一成员身份、付款人合法、金额安全范围,再调用计算器,不能靠 UI 下拉选项保证数据合法。
均摊的余分采用固定规则:先给每位参与者 floor(cents / n),剩余的分从参与列表末尾向前逐个补一分。这样两人均摊1分钱时,按 ['li','zhou'] 顺序仍然是李0分、周1分,兼容前篇的约定。比例模式先按整数权重计算下取整的份额,最后以“最大余数法”分配余下的分;余数相同时按参与列表的原始顺序决定,保证同一笔数据每次计算结果一致。固定分摊则必须恰好加总到整笔费用,不能自动凑整来掩盖录入错误。
// model/LedgerModel.ets
export interface Member { id: string; name: string; }
export type SplitMode = 'equal' | 'fixed' | 'ratio';
export interface Allocation {
mode: SplitMode;
participantIds: string[];
fixedCents: number[];
weights: number[];
}
export interface Expense {
id: string;
title: string;
cents: number;
payer: string;
allocation: Allocation;
revision: number;
}
export interface BalanceRow {
id: string;
paid: number;
owed: number;
balance: number;
}
export interface Transfer { from: string; to: string; cents: number; }
export const MEMBERS: Member[] = [
{ id: 'li', name: '李' }, { id: 'zhou', name: '周' }, { id: 'chen', name: '陈' }
];
export function equal(ids: string[]): Allocation {
return { mode: 'equal', participantIds: [...ids], fixedCents: [], weights: [] };
}
下面的计算器和 UI 无关。它不处理按钮、不弹 Toast、不读取存储,输入就是费用和成员,输出是合法的整数分份额。为避免 number 精度边界造成静默错误,示例单笔上限设为一亿分,权重总和不超过一万。分配函数统一检查 Number.isSafeInteger;还要检查参与列表不包含重复 ID,并且每一位参与者都存在于账本成员中。对更大财务规模或跨币种场景,应该采用专门的定点十进制金额库或后端核算,而不是放松这个校验。
// utils/AllocationEngine.ets
import { Allocation, Expense, Member, BalanceRow, Transfer } from '../model/LedgerModel';
export function splitExpense(e: Expense, members: Member[]): number[] {
const ids = e.allocation.participantIds;
const n = ids.length;
if (!Number.isSafeInteger(e.cents) || e.cents <= 0 || e.cents > 100000000 ||
n === 0 || new Set(ids).size !== n ||
ids.some((id: string) => !members.some((m: Member) => m.id === id))) {
throw new Error('费用或参与成员不合法');
}
const spec: Allocation = e.allocation;
if (spec.mode === 'fixed') {
if (spec.fixedCents.length !== n || spec.fixedCents.some((v: number) =>
!Number.isSafeInteger(v) || v < 0) ||
spec.fixedCents.reduce((a: number, b: number) => a + b, 0) !== e.cents) {
throw new Error('固定分配总额不一致');
}
return [...spec.fixedCents];
}
if (spec.mode === 'equal') {
const base = Math.floor(e.cents / n);
const result: number[] = ids.map(() => base);
let remainder = e.cents - base * n;
for (let i = n - 1; i >= 0 && remainder > 0; i--) {
result[i] += 1; remainder--;
}
return result;
}
if (spec.mode !== 'ratio' || spec.weights.length !== n ||
spec.weights.some((w: number) => !Number.isSafeInteger(w) || w <= 0)) {
throw new Error('比例配置不合法');
}
const weightSum = spec.weights.reduce((a: number, b: number) => a + b, 0);
if (weightSum > 10000) { throw new Error('权重合计超过上限'); }
const shares: number[] = spec.weights.map((w: number) =>
Math.floor(e.cents * w / weightSum));
const remainders = spec.weights.map((w: number, index: number) => ({
index, part: (e.cents * w) % weightSum
}));
remainders.sort((a, b) => b.part - a.part || a.index - b.index);
let rest = e.cents - shares.reduce((a: number, b: number) => a + b, 0);
for (let i = 0; i < rest; i++) { shares[remainders[i].index] += 1; }
return shares;
}
export function balances(expenses: Expense[], members: Member[]): BalanceRow[] {
const result: BalanceRow[] = members.map((m: Member) =>
({ id: m.id, paid: 0, owed: 0, balance: 0 }));
expenses.forEach((e: Expense) => {
const payer = result.find((r: BalanceRow) => r.id === e.payer);
if (!payer) { throw new Error('付款人不在成员表'); }
const amounts = splitExpense(e, members);
payer.paid += e.cents;
e.allocation.participantIds.forEach((id: string, i: number) => {
const row = result.find((r: BalanceRow) => r.id === id);
if (!row) { throw new Error('承担人缺失'); }
row.owed += amounts[i];
});
});
result.forEach((r: BalanceRow) => { r.balance = r.paid - r.owed; });
if (result.some((r: BalanceRow) => !Number.isSafeInteger(r.paid) ||
!Number.isSafeInteger(r.owed)) ||
result.reduce((sum: number, r: BalanceRow) => sum + r.balance, 0) !== 0) {
throw new Error('金额超界或账本不守恒');
}
return result;
}
export function planTransfers(rows: BalanceRow[]): Transfer[] {
const debtors = rows.filter((r: BalanceRow) => r.balance < 0)
.map((r: BalanceRow) => ({ id: r.id, left: -r.balance }));
const creditors = rows.filter((r: BalanceRow) => r.balance > 0)
.map((r: BalanceRow) => ({ id: r.id, left: r.balance }));
const output: Transfer[] = [];
let d = 0; let c = 0;
while (d < debtors.length && c < creditors.length) {
const cents = Math.min(debtors[d].left, creditors[c].left);
if (cents > 0) { output.push({ from: debtors[d].id, to: creditors[c].id, cents }); }
debtors[d].left -= cents; creditors[c].left -= cents;
if (debtors[d].left === 0) { d++; }
if (creditors[c].left === 0) { c++; }
}
if (d !== debtors.length || c !== creditors.length) { throw new Error('转账方案未平衡'); }
return output;
}
planTransfers() 采用顺序贪心匹配欠款者与应收者,输出一组可执行的转账建议,不宣称转账笔数一定是全局最少。如果产品未来要求“不允许陌生成员之间直接转账”或“优先减少银行手续费”,需要在余额守恒之外增加路径约束与相应的优化问题。把两类问题分开,比把“结算”一词同时用于余额计算和转账路径搜索更加准确。
三、在 ArkUI 页面以派生值展示多人账目
这篇仍使用 ArkUI V1 的 @State 和 ForEach,属于基本的 Stage 应用页面构建方式,不要求未公开的系统服务。为了兼容前两篇的业务身份,历史四笔费用仍保留 e1~e4 和原来的付款人;其中饮品通过 equal(['li']) 实现“仅李承担”,其余采用 equal(['li','zhou'])。第五笔 e5 是三人共同晚餐,因此在页面加载时加入,便于初始界面完整展示三个成员及两笔转账建议。点击“改为固定承担”将晚餐切换为李40元、周20元、陈30元;再点击“还原三人均摊”会恢复每人30元,并重新生成余额。这次变更只是内存中的业务展示,不能在没有实现持久化的情况下写成“已经保存到账本”。
// pages/Index.ets
import { Member, Expense, MEMBERS, equal, Allocation, BalanceRow, Transfer } from '../model/LedgerModel';
import { balances, planTransfers } from '../utils/AllocationEngine';
@Entry
@Component
struct Index {
@State expenses: Expense[] = [
{ id: 'e1', title: '住宿', cents: 12800, payer: 'li',
allocation: equal(['li', 'zhou']), revision: 0 },
{ id: 'e2', title: '市内交通', cents: 4800, payer: 'zhou',
allocation: equal(['li', 'zhou']), revision: 0 },
{ id: 'e3', title: '饮品', cents: 2600, payer: 'li',
allocation: equal(['li']), revision: 0 },
{ id: 'e4', title: '停车', cents: 1800, payer: 'zhou',
allocation: equal(['li', 'zhou']), revision: 0 },
{ id: 'e5', title: '共同晚餐', cents: 9000, payer: 'li',
allocation: equal(['li', 'zhou', 'chen']), revision: 0 }
];
@State notice: string = '5笔账目 · 3位成员';
private members: Member[] = MEMBERS;
private money(cents: number): string { return `¥${(cents / 100).toFixed(2)}`; }
private label(id: string): string {
const member = this.members.find((m: Member) => m.id === id);
return member ? member.name : '未知成员';
}
private rows(): BalanceRow[] { return balances(this.expenses, this.members); }
private transfers(): Transfer[] { return planTransfers(this.rows()); }
private changeDinner(fixed: boolean): void {
const alloc: Allocation = fixed ? {
mode: 'fixed', participantIds: ['li', 'zhou', 'chen'],
fixedCents: [4000, 2000, 3000], weights: []
} : equal(['li', 'zhou', 'chen']);
const next = this.expenses.map((e: Expense) => e.id === 'e5'
? { ...e, allocation: alloc, revision: e.revision + 1 } : e);
try {
balances(next, this.members); // 先验算候选数据
this.expenses = next; this.notice = fixed ? '晚餐改为固定承担' : '已恢复三人均摊';
} catch (error) { this.notice = '变更未通过金额守恒校验'; }
}
build() {
Scroll() {
Column({ space: 16 }) {
Text('旅费账本').fontSize(30).fontWeight(FontWeight.Bold).fontColor('#263D6C');
Text('三人成本分摊 · 余额与转账建议').fontSize(13).fontColor('#667A9D');
Column({ space: 6 }) {
Text('全部支出').fontColor('#E2E8FA').fontSize(14);
Text(this.money(this.expenses.reduce((s: number, e: Expense) => s + e.cents, 0)))
.fontSize(32).fontColor(Color.White).fontWeight(FontWeight.Bold);
Text('5笔费用 · 3名成员').fontColor('#E2E8FA');
}.width('100%').padding(20).alignItems(HorizontalAlign.Start)
.backgroundColor('#3A55A4').borderRadius(18);
Text('成员余额(已付-承担)').fontSize(20).fontWeight(FontWeight.Bold);
ForEach(this.rows(), (r: BalanceRow) => {
Row() {
Text(this.label(r.id)).width(40);
Text(`已付 ${this.money(r.paid)} · 承担 ${this.money(r.owed)}`)
.fontSize(12).layoutWeight(1);
Text(`${r.balance >= 0 ? '+' : '−'}${this.money(Math.abs(r.balance))}`)
.fontColor(r.balance >= 0 ? '#187F72' : '#A65C67');
}.padding(12).backgroundColor(Color.White).borderRadius(12);
}, (r: BalanceRow) => r.id);
Text('建议转账').fontSize(20).fontWeight(FontWeight.Bold);
ForEach(this.transfers(), (t: Transfer) => {
Text(`${this.label(t.from)} → ${this.label(t.to)} ${this.money(t.cents)}`)
.padding(12).backgroundColor('#E8F1FF').borderRadius(10);
}, (t: Transfer) => `${t.from}_${t.to}`);
Text('共同晚餐 · 李付款 ¥90.00').fontSize(17);
Row({ space: 10 }) {
Button('固定 40/20/30').onClick(() => this.changeDinner(true));
Button('恢复三人均摊').onClick(() => this.changeDinner(false));
}
Text(this.notice).fontColor('#637B9F').fontSize(12);
Text('费用明细').fontSize(20).fontWeight(FontWeight.Bold);
ForEach(this.expenses, (e: Expense) => {
Row() {
Text(e.title).layoutWeight(1);
Text(`${this.label(e.payer)}付款 · ${this.money(e.cents)}`);
}.padding(12).backgroundColor(Color.White).borderRadius(10);
}, (e: Expense) => `${e.id}:${e.revision}`);
}.padding(18).width('100%');
}.height('100%').backgroundColor('#F3F7FD');
}
}


四、把守恒测试当成需求,而不是调试补丁
最基本的验证是从旧数据开始:先只使用四笔费用和李、周两人的原有参与名单,陈虽存在成员表但应付、已付、余额都为零,历史结果仍是周转李31元。加入三人晚餐后核对总额310元、三人余额+91/-61/-30,以及两笔转账61和30;切换到固定40/20/30后,新余额应是李+81元、周-51元、陈-30元,转账建议随之变为周转李51元、陈转李30元。每次点击都可以人工检查“应付和=总额”与“余额和=0”,而不是只观察三张漂亮的金额卡片。
接着构造一笔只有一分钱的三人均摊。按本文从末尾向前分配余分的规则,['li','zhou','chen'] 将得到李0、周0、陈1分。调换参与列表顺序,余分接收者会改变;这提醒我们参与者顺序在当前规则中具有业务含义,未来若数据从服务器返回,应当固定排序策略,不允许 UI 列表偶然重排导致分配结果变化。再构造比例为2:1:1的101分钱,期望份额是51、25、25分:四个权重单位分配后的最大余数归第一位。最后为固定分摊输入30、30、29元,故意少一元,必须报错而不是自动补到某个人头上。
非法数据也要覆盖:付款人不在成员表、参加人员有重复ID、参与人数为零、权重为负、固定分配数组长度与参与人数不一致、金额超过上限、所有成员的累计付款超出安全整数范围,以及只有债务方没有应收方的异常输入。splitExpense() 的报错应阻止界面提交,不应在异常发生后退回旧的“平均除以三”逻辑。对连续快速切换的交互,先构造 next,校验通过后整份替换 expenses,同时提升 revision;这样比原地更改嵌套的 allocation 字段更容易触发 ArkUI 组件更新。
这里的“撤销”仍然沿用第二篇的单步快照设计:新增、编辑、删除或更改分配方案前保存旧数组的独立副本,恢复时整份替换,避免浅拷贝对嵌套 allocation 数组形成引用共享。上面给出本轮的可复用账目计算与两种切换入口,没有把单步撤销的 UI 代码重复贴一遍;把前篇撤销方法移植进当前数据结构时,应对每个 allocation 的参与数组、固定金额数组和权重数组做深层复制,不能继续用旧的 {...e} 作为完整快照。
五、多人账本的工程边界
从两人扩展到三人,最重要的成果不是多显示了一行“陈”,而是分摊算法不再知道成员数量:每笔费用明确指定参与名单与策略,付款和承担独立累计,余额由计算而不是由页面状态缓存维护。把这组纯函数放在 utils/AllocationEngine.ets,使将来支持四人、五人时不必重新写一个嵌套条件分支。参考华为 ArkUI @State 状态管理 和 ForEach 渲染控制 可以核对页面随数组替换刷新、列表使用稳定身份键的机制。
这仍然是一个离线记账与转账建议示例,既不与银行结算对账,也不对实际转账做状态确认。多人项目一旦涉及远端协作与历史账同步,还需要记录事务版本、操作时间、成员身份变更与冲突解决,不能拿一张截图代替一致性证明。第五篇会在这个稳定的分摊引擎之上讨论导出与本地持久化,确保版本升级时仍能识别前几篇的旧账结构,而不把“支持多人”变成一次不可逆的账本清空。
更多推荐

所有评论(0)