看见的树是 RenderObject;读屏器用的是 Semantics 树。两者并行,不是同一棵树的「无障碍模式」。关闭辅助功能时,语义管道几乎零成本;打开 TalkBack / VoiceOver 后,它成为第二条必须与布局同步提交的路径:脏语义 → 合成 SemanticsNode 树 → Engine 做成 SemanticsUpdate → 平台无障碍桥映射成 AccessibilityNodeInfo / UIAccessibility 元素。自定义绘制如果只画像素不供语义,结果是「能看见、能点到、读屏说没有按钮」。
读完你应该能独立回答三个问题:为什么视觉上的一个滑杆必须在语义上变成 slider、MergeSemantics 合并的是焦点还是绘制、打开读屏后掉帧该查哪条线程。
1. 先画边界:两棵树服务两个消费者
GPU 消费 Layer 树。人与系统服务(读屏、开关控制、输入法辅助、部分自动化测试)消费语义树。映射不是一一对应:
- 一个
RenderCustomPaint可以画出整张键盘,语义上必须是多个 key。 - 一个视觉按钮可能由图标 + 文字 + 水波纹三个 RenderObject 组成,语义上应是 一个 按钮。
Offstage/Opacity(0)/ 零尺寸不一定等于语义消失;ExcludeSemantics/IgnorePointer也不自动互为充要。
不变量:
- 语义更新跟 UI Runner 走,提交给 Engine 后,平台桥在 Platform Runner 上改系统无障碍节点。打开读屏时,主线程多了一份工作。
- 语义节点有自己的几何(屏幕坐标上的 rect),来自 RenderObject 的 paint bounds 变换。transform / clip / scroll offset 必须反映进语义几何,否则焦点框画在错的地方。
- 没有语义的可点控件,是产品 bug,不是「无障碍没开所以无所谓」。自动化和系统手势也会走这棵树。
SemanticsBinding.ensureSemantics() 会强制开管道,即使系统读屏没开。测试里常用。生产里不要无故 ensure——你在付第二条提交路径的钱。
2. 节点从哪来:Configuration 的合并规则
每个 RenderObject 可贡献 SemanticsConfiguration:是否成为节点、标签、hint、value、flags(button / focused / selected / hidden / textField…)、actions(tap / longPress / scroll / setValue / copy…)。SemanticsOwner 在帧末收集脏节点,做一次合成。
合成时的关键开关:
isSemanticBoundary: 自己成为独立节点。isMergingSemanticsOfDescendants(MergeSemantics): 子孙的标签和动作收进自己,子孙不再单独露出。图标+文字的按钮靠这个变成一次朗读。ExcludeSemantics: 子树对语义消失。装饰性动画、重复的视觉文本用它。排除后读屏完全不知道那里有东西——可点的不要排除。BlockSemantics: 挡住后面的兄弟(模态)。和绘制上的 barrier 类似,解决的是「对话框下面的按钮仍能被读到」。IgnoreSemanticsvs 隐藏 flag:hidden仍在树上,只是标记;排除是没有。焦点遍历对两者处理不同。
错误合并的典型后果:焦点顺序乱(三个控件被读成一句)、或一句被拆成三次(没 merge)。合并不是性能优化,是 身份 问题,和 Element 的 Key 同一类。
Semantics(
button: true,
label: '提交订单',
onTap: _submit,
child: const CustomPaint(painter: FancyButtonPainter()),
)
CustomPaint 默认几乎不贡献语义。没有这层 Semantics,读屏在这块矩形上会沉默,尽管 GestureDetector 能点。
3. 动作、焦点、文本:和竞技场不同的输入
语义动作是 另一条输入路径。读屏用户的「激活」变成 SemanticsAction.tap,不一定经过你的 TapGestureRecognizer。所以只在 GestureDetector.onTap 里处理、不在 Semantics.onTap / 按钮的 onPressed 里处理,读屏会点了没反应。ElevatedButton 这类控件两边都接好了;自定义 RenderBox 必须自己在 describeSemanticsConfiguration 里挂 action,并在 handler 里走同一套业务。
焦点:Flutter 的 FocusNode 和平台无障碍焦点不是自动同一件事。读屏焦点移动时,平台桥会请求 SemanticsAction.focus。文本框还要同步 selection、IME。自定义文本如果只用 TextPainter 画,不提供 Semantics 的 text field 配置,VoiceOver 转子无法按字符读。
滚动:Scrollable 暴露 scrollUp/Down。读屏的「往后翻」走动作,不是拖手指。自己写的列表若不用 Scrollable,要自己报可滚动和动作,否则用户卡在视口里。
4. 几何、裁剪、叠层:焦点框为什么会飘
语义 rect 在屏幕坐标。Transform、滚动 offset、PageView 视差,都要进变换链。漏了,朗读内容对、绿框错位——用户以为点的是 A,激活的是 B。
Clip 后的语义:被裁掉的节点应标记 hidden 或从树上摘掉,取决于是否还可交互。OverflowBox 画在外面但仍可点,语义也必须在外面。这和 HitTest 一致:语义几何应覆盖可交互区域,而不是「看起来的区域」。
Platform View 是又一次断裂:原生 WebView 有自己的无障碍树。Flutter 这一侧往往只有一个「占位」节点。深度读屏要进 WebView 内部,靠平台桥切换。不要对 Platform View 再 MergeSemantics 包一层假标签把内部挡死,除非它真的不可达。
5. 线程、内存、帧成本
关闭语义:RenderObject 仍可能做很轻的 configuration 标记,但不组树、不发 update。成本可忽略。
打开后:
- 每帧若有语义脏(动画改 label、进度条改 value、列表滚动改可见窗口),UI 线程要重算节点和几何。
- 提交到 Engine 后,Android
AccessibilityBridge在主线程上 diff 并更新AccessibilityNodeInfo。频繁改 value(每帧进度百分比)会打满 Platform Runner。 - 节点数量近似「可交互控件数」,不是 Widget 数。但错误地让每个装饰图标都成为节点,会让读屏遍历无法使用,也会让 diff 变贵。
不变量:动画不要每帧改语义字符串。 进度用 value 且降频(100ms 一次或只在停时更新)。滚动中的列表靠框架的可见窗口更新,不要自己每像素 setSemantics。
内存:语义树相对 Layer 很小。真正的成本是主线程 IPC 到系统 AccessibilityService。测打开 TalkBack 后的 vsyncOverhead 和 Platform 轨道,不要只看 Dart 堆。
6. 生命周期
SemanticsHandle 的持有者(测试、Inspector、系统读屏)决定管道开关。最后一个 handle 释放,管道关。自己 ensureSemantics 必须配对 dispose,否则生产里语义一直开——低端机上能测出掉帧。
Element unmount:对应语义节点要从树上摘掉,否则平台侧残留「幽灵焦点」,朗读已经不存在的按钮。这是框架的活;自定义 SemanticsOwner 才需要自己担心。
热重载后语义树重建。Debug 里读屏顺序偶发错乱,以 Release + 真机为准。
多 Engine:每份 Engine 一份语义桥。系统读屏一次只深度进入一个窗口。混合 App 里 FlutterView 和原生 View 的焦点切换靠 Embedder,不是 Dart 的 FocusScope。
7. 设计取舍
- 独立语义树而不是给 Layer 加标签: 视觉和辅助可以分叉(一个滑杆、一段 Path)。代价是两套要同步,漏了就是无障碍 bug。
- 默认控件带语义、自定义绘制不带: 让普通 App 自动可用。代价是游戏、图表、自绘控件作者必须补作业。
- Merge 以「用户理解的控件」为粒度: 一次激活一件事。代价是过度 merge 会让内部元素不可达(例如按钮里的「更多」被吞)。
- 打开才组树: 默认用户不付费。代价是「没开读屏时测不到」——CI 要用
SemanticsHandle强制开。
Flutter 选自绘 UI,就必须自己做这棵树。这是 Engine 架构那篇说过的账单:像素跨端一致,无障碍要自己接。
8. 排障
- 能点不能读: 自定义绘制缺
Semantics。用 DevTools 的 Semantics 调试器或debugDumpSemanticsTree()看节点在不在。 - 读三次: 缺
MergeSemantics,图标、文字、Ink 各成节点。 - 读一次但内部不可达: 过度 merge。把可独立操作的孩子移出 merge。
- 焦点框错位: 变换/滚动没进语义几何。看节点 rect 和真实 paint bounds。
- 打开 TalkBack 掉帧: 每帧改语义。降频 value,动画不要改 label。查 Platform 线程。
- 对话框下面还能读到: 缺
BlockSemantics/ModalBarrier。 - 测试绿、真机红: 测试没
ensureSemantics,或只断言了像素。用tester.getSemantics/matchesSemantics。 - Platform View 内部读不到: 期望错了。进原生树,或给占位标签说明「网页区域」。
testWidgets('提交按钮可被读屏激活', (tester) async {
final handle = tester.ensureSemantics();
addTearDown(handle.dispose);
await tester.pumpWidget(const MyForm());
expect(
tester.getSemantics(find.byKey(const Key('submit'))),
matchesSemantics(label: '提交订单', isButton: true, hasTapAction: true),
);
});
没有这个测试,语义回归只能靠人听。像素回归测不出它。
9. 自定义 RenderObject 必须同步的三件事
paint、hitTest、describeSemanticsConfiguration 是同一个控件的三个面。改一个必须问另两个:
- 画了新的可点区域?hit 和语义 rect 要扩。
- 加了手势?语义要有对应 action,handler 走同一函数。
- 视觉上禁用了?
enabledflag 和IgnorePointer一起改,否则读屏仍说可激活。
漏语义不是「以后再加的 polish」。它是协议缺口。
10. Flag、Action、排序:读屏真正用的协议
SemanticsFlag 和 SemanticsAction 是平台桥的词典。常用 flag:isButton、isLink、isHeader、isTextField、isFocused、isSelected、isEnabled、isHidden、isImage、hasCheckedState / isChecked、isToggled、isLiveRegion。flag 决定读屏怎么称呼和转子里有哪些动作。把非按钮标成 button,用户会用「激活」预期导航,你却只做了展示。
isLiveRegion:内容变了要主动朗读(错误提示、验证码倒计时)。滥用会变成每秒打断用户。倒计时不要 live;错误出现那一次要。
SemanticsAction:tap、longPress、scrollLeft/Right/Up/Down、increase/decrease(滑杆)、setSelection、copy/cut/paste、didGain/LoseAccessibilityFocus。滑杆只画不提供 increase/decrease,转子调值会失败。自定义 RenderSlider 必须把 value、valueMax 和这两个 action 写全,handler 里改同一份状态再 markNeedsSemanticsUpdate()。
SemanticsSortKey / OrdinalSortKey 决定遍历顺序。默认大致是阅读方向 + 几何。复杂仪表盘视觉顺序和阅读顺序不同,必须显式 sortKey,不要靠「把 Widget 写在前面」。TraversalGroup 把局部顺序包住,避免一个工具栏的按钮插进正文中间。
AttributedString 给 label 加 locale / 拼写提示。中英混排的按钮,locale 标错会导致 VoiceOver 用错语音库。
CustomSemanticsAction 注册「自定义转子动作」(例如「标记已读」)。平台能展示的数量有限,不要当普通菜单用。
ExcludeSemantics(excluding: !visible) 比把 label 设空更干净。空 label 的按钮仍可能停焦点。
11. 平台桥、测试、以及动画中的语义风暴
Android:AccessibilityBridge 把节点 id 映射到 AccessibilityNodeInfo。id 在树上要稳定,否则 TalkBack 焦点丢失。列表用 index 当语义 id 会在插入一行后焦点跳。框架用的是稳定 node id;自己 SemanticsProperties 不要每帧 new 不同的 label 导致它以为是新控件——label 变可以,角色变要谨慎。
AccessibilityNodeInfo 的 boundsInScreen 来自语义 rect。和 HitTest 不一致时,外接开关设备点的是语义框。所以「能点不能读」的对偶是「读屏焦点在、手指点不中」——两边几何没对齐。
iOS:UIAccessibilityElement。accessibilityTraits 来自 flag。系统转子(字符、标题、容器)依赖 isHeader、isTextField。标题漏标,转子「标题」会跳过整页。
测试:matchesSemantics 是回归的最小集。再加一条集成测试:tester.tap 走手势,tester.semantics.simulatedAccessibilityTool 或 SemanticsOwner.performAction 走动作,断言同一 onPressed 计数。两边不共用 handler 时,这个测试会红。
动画中的语义风暴:AnimatedBuilder 每帧改 Semantics(label: '${percent}%')。打开读屏后 UI 线程组树、主线程 IPC、读屏进程朗读取消再朗读。表现是动画掉帧 + 语音卡成「二十二十一二十二」。改成 value 数值、liveRegion 关闭、最多 250ms 节流更新 label。进度条用 Semantics(value: '$_p') 而不是把数字写进 label 每帧换。
debugDumpSemanticsTree() 的缩进就是树。看到一堆 ignored / hidden 节点,说明排除没做干净,diff 在白做。看到整页一个节点,说明过度 merge。两者都是结构问题,调 label 字符串解决不了。
隐式语义:框架已经替你做了什么
Text 贡献 label(文本本身)。Icon 默认经常 没有 语义——装饰图标要 ExcludeSemantics,功能图标要 Semantics(label: ...) 或 IconButton。Image 无 semanticLabel 时读屏可能跳过或读「图像」。有信息的图必须 label,装饰图必须 exclude。漏 exclude 会让用户听一堆「图像图像图像」。
InkWell / ListTile / Material 按钮已经 merge + button + tap。自定义再包一层 Semantics(button: true) 会双重朗读「按钮按钮」。DevTools 里看是否已经有 button flag。能用 Material 控件就用,语义是免费的。
Sliver 列表只给可见项语义节点。这是性能不变量,也是读屏「滑不到未 build 的项」的原因。读屏滚动动作会驱动 Scrollable 去 build 下一项。自己的虚拟列表若不接 SemanticsAction.scrollDown,读屏用户卡死。ListView 接好了;CustomPaint 画的列表没有。
国际化、数字、日期
label 要已经格式化、已经翻译。把 DateTime.toString() 当语义,读屏会读一串 ISO。数字用 NumberFormat。货币不要只靠颜色表示涨跌,flag 或 label 要带「涨/跌」。色盲和读屏同时存在。
ExcludeSemantics 包掉重复的视觉文本(大号装饰数字 + 下面小号同一数字),只留一份。视觉双写、语义单写。
测试之外的手工清单
每次自绘控件合入:TalkBack 线性遍历一遍、VoiceOver 转子「标题/按钮/表单」一遍、外接键盘 Tab 一遍。后一项走 Focus 系统,和语义有交集但不是同一棵树。焦点可见性(FocusHighlightMode)在外接键盘下必须有。只测读屏会漏掉键盘用户。
SemanticsDebugger Widget 用色块把语义矩形画在屏幕上,能立刻看见几何错位和漏节点。它很吵,仅调试。
SemanticsUpdate 是 diff 不是全量快照
Engine 收到的 SemanticsUpdate 按节点 id 增删改。Framework 尽量只发脏节点。你每帧改根上的 label,等于整棵相关子树都可能被标脏。脏的粒度是节点,不是字符。把会变的 value 放在叶子,根保持稳定的角色和 label。进度条:叶子改 value,祖先不要每帧 Semantics(label: '整个工具栏 ${random}')。
id 由框架分配,稳定性和 Element 类似。Key 不稳定导致 Element 重建,语义 id 也换,读屏焦点丢失。列表项用业务 Key,既为 diff 也为读屏。
隐式滚动与「读屏焦点请求可见」
节点被聚焦时,框架会 showOnScreen。自定义 RenderObject 若不实现 showOnScreen / 不加入正确的 scrollable,读屏焦点会移到屏幕外。用户听见按钮、看不见它。Scrollable.ensureVisible 是手动版。嵌在横向+纵向两层滚动里,要两层都确保。这是语义和滚动协议的接缝。
ExcludeSemantics 包掉的区域,读屏焦点不会进去,但 HitTest 仍可能进去——手指能点、读屏跳过。若这是装饰,OK;若是功能,必须一致地 IgnorePointer + ExcludeSemantics,或反过来都暴露。
自动化测试误用语义树
部分集成测试用语义找按钮(find.bySemanticsLabel)。这会倒逼正确语义,是好事。但测试若 ensureSemantics 后不 dispose,后续测试都在「读屏开」的成本模型下跑,性能用例会红。addTearDown(handle.dispose) 必写。也不要只靠语义找控件而完全不测 HitTest——那会放过「读屏行、手指不行」的几何 bug。两边各至少一条。
动态字体、粗体、系统字号
系统字号变大,视觉树 layout 变,语义 rect 必须跟着变。只改 paint 不 mark semantics,焦点框会小。MediaQuery.textScaler 变化应走正常 layout 脏路径,框架会更新语义几何。自定义 TextPainter 绘制若自己 cache 了旧 rect,要在 textScaler 变化时 markNeedsSemanticsUpdate。
自定义动作与确认
破坏性操作(删除)在读屏下应有确认。视觉上可能是滑删,语义上提供 customAction「删除」再弹 dialog。只靠手势的滑删,读屏用户做不到。这是手势竞技场覆盖不了的产品缺口。
与 Platform View、WebView 的焦点切换
焦点从 Flutter 按钮进 WebView 再出来,两端的读屏焦点栈要接。常见 bug:出来后焦点回到页面顶而不是刚才的按钮。保存「上次 Flutter 语义焦点」在离开时,回来 requestFocus。混合栈必测。测的是系统读屏,不是 FocusNode 调试高亮。
无障碍的验收应从「能不能用」升级到「能不能高效用」。能不能用:每个可点控件有角色、有名字、能激活,焦点框对齐。能不能高效用:标题层级让转子可跳,列表有 header,重复装饰被 exclude,liveRegion 不吵,排序符合阅读习惯。第一档没做到是缺陷;第二档没做到是体验债。自绘控件 PR 必须附 debugDumpSemanticsTree 片段或 SemanticsDebugger 截图,和像素截图并列。没有语义截图的自绘 PR 不合并。这条比培训更有效。
法律和商店审核在部分市场会查无障碍。即便不查,语义树仍是自动化测试的稳定锚:文案变、布局变,label 还可以当 find 句柄。投入语义,测试和读屏用户一起受益。把它当重复劳动的人,通常已经在用坐标点按钮——那种测试更脆。
与动画的关系再强调一次:语义不是每帧的展示层。展示层是 Layer。语义是状态层。状态按事件更新(完成、失败、进度到 10% 的阶梯),不按 vsync 更新。谁把语义当展示层用,谁就在读屏开时掉帧。这条不变量能解决一类「为什么开了 TalkBack 就卡」的争论:不是读屏进程魔法般变慢,是你每帧在主线程做 IPC。
语义节点的命名规范:动词+对象(「提交订单」)而不是「按钮1」。图标按钮必须有名字。关闭按钮叫「关闭」不是「叉」。规范进设计 token 和组件。组件库的 AppIconButton 强制 tooltip/semanticLabel 非空,编译期或 lint 检查。空 label 进不了主分支。
列表项语义包含状态:「已选中、未读、三十一秒」。状态变化要 markNeedsSemanticsUpdate,不要指望读屏自己猜颜色。未读红点是视觉,语义要「未读」。
多语言:label 走 l10n。硬编码中文 label 在英文读屏上会用错误语音。测试至少一种伪 locale。SemanticsProperties 的 attributedLabel 标 locale,中英混排的产品名才读得准。
与自动化:find.bySemanticsLabel 用正则时注意翻译。用 key 找控件做功能测试,用语义断言做无障碍测试,两条分开。混在一起会在换语言时把功能测试弄红,团队就会删语义断言。删了就回到零。
新同学容易把 Semantics(container: true) 当性能优化。container 影响的是节点边界和焦点,不是 GPU。乱加 container 会让转子跳得奇怪。语义属性每一个都有无障碍含义,没有「这个 flag 能让树更轻」这种用法。要轻,用 Exclude 掉装饰,而不是加 flag。
文档站的组件页应带「读屏朗读示例」一句。设计师能看见「提交订单,按钮」。比看 flag 列表直观。组件预览模式切到 SemanticsDebugger。投入很小,能挡住一批空 label。
政府/金融类 App 可能有无障碍验收清单。把清单映射到 SemanticsFlag 表,逐条自动化。比每次人工听一遍可重复。人工听仍要做,做抽样,自动化做回归。和像素测试同一结构。
语义工作要进迭代定义:每个迭代至少修一条读屏路径,或保证新自绘控件带测试。完全指望「无障碍专项」会永远排在视觉之后。专项可以做第二档高效用;第一档能不能用必须在日常 PR。日常靠 lint 和测试,专项靠真人听。两条腿。
与国际化团队的接口:新文案进 l10n 时标「也是语义 label」。过长的市场文案不适合朗读,要短名。视觉可以大段,语义要短。双重文案不是浪费,是两个消费者。和 Layer vs Semantics 两棵树同一哲学。产品接受双重文案,无障碍才能在中文营销语气下仍然好用。
语义树还可以当内部调试工具:用读屏走一遍新页,比用眼睛更能发现「能看见但点不中」的几何问题,因为焦点框暴露的是语义 rect。语义 rect 和 HitTest 不一致时,两条输入路径分裂。修到一致,手指用户也受益。无障碍不是额外税,是第二种测试仪。把读屏当测试仪用,投入会自愿发生,而不只发生在验收前夜。前夜才听,修不完。日常当测试仪,修得完。
语义树让「公开协议」四个字有了调试方法:dump、断言、读屏、焦点框。方法在,就可以要求每个自绘控件交出协议。交不出,等于控件没做完。做完的定义从「像素对」扩展到「像素、命中、语义」三面。三面是同一 RenderObject 的三个方法。三个方法一起写,比写完 paint 三个月后补 Semantics 便宜。便宜是因为那时几何还在脑子里。几何离开脑子再补,焦点框必飘。所以语义不是后置。后置是事故的时间表。前置是和 paint 同一天的时间表。同一天,才是同一控件。
无障碍在 Flutter 里贵,是因为自绘。自绘换来了像素一致,账单是自己接语义树和平台桥。账单不能拖到专项。拖到专项,等于承认日常交付的控件是半成品。半成品进生产,读屏用户就进不了生产。进不了,不是他们的设备不行,是协议没交。协议用三个方法交:paint、hitTest、describeSemanticsConfiguration。日常 PR 看三个方法是否同一天改。同一天,几何还在脑子里,焦点框不会飘。这是成本最低的无障碍。最低成本的方案反而要写进长文,因为最低成本不性感,容易被「以后专项」盖掉。盖掉之后成本最高。把最低成本写成测试和 lint,专项才能去做高效用那一档。两档分开,队伍才不会用专项去补日常的洞。洞在日常补。树在帧末提交。读屏在主线程听。你在 Dart 里按事件改状态,不按 vsync 改字符串。事件驱动的语义,才是轻的语义。轻了,打开 TalkBack 才掉得起帧。掉得起,才是真的能用。
组件库的 AppIconButton 强制 label,是把三面里的语义面抬到和视觉面相同的门禁。门禁在库边界,业务过不了空按钮。空按钮进不了库,也就进不了生产。生产上的按钮,读屏都能叫出名字。叫出名字,第一档「能不能用」就对按钮这一类成立。类成立,比零散修几个页面快。快,是因为杠杆在库。杠杆不在专项。专项修页面,库修类。先搬杠杆。搬完再听一遍主路径。听的时候带着转子跳标题。标题在,高效用那一档也开始成立。成立不是完美。完美没有。成立是可发布。可发布,是工程对无障碍该承诺的线。线画在库和测试上,不画在口号上。
可发布那条线画完,主路径听一遍仍不可少。听,是为了抓库覆盖不到的自绘页。自绘页用 dump 附件进 PR。附件比口号硬。硬了,三面同一天才能被看见。看见了,才能合并。合并了,生产里的读屏才不是抽奖。抽奖结束,才是无障碍工程的开始。开始在日常,不在专项前夜。前夜只做抽样。抽样不能代替门禁。门禁在库。抽样在人。人和库一起,树才既轻又完整。轻且完整,打开 TalkBack 才既可听又掉得起帧。两者同时成立,才算交了自绘那张账单。
12. 小结
语义树是 UI 对系统和人的公开协议,Layer 树是对 GPU 的协议。合并规则决定「什么算一个控件」,动作决定读屏如何驱动业务,几何决定焦点框。打开后第二条提交路径走 UI 再走 Platform 线程,所以每帧改语义会掉帧。自定义绘制必须同时提供像素、命中和语义。能 debugDumpSemanticsTree 并对上读屏焦点,无障碍就从道德要求变成和布局一样可调试的系统。