35 · 无障碍:Accessibility / Semantics
一句话定位:Flutter 的无障碍不是"加几个标签",而是一条与 Widget 树平行的语义树(Semantics Tree);本篇讲它怎么生成、怎么补、怎么测,以及为什么"视觉还原"不等于"无障碍达标"。
开篇速览
| 问题 | 答案 |
|---|---|
| 前端对应物 | React 的 aria-* / 无障碍树,Vue 的 v-bind:aria |
| 核心概念 | 每一帧都会从 Widget 树推导一棵 Semantics Tree,读屏软件只认这棵树 |
| 最常见缺口 | 纯图标按钮无语义、图片无替代文本、自定义绘制无语义、手势无操作名 |
| 一句话红线 | 语义树缺失时,界面"看起来没问题",但读屏用户什么都得不到 |
代码来源说明:HaierEnergy 当前
lib/中未系统接入无障碍标注(Semantics仅在组件库个别入口出现过)。 本篇按"通用方案 + 接入建议"撰写,落地时对照 07 项目架构总览 的he_components目录约定。
一、语义树是什么
读屏(TalkBack / VoiceOver)不读 Widget 树,也不读渲染结果,只读语义树。语义树是一棵"给辅助技术看的树",每个节点代表一个"用户可感知的元素":
| 层 | 谁生成 | 读屏看到什么 |
|---|---|---|
| Widget 树 | 你写的代码 | 看不到 |
| RenderObject 树 | 布局/绘制 | 看不到 |
| 语义树 | 框架从 Widget 树推导 | 角色(按钮/图片/文本)、名称、状态(选中/禁用)、操作(点击/滑动) |
推导规则:
Text/Image/IconButton等内建组件自带语义,多数情况下不用你管;- 手势、自定义绘制、多子 Widget 组合不会自动有语义,必须手动补;
- 一个 Widget 树节点合并后可能变成语义树里的一个节点(
MergeSemantics就是手动控制合并)。
与前端最大的思维差异
前端是"标签(aria)即语义";Flutter 是"语义要自己推导/声明"。你不写 Semantics,框架只能猜。猜的规则:
Text→ 它的文本IconButton→ 它内部的Icon+tooltipGestureDetector→ 完全没有语义(手势不是语义)
二、四个高频语义缺失
2.1 纯图标按钮没有名字
dart
// ❌ 读屏只报"按钮",不知道干什么
IconButton(icon: const Icon(Icons.settings), onPressed: _openSettings)
// ✅ 用 tooltip 当名字(IconButton 会自动把 tooltip 放进语义)
IconButton(
icon: const Icon(Icons.settings),
tooltip: '设置',
onPressed: _openSettings,
)2.2 图片没有替代文本
dart
// ❌ 装饰性图片:读屏会读文件/URL,信息量接近 0
Image.asset('assets/images/plant_banner.png')
// ✅ 信息性图片:给语义名
Semantics(
image: true,
label: '3 号电站今日发电 120 度',
child: Image.asset('assets/images/plant_banner.png'),
)
// ✅ 纯装饰图片:明确告诉读屏跳过
Semantics(excludeSemantics: true, child: Image.asset('assets/images/divider.png'))2.3 自定义绘制没有语义
CustomPainter 画出的图表、进度环,读屏一无所知。要补 Semantics:
dart
Semantics(
label: '今日用电 86 度,较昨日下降 12%',
child: CustomPaint(painter: _GaugePainter(progress: 0.86)),
)2.4 手势没有操作名
dart
// ❌ 读屏不知道该手势能干嘛
GestureDetector(onTap: _toggle, child: _card)
// ✅ 声明语义动作 + 按钮角色
Semantics(
button: true,
label: '展开更多',
onTap: _toggle,
child: GestureDetector(onTap: _toggle, child: _card),
)三、Semantics API 速查
| 场景 | 属性 | 说明 |
|---|---|---|
| 元素名字 | label | 读屏读到的文本 |
| 元素角色 | button / image / slider / header | 告诉读屏"这是什么" |
| 状态 | selected / enabled / checked | 选中/可用/勾选态 |
| 手势动作 | onTap / onLongPress / onIncrease / onDecrease | 让读屏用户能操作 |
| 聚合子节点 | MergeSemantics | 把子元素合并成一个语义节点,避免逐字朗读 |
| 屏蔽子节点 | excludeSemantics | 对读屏隐藏整棵子树 |
| 隐藏自身 | Semantics(container: true, ...) | 强制当前节点成为独立语义容器 |
| 说明文字 | hint / value | 操作提示 / 当前数值 |
MergeSemantics 经典场景
InkWell 包着一行"图标 + 文本 + 数值",不合并会读成三个孤立节点;合并后读成一个整体:
dart
MergeSemantics(
child: InkWell(
onTap: _openDetail,
child: Row(children: [
const Icon(Icons.bolt),
const Text('今日发电'),
Text('$value 度'),
]),
),
)四、动效与无障碍的冲突(必读)
配合 29 动画进阶 一起看。两条硬规则:
| 规则 | 做法 |
|---|---|
| 用户开了"减弱动效" | 读 MediaQuery.disableAnimationsOf(context),直接跳过/缩短动画 |
| 动画不能是唯一信息载体 | 状态变化要同步进语义(如"加载完成"用 Semantics(liveRegion: true)) |
dart
// 尊重系统"减弱动效"
final reduce = MediaQuery.disableAnimationsOf(context);
if (reduce) {
// 直接跳到最后状态,不做过渡
} else {
_controller.forward();
}五、文字与对比度
无障碍不止读屏,还包括可读性:
| 项 | 最低标准 | 检查方式 |
|---|---|---|
| 字号可放大 | 用 textScaler 支持系统字体缩放 | MediaQuery.textScalerOf(context),别写死 |
| 对比度 | 正文 ≥ 4.5:1,大字号 ≥ 3:1 | 设计稿阶段核对色板 |
| 点击区域 | ≥ 48×48 逻辑像素 | 参照 11 组件与主题 的 He 组件规范 |
| 颜色不能是唯一信号 | 状态不能只靠"红/绿"表达 | 配合文字或图标 |
字体缩放是最容易被忽略的回归点:很多页面在 textScaleFactor: 1.3 下直接溢出。验收时把系统字体调到最大跑一遍全流程。
六、怎么测(三个层级)
6.1 静态检查
flutter analyze 能抓出一部分:avoid_redundant_argument_values、缺少 tooltip 等 lint。
6.2 Widget 测试里断言语义
dart
testWidgets('读屏用户能听到设置按钮', (tester) async {
await tester.pumpWidget(const MyPage());
final semantics = tester.getSemantics(find.byType(IconButton));
expect(semantics.label, contains('设置'));
expect(semantics.hasTapAction, isTrue);
});6.3 真机读屏验证
| 平台 | 工具 | 验证方式 |
|---|---|---|
| Android | TalkBack | 从页面顶部开始滑动,逐项听读屏播报 |
| iOS | VoiceOver | 同上,并检查"转子"里的自定义操作 |
必须真机验证的原因:语义树在真机上才完整(开发工具里的语义预览是近似)。
七、组件库怎么落地(项目建议)
在 he_components 层面一次性做对,而不是每个页面补:
- 每个 He 组件补默认语义:
HeIconButton强制tooltip,HeSwitch声明onChanged语义动作; - 新建组件自检清单加一条:"这个组件在
excludeSemantics掉 icon 后,读屏还能理解吗?"; - 页面级做一次语义盘点:把每页的"纯装饰 / 信息性 / 可操作"三类元素过一遍。
动手练习
- 打开任一 He 页面,用真机 TalkBack / VoiceOver 滑一遍,记录 3 个"读不到或读错"的元素;
- 用
Semantics/MergeSemantics修掉其中 1 个,并写一个断言语义的 widget test; - 把系统字体放大到最大,跑一遍主流程,修掉溢出点。
自检清单
- [ ] 能解释"读屏只读语义树,不读 Widget 树"
- [ ] 能说出 4 个高频语义缺失场景(图标按钮/图片/自绘/手势)
- [ ] 会用
Semantics、MergeSemantics、excludeSemantics - [ ] 会处理"减弱动效"与
liveRegion - [ ] 知道正文对比度与点击区域的最低标准
- [ ] 能在 widget test 里断言语义
常见误区
- "内建组件不用管":
IconButton没tooltip时语义名是空的,等于没名字; - "加个
Semantics包一层就行":要按场景选label/onTap/excludeSemantics,包错反而多一层噪音; - "图片是装饰,不用管":装饰图片要显式
excludeSemantics,否则读屏读路径; - "测试跑过就行":语义断言覆盖不到读屏的真实播报顺序,必须真机验证。
相关文档
- 00 · 学习路线图 · README —— 阅读顺序与能力分级
- 05 · 交互与动画 —— 手势竞技场是"手势无语义"的根源
- 29 · 动画进阶与动效工程化 —— 减弱动效处理
- 11 · 组件与主题 —— 组件库规范,无障碍应在组件层落地
- 19 · 测试实战 —— 语义断言写法
- IM · 移动端面试题 —— 无障碍高频题