三个人一起旅行,李支付了酒店费,周付了交通费,陈临时加入并和两人一起吃饭。打开旧版“旅费账本”,你会发现问题并不在加一个头像:第三篇的 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 渲染控制 可以核对页面随数组替换刷新、列表使用稳定身份键的机制。

这仍然是一个离线记账与转账建议示例,既不与银行结算对账,也不对实际转账做状态确认。多人项目一旦涉及远端协作与历史账同步,还需要记录事务版本、操作时间、成员身份变更与冲突解决,不能拿一张截图代替一致性证明。第五篇会在这个稳定的分摊引擎之上讨论导出与本地持久化,确保版本升级时仍能识别前几篇的旧账结构,而不把“支持多人”变成一次不可逆的账本清空。

Logo

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

更多推荐