成为全栈·Flutter App 篇·主题与可访问性:深浅色、大字号和触控体验
这一篇讲视觉,但重点不在"好看",在**"这些颜色和尺寸从哪来,以及谁来保证它们一直是对的"**。
做 App 的过程里有个反复出现的现象:设计稿上写的是 A,开发实现的是 B,评审时没人发现。
因为设计稿是一张图,代码是一堆字符串,两边没有任何机制关联。评审的人只能凭眼睛看,而深浅色下的细微差别、字号 1.5 倍后的布局断裂,眼睛是看不全的。
这篇讲 app/theme/ 那三百多行,以及一个我自己定的规矩:代码里不允许出现任何具体的色值。

从间距开始,因为间距最不容易出错
先看最简单的部分:
dart
/// 4 基准间距刻度。移动端页面左右边距统一 [s4](16dp)。
abstract final class AppSpacing {
static const double s1 = 4;
static const double s2 = 8;
static const double s3 = 12;
static const double s4 = 16;
static const double s5 = 20;
static const double s6 = 24;
static const double s8 = 32;
static const double s10 = 40;
/// 页面左右边距
static const double page = s4;
/// 触控目标下限(docs/flutter-app/02 §1 硬要求)
static const double minTapTarget = 44;
}
只有 4、8、12、16、20、24、32、40 这八个值。
用刻度而不是任意值,是因为任意值会蔓延。今天用 16,明天看到某个地方"刚好差一点"就用 15,后天又用 17------最后整个 App 会有三十个不同的间距值,看起来都不协调但说不出哪里不对。
而那行注释 触控目标下限(docs/flutter-app/02 §1 硬要求) 说明了一件事:这个 44 不是设计偏好,是可访问性要求。
44dp 是苹果和安卓都给出的最小触控目标建议。低于这个值,手指点不准------尤其是手大的人、年纪大的人。
它被写在代码里而不是口头约定,意味着它可以被检查。(M4-25 后半段会讲怎么机器化验证。)
圆角和时长同理:
dart
abstract final class AppRadius {
static const double xs = 4; // 徽章、标签、小缩略图
static const double sm = 6; // 输入框、次级按钮
static const double md = 8; // 卡片、图片、按钮(默认)
static const double lg = 12; // 底部面板、抽屉顶部
static const double full = 999; // 头像、胶囊标签
}
abstract final class AppDuration {
static const Duration fast = Duration(milliseconds: 120); // 按下态
static const Duration base = Duration(milliseconds: 200); // 淡入、展开
static const Duration page = Duration(milliseconds: 250); // 页面转场
static const Curve curve = Curves.easeOutCubic;
}
每个圆角后面都写了"用在哪"。 这不是废话------半年后有人想用 md 做输入框,他就能看出"输入框应该是 sm",因为注释说了。
而 AppDuration 只有三档,因为它还有个额外作用:M4-14 讲过,无障碍要求在系统关闭动画时用 Duration.zero。 而 fast / base / page 这三个值和系统的"减弱动态效果"设置是可以对齐判断的。
为什么用 ThemeExtension 而不是 ColorScheme
Flutter 内置的 ColorScheme 有几十个字段,Theme.of(context).colorScheme.primary。用它不是不行,但它有个问题:
它把"语义色"和"具体颜色"混在了一起。 primary 是品牌主色还是"主要按钮的背景色"?如果是前者,那"标题文字的颜色"应该用什么?
所以现在的做法是自定义一个 ThemeExtension:
dart
class AppColors extends ThemeExtension<AppColors> {
static const AppColors light = AppPalettes.light;
static const AppColors dark = AppPalettes.dark;
...
@override
AppColors lerp(ThemeExtension<AppColors>? other, double t) { ... }
}
Flutter 对 ThemeExtension 的要求是三件事:
一、必须可比较相等。 因为切换主题时 Flutter 要判断"颜色变了吗",变了就重建。
二、必须实现 copyWith 和 lerp。 lerp 用于主题动画------切换深浅色时颜色平滑过渡而不是瞬间跳变。少了它,切换会有生硬的闪烁。
三、可以挂在 ThemeData.extensions 里,通过 context.colors 取。 业务代码写 context.colors.textMuted,不写 Theme.of(context).colorScheme.onSurfaceVariant。
第三点带来的收益是:业务代码完全不知道颜色是从哪来的。 想改配色,只改 AppPalettes;想加一个语义色,只改那个类。
而这个写法强制了一个纪律:业务代码里不允许写颜色字面量。
为什么不允许写色值
这是这一篇的核心决定。
dart
// ❌ 这样写是禁止的
Text('标题', style: TextStyle(color: Color(0xFF1A1A1A)))
// ✅ 只能这样写
Text('标题', style: context.text.title)
理由是深浅色。
设想有人写了 color: Color(0xFF1A1A1A)(接近黑)。在浅色主题下它是对的,但在深色主题下,接近黑的文字放在深色背景上,用户什么都看不见。
而这个 bug 在评审时极难发现------因为评审通常在浅色环境进行。只有切换到深色、而且刚好找到那一屏,才可能注意到。
用语义名之后,问题在类型层面就消失了:context.text.title 在深色下会解析成深色的文字色,因为它走的是主题。
这是一个"用架构消除错误类别"的例子。 不是"记得别写错",而是"写成这样就编译不过"。
同样的道理适用于字号:不用 fontSize: 14,用 context.text.bodySmall。因为字号还牵扯到大字号缩放(M4-13 讲过,textScaler 变化会让缓存失效)------如果字号散落在各处,有些地方会跟着缩放,有些不会,深浅色之外又多了一个不一致的维度。

