3

一、序

Hi,大家好,我是承香墨影!

Android 的辅助模式(Accessibility)功能非常的强大。基本上被获取到授权之后,可以监听手机上的任何事件,例如:屏幕点击、窗口的变化、以及模拟点击、模拟系统按键等等。

比较常见的实际使用例子,就是一般应用市场,会推荐开启辅助模式,以便在安装 Apk 的时候,自动帮你点击“下一步”和“安装”按钮。还有个例子就是微信抢红包插件,也是基于它来实现的。

Accessibility 的权限非常的高,基本上你授权开启某个别人提供的 AccessibilityService 之后,他就可以干很多事情而不让你知道,而这些是不需要 Root 权限的。所以一般小体量的产品,可能支持它并没有什么用,因为信任度太低了,大部分用户根本不会打开。比较常见的就是一些工具类的 App,帮用户节省一些点击的时间。

虽然很多时候,Accessibility 不会被用在商业产品上,但是这并不妨碍我们使用 Accessibility 来做一些有意思的功能。

二、辅助模式的使用步骤

辅助模式是可以支持第三方开发,也就是我们可以按照文档对其进行支持,只要用户授权开启此服务,我们就可以利用 Accessibility 提供的一些标准 Api 实现很多有意思的功能。

如果你想要使用辅助模式,你还需要如下步骤:

  1. 实现一个继承自 AccessibilityService 的服务类。
  2. 设定配置信息,以便系统知道该辅助模式的一些基本信息,例如监听那些事件。
  3. 在清单文件(AndroidManifest.xml)中,注册此服务。
  4. 在系统设置中,找到“无障碍”,并开启此服务。

接下来我们一步一步讲解这里的步骤和细节。

2.1 继承 AccessibilityService

辅助模式,本质上还是一个服务,我们如果想要支持它,首先需要继承 AccessibilityService 这个类。

AccessibilityService 类提供了很多需要重写的方法,其中有两个是强制重写的:

public abstract void onAccessibilityEvent(AccessibilityEvent event);
public abstract void onInterrupt();

当开启了某个 AccessibilityService 服务之后,系统会在该服务监听的事件发生的时候,回调它的 onAccessibilityEvent() 方法,并将该事件的信息当参数传递过去,如果你监听的事件足够多,它就会被频繁调用。

onInterrupt() 方法会在系统事件被打断的时候回调,也是会被频繁调用,一般我们不需要做额外处理。

通常我们只需要在 onAccessibilityEvent() 方法中,编写核心逻辑即可,其他的方法,只是辅助使用。

2.2 配置辅助模式

当创建一个 AccessibilityService 之后,我们还需要对其进行一些基本的配置,否则在系统设置的“无障碍”中,是看不到我们编写的服务的。

配置 AccessibilityService 有两种方式,

  • 通过 xml 配置文件
  • 通过 Java 代码中动态配置。

但是其实有一些属性是只能通过 XML 配置文件进行配置的,Java 代码只是让某一些配置项更灵活了而已,后面会细说。

1、xml 配置文件

想要使用 XML 配置文件,首先需要创建一个 res/xml 的目录,并在其内创建一个 xml 文件,文件名随意无要求,内部定义一个 accessibility-service 标签,在其中设定 AccessibilityService 的各项配置。例如我这里创建一个 accessibility_config.xml 的文件,后面会用到这个文件。

XML 配置 AccessibilityService 是我们一个比较常用的配置方法,非常清晰且方便。

<accessibility-service
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:accessibilityEventTypes="typeAllMask"
    android:accessibilityFeedbackType="feedbackAllMask"
    android:accessibilityFlags="flagReportViewIds"
    android:canRetrieveWindowContent="true"
    android:packageNames="com.forwarding.wechat"
    android:description="@string/accessbility_desc"
    android:notificationTimeout="100" />

例如上面就是一个常见的配置,如果没有特殊要求的话,直接复制过去,修改一些个别参数就可以使用。

