欢迎关注微信公众号:FSA全栈行动 👋
一、背景:为什么需要无障碍开发
很多开发者在写业务逻辑时,可能觉得无障碍开发(Accessibility)有点"可有可无"。但现实是,全球有数以亿计的视障用户每天都在使用这些 App。
如果你的 App 没有任何无障碍配置,这些用户在面对你的 UI 时,就像是在"摸黑瞎改"。他们无法通过视觉感知按钮在哪里、图片是什么内容,更不知道当前的 Switch 是开启还是关闭。
这时,他们依赖的是系统的"屏幕阅读器"(Screen Readers):
-
Android系统对应的功能是TalkBack。 -
iOS系统对应的功能是VoiceOver。
屏幕阅读器本身是"看不见" UI 的。它需要你的 Flutter 应用主动告诉它:
-
"这个
Widget是一个按钮。" -
"这张图片是一个头像。"
-
"这个
Switch现在是开启状态。"
这种让设备能"读懂"界面的信息,在 Flutter 中被称为 Semantics(语义化)。
二、原理:理解 Semantics Tree
很多人以为 Flutter 构建界面时只生成了一棵树。其实,为了实现无障碍功能,Flutter 在底层默默构建了多棵树。
常规的流程是: Flutter Code -> Widget Tree -> Element Tree -> Render Tree。
而在这一切之上,还存在着一棵至关重要的树:Semantics Tree。
它的作用就是把你的 UI 翻译成"人话":
假设你的屏幕上有一个用户卡片:
-
左边是头像
-
中间是名字
Chary Lin -
下面有一个
Follow按钮
普通用户通过眼睛看,能一眼识别出这三部分。但对于视障用户,他需要通过屏幕阅读器去"读"。这时,Flutter 会通过 Semantics Tree 告诉 TalkBack:
"头像,Chary Lin,Follow 按钮"
这种机制就像是给你的 App 准备了一套"盲文"。
三、实战:解决常见的语义问题
为了让无障碍体验更丝滑,我们需要用到 Semantics 相关的组件。下面我通过几个实战场景来拆解。
1、基础:Semantics
Semantics 组件的作用是给其他的 Widget "贴标签"。
比如你用了一个 Icon(Icons.delete)。在视觉上大家都知道这是"删除"图标,但对于 TalkBack 来说,它可能只会被识别成一个"图片"。
如果你想让它被正确读出,你需要这样做:
Dart
Semantics(
label: "Delete",
child: Icon(Icons.delete),
)
进阶用法:
-
hint:告诉用户下一步会发生什么。例如:hint: "Double tap to delete this message"。 -
value:用于处理带有状态变化的组件(如进度条)。例如:value: "70 percent"。 -
button:告诉系统这个组件是可点击的。
这里有个坑:如果你用 GestureDetector 包裹了一个 Container,Flutter 虽然知道这里有点击事件,但它默认并不知道这是一个"按钮"。如果不显式声明,TalkBack 可能只会读出 Container 里的文字,而不会提示这是个按钮。
Dart
Semantics(
button: true, // 明确告诉系统:这是一个按钮
label: "Login",
child: GestureDetector(
onTap: () {},
child: Container(
child: Text("Login"),
),
),
)
加上这个属性后,读屏器会提示:"Login, Button",用户体验瞬间提升。
2、进阶:MergeSemantics 与 ExcludeSemantics
这是两个最容易让新手产生疑惑的组件。
MergeSemantics:把相关的零件凑成一个整体
假设你正在做一个产品卡片,包含 Image、Text(产品名)和 Text(价格)。
如果不做处理,用户每划动一次,屏幕阅读器会报三次:
-
Image -
iPhone 16 -
9000 Yuan
这非常烦人!用户得划很多次才能跳到下一个卡片。
这时候,我们可以用 MergeSemantics 把它们包起来。它会把子组件的所有语义信息"合并"成一条信息。
Dart
MergeSemantics(
child: Column(
children: [
Icon(Icons.phone),
Text("iPhone 16"),
Text("9000 Yuan"),
],
),
)
合并后,读屏器只会报:"iPhone 16, 9000 Yuan"。一步到位,简洁明了。
ExcludeSemantics:剔除重复的"噪音"
这个组件的作用是"隐藏"某些信息。
比如你写了一个 Row,里面有一个 Icon(Icons.home) 和一个 Text("Home")。 如果不处理,读屏器会读:"Home Icon, Home"。这两个词意思一样,听起来很啰嗦,对吧?
这时候我们直接把 Icon 隐藏掉:
Dart
Row(
children: [
ExcludeSemantics(
child: Icon(Icons.home), // 隐藏图标的语义,不让它被读出来
),
Text("Home"),
],
)
现在,它只会简单地读出:"Home"。
3、组合拳:打造完美的设置项
在复杂的工程实践中,我们通常会把这三个组件组合起来。
想象我们要实现一个设置列表项:一个设置图标、文字"Settings"、以及一个向右的箭头图标。
Dart
Semantics(
label: "Settings",
hint: "Double tap to open settings",
button: true,
child: MergeSemantics( // 1. 合并所有子项,避免分三次读
child: Row(
children: [
ExcludeSemantics(child: Icon(Icons.settings)), // 2. 隐藏图标,避免重复
ExcludeSemantics(child: Text("Settings")), // 3. 隐藏文字(因为外层 Semantics 已定义)
ExcludeSemantics(child: Icon(Icons.arrow_forward)), // 4. 隐藏箭头
],
),
),
)
这样,用户只需要划动一次,就会听到:"Settings, Button, Double tap to open settings"。这才是优雅的无障碍体验。
四、总结
为了方便记忆,我总结了一份对比表:
|--------------------|-------|------------------|
| 组件 | 形象理解 | 核心作用 |
| Semantics | 贴上名牌 | 给组件增加语义/含义 |
| MergeSemantics | 装进文件夹 | 将多个关联组件合并为一个语义单元 |
| ExcludeSemantics | 删掉废话 | 隐藏不需要被读出的冗余信息 |
如果你在开发过程中,觉得 UI 的读屏逻辑太碎或者太啰嗦,记得去翻翻这三个组件。
五、最后
无障碍开发不仅仅是为了通过合规检查,更是为了让我们的产品真正具备"温度",让每个人都能平等地使用我们的成果。
本篇到此结束,欢迎在评论区交流你的开发心得。
如果文章对您有所帮助, 请不吝点击关注一下我的微信公众号:FSA全栈行动, 这将是对我最大的激励. 公众号不仅有Android技术, 还有iOS, Python等文章, 可能有你想要了解的技能知识点哦~