渐变:ThemeExtension 的盲区
这里有一个必须承认的限制:
dart
// 焦点区渐变(06 §2.1 `color.bg.wash`)。ThemeExtension 不能直接持有
ThemeExtension 不能直接持有渐变 (LinearGradient)。因为它要求可比较相等、可以实现 lerp,而渐变对象的相等比较语义不明确。
现在的解法是把渐变单独放一个扩展里:
dart
class AppGradients extends ThemeExtension<AppGradients> {
// wash / scrim / shimmer 等
}
一个 ThemeExtension 只能有一个类,所以需要多个扩展并存。这个限制有点烦,但它保证了颜色的相等语义清晰。
顺带说,如果渐变只用两个颜色、且那两个颜色都在 AppColors 里,那可以从颜色算出渐变------这是更干净的做法,但要多写一遍 lerp 逻辑。现在的选择是单独扩展,简单直接。
深浅色切换:不只是换颜色
切换主题时,除了颜色,还有几个东西要跟着变:
dart
/// 焦点图使用原型图标和渐变;切换索引仅影响当前轮播组件。
class FocusStories extends StatefulWidget {
M4-10 讲过首页的焦点图。它用的是渐变背景------而渐变在深浅色下是不同的(浅色下要淡,深色下要亮)。
所以焦点卡片的构建逻辑要拿到当前主题:
dart
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [context.colors.wash, context.colors.surface],
)
所有依赖背景色的元素都必须走语义色。 而这正好是"禁止色值"这条纪律的另一个收益------它强制把所有这类地方都标出来。
M4-10 讲过那句注释:"焦点图使用原型图标和渐变;切换索引仅影响当前轮播组件。" 这句话在设计语境下没什么信息量,但在代码语境下它是在说"这个组件的外观依赖主题"。
大字号:最难处理的一个
前两个好办,深浅色切换是"换一个值",大字号是"布局可能要重排"。
M4-13 讲过 didChangeDependencies 里读 MediaQuery.textScalerOf(context) 来触发缓存失效。但那只是数据层------界面上更麻烦。
用户把系统字号调到最大,以下这些都会出问题:
| 元素 | 正常 | 大字号下 |
|---|---|---|
| 底部 Tab | 图标 + 文字横排 | 挤不下,可能换行或者溢出 |
| 标签栏(chips) | 一行几个 | 全部堆叠,甚至撑破容器 |
| 文章卡片 | 标题一行 | 标题占三行,摘要被挤掉 |
| 头像 + 昵称 | 横排 | 昵称换行,布局错乱 |
这些问题的共同点:它们不是"字体变大了",而是"可用宽度相对变小了"。
一个常见的错误解法是给这些地方设固定高度。那样会导致文字被裁掉或者溢出。 正确做法是让它们能自然换行。
比如标签栏用 Wrap 而不是 Row:
dart
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final t in tags.take(8))
ActionChip(label: Text(t.name ?? ''), onPressed: () => search(t.name)),
],
)
Wrap 会在空间不够时自动换行 ,而 Row 不会。这是解决大字号问题最省事的办法------很多"大字号下布局崩了"的问题,换成 Wrap 就好了。
而底部 Tab 的图标尺寸,那属于设计稿和实现的分歧,需要人工对齐(M4-25 设计侧那 13 项待裁定里有这一项)。
触控目标:44 是硬线
最后是 M4-25 设计规范里那条硬要求:
dart
/// 触控目标下限(docs/flutter-app/02 §1 硬要求)
static const double minTapTarget = 44;
问题是:怎么保证每个可点击的东西都达到了 44?
靠人眼看不出来。一个 32×32 的图标按钮,谁也不会去量。所以现在有两个机制:
一、视觉尺寸和点击区域分离。 一个看起来 24×24 的图标按钮,可以用 SizedBox 或 Padding 把点击区域撑到 44:
dart
IconButton(
constraints: BoxConstraints(minWidth: 44, minHeight: 44),
icon: const PrototypeIcon('search', size: 20),
)
这样视觉上还是紧凑的,但手指点得准。 这是 Material Design 的标准做法。
二、机器化检查。 一个脚本扫所有可点击组件,验证它们的约束不小于 44。这个检查能抓到"忘了加"的情况,因为人的眼睛抓不到。
同样的思路也用在别处:
| 检查项 | 抓什么 |
|---|---|
| 触控目标 ≥ 44 | 忘了加 BoxConstraints |
| 深浅色下的文字对比度 | 用了语义色但选错了 |
| 大字号下是否溢出 | 用 Row 而非 Wrap |
| 是否存在色值字面量 | 没走语义色 |
这四类问题的共同点是"肉眼容易漏、机器容易抓"。 而凡是这类问题,就值得写一个检查脚本。
顺带说,深浅色对比度这个检查有个现实限制:代码高亮的颜色是第三方库(highlight.dart)生成的 ,它的深色主题配色不在我们的令牌体系里。所以代码块在深色下的对比度只能靠人看。
这是我在这个项目里最不愿意承认但必须承认的事------有一类问题只能靠人眼,而它恰好是设计上最重要的一类。

