Flutter 本地优先记账本实战:SQLite 数据建模、账单导入与多端发布

Flutter 本地优先记账本实战:SQLite 数据建模、账单导入与多端发布

这篇文章复盘一个完整的 Flutter 记账本项目“璃账”的实现过程。它不是一个只停留在 UI Demo 的练手页面,而是包含本地 SQLite 存储、实时余额计算、按天归档、账目增删改查、微信/支付宝账单导入预览、桌面端闪退修复,以及 macOS、Android、Windows 三端发布的完整小型应用。

项目的核心目标很明确:

  • 用户先输入当前金额,作为余额锚点;
  • 后续每一笔收入或支出都会实时影响当前余额;
  • 账目按交易日期自动归档到天;
  • 支持手动新增、编辑、删除和撤销删除;
  • 支持微信、支付宝账单文件或文本导入;
  • 导入前先预览有效行、重复行和错误行,避免记错账;
  • 数据默认保存在本地,不依赖账号和云端。

下面按工程落地顺序拆解这个项目。

技术栈与模块划分

项目使用 Flutter / Dart 开发,桌面端覆盖 macOS、Windows,移动端覆盖 Android。

主要依赖如下:

依赖用途
sqfliteAndroid、macOS 等平台的 SQLite 访问
sqflite_common_ffiWindows / Linux 桌面端 SQLite FFI
path_provider获取应用数据目录
file_picker选择账单文件
csv解析 CSV / 文本账单
excel解析 XLSX 账单
receive_sharing_intentAndroid 分享/打开文件导入入口
intl金额和日期格式化
crypto生成手动账目的外部 ID / 去重 ID

代码层面拆成了几类:

lib/
  main.dart                         # UI、交互、图标和页面布局
  models/
    ledger_entry.dart               # 账目、余额锚点、归档快照模型
    import_models.dart              # 导入预览模型
  services/
    money.dart                      # 金额解析、格式化、收入支出符号
    ledger_database.dart            # SQLite 表结构与读写
    ledger_controller.dart          # UI 状态和业务编排
    bill_importer.dart              # 微信/支付宝账单解析

这种拆分有一个好处:UI 只负责展示和触发动作,数据库和导入逻辑都可以单独测试。

金额处理:不要用浮点数存钱

记账软件最容易踩的坑之一,是直接用 double 存金额。项目里没有这么做,而是把金额统一转换成“分”存储,也就是整数。

核心逻辑在 money.dart

int parseMoneyToCents(String input) {
  final normalized = input
      .replaceAll(',', '')
      .replaceAll('¥', '')
      .replaceAll('¥', '')
      .replaceAll('元', '')
      .trim();
  if (normalized.isEmpty) {
    throw const FormatException('金额不能为空');
  }

  final negative = normalized.startsWith('-') || normalized.startsWith('支出');
  final match = RegExp(r'-?\d+(?:\.\d{1,2})?').firstMatch(normalized);
  if (match == null) {
    throw FormatException('无法识别金额:$input');
  }

  final value = double.parse(match.group(0)!);
  final cents = (value.abs() * 100).round();
  return negative ? -cents : cents;
}

这个函数处理了几类输入:

  • 12.34
  • ¥12.34
  • ¥12.34
  • 12.34元
  • -12.34
  • 支出12.34

入库时再结合账目类型,把支出转为负数,收入转为正数:

int signedAmountForType(EntryType type, int cents) {
  final absolute = cents.abs();
  return type == EntryType.expense ? -absolute : absolute;
}

这样做的好处是:

  • 数据库里没有浮点误差;
  • 展示层统一用格式化函数输出;
  • 收入/支出的符号逻辑集中处理;
  • 测试更容易写。

SQLite 数据建模:余额锚点 + 流水账

项目没有把“当前余额”直接存成一个不断覆盖的值,而是设计了一个余额锚点表 balance_anchor,再用流水表 ledger_entries 计算锚点之后的变化。

余额锚点表

CREATE TABLE IF NOT EXISTS balance_anchor (
  id INTEGER PRIMARY KEY CHECK (id = 1),
  amount_cents INTEGER NOT NULL,
  as_of_ms INTEGER NOT NULL,
  updated_at_ms INTEGER NOT NULL
)

这个表永远只保留一行:

  • amount_cents:用户输入的当前金额,单位是分;
  • as_of_ms:这个金额对应的时间;
  • updated_at_ms:更新时间。

