无障碍功能框架:如何让残疾/老龄群体更好使用微信?

2022-03-29 12:10:09 浏览数 (1)

  • 作者:nalecyxu
  • 简介:微信客户端Android团队,负责无障碍功能框架开发。

前言

为了帮助老年人、残疾人(视障/听障人群等)更好地使用微信App,Android微信完成了适老化及无障碍改造。本文主要介绍「Android根据适老化及无障碍需求 完成的一个协助业务侧进行无障碍功能开发的框架」,主要包括:

  • 需求说明
  • 框架简介
  • 基础知识
  • 整体流程
  • 执行原理
  • 核心说明
  • 走查工具

框架需求

本框架主要具备以下特性:

  • 可感知性

包括大字体适配,颜色对比度等

  • 可操作性

主要是过小热区的放大,提高老年人/残疾人的交互体验

  • 可理解性

微信应提供读屏文案等信息,帮助盲人在开启Talkback等读屏软件的情况下,正常使用微信

下面给出一些较为典型的需求:

需求1:过小热区的放大

需求是要求微信内的所有可交互控件,可点击范围不得低于 44dp * 44dp,像这种大小不合规的控件,如果一个个进行排查、布局修改。那工程量庞大。

需求2:响应区域会随无障碍开关发生变化

该Item由一个 SwitchButton TextView 组成。

开启 Talkback 时,整个 Item 识别为一个焦点,选中双击是时触发点击switch的逻辑。(在无障碍模式下,选中双击是直接触发相应控件的 Click 事件)。但是在不开Talkback的情况下点击 Item 又无需相应,只响应 SwitchButton 。也就是点击区域会随Talkback开关发生变化。

我们的实现可能是:在 ItemClick 中进行 if 判断。但这样写侵入性高,难维护

需求3:读屏文案由其他的控件的值组合

选中头像,读屏文案:腾讯行政的头像,有2条未读消息。需要读出列表中其他关联内容,这种只能把适配代码侵入到Adapter中。

还有很多细节需求,此处不作过多描述。

框架简介

框架将多种不同的无障碍需求的实现进行封装,抽象成不同的规则。

业务侧可以将一个 页面/业务 的无障碍需求,在一个配置类里使用规则表达出来,再由框架去进行处理。实现相应的效果。

代码语言:javascript复制
class ChatAccessibility(activity: AppCompatActivity) :
BaseAccessibilityConfig(activity) {
  override fun initConfig() {
        // 设置 contentDesc
   view(rootId,viewId).desc(R.string.send_smiley)
        // ...
  }
}

框架基类 BaseAccessibilityConfig 提供了一系列用于表达规则的api,包括但不限于如下功能:

  • 通过配置统一设置contentDescription
  • 支持把多个View组合成一体进行读屏
  • 通过配置禁用某个View被Talkback聚焦的能力
  • 支持按指定顺序进行读屏,支持局部控制Talkback聚焦顺序
  • 支持设定在Activity启动后的第一个读屏控件
  • 支持对某个父View的disableChildren功能
  • 在某个View满足条件时,对其进行读屏,但不聚焦
  • 在某个View满足条件时,读出提前设定的string,但不聚焦
  • 全局热区宽高补齐至44dp,并提供自定义热区放大/禁用热区放大的功能 ...

基础知识

在深入了解框架设计前,先来介绍一些无障碍功能开发的基础知识。

基础知识1:读屏软件识别View原理

读屏软件无法直接识别到View,只能识别到View提供的虚拟节点(「Node」),View和虚拟节点一般是一一对应的。当页面上内容发生变化,比如View被设值,或者发生滚动等情况,View会向无障碍系统发送一个事件,通知系统;

然后系统就回头向View索取节点,组成页面更新后新的节点树,而「节点树 和 ViewTree 是一一对应的」。此时读屏软件拿到的就是新的内容了。

基础知识2:读屏软件后的事件分发流程

分为上下两个部分:读屏软件拦截处理行为、读屏软件接受事件