一条我一开始想省掉的事
那三个动效时长(fast / base / page)我最初是直接写数字的:
dart
duration: const Duration(milliseconds: 200), // 散落各处
改成令牌之后,立刻碰到一个问题:怎么在系统关闭动画时真的变成 0?
因为如果只是"定义一个常量",那代码里写死的 200 还在。需要的是每个用到动效的地方都判断一次系统设置,而那是个容易漏的判断。
现在的做法是在少数几个真正需要动效的地方用 M4-14 那个模式:
dart
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 250),
注意我没有做一个"全局开关"来省事。 因为"关闭动画"不是全局统一的------目录滚动要关、页面转场可以保留、横滑交互条可能还是要动。一刀切的开关会连用户不需要关的部分也关掉。
代价是每个动效点都要写这一行,而这是可检查的 ------一个脚本可以扫"用了 Duration(milliseconds: X) 但前面没有 disableAnimationsOf 判断"的地方。
所以真正省事的做法不是抽象,是加检查。 抽象会让不同场景被迫一致,而一致性在这里不是优点。
小结
这一篇的结论不多,但每一条都在解决一类"肉眼难发现"的问题:
- 只有八个间距值 ------ 任意值会蔓延,且无法察觉。
- 语义名代替色值和字号 ------ 用架构消除错误类别,而不是靠人记得。
- 业务代码不知道颜色从哪来 ------ 想改配色只改一处。
- 渐变是
ThemeExtension的盲区 ------ 需要单独扩展,这是个已知限制。 - 大字号 = 宽度相对变小 ------ 用
Wrap让它自然换行,而不是固定高度。 - 44dp 触控目标可以机器检查 ------ 肉眼量不出来。
- 不做的抽象用检查来补 ------ "关闭动画"的判断逐点写,但可以扫。
第 2 条是这一篇的核心,而且它和 M4-24 讲的图片鉴权头是同一个原则:用结构消除错误,比用纪律减少错误可靠。 因为纪律依赖于每个开发者在每个时刻的注意力,而结构是自动生效的。
第 7 条则是一个反思:我一开始想做一个"全局动效开关",但那会牺牲场景差异。后来发现正确的解法不是抽象更细,而是加检查 ------这在设计系统里是个通用思路,因为它把"保证一致性"的成本从"每次写代码时记得"降到了"CI 里跑一遍"。
下一篇讲测试分层------它是这个系列里唯一一个不产出用户可见功能的话题,但它是唯一一个能防止回归的机制。
延伸阅读
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer
