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-assistantEdge 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),并专注于解决用户真实痛点(清晰的预算、可操作的节建议、可视化的未来激励),完全可以在短时间内打造出体验出色且功能扎实的产品模块。整个过程中,保持代码的整洁和可维护性,是为未来迭代铺平道路的关键。