为什么要有 as_of_ms?因为实时余额只需要计算锚点时间之后的流水差额:

if (entry.occurredAt.isAfter(anchor.asOf)) {
  realtimeDelta += entry.signedAmountCents;
}

最终实时余额就是:

realtimeBalanceCents: anchor.amountCents + realtimeDelta

这种设计比每次新增账目都直接修改余额更稳,因为历史流水可以编辑、删除、撤销删除,余额始终能从锚点和流水重新计算出来。

流水表

CREATE TABLE IF NOT EXISTS ledger_entries (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  type TEXT NOT NULL,
  amount_cents INTEGER NOT NULL,
  occurred_at_ms INTEGER NOT NULL,
  category TEXT NOT NULL DEFAULT '',
  merchant TEXT NOT NULL DEFAULT '',
  note TEXT NOT NULL DEFAULT '',
  source TEXT NOT NULL DEFAULT 'manual',
  external_id TEXT NOT NULL DEFAULT '',
  created_at_ms INTEGER NOT NULL,
  updated_at_ms INTEGER NOT NULL,
  deleted_at_ms INTEGER
)

几个字段值得关注:

字段说明
typeincomeexpense
amount_cents金额,单位为分
occurred_at_ms交易发生时间
source来源,比如 manualwechatalipay
external_id导入账单的交易号,或手动生成的去重 ID
deleted_at_ms软删除时间,非空表示已删除

项目还加了两个索引:

CREATE INDEX IF NOT EXISTS idx_entries_active_day
ON ledger_entries(deleted_at_ms, occurred_at_ms DESC)
CREATE INDEX IF NOT EXISTS idx_entries_external
ON ledger_entries(source, external_id)

第一个服务于常用列表查询和按天归档,第二个服务于导入去重。

软删除与撤销删除

记账软件有一个很实际的需求:用户可能误删账目,但又希望马上撤销。项目没有直接 DELETE,而是更新 deleted_at_ms

Future<void> deleteEntry(int id) async {
  final db = await database;
  final now = DateTime.now().millisecondsSinceEpoch;
  await db.update(
    'ledger_entries',
    {'deleted_at_ms': now, 'updated_at_ms': now},
    where: 'id = ?',
    whereArgs: [id],
  );
}

撤销删除则把 deleted_at_ms 置空:

Future<void> restoreEntry(int id) async {
  final db = await database;
  await db.update(
    'ledger_entries',
    {
      'deleted_at_ms': null,
      'updated_at_ms': DateTime.now().millisecondsSinceEpoch,
    },
    where: 'id = ?',
    whereArgs: [id],
  );
}

列表查询统一过滤:

where: 'deleted_at_ms IS NULL'

这个方案简单,但非常适合个人账本:误删可以恢复,导入去重也不会把已删除记录算作活跃记录。

按天归档:从流水派生视图数据

项目没有额外创建“每日汇总表”,而是在加载快照时按日期聚合:

List<DayArchive> _buildArchives(List<LedgerEntry> entries) {
  final grouped = <DateTime, List<LedgerEntry>>{};
  for (final entry in entries) {
    final day = DateTime(
      entry.occurredAt.year,
      entry.occurredAt.month,
      entry.occurredAt.day,
    );
    grouped.putIfAbsent(day, () => []).add(entry);
  }

  final archives = grouped.entries.map((group) {
    var income = 0;
    var expense = 0;
    for (final entry in group.value) {
      if (entry.type == EntryType.income) {
        income += entry.amountCents;
      } else {
        expense += entry.amountCents;
      }
    }
    return DayArchive(
      day: group.key,
      entries: group.value,
      incomeCents: income,
      expenseCents: expense,
      netCents: income - expense,
    );
  }).toList();

  archives.sort((a, b) => b.day.compareTo(a.day));
  return archives;
}

对于首版个人记账本,这样做足够直接:

  • 不需要维护额外汇总表;
  • 编辑、删除、撤销后重新加载即可得到新归档;
  • 后续数据量变大时,再考虑按月缓存或增量汇总。

微信/支付宝账单导入:先预览,再写入

账单导入是这个项目里最容易出错的部分。用户导出的文件可能是 CSV、XLSX,也可能是复制出来的表格文本;字段名也可能有细微差异。

项目采用了“先解析成预览,再确认写入”的流程。

文件和文本解析

