什么是AccessibilityService?
在开始之前我们先了解一下 AccessibilityService是一个什么东西;AccessibilityService 是一种提供用户界面增强功能的应用程序,可以帮助残障用户或者暂时无法与设备进行完全交互的用户提供界面反馈,帮助用户更好的处理和响应事件。通俗一点来说就是可以帮助我们监听页面的变化,比如按钮的点击,页面的切换,页面内容的变化,通知的接收等,然后我们接收到这些事件之后可以进行我们想做的操作;并且接收到事件之后我们可以获取到事件触发的 View 以及其子 View 的一些信息和当前窗口根节点 View 及其子 View 的一些信息。
Android 从 1.6 就已经引入了 AccessibilityService ,并且在 Android 4.0 对其进行了改进,而在 Android 8.0 开始已经可以执行手势操作了。 所以对我们现在开发 AccessibilityService 已经是非常方便了,我们可以根据用户的操作来进行对应的反馈,并且我们可以自定义一个脚本流程,当用户触发时,我们就可以进行一系列操作,比如自动打开某个App的页面等;微信自动抢红包功能就是基于 AccessibilityService 来实现的。
创建我们的AccessibilityService
1.我们首先要创建一个Service继承自AccessibilityService
2.在 AndroidManifest.xml 中配置我们的 Service (需要指定一些特别属性来表明这是一个 AccessibilityService )
3.在 xml 中配置我们的 AccessibilityService 所监听的 App 包名、接收的事件类型等
我们来一一实现这些步骤,第一步很简单,直接创建一个类继承即可:
class MyAccessibilityService : AccessibilityService() {
override fun onInterrupt() {
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
}
override fun onServiceConnected() {
super.onServiceConnected()
}
}
我们实现了三个方法,onServiceConnected() 为服务可用时调用的方法,我们在 xml 里面的配置也可以写到这里;onInterrupt() 为 连接断开时调用的方法;onAccessibilityEvent(event: AccessibilityEvent?) 为接收到监听事件的方法,我们主要的处理逻辑也会写在这里面。
和普通的 Service 一样,这个也需要在 AndroidManifest.xml 中进行声明配置,我们需要添加<intent-filter> 来表示为 AccessibilityService,从 Android 4.0 开始,我们可以添加 <meta-data> 来指定对应的 xml 配置文件,在 xml 里面做一些我们这个 AccessibilityService 的配置。从 4.1 开始,我们还必添加 BIND_ACCESSIBILITY_SERVICE 权限来保护服务,确保只有系统才能够绑定到它。所以 Service 的配置如下:
<service
android:name="com.android.service.MyAccessibilityService"
android:enabled="true"
android:exported="true"
android:label="服务名字"
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> 所指定的 resource 则为 我们的配置文件名字,这个配置也是可以通过 setServiceInfo(AccessibilityServiceInfo info) 在代码中配置的,我们这里只在xml中进行配置举例来说明,accessibility_service_config.xml代码如下:
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeNotificationStateChanged|typeWindowStateChanged|typeWindowContentChanged|typeViewScrolled|typeViewClicked|typeViewFocused"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagDefault|flagIncludeNotImportantViews|flagRetrieveInteractiveWindows|flagReportViewIds"
android:canRetrieveWindowContent="true"
android:notificationTimeout="100"
android:description="这个是这个service的用途介绍"
android:packageNames="com.tencent.mm" />
我们来一一分析这些属性的作用:
accessibilityEventTypes 指定接收事件类型,typeAllMask代表全部都接收,其他的可以我们可以通过上述的名字来知晓其用途,我们上述监听的分别是 通知状态改变,页面切换,页面内容改变,View 滑动,View 点击,View 获取焦点时的事件监听。
accessibilityFeedbackType 操作相应按钮以后辅助功能给用户的反馈类型,包括声音(feedbackAudible),震动(feedbackHaptic)等。
accessibilityFlags 辅助功能查找截点方式,一般配置为flagDefault默认方式 flagIncludeNotImportantViews获取完整的view层级列表。
canRetrieveWindowContent 是否能获取活动窗口内容。
notificationTimeout 响应事件的间隔,单位为毫秒。
description 对服务的描述;就是用户在开启这个服务的时候提示的用语
packageNames 指定监听的应用程序的包名,包名可以监听多个,用 “,” 逗号分隔。我们这里监听的包名为 WeChat 的包名。
到此我们的 AccessibilityService 基本就已经配置完成了。下面我们只要运行我们的 App 然后到 无障碍里面打开 名字为 “服务名字” 也就是我们上面 AndroidManifest.xml 里面 配置的那个名字就可以接收到 WeChat 的事件了,将在我们 Service 的 onAccessibilityEvent 中 触发对应的 事件。
下面我们将使用通过监听 onAccessibilityEvent 来实现 WeChat 的好友信息复制功能,流程为:
1.跳转到 WeChat主页面
2.点击通讯录按钮
3.从第一个联系人点击进入详情页面
4.存储详情页面的信息
5.返回滑动至下一条,点击进入,如此往复
6.执行到最后一个 item 为 xxx位联系人结束。
首先我们要先跳转到 WeChat 的主页面:
private fun goWechat() {
//修改为状态为开始
MyAccessibilityService.scanContactsStatus = WeChatContactsController.WX_CONTACTS_SCAN_START
try {
val intent = Intent(Intent.ACTION_MAIN)
val cmp = ComponentName("com.tencent.mm", "com.tencent.mm.ui.LauncherUI")
intent.addCategory(Intent.CATEGORY_LAUNCHER)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.component = cmp
startActivity(intent)
} catch (e: ActivityNotFoundException) {
// TODO: handle exception
toast("检查到您手机没有安装 WeChat,请安装后使用该功能")
}
}
然后我们会在onAccessibilityEvent 中接收到 AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED 的事件,我们在此判断当前的页面为哪个页面,如果是 WeChat主页面,则执行 查找到 通讯录 按钮点击过去 :
if(scanContactsStatus == WeChatContactsController.WX_CONTACTS_SCAN_START){
//查找 通讯录 进行点击操作
parseSource(source)
scanContactsStatus = WeChatContactsController.WX_CONTACTS_SCANNING
}
parseSource() 方法 是使用递归查找 text 为 通讯录 的 node 对其parent 进行点击操作(通过使用 uiautomatorviewer 我们可以得到微信的点击事件放在了 text 的 parent 上)。
我们接收到的 event 的 View 信息都为 AccessibilityNodeInfo ,我们可以通过node.text 获取当前 View 的文本,如果没有文本,将会返回 null,最新 WeChat 版本的 聊天记录页面聊天信息已经全部换为自定义 View 实现,我们是获取不到文本内容的,parseSource 代码如下:
private fun parseSource(source: AccessibilityNodeInfo?){
if(source == null) return
val sCount = source.childCount
for (i in 0 until sCount){
val info = source.getChild(i)
if (info != null) {
parseSourceInfo(info)
}
}
}
private fun parseSourceInfo(info : AccessibilityNodeInfo, tag : String? = "") {
val count = info.childCount
if (count == 0) {
if(info.className == "android.widget.TextView" && info.text != null && info.parent.className == "android.widget.LinearLayout"){
val name = info.text.toString()
if(name == "通讯录" && scanContactsStatus == WeChatContactsController.WX_CONTACTS_SCAN_START){
//获取点击事件的
Handler().postDelayed({
info.parent.parent.performAction(AccessibilityNodeInfo.ACTION_CLICK)
},100)
}
}
return
}
for (i in 0 until count){
val v = info.getChild(i)
if(v != null) parseSourceInfo(v,tag)
}
}
然后点击之后我们会在 AccessibilityEvent.TYPE_VIEW_CLICKED 收到响应,我们需要在此时响应中分析是否是通讯录的点击事件,如果是的话我们会将获取当前 rootInActiveWindow 的 AccessibilityNodeInfo,并且得到当前通讯录页面的 ListView Node :
AccessibilityEvent.TYPE_VIEW_CLICKED -> {
val source = event.source ?: return
//判断是否是 通讯录 点击过来的
val contactClick = analysisClickTextClass(source)
if(contactClick){
//通过 uiautomatorviewer 分析 viewpager 的包名 找到 viewpager 第二页 为通讯录页面
val vxViewpager = searchWxViewPager(rootInActiveWindow) ?: return
//将 list 赋为 当前的 通讯录 listView
parseContact(vxViewpager.getChild(1))
//记录当前滑动的下标
lastClick = list?.childCount?:0
//滑动到可以滑动的位置
scrollListView(list,lastClick)
//找到 ListView 的 item 进行点击操作
Handler().postDelayed({
getListInfo(list)
}, clickDelay)
}
}
private fun analysisClickTextClass(source: AccessibilityNodeInfo?,
analysisText : String? = "通讯录"): Boolean{
if(source == null) return false
val sCount = source.childCount
for (i in 0 until sCount){
val info = source.getChild(i)
if (info != null) {
if(analysisClickTextClass(info)){
return true
}
}
}
val text = source.text
if(text == analysisText){
//知道是 analysisText 点击的 返回 true
return true
}
return false
}
private fun searchWxViewPager(source: AccessibilityNodeInfo) : AccessibilityNodeInfo? {
//查找 view pager 返回
return if(source.className == "com.tencent.mm.ui.mogic.WxViewPager") {
source
}else {
val sCount = source.childCount
for (i in 0 until sCount){
val info = source.getChild(i)
if (info != null) {
return searchWxViewPager(info)
}
}
null
}
}
private fun parseContact(source: AccessibilityNodeInfo?) {
if(source == null) return
val sCount = source.childCount
//没有子view
if (sCount == 0) {
return
}else if(source.className == "android.widget.ListView"){
//此 list 为 通讯录页面的 list
list = source
}
for (i in 0 until sCount){
val info = source.getChild(i)
if (info != null) {
parseContact(info)
}
}
}
private fun scrollListView(source : AccessibilityNodeInfo?,position : Int) : Boolean{
if(source == null) return false
if(source.className != "android.widget.ListView") return false
//执行滑动事件,滑动到对应的item
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val bundle = Bundle()
bundle.putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_ROW_INT,position)
val b = source.performAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_TO_POSITION.id,bundle)
return b
}
return false
}
然后就是重复找到 ListView 对应的 item 进行点击 然后在滑动即可,查找的逻辑如下:
private fun getListInfo(list : AccessibilityNodeInfo?){
if(list == null) return
val count = list.childCount
var clickItem = lastClick % count
//如果到最底部了这里需要加加,滑动的时候只需要每次点击第一个 item 即可
if(lastClick > count){
clickItem = endPosition
}
if(endPosition == count){
//结束
scanContactsStatus = WeChatContactsController.WX_CONTACTS_SCAN_END
return
}
searchClickItem(list,clickItem,count)
}
private fun searchClickItem(list : AccessibilityNodeInfo, position : Int, count : Int){
var item = list.getChild(position)
if(item != null){
//解析list 判断是否到结尾了
val lCount = list.childCount
val endItem = list.getChild(lCount - 1)
if(endItem?.className == "android.widget.FrameLayout"
&& endItem.getChild(0)?.className == "android.widget.FrameLayout"){
endPosition++
}
}
if(item != null){
counter = 0
val cCount = item.childCount
val text = try {
item.getChild(cCount - 1)?.getChild(0)?.getChild(0)?.getChild(1)?.text
}catch (e : Exception){
""
}
//防止点击到的是头部item,过滤特定的 item
if(item.getChild(item.childCount - 1)?.className == "android.widget.RelativeLayout"
|| text == "微信团队" || text == "文件传输助手"){
searchClickItem(list,(position + 1) % count,count)
}else if(item.isClickable){
Handler().postDelayed({
ccName = try {
item.getChild(cCount - 1)?.getChild(0)?.getChild(0)?.getChild(1)?.text?.toString()?:""
}catch (e : Exception){
""
}
val b = item.performAction(AccessibilityNodeInfo.ACTION_CLICK)
if(!b){
scrollListView(list,lastClick--)
getListInfo(list)
}
},clickDelay)
}
} else {
counter++
if(counter > count){
//没有找到可以点击的按钮
return
}
//查找下一个可点击的view
searchClickItem(list,(position + 1) % count,count)
}
}
点击后,我们将收到 AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED 事件,此时页面切换到了联系人详情页面,我们只要解析此页面的 nodeInfo,即可得到联系人的信息:
if(event.className == "com.tencent.mm.plugin.profile.ui.ContactInfoUI"){
if(KeyboardService.scanContactsStatus != WeChatContactsController.WX_CONTACTS_SCANNING){
return
}
//联系人详情页面
val model = WxContactEntity(id = lastClick, remarkName = ccName)
parseContactInfo(source,model)
}
private fun parseContactInfo(source: AccessibilityNodeInfo,model : WxContactEntity){
val count = source.childCount
if(count == 0){
//找一下back键
val text = source.text?.split(": ")
when(text?.get(0)){
"昵称" ->{
val nickname = text[1]
model.nickname = nickname
}
"微信号" -> {
val wxId = text[1]
model.wxId = wxId
}
}
if(source.contentDescription == "返回"){
val back = source.parent
//找到返回按钮 返回上一个页面
Handler().postDelayed({
back.performAction(AccessibilityNodeInfo.ACTION_CLICK)
},clickDelay)
}
return
}
for (i in 0 until count){
val item = source.getChild(i)
if(item != null){
parseContactInfo(item,model)
}
}
}
存储完成后我们只需要找到返回按钮 返回上一个页面即可。返回后又会触发 AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED 所以我们需要在此重复我们的操作即可:
if(event.className == "com.tencent.mm.ui.LauncherUI"){
if(scanContactsStatus == WeChatContactsController.WX_CONTACTS_SCAN_START){
//查找 通讯录 进行点击操作
parseSource(source)
scanContactsStatus = WeChatContactsController.WX_CONTACTS_SCANNING
}
if(lastEventName != "com.tencent.mm.ui.LauncherUI"){
Handler().postDelayed({
val b = scrollListView(list,lastClick++)
if(!b){
scrollListView(list,lastClick++)
}
},300)
Handler().postDelayed({
getListInfo(list)
}, clickDelay)
}
lastEventName = event.className?.toString()
}
由于 ListView 的缓存原理,所以这里我们得到的 list 只是屏幕上的几个 View ,所以我们还需要在滑动的时候将 新的 ListView 的item 赋值给我们的 list 变量,这样我们每次遍历我们的 list 的时候 就可以得到当前页面的 View 的 list 了。滑动的时候我们会接收到 AccessibilityEvent.TYPE_VIEW_SCROLLED 事件:
AccessibilityEvent.TYPE_VIEW_SCROLLED -> {
if(event.className != "android.widget.ListView"
|| scanContactsStatus != WeChatContactsController.WX_CONTACTS_SCANNING) return
//滑动时候的监听
val source = event.source ?: return
list = source
}
自此,一个完整的流程就已经结束了。通过监听 AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED、AccessibilityEvent.TYPE_VIEW_CLICKED、AccessibilityEvent.TYPE_VIEW_SCROLLED 完成了我们所有的操作。
此方式仅供学习交流使用,在此只是为了学习具用 WeChat 的例子。