抖音无障碍背景
国家近期开展了无障碍建设活动。为了积极响应国家号召,为抖音视障用户能够得到更好的交互体验,对抖音无障碍功能进行了专项治理和改造。
无障碍模式下的使用方法
抖音的无障碍功能实现主要是通过开启 Google TalkBack(或第三方屏幕阅读)功能,将用户在屏幕上触摸选中区域的内容朗读出来,使得视障人士可以根据朗读的内容获取自己当前操作区域的信息,从而提升视障人士的使用和交互体验。
常用的操作手势:
- 浏览某个 View:单击
- 点击某个 View:双击
- 沿某个方向滑动:双指沿所需方向滑动
- 顺序浏览页面:单指左右滑动
本文的目的
使研发同学对无障碍功能有一个更加全面的认识和了解,方便研发同学进行无障碍功能的开发。
本文将分为无障碍功能实现原理和无障碍功能实现实例两部分进行介绍。
无障碍功能实现原理
系统结构
无障碍功能的实现需要以下三个部分的支持:辅助 App(例如 TalkBack)、被辅助 app(用户使用的 app,例如抖音头条等)以及系统服务 AccessibilityManagerService,这三者之间的关系如下图所示:
下面本文将主要分析以上流程中四个重点部分的内容:无障碍模式下的事件转换、触摸事件到 Activity 的传递过程、事件传递给具体的 View 的分发过程以及最终无障碍事件的执行流程。
1.无障碍模式下的事件转换
在 TalkBack 开启的状态下,由于 TalkBack 的无障碍服务中声明了 android:canRequestTouchExplorationMode=”true” ,因此开启 TalkBack 后 AccessibilityManagerService 会更新 AccessibilityInputFilter 的FLAG_FEATURE_TOUCH_EXPLORATION(触摸浏览)属性置为 true。
这里需要重点看一下 View 的 dispatchPointerEvent() 方法:
public final boolean dispatchPointerEvent(MotionEvent event) { if (event.isTouchEvent()) { return dispatchTouchEvent(event); } else { return dispatchGenericMotionEvent(event); }}
在该方法中对 event 进行判断,如果是 touchEvent 就调用 dispatchTouchEvent() 方法,否则调用 dispatchGenericMotionEvent() 方法。判断是否为 touch 事件的逻辑如下:
bool MotionEvent::isTouchEvent(int32_t source, int32_t action) { if (source & AINPUT_SOURCE_CLASS_POINTER) { // Specifically excludes HOVER_MOVE and SCROLL. switch (action & AMOTION_EVENT_ACTION_MASK) { case AMOTION_EVENT_ACTION_DOWN: case AMOTION_EVENT_ACTION_MOVE: case AMOTION_EVENT_ACTION_UP: case AMOTION_EVENT_ACTION_POINTER_DOWN: case AMOTION_EVENT_ACTION_POINTER_UP: case AMOTION_EVENT_ACTION_CANCEL: case AMOTION_EVENT_ACTION_OUTSIDE: return true; } } return false;}
符合以上 case 的 event 即为 TouchEvent。
首先来看一下 dispatchPointerEvent 方法中对 TouchEvent 事件的处理,进入 DecorView 的 dispatchTouchEvent() 方法中:
@Overridepublic boolean dispatchTouchEvent(MotionEvent ev) { final Window.Callback cb = mWindow.getCallback(); return cb != null && !mWindow.isDestroyed() && mFeatureId < 0 ? cb.dispatchTouchEvent(ev) : super.dispatchTouchEvent(ev);}
在该方法中,mWindow 是与 Activity 关联的 PhoneWindow 对象,由于 DecorView 是由 PhoneWindow 创建的,并且通过 setWindow() 方法,DecoView 对象持有 PhoneWindow 对象的引用。通过 getCallback() 方法,获得了实现了 Window.Callback 的对象,而 Activity 实现了这个接口,因此当调用cb.dispatchTouchEvent(ev) 时,实际上调用的是 Activity 中的 dispatchTouchEvent() 方法。
同样的在 dispatchGenericMotionEvent() 方法中,也有类似的代码逻辑:
@Overridepublic boolean dispatchGenericMotionEvent(MotionEvent ev) { final Window.Callback cb = mWindow.getCallback(); return cb != null && !mWindow.isDestroyed() && mFeatureId < 0 ? cb.dispatchGenericMotionEvent(ev) : super.dispatchGenericMotionEvent(ev);}
此方法中实际上也是调用了 Activity 的 dispatchGenericMotionEvent() 方法对事件进行后续的分发和处理。此时事件就已经传递到了 Activity,由 Activity 进一步进行事件分发。
3.触摸事件传递到具体 View 的过程
在研究无障碍模式下的事件传递过程之前,首先来回顾一下普通模式下的事件传递机制:
3.1 普通模式的事件分发
3.1.1 普通模式下事件分发 Key Method
当一个 MotionEvent 产生之后,系统需要将该事件传递给一个具体的 view,这个传递过程就是事件的分发过程。分发过程依赖于以下三个重要方法:
- public boolean dispatchTouchEvent(MotionEvent ev)
该方法用来进行事件的分发,方法的返回值取决于当前 View 的 onTouchEvent() 方法和子 View 的 dispatchTouchEvent() 方法的影响。
- public boolean onInterceptTouchEvent(MotionEvent ev)
仅 ViewGroup 拥有的方法,用来判断是否拦截某个事件。
- public boolean onTouchEvent(MotionEvent event)
在 dispatchTouchEvent() 方法中进行调用,用来处理点击事件。
3.1.2 普通模式下的事件分发
整个分发过程可以用以下的流程图来表示:
在 onHoverEvent() 方法中,会调用到 sendAccessibilityHoverEvent()方法,该方法后续会调用以下方法:
- sendAccessibilityEvent
- sendAccessibilityEventUnchecked
- onInitializeAccessibilityEvent
- dispatchPopulateAccessibilityEvent
- onPopulateAccessibilityEvent
- onRequestSendAccessibilityEvent(仅在 ViewGroup 中有默认实现)
以上 6 种方法为当自定义 View 时适配无障碍模式可以覆盖实现的方法,可以重写 View 的这些方法或者实现 View.AccessibilityDelegate 来解决一些特殊场景下 TalkBack 播报的问题。
其中的 sendAccessibilityEventUnchecked 方法会向上传递到 ViewRootImpl 的 requestSendAccessibilityEvent 方法中,从堆栈信息中就可以证实这一点:
这个方法会执行两个关键操作:
- 调用 ViewRootImpl 的 setAccessibilityFocus 方法将自身设置为 focus,然后调用 invalidate() 触发重绘操作,ViewRootImpl 会在 onPostDraw 方法中执行 drawAccessibilityFocusedDrawableIfNeeded 来绘制绿框。
- 调用 sendAccessibilityEvent 方法,将 TYPE_VIEW_ACCESSIBILITY_FOCUSED 事件发送出去,这个事件被 talkback 接收后,会调用朗读引擎 TTS 读出 View 的内容,实现了无障碍模式下对触摸区域内容的播报。
无障碍功能实现实例
- Case 1:无障碍模式下点击 View 播报“未加标签”
解决方案:在该 View 的 android:contentDescription 属性上设置需要播报的 String。
- Case 2:焦点过多,需要删除多余焦点或需要某个 View 能够进行播报
解决方案:将不需要播报的 View 的 android:importantForAccessibility 属性设置为 no,将需要播报的 View 的该属性设置为 yes。
- Case 3:无障碍模式下在上层页面点击仍能选中下层 View
解决方案:将下层的根 View 的 android:importantForAccessibility 属性设置为”noHideDescendants”
- Case 4:使用的自定义 Toast 不播报内容
解决方案:在自定义 Toast 展示的时候,主动发送一个 AccessibilityEvent 事件
mText.postDelayed(new Runnable() { @Override public void run() { mText.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_HOVER_ENTER); }}, 1);
设置延时是为了避免不生效的问题。
- Case 5:设置自定义 View 的播报内容
解决方法:override View 的 onPopulateAccessibilityEvent()方法。
举例:设置自定义 View 开/关状态(已开启/已关闭)的播报内容。
@Overridepublic void onPopulateAccessibilityEvent(AccessibilityEvent event) { super.onPopulateAccessibilityEvent(event); final CharSequence text = isChecked() ? "已开启" : "已关闭"; if (text != null) { event.getText().add(text); }}
- Case 6:设置自定义 View 播报的控件类型及选中状态
解决方法:使用 AccessibilityDelegate
ViewCompat.setAccessibilityDelegate(targetView, new AccessibilityDelegateCompat() { @Override public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCompat info) { super.onInitializeAccessibilityNodeInfo(host, info); info.setRoleDescription("标签类型");//设置播报的标签类型 info.setCheckable(true); info.setChecked(checked);//设置播报的被选中状态 }});
加入我们
欢迎加入抖音-关系与服务团队,我们专注于抖音多个核心业务场景的落地与迭代,在业务、架构、技术等方面都有投入,期待你的加入!