Future<ImportPreview> previewFile(String path, ImportMethod method) async {
  final file = File(path);
  final bytes = await file.readAsBytes();
  final lowerPath = path.toLowerCase();
  final rows = lowerPath.endsWith('.xlsx')
      ? _rowsFromXlsx(bytes)
      : _rowsFromText(_decodeText(bytes));
  final source = _detectSource(path, rows);
  return _previewRows(
    rows,
    source: source,
    method: method,
    label: file.uri.pathSegments.isEmpty ? path : file.uri.pathSegments.last,
  );
}

文本解析兼容了两种常见格式:

if (cleaned.contains('\t') && !cleaned.contains(',')) {
  return cleaned
      .split(RegExp(r'\r?\n'))
      .map((line) => line.split('\t').map((cell) => cell.trim()).toList())
      .toList();
}

final rows = Csv(dynamicTyping: false).decode(cleaned);

也就是说:

  • 如果是制表符分隔文本,按 \t 拆;
  • 否则按 CSV 解析;
  • XLSX 使用 excel 包读取所有 sheet 的行。

表头识别与归一化

账单导入不能只依赖固定列名。项目先对表头做归一化:

String _normalizeHeader(String value) {
  return value
      .replaceAll(RegExp(r'[\s ()()/\\:_-]'), '')
      .replaceAll('人民币', '')
      .replaceAll('元', '')
      .trim();
}

然后用关键词寻找列:

final timeIndex = _findColumn(headers, const [
  '交易时间',
  '付款时间',
  '创建时间',
  '时间',
]);
final amountIndex = _findColumn(headers, const ['金额', '金额元', '交易金额']);

这样可以兼容一些表头差异,例如:

  • 交易时间
  • 付款时间
  • 创建时间
  • 金额
  • 金额(元)
  • 交易金额

过滤失败交易和不计收支

导入时还做了基础过滤:

final statusIndex = _findColumn(headers, const ['状态', '交易状态', '当前状态']);
final status = _cell(row, statusIndex);
if (status.contains('关闭') || status.contains('失败')) {
  throw FormatException('忽略非成功交易:$status');
}
final typeIndex = _findColumn(headers, const ['收支', '收支类型', '收支方向', '类型']);
final typeText = _cell(row, typeIndex);
if (typeText.contains('不计')) {
  throw const FormatException('不计收支');
}

这些记录不会直接写入,而是在预览列表里显示为错误行,用户能看到为什么没导入。

去重策略

如果账单里有交易单号,就用来源 + 交易单号去重:

where: 'deleted_at_ms IS NULL AND source = ? AND external_id = ?'

如果没有交易单号,就根据来源、时间、金额、商户和备注生成哈希:

final externalId = _cell(row, externalIndex).isEmpty
    ? _hashParts([
        source,
        occurredAt.toIso8601String(),
        '$amount',
        merchant,
        note,
      ])
    : _cell(row, externalIndex);

写入时再次在事务里检查重复,避免预览和确认之间出现重复数据:

await db.transaction((txn) async {
  for (final candidate in preview.candidates) {
    if (!candidate.isValid) {
      continue;
    }
    final duplicate = await _hasPotentialDuplicateInTransaction(
      txn,
      candidate.entry,
    );
    if (duplicate) {
      continue;
    }
    await txn.insert(
      'ledger_entries',
      candidate.entry.toMap()..remove('id'),
    );
    inserted++;
  }
});

这个设计对个人记账工具很关键:导入账单通常不是一次完成,用户可能重复选择同一份文件。如果没有去重,很容易产生余额偏差。

SQLite FFI 闪退修复:不同平台不要走错数据库实现

项目开发过程中遇到过一个典型桌面端问题:macOS 新增账单时闪退。排查后定位到 SQLite FFI 原生层崩溃,栈里出现了 sqlite3_bind_text 相关调用。

修复思路是:macOS 不再强制使用 sqflite_common_ffi,而是走 sqflite_darwin 的原生插件;Windows / Linux 仍然使用 FFI。

最终数据库工厂配置如下:

static void configureDatabaseFactory() {
  if (Platform.isWindows || Platform.isLinux) {
    sqfliteFfiInit();
    sqflite.databaseFactory = databaseFactoryFfi;
  }
}

这个分流很重要:

平台数据库路径
macOSsqflite_darwin 原生插件
Androidsqflite_android 原生插件
Windowssqflite_common_ffi
Linuxsqflite_common_ffi