各项属性的含义:

  • accessibilityEventTypes:监听的事件类型,例如:typeAllMask 表示全部事件,而 typeViewClicked 表示只监听点击事件。
  • accessibilityFeedbackType:监听事件的反馈模式。
  • canRetrieveWindowContent:是否允许获取视图层级的访问权,如果它被设置为 false,node.getSource() 方法会调用失败。
  • accessibilityFlags:指定 Flag,一般用于指定根据 Node 获取 View ID 的权限。
  • packageNames:开启监听的应用包名,可以指定多个包名,通过逗号“,”分割,不设置此属性标识全局监听。
  • description:辅助功能的描述,它会显示在系统设置的“无障碍”中的描述信息中。
  • notificationTimeout:响应的毫秒数。

这些可配置的参数,系统都提供了可选的配置参数,正常不需要额外定制的时候,使用上面默认的配置即可,如果有定制需要,还是查阅官方文档获得最全的介绍。

AccessibilityService:

https://developer.android.com...

2、Java 代码中动态配置

除了 XML 文件配置的方式,我们还可以通过重写 AccessibilityService 的 onServiceConnected() 方法,我们首先需要构建一个 AccessibilityServiceInfo 对象,通过它的标准 Api 进行配置,再使用 setServiceInfo() 方法将它设置给辅助模式。

onServiceConnected() 会在应用成功连接到此辅助服务的时候系统调用,一般在其中做一些初始化的操作即可。

override fun onServiceConnected() {
    super.onServiceConnected()
    var serviceInfo = AccessibilityServiceInfo()
    serviceInfo.eventTypes = AccessibilityEvent.TYPES_ALL_MASK
    serviceInfo.feedbackType = AccessibilityServiceInfo.FEEDBACK_ALL_MASK
    serviceInfo.notificationTimeout = 100
    serviceInfo.packageNames = arrayOf("com.forwarding.wechat")
    serviceInfo.flags =  AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS
    setServiceInfo(serviceInfo)
}

这里提供的例子,其实和前面使用 XML 配置的效果一直。推荐使用 XML 的配置方式,会更清晰且灵活,而且像 description 这种属性,在 AccessibilityServiceInfo 中,并没有提供有效的类似 setDescription() 方法,这一点也确实是设计如此,毕竟服务没有运行,就不存在描述信息,在系统设置的“无障碍”页面,就读取不到。

也就是说即便是使用 setServiceInfo() 方法动态设置,也逃不脱使用 XML 配置文件的方式,我还是强烈建议都使用 XML 配置文件的方式配置辅助服务,主要是为了省事。

2.3 清单文件中注册服务

本质上 AccessibilityService 还是一个 Service,使用它我们还需要在清单文件中配置它。

<service android:label="承香墨影的辅助工具"
    android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
    android:name=".WeForwardServer">
    <intent-filter>
        <action android:name="android.accessibilityservice.AccessibilityService"/>
    </intent-filter>
    <meta-data
        android:name="android.accessibilityservice"
        android:resource="@xml/accessibility_config"/>
</service>

这就是一个标准的 Service,其中 label 会被解析在系统设置的“辅助模式中显示”,而 intent-filtermeta-data 按照格式写就好了,没什么原因。

meta-data 中,通过 android:resource 属性指定的就是我们在第二步编辑的配置文件路径,指定它就好了。

2.4 开启辅助模式

以上步骤都完成之后,你就可以在系统的“无障碍”设置里,看到你编写的辅助模式的开关了。

默认为关闭状态,打开它的时候,你会收到一个警告弹窗,说明当前你正在开启一个无障碍的服务,它有哪些权限,这个对话框,我们是控制不了的。

open-accessibility

注意这里的 Title 就是清单文件里配置的 android:label ,而描述就是 XML 配置文件里的 android:description 信息。

当你在系统设置里,能看到此开关的时候,就说明你的辅助模式的服务,配置的没问题了,接下来就要思考如何使用它。

三、编写逻辑代码

前面提到,在 AccessibilityService 里,我们最需要关注的就是 onAccessibilityEvent() 方法,它会在我们监听的事件发生的时候,被系统回调,并传递过来该事件相关的信息。

接下来我们看看如何在 onAccessibilityEvent() 回调方法里,编写具体的逻辑。

