Flutter 基础体系 · 第 10/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter 主题与设计系统:Material、ColorScheme、Token 和组件规范
1. 先区分四个层次:设计语言、主题、Token 和组件规范
在 Flutter 中,“主题”不是一个单独的颜色表。一个可维护的界面系统通常包含四个层次:
- Material:Google 提出的界面设计语言及其交互语义,例如按钮的层级、菜单、对话框、导航栏、状态反馈和动效。
- Theme:应用运行时提供给 Widget 树的主题对象,主要由
ThemeData承载。 - ColorScheme、Typography 等 Token:设计系统中的语义化设计变量,例如“主要颜色”“错误颜色”“正文样式”“小圆角”。
- 组件规范:把 Token 应用到具体组件,规定组件在不同状态下的颜色、尺寸、间距、形状、动效和可访问性行为。
它们的关系不是“Material 等于 ThemeData”:
Material 设计语义
↓
设计系统 Token:颜色、文字、间距、形状、动效
↓
ThemeData / ThemeExtension
↓
组件 Theme:ButtonTheme、InputDecorationTheme、CardTheme……
↓
具体 Widget 的状态与布局
例如,“主操作按钮使用品牌色”不是完整规范。完整规范至少还要回答:
- 默认、悬停、按下、禁用时使用什么颜色?
- 文字与背景的对比度是否足够?
- 按钮内容变长时如何布局?
- 键盘焦点是否可见?
- Android、iOS、桌面和 Web 的指针与焦点行为是否一致?
- 深色主题和高对比度主题如何处理?
- 组件内部是否直接写死了颜色,导致主题切换失效?
因此,主题系统的核心任务是:把设计意图转换为可组合、可继承、可测试的运行时数据,并让组件根据状态正确消费这些数据。
2. Material 与 Flutter Material 组件
2.1 Material 是什么
Material 是一套设计语言,不只是一个视觉皮肤。它描述了:
- 组件的语义和层级;
- 颜色、形状、排版和间距;
- 组件状态;
- 交互反馈;
- 表面与内容之间的关系;
- 可访问性和跨平台交互的基本原则。
Flutter 的 Material 库实现了大量 Material 组件,例如:
import 'package:flutter/material.dart';
class ExamplePage extends StatelessWidget {
const ExamplePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('设置'),
),
body: Center(
child: FilledButton(
onPressed: () {},
child: const Text('保存'),
),
),
);
}
}
这里的几个 Widget 有不同职责:
Scaffold:提供页面级结构,例如appBar、body、bottomNavigationBar、SnackBar。AppBar:Material 顶部应用栏组件。FilledButton:具有较强视觉强调的主要操作按钮。ThemeData:通过BuildContext向下游组件提供主题数据。
Scaffold 不是通用布局容器,也不会自动解决所有窗口适配问题。例如横屏、桌面宽窗口和 Web 宽页面仍然需要结合约束、断点和布局策略处理。主题负责视觉和交互语义,Row、Flex、LayoutBuilder、CustomScrollView 等负责几何布局;两者不能互相替代。
2.2 Material 2 与 Material 3
Flutter 同时保留了大量 Material 2 兼容 API,并逐步以 Material 3 作为默认设计方向。应用是否采用 Material 3,主要由 ThemeData.useMaterial3 控制。
MaterialApp(
theme: ThemeData(
useMaterial3: true,
),
);
在当前稳定 Flutter 中,Material 3 已是主要使用方向,但对于版本敏感的项目,显式设置仍然有价值,因为它能够表达项目意图,并减少升级时的隐式行为变化。
Material 2 与 Material 3 的差异不只在颜色:
| 方面 | Material 2 | Material 3 |
|---|---|---|
| 颜色模型 | 常见的是 primary、accentColor、background、error 等旧字段 |
以完整 ColorScheme 的语义角色为中心 |
| 按钮 | RaisedButton、FlatButton 等旧组件已逐步淘汰 |
ElevatedButton、FilledButton、FilledButton.tonal、OutlinedButton、TextButton |
| 颜色生成 | 常使用固定主色和强调色 | 支持基于种子颜色生成一组协调的色彩角色 |
| 形状 | 组件默认形状较为传统 | 更强调组件类别和状态下的形状规范 |
| 状态层 | 实现方式较分散 | 更普遍地通过 WidgetStateProperty 描述状态值 |
不要把“开启 Material 3”理解为自动完成产品设计。它只会让 Flutter 使用相应的组件默认值和主题解释方式;品牌颜色、业务组件、信息架构、桌面适配仍需要项目定义。
3. ThemeData:主题在 Widget 树中的运行时载体
3.1 ThemeData 的作用域
ThemeData 通过 MaterialApp 或 Theme 向下传递:
MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo,
brightness: Brightness.light,
),
),
home: const ExamplePage(),
);
后代 Widget 使用:
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final textTheme = theme.textTheme;
其数据流可以表示为:
MaterialApp.theme
↓
InheritedTheme / Theme
↓
Theme.of(context)
↓
组件读取 colorScheme、textTheme、组件主题
当主题发生变化时,依赖 Theme.of(context) 的 Widget 会重建。常见触发原因包括:
MaterialApp的theme与darkTheme切换;ThemeMode从light变为dark;- 局部
Theme覆盖; - 主题中的
ThemeExtension变更。
如果一个 Widget 在 build 之外缓存了主题值,就可能在主题切换后继续使用旧值。例如:
class BadWidget extends StatelessWidget {
BadWidget({super.key});
// 主题值不应在没有 BuildContext 的地方静态缓存。
final Color background = Colors.blue;
@override
Widget build(BuildContext context) {
return Container(color: background);
}
}
这段代码的问题不是语法错误,而是它绕过了主题数据流。正确方式是从当前上下文读取:
class GoodWidget extends StatelessWidget {
const GoodWidget({super.key});
@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.primary;
return ColoredBox(color: color);
}
}
3.2 主题来源的优先级
一个具体组件的最终样式通常来自多个层级。以按钮为例,概念上的优先级大致是:
具体 Widget 的 style
↓ 覆盖
局部组件主题,例如 ElevatedButtonThemeData
↓ 覆盖
全局 ThemeData
↓
组件默认值
例如:
ThemeData(
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
),
);
这个设置会影响主题范围内的 ElevatedButton。如果某个按钮显式设置了自己的 style,它通常会优先于全局组件主题。
但是,styleFrom 和 ButtonStyle 的合并不是简单的“把所有字段逐个覆盖”这么直观。某些字段内部还包含状态解析器,最终值取决于:
- 当前 Widget 是否提供了该字段;
- 组件主题是否提供了该字段;
- 状态属性是否能为当前状态解析出值;
- Flutter 组件实现如何合并
ButtonStyle。
因此,不应把组件样式当作普通的 Map<String, Color>。对于状态字段,要明确指定默认、悬停、按下、聚焦和禁用值。
4. ColorScheme:从原始颜色到语义角色
4.1 为什么不应直接使用 Colors.blue
原始颜色只表达色值,不表达用途:
Container(
color: Colors.blue,
);
这里无法知道蓝色代表:
- 品牌主色;
- 可点击控件;
- 页面背景;
- 成功状态;
- 信息提示;
- 深色主题中的表面颜色。
如果产品颜色调整,或者切换到深色主题,所有直接使用 Colors.blue 的地方都需要逐个修改。
ColorScheme 解决的是颜色语义问题:
final scheme = Theme.of(context).colorScheme;
Container(
color: scheme.primaryContainer,
child: Text(
'同步完成',
style: TextStyle(color: scheme.onPrimaryContainer),
),
);
primaryContainer 说明这个颜色用于主色相关的容器,onPrimaryContainer 说明它用于该容器上的内容。二者是一对语义角色,而不是任意两个颜色。
4.2 常用颜色角色
Material 3 的 ColorScheme 包含许多角色,常用角色可按用途理解:
| 角色 | 含义 |
|---|---|
primary |
主要品牌色或主要交互强调色 |
onPrimary |
放在 primary 上的内容颜色 |
primaryContainer |
主色的低强调容器 |
onPrimaryContainer |
放在主色容器上的内容颜色 |
secondary |
次要强调色 |
tertiary |
第三类强调色,通常用于区分另一种语义 |
error |
错误状态颜色 |
onError |
放在错误颜色上的内容颜色 |
surface |
页面或组件表面 |
onSurface |
表面上的主要内容 |
surfaceContainer* |
不同层级的表面容器 |
onSurfaceVariant |
表面上的次要内容 |
outline |
边框、分割线或轮廓 |
outlineVariant |
更低强调的轮廓 |
inverseSurface / onInverseSurface |
反转表面及其内容 |
scrim |
遮罩层颜色 |
不同 Flutter 版本可能增加或调整部分 ColorScheme 字段。编写跨版本库时,应以目标 Flutter SDK 的 API 文档和分析器结果为准,不要假定所有新角色都存在于旧版本。
4.3 ColorScheme.fromSeed
基于种子颜色生成配色:
final lightScheme = ColorScheme.fromSeed(
seedColor: const Color(0xFF6750A4),
brightness: Brightness.light,
);
final darkScheme = ColorScheme.fromSeed(
seedColor: const Color(0xFF6750A4),
brightness: Brightness.dark,
);
再配置到应用:
MaterialApp(
theme: ThemeData(
useMaterial3: true,
colorScheme: lightScheme,
),
darkTheme: ThemeData(
useMaterial3: true,
colorScheme: darkScheme,
),
themeMode: ThemeMode.system,
);
fromSeed 的输入是种子颜色,输出是一组具有关系的语义角色。它不是“把种子颜色复制到所有字段”,也不保证生成的颜色符合某个品牌手册的精确色板。如果设计团队提供了经过审核的完整色板,应直接构造或加载对应的 ColorScheme,而不是在运行时重新生成。
4.4 on* 颜色的配对规则
一个重要规则是:
背景角色 X
↕
内容角色 onX
例如:
DecoratedBox(
decoration: BoxDecoration(
color: scheme.errorContainer,
),
child: Text(
'上传失败',
style: TextStyle(
color: scheme.onErrorContainer,
),
),
);
错误示例:
DecoratedBox(
decoration: BoxDecoration(
color: scheme.primaryContainer,
),
child: Text(
'不一定可读',
style: TextStyle(
color: scheme.primary,
),
),
);
primary 与 primaryContainer 都是主色体系中的角色,但不代表它们在任何主题下都具有足够对比度。内容应优先使用与背景成对的 on... 角色。
4.5 对比度的形式化判断
常用的 WCAG 对比度公式为:
其中:
- 是两个颜色相对亮度中较大的值;
- 是较小的值;
- 相对亮度 根据颜色的线性化 RGB 分量计算。
直觉上,两个颜色的明暗差异越大,对比度越高。普通正文通常至少需要达到 WCAG AA 的 4.5:1;大号文字通常是 3:1。具体合规判断还取决于字体大小、字重、平台渲染和产品适用标准。
可以使用 Flutter 的 ColorScheme 配对降低错误概率,但不能因此省略真实检查。以下代码只适合调试可视化,不应被当作完整的无障碍审计:
class ColorPairPreview extends StatelessWidget {
const ColorPairPreview({super.key});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Column(
children: [
Container(
color: scheme.primary,
padding: const EdgeInsets.all(16),
child: Text(
'Primary / onPrimary',
style: TextStyle(color: scheme.onPrimary),
),
),
Container(
color: scheme.errorContainer,
padding: const EdgeInsets.all(16),
child: Text(
'Error container',
style: TextStyle(color: scheme.onErrorContainer),
),
),
],
);
}
}
5. 从设计变量到 Token
5.1 Token 的定义
Design Token 是具有名称、值和语义的设计变量。它把“设计意图”从具体组件中抽离出来。
例如:
color.action.primary
color.content.muted
shape.control
space.page
motion.standard
Token 不等于常量文件。一个好的 Token 至少包含:
- 稳定的名称;
- 明确的语义;
- 类型;
- 在不同主题或平台下的值;
- 使用边界;
- 与组件规范的映射关系。
可以把 Token 分为三层:
原始 Token
也称为基础值或 primitive:
blue500 = #6750A4
spacing4 = 4dp
radius12 = 12dp
语义 Token
把原始值映射到用途:
color.action.primary = blue500
color.content.onActionPrimary = white
shape.control = radius12
组件 Token
把语义值映射到组件:
button.filled.background = color.action.primary
button.filled.foreground = color.content.onActionPrimary
button.height = 48dp
依赖方向应当是:
原始 Token → 语义 Token → 组件 Token → Widget
如果组件直接引用原始 Token,设计系统就会失去语义。例如:
// 耦合到原始色值,不推荐。
color: const Color(0xFF6750A4);
比它更好的方式是:
// 依赖当前主题的语义角色。
color: Theme.of(context).colorScheme.primary;
5.2 Token 与 ColorScheme 的关系
ColorScheme 本身可以看作 Flutter Material 主题中的一组标准颜色 Token,但它不是所有项目 Token 的完整替代品。
它适合表达:
- Material 组件需要的颜色角色;
- 内容与表面的关系;
- 明暗主题的颜色变化;
- 状态颜色的基础语义。
它不一定适合表达:
- 品牌插画颜色;
- 业务图表颜色;
- 订单状态颜色;
- 特殊营销模块的视觉变量;
- 设计系统自定义的密度和动效。
这些额外 Token 可以通过 ThemeExtension 接入主题系统。
5.3 使用 ThemeExtension 承载自定义 Token
下面是一个可运行的自定义 Token 示例:
import 'package:flutter/material.dart';
@immutable
class AppTokens extends ThemeExtension<AppTokens> {
const AppTokens({
required this.pagePadding,
required this.controlRadius,
required this.success,
required this.onSuccess,
});
final double pagePadding;
final double controlRadius;
final Color success;
final Color onSuccess;
@override
AppTokens copyWith({
double? pagePadding,
double? controlRadius,
Color? success,
Color? onSuccess,
}) {
return AppTokens(
pagePadding: pagePadding ?? this.pagePadding,
controlRadius: controlRadius ?? this.controlRadius,
success: success ?? this.success,
onSuccess: onSuccess ?? this.onSuccess,
);
}
@override
AppTokens lerp(ThemeExtension<AppTokens>? other, double t) {
if (other is! AppTokens) {
return this;
}
return AppTokens(
pagePadding: lerpDouble(pagePadding, other.pagePadding, t),
controlRadius: lerpDouble(controlRadius, other.controlRadius, t),
success: Color.lerp(success, other.success, t) ?? success,
onSuccess: Color.lerp(onSuccess, other.onSuccess, t) ?? onSuccess,
);
}
static double lerpDouble(double a, double b, double t) {
return a + (b - a) * t;
}
}
把它加入主题:
final lightTheme = ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo,
),
extensions: const [
AppTokens(
pagePadding: 16,
controlRadius: 12,
success: Color(0xFF146C2E),
onSuccess: Colors.white,
),
],
);
在 Widget 中读取:
class SuccessBanner extends StatelessWidget {
const SuccessBanner({super.key});
@override
Widget build(BuildContext context) {
final tokens = Theme.of(context).extension<AppTokens>();
if (tokens == null) {
// 这是配置错误,不应默默使用错误颜色。
throw StateError('AppTokens is missing from ThemeData.extensions');
}
return Container(
padding: EdgeInsets.all(tokens.pagePadding),
decoration: BoxDecoration(
color: tokens.success,
borderRadius: BorderRadius.circular(tokens.controlRadius),
),
child: Text(
'操作成功',
style: TextStyle(color: tokens.onSuccess),
),
);
}
}
ThemeExtension 中的 lerp 很重要。主题切换或 AnimatedTheme 进行动画时,Flutter 会用它在两个主题之间插值。如果只实现 copyWith 而不正确实现 lerp,自定义 Token 可能在主题动画中突然跳变。
自定义 Token 也有边界:
- 不要用
ThemeExtension替代已有的ColorScheme; - 不要把页面临时变量全部塞进全局 Token;
- 不要在 Token 中保存页面状态,例如“当前选中的 Tab”;
- 不要把业务数据和视觉 Token 混在一起。
视觉 Token 描述“应该如何显示”,业务状态描述“当前发生了什么”,两者应保持分离。
6. Typography:文字 Token 不是简单的字号表
TextTheme 是 Flutter Material 主题中的排版 Token 集合:
final textTheme = Theme.of(context).textTheme;
Text(
'账户设置',
style: textTheme.titleLarge,
);
Text(
'修改密码后,其他设备上的会话可能需要重新登录。',
style: textTheme.bodyMedium,
);
文字样式至少包含:
fontFamily;fontSize;fontWeight;height;letterSpacing;color;- 文本的语义层级。
Material 3 中常见的文字角色包括:
displayLarge、displayMedium、displaySmallheadlineLarge、headlineMedium、headlineSmalltitleLarge、titleMedium、titleSmallbodyLarge、bodyMedium、bodySmalllabelLarge、labelMedium、labelSmall
不要把文字样式和颜色完全分开理解。例如:
Text(
'删除账户',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.error,
),
);
这里保留了排版 Token,只覆写了语义颜色。相比重新构造完整 TextStyle,它更不容易丢失字体、字重和字距设置。
6.1 文本缩放与布局边界
用户可能在系统中增大字体。TextTheme 不会自动保证所有布局都能容纳放大后的文本。
错误假设:
Row(
children: [
const Text('一个可能很长的标题'),
TextButton(
onPressed: () {},
child: const Text('操作'),
),
],
);
在窄屏或较大字体下,这个 Row 可能溢出。主题规范必须和布局约束一起验证:
ListTile(
title: Text(
'一个可能很长的标题',
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
trailing: TextButton(
onPressed: () {},
child: const Text('操作'),
),
);
如果内容和操作都必须完整显示,可以改为纵向布局,或者使用 Wrap。主题不能解决违反父子约束关系的问题;这属于布局系统的职责。
7. 组件规范:从静态样式到状态机
7.1 组件规范必须描述状态
一个组件不能只规定“正常颜色”。可交互组件通常至少有这些状态:
enabled
disabled
hovered
focused
pressed
selected
dragged
不同平台的状态来源不同:
- Android、iOS 触摸屏主要产生
pressed; - 桌面和 Web 鼠标会产生
hovered; - 键盘、遥控器和桌面交互会产生
focused; - 选择控件还可能有
selected; - 拖拽控件可能有
dragged。
因此,组件规范应该是一个状态函数:
其中:
ComponentType是组件类型;WidgetState是当前状态集合;ThemeMode是亮色或深色主题;Platform影响交互输入和部分平台适配。
在 Flutter 中,WidgetStateProperty<T> 正是对“根据状态解析值”的抽象。
7.2 按钮状态样式示例
class AppButton extends StatelessWidget {
const AppButton({
required this.label,
required this.onPressed,
super.key,
});
final String label;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return FilledButton(
onPressed: onPressed,
style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith<Color?>(
(states) {
if (states.contains(WidgetState.disabled)) {
return scheme.onSurface.withValues(alpha: 0.12);
}
if (states.contains(WidgetState.pressed)) {
return scheme.primary.withValues(alpha: 0.80);
}
if (states.contains(WidgetState.hovered)) {
return scheme.primary.withValues(alpha: 0.92);
}
return scheme.primary;
},
),
foregroundColor: WidgetStateProperty.resolveWith<Color?>(
(states) {
if (states.contains(WidgetState.disabled)) {
return scheme.onSurface.withValues(alpha: 0.38);
}
return scheme.onPrimary;
},
),
overlayColor: WidgetStateProperty.resolveWith<Color?>(
(states) {
if (states.contains(WidgetState.focused)) {
return scheme.onPrimary.withValues(alpha: 0.16);
}
if (states.contains(WidgetState.pressed)) {
return scheme.onPrimary.withValues(alpha: 0.12);
}
return null;
},
),
minimumSize: const WidgetStatePropertyAll(
Size.fromHeight(48),
),
),
child: Text(label),
);
}
}
这段代码的输入是:
- 当前主题中的
ColorScheme; onPressed是否为null;- Flutter 传入的
WidgetState集合。
预期行为是:
onPressed == null时,按钮进入禁用状态;- 按下时使用较低透明度的主色;
- 鼠标悬停时提供轻微视觉变化;
- 键盘焦点时显示焦点层;
- 正常状态使用
primary和onPrimary的配对颜色。
需要注意,状态集合可能同时包含多个状态,例如一个获得焦点且被按下的按钮。因此状态判断顺序是组件规范的一部分,而不是无关紧要的实现细节。
较简单的固定状态值可以使用:
style: ButtonStyle(
minimumSize: const WidgetStatePropertyAll(
Size(120, 48),
),
),
7.3 styleFrom 适合什么场景
styleFrom 适合快速指定常见的静态样式:
FilledButton.styleFrom(
minimumSize: const Size(120, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
);
它不适合承载复杂状态机。需要为不同状态返回不同值时,应直接使用 ButtonStyle 和 WidgetStateProperty.resolveWith,这样状态规则更明确,也更容易测试。
8. 组件主题:把规范下沉到全局
如果所有页面都需要相同按钮规范,可以配置组件主题:
ThemeData buildTheme(ColorScheme scheme) {
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
filledButtonTheme: FilledButtonThemeData(
style: ButtonStyle(
minimumSize: const WidgetStatePropertyAll(
Size.fromHeight(48),
),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(),
),
cardTheme: const CardThemeData(
margin: EdgeInsets.zero,
),
);
}
这里的 CardThemeData、FilledButtonThemeData、InputDecorationTheme 等 API 受 Flutter 版本影响较小,但具体主题类和字段仍应以目标 SDK 为准。某些 Flutter 版本中,旧主题类名可能存在兼容别名或迁移差异。
全局组件主题与业务组件的关系可以这样处理:
ColorScheme / TextTheme / AppTokens
↓
Material 组件主题
↓
业务组件封装
↓
页面
业务组件应尽量消费语义 Token,而不是把全局组件主题再次复制一遍。例如:
class StatusChip extends StatelessWidget {
const StatusChip({
required this.label,
required this.isSuccess,
super.key,
});
final String label;
final bool isSuccess;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final background = isSuccess
? scheme.primaryContainer
: scheme.errorContainer;
final foreground = isSuccess
? scheme.onPrimaryContainer
: scheme.onErrorContainer;
return Chip(
label: Text(label),
backgroundColor: background,
labelStyle: TextStyle(color: foreground),
);
}
}
这里业务状态 isSuccess 决定语义角色,主题决定具体颜色。这样切换亮色、深色或品牌色时,组件不需要修改业务逻辑。
9. 一个端到端主题示例
下面的示例包含:
- 亮色和深色
ColorScheme; ThemeMode.system;- 自定义
ThemeExtension; - 全局按钮与输入框规范;
- 运行时读取 Token;
- 一个带错误状态的表单组件。
import 'package:flutter/material.dart';
void main() {
runApp(const DesignSystemApp());
}
@immutable
class AppTokens extends ThemeExtension<AppTokens> {
const AppTokens({
required this.pagePadding,
required this.controlRadius,
required this.success,
required this.onSuccess,
});
final double pagePadding;
final double controlRadius;
final Color success;
final Color onSuccess;
@override
AppTokens copyWith({
double? pagePadding,
double? controlRadius,
Color? success,
Color? onSuccess,
}) {
return AppTokens(
pagePadding: pagePadding ?? this.pagePadding,
controlRadius: controlRadius ?? this.controlRadius,
success: success ?? this.success,
onSuccess: onSuccess ?? this.onSuccess,
);
}
@override
AppTokens lerp(ThemeExtension<AppTokens>? other, double t) {
if (other is! AppTokens) {
return this;
}
return AppTokens(
pagePadding: _lerp(pagePadding, other.pagePadding, t),
controlRadius: _lerp(controlRadius, other.controlRadius, t),
success: Color.lerp(success, other.success, t) ?? success,
onSuccess: Color.lerp(onSuccess, other.onSuccess, t) ?? onSuccess,
);
}
static double _lerp(double a, double b, double t) {
return a + (b - a) * t;
}
}
class DesignSystemApp extends StatelessWidget {
const DesignSystemApp({super.key});
static const seedColor = Color(0xFF6750A4);
ThemeData _buildTheme(Brightness brightness) {
final scheme = ColorScheme.fromSeed(
seedColor: seedColor,
brightness: brightness,
);
final isDark = brightness == Brightness.dark;
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
textTheme: const TextTheme(
titleLarge: TextStyle(fontWeight: FontWeight.w700),
),
extensions: [
AppTokens(
pagePadding: isDark ? 16 : 20,
controlRadius: 12,
success: isDark
? const Color(0xFF8FDBA5)
: const Color(0xFF146C2E),
onSuccess: isDark
? const Color(0xFF003915)
: Colors.white,
),
],
filledButtonTheme: FilledButtonThemeData(
style: ButtonStyle(
minimumSize: const WidgetStatePropertyAll(
Size.fromHeight(48),
),
shape: const WidgetStatePropertyAll(
RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
),
),
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
);
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '主题示例',
theme: _buildTheme(Brightness.light),
darkTheme: _buildTheme(Brightness.dark),
themeMode: ThemeMode.system,
home: const SettingsPage(),
);
}
}
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key});
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
final controller = TextEditingController();
bool submitting = false;
String? errorText;
@override
void dispose() {
controller.dispose();
super.dispose();
}
Future<void> _save() async {
final value = controller.text.trim();
if (value.isEmpty) {
setState(() {
errorText = '名称不能为空';
});
return;
}
setState(() {
submitting = true;
errorText = null;
});
try {
// 这里模拟异步请求。真实代码应处理网络异常、取消和重复提交。
await Future<void>.delayed(const Duration(milliseconds: 500));
if (!mounted) {
return;
}
setState(() {
submitting = false;
});
final tokens = Theme.of(context).extension<AppTokens>()!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: tokens.success,
content: Text(
'保存成功',
style: TextStyle(color: tokens.onSuccess),
),
),
);
} catch (_) {
if (!mounted) {
return;
}
setState(() {
submitting = false;
errorText = '保存失败,请稍后重试';
});
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = theme.extension<AppTokens>()!;
return Scaffold(
appBar: AppBar(
title: Text(
'账户设置',
style: theme.textTheme.titleLarge,
),
),
body: ListView(
padding: EdgeInsets.all(tokens.pagePadding),
children: [
Text(
'显示名称',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
TextField(
controller: controller,
decoration: InputDecoration(
hintText: '请输入名称',
errorText: errorText,
),
textInputAction: TextInputAction.done,
onSubmitted: submitting ? null : (_) => _save(),
),
const SizedBox(height: 20),
FilledButton(
onPressed: submitting ? null : _save,
child: submitting
? const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Text('保存'),
),
],
),
);
}
}
9.1 运行前置条件
在 Flutter 项目中执行:
flutter create theme_demo
cd theme_demo
将 lib/main.dart 替换为上面的代码,然后运行:
flutter run
预期结果:
- 系统使用浅色模式时显示浅色主题;
- 系统使用深色模式时显示深色主题;
- 空名称提交后,输入框显示错误;
- 提交期间按钮禁用并显示进度指示器;
- 保存完成后显示使用自定义 Token 的成功消息。
9.2 为什么异步代码需要 mounted
await 会把当前函数拆成等待前和恢复后两个阶段:
点击保存
↓
setState(submitting = true)
↓
await 网络请求
↓
页面可能已被移除
↓
检查 mounted
↓
setState 或显示 SnackBar
如果用户在请求期间离开页面,State 可能已经销毁。此时直接调用 setState 会产生异常。主题系统本身不处理这个生命周期问题,但主题中的状态反馈通常会出现在异步流程中,所以组件规范必须和生命周期处理一起实现。
此外,示例中的 Future.delayed 只模拟成功路径。生产代码还应区分:
- 网络超时;
- 服务端业务错误;
- 用户取消;
- 重复提交;
- 页面销毁;
- SnackBar 所属的
ScaffoldMessenger不再可用。
10. 明暗主题不是简单的颜色取反
明暗主题的转换不是:
darkColor = invert(lightColor);
原因是明暗主题还涉及:
- 表面层级;
- 内容与表面的对比度;
- 禁用状态;
- 分割线和轮廓的可见性;
- 图片、图标和插画;
- 系统状态栏;
- 阴影和遮罩;
- 错误、成功、警告等状态颜色。
正确做法是提供两个完整的 ThemeData:
final light = ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo,
brightness: Brightness.light,
),
);
final dark = ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo,
brightness: Brightness.dark,
),
);
然后由:
MaterialApp(
theme: light,
darkTheme: dark,
themeMode: ThemeMode.system,
);
选择主题。
ThemeMode.system 会跟随系统设置;ThemeMode.light 和 ThemeMode.dark 则强制使用对应主题。若应用允许用户选择主题,应将用户偏好持久化,并在状态变化时重建 MaterialApp。
系统主题变化的路径通常是:
操作系统主题变化
↓
Flutter 平台配置变化
↓
MediaQuery / WidgetsBinding 更新
↓
MaterialApp 根据 ThemeMode.system 选择 ThemeData
↓
依赖 Theme.of(context) 的 Widget 重建
10.1 Brightness 与平台
Brightness 只表示亮或暗,并不代表平台。Android、iOS、桌面和 Web 都可能提供系统明暗设置,但平台对窗口装饰、系统栏、浏览器环境的支持不同。
例如 Android 和 iOS 的状态栏颜色可以通过 AppBarTheme 或 SystemUiOverlayStyle 影响;桌面窗口标题栏和 Web 浏览器 UI 则不能完全由 Flutter Material 主题控制。不要把 ColorScheme.surface 误认为会自动覆盖操作系统的所有界面区域。
11. 局部主题与主题边界
可以使用 Theme 为子树提供局部覆盖:
Theme(
data: Theme.of(context).copyWith(
colorScheme: Theme.of(context).colorScheme.copyWith(
primary: Colors.teal,
),
),
child: const LocalSection(),
);
局部主题适合:
- 一个独立业务模块;
- 嵌入式组件;
- 预览某个主题方案;
- 对单个页面进行有限的 Material 覆盖。
但局部主题不是解决所有样式冲突的工具。过多嵌套会产生:
- 难以判断最终颜色来源;
- 主题切换行为不一致;
- 组件在不同页面显示不同;
- 测试场景增加;
- 设计 Token 的语义被局部覆盖破坏。
一个实用的边界是:
应用级:品牌、基础颜色、排版、全局组件规范
模块级:明确的模块视觉隔离
组件级:状态、内容相关的局部变化
页面级临时样式:只在确有语义时使用
如果只是为了让某个文本变成蓝色,局部 Theme 通常过重;如果整个嵌入模块需要一套独立的颜色和形状语义,局部 Theme 才有合理性。
12. Material 组件与平台差异
12.1 Android
Android 通常具有较强的 Material 交互预期:
- 触摸按压反馈;
- 系统返回手势或返回键;
- 状态栏和导航栏;
- 动态字体缩放;
- 系统深色模式;
- Android 特有的输入法和窗口 Insets。
Flutter Material 组件会提供跨平台的默认行为,但系统栏颜色、边到边布局和返回行为仍需单独验证。
12.2 iOS
iOS 用户更熟悉 Cupertino 视觉和交互。如果产品要求严格遵循 iOS 人机界面规范,不能仅靠修改 ColorScheme 把 Material 组件“染成 iOS 风格”。
可以根据平台选择组件:
Widget buildAdaptiveButton({
required VoidCallback? onPressed,
required Widget child,
}) {
return switch (Theme.of(context).platform) {
TargetPlatform.iOS || TargetPlatform.macOS =>
CupertinoButton(
onPressed: onPressed,
child: child,
),
_ => FilledButton(
onPressed: onPressed,
child: child,
),
};
}
上面的代码需要在具有 BuildContext 的 Widget 方法中使用,并导入:
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
Material 主题不会自动配置 Cupertino 组件的全部视觉参数。两套组件体系可以共享业务 Token,但需要分别定义组件映射。
12.3 桌面
桌面平台增加了:
- 鼠标悬停;
- 键盘焦点;
- 右键菜单;
- 更大的窗口;
- 更复杂的窗口尺寸变化;
- 鼠标滚轮和拖拽;
- 可能存在的高 DPI 缩放。
只设计触摸态而不设计 hovered 和 focused 状态,会导致桌面应用交互反馈不足。另一方面,不能简单地把移动端按钮放大到桌面窗口中;桌面布局通常需要更明确的最大内容宽度和多栏结构。
12.4 Web
Web 需要额外考虑:
- 浏览器默认焦点行为;
- 键盘 Tab 顺序;
- 鼠标悬停;
- 浏览器窗口缩放;
- URL、刷新和前进后退;
- Canvas 渲染与文本可选性;
- 不同浏览器对字体和系统控件的差异。
Flutter Web 的视觉结果由 Flutter 渲染,但它仍运行在浏览器环境中。CSS 的默认样式不会自动成为 Flutter Widget 的主题,也不能假设浏览器的系统字体、滚动条和焦点表现与移动端相同。
13. 组件规范中的尺寸、形状和布局
颜色只是 Token 的一部分。组件规范通常还要定义:
- 最小可点击尺寸;
- 内部水平和垂直间距;
- 文本与图标之间的间距;
- 圆角;
- 边框宽度;
- 阴影或表面层级;
- 内容溢出行为;
- 组件在窄屏和宽屏下的变化。
例如按钮的 minimumSize 并不等于最终尺寸。Flutter 的布局仍然遵循约束传递:
父 RenderObject 提供约束
↓
按钮根据内容和 minimumSize 计算尺寸
↓
按钮向子树传递更小的约束
↓
文字和图标布局
↓
父级检查最终尺寸是否满足约束
如果父级给出的最大宽度小于按钮的期望宽度,按钮必须收缩、换行或溢出,主题不会绕过这些约束。
错误示例:
SizedBox(
width: 100,
child: FilledButton(
onPressed: () {},
child: const Text('这是一个很长的保存操作'),
),
);
这可能导致文本溢出或布局不符合预期。可以:
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () {},
child: const Text('保存'),
),
);
或者针对长文本使用可换行布局。对于 Sliver 场景,还需要考虑滚动视口提供的约束和懒加载生命周期;将一个依赖固定高度的组件直接放入不同 Sliver 结构,可能产生尺寸错误。
形状 Token 也不能脱离组件语义。相同的 12 圆角可以用于按钮和卡片,但不意味着二者应该共享所有形状规则:
FilledButtonThemeData(
style: ButtonStyle(
shape: const WidgetStatePropertyAll(
RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
),
);
CardThemeData(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
);
“圆角统一”不等于“所有组件使用相同圆角”。组件类别、尺寸、表面层级和交互语义都可能影响形状。
14. 颜色、状态和数据流应保持分离
一个常见的错误是让业务状态直接决定具体颜色:
// 业务代码和具体色值耦合。
Color color = order.isPaid ? Colors.green : Colors.red;
更稳定的方式是:
enum OrderStatus { paid, failed }
class OrderStatusView extends StatelessWidget {
const OrderStatusView({
required this.status,
super.key,
});
final OrderStatus status;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final (background, foreground, label) = switch (status) {
OrderStatus.paid => (
scheme.primaryContainer,
scheme.onPrimaryContainer,
'已支付',
),
OrderStatus.failed => (
scheme.errorContainer,
scheme.onErrorContainer,
'支付失败',
),
};
return Chip(
backgroundColor: background,
labelStyle: TextStyle(color: foreground),
label: Text(label),
);
}
}
这里有三条分离关系:
业务状态:OrderStatus
↓
语义映射:primaryContainer / errorContainer
↓
主题值:亮色或深色中的具体颜色
这种数据流可以处理:
- 主题切换;
- 品牌换肤;
- 测试状态;
- 多平台差异;
- 后续增加
pending、refunded等业务状态。
但也要避免把所有业务状态都强行映射到 Material 标准角色。比如“审核中”可能需要产品自定义的黄色 Token,此时可以通过 ThemeExtension 定义 warning 及其内容色,而不是滥用 tertiary。
15. 常见失败表现与诊断方法
15.1 主题切换后部分区域不变
表现:
- AppBar 已切换,某个卡片仍是固定白色;
- 深色模式下文字变成黑色;
- 自定义组件在主题变化后不更新。
原因:
- 使用了
Colors.white、Colors.black等硬编码; - 在 Widget 外缓存了主题值;
- 自定义组件没有通过
Theme.of(context)读取; - 使用了未接入
ThemeExtension的全局常量。
诊断:
全局搜索:
Colors.white
Colors.black
Color(0x...
ThemeData.light()
ThemeData.dark()
然后逐个判断这些值是:
- 真正的固定资产颜色;
- 应该成为语义 Token 的颜色;
- 应该使用
ColorScheme的颜色。
不是所有固定颜色都必须删除。例如品牌 Logo 的原始 SVG 颜色可能是资产规范的一部分,但它不能被误当成页面表面色。
15.2 文字在深色主题中不可读
表现:
Container(
color: scheme.surfaceContainerHighest,
child: Text(
'内容',
style: TextStyle(color: scheme.primary),
),
);
在某些主题下对比度不足。
诊断步骤:
- 确认背景实际使用的角色;
- 查找对应的
on...角色; - 检查文字是否又被局部
TextStyle覆盖; - 使用亮色、深色和大字体分别测试;
- 对关键文字做对比度检查。
15.3 按钮禁用后仍然触发操作
Flutter Material 按钮通常通过 onPressed: null 表示禁用:
FilledButton(
onPressed: submitting ? null : _save,
child: const Text('保存'),
);
如果只修改按钮颜色而不把回调设为 null:
FilledButton(
onPressed: _save,
style: FilledButton.styleFrom(
backgroundColor: Colors.grey,
),
child: const Text('保存'),
);
它只是“看起来像禁用”,实际上仍然可点击。这说明视觉状态和交互状态必须同时定义。
15.4 状态样式互相覆盖
如果同时配置:
backgroundColor: const WidgetStatePropertyAll(Colors.blue),
和:
backgroundColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.pressed)
? Colors.darkBlue
: Colors.blue,
),
前者会让后者失去意义,或者在样式合并过程中出现非预期结果。诊断时应从具体 Widget 向上追踪:
具体 Button.style
→ FilledButtonThemeData
→ ThemeData
→ 默认 ButtonStyle
将同一属性的状态逻辑集中在一个明确层级,通常比多层叠加更容易维护。
15.5 主题 API 迁移导致编译错误
Flutter 会逐步迁移部分 API 命名。例如较新的 SDK 更倾向使用 WidgetState 和 WidgetStateProperty,旧代码可能使用 MaterialState 和 MaterialStateProperty。
正确做法是:
- 以项目当前 Flutter SDK 的 analyzer 提示为准;
- 查看目标 SDK 的 API 文档;
- 使用
dart fix前先审查修改结果; - 不要为了兼容旧 SDK 随意复制不存在的类型别名;
- 库代码明确声明 SDK 下限。
版本敏感代码应放在较小的适配层中,而不是散落到所有组件。
16. 可访问性与主题
主题不能自动保证可访问性,但主题会直接影响可访问性。
16.1 焦点必须可见
键盘、桌面和辅助设备用户依赖焦点状态。不要只设计 pressed 状态而忽略 focused:
ButtonStyle(
side: WidgetStateProperty.resolveWith<BorderSide?>(
(states) {
if (states.contains(WidgetState.focused)) {
return BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2,
);
}
return null;
},
),
);
实际项目中还要检查焦点边框是否与背景有足够对比度,是否被裁剪,是否在 ClipRRect、滚动容器和自定义绘制中仍然可见。
16.2 颜色不能承担唯一语义
错误示例:
绿色 = 成功
红色 = 失败
对于色觉差异用户,颜色不应是唯一信息来源。应同时提供:
- 文本;
- 图标;
- 语义标签;
- 状态变化反馈。
例如:
Row(
children: [
Icon(
Icons.error_outline,
color: Theme.of(context).colorScheme.error,
semanticLabel: '错误',
),
const SizedBox(width: 8),
Text('支付失败'),
],
);
如果图标旁边已有同样的文字,图标可能应设置为装饰性而不是重复朗读;具体行为需要结合 Semantics 结构测试。
16.3 字体大小变化
主题排版必须在较大系统字体下验证。不能通过固定高度裁剪文字:
SizedBox(
height: 40,
child: Text(
'可能随系统字体放大的文本',
style: Theme.of(context).textTheme.bodyLarge,
),
);
固定高度可能导致文字被裁剪。应允许组件根据内容和约束增长,或者明确设计多行与溢出策略。
17. 测试设计系统,而不是只测试色值
17.1 Widget 测试应验证语义结果
测试不应只断言“颜色等于某个十六进制值”,因为颜色可能随主题算法或设计调整而变化。更有价值的是验证:
- 正常状态可见;
- 禁用状态不可点击;
- 错误状态包含错误文本;
- 深色主题下仍存在可读内容;
- 焦点状态有可见反馈;
- 主题 Token 能被正确读取。
示例:
testWidgets('提交期间按钮被禁用', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
),
home: const SettingsPage(),
),
);
final button = find.byType(FilledButton);
expect(button, findsOneWidget);
// 这里还应根据页面交互触发 submitting 状态,
// 再断言按钮的 onPressed 已为 null。
});
如果需要读取 Material 按钮的状态,应优先通过用户行为和可观察结果验证,而不是依赖内部实现字段。
17.2 Golden 测试的边界
Golden 测试适合发现:
- 颜色明显变化;
- 间距或圆角变化;
- 深色主题缺少覆盖;
- 组件状态外观回归;
- 不同屏幕尺寸下的视觉异常。
但 Golden 测试受以下因素影响:
- 字体安装;
- 操作系统字体渲染;
- Flutter 渲染器;
- 像素比例;
- 平台差异;
- 主题动画时机。
因此应固定测试环境,并为亮色、深色、禁用、错误、焦点和大字体等关键状态建立有限而有意义的样本,而不是为每个页面无条件生成大量截图。
18. 设计 Token 的工程组织方式
一个中型项目可以采用如下目录:
lib/
design_system/
app_theme.dart
app_tokens.dart
app_colors.dart
components/
app_button.dart
status_chip.dart
app_text_field.dart
features/
settings/
settings_page.dart
职责划分:
app_theme.dart:构造亮色、深色和必要的高对比度主题;app_tokens.dart:定义ThemeExtension;app_colors.dart:保存确实属于品牌资产的基础值;components/:实现组件状态和布局规范;features/:组合业务状态与设计系统组件。
不推荐将所有颜色都放在:
class AppColors {
static const blue = Color(...);
static const grey = Color(...);
}
然后在页面中随意使用。这样只是把硬编码集中到了一个文件,仍然没有表达颜色用途。更好的调用方式是:
final scheme = Theme.of(context).colorScheme;
final tokens = Theme.of(context).extension<AppTokens>()!;
如果必须引用品牌原始值,也应在主题构造阶段完成语义映射:
final scheme = ColorScheme.fromSeed(
seedColor: BrandColors.primarySeed,
);
页面不应关心 BrandColors.primarySeed 具体是什么。
19. 主题系统的边界:什么不该放进去
以下内容通常不属于 ThemeData 或设计 Token:
- 当前用户;
- 网络请求状态;
- 表单校验结果本身;
- 当前选中的业务实体;
- 订单金额;
- 权限判断;
- 路由栈;
- 服务器返回的数据;
- 需要精确生命周期管理的资源。
可以把“错误状态对应哪个颜色”放进组件规范,但“请求是否失败”应由业务状态管理。完整数据流应是:
网络层返回错误
↓
状态管理层更新 requestState = failure
↓
页面将 failure 映射为错误组件
↓
错误组件读取 colorScheme.error / errorContainer
如果把业务错误直接存进主题,就会造成全局隐式状态,页面之间互相影响,也难以测试。
同样,主题不是性能优化机制。Theme.of(context) 会建立依赖关系;如果一个非常大的 Widget 子树都依赖全局主题,主题变化时可能触发较多重建。通常应通过合理拆分 Widget、缩小依赖范围和避免无意义的主题频繁变化来处理,而不是绕过主题读取并缓存过期值。
20. 一套可验证的设计系统检查流程
可以按以下因果顺序检查一个 Flutter 设计系统:
第一步:确认语义模型
先写出组件需要的语义:
主要操作
次要操作
页面表面
容器表面
主要文字
次要文字
错误
成功
焦点
禁用
如果只列出“紫色、灰色、白色”,说明还没有建立 Token 语义。
第二步:建立主题输入
确认:
- 亮色和深色是否都有值;
ColorScheme是否完整;- 自定义 Token 是否实现
copyWith和lerp; - 组件主题是否从同一套 Token 派生。
第三步:定义组件状态函数
对每个交互组件列出:
默认 → 悬停 → 聚焦 → 按下 → 禁用
再确认各状态是否改变:
- 背景;
- 内容;
- 边框;
- 阴影或层级;
- 光标;
- 语义;
- 是否允许操作。
第四步:验证布局约束
在以下条件下运行:
- 小屏;
- 横屏;
- 桌面宽窗口;
- Web 浏览器缩放;
- 大字体;
- 长文本;
- RTL 语言;
- 键盘焦点。
这一步验证的是主题 Token 与布局系统的组合,而不是单纯验证颜色。
第五步:验证平台路径
至少检查:
- Android 触摸和系统返回;
- iOS 的组件风格与安全区域;
- 桌面悬停和焦点;
- Web Tab 顺序、悬停和窗口缩放。
第六步:验证故障路径
对涉及异步操作的组件检查:
正常提交
重复点击
提交期间离开页面
请求超时
服务端错误
主题在请求期间切换
主题切换不应导致请求状态丢失,异步恢复后也不应对已销毁的 State 调用 setState。
21. 最终模型
可以用下面的模型理解 Flutter 主题与设计系统:
设计意图
├─ 颜色语义
├─ 排版层级
├─ 形状与间距
├─ 状态反馈
└─ 平台与可访问性要求
↓
Token
├─ ColorScheme
├─ TextTheme
├─ ThemeExtension
└─ 组件主题
↓
Widget 状态机
├─ enabled / disabled
├─ hovered / focused / pressed
├─ selected / dragged
└─ loading / error 等业务映射
↓
布局与渲染
├─ 约束
├─ 尺寸
├─ Flex / Sliver
├─ 文本适配
└─ 平台窗口与输入
Material 提供组件和交互语义,ColorScheme 提供标准颜色角色,Token 提供项目级设计变量,组件规范则把这些变量转化为状态相关的可执行规则。只有四者都建立清晰边界,主题切换、品牌换肤、平台适配、可访问性和组件复用才能沿着可追踪的数据流工作,而不是依赖页面中的零散颜色和临时样式。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter 动画体系:Implicit、Controller、Hero、CustomPainter 和性能
- 下一篇:Flutter 状态管理:InheritedWidget、Provider、Riverpod、BLoC 和边界
- 延伸:Flutter Widget 与布局:约束、尺寸、Flex、Sliver 和渲染树
- 延伸:Flutter 可访问性与国际化:Semantics、焦点、Locale 和文本适配
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论