普通视图

发现新文章,点击刷新页面。
昨天 — 2025年11月23日首页

Flutter 实战:为开源记账 App 实现优雅的暗黑模式(Design Token + 动态主题)

作者 全栈阿笑
2025年11月22日 11:39

最近为我的开源记账 App BeeCount 蜜蜂记账 实现了暗黑模式,踩了不少坑,也总结了一些经验。这篇文章会详细介绍整个技术方案和实现过程,希望对大家有帮助。

效果预览

1.jpg

2.jpg

3.jpg

4.jpg

5.jpg

6.jpg

暗黑模式采用「纯黑背景 + 主题色边框」的设计方案:

  • 纯黑背景:OLED 友好,夜间护眼
  • 主题色边框:保留个性化,层次分明
  • 全局适配:所有页面、组件统一风格

技术方案概述

在开始写代码之前,我先梳理了整体的技术方案:

  1. Design Token 系统:统一管理所有颜色,一处修改全局生效
  2. 主题状态管理:使用 Riverpod 管理主题模式,支持持久化
  3. MaterialApp 配置:配置 theme 和 darkTheme,支持跟随系统切换
  4. 组件级适配:针对特殊组件(图表、弹窗等)单独处理

下面逐一介绍。


1. Design Token 系统

为什么需要 Design Token?

项目初期,颜色都是硬编码的:

// 到处都是这种代码
Text('Hello', style: TextStyle(color: Colors.black87))
Container(color: Colors.white)

这样做的问题:

  • 要支持暗黑模式,得全局搜索替换
  • 颜色值不统一,同样的「次要文字」可能写成 black54greyColors.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),
          ),
        ),
      ],
    ),
  );
}

弹窗适配

showModalBottomSheetshowDialog 等弹窗需要单独处理:

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:图片和图标

问题:某些图标/图片在暗黑模式下对比度不够。

解决

  1. 使用 Icon 时,显式指定颜色而非依赖默认值
  2. 对于图片,考虑准备暗黑模式版本,或使用 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 不省电、对比度低

最终选择纯黑,因为:

  1. 现在大部分手机是 OLED 屏,纯黑真的能省电
  2. 配合主题色边框,不会显得单调
  3. 极简风格更符合这个 App 的调性

为什么用主题色边框?

  1. 保留个性化:用户可以自定义主题色,暗黑模式下也能体现
  2. 层次分明:边框让卡片和背景区分开,不会黑成一片
  3. 品牌感:彩色点缀让界面更有记忆点
  4. 技术简单:只需要在暗黑模式下加一个 border,不用准备两套资源

项目地址

GitHub: github.com/TNT-Likely/…

这是一个完全开源的 Flutter 记账 App,目前已有 600+ Star

主要特性:

  • 完全免费,无广告
  • 隐私优先,数据本地存储
  • 支持 WebDAV/Supabase 自托管同步
  • 多账本、账户管理、统计报表等完整功能
  • 支持 iOS 和 Android

如果这篇文章或这个项目对你有帮助,欢迎给个 ⭐ Star 支持一下!

代码都是开源的,欢迎参考和借鉴。


下载体验

欢迎试用并反馈意见!有问题可以在 GitHub Issues 或评论区告诉我。

❌
❌