随手记Android无障碍实践
2018-05-15 18:21 阅读(314)

前言

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

无障碍指南

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

规范细则

  • 所有View应统一通过contentDescription属性加上标签

  • 文字标签要有意义

  • 装饰性的UI元素需要去掉标签和焦点

  • EditText需通过hint属性设置标签

  • 触摸目标大小至少为48*48dp,触摸目标间距至少为8dp

  • 应将相关的、有相同响应的元素组合在一起

  • 焦点切换顺序应遵循视觉顺序,从左到右,从上到下

  • 较复杂的页面应采取分组聚焦的形式,减少细粒度

  • 自定义的控件需要进行无障碍改造

WCAG 2.0四大原则

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

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

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

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

开启无障碍服务

  1. 下载安装TalkBack软件(有些系统自带),它能读取屏幕中的文本信息

  2. 保证有文字转语音(TTS)输出引擎,通常手机会自带一个,另外也可以下载讯飞语记

  3. 进入设置 -> 辅助功能(或高级选项) -> 找到TalkBack服务并开启

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



操作方式有所改变

  • 单击,选中某个具有焦点的View(绿区域)

  • 双击相当于正常模式下的点击(启动、进入等)

  • 滑动,需要双指往上、下、左、右

实战实例

1.给UI元素添加标签

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



简单代码示例:

// XML
<ImageButton
   ...
  android:contentDescription="@string/share"  />

// 代码
private void updateImageButton() {
   if (mediaCurrentlyPlaying) {
      playPauseImageView.setContentDescription(getString(R.string.pause));
   } else {
      playPauseImageView.setContentDescription(getString(R.string.play));
   }
}

1.1 正确添加标签

  • TextView或者继承至其的控件,如果contentDescription属性的值为空,无障碍服务会获取text属性的文本信息作为语音提示。

  • EditText,需设置hint属性的值

  • 其它控件(如ImageView、ImageButton)需要通过设置contentDescription的值

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

  • 力求精确、简洁

  • 避免在描述文本中包含类型和状态

  • 指明元素功能,而不是描述图标

  • 状态改变或功能改变,标签需随之改变

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



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


rootView.setAccessibilityDelegate(new View.AccessibilityDelegate() {
    @Override
    public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) {
        super.onInitializeAccessibilityNodeInfo(host, info);
        info.setCheckable(true);
               info.setChecked(itemData.isSelected());
        }
     });

3.焦点处理



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


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


移除焦点代码示例:

android:focusable="false" 
android:focusableInTouchMode="false"
android:importantForAccessibility="no"

4.自定义View的改造

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

记一笔滚轮


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

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

// 防止频率过高,做了延时处理
private void sendAccessibilityViewSelectedEvent() {
   postDelayed(new Runnable() {
       @Override
       public void run() {
           sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED);
       }
   }, 200L);
}

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

@Override
@TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
public void onPopulateAccessibilityEvent(AccessibilityEvent event) {
   super.onPopulateAccessibilityEvent(event);
   int eventType = event.getEventType();
   if (eventType == AccessibilityEvent.TYPE_VIEW_SELECTED
               || eventType == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED) {
       if (viewAdapter != null) {
           event.getText().add(viewAdapter.getItemContentDes(currentItem));
           event.setItemCount(viewAdapter.getItemsCount());
           event.setCurrentItemIndex(currentItem);
       }
   }
}

4.2 手势面板改造

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

手势面板

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

private final class PatternExploreByTouchHelper extends ExploreByTouchHelper {
   private Rect mTempRect = new Rect();
   private HashMap<Integer, VirtualViewContainer> mItems = new HashMap<>();
   private static final int  VIRTUAL_BASE_VIEW_ID = 1;

   /**
   * 手势面板有9个点,每个点都做为一个虚拟节点,要根据x,y坐标获取对应的虚拟节点的编号(这个int值由自己约定)
   * @return 其它返回ExploreByTouchHelper.INVALID_ID
   */

   @Override
   protected  int getVirtualViewAt(float x, float y) {
       final int rowHit = getRowHit(y);
       if (rowHit < 0) {
           return ExploreByTouchHelper.INVALID_ID;
       }
       final int columnHit = getColumnHit(x);
       if (columnHit < 0) {
           return ExploreByTouchHelper.INVALID_ID;
       }
       boolean dotAvailable = mPatternDrawLookup[rowHit][columnHit];
       int dotId = (rowHit * 3 + columnHit) + VIRTUAL_BASE_VIEW_ID;
       int view = dotAvailable ? dotId : ExploreByTouchHelper.INVALID_ID;
       return view;
   }

