UI 与交互篇(4/6):深色模式、主题系统与设计令牌

3 阅读5分钟

问题背景:业务场景 + 现象

项目做到中后期,UI 一般会经历这几个阶段:

  • 早期为了赶进度,颜色直接写在组件里:Color(0xFF...) 到处都是。
  • 新增深色模式时,只能全局搜颜色替换,改一处炸三处。
  • 活动页、会员页、普通页各自定义“品牌色”,结果按钮、文本、卡片的视觉层级不一致。
  • 设计同学给了 Figma Token,但代码里没有统一映射,开发只能“猜”语义色。

最终表现就是:UI 看起来能用,但不可持续维护。一旦你需要支持深色、节日皮肤、A/B 主题,成本会直线上升。


原因分析:核心原理 + 排查过程

根因并不是“不会写 ThemeData”,而是缺少三层抽象:

  1. 原始值层(Raw Value):具体色值、圆角、间距、字号。
  2. 语义层(Semantic Token):如 textPrimarysurfaceCarddanger
  3. 组件层(Component Token):如 buttonPrimaryBgdialogRadiustagSuccessText

多数项目的问题是直接从组件跳到原始值,导致:

  • 组件直接依赖色值,无法在深色模式下按语义切换。
  • 同一个“主文本色”在 10 个文件里写成 10 个近似值。
  • 设计改动无法批量生效(因为没有统一入口)。

排查建议按下面顺序:

  • 用全局搜索统计 Color(TextStyle(BorderRadius.circular( 出现频次。
  • 抽样 3 个高频页面,记录重复出现的颜色/字号/间距。
  • 找出“同语义不同值”与“同值不同语义”的混乱点。
  • 决定先治理 颜色 token,再扩展到字号/圆角/间距(不要一步到位全改)。

解决方案:方案对比 + 最终选择

方案 A:只用 ThemeData.light()/dark() 直接覆盖

优点:接入快。
缺点:语义层不清晰,长期还是会回到“写死色值”。

方案 B:自定义 ThemeExtension + Token 分层(推荐)

优点:

  • Token 语义明确,业务组件只依赖语义,不碰原始色值。
  • 深浅色、品牌主题切换只换一份 Token 映射。
  • 支持逐步迁移:旧页面与新体系可共存。

缺点:前期需要统一命名规范和迁移策略。

最终实践(推荐落地路径)

  • 第 1 周:只治理颜色(Text/Surface/Primary/Status)。
  • 第 2 周:补充组件 token(Button/Card/Dialog)。
  • 第 3 周:把字号、圆角、间距纳入设计令牌。
  • 全程“新页面强制新 token,旧页面按需求触达迁移”。

关键代码:最小必要代码片段

1)定义语义 Token(支持深浅两套)

import 'package:flutter/material.dart';

@immutable
class AppColors extends ThemeExtension<AppColors> {
  final Color textPrimary;
  final Color textSecondary;
  final Color surfacePage;
  final Color surfaceCard;
  final Color primary;
  final Color danger;

  const AppColors({
    required this.textPrimary,
    required this.textSecondary,
    required this.surfacePage,
    required this.surfaceCard,
    required this.primary,
    required this.danger,
  });

  static const light = AppColors(
    textPrimary: Color(0xFF111111),
    textSecondary: Color(0xFF666666),
    surfacePage: Color(0xFFF7F8FA),
    surfaceCard: Colors.white,
    primary: Color(0xFF2F6BFF),
    danger: Color(0xFFFF4D4F),
  );

  static const dark = AppColors(
    textPrimary: Color(0xFFF2F3F5),
    textSecondary: Color(0xFFA1A6B2),
    surfacePage: Color(0xFF111318),
    surfaceCard: Color(0xFF1A1D24),
    primary: Color(0xFF6C8CFF),
    danger: Color(0xFFFF7875),
  );

  @override
  AppColors copyWith({
    Color? textPrimary,
    Color? textSecondary,
    Color? surfacePage,
    Color? surfaceCard,
    Color? primary,
    Color? danger,
  }) {
    return AppColors(
      textPrimary: textPrimary ?? this.textPrimary,
      textSecondary: textSecondary ?? this.textSecondary,
      surfacePage: surfacePage ?? this.surfacePage,
      surfaceCard: surfaceCard ?? this.surfaceCard,
      primary: primary ?? this.primary,
      danger: danger ?? this.danger,
    );
  }

  @override
  AppColors lerp(ThemeExtension<AppColors>? other, double t) {
    if (other is! AppColors) return this;
    return AppColors(
      textPrimary: Color.lerp(textPrimary, other.textPrimary, t)!,
      textSecondary: Color.lerp(textSecondary, other.textSecondary, t)!,
      surfacePage: Color.lerp(surfacePage, other.surfacePage, t)!,
      surfaceCard: Color.lerp(surfaceCard, other.surfaceCard, t)!,
      primary: Color.lerp(primary, other.primary, t)!,
      danger: Color.lerp(danger, other.danger, t)!,
    );
  }
}

2)挂载到 ThemeData

ThemeData buildLightTheme() {
  return ThemeData(
    brightness: Brightness.light,
    extensions: const <ThemeExtension<dynamic>>[
      AppColors.light,
    ],
  );
}

ThemeData buildDarkTheme() {
  return ThemeData(
    brightness: Brightness.dark,
    extensions: const <ThemeExtension<dynamic>>[
      AppColors.dark,
    ],
  );
}

3)组件只消费语义,不直接写色值