跨平台 Flutter 应用不能只看 Dart 层 API 一样,就假设所有平台原生实现都一样。桌面端尤其要注意 native assets、动态库和插件实现差异。

状态管理:Controller 负责业务编排

项目没有引入复杂状态管理库,而是使用 ChangeNotifierLedgerController 负责把数据库、导入器和 UI 状态串起来。

例如保存一笔手动账:

Future<int> saveEntry({
  int? id,
  required EntryType type,
  required String amountText,
  required DateTime occurredAt,
  required String category,
  required String merchant,
  required String note,
}) async {
  final amount = parseMoneyToCents(amountText).abs();
  final now = DateTime.now();
  final entry = LedgerEntry(
    id: id,
    type: type,
    amountCents: amount,
    occurredAt: occurredAt,
    category: category.trim().isEmpty ? type.label : category.trim(),
    merchant: merchant.trim().isEmpty ? '手动记账' : merchant.trim(),
    note: note.trim(),
    source: 'manual',
    externalId: id == null
        ? _manualExternalId(now, type, amount, merchant, note)
        : '',
    createdAt: now,
    updatedAt: now,
  );
  final savedId = await database.saveEntry(entry);
  notice = id == null ? '已记一笔' : '已更新账目';
  await load();
  return savedId;
}

保存后调用 load() 重新加载快照,这样余额、今日流水和按天归档都能保持一致。

对于这个体量的应用,ChangeNotifier + Service 是比较克制的选择。等后续 AI 分析、预算、统计图表变复杂,再考虑 Riverpod、Bloc 或更细粒度的状态拆分也不迟。

UI 设计:工具应用优先可读性

UI 采用 Material 3,并做了玻璃质感的面板背景。主界面分成几个核心区域:

  • 实时余额面板;
  • 记一笔编辑器;
  • 导入中心;
  • 今日流水;
  • 按天归档。

为了让账目一眼可读,项目给分类加了图标映射,例如:

  • 餐饮:restaurant_rounded
  • 交通:directions_transit_rounded
  • 购物:shopping_bag_rounded
  • 房租:home_rounded
  • 医疗:local_hospital_rounded
  • 工资:payments_rounded

图标不是装饰,而是降低长期使用时的扫读成本。记账软件每天打开,如果每条流水都只能读文字,使用负担会明显增加。

测试:覆盖金额、导入、数据库和新增流程

项目里至少覆盖了几类关键测试:

1. 金额与符号

验证金额解析和收入支出符号,避免浮点误差和正负号错误。

2. 账单导入解析

测试微信、支付宝常见 CSV 文本,确保表头识别和导入来源检测可用。

3. 数据库保存

数据库测试使用临时 SQLite 文件:

final dir = await Directory.systemTemp.createTemp('lizhang-db-test-');
final database = LedgerDatabase(
  databasePath: p.join(dir.path, 'test.sqlite'),
);
final controller = LedgerController(database);

然后验证保存账目和实时余额:

await controller.setBalanceAnchor('100.00');
final id = await controller.saveEntry(
  type: EntryType.expense,
  amountText: '12.34',
  occurredAt: DateTime.now().add(const Duration(seconds: 1)),
  category: '餐饮',
  merchant: '咖啡店',
  note: '拿铁',
);

expect(id, isPositive);
expect(controller.snapshot?.entries, hasLength(1));
expect(controller.snapshot?.realtimeBalanceCents, 8766);

这能保证:余额 100.00,在锚点之后新增支出 12.34,实时余额应为 87.66。

4. macOS 新增账目集成测试

项目还加了一个 integration test,直接启动 App,填写金额、商户、备注并点击保存:

await tester.enterText(fields.at(1), '12.34');
await tester.enterText(fields.at(3), '咖啡店');
await tester.enterText(fields.at(4), '拿铁');

final saveButton = find.widgetWithText(FilledButton, '保存');
await tester.ensureVisible(saveButton);
await tester.tap(saveButton);
await tester.pumpAndSettle();

final entry = controller.snapshot?.entries.single;
expect(entry?.amountCents, 1234);
expect(entry?.category, '餐饮');
expect(entry?.merchant, '咖啡店');

这个测试的价值在于,它不只测数据库方法,还覆盖了 UI 表单到业务保存的完整路径。

多端构建与发布

本地可以构建 macOS 和 Android:

