Flutter实战:打造带账单分摊功能的记账本,支持多人AA与智能结算

聚餐、旅行、合租…生活中经常需要多人分摊费用。本文将用Flutter实现一款功能完善的记账本应用,不仅支持日常收支记录,还能智能计算多人分摊和最优结算方案。

效果预览图
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

功能特性

  • 💰 收支记录:支持多种分类的收入/支出记录
  • 👥 多人分摊:指定付款人和参与者,自动计算人均费用
  • 🧮 智能结算:自动生成最优还款方案
  • 📊 统计分析:按分类展示支出占比
  • 💾 本地存储:数据持久化到本地

应用架构

核心逻辑

页面层

数据层

BillRecord

SharedPreferences

CategoryData

收支分类

HomePage

账单列表

SplitPage

分摊结算

StatsPage

统计图表

SettingsPage

成员管理

余额计算

结算算法

分类统计

占比计算

数据模型设计

账单记录

每条账单记录包含基本信息和分摊信息:

class BillRecord {
  final String id;           // 唯一标识
  final String title;        // 标题/备注
  final double amount;       // 金额
  final String category;     // 分类
  final bool isExpense;      // 是否支出
  final DateTime date;       // 日期
  final List<String> participants;  // 参与分摊的人
  final String payer;        // 付款人

  BillRecord({
    required this.id,
    required this.title,
    required this.amount,
    required this.category,
    required this.isExpense,
    required this.date,
    this.participants = const [],
    this.payer = '',
  });

  // JSON序列化
  Map<String, dynamic> toJson() => {
    'id': id,
    'title': title,
    'amount': amount,
    'category': category,
    'isExpense': isExpense,
    'date': date.toIso8601String(),
    'participants': participants,
    'payer': payer,
  };

  factory BillRecord.fromJson(Map<String, dynamic> json) => BillRecord(
    id: json['id'],
    title: json['title'],
    amount: json['amount'],
    category: json['category'],
    isExpense: json['isExpense'],
    date: DateTime.parse(json['date']),
    participants: List<String>.from(json['participants'] ?? []),
    payer: json['payer'] ?? '',
  );
}

分类数据

预定义支出和收入的分类,每个分类包含名称、图标和颜色:

class CategoryData {
  static const List<Map<String, dynamic>> expenseCategories = [
    {'name': '餐饮', 'icon': Icons.restaurant, 'color': Colors.orange},
    {'name': '交通', 'icon': Icons.directions_car, 'color': Colors.blue},
    {'name': '购物', 'icon': Icons.shopping_bag, 'color': Colors.pink},
    {'name': '娱乐', 'icon': Icons.movie, 'color': Colors.purple},
    {'name': '住房', 'icon': Icons.home, 'color': Colors.teal},
    {'name': '医疗', 'icon': Icons.local_hospital, 'color': Colors.red},
    {'name': '教育', 'icon': Icons.school, 'color': Colors.indigo},
    {'name': '其他', 'icon': Icons.more_horiz, 'color': Colors.grey},
  ];

  static const List<Map<String, dynamic>> incomeCategories = [
    {'name': '工资', 'icon': Icons.work, 'color': Colors.green},
    {'name': '奖金', 'icon': Icons.card_giftcard, 'color': Colors.amber},
    {'name': '投资', 'icon': Icons.trending_up, 'color': Colors.blue},
    {'name': '兼职', 'icon': Icons.laptop, 'color': Colors.cyan},
    {'name': '其他', 'icon': Icons.more_horiz, 'color': Colors.grey},
  ];
}

分类图标一览:

类型 分类 图标 颜色
支出 餐饮 🍽️ 橙色
支出 交通 🚗 蓝色
支出 购物 🛍️ 粉色
支出 娱乐 🎬 紫色
支出 住房 🏠 青色
收入 工资 💼 绿色
收入 奖金 🎁 琥珀色
收入 投资 📈 蓝色

账单分摊算法