extension AppThemeX on BuildContext {
  AppColors get appColors => Theme.of(this).extension<AppColors>()!;
}

class ProfileCard extends StatelessWidget {
  const ProfileCard({super.key});

  @override
  Widget build(BuildContext context) {
    final c = context.appColors;
    return Container(
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: c.surfaceCard,
        borderRadius: BorderRadius.circular(12),
      ),
      child: Text(
        '会员中心',
        style: TextStyle(color: c.textPrimary, fontSize: 16),
      ),
    );
  }
}

4)主题切换(跟随系统 + 手动覆盖)

enum ThemeModeType { system, light, dark }

// 持久化后从本地恢复,映射到 MaterialApp.themeMode
ThemeMode mapThemeMode(ThemeModeType mode) {
  switch (mode) {
    case ThemeModeType.light:
      return ThemeMode.light;
    case ThemeModeType.dark:
      return ThemeMode.dark;
    case ThemeModeType.system:
      return ThemeMode.system;
  }
}

效果验证:数据/截图/日志

可验证结果建议从“可感知 + 可量化”两条线推进:

  • 一致性:抽查首页、列表、弹窗、设置页,深浅色下文本/背景/状态色层级一致。
  • 改动成本:主品牌色变更时,改 Token 映射即可生效,无需全局替换色值。
  • 回归效率:主题相关回归点从“页面清单”变为“语义清单”(Text/Surface/Primary/Status)。
  • 线上稳定性:主题切换后无“局部闪白/文字不可见/对比度不足”问题。

建议补一份日志或检查项:

  • 深色模式下 danger 在卡片背景上的对比度是否达标。
  • 系统主题切换时是否触发异常重建。
  • 弹窗、底部面板是否继承了当前 Theme(常见漏点)。

可复用结论:通用经验 + 避坑清单

通用经验

  • 先有“语义”,再谈“视觉值”;不要让组件直接依赖十六进制色值。
  • 主题系统不是只有颜色,最终要覆盖 typography/spacing/radius/elevation。
  • 设计令牌必须可追溯:命名统一、来源可查、变更有版本。
  • 迁移策略要现实:允许新旧并存,但新增页面必须走新 token。

避坑清单

  • Theme.of(context).colorScheme 和自定义 token 混用到失控。
  • 深色模式只改背景,不改文本和状态色,导致对比度不达标。
  • 一个语义色在不同组件被二次“魔改”(withOpacity 滥用)。
  • 忽略第三方组件主题透传,导致局部 UI 风格断层。
  • 没有“设计到代码”的映射表,新同学无法判断该用哪个 token。

下一篇可接:自定义弹窗/底部面板的统一抽象(UI 与交互篇 5/6)