[关闭]
@ZeroGeek 2018-05-16T00:03:12.000000Z 字数 6709 阅读 2085

随手记Android无障碍实践


前言

根据统计,目前我国有1700多万视障人士,意味着平均每81人中就有一位视障人士可能会在使用互联网服务时遇到困难。目前随手记拥有3亿注册用户,为了让财务金融服务惠及每一位用户,为帮助视障人士轻松地进行记账、投资和学习财商知识,让他们能平等、方便、无障碍地获取信息和利用信息,我们对随手记Android进行了无障碍改造和优化。

无障碍指南

Android产品的无障碍主要是针对视觉障碍人士,在设备的辅助功能中开启无障碍服务(如TalkBack)后,它能够读取屏幕上的文本信息,转化为语音提示,达到信息无障碍。

规范细则

WCAG 2.0四大原则

开启无障碍服务

  1. 下载安装TalkBack软件(有些系统自带),它能读取屏幕中的文本信息
  2. 保证有文字转语音(TTS)输出引擎,通常手机会自带一个,另外也可以下载讯飞语记
  3. 进入设置 -> 辅助功能(或高级选项) -> 找到TalkBack服务并开启

当出现绿区域并伴有语音提示的时候表示进入了无障碍模式。View能被正常选中,并有语音提示其文本信息,说明该View具有无障碍功能。
添加标签

操作方式有所改变

实战实例

1.给UI元素添加标签

找到界面中所有有效的元素,设置文本信息。
添加标签

简单代码示例:

  1. // XML
  2. <ImageButton
  3. ...
  4. android:contentDescription="@string/share" />
  1. // 代码
  2. private void updateImageButton() {
  3. if (mediaCurrentlyPlaying) {
  4. playPauseImageView.setContentDescription(getString(R.string.pause));
  5. } else {
  6. playPauseImageView.setContentDescription(getString(R.string.play));
  7. }
  8. }

1.1 正确添加标签

1.2 提供清晰和有意义的标签文本

2.改造非标准组件的选中状态

添加标签
如上,有些界面的选中状态是通过设置ImageView的背景图片来控制的。无障碍服务无法识别,语音提示中不包含选中状态。
处理方法:
一、使用可以朗读选中状态的系统标准控件,如CheckBox或CheckedTextView。
二、给控件添加无障碍代理(AccessibilityDelegate),在onInitializeAccessibilityNodeInfo()方法中调用AccessibilityNodeInfo对象的setChecked方法设置选中状态。
我们使用的是第二种方式。具体实现如下:

  1. rootView.setAccessibilityDelegate(new View.AccessibilityDelegate() {
  2. @Override
  3. public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) {
  4. super.onInitializeAccessibilityNodeInfo(host, info);
  5. info.setCheckable(true);
  6. info.setChecked(itemData.isSelected());
  7. }
  8. });

3.焦点处理

添加标签
图中第三方登录的微信图标和文本分别具有焦点,需要整合到一起。避免多余的操作,加快浏览。对于类似手机快捷注册文本按钮,应该扩大可触碰范围。

有些界面包含装饰性的元素,需要去除掉焦点。
例如:随手记更多界面的间隔块。
添加标签

移除焦点代码示例:

  1. android:focusable="false"
  2. android:focusableInTouchMode="false"
  3. android:importantForAccessibility="no"

4.自定义View的改造

4.1 如下图记一笔中的滚轮,未处理时在无障碍模式下无法使用。

记一笔滚轮
改造过程:
1.先设置滚轮面板的焦点,保证可选中。

2.在滚轮Item选中的回调函数中,设置view的contentDescription属性同时发送无障碍事件。

  1. // 防止频率过高,做了延时处理
  2. private void sendAccessibilityViewSelectedEvent() {
  3. postDelayed(new Runnable() {
  4. @Override
  5. public void run() {
  6. sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED);
  7. }
  8. }, 200L);
  9. }

3.重载onPopulateAccessibilityEvent方法,添加描述文本

  1. @Override
  2. @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
  3. public void onPopulateAccessibilityEvent(AccessibilityEvent event) {
  4. super.onPopulateAccessibilityEvent(event);
  5. int eventType = event.getEventType();
  6. if (eventType == AccessibilityEvent.TYPE_VIEW_SELECTED
  7. || eventType == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED) {
  8. if (viewAdapter != null) {
  9. event.getText().add(viewAdapter.getItemContentDes(currentItem));
  10. event.setItemCount(viewAdapter.getItemsCount());
  11. event.setCurrentItemIndex(currentItem);
  12. }
  13. }
  14. }

4.2 手势面板改造

没处理之前就是一个块区,滑动没反应。

手势面板