flutter pub get
dart analyze
flutter test
flutter test integration_test/add_entry_test.dart -d macos
flutter build macos
flutter build apk

macOS 产物:

build/macos/Build/Products/Release/璃账.app

Android 产物:

build/app/outputs/flutter-apk/app-release.apk

Windows 不能在 macOS 上交叉构建,所以项目加了 GitHub Actions,在 Windows runner 上构建:

name: Build Windows Release

on:
  workflow_dispatch:
    inputs:
      tag:
        required: true
        default: "v1.0.0"
      version:
        required: true
        default: "1.0.0"

permissions:
  contents: write

jobs:
  build-windows:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: "3.44.0"
          channel: stable
          cache: true
      - run: flutter pub get
      - run: dart analyze
      - run: flutter test
      - run: flutter build windows --release

构建完成后,用 PowerShell 打 zip,并上传到 Release:

- name: Package Windows build
  shell: pwsh
  run: |
    New-Item -ItemType Directory -Force -Path dist | Out-Null
    $asset = "Lizhang-${{ inputs.version }}-Windows-x64.zip"
    Compress-Archive -Path "build/windows/x64/runner/Release/*" -DestinationPath "dist/$asset" -Force
    Get-FileHash "dist/$asset" -Algorithm SHA256 | Format-List

- name: Upload to GitHub Release
  shell: pwsh
  env:
    GH_TOKEN: ${{ github.token }}
  run: |
    gh release upload "${{ inputs.tag }}" "dist/Lizhang-${{ inputs.version }}-Windows-x64.zip" --repo "${{ github.repository }}" --clobber

最终发布资产包括:

平台资产
macOSLizhang-1.0.0-macOS.dmg
AndroidLizhang-1.0.0-Android.apk
WindowsLizhang-1.0.0-Windows-x64.zip

Windows zip 解压后包含:

lizhang.exe
flutter_windows.dll
sqlite3.dll
data/flutter_assets/...

这个项目的几个工程经验

1. 记账软件先保证数据正确,再追求功能多

首版没有急着做复杂图表,而是先处理:

  • 金额精度;
  • 正负号;
  • 数据库表结构;
  • 编辑和撤销;
  • 导入预览;
  • 去重。

这是合理的优先级。记账工具一旦算错余额,其他功能都没有意义。

2. 导入必须先预览

微信、支付宝账单格式并不是严格统一的接口协议。导入时直接写库很危险。预览层可以让用户看到:

  • 哪些行有效;
  • 哪些行重复;
  • 哪些行格式错误;
  • 为什么某一行被忽略。

这比“导入失败”四个字有用得多。

3. 跨平台插件要按平台验证

这次 macOS 闪退就是典型例子。Dart API 看起来一样,但底层可能是:

  • Android 原生插件;
  • macOS Darwin 插件;
  • Windows FFI;
  • Linux FFI。

跨平台应用发布前,至少要分别验证:

  • 本地数据库读写;
  • 文件选择;
  • 字体和图标;
  • 构建产物;
  • 安装包能否正常启动。

4. Windows 包可以交给 Actions

如果开发主机是 macOS,Windows 构建不要硬凑。Flutter 本身不支持从 macOS 交叉构建 Windows 桌面程序,用 GitHub Actions 的 Windows runner 更稳:

  • 构建环境可复现;
  • 分析和测试一起跑;
  • zip 自动上传 Release;
  • 后续发版只要触发 workflow。

总结

这个 Flutter 记账本项目的价值不在于代码有多复杂,而在于它覆盖了一个真实小应用从开发到发布会遇到的完整链路:

  • 用整数“分”处理金额,避免浮点误差;
  • 用余额锚点和流水差额计算实时余额;
  • 用 SQLite 软删除支持撤销;
  • 用按天聚合生成归档视图;
  • 用 CSV / XLSX / 文本解析支持微信、支付宝账单导入;
  • 用预览和去重降低导入风险;
  • 按平台选择 SQLite 插件实现,修复 macOS FFI 闪退;
  • 用测试覆盖数据库保存和新增账目流程;
  • 用 GitHub Actions 补齐 Windows x64 发布包。

如果你也在做本地优先工具类 App,可以参考这个思路:先把数据模型、导入边界、错误恢复和发布流程做好,再逐步增加统计、预算、AI 分析等高级功能。工具类应用不怕功能从少到多,怕的是第一版就把数据可信度做坏。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值