简单封装AccessibilityService写个库,助力Android自动化

6,344 阅读9分钟

看过杰哥专栏的童鞋,应该都知道 无障碍服务AccessibilityService 的作用:通过APP控制Android设备自动化,不了解的童鞋可以先翻阅下《AccessibilityService基础》

之前百无聊赖的时候随手写了这个库,本节主要是记录库的实现思路,会涉及到开发无障碍服务常见的一些问题,相信会对需要的读者有帮助~


0x1、为什么要写这个库?

  • 时常有APP自动化的需求 → 自己爬数据、做各种APP日常、接单子等;
  • 每次都得CV大量代码 → 没有啥复用性可言,开发效率极低;
  • 急需一个趁手兵器 → 利用Kotlin简洁的语法特性,封装常用代码逻辑暴露简单API,达成快速开发的目的;

当然,也不是非得自己写个库,也可以用别人封装得比较好的方案,比如 autojs,省时省力好多。自己写库最大的好处就是:可定制,想加什么功能就加什么功能,比如加个截图OCR啥的~


0x2、库设计要点

使用AccessibilityService完成APP自动化三个主要的核心步骤如下:

  • 获取页面结点
  • 解析定位结点
  • 触发交互

所以,这个库要做的事情就是围绕着这三步展开,然后封装,接着细化下开发要点~


1、判断无障碍服务是否开启

常规的判断方式:判断无障碍功能是否可用获取启用的无障碍服务字符串拆分成多个子串迭代判断是否包含我们服务的包名。代码示例如下:

fun Context.isAccessibilitySettingsOn(clazz: Class<out AccessibilityService?>): Boolean {
    // 判断设备的无障碍功能是否可用
    var accessibilityEnabled = false
    try {
        accessibilityEnabled = Settings.Secure.getInt(
            applicationContext.contentResolver,
            Settings.Secure.ACCESSIBILITY_ENABLED
        ) == 1
    } catch (e: Settings.SettingNotFoundException) {
        e.printStackTrace()
    }
    // 创建一个字符串拆分工具实例
    val mStringColonSplitter = TextUtils.SimpleStringSplitter(':')
    if (accessibilityEnabled) {
        // 获取启用的无障碍服务
        val settingValue: String? = Settings.Secure.getString(
            applicationContext.contentResolver,
            Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES
        )
        if (settingValue != null) {
            // 迭代判断是否包含我们的服务
            mStringColonSplitter.setString(settingValue)
            while (mStringColonSplitter.hasNext()) {
                val accessibilityService = mStringColonSplitter.next()
                if (accessibilityService.equals("${packageName}/${clazz.canonicalName}", ignoreCase = true))
                    return true
            }
        }
    }
    return false
}

但这里,我们不采用这种方法,而是通过:定义一个 抽象的AccessibilityService父类 + 单例 来实现:

在子类无障碍服务绑定回调 onServiceConnected() 时实例化,在服务销毁回调 onDestroy() 时清空。即如果 instance 有值,说明已经无障碍功能可用,简单粗暴的判断。

另外,这里的 specificServiceClass 是具体子类无障碍服务的类类型,定义了一个 init() 方法用于初始化,在这里传入,有些地方要用到。

该方法需要在服务启用前调用,比如放到App类中:


2、跳转无障碍服务设置页

判断服务未启用,然后跳转设置页授权,没啥好讲的,直接封装个工具方法:

然后在父类里定义一个请求无障碍权限的静态方法:

简单调用下,在onResume()的时候判断服务状态,分别显示开启与未开启的UI:

点击开启的时候,同样判断服务是否开启,是提示,否则跳转设置页:

运行看下效果(部分手机可能不会自动定位我们的APP,还要再操作下~):

效果还是挺骚气的,继续往下走~


3、获取页面节点信息

有三个可选方法,依次讲解:

AccessibilityEvent.getSource()

获取触发当前 AccessibilityEvent 事件的 源View,比如监听到按钮点击事件,这个方法拿到的就是触发点击的View。

AccessibilityService.getRootInActiveWindow()

获取当前 活动窗口或前台界面的根视图,对应DecorView,通过该视图可以获得界面所有View树来进行遍历操作。

AccessibilityService.getWindows()