核心概念

分摊计算涉及三个关键概念:

付款金额 paid

余额 balance

应付金额 shouldPay

balance > 0

别人欠他钱

他欠别人钱

余额 = 实际支付金额 − 应付金额 余额 = 实际支付金额 - 应付金额 余额=实际支付金额应付金额

  • 余额 > 0:说明多付了,别人欠他钱
  • 余额 < 0:说明少付了,他欠别人钱

余额计算

// 计算每个人的收支情况
final Map<String, double> paid = {};     // 每人支付的总额
final Map<String, double> shouldPay = {}; // 每人应付的总额

for (var m in members) {
  paid[m] = 0;
  shouldPay[m] = 0;
}

// 遍历所有分摊记录
for (var r in splitRecords) {
  // 付款人支付了全部金额
  paid[r.payer] = (paid[r.payer] ?? 0) + r.amount;
  
  // 每个参与者应付的金额(平均分摊)
  final perPerson = r.amount / r.participants.length;
  for (var p in r.participants) {
    shouldPay[p] = (shouldPay[p] ?? 0) + perPerson;
  }
}

// 计算余额
final Map<String, double> balance = {};
for (var m in members) {
  balance[m] = (paid[m] ?? 0) - (shouldPay[m] ?? 0);
}

最优结算算法

结算的目标是让所有人的余额归零,即欠钱的人把钱还给被欠钱的人。

遍历所有欠款人

获取欠款金额

遍历所有债权人

债权人有余额?

计算转账金额

记录结算方案

更新双方余额

List<Map<String, dynamic>> _calculateSettlements(Map<String, double> balance) {
  final settlements = <Map<String, dynamic>>[];
  final debtors = <String>[];   // 欠钱的人(余额为负)
  final creditors = <String>[]; // 被欠钱的人(余额为正)

  // 分类
  balance.forEach((name, amount) {
    if (amount < -0.01) debtors.add(name);
    if (amount > 0.01) creditors.add(name);
  });

  // 欠钱的人依次还给被欠钱的人
  for (var debtor in debtors) {
    var debt = -(balance[debtor] ?? 0);  // 欠款金额(取正)
    
    for (var creditor in creditors) {
      if (debt <= 0.01) break;  // 已还清
      
      var credit = balance[creditor] ?? 0;  // 债权金额
      if (credit <= 0.01) continue;  // 该债权人已收齐
      
      // 转账金额 = min(欠款, 债权)
      var amount = debt < credit ? debt : credit;
      
      // 记录结算方案
      settlements.add({
        'from': debtor,
        'to': creditor,
        'amount': amount,
      });
      
      // 更新余额
      balance[debtor] = (balance[debtor] ?? 0) + amount;
      balance[creditor] = (balance[creditor] ?? 0) - amount;
      debt -= amount;
    }
  }

  return settlements;
}

分摊示例

假设三人聚餐,消费情况如下:

项目 金额 付款人 参与者
午餐 ¥300 小明 小明、小红、小刚
晚餐 ¥450 小红 小明、小红、小刚
零食 ¥60 小刚 小明、小刚

计算过程:

小 明 p a i d = 300 , 小 明 s h o u l d = 100 + 150 + 30 = 280 小 红 p a i d = 450 , 小 红 s h o u l d = 100 + 150 = 250 小 刚 p a i d = 60 , 小 刚 s h o u l d = 100 + 150 + 30 = 280 \begin{aligned} 小明_{paid} &= 300, \quad 小明_{should} = 100 + 150 + 30 = 280 \\ 小红_{paid} &= 450, \quad 小红_{should} = 100 + 150 = 250 \\ 小刚_{paid} &= 60, \quad 小刚_{should} = 100 + 150 + 30 = 280 \end{aligned} paidpaidpaid=300,should=100+150+30=280=450,should=100+150=250=60,should=100+150+30=280

