随手记Android无障碍实践

12,815 阅读7分钟

欢迎关注微信公众号「随手记技术团队」,查看更多随手记团队的技术文章。转载请注明出处
本文作者:邓慧
原文链接:mp.weixin.qq.com/s/YckeGC_ZU…

随手记Android无障碍实践


前言

根据统计,目前我国有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官方无障碍指南
Android无障碍宝典
WCAG 2.0