接下来 "程序员思维" 要上线了,把大象关冰箱,需要几步。我们接下来来拆分辅助模式的步骤。

  1. 判断事件,onAccessibilityEvent() 会被回调多次,而我们只需要处理我们关心的事件,其他的忽略过滤掉即可。
  2. 找到需要控制的关键节点(Node),以便之后进行控制。
  3. 对关键节点,发送对于的操作事件,以便完成我们的步骤。
  4. 回收资源,防止资源泄露。

很简单对不对,接下来我们细细的说下,这些步骤相关的方法和属性。

3.1 判断事件

onAccessibilityEvent() 被系统回调的时候,同时也会传递过来一个 AccessibilityEvent 对象,它其中包含了很多与当前事件相关的信息,有兴趣可以看看源码,我们这里只关注最需要的几个属性。

1、eventType 判断事件类型

通过 eventType 来判断事件的类型,我们可以利用 getEventType() 方法获取到它。

这些事件都很好辨认,例如:TYPE_NOTIFICATION_STATE_CHANGED 是一个窗口 View 发生了变化,TYPE_VIEW_CLICKED 是某个 View 发生了一次点击事件等等。

2、packageName 判断事件发生的 App

通过 getPackageName() 方法,判断出事件发生在那个 App 里的。

3、className 判断当前发生事件的是那个类

通过 getClassName() 判断当前发生事件的是那个类,例如 页面的显示,className 可能指向一个 Activity,一个按钮的点击,className 可能指向的是一个 Button,这些都是根据实际场景区分的。

4、text 判断当前事件触发源上的 Text

通过 getText() 获取当前事件源的 text 属性,可能是 TextView 的 Text,也可能是 Activity 的 Label 属性,依然是根据实际情况区分。

一般我们可以通过以上几种方式,猜测是否是我们需要监听的事件,下一步就是我们找到我们要操作的源。

3.2 找到待控制的关键节点(Node)

通常我们是使用辅助模式去操作页面上的某个元素,那这一步,就是为了找到它。

在辅助模式下,页面上的每个元素,其实都是一个个 AccessibilityNodeInfo 节点,它是一个类似树形的结构,其内和我们真实 App 内的布局层级是一致的,但是并不能将它单纯的理解成一个 ViewTree。

既然是树形结构,我们首先要获取到根节点的 NodeInfo,可以通过以下两个方式获取:

  • event.getSource()
  • getRootInActiveWindow()

这两个方法都会返回一个 AccessibilityNodeInfo 对象。getSource() 是AccessibilityEvent 的方法,它可用的前提是前面配置 android:canRetrieveWindowContent 的时候,被设置为 True。所以我推荐使用 getRootInActiveWindow() 方法来获取。这两个方法还是略微有些差异,有兴趣可以打断点看看信息,但是大多数情况下,对我们使用者来说是一致的。

获得根节点的 AccessibilityNodeInfo 之后,就可以通过它找到我们想操作的关键节点,在 AccessibilityNodeInfo 中,提供了以下两个方法来找到关键节点。

  • findAccessibilityNodeInfosByViewId(String viewId)
  • findAccessibilityNodeInfosByText(String text)

一个是依赖 ViewId,另外一个是依赖 Text 信息。

使用 ViewId 查找关键节点是稳妥的方案,而使用 Text 去查找,可能会找不到。

无论通过哪种方式查找 关键节点 ,都是存在能找到多个 NodeInfo 的可能的,所以这两个方法干脆的都返回了一个 List<AccessibilityNodeInfo> ,所以需要我们通过其他条件再过滤一遍,通常就是通过 Text 信息过滤。

var mNodeInfo = rootInActiveWindow
var listItem = mNodeInfo.findAccessibilityNodeInfosByViewId("com.tencent.mm:id/lp")
for (item in listItem) {
    if (item.text.toString().equals("承香墨影")){
        nodeClick(item)
    }
}

如果是使用 findXxxByText() 的方法的话,还需要注意它实际上不是通过类似 == 或者 equals() 的方法来查找子节点的,而是通过类似 contain() 的方式,所以只要节点的 text 属性包含查找的内容,都会被找到,这个我们额外还需要增加判断条件。

如果这些方法都试过,还是找不到关键节点,可以通过遍历的方式查找。

AccessibilityNodeInfo 既然是一个树状结构,也提供了我们遍历树的方法。

  • getParent():查找父节点。
  • getChild():返回子节点。
  • getChildCount():当前节点的子节点个数。

