[Android] 无障碍服务Accessibility - Talkback

Posted by xiuyuantech on 2025-05-02

Android 应用的目标应该是让所有人都可以使用,包括具有无障碍功能需求的人。
某些使用 Android 设备的用户具有不同于他人的无障碍功能需求。为了帮助具有共同的无障碍功能需求的特定人群,Android 框架为开发者创建无障碍服务提供了相关功能,这种服务可将应用的内容呈现给此类用户,并代表他们在应用中执行操作。
Android产品的无障碍主要是针对视觉障碍人士,在设备的辅助功能中开启无障碍服务(如TalkBack)后,它能够读取屏幕上的文本信息,转化为语音提示,达到信息无障碍。

规范

  • 所有View应统一通过contentDescription属性加上标签。
  • 文字标签要有意义。
  • 装饰性的UI元素需要去掉标签和焦点。
  • EditText需通过hint属性设置标签。
  • 触摸目标大小至少为48*48dp,触摸目标间距至少为8dp。
  • 应将相关的、有相同响应的元素组合在一起。
  • 焦点切换顺序应遵循视觉顺序,从左到右,从上到下。
  • 较复杂的页面应采取分组聚焦的形式,减少细粒度。
  • 自定义的控件需要进行无障碍改造。

原则

  • 可感知性:信息和用户界面组件必须以可感知的方式呈现给用户。

  • 可操作性:用户界面组件和导航必须可操作。

  • 可理解性:信息和用户界面操作必须是可理解的。

  • 鲁棒性:内容必须健壮到可信地被种类繁多的用户代理(包括辅助技术) 所解释。

实战

在大多数情况下,您可以在包含给定界面元素的布局资源文件中指定该元素的说明,通常使用 contentDescription 属性添加标签。其他用法如下:

EditText:

1
2
3
4
5
6
7
8
9
//给EditText添加android:hint 属性,给定的 EditText 元素通常会有一个相应的 View 对象,该对象描述了用户应在 EditText 元素中输入的内容。在搭载 Android 4.2 (API 级别 17) 或更高版本的设备上,您可以通过设置 View 对象的 android:labelFor 属性来指明这种关系。
<TextView
android:id="@+id/usernameLabel" ...
android:text="@string/username"
android:labelFor="@+id/usernameEntry" />

<EditText
android:id="@+id/usernameEntry"
android:hint="@string/aptSuiteBuilding" ... />

RecyclerView:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
data class MovieRating(val title: String, val starRating: Integer)

class MyMovieRatingsAdapter(private val myData: Array<MovieRating>):
RecyclerView.Adapter<MyMovieRatingsAdapter.MyRatingViewHolder>() {

class MyRatingViewHolder(val ratingView: ImageView) :
RecyclerView.ViewHolder(ratingView)

override fun onBindViewHolder(holder: MyRatingViewHolder, position: Int) {
val ratingData = myData[position]
holder.ratingView.contentDescription = "Movie ${position}: " +
"${ratingData.title}, ${ratingData.starRating} stars"
}
}

如果应用显示的多个界面元素构成一个自然组 (如歌曲的详细信息或消息的属性),应将这些元素整理到一个容器中,该容器通常是 ViewGroup 的子类。
将容器对象的android:screenReaderFocusable属性设为 true,并将每个内部对象的android:focusable属性设为 false。这样,无障碍服务就可以在单次语音中逐个读出内部元素的内容说明。这样整合相关元素有助于使用辅助技术的用户更高效地发现屏幕上的信息。

注意: 在 Android 8.1 (API 级别 27) 及更低版本中,android:screenReaderFocusable 属性不可用,因此应改为设置容器的 android:focusable 属性。

以下代码段包含彼此相关的内容片段,因此容器元素 (即 ConstraintLayout 的实例) 的android:screenReaderFocusable属性设为 true,每个内部 TextView 元素的android:focusable属性设为 false:

1
2
3
4
5
6
7
8
9
10
11
12
13
<ConstraintLayout
android:id="@+id/song_data_container" ...
android:screenReaderFocusable="true">

<TextView
android:id="@+id/song_title" ...
android:focusable="false"
android:text="@string/my_song_title" />
<TextView
android:id="@+id/song_artist"
android:focusable="false"
android:text="@string/my_songwriter" />
</ConstraintLayout>

