Flutter Web + Supabase 构建 AI 家计簿:从原型到全功能模块的实战
1. 项目概述:从128行原型到全功能AI家计簿的蜕变
最近在做一个挺有意思的项目,我们团队在开发一个叫“自分株式会社”的AI生活管理应用,目标是把Notion、Evernote、MoneyForward、Slack这些你日常用的21个SaaS工具,全都整合到一个地方。这想法听起来有点野心,但做起来确实能解决信息碎片化的大问题。就在前几天,看到亚马逊的Rufus AI推出了“Buy for Me”功能,能帮你分析购买决策,这让我突然意识到,我们手头的家计管理模块还只是个128行的静态展示页面,实在太简陋了。作为一个财务管理和效率工具的聚合平台,没有点智能化的家计分析功能,实在说不过去。于是,我决定用Flutter Web,在几天内把这个“摆设”页面,彻底重构成一个带有AI节建议和未来资产模拟的、真正能用的家计AI顾问。
这个新页面的核心目标很明确:不仅要能像MoneyForward那样清晰地记录和分类收支,更要利用AI去理解你的消费模式,主动给出省钱的实操建议,并且能让你直观地看到,如果坚持某个储蓄或投资计划,未来5年、10年你的资产会变成什么样。最终,我把一个原本只有几个数字卡片的页面,扩展成了包含4个核心标签页、超过750行代码的完整功能模块。整个过程没有增加新的后端服务,完全复用现有的AI能力,用纯Dart实现了复杂的财务计算,并且保持了代码库的绝对整洁( flutter analyze 和 deno lint 都是0警告)。如果你也在用Flutter做Web应用,并且想引入AI能力或处理复杂的业务逻辑,我踩过的坑和总结的模式,或许能帮你省下不少时间。
2. 架构设计与技术选型背后的思考
2.1 为什么是Flutter Web + Supabase组合?
选择Flutter Web作为前端,对我们来说几乎是必然的。我们的核心应用是跨平台的,一套代码能跑在移动端和Web端,维护成本大大降低。Flutter Web经过几个大版本的迭代,现在的性能和体验已经足够支撑这种数据密集型的后台管理页面。渲染图表、频繁更新状态(比如用户调整预算滑块时实时更新进度条)都很流畅。更重要的是,Flutter丰富的UI组件库和高度自定义的能力,让我们能快速构建出体验一致且美观的财务数据看板。
后端选择Supabase,则主要基于其“一体化”和“无服务器优先”的特性。我们的应用涉及用户认证、实时数据、AI接口调用等多个层面。Supabase的Auth、Postgres数据库、Realtime、Storage以及Edge Functions,正好覆盖了所有这些需求。特别是Edge Functions,它让我们能用TypeScript或Deno快速部署无服务器函数,处理像AI对话这类需要调用外部API(如Anthropic的Claude)的敏感或复杂逻辑,而无需自己管理服务器。这次家计AI顾问的核心——节建议生成,就是直接复用了我们已有的一个通用 ai-assistant Edge Function,实现了零成本的功能扩展。
2.2 数据层设计:通用表与源标识模式
在数据存储设计上,我们采用了一个非常灵活且节省资源的模式。通常,遇到“预算计划”、“实际支出”这类新功能,第一反应可能是创建 budget_plans 和 expenses 这样的专用表。但我们没有这么做,而是选择复用了现有的 app_analytics 通用事件表。
这个表结构很简单,核心字段有 user_id 、 timestamp 、 source 和 metadata (JSONB类型)。 source 字段就是关键,我们用不同的字符串来区分数据用途。例如:
// 保存用户设定的2024年7月“餐饮”预算
await supabaseClient.from('app_analytics').insert({
'user_id': currentUser.id,
'source': 'budget_plan',
'metadata': {
'month': '2024-07',
'category': '餐饮',
'amount': 50000
}
});
// 保存一笔2024年7月“餐饮”类的实际支出
await supabaseClient.from('app_analytics').insert({
'user_id': currentUser.id,
'source': 'budget_expense',
'metadata': {
'month': '2024-07',
'category': '餐饮',
'amount': 3800,
'description': '周五部门聚餐'
}
});
这么做的几个核心好处:
- 避免Schema爆炸 :每加一个小功能就建新表,长期来看数据库会变得难以维护。用
source字段区分,逻辑清晰,扩展时无需频繁执行ALTER TABLE。 - 节省Supabase资源 :Supabase的免费和收费计划对数据库表数量有限制。复用现有表,相当于在配额内做了最大化利用。
- 灵活的数据结构 :
metadata作为JSONB字段,可以存储任意结构的数据。今天预算只需要amount,明天如果想加个color标签,直接存进去就行,前端解析处理即可,后端完全不用动。 - 统一的查询接口 :所有财务相关数据的读写都通过同一张表,简化了数据访问层的代码。
当然,这种模式不适合数据量极大、需要复杂关联查询或强事务保证的场景。但对于我们这种用户个人财务数据量级(每月几十到几百条记录)和查询模式(主要是按用户、月份、来源筛选),它提供了最佳的开发速度和灵活性。
3. 核心功能模块的深度实现解析
3.1 四标签页布局与状态管理策略
UI上我们采用了经典的顶部标签栏(TabBar)加内容区(TabBarView)的布局,四个标签分别是:概览、预算、AI节建议、未来模拟。状态管理是这里的一个小挑战,因为每个标签的数据(概览的KPI、预算的设置与进度、AI建议内容、模拟计算结果)都是独立获取和更新的,而且有些操作(比如在“预算”页调整金额)需要实时反映在“概览”页的进度条上。
我们没有引入复杂的状态管理库(如Bloc、Riverpod),因为当前模块的复杂度可控。而是使用了Flutter内置的 ValueNotifier 配合 Consumer (来自 provider 包)来实现局部的、高效的状态响应。具体来说,我们为整个财务页面创建了一个 FinancialDataController 类,它内部管理着多个 ValueNotifier :
class FinancialDataController {
final ValueNotifier<Map<String, double>> monthlyBudgetNotifier = ValueNotifier({});
final ValueNotifier<List<ExpenseRecord>> currentMonthExpensesNotifier = ValueNotifier([]);
final ValueNotifier<String?> aiAdviceNotifier = ValueNotifier(null);
final ValueNotifier<double?> simulationResultNotifier = ValueNotifier(null);
// 加载预算数据的方法
Future<void> loadBudget(String month) async {
final data = await _fetchBudgetFromSupabase(month);
monthlyBudgetNotifier.value = data; // 更新Notifier,所有监听它的Widget会自动重建
}
// 更新单项预算的方法
Future<void> updateBudget(String category, double newAmount) async {
await _saveBudgetToSupabase(category, newAmount);
// 先更新本地内存中的数据
final newMap = Map<String, double>.from(monthlyBudgetNotifier.value);
newMap[category] = newAmount;
monthlyBudgetNotifier.value = newMap; // 触发UI更新
// 同时,概览页的进度条Widget监听了这个Notifier,也会自动更新
}
}
在UI中,对于只关心预算数据的Widget,我们用 ValueListenableBuilder 包裹,这样只有当 monthlyBudgetNotifier 变化时,这个Widget才会重建,性能最优。这种“细粒度响应式”的模式,在Flutter Web这种单页面应用里,能有效避免不必要的全局重建,保持界面流畅。
3.2 AI节建议生成:低成本接入大语言模型
这是本项目的亮点之一。我们并没有为这个功能单独开发一个新的后端API或Edge Function,而是巧妙地复用了项目中已有的一个通用AI助手函数 ai-assistant 。
实现步骤:
-
数据准备 :在Flutter前端,我们将用户指定月份(如“2024-07”)的财务数据汇总并格式化成一段清晰的文本。这包括总收入、总支出、以及分门别类的支出明细(例如:“餐饮: ¥85,000,交通: ¥25,000,娱乐: ¥18,000 ...”)。
-
构建提示词(Prompt) :这是让AI输出高质量建议的关键。我们设计了一个结构化的提示词:
请扮演一位专业的个人理财顾问。请分析以下用户[2024-07]月份的家计数据,并提供三条具体、可立即行动的节建议。 数据概览: - 总收入:¥450,000 - 总支出:¥380,000 - 主要支出类别: 餐饮:¥85,000 (占支出22.4%) 交通:¥25,000 娱乐:¥18,000 ...(其他类别) 要求: 1. 请基于上述数据,指出最有可能节省开支的1-2个类别。 2. 针对这些类别,提出三条非常具体、实操性强的建议(例如:“尝试每周自带午餐3次,预计每月可节省约¥12,000”,而非“减少餐饮支出”)。 3. 每条建议请用一句话说明,预估每月可节省的金额范围。 4. 输出格式严格遵循:仅输出三条建议,每条以‘• ’开头,使用中文。这个提示词明确了AI的角色、输入数据的结构、输出要求(三条、具体、带金额预估)和格式。通过限制输出条数和格式,我们能得到稳定、整洁、可直接在UI上展示的结果,无需复杂的后处理。
-
调用Edge Function :通过Supabase客户端库,调用
ai-assistant函数,将上述提示词作为消息体发送。Future<String> fetchAiAdvice(String month, FinancialSummary summary) async { final prompt = _buildAdvicePrompt(month, summary); // 构建上述提示词 try { final response = await supabase.functions.invoke('ai-assistant', body: { 'action': 'chat', 'message': prompt, }); return response.data['reply'] as String; // 假设返回结构为 {“reply”: “...”} } catch (e) { // 处理网络或API错误,返回友好提示 return 'AI分析暂时不可用,请稍后重试。'; } } -
前端展示 :将返回的文本(三条带
•的建议)用Text组件渲染,或者进一步用正则表达式拆分后放入ListView中,提升视觉效果。
避坑心得:
- 提示词工程是关键 :最初的版本只是简单地把数据扔给AI,结果它可能回复一段冗长的分析文章,或者建议数量不固定。通过精确的提示词约束,才能得到产品化所需的结构化输出。
- 错误处理必须友好 :AI API调用可能因为网络、额度、内容策略等原因失败。前端一定要做好
try-catch,给用户明确的反馈(如“分析中...”、“服务繁忙”),而不是让界面卡死或崩溃。 - 成本控制 :复用现有Edge Function,避免了新函数的冷启动开销和额外的监控负担。同时,在提示词中限制输出长度,也能有效控制每次调用消耗的Token数,从而控制成本。
3.3 未来资产模拟:纯Dart实现的复利计算器
“未来模拟”标签页的核心是一个复利计算器。用户输入初始金额、每月追加投资额、预期年化回报率和投资年限,点击计算后,就能看到期末的总资产预估。这个功能完全在前端用Dart实现,不依赖任何后端服务或复杂库。
核心算法实现: 我们采用按月复利计算的方式,更贴近大多数基金定投的实际情景。核心函数如下:
/// 计算复利终值(按月计算)
/// [principal] 初始本金
/// [monthlyAddition] 每月追加金额
/// [annualRate] 预期年化收益率(百分比,如5.0表示5%)
/// [years] 投资年数
double calculateCompoundInterest(
double principal, double monthlyAddition, double annualRate, int years) {
// 1. 将年利率转换为月利率(小数形式)
double monthlyRate = annualRate / 100 / 12;
int totalMonths = years * 12;
double futureValue = principal;
// 2. 按月循环计算
for (int i = 0; i < totalMonths; i++) {
// 每月先计算利息:上月本金 * 月利率
// 然后加上本月追加的投资额
futureValue = futureValue * (1 + monthlyRate) + monthlyAddition;
}
// 3. 返回最终结果
return futureValue;
}
为什么选择循环计算而非公式? 标准的复利终值公式是 FV = P*(1+r)^n + PMT*[((1+r)^n - 1)/r] 。虽然公式更高效,但对于大多数用户来说,理解“每月投入、按月复利”这个过程,循环计算在概念上更直观。而且,对于几十年的计算(最多几百次循环),在浏览器的JavaScript/Dart引擎上性能开销完全可以忽略不计,代码的可读性和可维护性收益更大。
一个生动的例子: 假设用户有100万日元初始资金,计划每月追加投资3万日元,预期年化回报率为5%,投资20年。
- 总投入本金 = 1,000,000 + (30,000 * 12 * 20) = 8,200,000日元。
- 通过上述函数计算,20年后的资产总额约为15,440,000日元。
- 利息收益部分约为7,240,000日元 。这个数字直观地展示了“时间+复利”的威力:利息收益几乎接近本金总额。我们在UI上特意将这个“利息部分”高亮显示,对用户是非常有力的储蓄激励。
UI交互细节: 我们使用了 TextFormField 来接收用户输入,并为其添加了输入验证(确保是正数、利率合理等)。当任何输入框的值发生变化时,我们使用 onChanged 回调来触发重新计算,并实时更新显示结果,给用户即时的反馈。同时,我们预设了几个“快速设置”按钮(如“保守型3%”、“进取型7%”),方便用户快速切换场景进行对比。
3.4 预算管理与进度可视化
预算页面允许用户在15个预设的生活类别(如住房、餐饮、交通、娱乐、学习等)中设置月度预算。数据通过前面提到的通用表模式保存到Supabase。
可视化实现: 每个预算条目都是一个 ListTile ,包含类别图标、名称、预算金额输入框和一个线性进度条( LinearProgressIndicator )。进度条的长度根据“实际支出 / 预算金额”的比例动态计算。
LinearProgressIndicator(
value: expenseAmount / budgetAmount, // 比例,超过1.0则显示为满格(可考虑颜色变红)
backgroundColor: Colors.grey[200],
valueColor: AlwaysStoppedAnimation<Color>(
(expenseAmount / budgetAmount) <= 1.0 ? Colors.blue : Colors.red,
),
)
当用户在概览页记录一笔新支出时,该类别对应的进度条会实时更新。这个“实时性”得益于我们之前提到的 ValueNotifier 状态管理。支出记录保存后,会触发 currentMonthExpensesNotifier 更新,而预算页的Widget监听相关数据,会自动重绘进度条。
注意事项:
- 数据一致性 :预算和支出都按“年月”(如‘2024-07’)严格区分。查询时务必带上时间范围,避免把上月的支出算到本月。
- 进度条超限处理 :当支出超过预算(比例>1.0)时,我们把进度条颜色设为红色,并且值固定为1.0(填满),这样既能直观告警,又不会让进度条“溢出”UI组件。
4. Flutter Web开发与代码质量维护的实战要点
4.1 保持 flutter analyze 0 警告的纪律
在团队协作和长期维护中,保持代码静态分析零警告至关重要。这次重构我特别关注了Flutter 3.19(当前稳定版)中 analysis_options.yaml 里 require_trailing_commas 这条规则。它要求在多行的集合字面量、函数调用参数列表的每一行末尾都加上逗号。
为什么这个规则重要?
- 版本控制友好 :当你在集合中添加一个新元素时,只需要新增一行,上一行的末尾因为已有逗号,所以这行修改在git diff中只会显示为“添加了一行”,而不是“修改了上一行(添加逗号)+ 新增一行”。这让代码审查更清晰。
- 格式统一 :自动格式化工具(如
dart format)能更好地工作,代码风格完全一致。
错误示例和正确示例:
// ❌ 错误:最后一行参数后面缺少逗号,flutter analyze会报错
Widget _buildKpiCard(String title, double value, Color bgColor, Color textColor, IconData icon) {
return Card(
color: bgColor,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Icon(icon, color: textColor),
Text(title, style: TextStyle(color: textColor)),
Text(formatCurrency(value), style: TextStyle(...)),
], // <- children 列表的 ] 前面也应该有逗号,但这里先关注参数
),
),
);
}
// ✅ 正确:所有多行参数列表、集合的末尾都有逗号
Widget _buildKpiCard(
String title,
double value,
Color bgColor,
Color textColor,
IconData icon, // <- 参数列表最后一项也有逗号
) {
return Card(
color: bgColor,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Icon(icon, color: textColor),
Text(title, style: TextStyle(color: textColor)),
Text(formatCurrency(value), style: TextStyle(...)),
], // <- children 列表的 ] 前面也有逗号
),
),
);
}
养成这个习惯后,代码会整洁很多。建议在IDE(VSCode或Android Studio)中配置保存时自动运行 dart format ,并定期在终端运行 flutter analyze ,确保团队代码规范。
4.2 适配Flutter版本:DropdownButtonFormField的变迁
另一个在实际开发中遇到的细节是 DropdownButtonFormField 的API变化。在Flutter 3.3之后,直接设置 value 属性来预选值的方式被标记为弃用(deprecated),转而推荐使用 initialValue 。
旧方式(已弃用):
String _selectedCategory = '餐饮';
DropdownButtonFormField<String>(
value: _selectedCategory, // 在Flutter 3.3+会提示deprecated
items: categories.map((String category) {
return DropdownMenuItem(value: category, child: Text(category));
}).toList(),
onChanged: (newValue) { setState(() { _selectedCategory = newValue!; }); },
);
新方式(推荐):
final _categoryController = TextEditingController(text: '餐饮'); // 通过Controller设置初始值
DropdownButtonFormField<String>(
// 不再使用value属性
items: categories.map((String category) {
return DropdownMenuItem(value: category, child: Text(category));
}).toList(),
onChanged: (newValue) {
setState(() { _categoryController.text = newValue!; });
},
controller: _categoryController, // 使用controller
// 或者,如果与Form关联,可以使用initialValue
// initialValue: '餐饮',
);
这个改动是为了更好地将下拉菜单集成到Flutter的 Form 生态中,使其行为与其他表单字段(如 TextFormField )一致。如果你在升级Flutter版本后遇到相关警告,按照新方式修改即可。
4.3 性能优化:列表渲染与数据分页
当支出记录越来越多时,直接在 ListView 中渲染所有条目可能会导致滚动卡顿。我们采用了 ListView.builder 来按需构建子项,这是Flutter处理长列表的标准做法。更进一步,如果数据量巨大(虽然家计数据通常不会),可以考虑集成Supabase的实时分页查询。
基础优化示例:
ValueListenableBuilder<List<ExpenseRecord>>(
valueListenable: financialController.currentMonthExpensesNotifier,
builder: (context, expenses, child) {
if (expenses.isEmpty) return _buildEmptyState();
return ListView.builder(
itemCount: expenses.length,
itemBuilder: (context, index) {
final expense = expenses[index];
return ExpenseListItem(expense: expense); // 使用独立的StatelessWidget
},
);
},
)
将列表项抽离成独立的 StatelessWidget (如 ExpenseListItem ),可以最小化重绘范围。当只有某一条目的数据变化时,只有那个对应的 ListItem 会重建,而不是整个列表。
5. 部署、测试与未来迭代方向
5.1 Flutter Web的构建与部署
开发完成后,使用 flutter build web 命令生成优化的发布包。我们选择部署到Firebase Hosting,因为它与Flutter工具链集成良好,部署简单快捷。
# 1. 构建生产版本
flutter build web --release --web-renderer canvaskit # 使用CanvasKit渲染器以获得更好的浏览器兼容性
# 2. 部署到Firebase (需先安装并登录Firebase CLI)
firebase deploy --only hosting
--web-renderer canvaskit 是一个重要选项。CanvasKit渲染器能确保UI在不同浏览器中具有最高的一致性,特别是对于自定义图形和文本渲染。虽然初始加载体积会比 html 渲染器稍大,但对于我们这种包含自定义图表和复杂布局的应用来说,稳定性优先。
5.2 核心功能测试策略
对于这样一个工具,测试重点在于逻辑正确性和用户体验。
- 复利计算单元测试 :为
calculateCompoundInterest函数编写Dart单元测试,验证常见场景(零本金、零利率、长期投资)下的计算结果是否正确,特别是与已知的财务计算器结果进行对比。 - AI提示词与解析测试 :模拟不同的财务数据输入,检查生成的提示词是否符合预期格式,并模拟Edge Function返回各种格式的文本(包括可能出现的错误信息),测试前端解析和显示逻辑的健壮性。
- UI交互测试 :使用
flutter_test进行Widget测试,模拟用户点击标签页、输入预算、点击计算按钮等操作,验证界面状态是否正确更新。 - 集成测试(关键) :编写一个简单的集成测试,模拟用户从登录到查看AI建议的完整流程。这能确保前端与Supabase Auth、Database、Functions的集成是可靠的。
5.3 可能的未来扩展方向
这个家计AI顾问模块已经具备了核心功能,但还有很大的深化空间:
- 数据可视化增强 :引入
charts_flutter库,在概览页增加月度收支趋势折线图、支出类别占比饼图,让数据更直观。 - AI能力深化 :
- 消费预测 :基于历史数据,让AI预测下个月在各类别的大致支出。
- 个性化建议 :不仅分析月度数据,还能结合用户的长期目标(如“两年内存够100万日元旅行基金”),给出阶段性的储蓄和支出调整建议。
- 收据图像识别 :通过Supabase Storage上传收据图片,利用Edge Function调用OCR和AI服务,自动提取金额、类别、商家信息,实现“拍照记账”。
- 多账户与家庭共享 :扩展数据模型,支持用户管理多个账户(如个人账户、家庭共同账户),并实现家庭成员间的预算共享和支出可见(在隐私授权前提下)。
- 与日历/待办事项集成 :这是我们“AI生活管理应用”的终极愿景。例如,识别到日历中有“朋友生日”事件,AI可以提前一周给出合理的礼物预算建议;或者当某类别支出快超预算时,在待办事项中生成一条“本周减少外出就餐”的提醒。
从128行的静态页面到如今功能丰富的AI家计顾问,这次重构让我深刻体会到,利用好现有的强大工具链(Flutter、Supabase),复用已有能力(AI Edge Function),并专注于解决用户真实痛点(清晰的预算、可操作的节建议、可视化的未来激励),完全可以在短时间内打造出体验出色且功能扎实的产品模块。整个过程中,保持代码的整洁和可维护性,是为未来迭代铺平道路的关键。
更多推荐
所有评论(0)