Flutter 基础体系 · 第 18/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter 可访问性与国际化:Semantics、焦点、Locale 和文本适配
在 Flutter 中,可访问性和国际化不是给页面“补充几个属性”就完成的功能。它们分别作用于不同层次:
- 可访问性解决用户如何感知和操作界面,包括屏幕阅读器、键盘、开关控制、辅助触控和放大显示。
- Semantics把渲染树中的视觉组件转换为辅助技术可以理解的语义节点。
- 焦点决定键盘、桌面辅助技术和部分移动端辅助操作当前作用于哪个控件。
- Locale描述语言、书写方向以及地区偏好,是选择翻译内容和格式化数据的输入。
- 文本适配解决翻译长度、字体缩放、粗体、RTL(从右到左)和不同平台窗口尺寸导致的布局变化。
这些概念彼此有关,但不能互相替代。例如,给按钮设置 Semantics(label: ...) 不会让它获得键盘焦点;设置 Locale('zh') 也不会自动把日期和数字格式化为符合用户地区的形式。
一、先建立整体模型:视觉树、语义树、焦点树和本地化数据流
Flutter 的界面至少可以从四个角度观察。
1. 渲染树负责“画什么”
Widget 经过 Element 和 RenderObject 后形成渲染树。布局约束、尺寸、颜色、绘制顺序都主要在这一层完成。
例如:
ElevatedButton(
onPressed: submit,
child: const Text('提交'),
)
这段代码表达了一个按钮,但“提交”这个字符串只是视觉内容。辅助技术需要知道:
- 这是一个可操作控件;
- 控件的名称是什么;
- 当前是否可用;
- 当前是否已被选中或切换;
- 激活后会发生什么。
这些信息来自语义树。
2. 语义树负责“它是什么、能做什么”
Flutter 会从可参与语义的 RenderObject 生成 Semantics tree,并通过平台桥接到:
- Android 的 TalkBack 等辅助服务;
- iOS 的 VoiceOver;
- macOS 的 VoiceOver;
- Windows 的辅助技术;
- Web 浏览器的无障碍树和屏幕阅读器。
语义树不是渲染树的逐节点复制。多个视觉节点可能合并为一个语义节点,也可能被排除;一个视觉控件也可能需要额外的语义说明。
3. 焦点系统负责“当前由谁接收操作”
Flutter 焦点系统主要由以下对象组成:
FocusManager:管理整个应用的主焦点;FocusNode:表示一个可获得焦点的节点;FocusScopeNode:管理一个焦点范围;Focus、FocusScope、FocusableActionDetector:在 Widget 树中接入焦点和键盘事件;Actions与Shortcuts:把按键或意图映射为动作。
触摸操作通常不依赖焦点,但硬件键盘、桌面 Tab 导航、键盘快捷键和某些辅助输入方式依赖焦点。
4. Locale 数据流负责“使用哪种语言和格式”
一个典型的数据流是:
系统或应用设置
│
▼
MaterialApp.locale / localeListResolutionCallback
│
▼
Locale 解析为 supportedLocales 中的语言环境
│
▼
Localizations 加载翻译资源
│
▼
AppLocalizations.of(context)
│
▼
Widget 文本、Semantics label、日期和数字格式
其中任何一层配置错误,都可能导致界面语言不正确、回退到错误语言,或者虽然文字翻译了,但日期和数字仍使用不合适的格式。
二、Semantics:从视觉组件构造可操作的语义
2.1 Semantics 的基本结构
Semantics Widget 用于向语义树提供信息:
Semantics(
button: true,
label: '保存设置',
hint: '双击以保存当前设置',
enabled: true,
child: const Icon(Icons.save),
)
这里有几个不同层次的概念:
label:控件名称,回答“这是什么”。hint:操作提示,回答“如何操作或操作后会怎样”。button:控件角色。enabled:是否可用。value:当前值,例如滑块当前百分比。checked、selected、toggled:状态信息。onTap、onLongPress等语义动作:告诉辅助技术该节点可执行什么操作。
Semantics 并不是给任意 Widget 自动增加点击能力。下面的代码只有文字语义,没有可操作动作:
Semantics(
label: '删除项目',
child: const Icon(Icons.delete),
)
如果外层确实是一个操作控件,应优先使用具有正确语义和交互行为的组件:
IconButton(
tooltip: '删除项目',
onPressed: deleteItem,
icon: const Icon(Icons.delete),
)
IconButton 通常会生成按钮语义;tooltip 也会用于描述没有可见文字的图标按钮。只有当标准组件无法表达自定义控件时,才需要手动组合 Semantics 与交互逻辑。
2.2 语义属性、视觉内容和动作必须一致
可访问控件可以抽象为:
控件语义 = 角色 + 名称 + 状态 + 当前值 + 可执行动作
例如一个开关:
Semantics(
toggled: isEnabled,
label: '自动同步',
onTap: toggle,
child: Switch(
value: isEnabled,
onChanged: (_) => toggle(),
),
)
但这段代码存在一个潜在问题:Switch 自身已经提供语义,外层再次添加语义可能造成重复或冲突。更合理的做法通常是直接使用标准组件,并通过 label 或相邻文本构成完整语义:
SwitchListTile(
title: Text(l10n.autoSync),
value: isEnabled,
onChanged: setAutoSync,
)
只有在自定义绘制或复合控件中,才需要主动控制语义合并。
2.3 MergeSemantics、ExcludeSemantics 和 BlockSemantics
视觉上一个组件可能由多个节点组成。例如:
Row(
children: [
const Icon(Icons.warning),
const Text('密码强度不足'),
],
)
如果图标只是装饰,屏幕阅读器不应该读出“警告图标”后再读文本。可以排除图标语义:
ExcludeSemantics(
child: const Icon(Icons.warning),
)
如果一整行应该作为一个语义单元,可以合并:
MergeSemantics(
child: Row(
children: [
const Icon(Icons.warning),
Text(l10n.weakPassword),
],
),
)
需要注意,MergeSemantics 的子树中不能随意包含要求独立语义节点的组件。某些复杂控件内部存在自己的语义边界,强行合并可能触发异常或破坏语义结构。对 CheckboxListTile 等 Material 组件而言,框架已经完成了相应的语义组合,不要再无条件包裹 MergeSemantics。
BlockSemantics 用于让当前语义节点阻挡后面的语义节点,常见于模态内容或临时覆盖层。实际使用时,优先使用 ModalBarrier、Dialog、showDialog 等已经处理了焦点和语义边界的组件。
2.4 语义排序不是视觉排序
屏幕阅读器的读取顺序通常来自语义树顺序,但复杂布局、Stack、表格和自定义绘制可能使其与视觉顺序不一致。
SemanticsSortKey 可以控制语义遍历顺序:
Column(
children: [
Semantics(
sortKey: const OrdinalSortKey(1),
child: const Text('第一项'),
),
Semantics(
sortKey: const OrdinalSortKey(2),
child: const Text('第二项'),
),
],
)
这里的数字只在同一排序上下文中比较。排序键不是给用户显示的编号,也不应代替正确的 Widget 树结构。首先应修正布局和语义层次,只有当视觉布局与辅助技术阅读顺序确实不同,才使用排序键。
2.5 动态状态必须更新语义
假设一个按钮可以展开和收起内容:
Semantics(
button: true,
expanded: isExpanded,
label: l10n.details,
onTap: toggleExpanded,
child: Icon(isExpanded ? Icons.expand_less : Icons.expand_more),
)
isExpanded 改变后,Widget 必须重建,语义树才会更新。若只改变了内部变量而没有触发 setState、状态管理更新或其他重建机制,屏幕阅读器仍会读到旧状态。
同理,异步提交的按钮应反映进行中状态:
Semantics(
button: true,
enabled: !isSubmitting,
label: isSubmitting ? l10n.saving : l10n.save,
child: FilledButton(
onPressed: isSubmitting ? null : submit,
child: Text(isSubmitting ? l10n.saving : l10n.save),
),
)
这里的因果关系是:
isSubmitting为true;onPressed变为null,视觉控件不可操作;enabled和文本同步更新;- 屏幕阅读器能够知道按钮当前不可用或正在保存。
不要只把按钮文字改成“保存中”,却让 onPressed 仍可重复提交。
2.6 label 不应无条件覆盖可见文字
下面的代码可能造成重复或信息不一致:
Semantics(
label: '提交表单',
child: Text('发送'),
)
用户看到“发送”,屏幕阅读器却听到“提交表单”。如果两者表达的不是同一含义,视觉用户和辅助技术用户获得了不同的信息。
更安全的原则是:
- 可见文字已经准确时,不要额外设置
label; - 图标按钮没有可见名称时,提供
tooltip或Semantics(label: ...); label必须来自同一份本地化资源;- 不要把装饰图片、重复标题和已被父节点包含的文字再次读出。
图片示例:
Semantics(
image: true,
label: l10n.profilePhotoDescription,
child: Image.network(
avatarUrl,
errorBuilder: (_, __, ___) => const Icon(Icons.person),
),
)
如果图片只是背景或装饰,应使用:
ExcludeSemantics(
child: Image.asset('assets/banner.png'),
)
三、焦点:键盘导航、表单输入和辅助技术操作的共同基础
3.1 FocusNode 是有生命周期的对象
不要在 build 方法中创建 FocusNode:
// 错误示例
TextField(
focusNode: FocusNode(),
)
每次重建都会产生新节点,导致焦点丢失、监听器失效,甚至造成资源泄漏。
正确做法是在 State 中创建并释放:
class LoginForm extends StatefulWidget {
const LoginForm({super.key});
@override
State<LoginForm> createState() => _LoginFormState();
}
class _LoginFormState extends State<LoginForm> {
late final FocusNode emailFocus;
late final FocusNode passwordFocus;
@override
void initState() {
super.initState();
emailFocus = FocusNode(debugLabel: 'email');
passwordFocus = FocusNode(debugLabel: 'password');
}
@override
void dispose() {
emailFocus.dispose();
passwordFocus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
focusNode: emailFocus,
textInputAction: TextInputAction.next,
onSubmitted: (_) {
passwordFocus.requestFocus();
},
),
TextField(
focusNode: passwordFocus,
obscureText: true,
textInputAction: TextInputAction.done,
),
],
);
}
}
FocusNode 的所有权原则是:谁创建,谁负责 dispose。如果节点由父组件传入,则子组件不应擅自释放它。
3.2 焦点请求必须发生在合适的时机
在 initState 中直接调用:
emailFocus.requestFocus();
通常太早,因为 Widget 可能尚未完成挂载。更可靠的方式是:
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
emailFocus.requestFocus();
});
}
但自动抢焦点并不总是友好:
- 移动端可能立即弹出键盘,遮挡上下文;
- 屏幕阅读器用户可能被突然带到意外位置;
- 页面打开时的焦点转移应有明确的产品语义。
因此,只有当页面的主要任务就是立即输入时才考虑 autofocus 或首次帧请求焦点。错误消息出现后,应把焦点移到真正需要修正的字段,而不是随意移动到页面顶部。
3.3 焦点、键盘和 TextInputAction 的关系
TextInputAction.next、done 等属性向输入法表达下一步动作,但它们不自动保证焦点转移。应用必须实现相应行为:
TextField(
textInputAction: TextInputAction.next,
onSubmitted: (_) => passwordFocus.requestFocus(),
)
输入法动作的具体显示受平台、键盘和字段配置影响:
- Android、iOS 的软键盘通常会根据
TextInputAction显示“下一步”或“完成”; - 桌面端硬件键盘不会弹出软键盘,但仍会产生键盘事件;
- Web 行为取决于浏览器和 DOM/CanvasKit 渲染模式;
- 某些输入法可能不严格遵循应用请求的动作样式。
焦点不是输入控制器。TextEditingController 保存文本,FocusNode 保存焦点状态,两者应分别管理和释放。
3.4 Tab 遍历与 FocusTraversalGroup
桌面和 Web 应用通常需要支持 Tab、Shift+Tab。可以显式设置遍历顺序:
FocusTraversalGroup(
policy: OrderedTraversalPolicy(),
child: Column(
children: [
FocusTraversalOrder(
order: const NumericFocusOrder(1),
child: TextField(
decoration: const InputDecoration(labelText: '邮箱'),
),
),
FocusTraversalOrder(
order: const NumericFocusOrder(2),
child: TextField(
decoration: const InputDecoration(labelText: '密码'),
),
),
FocusTraversalOrder(
order: const NumericFocusOrder(3),
child: FilledButton(
onPressed: () {},
child: const Text('登录'),
),
),
],
),
)
这段代码的前提是三个子节点都能获得焦点。Text、装饰性 Icon 等节点不会自然参与同样的控件遍历。
常见失败表现包括:
- Tab 跳过了主要操作;
- 焦点进入不可见或已禁用的控件;
- 弹窗打开后,Tab 仍然移动到背景页面;
- 关闭弹窗后,焦点没有返回触发弹窗的按钮。
Material 的 Dialog、showDialog 等组件会提供一定的焦点隔离,但复杂自定义 Overlay 需要自行验证。弹窗关闭后,通常应恢复到打开它的控件;如果原控件已经被移除,应选择语义上相邻的稳定节点。
3.5 Shortcuts 和 Actions 不等于普通按键监听
Shortcuts 将物理按键映射为 Intent,Actions 再将意图映射为逻辑动作:
class SaveIntent extends Intent {
const SaveIntent();
}
Shortcuts(
shortcuts: const {
SingleActivator(LogicalKeyboardKey.keyS, control: true): SaveIntent(),
},
child: Actions(
actions: {
SaveIntent: CallbackAction<SaveIntent>(
onInvoke: (_) {
save();
return null;
},
),
},
child: const Focus(
autofocus: true,
child: Text('按 Ctrl+S 保存'),
),
),
)
Windows/Linux 常见的是 Ctrl+S,macOS 通常是 Command+S。生产代码应根据平台或使用适合平台的快捷键策略,不应把一个平台的修饰键硬编码到所有平台。
如果只是监听某个局部键盘事件,可以使用 KeyboardListener;但对于可复用的应用级操作,Shortcuts/Actions 更接近语义动作模型,也更容易与默认键盘行为组合。
四、Locale:语言、地区、脚本和方向不是同一件事
4.1 Locale 的组成
Locale 通常包含:
const Locale('zh', 'CN')
其中:
languageCode是语言代码,例如zh、en、ar;countryCode是地区代码,例如CN、TW、US;- 某些语言还需要脚本代码,例如简体/繁体或塞尔维亚语的拉丁/西里尔脚本。
Locale('zh') 只指定中文语言,不指定地区。Locale('zh', 'CN') 和 Locale('zh', 'TW') 可能选择不同翻译、货币和日期格式。
语言选择与数据格式化是两个问题:
语言:显示“保存”还是“儲存”
格式:显示 2025/03/08、08/03/2025 还是 8 במרץ 2025
不能通过字符串替换解决日期、数字、货币和复数规则。
4.2 配置 MaterialApp 的本地化
使用 Flutter 官方的生成式本地化时,应用通常包含:
import 'package:flutter_localizations/flutter_localizations.dart';
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const HomePage(),
)
如果还没有生成类,需要在 pubspec.yaml 开启:
flutter:
generate: true
创建 l10n.yaml:
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
英文模板 lib/l10n/app_en.arb:
{
"@@locale": "en",
"appTitle": "Account",
"save": "Save",
"saving": "Saving…",
"itemsCount": "{count, plural, =0 {No items} =1 {1 item} other {{count} items}}",
"@itemsCount": {
"description": "Number of items shown in the account"
}
}
中文文件 lib/l10n/app_zh.arb:
{
"@@locale": "zh",
"appTitle": "账户",
"save": "保存",
"saving": "保存中…",
"itemsCount": "{count, plural, =0 {没有项目} other {共有 {count} 个项目}}"
}
页面中使用生成的 API:
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(l10n.appTitle)),
body: Column(
children: [
Text(l10n.itemsCount(3)),
FilledButton(
onPressed: () {},
child: Text(l10n.save),
),
],
),
);
}
}
生成过程通常由 Flutter 工具在构建或相关命令中执行;具体生成时机取决于当前 Flutter 版本和项目配置。若 IDE 尚未识别生成文件,先执行 flutter pub get,再执行 flutter run 或 flutter gen-l10n 检查生成结果。
4.3 Locale 解析不是字符串前缀匹配
假设应用支持:
supportedLocales: const [
Locale('en'),
Locale('zh'),
Locale('zh', 'TW'),
],
用户系统返回 zh_TW 时,应用应优先匹配 Locale('zh', 'TW');用户返回 zh_HK 时,如果没有 zh_HK,通常会回退到语言级别的 zh,具体结果受 Flutter 的 locale resolution 规则和列表顺序影响。
不要在业务代码中写:
if (locale.toString().startsWith('zh')) {
// ...
}
这无法准确处理地区和脚本。语言选择应交给 supportedLocales、localeListResolutionCallback 或应用自己的明确策略:
MaterialApp(
supportedLocales: const [
Locale('en'),
Locale('zh', 'CN'),
Locale('zh', 'TW'),
],
localeListResolutionCallback: (locales, supported) {
for (final deviceLocale in locales ?? const <Locale>[]) {
for (final supportedLocale in supported) {
if (deviceLocale.languageCode == supportedLocale.languageCode &&
deviceLocale.countryCode == supportedLocale.countryCode) {
return supportedLocale;
}
}
}
for (final deviceLocale in locales ?? const <Locale>[]) {
for (final supportedLocale in supported) {
if (deviceLocale.languageCode == supportedLocale.languageCode) {
return supportedLocale;
}
}
}
return supported.first;
},
)
这段代码表达了两级匹配:
- 先匹配语言和地区;
- 再只匹配语言;
- 都失败时使用第一个支持的 Locale。
在复杂产品中,还应处理脚本代码和用户手动选择。用户显式选择的语言一般应优先于系统语言,并持久化到应用设置中。
五、翻译文本、复数和格式化数据
5.1 不要把动态值拼接进固定翻译句子
错误方式:
Text('$name 有 $count 个项目')
这只适合单一语言和单一语序。其他语言可能要求不同顺序、复数形式或词形变化。
使用 ARB 参数:
{
"welcomeUser": "Welcome, {name}",
"@welcomeUser": {
"description": "Greeting shown on the home page",
"placeholders": {
"name": {
"type": "String"
}
}
}
}
生成后:
Text(l10n.welcomeUser(userName))
5.2 复数不是简单的 count == 1
英文常见规则是:
0 items
1 item
2 items
但不同语言的复数分类可能不同。ARB 的 ICU MessageFormat 允许本地化工具按语言处理:
{
"notifications": "{count, plural, =0 {No notifications} =1 {1 notification} other {{count} notifications}}"
}
不要在 Dart 中先决定:
final text = count == 1 ? '1 item' : '$count items';
这样会把英文规则错误地固化到所有语言。
5.3 日期、数字和货币应使用 Locale-aware 格式化
若项目使用 intl,格式化时应传入当前 Locale:
import 'package:intl/intl.dart';
String formatAmount(BuildContext context, num amount) {
final locale = Localizations.localeOf(context).toLanguageTag();
return NumberFormat.currency(
locale: locale,
symbol: '¥',
).format(amount);
}
这里有两个边界:
- 货币符号不一定只由语言决定,地区可能改变货币;
¥可能对应人民币或日元,真实业务应使用明确的货币代码和地区策略,而不是仅靠符号。
日期示例:
String formatDate(BuildContext context, DateTime date) {
final locale = Localizations.localeOf(context).toLanguageTag();
return DateFormat.yMMMd(locale).format(date);
}
DateTime 本身没有时区显示策略。若业务要求显示用户时区,必须先完成时区转换,再交给格式化器;Locale 只解决语言和格式规则,不负责时区业务逻辑。
六、RTL 与布局:书写方向会改变几何关系
Directionality 通过 TextDirection 为子树提供书写方向。MaterialApp 通常会根据 Locale 和本地化配置建立相应方向,但自定义入口或局部内容可能需要显式设置。
应优先使用方向无关的边距和对齐:
Padding(
padding: const EdgeInsetsDirectional.only(start: 16),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Text(l10n.appTitle),
),
)
不要把“左边”当成“起始边”:
// 可能不适合 RTL
Padding(
padding: const EdgeInsets.only(left: 16),
child: child,
)
区别如下:
left/right:物理方向,永远是屏幕左/右;start/end:书写方向相关,LTR 中 start 通常是左,RTL 中 start 通常是右;Alignment.centerLeft:物理左;AlignmentDirectional.centerStart:语义起始方向。
图标也有边界。返回箭头、前进箭头等方向性图标可能需要在 RTL 中镜像,而品牌图标、播放图标或某些数据图表不应无条件镜像。可以根据语义判断,而不是看到 RTL 就统一翻转。
检测方向:
final direction = Directionality.of(context);
if (direction == TextDirection.rtl) {
// 仅对确实具有方向语义的元素进行处理
}
TextAlign.start 和 TextAlign.end 也比 TextAlign.left、TextAlign.right 更适合多语言文本。
七、文本适配:翻译长度只是第一层问题
7.1 文本长度没有稳定上限
同一含义在不同语言中的长度可能差异很大:
Save
Speichern
Enregistrer
保存
因此下面这种布局存在风险:
SizedBox(
width: 80,
child: Text(l10n.save),
)
如果按钮中还包含图标、内边距和字体缩放,固定宽度很容易造成截断或溢出。
更合理的结构是让内容决定最小尺寸,让父布局提供可伸缩空间:
FilledButton(
onPressed: save,
child: Text(
l10n.save,
textAlign: TextAlign.center,
),
)
在横向空间有限时,可使用 Expanded、Flexible 或允许换行:
Row(
children: [
const Icon(Icons.info_outline),
const SizedBox(width: 8),
Expanded(
child: Text(l10n.longDescription),
),
],
)
7.2 文本缩放是用户可访问性设置,不是异常情况
用户可能将系统字体放大。Flutter 当前推荐使用 TextScaler,而不是在新代码中继续依赖已逐步被替代的 textScaleFactor:
final mediaQuery = MediaQuery.of(context);
final scaler = mediaQuery.textScaler;
Text(
l10n.longDescription,
textScaler: scaler,
)
大多数情况下,Text 已经自动使用环境中的文本缩放,不需要手动传递。真正重要的是不要用固定高度包裹可能增长的文本:
// 风险较高
SizedBox(
height: 48,
child: Text(l10n.longDescription),
)
字体放大后,文本可能变成两行或更多行,固定高度会导致裁剪、溢出或遮挡相邻控件。
7.3 maxLines、省略号和信息损失
Text(
l10n.articleTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
省略号只说明“空间不足”,不等于内容仍然可访问。对于标题列表,通常应让用户进入详情查看完整内容;如果截断文本本身是可操作控件,应提供完整语义名称:
Semantics(
label: l10n.fullArticleTitle,
button: true,
child: Text(
l10n.fullArticleTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
)
如果文本已经被父级按钮完整读取,额外的 Semantics(label: ...) 可能重复朗读,应通过语义树检查而不是盲目添加。
7.4 TextOverflow 的选择有语义后果
clip:直接裁剪,用户可能不知道内容丢失;ellipsis:显示省略号,但仍隐藏部分内容;fade:视觉上淡出,也可能隐藏内容;visible:可能破坏布局,但不会主动截断。
选择哪一种取决于信息重要性。错误消息、法律文本、表单标签和主要操作说明不应依赖省略号;装饰性标题或可进入详情的列表项可以使用省略号,但必须确保完整信息仍可获得。
八、主题、对比度和系统可访问性设置
可访问性不只由语义决定,视觉对比度和状态表达同样重要。
主题系统应使用 ColorScheme 和语义角色,而不是到处硬编码颜色:
final colorScheme = Theme.of(context).colorScheme;
Text(
l10n.errorMessage,
style: TextStyle(color: colorScheme.error),
)
但颜色不能是传达错误、选中或成功的唯一方式:
Row(
children: [
Icon(Icons.error, color: colorScheme.error),
const SizedBox(width: 8),
Text(l10n.invalidEmail),
],
)
这里同时使用了图标和文字,色觉差异用户仍能理解状态。图标如果没有独立信息,可以排除其语义,避免重复朗读。
可读取系统可访问性环境:
final media = MediaQuery.of(context);
final highContrast = media.highContrast;
final boldText = media.boldText;
final accessibleNavigation = media.accessibleNavigation;
这些属性的意义是:
highContrast:用户请求更高对比度;boldText:系统请求较粗字体;accessibleNavigation:辅助导航方式可能改变用户对动画、滚动和交互的需求。
不要把 highContrast == true 当作“自动通过对比度检查”。应用仍需提供足够的颜色对比度,并验证禁用、悬停、聚焦、错误等状态。
动画也应考虑减少动态效果的用户设置。不要仅因为 MediaQuery 中某个属性存在,就假设所有平台都以相同方式报告系统设置;Android、iOS、桌面和 Web 的映射能力不同,必须在目标平台实测。
九、表单、校验错误与焦点恢复
表单是 Semantics、焦点和国际化同时发生的典型场景。
9.1 字段标签不能只依赖占位符
不推荐只写:
TextField(
decoration: const InputDecoration(
hintText: '请输入邮箱',
),
)
占位符会在输入后消失,屏幕阅读器也可能把它当作提示而不是稳定标签。更可靠的写法是:
TextField(
decoration: InputDecoration(
labelText: l10n.email,
hintText: l10n.emailHint,
),
)
labelText 表示字段名称,hintText 表示输入格式或示例。两者不应混为一谈。
9.2 错误文本必须与字段关联
TextFormField(
controller: emailController,
focusNode: emailFocus,
decoration: InputDecoration(
labelText: l10n.email,
errorText: emailError,
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return l10n.emailRequired;
}
return null;
},
)
Material 表单字段通常会把 errorText 纳入相关语义,但自定义表单布局不能假设这一点。若错误文本被放在完全独立的区域,辅助技术用户可能不知道它属于哪个字段,应通过字段语义、布局关系或明确标签建立关联。
提交失败后移动焦点:
final valid = formKey.currentState?.validate() ?? false;
if (!valid) {
emailFocus.requestFocus();
return;
}
如果第一个错误字段可能动态变化,应根据实际校验结果选择第一个错误节点,而不是固定聚焦邮箱字段。
9.3 异步校验必须处理竞态
例如用户名校验:
用户输入 "a"
发出请求 A
用户输入 "alice"
发出请求 B
B 先返回:可用
A 后返回:不可用
如果不处理请求顺序,旧响应会覆盖新状态。可采用递增序号:
int _validationVersion = 0;
Future<void> validateUsername(String value) async {
final version = ++_validationVersion;
setState(() {
usernameError = null;
checkingUsername = true;
});
final available = await repository.isUsernameAvailable(value);
if (!mounted || version != _validationVersion) return;
setState(() {
checkingUsername = false;
usernameError = available ? null : l10n.usernameTaken;
});
}
这里的条件 version != _validationVersion 保证只有最后一次输入对应的响应能够更新 UI。mounted 则防止异步操作在页面销毁后调用 setState。
当校验中、成功或失败状态会被屏幕阅读器感知时,还应让状态文本同步进入语义树,避免视觉上显示“正在检查”,但辅助技术只能读到旧错误。
十、一个可运行的组合示例
下面示例展示:
- 生成式本地化;
- Locale 配置;
- 本地化 Semantics 标签;
FocusNode生命周期;- 文本输入和提交;
- RTL 友好的布局;
- 异步状态与按钮可用性同步。
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'l10n/app_localizations.dart';
void main() {
runApp(const ExampleApp());
}
class ExampleApp extends StatefulWidget {
const ExampleApp({super.key});
@override
State<ExampleApp> createState() => _ExampleAppState();
}
class _ExampleAppState extends State<ExampleApp> {
Locale? _locale;
void _toggleLocale() {
setState(() {
_locale = _locale?.languageCode == 'zh'
? const Locale('en')
: const Locale('zh');
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
locale: _locale,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [
Locale('en'),
Locale('zh'),
],
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: ExamplePage(onToggleLocale: _toggleLocale),
);
}
}
class ExamplePage extends StatefulWidget {
const ExamplePage({
required this.onToggleLocale,
super.key,
});
final VoidCallback onToggleLocale;
@override
State<ExamplePage> createState() => _ExamplePageState();
}
class _ExamplePageState extends State<ExamplePage> {
final _formKey = GlobalKey<FormState>();
late final FocusNode _emailFocus;
late final TextEditingController _emailController;
bool _submitting = false;
@override
void initState() {
super.initState();
_emailFocus = FocusNode(debugLabel: 'email');
_emailController = TextEditingController();
}
@override
void dispose() {
_emailFocus.dispose();
_emailController.dispose();
super.dispose();
}
Future<void> _submit() async {
final l10n = AppLocalizations.of(context)!;
final valid = _formKey.currentState?.validate() ?? false;
if (!valid) {
_emailFocus.requestFocus();
return;
}
setState(() {
_submitting = true;
});
await Future<void>.delayed(const Duration(milliseconds: 500));
if (!mounted) return;
setState(() {
_submitting = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.saved)),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text(l10n.appTitle),
actions: [
Semantics(
button: true,
label: l10n.changeLanguage,
child: IconButton(
tooltip: l10n.changeLanguage,
onPressed: widget.onToggleLocale,
icon: const Icon(Icons.language),
),
),
],
),
body: SafeArea(
child: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsetsDirectional.all(16),
children: [
Text(
l10n.emailDescription,
textAlign: TextAlign.start,
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
focusNode: _emailFocus,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
labelText: l10n.email,
hintText: l10n.emailHint,
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return l10n.emailRequired;
}
if (!value.contains('@')) {
return l10n.emailInvalid;
}
return null;
},
onFieldSubmitted: (_) => _submit(),
),
const SizedBox(height: 16),
Semantics(
button: true,
enabled: !_submitting,
label: _submitting ? l10n.saving : l10n.save,
child: FilledButton(
onPressed: _submitting ? null : _submit,
child: Text(_submitting ? l10n.saving : l10n.save),
),
),
],
),
),
),
);
}
}
对应的中文 ARB 可以包含:
{
"@@locale": "zh",
"appTitle": "账户",
"changeLanguage": "切换语言",
"emailDescription": "请输入邮箱地址以保存设置。",
"email": "邮箱",
"emailHint": "例如 name@example.com",
"emailRequired": "请输入邮箱地址",
"emailInvalid": "请输入有效的邮箱地址",
"save": "保存",
"saving": "保存中…",
"saved": "已保存"
}
这个示例的关键路径如下:
MaterialApp.locale改变;Localizations重新加载或切换资源;AppLocalizations.of(context)返回新语言实例;- 页面重建;
- 可见文本和
Semantics的label同时变化; - 表单校验失败时焦点转移到字段;
- 提交开始时按钮禁用、文字和语义状态同时改变;
- 异步任务结束后先检查
mounted,再恢复 UI。
示例中的外层 Semantics 需要谨慎使用,因为 FilledButton 本身也有语义。若测试发现屏幕阅读器重复读取按钮名称,应移除外层的 Semantics,只保留本地化的按钮文字;对于没有可见文字的图标按钮,tooltip 通常已经足够。
十一、平台差异:同一份 Dart 代码不代表同一种辅助技术行为
Android
Android 主要通过 TalkBack 和系统无障碍服务使用 Flutter 语义树。触摸探索、双击激活、滑动朗读顺序等行为由系统辅助服务决定。自定义控件必须有稳定角色、标签和动作,不能只依赖视觉点击区域。
iOS
iOS 主要通过 VoiceOver 使用语义。iOS 对元素特征、提示、可访问性焦点和动态文字有自己的行为。Flutter 会尽量映射通用语义,但不是所有平台特性都能一一对应;尤其是复杂自定义控件、弹窗焦点和动态通知必须在真实设备上验证。
Windows 和 macOS
桌面端除了屏幕阅读器,还要考虑:
- Tab 和 Shift+Tab;
- Enter、Space 激活控件;
- 鼠标悬停与键盘焦点;
- 窗口缩放和高 DPI;
- 菜单、快捷键、系统窗口行为。
桌面应用如果只测试鼠标点击,可能完全漏掉焦点遍历问题。
Web
Flutter Web 的可访问性行为与渲染模式、浏览器和浏览器扩展有关。HTML 渲染模式和 CanvasKit 在 DOM 暴露方式、文本选择、输入元素和辅助技术交互上可能存在差异。浏览器缩放、窗口宽度和键盘导航尤其重要。
Web 端不能假设 Semantics 完全等同于手写 HTML ARIA。Flutter 会生成相应的语义辅助结构,但复杂页面仍应使用浏览器、屏幕阅读器和键盘实际验证。
十二、诊断方法:不要只看屏幕截图
12.1 查看 Flutter 语义树
在开发环境中,可以使用 Flutter 的语义调试能力查看语义边界和节点。常见做法包括:
MaterialApp(
showSemanticsDebugger: true,
home: const HomePage(),
)
这会在界面上显示语义节点边界,适合发现:
- 图标被错误地读出;
- 两个视觉控件被合并;
- 语义范围远大于实际控件;
- 读取顺序与视觉顺序不一致。
该调试模式只用于开发验证,不应在生产环境开启。
12.2 测试语义
Flutter 测试可以使用 SemanticsTester 或语义相关 Finder 检查节点。例如,测试一个按钮是否可用:
testWidgets('save button exposes the correct semantics', (tester) async {
await tester.pumpWidget(const ExampleApp());
expect(
find.bySemanticsLabel('Save'),
findsOneWidget,
);
});
生成式本地化后,测试文本应使用当前 Locale 对应的字符串,不要在所有测试中硬编码单一语言。更稳妥的做法是验证角色、标签、状态和动作是否正确,而不是只验证屏幕上有没有某个字符串。
12.3 手工验证矩阵
至少应覆盖:
语言:
英文、中文、至少一种 RTL 语言
显示:
默认字体、较大系统字体、粗体、高对比度
输入:
触摸、Tab、Shift+Tab、Enter、软键盘 Next/Done
辅助技术:
Android TalkBack
iOS VoiceOver
目标桌面屏幕阅读器
目标浏览器屏幕阅读器
状态:
初始、加载、禁用、错误、空数据、长文本、网络失败
每个状态都要检查可见表现和语义表现是否一致。
十三、常见错误及其根因
错误一:给所有图标都添加 Semantics(label:)
根因是把“视觉内容”误认为“都需要朗读”。如果图标旁边已有文本,图标可能只是装饰,额外语义会导致重复朗读。
处理方式:判断图标是否提供独立信息。没有独立信息时使用 ExcludeSemantics 或依赖父组件的语义。
错误二:把 autofocus 当作可访问性
自动聚焦只解决“焦点落在哪里”,不保证用户理解页面,也不保证焦点顺序正确。移动端自动弹出键盘还可能打断屏幕阅读器用户。
处理方式:只在明确的输入场景中使用,并验证首次进入页面、返回页面和错误恢复路径。
错误三:翻译只放在 Text 中,Semantics 仍是英文
例如:
Semantics(
label: 'Delete',
child: IconButton(
onPressed: delete,
icon: const Icon(Icons.delete),
),
)
切换到中文后,视觉界面变成中文,但辅助技术仍读取英文。所有用户可感知的文本,包括标签、提示、错误、按钮、图片说明,都应来自同一套本地化资源。
错误四:用 Locale 决定业务逻辑
Locale 可以决定语言和格式,但不应直接决定权限、价格规则、服务可用性等业务行为。地区、账户设置、服务器策略和法律区域可能才是这些逻辑的输入。
错误五:用固定高度“解决”多语言布局
固定高度可能在默认字体和英文中看起来正常,但在中文、德文、阿拉伯文或大字体下发生裁剪。应让文本自然换行,并让父级滚动或扩展。
错误六:异步回调销毁后仍更新状态
页面退出后,网络请求完成并调用 setState 会产生异常。使用 mounted 检查可以避免直接错误,但如果请求昂贵或会持续产生结果,还应取消订阅、取消请求或用版本号丢弃过期响应。
十四、生产取舍:标准组件优先,自定义语义要有验证闭环
Material 和 Cupertino 中的标准交互组件通常已经包含:
- 基础角色;
- 点击、长按、选择等动作;
- 禁用状态;
- 常见焦点行为;
- 与主题和平台的协作。
因此,优先选择 Button、Checkbox、Switch、Slider、TextField、Dialog、ListTile 等组件,再通过本地化文本和适当属性补充信息。自定义绘制控件并不会自动获得同等质量的语义和键盘行为。
当必须构造自定义控件时,应同时实现:
视觉状态
├─ enabled / disabled
├─ selected / unselected
├─ expanded / collapsed
└─ loading / error
语义状态
├─ role
├─ label
├─ value
├─ action
└─ focusability
本地化状态
├─ visible text
├─ semantic label
├─ hint / error
├─ date / number format
└─ text direction
如果其中一列没有同步更新,就会出现典型的不一致:
- 视觉上按钮已禁用,语义上仍可点击;
- 视觉上展开了内容,语义上仍报告收起;
- 视觉上是中文,辅助技术仍读英文;
- 视觉上文本未溢出,但大字体用户看不到完整错误信息;
- 触摸可以操作,Tab 却永远到不了。
可访问性和国际化的完成标准不是“代码里出现了 Semantics 和 Locale”,而是同一状态在视觉、语义、焦点和本地化四个层面具有一致的数据来源与生命周期。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter 性能优化:帧流水线、重建、栅格、内存和 DevTools
- 下一篇:Flutter 应用安全:Secret、网络、存储、WebView、证书和供应链
- 延伸:Flutter 表单与输入:Controller、Focus、校验、键盘和无障碍
- 延伸:Flutter 主题与设计系统:Material、ColorScheme、Token 和组件规范
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论