Flutter 基础体系 · 第 10/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter 主题与设计系统:Material、ColorScheme、Token 和组件规范

1. 先区分四个层次:设计语言、主题、Token 和组件规范

在 Flutter 中,“主题”不是一个单独的颜色表。一个可维护的界面系统通常包含四个层次:

  1. Material:Google 提出的界面设计语言及其交互语义,例如按钮的层级、菜单、对话框、导航栏、状态反馈和动效。
  2. Theme:应用运行时提供给 Widget 树的主题对象,主要由 ThemeData 承载。
  3. ColorScheme、Typography 等 Token:设计系统中的语义化设计变量,例如“主要颜色”“错误颜色”“正文样式”“小圆角”。
  4. 组件规范:把 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:提供页面级结构,例如 appBarbodybottomNavigationBarSnackBar
  • AppBar:Material 顶部应用栏组件。
  • FilledButton:具有较强视觉强调的主要操作按钮。
  • ThemeData:通过 BuildContext 向下游组件提供主题数据。

Scaffold 不是通用布局容器,也不会自动解决所有窗口适配问题。例如横屏、桌面宽窗口和 Web 宽页面仍然需要结合约束、断点和布局策略处理。主题负责视觉和交互语义,RowFlexLayoutBuilderCustomScrollView 等负责几何布局;两者不能互相替代。

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
颜色模型 常见的是 primaryaccentColorbackgrounderror 等旧字段 以完整 ColorScheme 的语义角色为中心
按钮 RaisedButtonFlatButton 等旧组件已逐步淘汰 ElevatedButtonFilledButtonFilledButton.tonalOutlinedButtonTextButton
颜色生成 常使用固定主色和强调色 支持基于种子颜色生成一组协调的色彩角色
形状 组件默认形状较为传统 更强调组件类别和状态下的形状规范
状态层 实现方式较分散 更普遍地通过 WidgetStateProperty 描述状态值

不要把“开启 Material 3”理解为自动完成产品设计。它只会让 Flutter 使用相应的组件默认值和主题解释方式;品牌颜色、业务组件、信息架构、桌面适配仍需要项目定义。


3. ThemeData:主题在 Widget 树中的运行时载体

3.1 ThemeData 的作用域

ThemeData 通过 MaterialAppTheme 向下传递:

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 会重建。常见触发原因包括:

  • MaterialAppthemedarkTheme 切换;
  • ThemeModelight 变为 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,它通常会优先于全局组件主题。

但是,styleFromButtonStyle 的合并不是简单的“把所有字段逐个覆盖”这么直观。某些字段内部还包含状态解析器,最终值取决于:

  1. 当前 Widget 是否提供了该字段;
  2. 组件主题是否提供了该字段;
  3. 状态属性是否能为当前状态解析出值;
  4. 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,
    ),
  ),
);

primaryprimaryContainer 都是主色体系中的角色,但不代表它们在任何主题下都具有足够对比度。内容应优先使用与背景成对的 on... 角色。

4.5 对比度的形式化判断

常用的 WCAG 对比度公式为:

Contrast=Lmax+0.05Lmin+0.05Contrast = \frac{L_{max}+0.05}{L_{min}+0.05}

其中:

  • LmaxL_{max} 是两个颜色相对亮度中较大的值;
  • LminL_{min} 是较小的值;
  • 相对亮度 LL 根据颜色的线性化 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 中常见的文字角色包括:

  • displayLargedisplayMediumdisplaySmall
  • headlineLargeheadlineMediumheadlineSmall
  • titleLargetitleMediumtitleSmall
  • bodyLargebodyMediumbodySmall
  • labelLargelabelMediumlabelSmall

不要把文字样式和颜色完全分开理解。例如:

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

因此,组件规范应该是一个状态函数:

Style=f(ComponentType,WidgetState,ThemeMode,Platform)Style = f(ComponentType, WidgetState, ThemeMode, Platform)

其中:

  • 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 集合。

预期行为是:

  1. onPressed == null 时,按钮进入禁用状态;
  2. 按下时使用较低透明度的主色;
  3. 鼠标悬停时提供轻微视觉变化;
  4. 键盘焦点时显示焦点层;
  5. 正常状态使用 primaryonPrimary 的配对颜色。