余额:

  • 小明: 300 − 280 = + 20 300 - 280 = +20 300280=+20(被欠20元)
  • 小红: 450 − 250 = + 200 450 - 250 = +200 450250=+200(被欠200元)
  • 小刚: 60 − 280 = − 220 60 - 280 = -220 60280=220(欠220元)

结算方案:

  • 小刚 → 小明:¥20
  • 小刚 → 小红:¥200

UI组件实现

账单列表(按日期分组)

Widget build(BuildContext context) {
  // 按日期分组
  final grouped = <String, List<BillRecord>>{};
  for (var r in records) {
    final key = '${r.date.year}-${r.date.month}-${r.date.day}';
    grouped.putIfAbsent(key, () => []).add(r);
  }

  return CustomScrollView(
    slivers: [
      // 顶部统计卡片
      SliverAppBar(
        expandedHeight: 180,
        flexibleSpace: FlexibleSpaceBar(
          background: Container(
            decoration: BoxDecoration(
              gradient: LinearGradient(
                colors: [Colors.green[400]!, Colors.green[700]!],
              ),
            ),
            child: Column(
              children: [
                Text('本月支出: ¥${monthExpense.toStringAsFixed(2)}'),
                Text('本月收入: ¥${monthIncome.toStringAsFixed(2)}'),
                Text('结余: ¥${(monthIncome - monthExpense).toStringAsFixed(2)}'),
              ],
            ),
          ),
        ),
      ),
      // 账单列表
      SliverList(
        delegate: SliverChildBuilderDelegate(
          (context, index) {
            final dateKey = sortedKeys[index];
            final dayRecords = grouped[dateKey]!;
            return Column(
              children: [
                // 日期标题
                Text(dateKey),
                // 当日账单
                ...dayRecords.map((r) => _buildRecordTile(r)),
              ],
            );
          },
          childCount: sortedKeys.length,
        ),
      ),
    ],
  );
}

添加账单表单

支持收入/支出切换、分类选择、日期选择、分摊设置:

class AddBillSheet extends StatefulWidget {
  final List<String> members;
  final Function(BillRecord) onSave;

  
  Widget build(BuildContext context) {
    return Column(
      children: [
        // 收入/支出切换
        Row(
          children: [
            _buildTypeButton('支出', true, Colors.red),
            _buildTypeButton('收入', false, Colors.green),
          ],
        ),
        // 金额输入
        TextField(
          controller: _amountController,
          keyboardType: TextInputType.numberWithOptions(decimal: true),
          style: TextStyle(fontSize: 36, fontWeight: FontWeight.bold),
          decoration: InputDecoration(prefixText: '¥ '),
        ),
        // 分类选择
        Wrap(
          children: categories.map((c) => ChoiceChip(
            avatar: Icon(c['icon']),
            label: Text(c['name']),
            selected: _category == c['name'],
            onSelected: (_) => setState(() => _category = c['name']),
          )).toList(),
        ),
        // 分摊设置(仅支出且有多人时显示)
        if (_isExpense && members.length > 1)
          SwitchListTile(
            title: Text('开启分摊'),
            value: _enableSplit,
            onChanged: (v) => setState(() => _enableSplit = v),
          ),
      ],
    );
  }
}

滑动删除

使用 Dismissible 组件实现滑动删除:

Widget _buildRecordTile(BillRecord record) {
  return Dismissible(
    key: Key(record.id),
    direction: DismissDirection.endToStart,
    background: Container(
      alignment: Alignment.centerRight,
      padding: const EdgeInsets.only(right: 20),
      color: Colors.red,
      child: const Icon(Icons.delete, color: Colors.white),
    ),
    onDismissed: (_) => onDelete(record.id),
    child: ListTile(
      leading: CircleAvatar(
        child: Icon(categoryIcon),
      ),
      title: Text(record.title),
      trailing: Text(
        '${record.isExpense ? '-' : '+'}¥${record.amount}',
        style: TextStyle(
          color: record.isExpense ? Colors.red : Colors.green,
        ),
      ),
    ),
  );
}

