Flutter 基础体系 · 第 18/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter 可访问性与国际化:Semantics、焦点、Locale 和文本适配

在 Flutter 中,可访问性和国际化不是给页面“补充几个属性”就完成的功能。它们分别作用于不同层次:

  • 可访问性解决用户如何感知和操作界面,包括屏幕阅读器、键盘、开关控制、辅助触控和放大显示。
  • Semantics把渲染树中的视觉组件转换为辅助技术可以理解的语义节点。
  • 焦点决定键盘、桌面辅助技术和部分移动端辅助操作当前作用于哪个控件。
  • Locale描述语言、书写方向以及地区偏好,是选择翻译内容和格式化数据的输入。
  • 文本适配解决翻译长度、字体缩放、粗体、RTL(从右到左)和不同平台窗口尺寸导致的布局变化。

这些概念彼此有关,但不能互相替代。例如,给按钮设置 Semantics(label: ...) 不会让它获得键盘焦点;设置 Locale('zh') 也不会自动把日期和数字格式化为符合用户地区的形式。


一、先建立整体模型:视觉树、语义树、焦点树和本地化数据流

Flutter 的界面至少可以从四个角度观察。

1. 渲染树负责“画什么”

Widget 经过 ElementRenderObject 后形成渲染树。布局约束、尺寸、颜色、绘制顺序都主要在这一层完成。

例如:

ElevatedButton(
  onPressed: submit,
  child: const Text('提交'),
)

这段代码表达了一个按钮,但“提交”这个字符串只是视觉内容。辅助技术需要知道:

  • 这是一个可操作控件;
  • 控件的名称是什么;
  • 当前是否可用;
  • 当前是否已被选中或切换;
  • 激活后会发生什么。

这些信息来自语义树。

2. 语义树负责“它是什么、能做什么”

Flutter 会从可参与语义的 RenderObject 生成 Semantics tree,并通过平台桥接到:

  • Android 的 TalkBack 等辅助服务;
  • iOS 的 VoiceOver;
  • macOS 的 VoiceOver;
  • Windows 的辅助技术;
  • Web 浏览器的无障碍树和屏幕阅读器。

语义树不是渲染树的逐节点复制。多个视觉节点可能合并为一个语义节点,也可能被排除;一个视觉控件也可能需要额外的语义说明。

3. 焦点系统负责“当前由谁接收操作”

Flutter 焦点系统主要由以下对象组成:

  • FocusManager:管理整个应用的主焦点;
  • FocusNode:表示一个可获得焦点的节点;
  • FocusScopeNode:管理一个焦点范围;
  • FocusFocusScopeFocusableActionDetector:在 Widget 树中接入焦点和键盘事件;
  • ActionsShortcuts:把按键或意图映射为动作。

触摸操作通常不依赖焦点,但硬件键盘、桌面 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:当前值,例如滑块当前百分比。
  • checkedselectedtoggled:状态信息。
  • onTaponLongPress 等语义动作:告诉辅助技术该节点可执行什么操作。

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 MergeSemanticsExcludeSemanticsBlockSemantics

视觉上一个组件可能由多个节点组成。例如:

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 用于让当前语义节点阻挡后面的语义节点,常见于模态内容或临时覆盖层。实际使用时,优先使用 ModalBarrierDialogshowDialog 等已经处理了焦点和语义边界的组件。

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),
  ),
)

这里的因果关系是:

  1. isSubmittingtrue
  2. onPressed 变为 null,视觉控件不可操作;
  3. enabled 和文本同步更新;
  4. 屏幕阅读器能够知道按钮当前不可用或正在保存。

不要只把按钮文字改成“保存中”,却让 onPressed 仍可重复提交。

2.6 label 不应无条件覆盖可见文字

下面的代码可能造成重复或信息不一致:

Semantics(
  label: '提交表单',
  child: Text('发送'),
)

用户看到“发送”,屏幕阅读器却听到“提交表单”。如果两者表达的不是同一含义,视觉用户和辅助技术用户获得了不同的信息。

更安全的原则是:

  • 可见文字已经准确时,不要额外设置 label
  • 图标按钮没有可见名称时,提供 tooltipSemantics(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.nextdone 等属性向输入法表达下一步动作,但它们不自动保证焦点转移。应用必须实现相应行为:

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 的 DialogshowDialog 等组件会提供一定的焦点隔离,但复杂自定义 Overlay 需要自行验证。弹窗关闭后,通常应恢复到打开它的控件;如果原控件已经被移除,应选择语义上相邻的稳定节点。

3.5 ShortcutsActions 不等于普通按键监听

Shortcuts 将物理按键映射为 IntentActions 再将意图映射为逻辑动作:

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 是语言代码,例如 zhenar
  • countryCode 是地区代码,例如 CNTWUS
  • 某些语言还需要脚本代码,例如简体/繁体或塞尔维亚语的拉丁/西里尔脚本。

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 runflutter 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')) {
  // ...
}

这无法准确处理地区和脚本。语言选择应交给 supportedLocaleslocaleListResolutionCallback 或应用自己的明确策略:

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;
  },
)

这段代码表达了两级匹配:

  1. 先匹配语言和地区;
  2. 再只匹配语言;
  3. 都失败时使用第一个支持的 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.startTextAlign.end 也比 TextAlign.leftTextAlign.right 更适合多语言文本。


七、文本适配:翻译长度只是第一层问题

7.1 文本长度没有稳定上限

同一含义在不同语言中的长度可能差异很大:

Save
Speichern
Enregistrer
保存

因此下面这种布局存在风险:

SizedBox(
  width: 80,
  child: Text(l10n.save),
)

如果按钮中还包含图标、内边距和字体缩放,固定宽度很容易造成截断或溢出。

更合理的结构是让内容决定最小尺寸,让父布局提供可伸缩空间:

FilledButton(
  onPressed: save,
  child: Text(
    l10n.save,
    textAlign: TextAlign.center,
  ),
)

在横向空间有限时,可使用 ExpandedFlexible 或允许换行:

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": "已保存"
}

这个示例的关键路径如下:

  1. MaterialApp.locale 改变;
  2. Localizations 重新加载或切换资源;
  3. AppLocalizations.of(context) 返回新语言实例;
  4. 页面重建;
  5. 可见文本和 Semanticslabel 同时变化;
  6. 表单校验失败时焦点转移到字段;
  7. 提交开始时按钮禁用、文字和语义状态同时改变;
  8. 异步任务结束后先检查 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 中的标准交互组件通常已经包含:

  • 基础角色;
  • 点击、长按、选择等动作;
  • 禁用状态;
  • 常见焦点行为;
  • 与主题和平台的协作。

因此,优先选择 ButtonCheckboxSwitchSliderTextFieldDialogListTile 等组件,再通过本地化文本和适当属性补充信息。自定义绘制控件并不会自动获得同等质量的语义和键盘行为。

当必须构造自定义控件时,应同时实现:

视觉状态
  ├─ 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 却永远到不了。

可访问性和国际化的完成标准不是“代码里出现了 SemanticsLocale”,而是同一状态在视觉、语义、焦点和本地化四个层面具有一致的数据来源与生命周期。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。