抖音无障碍背景
国家近日举办了无障碍建设活动。为了积极响应国家呼吁,为抖音残障用户就能得到更好的交互体验,对抖音无障碍功能进行了专项整治和整修。
无障碍模式下的使用方式
抖音的无障碍功能实现主要是通过开启GoogleTalkBack(或第三方屏幕阅读)功能,将用户在屏幕上触摸选中区域的内容朗读下来,致使残障人士可以按照朗读的内容获取自己当前操作区域的信息,进而提高残障人士的使用和交互体验。
常用的操作手势:
本文的目的
使研制同事对无障碍功能有一个愈发全面的认识和了解,便捷研制朋友进行无障碍功能的开发。
本文将分为无障碍功能实现原理和无障碍功能实现实例两部份进行介绍。
无障碍功能实现原理系统结构
无障碍功能的实现须要以下三个部份的支持:辅助App(比如TalkBack)、被辅助app(用户使用的app,比如抖音头条等)以及系统服务AccessibilityManagerService,这两者之间的关系如右图所示:
从上图中可以看出,以上的流程主要涉及到三个进程的通讯。辅助app和被辅助app不须要直接跟被辅助的app通讯,而是通过SystemServer进行中转通讯,这个过程主要涉及到了四个aidl插口:
当被辅助app形成触摸风波后,会通过该插口发送无障碍风波给SystemServer进程的AccessibilityManagerService。
当SystemServer接收到被辅助app发送的无障碍风波时,会将风波通过该插口传递给辅助app(比如TalkBack)进行处理。
当须要被辅助app的某个View的信息时,可以通过这两个插口的findAccessibilityNodeInfosByViewId方式实现。
无障碍风波传递流程
当用户触摸屏幕时,会经过以下的流程将触摸风波传递给被触摸的View:
下边本文将主要剖析以上流程中四个重点部份的内容:无障碍模式下的风波转换、触摸风波到Activity的传递过程、事件传递给具体的View的分发过程以及最终无障碍风波的执行流程。
1.无障碍模式下的风波转换
在TalkBack开启的状态下,因为TalkBack的无障碍服务中申明了android:canRequestTouchExplorationMode=''true'',因而开启TalkBack后AccessibilityManagerService会更新AccessibilityInputFilter的FLAG_FEATURE_TOUCH_EXPLORATION(触摸浏览)属性置为true。
在FLAG_FEATURE_TOUCH_EXPLORATION模式下会创建一个TouchExplorer对象。AccessibilityInputFilter承继了InputFilter,对输入风波进行过滤,通过和TouchExplorer共同实现TalkBack模式下的触摸浏览手势。TouchExplorer负责将普通触摸风波转换为触摸浏览手势,比如将MotionEvent.ACTION_DOWN风波转换为MotionEvent.ACTION_HOVER_ENTER(悬停风波)。因而在TalkBack开启的情况下,用户单击View时,App执行的是ACTION_HOVER_ENTER风波,双击View时才能执行ACTION_DOWN风波。
2.触摸风波到Activity的传递过程
在Android中,消息机制是handler机制,通过将消息封装到Message中,并将该消息发送到handler所在的MessageQueue中,通过Looper不断调用MessageQueue的next方式进行消息的处理。
当用户触摸屏幕上的某个View时,handler会对收到的消息进行以下的处理:
这儿须要重点看一下View的dispatchPointerEvent()方式:
<p><pre class="has"> <code class="language-go">public final boolean dispatchPointerEvent(MotionEvent event) {
if (event.isTouchEvent()) {
return dispatchTouchEvent(event);
} else {
return dispatchGenericMotionEvent(event);
}
}
</code></pre></p>
在该方式中对event进行判别,倘若是touchEvent就调用dispatchTouchEvent()方式,否则调用dispatchGenericMotionEvent()方式。判定是否为touch风波的逻辑如下:
<p><pre class="has"> <code class="language-go">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;
}
</code></pre></p>
符合以上case的event即为TouchEvent。
首先来看一下dispatchPointerEvent方式中对TouchEvent风波的处理,步入DecorView的dispatchTouchEvent()方式中:
<p><pre class="has"> <code class="language-go">@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
final Window.Callback cb = mWindow.getCallback();
return cb != null && !mWindow.isDestroyed() && mFeatureId < 0
? cb.dispatchTouchEvent(ev) : super.dispatchTouchEvent(ev);
}
</code></pre></p>
在该方式中,mWindow是与Activity关联的PhoneWindow对象,因为DecorView是由PhoneWindow创建的,但是通过setWindow()方式,DecoView对象持有PhoneWindow对象的引用。通过getCallback()方式,获得了实现了Window.Callback的对象,而Activity实现了这个插口,因而当调用cb.dispatchTouchEvent(ev)时,实际上调用的是Activity中的dispatchTouchEvent()方式。
同样的在dispatchGenericMotionEvent()方式中,也有类似的代码逻辑:
<p><pre class="has"> <code class="language-go">@Override
public boolean dispatchGenericMotionEvent(MotionEvent ev) {
final Window.Callback cb = mWindow.getCallback();
return cb != null && !mWindow.isDestroyed() && mFeatureId < 0
? cb.dispatchGenericMotionEvent(ev) : super.dispatchGenericMotionEvent(ev);
}
</code></pre></p>
此方式中实际上也是调用了Activity的dispatchGenericMotionEvent()方式对风波进行后续的分发和处理。此时风波就早已传递到了Activity,由Activity进一步进行风波分发。
3.触摸风波传递到具体View的过程
在研究无障碍模式下的风波传递过程之前,首先来回顾一下普通模式下的风波传递机制:
3.1普通模式的风波分发
3.1.1普通模式下风波分发KeyMethod
当一个MotionEvent形成以后,系统须要将该风波传递给一个具体的view,这个传递过程就是风波的分发过程。分发过程依赖于以下三个重要方式:
该方式拿来进行风波的分发,技巧的返回值取决于当前View的onTouchEvent()方式和子View的dispatchTouchEvent()方式的影响。
仅ViewGroup拥有的方式,拿来判定是否拦截某个风波。
在dispatchTouchEvent()方式中进行调用,拿来处理点击风波。
3.1.2普通模式下的风波分发
整个分发过程可以用以下的流程图来表示:
3.2无障碍模式下的风波分发
无障碍模式下的风波分发与普通模式下的风波分发有好多相像之处:
3.2.1无障碍模式下的风波分发KeyMethod:
与普通风波触摸风波的分发类似,无障碍风波触发风波分发也有类似的三个重要方式:
该方式拿来进行风波的分发,技巧的返回值取决于当前View的onHoverEvent()方式和子View的dispatchHoverEvent()方式的影响。
仅ViewGroup拥有的方式,拿来判定是否拦截某个风波。
在dispatchHoverEvent()方式中进行调用,拿来处理hover风波。
3.2.2无障碍模式下的风波分发
当用户处于无障碍模式下,用户进行点击屏幕时,会调用dispatchPointerEvent方式中的dispatchGenericMotionEvent方式:
<p><pre class="has"> <code class="language-go">public final boolean dispatchPointerEvent(MotionEvent event) {
if (event.isTouchEvent()) {
return dispatchTouchEvent(event);
} else {
return dispatchGenericMotionEvent(event);
}
}
</code></pre></p>
实际上调用的是Activity的dispatchGenericMotionEvent()方式,Activity接收到风波后,会传递给PhoneWindow再传递给DecorView。DecorView会调用View的dispatchGenericMotionEvent()方式:
<p><pre class="has"> <code class="language-go">public boolean dispatchGenericMotionEvent(MotionEvent event) {
···
final int source = event.getSource();
if ((source & InputDevice.SOURCE_CLASS_POINTER) != 0) {
final int action = event.getAction();
//判断事件类型属于Hover,调用dispatch方法开始进行分发
if (action == MotionEvent.ACTION_HOVER_ENTER
|| action == MotionEvent.ACTION_HOVER_MOVE
|| action == MotionEvent.ACTION_HOVER_EXIT) {
if (dispatchHoverEvent(event)) {
return true;
}
}
...
return false;
}
</code></pre></p>
在该方式中,假如判定风波为HoverEvent,就调用ViewGroup的dispatchHoverEvent()方式开始进行风波分发。
假如某个ViewGroup的onInterceptHoverEvent()方式返回true,表示它要拦截当前风波,并交给自己处理,反之返回false表示不拦截当前风波,并将当前风波继续传递给子View,子View会调用自己的dispatchHoverEvent()方式,这般循环往复直至风波最终被处理。
在风波处理阶段,View/ViewGroup首先会判定是否设置了OnHoverListener,并判定它的onHover方式的返回值是否为true,假如返回值为true,则不会调用onHoverEvent(),反之会调用onHoverEvent()方式对风波进行处理。
整个处理过程可以用下边的流程图进行表示:
在onHoverEvent()方式中,会调用到sendAccessibilityHoverEvent()方式,该方式后续会调用以下方式:
以上6种方式为当自定义View时适配无障碍模式可以覆盖实现的方式,可以重画View的这种方式或则实现View.AccessibilityDelegate来解决一些特殊场景下TalkBack播报的问题。
其中的sendAccessibilityEventUnchecked方式会向下传递到ViewRootImpl的requestSendAccessibilityEvent方式中,从堆栈信息中就可以否认这一点:
接着无障碍风波会通过AccessibilityManager的sendAccessibilityEvent方式跨进程调用system_process进程的AccessibilityManagerService,将AccessibilityEvent风波传递到TalkBack的TalkBackService中。
4.无障碍风波的执行流程
这一节主要剖析从TalkBack发出无障碍风波,到被辅助app在屏幕上勾画出绿框的过程。
TalkBack将无障碍风波发送给被辅助APP时,须要system_process进程作为中转,对应的插口为IAccessibilityServiceConnection.aidl和IAccessibilityInteractionConnection.aidl。经过中转后,最终会调用到被触摸View的performAccessibilityAction方式中,在没有delegate的情况下,会执行performAccessibilityActionInternal技巧。在该方式中,假如是ACTION_ACCESSIBILITY_FOCUS风波,会执行requestAccessibilityFocus方式:
这个技巧会执行两个关键操作:
调用ViewRootImpl的setAccessibilityFocus方式将自身设置为focus,之后调用invalidate()触发重画操作,ViewRootImpl会在onPostDraw方式中执行drawAccessibilityFocusedDrawableIfNeeded来勾画绿框。
调用sendAccessibilityEvent方式,将TYPE_VIEW_ACCESSIBILITY_FOCUSED风波发送出去,这个风波被talkback接收后,会调用朗读引擎TTS读出View的内容,实现了无障碍模式下对触摸区域内容的播报。
无障碍功能实现实例
解决方案:在该View的android:contentDescription属性上设置须要播报的String。
解决方案:将不须要播报的View的android:importantForAccessibility属性设置为no,将须要播报的View的该属性设置为yes。
解决方案:将上层的根View的android:importantForAccessibility属性设置为"noHideDescendants"
解决方案:在自定义Toast展示的时侯,主动发送一个AccessibilityEvent风波
<p><pre class="has"> <code class="language-go">mText.postDelayed(new Runnable() {
@Override public void run() {
mText.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_HOVER_ENTER);
}
}, 1);
</code></pre></p>
设置延时是为了防止不生效的问题。
解决方式:overrideView的onPopulateAccessibilityEvent()技巧。
举例:设置自定义View开/关状态(已开启/已关掉)的播报内容。
<p><pre class="has"> <code class="language-go">@Override
public void onPopulateAccessibilityEvent(AccessibilityEvent event) {
super.onPopulateAccessibilityEvent(event);
final CharSequence text = isChecked() ? "已开启" : "已关闭";
if (text != null) {
event.getText().add(text);
}
}
</code></pre></p>
解决方式:使用AccessibilityDelegate
<p><pre class="has"> <code class="language-go">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);//设置播报的被选中状态
}
});
</code></pre></p>
加入我们
欢迎加入抖音-关系与服务团队,我们专注于抖音多个核心业务场景的落地与迭代,在业务、架构、技术等方面都有投入,期盼你的加入!
抖音-关系与服务团队正在热招Android&iOS研制,在上海,上海均有职位,欢迎投递简历!
点个在看杀个Bug❤ |
|