无障碍服务适配大家应该多多少少的都遇到过,简单点讲就是给图片、文本等控件加上 android:contentDescription=""标签,这样在使用无障碍服务(比如手机自带的 talkback)时,可以将contentDescription的内容以声音的方式读出来,方便视障用户使用我们的 app。
这不是本文的重点,重点是在无障碍-->已安装的服务中中发现了一些其他的应用也提供了一些无障碍服务,比如某输入法提供了"智能回复"、"智能应答"等服务,某些应用还提供了类似于一键进行微信视频通话功能.
这玩意咋搞的?
我们能不能搞?
能不能给老人做一个简单的工具,点个按钮就能和我们进行视频通话?
查了一些资料,我们可以使用AccessibilityService来实现该功能。该服务可以在页面切换或者发生其他变化时回调某些方法,我们可以根据这些回调,获取到页面的节点(控件)信息,来进行点击、长按等操作。
不要用无障碍服务做违法的事情!!!不要用无障碍服务做违法的事情!!!不要用无障碍服务做违法的事情!!!
第一步:创建与配置
我们需要自定义一个继承自AccessibilityService的 service,然后在AndroidManifest.xml文件中注册一下,和普通的service差不多,这里有三个可以被重写方法
import android.accessibilityservice.AccessibilityService;
import android.view.accessibility.AccessibilityEvent;
public class MyAccessibilityService extends AccessibilityService {
@Override
public void onAccessibilityEvent(AccessibilityEvent event) {
// 处理接收到的辅助功能事件
}
@Override
public void onInterrupt() {
// 处理服务被中断的情况
}
@Override
protected void onServiceConnected() {
super.onServiceConnected();
// 初始化服务
}
}
在清单文件中注册一下
<service
android:name=".MyAccessibilityService"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config" />
</service>
需要注意这里的meta-data标签,其中的name属性值是固定的,resource属性则是我们为无障碍服务提供的配置文件。这里取了个名字accessibility_service_config.xml ,其中包含了许多属性,用于定义服务的行为和特性。以下是这些属性的详细介绍和示例说明:
1. android:description
描述服务的用途,通常是一个字符串资源的引用。这个值会展示在开启无障碍服务时的帮助说明中
2. android:accessibilityEventTypes
定义服务要监听的事件类型。可以是以下之一或多个的组合:
typeAllMasktypeViewClickedtypeViewFocusedtypeViewLongClickedtypeViewSelectedtypeViewTextChangedtypeWindowContentChangedtypeWindowStateChanged。
这里我们只需要监听 typeWindowContentChanged 和 typeWindowStateChanged 就足够了
3. android:packageNames
指定服务要监听的应用包名。多个包名可以用逗号分隔。这个没啥好说的
4. android:accessibilityFeedbackType
定义服务的反馈类型,就是如何给用户反馈,可以是以下之一或多个的组合:
feedbackSpoken: 适用于需要将信息通过语音读出来的情况,例如屏幕阅读器。feedbackHaptic: 适用于需要通过振动提醒用户的情况,例如通知用户某个操作成功或失败。feedbackAudible: 适用于需要通过音效提醒用户的情况,例如提示音。feedbackVisual: 适用于需要通过视觉效果(如闪烁、颜色变化)提醒用户的情况。feedbackGeneric: 适用于不特定于某一种反馈类型的情况。feedbackBraille: 适用于需要将信息传递给盲文设备用户的情况。
5. android:notificationTimeout
定义服务在处理连续事件之间的最短时间间隔,以毫秒为单位。当辅助功能服务接收到大量的连续事件时,可能会导致性能问题或用户体验不佳。通过设置 notificationTimeout,可以指定一个时间窗口,在这个时间窗口内重复的事件将被合并为一个事件,从而减少处理的频率。
6. android:canRetrieveWindowContent
定义服务是否可以检索窗口内容。设置为 true 表示服务可以访问窗口内容。
7. android:settingsActivity
指定一个设置活动的类名,用户可以通过辅助功能设置页面进入该活动。配置了该属性之后,用户可以在开启无障碍服务页面点击更多设置直接进入到该页面
8. android:canRequestTouchExplorationMode
属性用于指定辅助功能服务是否可以请求触摸探索模式。触摸探索模式是一种特殊的输入模式,通常用于帮助视力障碍用户使用触摸屏设备 这里也需要我们在代码中设置一下
override fun onServiceConnected() {
super.onServiceConnected()
val info = AccessibilityServiceInfo()
info.flags = AccessibilityServiceInfo.FLAG_REQUEST_TOUCH_EXPLORATION_MODE
serviceInfo = info
}
并且,在处理事件中我们也需要处理更多的事件
启用时:应用需要处理更多的辅助功能事件,如 TYPE_TOUCH_EXPLORATION_GESTURE_START 和 TYPE_TOUCH_EXPLORATION_GESTURE_END。这些事件帮助应用确定用户正在进行触摸探索。 未启用时:应用只需处理标准的触摸事件。
9. android:canRequestEnhancedWebAccessibility
定义服务是否可以请求增强的网页辅助功能。
10. android:canRequestFilterKeyEvents
用于指定辅助功能服务是否可以请求过滤键事件(key events)。这对于开发辅助功能服务(如屏幕阅读器或其他辅助工具)非常重要,因为它允许这些服务拦截和处理按键事件,以提供更好的用户体验和辅助功能支持。同样的,不仅要在配置文件中声明,也需要在代码中设置
override fun onServiceConnected() {
super.onServiceConnected()
val info = AccessibilityServiceInfo()
info.flags = AccessibilityServiceInfo.FLAG_REQUEST_FILTER_KEY_EVENTS
serviceInfo = info
}
11. android:canPerformGestures
定义服务是否可以执行手势。如果为 true,我们可以这样执行手势
// 执行点击手势
private void performClick(float x, float y) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
Path clickPath = new Path();
clickPath.moveTo(x, y);
GestureDescription.StrokeDescription clickStroke = new GestureDescription.StrokeDescription(clickPath, 0, 100);
GestureDescription gestureDescription = new GestureDescription.Builder().addStroke(clickStroke).build();
dispatchGesture(gestureDescription, null, null);
}
}
12. android:accessibilityFlags
定义服务的辅助功能标志,这些标志定义了服务的行为和特性。通过设置不同的标志,开发者可以控制辅助功能服务如何与系统和应用交互。可以是以下之一或多个的组合:
-
flagIncludeNotImportantViews:- 作用:包括那些通常被认为不重要的视图(如布局视图)在辅助功能事件中。
- 使用场景:当需要确保所有视图都被辅助功能服务处理时使用。
-
flagRequestTouchExplorationMode:- 作用:请求触摸探索模式,这对于视力障碍用户非常有用。
- 使用场景:当辅助功能服务需要解释触摸事件并提供反馈时使用。
-
flagReportViewIds:- 作用:报告视图的资源 ID。
- 使用场景:当辅助功能服务需要识别和操作特定视图时使用。
-
flagRetrieveInteractiveWindows:- 作用:允许辅助功能服务检索交互窗口。
- 使用场景:当需要处理多个窗口或弹出窗口时使用。
当然我们也可以在代码中设置标志
在你的 AccessibilityService 中,你可以使用 AccessibilityServiceInfo 来设置标志:
override fun onServiceConnected() {
super.onServiceConnected()
val info = AccessibilityServiceInfo()
info.flags = (AccessibilityServiceInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS
or AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS
or AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS)
serviceInfo = info
}
示例完整配置文件
下面是我们这次需要用到的配置文件内容:
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service
xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowContentChanged|typeWindowStateChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagIncludeNotImportantViews|flagReportViewIds"
android:canRetrieveWindowContent="true"
android:canRequestTouchExplorationMode="true"
android:canRequestFilterKeyEvents="true"
android:canPerformGestures="true"
android:packageNames="com.tencent.mm"
android:settingsActivity="com.huangyuanlove.auxiliary.SettingActivity"
android:description="@string/wx_make_call_service_helper"
android:notificationTimeout="100"/>
通过配置这些属性,你可以精确地控制 AccessibilityService 的行为,以满足特定的需求和用例。
左边图片为荣耀v10上开启应用无障碍服务的截图。右边图片为红米k70pro开启应用无障碍服务的截图.
第二步:rua代码
在上面我们已经做好了基础配置,下面开始rua 代码,看看我们应该怎么做。
分析路径流程
我们先做好微信的前期准备工作:
通话双方是好友、微信已经登录。
那么我们的使用流程大致是这样的:
打开微信
点击底部通讯录
找到这个好友(可能需要滑动通讯录列表)点击一下进入到好友信息页面
点击信息页面的音视频通话
在底部弹窗中点击视频通话或者语音通话
简单的 API 调用准备
onAccessibilityEvent
当触发了我们在配置文件中指定的事件时,系统会回调AccessibilityService#onAccessibilityEvent(event: AccessibilityEvent)这个方法。
我们可以通过event对象获取触发这个事件的包名,触发的事件类型等,
getRootInActiveWindow
我们可以在AccessibilityService中调用这个方法获取当前页面的根节点,这个节点可以看做是当前视图树的根节点,这样我们就可以遍历整个视图树了。
同样的,我们也可以通过AccessibilityNodeInfo实例来获取对应节点的属性,比如是否可以点击(isClickable)、类型(className)、按钮|文本内容(text)、无障碍服务标签内容(contentDescription)等。我们可以根据这些属性来判断是不是我们需要的节点(控件)
注意事项
就像我们平时开发一样,有些事件并不是直接设置在 TextView 或者 Button 上的,可能是设置在它们的父级组件上,比如LinearLayout或者RelativeLayout等。所以当我们获取到对应的节点后,需要判断一下是不是我们需要的节点,如果不是的话,就在找找父级是不是我们需要的节点。 当然如果我们知道某个页面某个节点的id,就不需要这么麻烦了,直接根据 id 查找就好了。
权限
需要开启无障碍服务才可以进行对应的操作
fun isServiceEnabled(context: Context): Boolean {
(context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager)
.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
.filter { it.id == "${context.packageName}/${MakeWeChatCallService::class.java.name}" }
.let { return it.isNotEmpty() }
}
//跳转到开启无障碍服务页面
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
动手开工
打开微信
这个很简单哇,知道微信的包名就好了
val intent = packageManager.getLaunchIntentForPackage(WX_PACKAGE_NAME)
intent?.let {
startActivity(intent)
}
当我们打开微信后,标记接下来需要点击通讯录按钮:current_step= click_contacts;
点击底部通讯录
这个就需要用到上面准备好的AccessibilityService了,按照上面的配置,当我们打开微信之后,就开始回调onAccessibilityEvent(event: AccessibilityEvent)这个方法了。
我们假设用户使用的是中文,我们需要找到"通讯录"这个按钮对应的AccessibilityNodeInfo实例,然后调用performAction(AccessibilityNodeInfo.ACTION_CLICK)进行点击就好了。
注意,这个的通讯录文本并不是可以点击的,我们打开无障碍服务talkback将框框移动到通讯录这里,就可以看到通讯录和上面的图标是一体的。但我们也不清楚他们到底是怎么实现的,所以我们查找这个文本的父级控件,看是否能点,不能点击就再往上查找。多次尝试之后,发现需要向上查找两次。这里写了一个扩展方法
private fun AccessibilityNodeInfo.clickNodeByText(
textList: Array<String>,
parentCount: Int = 0
): Boolean {
var node = getNodeByText(textList)
repeat(parentCount) {
node = node?.parent
}
node?.let {
return it.performAction(AccessibilityNodeInfo.ACTION_CLICK)
}
return false
}
这里的参数parentCount表示需要向上查找几次。
我们点击通讯录的时候调用rootInActiveWindow.clickNodeByText(arrayOf("通讯录"), 2)就可以了.
点击成功后,我们标记接下来需要点击联系人:current_step=click_contact;
找到好友
通讯录是个列表,我们猜要不是 ListView,要不是 RecyclerView,我觉得不大可能是 ScrollView。要注意。右侧还有一个字母列表,不要搞错了。 我们先从当前可看到的页面查找联系人。 这里也搞了个扩展方法
private fun AccessibilityNodeInfo.getNodeByText(textList: Array<String>): AccessibilityNodeInfo? {
var node: AccessibilityNodeInfo? = null
var index = 0
while (index < textList.size && node === null) {
node = this.findAccessibilityNodeInfosByText(textList[index]).getOrNull(0)
index++
}
return node
}
查找这个联系人
var contactNode = rootInActiveWindow.getNodeByText(arrayOf(cantactName))
如果contactNode为空,表示当前可视内容中没有这个联系人,我们需要滑动列表。
首先,找到联系人列表的RecyclerView,别问为啥是RecyclerView,试了好多次试出来的。
private fun getContactListView(): AccessibilityNodeInfo? {
val queue = LinkedList<AccessibilityNodeInfo>()
queue.offer(rootInActiveWindow)
var info: AccessibilityNodeInfo?
while (!queue.isEmpty()) {
info = queue.poll()
if (info == null) {
continue
}
if (info.className.equals("androidx.recyclerview.widget.RecyclerView") && info.isScrollable) {
return info
}
for (i in 0 until info.childCount) {
queue.offer(info.getChild(i))
}
}
return null
}
找到列表控件后滑动一下
val contactListNode = getContactListView()
contactListNode?.performAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD)
注意,这里列表的滑动同样会触发onAccessibilityEvent这个方法,我们再重复上面的流程,直到找到这个联系人控件。需要注意的是,这里的联系人显示的名字要是单个英文字母,这会和列表分组上面的单个英文字母相同,导致查找到的控件不是我们想要的
当我们找到这个联系人控件后,进行点击
var contactNode = rootInActiveWindow.getNodeByText(arrayOf(cantactName))
repeat(6) {//别问这里为啥是 6,试出来的,或者可以遍历一下视图树,自己数一下层级
contactNode = contactNode?.parent
}
contactNode?.let {
val result = it.performAction(AccessibilityNodeInfo.ACTION_CLICK)
if(result){
//标记接下来需要在联系人详情页面点击音视频通话
current_step= click_video;
}
}
点击音视频通话
这个就比较简单了,还是调用我们上面写的扩展方法找到按钮,然后点击就行了
var contactNode = rootInActiveWindow.getNodeByText(arrayOf("音视频通话"))
repeat(2) {
contactNode = contactNode?.parent
}
contactNode?.let {
val result = it.performAction(AccessibilityNodeInfo.ACTION_CLICK)
if(result){
//标记接下来需要点击弹窗中的视频通话
current_step= click_video_on_dialog;
}
}
点击弹窗中的视频通话
这个就更简单了
rootInActiveWindow.clickNodeByText(arrayOf("语音通话"), 3)
rootInActiveWindow.clickNodeByText(arrayOf("视频通话"), 3)
一个语音通话,一个视频通话。 到这里我们就可以进行视频通话了。
注意事项
- 使用一个变量来标记当前需要做哪一步。
- onAccessibilityEvent回调比较频繁,有些老旧手机卡顿比较严重,发生回调时可能页面还没有展示出来,因此可以在回调后延迟一段时间时间执行。
// 当窗口发生的事件是我们配置监听的事件时,会回调此方法.会被调用多次
override fun onAccessibilityEvent(event: AccessibilityEvent) {
if (event.packageName == WX_PACKAGE_NAME && (event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED || event.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED)) {
MMKV.mmkvWithID(INVOKE_DAILY_TIME_MMKV_NAME).getInt(INVOKE_DAILY_TIME_MULTIPLE,1)
when (WeChatCallStepManager.step) {
Step_Start -> {
handler.removeMessages(Step_Start)
handler.sendEmptyMessageDelayed(Step_Start, 100 * timeMultiple.toLong())
}
Step_search_contact -> {
handler.removeMessages(Step_search_contact)
handler.sendEmptyMessageDelayed(Step_search_contact, 100 * timeMultiple.toLong())
}
Step_search_and_click_video -> {
handler.removeMessages(Step_search_and_click_video)
handler.sendEmptyMessageDelayed(Step_search_and_click_video, 100* timeMultiple.toLong())
}
Step_start_video_chat -> {
handler.removeMessages(Step_start_video_chat)
handler.sendEmptyMessageDelayed(Step_start_video_chat, 500* timeMultiple.toLong())
}
else -> {
Log.e("huangyuan", "当前步骤未进行 $WeChatCallStepManager.step")
}
}
}
}
总结
我们可以使用无障碍服务做一些有意思的事情,来帮助我们更好的使用各种软件。比如对于家里文化程度不高、不能熟练的使用智能手机的老人,打个电话、保存电话号码对他们来讲都是很困难的事情,更别提一些更复杂的软件了。
我们就可以将常用的软件功能集中在一个软件中,甚至可以做一个老人桌面,点击桌面上的某个按钮就能实现一键拨打微信视频、保存电话号码到通讯录、一键拨打电话等功能。
其他问题
上面的流程是最理想的状态,还有一些意料之外的问题:
比如我们视频通话结束后,需要返回到列表页,也就是在通话结束后点击左上角的返回,这个功能没有写。
比如打开微信的时候不是在首页,比如在浏览公众号信息怎么办?同样需要找到左上角的返回按钮,一直到首页之后才可以进行点击通讯录的操作。
比如联系人的名字就是单个英文字母,上面也提到,这种情况下查找到的会是分组的名称,无法进行点击。
另外一条路径:
或者我们可以从首页点击右上角的搜索,输入联系人名字,然后在搜索列表中点击联系人,进入到聊天页面,然后点击左下角加号,在更多菜单里面点击音视频通话也行。