Flutter 实战:为开源记账 App 实现优雅的暗黑模式(Design Token + 动态主题)
最近为我的开源记账 App BeeCount 蜜蜂记账 实现了暗黑模式,踩了不少坑,也总结了一些经验。这篇文章会详细介绍整个技术方案和实现过程,希望对大家有帮助。
效果预览
![]()
![]()
![]()
![]()
![]()
![]()
暗黑模式采用「纯黑背景 + 主题色边框」的设计方案:
- 纯黑背景:OLED 友好,夜间护眼
- 主题色边框:保留个性化,层次分明
- 全局适配:所有页面、组件统一风格
技术方案概述
在开始写代码之前,我先梳理了整体的技术方案:
- Design Token 系统:统一管理所有颜色,一处修改全局生效
- 主题状态管理:使用 Riverpod 管理主题模式,支持持久化
- MaterialApp 配置:配置 theme 和 darkTheme,支持跟随系统切换
- 组件级适配:针对特殊组件(图表、弹窗等)单独处理
下面逐一介绍。
1. Design Token 系统
为什么需要 Design Token?
项目初期,颜色都是硬编码的:
// 到处都是这种代码
Text('Hello', style: TextStyle(color: Colors.black87))
Container(color: Colors.white)
这样做的问题:
- 要支持暗黑模式,得全局搜索替换
- 颜色值不统一,同样的「次要文字」可能写成
black54、grey、Colors.grey.shade600 - 维护困难,改一个颜色要改 N 个地方
所以我借鉴了 Web 前端的 CSS Variables 思路,建立了一套 Design Token 系统。
核心实现
文件:lib/styles/tokens.dart
import 'package:flutter/material.dart';
/// Design Token 系统
/// 统一管理所有颜色,自动适配亮色/暗黑模式
class BeeTokens {
// ========== 工具方法 ==========
/// 判断当前是否暗黑模式
static bool isDark(BuildContext context) {
return Theme.of(context).brightness == Brightness.dark;
}
/// 获取主题色
static Color primary(BuildContext context) {
return Theme.of(context).primaryColor;
}
// ========== 背景色 ==========
/// 页面背景色
static Color scaffoldBackground(BuildContext context) {
return isDark(context) ? Colors.black : const Color(0xFFF5F5F5);
}
/// 卡片/表面背景色
static Color surface(BuildContext context) {
return isDark(context) ? Colors.black : Colors.white;
}
/// 提升的表面(如弹窗、下拉菜单)
static Color surfaceElevated(BuildContext context) {
return isDark(context) ? const Color(0xFF1C1C1E) : Colors.white;
}
/// Header 背景色
static Color surfaceHeader(BuildContext context) {
return isDark(context) ? Colors.black : Colors.white;
}
// ========== 文字颜色 ==========
/// 主要文字(标题、正文)
static Color textPrimary(BuildContext context) {
return isDark(context) ? Colors.white : Colors.black87;
}
/// 次要文字(说明文字、副标题)
static Color textSecondary(BuildContext context) {
return isDark(context) ? Colors.white70 : Colors.black54;
}
/// 提示文字(placeholder、禁用状态)
static Color textTertiary(BuildContext context) {
return isDark(context) ? Colors.white38 : Colors.black38;
}
// ========== 图标颜色 ==========
static Color iconPrimary(BuildContext context) {
return isDark(context) ? Colors.white : Colors.black87;
}
static Color iconSecondary(BuildContext context) {
return isDark(context) ? Colors.white70 : Colors.black54;
}
static Color iconTertiary(BuildContext context) {
return isDark(context) ? Colors.white38 : Colors.black38;
}
// ========== 边框和分割线 ==========
/// 普通边框
static Color border(BuildContext context) {
if (isDark(context)) {
return Theme.of(context).primaryColor.withOpacity(0.3);
}
return Colors.grey.shade200;
}
/// 分割线
static Color divider(BuildContext context) {
return isDark(context) ? Colors.white12 : Colors.grey.shade200;
}
/// 主题色边框(用于强调)
static Color borderThemed(BuildContext context) {
return Theme.of(context).primaryColor.withOpacity(0.3);
}
// ========== 语义颜色 ==========
/// 成功色(收入、正数)
static Color success(BuildContext context) {
return isDark(context) ? const Color(0xFF4CD964) : const Color(0xFF34C759);
}
/// 错误色(支出、负数、错误提示)
static Color error(BuildContext context) {
return isDark(context) ? const Color(0xFFFF6B6B) : const Color(0xFFFF3B30);
}
/// 警告色
static Color warning(BuildContext context) {
return isDark(context) ? const Color(0xFFFFD60A) : const Color(0xFFFF9500);
}
}
使用方式
替换原来的硬编码颜色:
// ❌ 之前
Container(
color: Colors.white,
child: Text(
'Hello',
style: TextStyle(color: Colors.black87),
),
)
// ✅ 之后
Container(
color: BeeTokens.surface(context),
child: Text(
'Hello',
style: TextStyle(color: BeeTokens.textPrimary(context)),
),
)
Token 命名规范
我采用了语义化命名,让代码更易读:
| Token | 用途 | 亮色 | 暗色 |
|---|---|---|---|
scaffoldBackground |
页面背景 | #F5F5F5 | #000000 |
surface |
卡片背景 | #FFFFFF | #000000 |
surfaceElevated |
弹窗背景 | #FFFFFF | #1C1C1E |
textPrimary |
主要文字 | black87 | white |
textSecondary |
次要文字 | black54 | white70 |
textTertiary |
提示文字 | black38 | white38 |
border |
卡片边框 | grey200 | 主题色30% |
divider |
分割线 | grey200 | white12 |
2. 主题状态管理
Provider 定义
使用 Riverpod 管理主题状态:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// 主题模式枚举
enum ThemeModeOption {
system, // 跟随系统
light, // 始终亮色
dark, // 始终暗黑
}
/// 扩展方法:转换为 Flutter 的 ThemeMode
extension ThemeModeOptionExtension on ThemeModeOption {
ThemeMode toThemeMode() {
switch (this) {
case ThemeModeOption.system:
return ThemeMode.system;
case ThemeModeOption.light:
return ThemeMode.light;
case ThemeModeOption.dark:
return ThemeMode.dark;
}
}
String toDisplayName(BuildContext context) {
switch (this) {
case ThemeModeOption.system:
return '跟随系统';
case ThemeModeOption.light:
return '始终亮色';
case ThemeModeOption.dark:
return '始终暗黑';
}
}
}
/// 主题模式 Provider
final themeModeProvider = StateNotifierProvider<ThemeModeNotifier, ThemeModeOption>((ref) {
return ThemeModeNotifier();
});
class ThemeModeNotifier extends StateNotifier<ThemeModeOption> {
ThemeModeNotifier() : super(ThemeModeOption.system) {
_loadFromPrefs();
}
static const _key = 'theme_mode';
Future<void> _loadFromPrefs() async {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getString(_key);
if (value != null) {
state = ThemeModeOption.values.firstWhere(
(e) => e.name == value,
orElse: () => ThemeModeOption.system,
);
}
}
Future<void> setThemeMode(ThemeModeOption mode) async {
state = mode;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_key, mode.name);
}
}
主题色 Provider
用户可以自定义主题色,这个也要持久化:
/// 预设主题色列表
const List<Color> presetColors = [
Color(0xFFFFB020), // 蜜蜂黄(默认)
Color(0xFFFF6B6B), // 珊瑚红
Color(0xFF4ECDC4), // 薄荷绿
Color(0xFF5C7AEA), // 靛蓝
Color(0xFFAB47BC), // 紫罗兰
Color(0xFF26A69A), // 青绿
Color(0xFFEC407A), // 粉红
Color(0xFF42A5F5), // 天蓝
];
/// 主题色 Provider
final primaryColorProvider = StateNotifierProvider<PrimaryColorNotifier, Color>((ref) {
return PrimaryColorNotifier();
});
class PrimaryColorNotifier extends StateNotifier<Color> {
PrimaryColorNotifier() : super(presetColors[0]) {
_loadFromPrefs();
}
static const _key = 'primary_color';
Future<void> _loadFromPrefs() async {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getInt(_key);
if (value != null) {
state = Color(value);
}
}
Future<void> setPrimaryColor(Color color) async {
state = color;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_key, color.value);
}
}
3. MaterialApp 配置
在 App 入口处配置主题:
class MyApp extends ConsumerWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeMode = ref.watch(themeModeProvider);
final primaryColor = ref.watch(primaryColorProvider);
return MaterialApp(
title: 'BeeCount',
// 主题模式
themeMode: themeMode.toThemeMode(),
// 亮色主题
theme: ThemeData(
brightness: Brightness.light,
primaryColor: primaryColor,
scaffoldBackgroundColor: const Color(0xFFF5F5F5),
colorScheme: ColorScheme.light(
primary: primaryColor,
secondary: primaryColor,
surface: Colors.white,
),
appBarTheme: AppBarTheme(
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
elevation: 0,
systemOverlayStyle: SystemUiOverlayStyle.dark,
),
dividerTheme: DividerThemeData(
color: Colors.grey.shade200,
thickness: 0.5,
),
// ... 其他配置
),
// 暗黑主题
darkTheme: ThemeData(
brightness: Brightness.dark,
primaryColor: primaryColor,
scaffoldBackgroundColor: Colors.black,
colorScheme: ColorScheme.dark(
primary: primaryColor,
secondary: primaryColor,
surface: Colors.black,
),
appBarTheme: AppBarTheme(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
elevation: 0,
systemOverlayStyle: SystemUiOverlayStyle.light,
),
dividerTheme: const DividerThemeData(
color: Colors.white12,
thickness: 0.5,
),
// ... 其他配置
),
home: const HomePage(),
);
}
}
4. 组件级适配
卡片组件
封装一个统一的卡片组件:
class BeeCard extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry? margin;
final double borderRadius;
const BeeCard({
super.key,
required this.child,
this.padding,
this.margin,
this.borderRadius = 12,
});
@override
Widget build(BuildContext context) {
final isDark = BeeTokens.isDark(context);
return Container(
margin: margin,
padding: padding ?? const EdgeInsets.all(16),
decoration: BoxDecoration(
color: BeeTokens.surface(context),
borderRadius: BorderRadius.circular(borderRadius),
// 暗黑模式:主题色边框
border: isDark
? Border.all(color: BeeTokens.border(context), width: 1)
: null,
// 亮色模式:阴影
boxShadow: isDark
? null
: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: child,
);
}
}
图表适配
使用 fl_chart 库时,需要单独处理颜色:
import 'package:fl_chart/fl_chart.dart';
Widget buildLineChart(BuildContext context, List<FlSpot> spots) {
final isDark = BeeTokens.isDark(context);
final primaryColor = Theme.of(context).primaryColor;
return LineChart(
LineChartData(
// 网格线
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: 1,
getDrawingHorizontalLine: (value) => FlLine(
color: isDark ? Colors.white10 : Colors.grey.shade200,
strokeWidth: 1,
),
),
// 边框
borderData: FlBorderData(
show: true,
border: Border(
bottom: BorderSide(
color: isDark ? Colors.white24 : Colors.grey.shade300,
),
left: BorderSide(
color: isDark ? Colors.white24 : Colors.grey.shade300,
),
),
),
// 坐标轴标题
titlesData: FlTitlesData(
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) => Text(
'${value.toInt()}',
style: TextStyle(
color: BeeTokens.textSecondary(context),
fontSize: 12,
),
),
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) => Text(
'${value.toInt()}',
style: TextStyle(
color: BeeTokens.textSecondary(context),
fontSize: 12,
),
),
),
),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
// 数据线
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: true,
color: primaryColor,
barWidth: 2,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, barData, index) {
return FlDotCirclePainter(
radius: 4,
color: primaryColor,
strokeWidth: 2,
strokeColor: isDark ? Colors.black : Colors.white,
);
},
),
belowBarData: BarAreaData(
show: true,
color: primaryColor.withOpacity(0.1),
),
),
],
),
);
}
弹窗适配
showModalBottomSheet、showDialog 等弹窗需要单独处理:
Future<T?> showBeeBottomSheet<T>({
required BuildContext context,
required WidgetBuilder builder,
bool isScrollControlled = false,
}) {
return showModalBottomSheet<T>(
context: context,
isScrollControlled: isScrollControlled,
// 关键:指定背景色
backgroundColor: BeeTokens.surfaceElevated(context),
// 圆角
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: builder,
);
}
Future<T?> showBeeDialog<T>({
required BuildContext context,
required WidgetBuilder builder,
}) {
return showDialog<T>(
context: context,
builder: (context) => Dialog(
backgroundColor: BeeTokens.surfaceElevated(context),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
// 暗黑模式下加边框
side: BeeTokens.isDark(context)
? BorderSide(color: BeeTokens.border(context))
: BorderSide.none,
),
child: builder(context),
),
);
}
输入框适配
TextField(
decoration: InputDecoration(
hintText: '请输入',
hintStyle: TextStyle(color: BeeTokens.textTertiary(context)),
filled: true,
fillColor: BeeTokens.isDark(context)
? Colors.white.withOpacity(0.05)
: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: Theme.of(context).primaryColor,
width: 1.5,
),
),
),
style: TextStyle(color: BeeTokens.textPrimary(context)),
)
5. 踩坑记录
坑 1:硬编码颜色遍布全局
问题:项目历史代码中,颜色值散落在各处。
解决:我写了个脚本,全局搜索替换常见的硬编码颜色:
# 搜索硬编码颜色
grep -rn "Colors.black87" lib/
grep -rn "Colors.black54" lib/
grep -rn "Colors.white" lib/
grep -rn "Color(0xFF" lib/
然后逐个替换为对应的 Token。这个过程比较繁琐,但一劳永逸。
坑 2:弹窗背景色不生效
问题:showModalBottomSheet 在暗黑模式下背景还是白色。
原因:Flutter 的 BottomSheet 默认使用 Theme.of(context).canvasColor。
解决:显式指定 backgroundColor 参数。
showModalBottomSheet(
context: context,
backgroundColor: BeeTokens.surfaceElevated(context), // 必须指定!
builder: (context) => ...
)
坑 3:状态栏图标颜色
问题:暗黑模式下状态栏图标还是黑色,看不清。
解决:在 AppBar 或页面级别设置 SystemUiOverlayStyle:
// 方式 1:通过 AppBar
AppBar(
systemOverlayStyle: BeeTokens.isDark(context)
? SystemUiOverlayStyle.light
: SystemUiOverlayStyle.dark,
)
// 方式 2:通过 AnnotatedRegion
AnnotatedRegion<SystemUiOverlayStyle>(
value: BeeTokens.isDark(context)
? SystemUiOverlayStyle.light
: SystemUiOverlayStyle.dark,
child: Scaffold(...),
)
坑 4:CupertinoDatePicker 颜色
问题:iOS 风格的日期选择器在暗黑模式下颜色不对。
解决:用 CupertinoTheme 包裹:
CupertinoTheme(
data: CupertinoThemeData(
brightness: BeeTokens.isDark(context) ? Brightness.dark : Brightness.light,
primaryColor: Theme.of(context).primaryColor,
),
child: CupertinoDatePicker(...),
)
坑 5:图片和图标
问题:某些图标/图片在暗黑模式下对比度不够。
解决:
- 使用 Icon 时,显式指定颜色而非依赖默认值
- 对于图片,考虑准备暗黑模式版本,或使用
ColorFiltered调整
// 图标:显式指定颜色
Icon(
Icons.settings,
color: BeeTokens.iconPrimary(context), // 不要省略
)
// 图片:可以用 ColorFiltered 调整
ColorFiltered(
colorFilter: BeeTokens.isDark(context)
? const ColorFilter.mode(Colors.white, BlendMode.srcIn)
: null,
child: Image.asset('assets/logo.png'),
)
坑 6:主题切换后 Provider 状态
问题:主题切换后,某些页面颜色没更新。
原因:没有正确使用 ref.watch()。
解决:确保在 build 方法中使用 ref.watch() 监听 Provider:
// ❌ 错误:只在 initState 读取一次
class _MyPageState extends ConsumerState<MyPage> {
late Color _bgColor;
@override
void initState() {
super.initState();
_bgColor = BeeTokens.surface(context); // 这里读取后不会更新
}
}
// ✅ 正确:在 build 中监听
class _MyPageState extends ConsumerState<MyPage> {
@override
Widget build(BuildContext context) {
final bgColor = BeeTokens.surface(context); // 每次 build 都重新获取
// ...
}
}
设计思考
为什么选纯黑而不是深灰?
| 方案 | 优点 | 缺点 |
|---|---|---|
| 纯黑 #000000 | OLED 省电、对比度高、极简 | 可能显得「空洞」 |
| 深灰 #121212 | Material Design 推荐、柔和 | OLED 不省电、对比度低 |
最终选择纯黑,因为:
- 现在大部分手机是 OLED 屏,纯黑真的能省电
- 配合主题色边框,不会显得单调
- 极简风格更符合这个 App 的调性
为什么用主题色边框?
- 保留个性化:用户可以自定义主题色,暗黑模式下也能体现
- 层次分明:边框让卡片和背景区分开,不会黑成一片
- 品牌感:彩色点缀让界面更有记忆点
- 技术简单:只需要在暗黑模式下加一个 border,不用准备两套资源
项目地址
GitHub: github.com/TNT-Likely/…
这是一个完全开源的 Flutter 记账 App,目前已有 600+ Star。
主要特性:
- 完全免费,无广告
- 隐私优先,数据本地存储
- 支持 WebDAV/Supabase 自托管同步
- 多账本、账户管理、统计报表等完整功能
- 支持 iOS 和 Android
如果这篇文章或这个项目对你有帮助,欢迎给个 ⭐ Star 支持一下!
代码都是开源的,欢迎参考和借鉴。
下载体验
- iOS App Store(海外区):搜索「蜜蜂记账-简洁记账本」
- iOS TestFlight:testflight.apple.com/join/Eaw2rW…
- Android APK:github.com/TNT-Likely/…
欢迎试用并反馈意见!有问题可以在 GitHub Issues 或评论区告诉我。