Flutter 本地优先记账本实战:SQLite 数据建模、账单导入与多端发布
这篇文章复盘一个完整的 Flutter 记账本项目“璃账”的实现过程。它不是一个只停留在 UI Demo 的练手页面,而是包含本地 SQLite 存储、实时余额计算、按天归档、账目增删改查、微信/支付宝账单导入预览、桌面端闪退修复,以及 macOS、Android、Windows 三端发布的完整小型应用。
项目的核心目标很明确:
- 用户先输入当前金额,作为余额锚点;
- 后续每一笔收入或支出都会实时影响当前余额;
- 账目按交易日期自动归档到天;
- 支持手动新增、编辑、删除和撤销删除;
- 支持微信、支付宝账单文件或文本导入;
- 导入前先预览有效行、重复行和错误行,避免记错账;
- 数据默认保存在本地,不依赖账号和云端。
下面按工程落地顺序拆解这个项目。
技术栈与模块划分
项目使用 Flutter / Dart 开发,桌面端覆盖 macOS、Windows,移动端覆盖 Android。
主要依赖如下:
| 依赖 | 用途 |
|---|---|
sqflite | Android、macOS 等平台的 SQLite 访问 |
sqflite_common_ffi | Windows / Linux 桌面端 SQLite FFI |
path_provider | 获取应用数据目录 |
file_picker | 选择账单文件 |
csv | 解析 CSV / 文本账单 |
excel | 解析 XLSX 账单 |
receive_sharing_intent | Android 分享/打开文件导入入口 |
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.3412.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
)
几个字段值得关注:
| 字段 | 说明 |
|---|---|
type | income 或 expense |
amount_cents | 金额,单位为分 |
occurred_at_ms | 交易发生时间 |
source | 来源,比如 manual、wechat、alipay |
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;
}
}
这个分流很重要:
| 平台 | 数据库路径 |
|---|---|
| macOS | sqflite_darwin 原生插件 |
| Android | sqflite_android 原生插件 |
| Windows | sqflite_common_ffi |
| Linux | sqflite_common_ffi |
跨平台 Flutter 应用不能只看 Dart 层 API 一样,就假设所有平台原生实现都一样。桌面端尤其要注意 native assets、动态库和插件实现差异。
状态管理:Controller 负责业务编排
项目没有引入复杂状态管理库,而是使用 ChangeNotifier。LedgerController 负责把数据库、导入器和 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
最终发布资产包括:
| 平台 | 资产 |
|---|---|
| macOS | Lizhang-1.0.0-macOS.dmg |
| Android | Lizhang-1.0.0-Android.apk |
| Windows | Lizhang-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 分析等高级功能。工具类应用不怕功能从少到多,怕的是第一版就把数据可信度做坏。



被折叠的 条评论
为什么被折叠?



