写在前面
最近用 Flutter 从零到一撸了一个习惯追踪应用 HabitGo ,代码量不大但五脏俱全,涵盖了状态管理、本地存储、AI 集成、自定义动画、主题系统、国际化、订阅变现等多个技术点。本文不打算写「如何用 Flutter 做习惯 App」的入门教程,而是聚焦在架构设计思路 和具体技术实现细节上,希望能给同样在做 Flutter 中大型项目的同学一些参考。
先放一张架构总览:
bash
lib/
├── app/ # 应用级配置(路由、主题入口)
├── core/ # 基础设施层
│ ├── ai/ # AI 服务(DeepSeek + 离线兜底 + 个性化)
│ ├── coins/ # 金币系统
│ ├── database/ # Hive 初始化
│ ├── feedback/ # 自定义 Toast
│ ├── insights/ # 智能洞察
│ ├── monetization/ # 免费版限制
│ ├── notifications/ # 本地通知
│ ├── profile/ # 用户档案
│ ├── purchases/ # RevenueCat 订阅
│ ├── security/ # 设备完整性检测
│ └── theme/ # 12 套主题预设
├── features/ # 功能模块(Feature-First)
│ ├── ai/ # AI 对话
│ ├── anniversary/ # 周年纪念
│ ├── checkin/ # 打卡
│ ├── discovery/ # 发现页
│ ├── favorites/ # 收藏
│ ├── feedback/ # 用户反馈
│ ├── habits/ # 习惯管理(核心)
│ ├── lesson/ # 每日一课
│ ├── mailbox/ # 成就邮箱
│ ├── mood/ # 心情日记
│ ├── profile/ # 个人中心
│ ├── settings/ # 设置/隐私/条款
│ ├── splash/ # 启动页
│ ├── statistics/ # 数据统计
│ ├── templates/ # 模板市场
│ └── wishbank/ # 心愿银行
├── l10n/ # 10 种语言翻译
└── shared/widgets/ # 共享 UI 组件
一、技术栈一览
| 分类 | 选型 | 原因 |
|---|---|---|
| 框架 | Flutter 3.x + Dart 3.12+ | 跨平台,Material 3 原生支持 |
| 状态管理 | Riverpod 2.6 | 编译安全,无 BuildContext 依赖,Provider 的进化版 |
| 路由 | GoRouter 14.8 | 声明式路由,支持 ShellRoute、深度链接 |
| 本地数据库 | Hive 2.2 | 纯 Dart 实现,无原生依赖,极快的键值存储 |
| 配置存储 | SharedPreferences | 轻量 KV,适合非结构化配置 |
| 图表 | fl_chart | 月度/年度热力图、周度折线图 |
| 订阅支付 | RevenueCat 9.8 | 跨平台订阅管理,开箱即用的 Paywall UI |
| 本地通知 | flutter_local_notifications 18 | 精确定时 + 智能推送 |
| AI 大模型 | DeepSeek API (deepseek-chat) | 高性价比,中文能力强 |
| 国际化 | Flutter 原生 Intl + ARB | 10 种语言,含阿拉伯语 RTL |
二、架构设计:Feature-First + 分层架构
2.1 为什么选择 Feature-First?
Flutter 项目常见的目录结构有三种:
- Layer-First (按层分):
models/、services/、views/全挤在一起,模块一多就成灾难。 - Type-First :按文件类型分(
providers/、widgets/、pages/),跨了模块理解成本高。 - Feature-First:按功能域分,每个 feature 自成一体,内部分层。
HabitGo 选择了 Feature-First + 内部分层 的组合:
bash
features/habits/
├── domain/ # 数据模型(Habit)
├── data/ # 仓库(HabitRepository)
├── providers/ # Riverpod Provider 定义
└── presentation/ # UI 层
├── pages/ # 页面
└── widgets/ # 组件
这样做的优势很明显:
- 高内聚:一个 feature 的所有代码在一个目录下,改习惯相关功能不需要跨 5 个目录。
- 低耦合 :feature 之间通过
core/层和 Provider 通信,不直接依赖彼此的内部实现。 - 可迁移:如果将来想把某个 feature 拆出去独立 package,直接拎走即可。
2.2 core/ 层的职责边界
core/ 是项目的基础设施层,提供跨 feature 共享的服务和能力:
- ai/:AI 服务抽象接口 + DeepSeek 实现 + 离线兜底 + 个性化进化系统
- theme/:12 套预设主题 + ThemeMode 管理
- feedback/:自定义 Toast 服务
- notifications/:本地推送,带智能判断逻辑
- purchases/:RevenueCat 订阅封装,带 Web 平台 stub
- coins/ 、insights/ 、monetization/:通用业务规则
核心原则:core 不依赖任何 feature,feature 通过 Provider 消费 core 的能力。
三、状态管理:Riverpod 的五种 Provider 实践
项目中用到了 Riverpod 的 5 种 Provider,每种都有明确的适用场景:
3.1 StateNotifierProvider --- 复杂可变状态
当状态需要多个方法去修改,且逻辑比较复杂时,用 StateNotifier 封装:
dart
// 习惯列表的状态管理
class HabitListNotifier extends StateNotifier<List<Habit>> {
final HabitRepository _repo;
HabitListNotifier(this._repo) : super([]);
void addHabit(Habit habit) { /* ... */ }
void updateHabit(Habit habit) { /* ... */ }
void deleteHabit(String id) { /* ... */ }
void toggleActive(String id) { /* ... */ }
}
final habitListProvider =
StateNotifierProvider<HabitListNotifier, List<Habit>>((ref) {
return HabitListNotifier(ref.read(habitRepositoryProvider));
});
同样的模式用于 ThemeModeNotifier、ThemePresetNotifier 等。
3.2 StateProvider --- 简单值状态
适合单一值、不需要复杂逻辑的场景:
dart
final currentLocaleCodeProvider = StateProvider<String>((ref) => 'zh');
final currentTabProvider = StateProvider<int>((ref) => 0);
3.3 Provider --- 依赖注入 / 只读服务
适合注入 Repository、Service 等不变化的对象:
dart
final habitRepositoryProvider = Provider<HabitRepository>((ref) {
return HabitRepository();
});
final routerProvider = Provider<GoRouter>((ref) {
return GoRouter(/* ... */);
});
final aiServiceProvider = Provider<AiService>((ref) {
return RemoteAiService();
});
3.4 StreamProvider --- 流式数据
RevenueCat 的授权状态需要持续监听变化:
dart
final revenueCatEntitlementProvider = StreamProvider<EntitlementInfo?>((ref) {
return RevenueCatService.entitlementStream;
});
3.5 FutureProvider --- 异步一次性数据
dart
final aiChatRemainingProvider = FutureProvider<int>((ref) async {
final prefs = await SharedPreferences.getInstance();
return prefs.getInt('ai_chat_count') ?? 0;
});
小结
| Provider 类型 | 适用场景 |
|---|---|
StateNotifierProvider |
复杂可变状态 + 多操作方法 |
StateProvider |
简单值(布尔、数字、字符串) |
Provider |
依赖注入、只读服务 |
StreamProvider |
持续变化的异步数据流 |
FutureProvider |
一次性异步数据 |
这种细粒度使用不同 Provider 类型的做法,避免了「一把梭用 ChangeNotifier」带来的性能和不必要的 rebuild。
四、本地存储:Hive + SharedPreferences 双轨策略
4.1 为什么是双轨?
| 存储方式 | 存放内容 | 原因 |
|---|---|---|
| Hive | Habit、CheckinLog、WishItem | 结构化数据、需要高性能读写、数据量大 |
| SharedPreferences | 主题设置、金币数、AI 用量、用户资料 | 轻量 KV,存取简单 |
如果全扔 Hive,配置类数据需要开 Box 很浪费;如果全扔 SharedPreferences,结构化数据序列化成本高。两者互补是最佳实践。
4.2 Hive 的 Adapter 手写
Hive 官方推荐用 hive_generator 自动生成 Adapter,但我选择了手写,原因有二:
- 减少依赖:少一个 build_runner 的构建步骤,CI/CD 更快。
- 完全可控 :字段变更时直接改,不需要每次
flutter pub run build_runner build。
dart
class HabitAdapter extends TypeAdapter<Habit> {
@override
final int typeId = 0;
@override
Habit read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return Habit(
id: fields[0] as String,
name: fields[1] as String,
icon: fields[2] as String?,
color: fields[3] as int,
weekDays: (fields[4] as List).cast<int>(),
createdAt: fields[5] as DateTime,
isActive: fields[6] as bool? ?? true,
category: fields[7] as String?,
);
}
@override
void write(BinaryWriter writer, Habit obj) {
writer.writeByte(8); // 8 个字段
writer.writeByte(0); writer.write(obj.id);
writer.writeByte(1); writer.write(obj.name);
writer.writeByte(2); writer.write(obj.icon);
writer.writeByte(3); writer.write(obj.color);
writer.writeByte(4); writer.write(obj.weekDays);
writer.writeByte(5); writer.write(obj.createdAt);
writer.writeByte(6); writer.write(obj.isActive);
writer.writeByte(7); writer.write(obj.category);
}
}
手写 Adapter 的关键在于字段编号(0-7)必须与 @HiveField(n) 注解保持一致,添加新字段时往后追加即可,不会破坏已有数据的兼容性。
4.3 初始化流程
dart
class HiveInit {
static late Box<Habit> habitsBox;
static late Box<CheckinLog> checkinsBox;
static Future<void> init() async {
final appDocDir = await getApplicationDocumentsDirectory();
Hive.init(appDocDir.path);
Hive.registerAdapter(HabitAdapter());
Hive.registerAdapter(CheckinLogAdapter());
habitsBox = await Hive.openBox<Habit>('habits');
checkinsBox = await Hive.openBox<CheckinLog>('checkins');
}
}
全局只开两个 Box,通过 static late 暴露给各处使用,避免了到处 open/close 的资源泄漏。
五、AI 深度集成:不止是套个 API
这是整个项目技术密度最高的部分。AI 不是简单的「接个聊天接口」,而是深度融入产品各个角落。
5.1 服务抽象层
先看看接口设计:
dart
abstract class AiService {
Future<String> generateEncouragement({
required int streakDays,
required int todayDone,
required int todayTotal,
});
Future<String> analyzeInsights();
Future<List<HabitTemplate>> recommendTemplates();
Future<String> explainMissedDay({required DateTime date});
}
为什么要有抽象接口?三个原因:
- Mock 友好:测试时可以注入 Mock 实现。
- 离线兜底 :网络挂了有
OfflineAiService顶上,用户感知不到异常。 - 模型切换 :将来换 OpenAI、Claude 或自有模型,
RemoteAiService内部切换即可,调用方零改动。
5.2 DeepSeek API 调用
核心调用链路很简单:
bash
UI → Provider → AiService → http.post(deepseek/v1/chat/completions)
但细节远不止这么简单:
- 多语言 System Prompt:根据当前 locale 动态选择中文/英文/日文等系统提示词,确保 AI 回答语言匹配。
- 每日用量限制 :免费版每天 10 次对话,通过 SharedPreferences 计数 +
FutureProvider暴露剩余次数。 - 个性化注入:用户对 AI 回答点「有用」后,Q&A 样本会被追加到 system prompt 的 addendum 中(上限 30 条),让 AI 越来越懂用户。
5.3 AI 个性化进化系统
这是我最喜欢的设计之一。实现很简单但效果很好:
dart
class AiPersonalization {
static const _maxDaily = 3;
static const _maxTotal = 30;
Future<void> addFeedback(String question, String answer) async {
final prefs = await SharedPreferences.getInstance();
// 每日上限检查
final today = DateTime.now().toIso8601String().substring(0, 10);
final todayCount = prefs.getInt('ai_feedback_$today') ?? 0;
if (todayCount >= _maxDaily) return;
// 总数上限检查
final samples = prefs.getStringList('ai_feedback_samples') ?? [];
if (samples.length >= _maxTotal) return;
// 存入样本
samples.add('Q: $question\nA: $answer');
await prefs.setStringList('ai_feedback_samples', samples);
await prefs.setInt('ai_feedback_$today', todayCount + 1);
}
String buildAddendum() {
final prefs = SharedPreferences.getInstance(); // 实际代码用了 await
final samples = prefs.getStringList('ai_feedback_samples') ?? [];
if (samples.isEmpty) return '';
return '\n---\n以下是用户之前标记为"有用"的对话,请参考其风格:\n${samples.join('\n\n')}';
}
}
这样每次 AI 调用时,buildAddendum() 返回的内容会被拼接到 system prompt 末尾,AI 的回答风格会逐渐向用户偏好靠拢。用极低的成本实现了「千人千面」的效果。
5.4 自研 Markdown 轻量渲染器
AI 返回的内容通常带 Markdown 格式。现有方案如 flutter_markdown 太重(依赖 webview 级别的解析器),所以造了个轻量轮子:
dart
class MarkdownLite extends StatelessWidget {
final String source;
// 支持的语法:
// - `**bold**` 加粗
// - `*italic*` 斜体
// - `` `code` `` 行内代码
// - `# ` `## ` `### ` 标题
// - `- ` 无序列表
// - `1. ` 有序列表
List<Widget> _parse(String input, TextStyle style, BuildContext context) {
final lines = input.split('\n');
// 逐行匹配正则,构建对应的 Text.rich() 或 Row
// ...
}
}
核心思路:逐行扫描 + 正则匹配,每行生成对应的 Widget。对于 DeepSeek 的输出格式来说完全够用,而且性能远优于通用 Markdown 解析器。
六、几个有意思的技术实现
6.1 CustomPainter 实现打卡勾选动画
这是用户每天都会交互的核心组件,体验优化到了细节:
dart
class CheckinCheckbox extends StatefulWidget {
final bool checked;
final Color color;
final VoidCallback onTap;
// ...
}
class _CheckinCheckboxState extends State<CheckinCheckbox>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
late final Animation<double> _scale;
late final Animation<double> _checkProgress;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 360),
);
_scale = Tween(begin: 0.85, end: 1.0)
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutBack));
_checkProgress = CurvedAnimation(
parent: _ctrl, curve: Curves.easeOutCubic);
}
@override
void didUpdateWidget(CheckinCheckbox old) {
super.didUpdateWidget(old);
if (widget.checked != old.checked) {
if (!kIsWeb) HapticFeedback.lightImpact(); // 触觉反馈
if (widget.checked) {
_ctrl.forward(from: 0);
} else {
_ctrl.reverse();
}
}
}
}
动画拆解为三段:
- scale 变化 (0.85 → 1.0),使用
easeOutBack曲线产生弹性回弹效果。 - 颜色过渡 (透明 → 主题色),通过
Color.lerp()插值。 - 勾号绘制 ,通过
_CheckPainter的_checkProgress参数控制路径绘制进度。
三段动画由同一个 AnimationController 驱动,didUpdateWidget 中根据状态变化自动触发,完全无手动状态管理。
6.2 纯 Overlay Toast 系统
Flutter 原生的 SnackBar 有个痛点:在 Material 3 下会继承 ThemeData 的配色,导致背景色不可控。于是直接基于 Overlay 实现了自定义 Toast:
dart
class ToastService {
static OverlayEntry? _current;
static final _queue = <_ToastItem>[];
static void _enqueue(String message, {IconData? icon, ...}) {
_queue.add(_ToastItem(/* ... */));
_showNext();
}
static void _showNext() {
if (_current != null || _queue.isEmpty) return;
final item = _queue.removeAt(0);
late final OverlayEntry entry;
entry = OverlayEntry(
builder: (context) => _ToastWidget(
message: item.message,
onDismiss: () {
entry.remove();
_current = null;
_showNext(); // 队列消费下一条
},
),
);
_current = entry;
overlay.insert(entry);
}
// 便捷方法
static void success(String msg) => _enqueue(msg,
icon: Icons.check_circle_rounded,
background: Color(0xFF059669));
static void error(String msg) => _enqueue(msg,
icon: Icons.error_rounded,
background: Color(0xFFDC2626));
}
设计亮点:
- 队列机制:多个 Toast 不会重叠,逐条显示。
- 完全绕过 SnackBar:不受任何 ThemeData 干扰,背景色、文字色完全自定义。
- 内置三种样式 :
success()/error()/info(),调用方只需一行代码。
6.3 可拖动的 FAB 按钮
首页的 FAB(+ 号按钮)支持水平拖动,拖动超过 50px 会自动切换收起/展开状态:
- 展开态:显示 + 号,点击跳转新建习惯。
- 收起态:显示小箭头,提示用户可拖动。
dart
// 伪代码展示核心逻辑
GestureDetector(
onHorizontalDragUpdate: (details) {
_offset += details.delta.dx;
setState(() {});
},
onHorizontalDragEnd: (details) {
if (_offset.abs() > 50) {
setState(() => _fabCollapsed = !_fabCollapsed);
}
_offset = 0; // 复位
setState(() {});
},
child: AnimatedContainer(
duration: Duration(milliseconds: 300),
curve: Curves.easeOutBack,
width: _fabCollapsed ? 48 : 56,
child: _fabCollapsed
? Icon(Icons.chevron_right)
: Icon(Icons.add),
),
)
配合 AnimatedContainer + Curves.easeOutBack,状态切换有自然的弹性动效,交互手感很好。
6.4 年度热力图(GitHub 风格)
统计页的核心视觉组件,类似 GitHub 的 contribution graph:
dart
class YearlyHeatmapWidget extends ConsumerWidget {
final int year;
final bool compact; // 支持紧凑/大图两种模式
@override
Widget build(BuildContext context, WidgetRef ref) {
final data = ref.watch(yearlyHeatmapProvider(year));
return data.when(
data: (checkins) => compact
? _CompactView(year: year, checkins: checkins)
: _BigGridView(year: year, checkins: checkins),
// loading / error ...
);
}
}
实现要点:
Map<DateTime, int>作为数据源,key 是日期,value 是打卡数量。- 颜色深度由打卡数映射到 0-4 级色阶,视觉上区分「无 / 少量 / 中等 / 大量 / 全勤」。
- 紧凑模式:12 列水平排列,适合列表页嵌入。
- 大图模式:每行 7 格(周一至周日),每个月可点击展开查看详情。
6.5 平台条件导入
项目中多处使用了 Dart 的平台条件导入,解决 Web 和原生平台的差异化问题:
dart
// 通知服务
import '_fln_stub.dart' if (dart.library.io) '_fln_real.dart';
// 时区支持
import '_tz_stub.dart'
if (dart.library.io) '_tz_io.dart'
if (dart.library.html) '_tz_web.dart';
// RevenueCat
import '_purchases_stub.dart' if (dart.library.io) '_purchases_real.dart';
这对 Flutter Web 支持非常关键:flutter_local_notifications、timezone、RevenueCat 在 Web 平台上不兼容,通过条件导入注入 stub 实现,确保代码在任何平台都能编译通过且不崩溃。
七、主题系统:Material 3 + 12 套预设
7.1 预设主题注册
dart
class ThemePreset {
final String name;
final Color seed;
final LinearGradient gradient;
final bool free; // 免费 or 会员专属
}
class ThemePresets {
static const indigo = ThemePreset('靛蓝',
Color(0xFF6366F1),
LinearGradient(colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)]),
true);
static const rose = ThemePreset('薄暮',
Color(0xFFEC4899),
LinearGradient(colors: [Color(0xFFEC4899), Color(0xFFF43F5E)]),
true);
static const amber = ThemePreset('暖阳', ...); // 会员
static const emerald = ThemePreset('翡翠', ...); // 会员
// ... 共 12 套
}
- 前 2 套免费,后 10 套会员专属 --- 这是产品策略层面的技术落地。
- 每套有独立的 seed color (用于
ColorScheme.fromSeed())和 gradient(用于卡片、按钮等装饰)。 - 用户选择的主题通过
SharedPreferences持久化,启动时恢复。
7.2 ThemeData 构建
dart
ThemeData _buildTheme(Color seed, Brightness brightness) {
final scheme = ColorScheme.fromSeed(
seedColor: seed,
brightness: brightness,
);
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
cardTheme: CardThemeData(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: BorderSide(color: scheme.outlineVariant, width: 0.5),
),
),
inputDecorationTheme: InputDecorationTheme(
borderRadius: BorderRadius.circular(14),
// ...
),
// AppBarTheme、FABTheme、NavigationBarTheme 等全面自定义
);
}
细节:
- 圆角统一:卡片 20px、输入框 14px、按钮 14px,视觉一致。
- 边框微妙 :
outlineVariant+ 0.5px 宽度,既区分层次又不喧宾夺主。 - 深色模式:深色 scaffold 背景、不同的卡片色,暗色下也能保持好的可读性。
八、国际化的工程化实践
项目支持 10 种语言 ,含阿拉伯语(RTL 语言)。Flutter 官方国际化方案 .arb 文件 + AppLocalizations:
bash
lib/l10n/
├── app_ar.arb # 阿拉伯语
├── app_de.arb # 德语
├── app_en.arb # 英语
├── app_es.arb # 西班牙语
├── app_fr.arb # 法语
├── app_ja.arb # 日语
├── app_ko.arb # 韩语
├── app_pt.arb # 葡萄牙语
├── app_ru.arb # 俄语
└── app_zh.arb # 简体中文
AI 的系统提示词也随语言动态切换:
dart
String _systemPrompt(BuildContext context) {
final locale = Localizations.localeOf(context).languageCode;
switch (locale) {
case 'zh': return '你是一个习惯教练,请用中文回复...';
case 'ja': return 'あなたは習慣コーチです。日本語で返信してください...';
case 'en': return 'You are a habit coach. Reply in English...';
// ... 其他语言
default: return 'You are a habit coach...';
}
}
九、商业化的技术实现
9.1 RevenueCat 订阅集成
dart
class RevenueCatService {
static final _entitlementController =
StreamController<EntitlementInfo?>.broadcast();
static Stream<EntitlementInfo?> get entitlementStream =>
_entitlementController.stream;
static Future<void> init() async {
await Purchases.configure(
PurchasesConfiguration('YOUR_API_KEY')
..entitlementVerificationMode =
EntitlementVerificationMode.informational,
);
Purchases.addCustomerInfoUpdateListener((info) {
_entitlementController.add(info.entitlements.active['premium']);
});
}
}
通过 StreamController 将 RevenueCat 的回调转为 Dart Stream,再包一层 StreamProvider,UI 层就能响应式地感知订阅状态变化。
9.2 免费版限制策略
dart
// monetization 模块
bool canCreateMoreHabits(int currentCount) {
if (isPremium) return true;
return currentCount < 5; // 免费版最多 5 个习惯
}
bool canUseAiChat(int todayCount) {
if (isPremium) return true;
return todayCount < 10; // 免费版每天 10 次 AI 对话
}
限制规则集中在 core/monetization/ 中,UI 层调用时传入当前状态即可,策略清晰且容易调整。
十、总结与思考
技术收获
- Riverpod 的组合能力远超 Provider:不同类型的 Provider 各司其职,告别了「一切皆 ChangeNotifier」的混乱。
- Feature-First 架构值得投入:初期多花一点时间建好目录规范,后期维护成本大幅降低。
- 手写 Hive Adapter 并不难:Hive 的二进制协议很简单,手写一次就懂了,还能省掉代码生成的依赖。
- AI 集成不能止于「接个 API」:个性化、多语言 prompt、离线兜底、用量限制,这些「工程化」的细节才是产品质量的关键。
- Dart 的条件导入是跨平台利器 :比
kIsWeb分支判断更优雅,编译期就能剔除不可用代码。
可改进的方向
- 数据备份/云同步:目前纯本地存储,换设备数据就丢了。
- Widget 测试和集成测试:目前测试覆盖不足。
- SQLite 替代 Hive :当数据量增长到需要复杂查询时,可以考虑迁移到
drift。 - CI/CD 自动化:配一套 GitHub Actions 做 lint + test + build。
以上就是 HabitGo 项目在技术层面的完整剖析。如果你也在做 Flutter 项目,或者对某个技术点感兴趣,欢迎评论区交流讨论!