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': '周五部门聚餐'
  }
});

这么做的几个核心好处:

  1. 避免Schema爆炸 :每加一个小功能就建新表,长期来看数据库会变得难以维护。用 source 字段区分,逻辑清晰,扩展时无需频繁执行 ALTER TABLE
  2. 节省Supabase资源 :Supabase的免费和收费计划对数据库表数量有限制。复用现有表,相当于在配额内做了最大化利用。
  3. 灵活的数据结构 metadata 作为JSONB字段,可以存储任意结构的数据。今天预算只需要 amount ,明天如果想加个 color 标签,直接存进去就行,前端解析处理即可,后端完全不用动。
  4. 统一的查询接口 :所有财务相关数据的读写都通过同一张表,简化了数据访问层的代码。

当然,这种模式不适合数据量极大、需要复杂关联查询或强事务保证的场景。但对于我们这种用户个人财务数据量级(每月几十到几百条记录)和查询模式(主要是按用户、月份、来源筛选),它提供了最佳的开发速度和灵活性。

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

实现步骤:

  1. 数据准备 :在Flutter前端,我们将用户指定月份(如“2024-07”)的财务数据汇总并格式化成一段清晰的文本。这包括总收入、总支出、以及分门别类的支出明细(例如:“餐饮: ¥85,000,交通: ¥25,000,娱乐: ¥18,000 ...”)。

  2. 构建提示词(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上展示的结果,无需复杂的后处理。

  3. 调用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分析暂时不可用,请稍后重试。';
      }
    }
    
  4. 前端展示 :将返回的文本(三条带 的建议)用 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 这条规则。它要求在多行的集合字面量、函数调用参数列表的每一行末尾都加上逗号。

为什么这个规则重要?

  1. 版本控制友好 :当你在集合中添加一个新元素时,只需要新增一行,上一行的末尾因为已有逗号,所以这行修改在git diff中只会显示为“添加了一行”,而不是“修改了上一行(添加逗号)+ 新增一行”。这让代码审查更清晰。
  2. 格式统一 :自动格式化工具(如 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 核心功能测试策略

对于这样一个工具,测试重点在于逻辑正确性和用户体验。

  1. 复利计算单元测试 :为 calculateCompoundInterest 函数编写Dart单元测试,验证常见场景(零本金、零利率、长期投资)下的计算结果是否正确,特别是与已知的财务计算器结果进行对比。
  2. AI提示词与解析测试 :模拟不同的财务数据输入,检查生成的提示词是否符合预期格式,并模拟Edge Function返回各种格式的文本(包括可能出现的错误信息),测试前端解析和显示逻辑的健壮性。
  3. UI交互测试 :使用 flutter_test 进行Widget测试,模拟用户点击标签页、输入预算、点击计算按钮等操作,验证界面状态是否正确更新。
  4. 集成测试(关键) :编写一个简单的集成测试,模拟用户从登录到查看AI建议的完整流程。这能确保前端与Supabase Auth、Database、Functions的集成是可靠的。

5.3 可能的未来扩展方向

这个家计AI顾问模块已经具备了核心功能,但还有很大的深化空间:

  1. 数据可视化增强 :引入 charts_flutter 库,在概览页增加月度收支趋势折线图、支出类别占比饼图,让数据更直观。
  2. AI能力深化
    • 消费预测 :基于历史数据,让AI预测下个月在各类别的大致支出。
    • 个性化建议 :不仅分析月度数据,还能结合用户的长期目标(如“两年内存够100万日元旅行基金”),给出阶段性的储蓄和支出调整建议。
    • 收据图像识别 :通过Supabase Storage上传收据图片,利用Edge Function调用OCR和AI服务,自动提取金额、类别、商家信息,实现“拍照记账”。
  3. 多账户与家庭共享 :扩展数据模型,支持用户管理多个账户(如个人账户、家庭共同账户),并实现家庭成员间的预算共享和支出可见(在隐私授权前提下)。
  4. 与日历/待办事项集成 :这是我们“AI生活管理应用”的终极愿景。例如,识别到日历中有“朋友生日”事件,AI可以提前一周给出合理的礼物预算建议;或者当某类别支出快超预算时,在待办事项中生成一条“本周减少外出就餐”的提醒。

从128行的静态页面到如今功能丰富的AI家计顾问,这次重构让我深刻体会到,利用好现有的强大工具链(Flutter、Supabase),复用已有能力(AI Edge Function),并专注于解决用户真实痛点(清晰的预算、可操作的节建议、可视化的未来激励),完全可以在短时间内打造出体验出色且功能扎实的产品模块。整个过程中,保持代码的整洁和可维护性,是为未来迭代铺平道路的关键。

更多推荐