需要注意,状态集合可能同时包含多个状态,例如一个获得焦点且被按下的按钮。因此状态判断顺序是组件规范的一部分,而不是无关紧要的实现细节。

较简单的固定状态值可以使用:

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),
  ),
);

它不适合承载复杂状态机。需要为不同状态返回不同值时,应直接使用 ButtonStyleWidgetStateProperty.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,
    ),
  );
}

这里的 CardThemeDataFilledButtonThemeDataInputDecorationTheme 等 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.lightThemeMode.dark 则强制使用对应主题。若应用允许用户选择主题,应将用户偏好持久化,并在状态变化时重建 MaterialApp

系统主题变化的路径通常是:

操作系统主题变化
        ↓
Flutter 平台配置变化
        ↓
MediaQuery / WidgetsBinding 更新
        ↓
MaterialApp 根据 ThemeMode.system 选择 ThemeData
        ↓
依赖 Theme.of(context) 的 Widget 重建

10.1 Brightness 与平台

Brightness 只表示亮或暗,并不代表平台。Android、iOS、桌面和 Web 都可能提供系统明暗设置,但平台对窗口装饰、系统栏、浏览器环境的支持不同。

例如 Android 和 iOS 的状态栏颜色可以通过 AppBarThemeSystemUiOverlayStyle 影响;桌面窗口标题栏和 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 缩放。

只设计触摸态而不设计 hoveredfocused 状态,会导致桌面应用交互反馈不足。另一方面,不能简单地把移动端按钮放大到桌面窗口中;桌面布局通常需要更明确的最大内容宽度和多栏结构。

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
        ↓
主题值:亮色或深色中的具体颜色

这种数据流可以处理:

  • 主题切换;
  • 品牌换肤;
  • 测试状态;
  • 多平台差异;
  • 后续增加 pendingrefunded 等业务状态。

但也要避免把所有业务状态都强行映射到 Material 标准角色。比如“审核中”可能需要产品自定义的黄色 Token,此时可以通过 ThemeExtension 定义 warning 及其内容色,而不是滥用 tertiary


15. 常见失败表现与诊断方法

15.1 主题切换后部分区域不变

表现:

  • AppBar 已切换,某个卡片仍是固定白色;
  • 深色模式下文字变成黑色;
  • 自定义组件在主题变化后不更新。

原因:

  • 使用了 Colors.whiteColors.black 等硬编码;
  • 在 Widget 外缓存了主题值;
  • 自定义组件没有通过 Theme.of(context) 读取;
  • 使用了未接入 ThemeExtension 的全局常量。

诊断:

全局搜索:

Colors.white
Colors.black
Color(0x...
ThemeData.light()
ThemeData.dark()

然后逐个判断这些值是:

  1. 真正的固定资产颜色;
  2. 应该成为语义 Token 的颜色;
  3. 应该使用 ColorScheme 的颜色。

不是所有固定颜色都必须删除。例如品牌 Logo 的原始 SVG 颜色可能是资产规范的一部分,但它不能被误当成页面表面色。

15.2 文字在深色主题中不可读

表现:

Container(
  color: scheme.surfaceContainerHighest,
  child: Text(
    '内容',
    style: TextStyle(color: scheme.primary),
  ),
);

在某些主题下对比度不足。

诊断步骤:

  1. 确认背景实际使用的角色;
  2. 查找对应的 on... 角色;
  3. 检查文字是否又被局部 TextStyle 覆盖;
  4. 使用亮色、深色和大字体分别测试;
  5. 对关键文字做对比度检查。

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 更倾向使用 WidgetStateWidgetStateProperty,旧代码可能使用 MaterialStateMaterialStateProperty

正确做法是:

  1. 以项目当前 Flutter SDK 的 analyzer 提示为准;
  2. 查看目标 SDK 的 API 文档;
  3. 使用 dart fix 前先审查修改结果;
  4. 不要为了兼容旧 SDK 随意复制不存在的类型别名;
  5. 库代码明确声明 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 是否实现 copyWithlerp
  • 组件主题是否从同一套 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 官方文档重新梳理;正文与示例由 WR BLOG 编写。