某些应用使用标题总结屏幕上显示的多组文字。如果特定的 View 元素表示一个标题,您可以通过将该元素的android:accessibilityHeading属性设为 true,表明它的无障碍服务用途。

在 Android 9 (API 级别 28) 及更高版本中,您可以为屏幕的窗格提供使用起来没有障碍的标题。出于无障碍目的,窗格是窗口中能够从视觉上加以区分的部分,如 Fragment 的内容。为了让无障碍服务能够理解与窗口行为类似的窗格行为,您应该为应用的窗格指定描述性标题。这样一来,当窗格的外观或内容发生变化时,无障碍服务就可以为用户提供更精细的信息。

如需指定窗格的标题,请使用android:accessibilityPaneTitle属性,如以下代码段所示:

1
2
3
4
5
6
7
<MyShoppingCartView
android:id="@+id/shoppingCartContainer"
android:accessibilityPaneTitle="@string/shoppingCart" ... />

<MyShoppingBrowseView
android:id="@+id/browseItemsContainer"
android:accessibilityPaneTitle="@string/browseProducts" ... />

自定义View:

1
2
3
4
5
6
7
View.setAccessibilityDelegate(new View.AccessibilityDelegate() {
@Override
public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) {
super.onInitializeAccessibilityNodeInfo(host, info);
...
}
});

如果界面中某个元素的存在只是为了让内容看起来间距合理或布局美观,请将其 android:contentDescription 属性设为 "null".
如果应用仅支持搭载 Android 4.1 (API 级别 16) 或更高版本的设备,您可以将这些纯装饰性元素的 android:importantForAccessibility 属性设为 "no"

避坑

如何确保View控件在TalkBack中不被焦点选中?

1
2
3
4
//移除焦点
android:focusable="false"
android:focusableInTouchMode="false"
android:importantForAccessibility="no"

talkback模式弹窗获取不到焦点?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 //开talkback时,弹窗优先获取焦点
layoutParams.flags = WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED

//如果上面的方法不行,使用尝试如下方法
this.isFocusable = true
this.isFocusableInTouchMode = true

//并确保 WindowManager.LayoutParams.flags 没有设置 FLAG_NOT_FOCUSABLE:

layoutParams.flags = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
//如果需要确保弹窗优先于页面获取焦点,可以移除 FLAG_NOT_TOUCH_MODAL,并设置 FLAG_LAYOUT_IN_SCREEN:

layoutParams.flags = WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED

View获得焦点但是不响应按键?
在开启和关闭 TalkBack 的情况下,Android 的焦点管理机制行为不同。

  1. 开启 TalkBack 的情况下
    TalkBack 会拦截用户的按键事件并优先处理这些事件,例如方向键的按下(KEYCODE_DPAD_LEFT、KEYCODE_DPAD_RIGHT 等)。
    当用户按下方向键,TalkBack 会尝试通过调用 focusSearch 来确定下一个应获取焦点的视图,而不会直接将事件传递给 onKeyDown 方法。
  2. 开启 TalkBack 的情况下
    在关闭 TalkBack 时,按键事件会直接传递给当前视图,触发 onKeyDown 方法。

解决方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
//在focusSearch方法直接调用onKeyDown,或者写相同的逻辑
focusSearch (View focused,int direction){
if (direction == View.FOCUS_UP) {
// talkback模式下,焦点导航逻辑依靠focusSearch而不是onKeyDown
focused.onKeyDown(KeyEvent.KEYCODE_DPAD_UP, KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_UP))
return focused
} else if (direction == View.FOCUS_DOWN) {
// talkback模式下,焦点导航逻辑依靠focusSearch而不是onKeyDown
focused.onKeyDown(KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_DOWN))
return focused
}
return super.focusSearch(focused, direction)
}

总结

无障碍不仅可以扩展使用用户,也满足了许多国家和地区的有关法律规定以确保所有用户都能平等地使用技术产品‌。同时需要不断迭代优化,列入代码审查要点中,来保证产品持续提供良好的无障碍功能,让更多的人享受这个更美好的世界。