获取显示在屏幕上的 所有窗口列表,包括Activity、Dialog、Toast 等窗口,可以 根据窗口ID 或名称获取指定的窗口

一般来说,方法②用得比较多,但在有 悬浮框 的场景(即目标APP不是处于前台),就需要用到方法③了。如根据title获取微信的窗口,并调用getRoot()方法获得它的AccessibilityNodeInfo:

accessibilityService.windows.first { it.title == "微信" }.root

接着是 获取页面节点信息 的时机,一般都是重写 AccessibilityService.onAccessibilityEvent() 方法,按需处理 系统发送过来的Event,这是 被动 的状态,而有时我们需要 主动 获取当前页面的结点信息。那该怎么实现呢?一种方式就是 自己构造Event,代码示例:

运行后上述代码后,发现Event发出去了,但却没有回调 onAccessibilityEvent() 方法。因为这个Event是你的App发出的,而非监听的目标APP(比如微信),还需要进行两项修改:

无障碍服务的配置xml文件 → 添加自己APP的包名

event指定packageName名

然后就可以收到自己发送的Event啦。另外,因为我们定义的 FastAccessibilityService 是一个单例,完全可以直接暴露一个静态方法,供外部直接调用,直接省去发送Event这一步。


4、解析节点

本质就是 递归遍历View树,这里定义一个结点包装类,提取节点的常用属性,方便处理:

写下遍历节点的方法:

解析结点是一种 耗时操作,如果直接在主线程执行,频繁调用可能会引起 界面卡顿,这里把解析操作丢到线程池里:

把结点都保存到list中,方便后续定位结点处理:


5、定位节点

这一步的话,就是按需遍历上面解析出来的结点列表,封装了下述可供调用的方法:

/**
 * 根据文本查找结点
 *
 * @param text 匹配的文本
 * @param textAllMatch 文本全匹配
 * @param includeDesc 同时匹配desc
 * @param descAllMatch desc全匹配
 * @param enableRegular 是否启用正则
 * */
findNodeByText(
    text: String,
    textAllMatch: Boolean = false,
    includeDesc: Boolean = false,
    descAllMatch: Boolean = false,
    enableRegular: Boolean = false,
): NodeWrapper? { /*...*/ }


/**
 * 根据文本查找结点列表
 *
 * @param text 匹配的文本
 * @param textAllMatch 文本全匹配
 * @param includeDesc 同时匹配desc
 * @param descAllMatch desc全匹配
 * @param enableRegular 是否启用正则
 * */
fun AnalyzeSourceResult.findNodesByText(
    text: String,
    textAllMatch: Boolean = false,
    includeDesc: Boolean = false,
    descAllMatch: Boolean = false,
    enableRegular: Boolean = false,
): AnalyzeSourceResult { /*...*/ }

/**
 * 根据id查找结点 (模糊匹配)
 * @param ids 结点id,可传入多个
 * */
fun AnalyzeSourceResult.findNodeById(vararg ids: String): NodeWrapper? { /*...*/ }

/**
 * 根据id查找结点列表 (模糊匹配)
 * @param ids 结点id, 可传入多个
 * */
fun AnalyzeSourceResult.findNodesById(vararg ids: String): AnalyzeSourceResult { /*...*/ }

/**
 * 根据传入的表达式结果查找结点
 * @param expression 匹配条件表达式
 * */
fun AnalyzeSourceResult.findNodeByExpression(expression: (NodeWrapper) -> Boolean): NodeWrapper? { /*...*/ }

/**
 * 根据传入的表达式结果查找结点列表
 * @param expression 匹配条件表达式
 * */
fun AnalyzeSourceResult.findNodesByExpression(expression: (NodeWrapper) -> Boolean): AnalyzeSourceResult { /*...*/ }

/**
 * 查找所有文本不为空的结点
 * */
fun AnalyzeSourceResult.findAllTextNode(includeDesc: Boolean = false): AnalyzeSourceResult { /*...*/ }

6、触发交互

节点找到了,接着就是 触发交互 的封装了,先是:performGlobalAction() 全局操作 的相关API:

fun performAction(action: Int) = FastAccessibilityService.require.performGlobalAction(action)

// 返回
fun back() = performAction(AccessibilityService.GLOBAL_ACTION_BACK)

