Skip to content

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 + tooltip
  • GestureDetector → 完全没有语义(手势不是语义)

二、四个高频语义缺失 ​

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 真机读屏验证 ​

平台工具验证方式
AndroidTalkBack从页面顶部开始滑动,逐项听读屏播报
iOSVoiceOver同上,并检查"转子"里的自定义操作

必须真机验证的原因:语义树在真机上才完整(开发工具里的语义预览是近似)。


七、组件库怎么落地(项目建议) ​

在 he_components 层面一次性做对,而不是每个页面补:

  1. 每个 He 组件补默认语义:HeIconButton 强制 tooltip,HeSwitch 声明 onChanged 语义动作;
  2. 新建组件自检清单加一条:"这个组件在 excludeSemantics 掉 icon 后,读屏还能理解吗?";
  3. 页面级做一次语义盘点:把每页的"纯装饰 / 信息性 / 可操作"三类元素过一遍。

动手练习 ​

  1. 打开任一 He 页面,用真机 TalkBack / VoiceOver 滑一遍,记录 3 个"读不到或读错"的元素;
  2. 用 Semantics / MergeSemantics 修掉其中 1 个,并写一个断言语义的 widget test;
  3. 把系统字体放大到最大,跑一遍主流程,修掉溢出点。

自检清单 ​

  • [ ] 能解释"读屏只读语义树,不读 Widget 树"
  • [ ] 能说出 4 个高频语义缺失场景(图标按钮/图片/自绘/手势)
  • [ ] 会用 Semantics、MergeSemantics、excludeSemantics
  • [ ] 会处理"减弱动效"与 liveRegion
  • [ ] 知道正文对比度与点击区域的最低标准
  • [ ] 能在 widget test 里断言语义

常见误区 ​

  • "内建组件不用管":IconButton 没 tooltip 时语义名是空的,等于没名字;
  • "加个 Semantics 包一层就行":要按场景选 label / onTap / excludeSemantics,包错反而多一层噪音;
  • "图片是装饰,不用管":装饰图片要显式 excludeSemantics,否则读屏读路径;
  • "测试跑过就行":语义断言覆盖不到读屏的真实播报顺序,必须真机验证。

相关文档 ​