通过 getChild()getChildCount() 两个方法,我们是可以对整个 ViewNodeTree 进行遍历,来找到我们关注的关键节点,这是一个最后的方案,并不推荐使用。

3.3 触发事件

辅助模式一般都是帮助我们响应一些事件,而这些事件大体上,可以分为两类。

  • 全局系统事件。
  • View 事件。

对于全局系统事件,其实我们并不需要第二步找到的关键节点。AccessibilityService 提供了一个 performGlobalAction() 方法,我们可以通过该方法,操作一些全局的系统事件,例如:模拟返回键点击、模拟 HOME 键点击、锁屏等等。

// 返回键
performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK);
// HOME键
performGlobalAction(AccessibilityService.GLOBAL_ACTION_HOME);

这些事件被封装在 AccessibilityService 中,以 GLOBAL_ 为前缀,看看属性说明就懂了。

除了全局系统事件之外,通常我们就是想操作第二步拿到的关键节点。

在 AccessibilityNodeInfo 中,提供了一个 performAction() 的方法,可以通过该方法,对关键节点传递一个我们需要的事件。

这些事件都被定义在 AccessibilityNodeInfo 中,以 ACTION_ 为前缀定义。例如:ACTION_CLICK 是一个点击事件,ACTION_SET_TEXT 设置一个输入。

这里仅介绍一些比较常见的操作,更多的操作也是类似的使用方式。

1. View 的点击

找到关键节点之后,就可以发送 AccessibilityNodeInfo.ACTION_CLICK 模拟对这个 View 的点击操作。

但是有时候它是不生效的,主要原因是因为你找到的这个关键节点,它的 isClickable() 为 false。

例如微信的这个公众号分享弹窗,如果我们想要查找“发送给朋友”,其实最好的办法是找到这个 TextView 控件所代表的关键节点(NodeInfo),然后对它进行点击。而实际上这个 TextView 是不具有点击效果的,它的 isClickable() 为 false。

这个时候可以想一个折中的方案,去找关键节点(NodeInfo)的父节点,再去判断它是否可点击,可点击则点击它,否则继续向上找。

private fun nodeClick(node : AccessibilityNodeInfo?){
    var clickNode = node;
    while (clickNode!=null){
        if(clickNode.isClickable){
            clickNode.performAction(AccessibilityNodeInfo.ACTION_CLICK)
            break;
        }
        clickNode = node?.parent
    }
}

虽然 AccessibilityNodeInfo 其实也开放了 setClickable() 方法,但是我不建议操作它,有些时候会抛出一个异常,不太稳定。

2. EditText 输入文字

对 EditText 输入文字,最少需要两个参数,关键节点和输入的文字。这就需要用到 performAction() 的另外一个重载方法,它允许额外在传递一个 Bundle 来指定参数。

private fun nodeSetText(node : AccessibilityNodeInfo?,text:String){
    var argument = Bundle()
    argument.putString(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE,text)
    node?.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT,argument)
}

所有支持定义的额外参数,都被定义在 AccessibilityNodeInfo 中,并以 ACTION_ARGUMENT_ 为前缀定义。

3. ListView 的滚动

AccessibilityNodeInfo 其实只能操作当前屏幕下可见的 节点,所以碰上 ListView 或者 RecycleView 这种列表,就需要对它进行滚动。

滚动的事件有两种:

  • ACTION_SCROLL_FORWARD
  • ACTION_SCROLL_BACKWARD
private fun nodeScrollList(node : AccessibilityNodeInfo?){
    node?.performAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD)
}

一个前进一个后退,足够使用了。

3.4 回收资源

在使用完 AccessibilityNodeInfo 之后,别忘了还需要调用 recycle() 方法,释放资源。

nodeInfo.recycle();

四、小结

辅助模式如何使用,到现在已经讲解的非常清楚了,后面基本上就是靠自己的想象力来做小功能了。

利用辅助模式,发挥想象力,你也可以做出很多有意思的功能。


公众号后台回复成长『成长』,将会得到我准备的学习资料,也能回复『加群』,一起学习进步;你还能回复『提问』,向我发起提问。

推荐阅读:


plokmju88
1.5k 声望132 粉丝

大家好,我是承香墨影。