// Home键
fun home() = performAction(AccessibilityService.GLOBAL_ACTION_HOME)

// 最近任务
fun recent() = performAction(AccessibilityService.GLOBAL_ACTION_RECENTS)

// 电源菜单
fun powerDialog() = performAction(AccessibilityService.GLOBAL_ACTION_POWER_DIALOG)

// 通知栏
fun notificationBar() = performAction(AccessibilityService.GLOBAL_ACTION_NOTIFICATIONS)

// 通知栏 → 快捷设置
fun quickSettings() = performAction(AccessibilityService.GLOBAL_ACTION_QUICK_SETTINGS)

// 锁屏
@RequiresApi(Build.VERSION_CODES.P)
fun lockScreen() = performAction(AccessibilityService.GLOBAL_ACTION_LOCK_SCREEN)

// 应用分屏
fun splitScreen() = performAction(AccessibilityService.GLOBAL_ACTION_TOGGLE_SPLIT_SCREEN)

// 休眠
fun sleep(millis: Long) = Thread.sleep(millis)

然后是 结点的交互操作,现在很多APP都做了防护,调用 节点的performAction() 方法执行点击、滑动等操作,基本是不管用的,这里使用 手势 来进行模拟。同时加入 随机偏移随机延时 来避免风控检测。

/**
 * 使用手势模拟点击,长按的话,传入的Duration大一些就好,比如1000(1s)
 *
 * @param x 点击坐标点的x坐标
 * @param y 点击坐标点的y坐标
 * @param delayTime 延迟多久进行本次点击,单位毫秒
 * @param duration 模拟触摸屏幕的时长(按下到抬起),太短会导致部分应用下点击无效,单位毫秒
 * @param repeatCount 本次点击重复多少次,必须大于0
 * @param randomPosition 点击位置随机偏移距离,用于反检测
 * @param randomTime 在随机参数上加减延时时长,有助于防止点击器检测,单位毫秒
 *
 * */
fun click(
    x: Int,
    y: Int,
    delayTime: Long = 0,
    duration: Long = 200,
    repeatCount: Int = 1,
    randomPosition: Int = 0,
    randomTime: Long = 0
) { /*...*/ }

/**
 * 使用手势模拟滑动
 *
 * @param startX 滑动起始坐标点的x坐标
 * @param startY 滑动起始坐标点的y坐标
 * @param endX 滑动终点坐标点的x坐标
 * @param endY 滑动终点坐标点的y坐标
 * @param duration 滑动持续时间,一般在300~500ms效果会好一些,太快会导致滑动不可用
 * @param repeatCount 滑动重复次数
 * @param randomPosition 点击位置随机偏移距离,用于反检测
 * @param randomTime 在随机参数上加减延时时长,有助于防止点击器检测,单位毫秒
 * */
fun swipe(
    startX: Int,
    startY: Int,
    endX: Int,
    endY: Int,
    duration: Long = 1000L,
    repeatCount: Int = 1,
    randomPosition: Int = 0,
    randomTime: Long = 0
) { /*...*/ }

/* 向前、向后滑动一段距离 */
fun NodeWrapper?.forward(isForward: Boolean = true) { /*...*/ }
fun NodeWrapper?.backward() = forward(false)

/* 文本填充 */
fun NodeWrapper?.input(content: String) { /*...*/ }

7、前台服务

保活,2333,懂的都懂,就是在Notification显示一个前台服务,直接封装显示和关闭前台服务的API

简单调用下:

运行后看看效果:


8、写一个解析页面节点的悬浮框

定位结点,需要知道目标节点的要素:id、文本等,可以用 《AccessibilityService基础-节点查找》 里提到的集中方法来分析。而这里可以直接写一个悬浮框,点击就解析当前页面节点,直接在Logcat输出。

运行后会出现一个小的悬浮框:

点击悬浮框后,logcat会输出所有TextView的节点信息:

舒服~


0x3、简单使用示例:朋友圈自动点赞

逻辑很清晰:找到并点击定位的图标 → 点击赞 → 往上滑动,直接写出代码~

库整体使用起来还是非常简单的,当然实现得比较 粗糙,后续有时间再慢慢优化,感兴趣的可以先Star → CpFastAccessibility