   /**
   * 方法名有点奇怪,它的作用是把虚拟节点的编号放进List中
   * 这里我们加了9个编号进来,1到9
   * @param virtualViewIds
   */

   @Override
   protected void getVisibleVirtualViews(List<Integer> virtualViewIds) {
       if (!mPatternInProgress) {
           return;
       }

       for (int i = VIRTUAL_BASE_VIEW_ID; i < VIRTUAL_BASE_VIEW_ID + 9; i++) {
           if (!mItems.containsKey(i)) {
               VirtualViewContainer item = new VirtualViewContainer(getTextForVirtualView(i));
               mItems.put(i, item);
           }
           virtualViewIds.add(i);
       }
   }

   /**
    * 给每个虚拟节点填充事件,即手势面板中的9个点设置描述文本
    */

   @Override
   protected void onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event) {
       if (mItems.containsKey(virtualViewId)) {
           CharSequence contentDescription = mItems.get(virtualViewId).description;
           event.getText().add(contentDescription);
       }
   }

   /**
    * 给宿主View填充事件,即手势面板设置描述文本
    */

   @Override
   public  void onPopulateAccessibilityEvent(View host, AccessibilityEvent event) {
       super.onPopulateAccessibilityEvent(host, event);
       if (!mPatternInProgress) {
           CharSequence contentDescription = getContext().getText(R.string.lock_pattern_area);
           event.setContentDescription(contentDescription);
       }
   }

   /**
   * 给虚拟View设置描述文本和边框
   * 边框是指无障碍模式下选中的区块边界
   * @param virtualViewId
   * @param node
   */

   @Override
   protected void onPopulateNodeForVirtualView(int virtualViewId, AccessibilityNodeInfoCompat node) {
       node.setText(getTextForVirtualView(virtualViewId));
       node.setContentDescription(getTextForVirtualView(virtualViewId));

       if (mPatternInProgress) {
           node.setFocusable(true);
           if (isClickable(virtualViewId)) {
               node.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK);
               node.setClickable(isClickable(virtualViewId));
           }
       }
       final Rect bounds = getBoundsForVirtualView(virtualViewId);
       node.setBoundsInParent(bounds);
   }

   /**
    * 提供交互,触发回调重绘控件
    */

   @Override
   protected  boolean onPerformActionForVirtualView(int virtualViewId, int action, Bundle arguments) {
       switch (action) {
           case AccessibilityNodeInfo.ACTION_CLICK:
               return onItemClicked(virtualViewId);
           default:
               break;
       }
       return false;
   }

   // ...
}

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

public LockPatternView(Context context) {
   // something else
   // ...

   // 无障碍代理
   mPatternTouchHelper = new PatternExploreByTouchHelper(this);
   ViewCompat.setAccessibilityDelegate(this, mPatternTouchHelper);
}

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

@Override
public boolean onHoverEvent(MotionEvent event) {
   final int action = event.getAction();
   switch (action) {
       case MotionEvent.ACTION_HOVER_ENTER:
           event.setAction(MotionEvent.ACTION_DOWN);
           break;
       case MotionEvent.ACTION_HOVER_MOVE:
           event.setAction(MotionEvent.ACTION_MOVE);
           break;
       case MotionEvent.ACTION_HOVER_EXIT:
           event.setAction(MotionEvent.ACTION_UP);
           break;
       case MotionEvent.ACTION_CANCEL:
           event.setAction(MotionEvent.ACTION_CANCEL);
   }
   onTouchEvent(event);
   event.setAction(action);
   return super.onHoverEvent(event);
}

@Override

protected boolean dispatchHoverEvent(MotionEvent event) {
   boolean handled = super.dispatchHoverEvent(event);
   handled |= mPatternTouchHelper.dispatchHoverEvent(event);
   return handled;
}

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

总结

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

参考资料

Android官方无障碍指南(https://developer.android.com/guide/topics/ui/accessibility/)
Android无障碍宝典(http://geek.csdn.net/news/detail/93269)
WCAG 2.0(https://www.w3.org/TR/UNDERSTANDING-WCAG20/intro.html)