较好实现无障碍的方式是借助ExploreByTouchHelper。(笔者主要是参考了Android 5.1系统源码中LockPatternView类的无障碍实现)
下面给出了部分代码实现:
1.编写相应的ExploreByTouchHelper类,重载6个方法

  1. private final class PatternExploreByTouchHelper extends ExploreByTouchHelper {
  2. private Rect mTempRect = new Rect();
  3. private HashMap<Integer, VirtualViewContainer> mItems = new HashMap<>();
  4. private static final int VIRTUAL_BASE_VIEW_ID = 1;
  5. /**
  6. * 手势面板有9个点,每个点都做为一个虚拟节点,要根据x,y坐标获取对应的虚拟节点的编号(这个int值由自己约定)
  7. * @return 其它返回ExploreByTouchHelper.INVALID_ID
  8. */
  9. @Override
  10. protected int getVirtualViewAt(float x, float y) {
  11. final int rowHit = getRowHit(y);
  12. if (rowHit < 0) {
  13. return ExploreByTouchHelper.INVALID_ID;
  14. }
  15. final int columnHit = getColumnHit(x);
  16. if (columnHit < 0) {
  17. return ExploreByTouchHelper.INVALID_ID;
  18. }
  19. boolean dotAvailable = mPatternDrawLookup[rowHit][columnHit];
  20. int dotId = (rowHit * 3 + columnHit) + VIRTUAL_BASE_VIEW_ID;
  21. int view = dotAvailable ? dotId : ExploreByTouchHelper.INVALID_ID;
  22. return view;
  23. }
  24. /**
  25. * 方法名有点奇怪,它的作用是把虚拟节点的编号放进List中
  26. * 这里我们加了9个编号进来,1到9
  27. * @param virtualViewIds
  28. */
  29. @Override
  30. protected void getVisibleVirtualViews(List<Integer> virtualViewIds) {
  31. if (!mPatternInProgress) {
  32. return;
  33. }
  34. for (int i = VIRTUAL_BASE_VIEW_ID; i < VIRTUAL_BASE_VIEW_ID + 9; i++) {
  35. if (!mItems.containsKey(i)) {
  36. VirtualViewContainer item = new VirtualViewContainer(getTextForVirtualView(i));
  37. mItems.put(i, item);
  38. }
  39. virtualViewIds.add(i);
  40. }
  41. }
  42. /**
  43. * 给每个虚拟节点填充事件,即手势面板中的9个点设置描述文本
  44. */
  45. @Override
  46. protected void onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event) {
  47. if (mItems.containsKey(virtualViewId)) {
  48. CharSequence contentDescription = mItems.get(virtualViewId).description;
  49. event.getText().add(contentDescription);
  50. }
  51. }
  52. /**
  53. * 给宿主View填充事件,即手势面板设置描述文本
  54. */
  55. @Override
  56. public void onPopulateAccessibilityEvent(View host, AccessibilityEvent event) {
  57. super.onPopulateAccessibilityEvent(host, event);
  58. if (!mPatternInProgress) {
  59. CharSequence contentDescription = getContext().getText(R.string.lock_pattern_area);
  60. event.setContentDescription(contentDescription);
  61. }
  62. }
  63. /**
  64. * 给虚拟View设置描述文本和边框
  65. * 边框是指无障碍模式下选中的区块边界
  66. * @param virtualViewId
  67. * @param node
  68. */
  69. @Override
  70. protected void onPopulateNodeForVirtualView(int virtualViewId, AccessibilityNodeInfoCompat node) {
  71. node.setText(getTextForVirtualView(virtualViewId));
  72. node.setContentDescription(getTextForVirtualView(virtualViewId));
  73. if (mPatternInProgress) {
  74. node.setFocusable(true);
  75. if (isClickable(virtualViewId)) {
  76. node.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK);
  77. node.setClickable(isClickable(virtualViewId));
  78. }
  79. }
  80. final Rect bounds = getBoundsForVirtualView(virtualViewId);
  81. node.setBoundsInParent(bounds);
  82. }
  83. /**
  84. * 提供交互,触发回调重绘控件
  85. */
  86. @Override
  87. protected boolean onPerformActionForVirtualView(int virtualViewId, int action, Bundle arguments) {
  88. switch (action) {
  89. case AccessibilityNodeInfo.ACTION_CLICK:
  90. return onItemClicked(virtualViewId);
  91. default:
  92. break;
  93. }
  94. return false;
  95. }
  96. // ...
  97. }

2.在构造函数中设置无障碍代理

  1. public LockPatternView(Context context) {
  2. // something else
  3. // ...
  4. // 无障碍代理
  5. mPatternTouchHelper = new PatternExploreByTouchHelper(this);
  6. ViewCompat.setAccessibilityDelegate(this, mPatternTouchHelper);
  7. }

3.在LockPatternView中实现onHoverEvent()和dispatchHoverEvent()

  1. @Override
  2. public boolean onHoverEvent(MotionEvent event) {
  3. final int action = event.getAction();
  4. switch (action) {
  5. case MotionEvent.ACTION_HOVER_ENTER:
  6. event.setAction(MotionEvent.ACTION_DOWN);
  7. break;
  8. case MotionEvent.ACTION_HOVER_MOVE:
  9. event.setAction(MotionEvent.ACTION_MOVE);
  10. break;
  11. case MotionEvent.ACTION_HOVER_EXIT:
  12. event.setAction(MotionEvent.ACTION_UP);
  13. break;
  14. case MotionEvent.ACTION_CANCEL:
  15. event.setAction(MotionEvent.ACTION_CANCEL);
  16. }
  17. onTouchEvent(event);
  18. event.setAction(action);
  19. return super.onHoverEvent(event);
  20. }
  21. @Override
  22. protected boolean dispatchHoverEvent(MotionEvent event) {
  23. boolean handled = super.dispatchHoverEvent(event);
  24. handled |= mPatternTouchHelper.dispatchHoverEvent(event);
  25. return handled;
  26. }

4.手势状态(如完成、中断等)的回调函数中要调用announceForAccessibility()提示用户。

总结

在实现无障碍的同时,也解决了自定义View的UI自动化测试问题。无障碍需要不断更新迭代、优化。对此团队也制定了无障碍编码规范,列入代码审查要点中,来保证产品持续提供良好的无障碍功能。

参考资料

Android官方无障碍指南
Android无障碍宝典
WCAG 2.0

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注