问题背景:业务场景 + 现象
项目做到中后期,UI 一般会经历这几个阶段:
- 早期为了赶进度,颜色直接写在组件里:
Color(0xFF...)到处都是。 - 新增深色模式时,只能全局搜颜色替换,改一处炸三处。
- 活动页、会员页、普通页各自定义“品牌色”,结果按钮、文本、卡片的视觉层级不一致。
- 设计同学给了 Figma Token,但代码里没有统一映射,开发只能“猜”语义色。
最终表现就是:UI 看起来能用,但不可持续维护。一旦你需要支持深色、节日皮肤、A/B 主题,成本会直线上升。
原因分析:核心原理 + 排查过程
根因并不是“不会写 ThemeData”,而是缺少三层抽象:
- 原始值层(Raw Value):具体色值、圆角、间距、字号。
- 语义层(Semantic Token):如
textPrimary、surfaceCard、danger。 - 组件层(Component Token):如
buttonPrimaryBg、dialogRadius、tagSuccessText。
多数项目的问题是直接从组件跳到原始值,导致:
- 组件直接依赖色值,无法在深色模式下按语义切换。
- 同一个“主文本色”在 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)。