数据持久化

使用 SharedPreferences 存储账单数据和成员列表:

Future<void> _loadData() async {
  final prefs = await SharedPreferences.getInstance();
  
  // 加载账单记录
  final recordsJson = prefs.getString('bill_records');
  if (recordsJson != null) {
    final List<dynamic> list = jsonDecode(recordsJson);
    _records = list.map((e) => BillRecord.fromJson(e)).toList();
  }
  
  // 加载成员列表
  final membersJson = prefs.getString('members');
  if (membersJson != null) {
    _members = List<String>.from(jsonDecode(membersJson));
  }
}

Future<void> _saveData() async {
  final prefs = await SharedPreferences.getInstance();
  await prefs.setString(
    'bill_records',
    jsonEncode(_records.map((e) => e.toJson()).toList()),
  );
  await prefs.setString('members', jsonEncode(_members));
}

统计分析

按分类统计本月支出,计算各分类占比:

Widget build(BuildContext context) {
  // 筛选本月支出记录
  final monthRecords = records.where((r) => 
    r.date.year == now.year && 
    r.date.month == now.month && 
    r.isExpense
  );
  
  // 按分类汇总
  final Map<String, double> categoryStats = {};
  for (var r in monthRecords) {
    categoryStats[r.category] = 
      (categoryStats[r.category] ?? 0) + r.amount;
  }
  
  // 计算总额和占比
  final total = categoryStats.values.fold(0.0, (sum, v) => sum + v);
  
  return ListView(
    children: categoryStats.entries.map((e) {
      final percent = total > 0 ? e.value / total : 0.0;
      return Row(
        children: [
          Text(e.key),
          LinearProgressIndicator(value: percent),
          Text('${(percent * 100).toStringAsFixed(1)}%'),
        ],
      );
    }).toList(),
  );
}

页面导航结构

设置页

统计页

分摊页

账单页

底部导航

账单

分摊

统计

设置

月度概览

按日分组列表

添加账单FAB

成员余额

结算方案

分摊记录

月度总支出

分类占比

成员管理

关于

状态管理流程

SharedPreferences State 页面 用户 SharedPreferences State 页面 用户 添加账单 _addRecord() _records.insert() _saveData() setState() 更新列表 滑动删除 _deleteRecord() _records.remove() _saveData() setState() 移除项目

扩展建议

  1. 预算管理:设置月度预算,超支提醒
  2. 图表可视化:饼图、折线图展示趋势
  3. 导出功能:导出为Excel或PDF
  4. 云同步:多设备数据同步
  5. 周期账单:自动记录固定支出(房租、会员等)
  6. 多币种:支持外币记账和汇率换算
  7. 标签系统:自定义标签,灵活分类

项目依赖

dependencies:
  flutter:
    sdk: flutter
  shared_preferences: ^2.2.2

项目结构

lib/
└── main.dart
    ├── BillRecord          # 账单数据模型
    ├── CategoryData        # 分类数据
    ├── AccountBookApp      # 主应用(状态管理)
    ├── AddBillSheet        # 添加账单表单
    ├── HomePage            # 账单列表页
    ├── SplitPage           # 分摊结算页
    ├── StatsPage           # 统计分析页
    └── SettingsPage        # 设置页

总结

这个记账本应用展示了几个实用的开发技巧:

  1. 数据建模:设计灵活的数据结构,同时支持普通记账和分摊场景
  2. 分摊算法:通过余额计算和贪心策略,生成最优结算方案
  3. 列表分组:使用 Map 按日期分组,配合 SliverList 实现分组列表
  4. 滑动操作:Dismissible 组件实现滑动删除
  5. 本地存储:SharedPreferences + JSON 序列化实现数据持久化

账单分摊功能特别适合朋友聚餐、合租、旅行等多人共同消费的场景,自动计算谁该给谁转多少钱,省去手动算账的麻烦。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