流程如下

  1. 读屏软件拦截用户Touch事件,根据事件的坐标去定位到目标节点
  2. 将Touch事件解释为节点行为,这里以触摸选中为例,那么就是聚焦行为
  3. 读屏软件通过该节点向无障碍系统发送,无障碍系统又转发给View(聚焦产生的绿框框就是在View的内部处理里去绘制的
  4. 生成新的虚拟节点并提供给读屏软件后,读屏软件组合信息,通过TTS语音引擎的api读出

读屏软件展示给用户的所有信息,全部来自虚拟节点。我们可以在节点生成的过程中,修改节点的信息,所以这里是一个绝佳的「信息自定义」的地方

❝采用将所有的 View 都 「Wrap 一层 AccessibilityDelegate」 的方式,「在 onInitializeAccessibilityNodeInfo 方法中修改节点信息」

整体流程

  1. 业务侧实现规则配置类,编写的规则会进入配置池。
  2. 框架在View生成节点给系统的时候进行拦截「(onInitializeAccessibilityNodeInfo)」
  3. 在配置池中寻找匹配的规则
  4. 根据匹配的规则对节点进行修改

最后生成的节点就会由系统交由给读屏软件进行读屏

执行原理

核心原理:采用基于责任链的流水线处理。整体流程主要分为两部分:

  1. View预处理责任链(图示左边)

执行预出来操作,如如异步生成缓存、View标记等;

  1. 节点处理责任链(图示右边)

节点处理的同时会同步查找规则进行设置

接下来主要简单介绍下框架的一个核心功能实现:「全局热区补足机制」 (位于在框架流程中的预处理责任链中的其中一环)。

核心说明:全局热区补足机制

1. 背景说明

  • 需求说明

过小热区放大,即微信内的所有可交互控件可点击范围不得低于 44dp * 44dp,像一些大小不合规的控件,如果一个个进行排查、布局修改,工程量太庞大。还有热区其他一些需求etc...

  • 问题难点

一般会选择直接修改padding,有些甚至需要改动相应布局,但这样的改动工作量太大且容易影响原来视图布局。

  • 解决方案

需要一个全局的热区补足机制,将过小热区补足至规范。

2. 具体实现

「创建View的统一入口」去设置 TouchDelegate 代理,由父View作为TouchDelegate的承载View去代理Touch事件,这里有三个问题需要解决:

  1. 如何找到合适的承载View
  2. 热区及时更新
  3. 性能优化
  4. 读屏模式下的热区扩大
2.1 如何找到合适的承载View

从目标 View 向上冒泡,找到一个合适的父View。那么我们需要「冒泡终止条件」。首先条件一肯定是「足够大」。当前 View 够大了就没必要再往上冒了

但是这样会存在问题:子View的Click优先级高于父View的TouchDelegate。我们知道事件派发机制:

  1. 从父View往子View派发,从子View向上处理
  2. View的事件处理顺序是先OnTouchListener,然后是TouchDelegate,再是Click、LongClick

所以会导致下图的情况

我们目前进行了折中处理,相比上图,显然是下图的放大后的体验更佳:

同时我们加入了条件二:「该承载 View 是 Clickable、LongClickable」,最终方案流程确定如下:

2.2 热区及时更新
  • 背景

承载View的TouchDelegate需要的参数包含一个 Rect,也就是对扩大的热区进行响应。

  • 问题

这个矩阵是提前传入,且和 小View 没有直接的关系。如果 小View 的布局发生变动,会导致扩大后热区没有及时跟上变化。导致热区错位

  • 解决方案

在 小View 的 onLayoutChange 中重新进行一遍 ·View扩大方案· 的处理。同时为了防止 onLayoutChange  执行过于频繁,我们将 onLayoutChange 包装成 View 的一个事件。如果短时间内多次 onLayoutChange  ,我们只在最后一次 onLayoutChange 的时候进行  ·View扩大方案· 处理。

2.3 性能优化
  • 背景

最初的 View扩大方案执行时机 是在创建View的统一入口,也就是在 LayoutInflate 的 onCreateView 中同步执行,每个 View 都得执行。

  • 问题

由于 View 数量较为庞大,所以存在较大的性能隐患。

  • 解决方案

采用了异步方案并同时对View处理任务进行收拢。将执行时机提前到 LayoutInflate.inflate 并异步处理,在异步任务中去遍历该inflate的根View的所有子View。尽量不去阻塞主线程的运行

2.4 读屏模式下的热区扩大

通过了上面的实现,点击热区确实是扩大了但是在读屏模式下选中的时候,选中的框框并没有扩大。那么首先我们需要知道选中时的框框是以什么作为Bound。

绿框的绘制核心逻辑位于 ViewRootImpl 中的一个 drawAccessibilityFocusedDrawableIfNeeded(),该方法的调用时机是用户触摸选中某个View后,传递到 ViewRootImpl 时进行调用,也就是读屏选中的绿框框是由系统绘制的,而不是由系统绘制。从源码中能够得知的是,绿框的Bound根据是否有虚拟节点,分为两种情况:

代码语言:javascript复制
private void drawAccessibilityFocusedDrawableIfNeeded(Canvas canvas) {
    final Rect bounds = mAttachInfo.mTmpInvalRect;
    if (getAccessibilityFocusedRect(bounds)) {
        final Drawable drawable = getAccessibilityFocusedDrawable();
        if (drawable != null) {
            drawable.setBounds(bounds);
            drawable.draw(canvas);
        }
    } else if (mAttachInfo.mAccessibilityFocusDrawable != null) {
        mAttachInfo.mAccessibilityFocusDrawable.setBounds(0, 0, 0, 0);
    }
}

private boolean getAccessibilityFocusedRect(Rect bounds) {
    ...
    final AccessibilityNodeProvider provider = host.getAccessibilityNodeProvider();
    if (provider == null) {
        host.getBoundsOnScreen(bounds, true);
    } else if (mAccessibilityFocusedVirtualView != null) {
        mAccessibilityFocusedVirtualView.getBoundsInScreen(bounds);
    } else {
        return false;
    }
  ...
    return !bounds.isEmpty();
}

经过跟踪源码发现,这个是因为「绿框的绘制」是根据 View.getBoundInScreen 获取的矩阵。而 TouchDelegate 的设置无法改变 View.getBoundInScreen 获取到的矩阵。在使用虚拟节点的情况下,才会使用虚拟节点的Bound进行绘制。

解决思路:

  1. 对每个 View 设置自定义的 AccessibilityDelegate,  并实现其中的 getAccessibilityNodeProvider 方法
  2. 如果判断 View 需要扩大,在 getAccessibilityNodeProvider 中返回自定义的 Provider
  3. 在自定义的 Provider 中,计算 View 的扩大后的矩阵在屏幕上的位置
  4. 将矩阵设置给虚拟节点,并返回给系统

额外说明

1. 如何匹配规则与View

框架将配置池按Activity划分,极大减少冲突概率,同时减少配置池大小,加快查找规则的速度,提供 layoutId viewId,rootId viewId 两种形式的 View 定位机制。由两个Id确定一个View,减少冲突。

2. 查找规则时间长可能导致的主线程卡顿

由于查找规则的时机是在生成节点的时候,是由系统触发且无法异步。我们在查找规则的过程中,使用预处理的时候提前生成的缓存进行查找,尽可能减少耗时

走查工具

1. 背景

当我们完成无障碍需求的开发后,需进行验证。在验证过程中发现开启验证效率低下,得开启读屏软件后,逐个元素验证。

2. 解决方案

基于无障碍服务(AccessibilityService)开发、集成了在不开启 Talkback 的情况下能展现读屏区域一个无障碍功能走查工具,无需开启 Talkback 逐个手动触摸,就能高效检查无障碍适配情况。

3. 实现原理

  1. 自定义实现一个AccessibilityService用于获取到当前活跃窗口的根节点
  2. 每隔0.5s进行一次节点的获取:从当前活跃窗口的根节点遍历所有的节点,逐个进行判断是否会被聚焦
  3. 对通过允许聚焦的节点进行信息收集,在一次遍历完成后通知到 DrawService
  4. 提前在window中添加一个View用于绘制信息,由 DrawService 进行绘制

4. 具体实现

关键实现:如何判断一个节点能否被聚焦,即需理解Talkback是如何聚焦,流程如下:

  1. 如果是支持 WebView 中Html无障碍,特殊判断
  2. 如果不可见,肯定不聚焦啦
  3. 判断是否是画中画,像下图的红框这种就是画中画,如果是画中画,这个就是焦点
  1. 该节点是否和window边界重合等大,对于这种和window等大的节点,Talkback选择不做聚焦
  2. 检查该节点 是否 clickable/longClickable/focusable 或者是列表的 “会说话的” 顶层视图(满足->6 不满足->7)列表(ListView/RecycleView)的顶层视图例子如下:

但是聚焦的前提是 “会说话的”,“会说话的” 包括以下几个条件:

  • HasText:包括 contentDescription、text、hintText(包括 Button 的 Text)
  • hasStateDescription:包括 CheckBox的已选未选状态、进度条的进度状态等
  • hasNonActionableSpeakingChildren:含有无法聚焦、点击但是 HasText 的子View(如上图通讯录中的 “新的朋友” TextView,就是无法聚焦、点击但是 HasText 的子View)
  1. 基本上满足了步骤5就可以视为可聚焦了,但是有一些View仅仅是Focusable,但是却 ”什么话都没得说“ ,对于这种 View 应该是要排除的。故按如下步骤做判断:

6.1 只要是没有子节点的 focusable/clickable/longclickable 的 View,全部聚焦 

6.2 “会说话的” 全部聚焦 6.3 剩下的就不聚焦了(“不会说话”、“有子节点”)

  1. 能到这一步,说明步骤5不满足,即该节点是普通的不可聚焦的View。但是防止错过一些没有点击事件的TextView之类的需要聚焦,需要再最后做一步判断(这一步也是啥为了保证所有的信息都可以不遗漏);如果没有可聚焦父节点,但仍然 hasText 或 hasStateDescription,聚集该节点
  2. 一路闯关到这的 View,就终于逃离TalkBack的聚焦了

至此,关于Android微信无障碍功能开发框架讲解完毕。

想了解更多「微信客户端技术及开发经验」,请关注「微信客户端技术团队公众号」

微信客户端-火热招聘中!

热招岗位

Android(广州)、iOS(广州)

Windows(深圳)、Mac(深圳)

岗位详情 & 投递

点击左下角「原文链接」

0 人点赞