无障碍增强工具类,助力高效开发

前言

本文是上一次封装类的升级版本(点击这里查看),两个版本的唯一区别是本次升级版加入了超时检索控件功能!如果你用过 Selenium,AutoJS 等自动化工具你就会知道这个功能有多重要了!然而安卓原生的 AccessibilityService 并没有实现类似的功能(如 findOnce 和 findOne(timeout) ),在各大开源平台也没有找到相关的代码,可能是用的人比较少吧,那我就基于原生 AccessibilityService 自己来造一个轮子。

正文

直接进入正题,工具类使用 Kotlin 语言编写。

阅读本文时默认你已经有 AccessibilityService 应用开发经验。

使用方式:直接将 AccessibilityUtil2.kt 文件复制到项目内使用即可,该工具无其他更多依赖。
可以从 GitHub 直接下载,点击查看

函数都是实战检验并高效稳定运行,如寻找上一个/下一个兄弟节点,打印视图树等便捷操作。
寻找控件升级:
按文本(关键词)寻找节点和子节点内的一个匹配项
findOneByText
findOnceByText

按类名寻找节点和子节点内的一个匹配项
findOneByClazz
findOnceByClazz

按类名寻找节点和子节点内的所有匹配项
findAllByClazz
findAllOnceByClazz

注:findOne 即可以设置查询等待时间,发现则立即返回否则等到超时时间返回空数据; findOnce 即立即查询一次并返回结果或空数据。
本工具已经成功应用在企业微信自动机器人项目 WorkTool 上稳定运行。开源地址:https://github.com/gallonyin/worktool

import android.accessibilityservice.AccessibilityService
import android.accessibilityservice.GestureDescription
import android.annotation.TargetApi
import android.app.Notification
import android.app.PendingIntent
import android.graphics.Path
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import com.blankj.utilcode.util.LogUtils
import java.lang.Exception
import java.lang.Thread.sleep

/**
 * 1.查询类
 * findOneByClazz 按类名寻找节点和子节点内的一个匹配项
 * findAllByClazz 按类名寻找节点和子节点内的所有匹配项
 * findFrontNode 查找节点的前兄弟节点
 * findBackNode 查找节点的后兄弟节点
 * findCanScrollNode 返回可滚动元素集合
 * findOneByDesc 按描述寻找节点和子节点内的一个匹配项
 * findOneByText 按文本(关键词)寻找节点和子节点内的一个匹配项
 * findAllByText 按文本(关键词)寻找节点和子节点内的所有匹配项
 *
 * 2.全局操作
 * globalGoBack 回退
 * globalGoHome 回桌面
 *
 * 3.窗口操作
 * printNodeClazzTree 深度搜索打印节点及其子节点
 * performScrollUp 对某个节点向上滚动 未生效
 * performScrollDown 对某个节点向下滚动 未生效
 * performClick 对某个节点进行点击
 * performXYClick 输入x, y坐标模拟点击事件
 * editTextInput 编辑EditView(非粘贴 推荐)
 * findTextAndClick 寻找第一个文本匹配(关键词)并点击
 * findTextInput 寻找第一个EditView编辑框并输入文本
 * findListOneAndClick 寻找第一个列表并点击指定条目(默认点击第一个条目)
 * scrollAndFindByText 滚动并按文本寻找第一个控件
 * performClickWithSon 对某个节点或子节点进行点击
 * performLongClick 对某个节点或父节点进行长按
 * performLongClickWithSon 对某个节点或子节点进行长按
 *
 */
object AccessibilityUtil2 {
    private const val tag = "AccessibilityUtil"
    private const val SHORT_INTERVAL = 100L
    private const val SCROLL_INTERVAL = 300L

    //需要设置service以获取窗口
    public var service: AccessibilityService? = null

    //编辑EditView(非粘贴 推荐)
    fun editTextInput(nodeInfo: AccessibilityNodeInfo?, text: String): Boolean {
        val nodeInfo: AccessibilityNodeInfo = nodeInfo ?: return false
        val arguments = Bundle()
        arguments.putCharSequence(
            AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE,
            text
        )
        nodeInfo.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, arguments)
        return true
    }

    //寻找第一个文本匹配(关键词)并点击
    fun findTextAndClick(nodeInfo: AccessibilityNodeInfo?, text: String): Boolean {
        val textView = findOneByText(nodeInfo, text) ?: return false
        return performClick(textView)
    }

    //寻找第一个EditView编辑框并输入文本
    fun findTextInput(nodeInfo: AccessibilityNodeInfo?, text: String, root: Boolean = true): Boolean {
        if (root) {
            val editText = findOneByClazz(nodeInfo, "android.widget.EditText") ?: return false
            val arguments = Bundle()
            arguments.putCharSequence(
                AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE,
                text
            )
            editText.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, arguments)
        } else {
            val editText = findOnceByClazz(nodeInfo, "android.widget.EditText") ?: return false
            val arguments = Bundle()
            arguments.putCharSequence(
                AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE,
                text
            )
            editText.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, arguments)
        }
        return true
    }

    //寻找第一个列表并点击指定条目(默认点击第一个条目)
    fun findListOneAndClick(nodeInfo: AccessibilityNodeInfo, index: Int = 0): Boolean {
        val rv = findOnceByClazz(nodeInfo, "androidx.recyclerview.widget.RecyclerView")
        val lv = findOnceByClazz(nodeInfo, "android.widget.ListView")
        if (rv == null && lv == null) return false
        if (rv != null && rv.childCount > index) {
            performClick(rv.getChild(index))
        } else if (lv != null && lv.childCount > index) {
            performClick(lv.getChild(index))
        }
        return true
    }

    //滚动并按文本寻找第一个控件
    fun scrollAndFindByText(
        nodeInfo: AccessibilityNodeInfo,
        text: String,
        maxRetry: Int = 3
    ): AccessibilityNodeInfo? {
        var index = 0
        while (index++ < maxRetry) {
            performScrollUp(nodeInfo, 0)
            val node = findOnceByText(nodeInfo, text)
            if (node != null) {
                return node
            }
        }
        while (index++ < maxRetry * 2) {
            performScrollDown(nodeInfo, 0)
            val node = findOnceByText(nodeInfo, text)
            if (node != null) {
                return node
            }
        }
        return null
    }

    //输入x, y坐标模拟点击事件
    @TargetApi(Build.VERSION_CODES.N)
    fun performXYClick(service: AccessibilityService, x: Float, y: Float) {
        val path = Path()
        path.moveTo(x, y)
        val builder = GestureDescription.Builder()
        builder.addStroke(GestureDescription.StrokeDescription(path, 0, 1))
        val gestureDescription = builder.build()
        service.dispatchGesture(
            gestureDescription,
            object : AccessibilityService.GestureResultCallback() {
                override fun onCompleted(gestureDescription: GestureDescription) {
                    super.onCompleted(gestureDescription)
                    //Log.i(Constant.TAG, "onCompleted: completed");
                }

                override fun onCancelled(gestureDescription: GestureDescription) {
                    super.onCancelled(gestureDescription)
                    //Log.i(Constant.TAG, "onCancelled: cancelled");
                }
            },
            null
        )
    }

    /**
     * 对某个节点或父节点进行点击
     */
    fun performClick(nodeInfo: AccessibilityNodeInfo?): Boolean {
        var nodeInfo: AccessibilityNodeInfo? = nodeInfo ?: return false
        while (nodeInfo != null) {
            if (nodeInfo.isClickable) {
                nodeInfo.performAction(AccessibilityNodeInfo.ACTION_CLICK)
                return true
            }
            nodeInfo = nodeInfo.parent
        }
        return false
    }

    /**
     * 对某个节点或子节点进行点击
     */
    fun performClickWithSon(nodeInfo: AccessibilityNodeInfo?): Boolean {
        var nodeInfo: AccessibilityNodeInfo? = nodeInfo ?: return false
        while (nodeInfo != null) {
            if (nodeInfo.isClickable) {
                nodeInfo.performAction(AccessibilityNodeInfo.ACTION_CLICK)
                return true
            }
            if (nodeInfo.childCount > 0) {
                for (i in 0 until nodeInfo.childCount) {
                    if (performClickWithSon(nodeInfo.getChild(i))) {
                        return true
                    }
                }
            } else {
                nodeInfo = null
            }
        }
        return false
    }

    /**
     * 对某个节点或父节点进行长按
     */
    fun performLongClick(nodeInfo: AccessibilityNodeInfo?): Boolean {
        var nodeInfo: AccessibilityNodeInfo? = nodeInfo ?: return false
        while (nodeInfo != null) {
            if (nodeInfo.isLongClickable) {
                nodeInfo.performAction(AccessibilityNodeInfo.ACTION_LONG_CLICK)
                return true
            }
            nodeInfo = nodeInfo.parent
        }
        return false
    }

    /**
     * 对某个节点或子节点进行长按
     */
    fun performLongClickWithSon(nodeInfo: AccessibilityNodeInfo?): Boolean {
        var nodeInfo: AccessibilityNodeInfo? = nodeInfo ?: return false
        while (nodeInfo != null) {
            if (nodeInfo.isLongClickable) {
                nodeInfo.performAction(AccessibilityNodeInfo.ACTION_LONG_CLICK)
                return true
            }
            if (nodeInfo.childCount > 0) {
                for (i in 0 until nodeInfo.childCount) {
                    if (performLongClickWithSon(nodeInfo.getChild(i))) {
                        return true
                    }
                }
            } else {
                nodeInfo = null
            }
        }
        return false
    }

    //对某个节点向上滚动
    fun performScrollUp(nodeInfo: AccessibilityNodeInfo?): Boolean {
        var nodeInfo: AccessibilityNodeInfo? = nodeInfo ?: return false
        while (nodeInfo != null) {
            if (nodeInfo.isScrollable) {
                nodeInfo.performAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD)
                return true
            }
            nodeInfo = nodeInfo.parent
        }
        return false
    }

    //对某个节点或父节点向下滚动
    fun performScrollDown(node: AccessibilityNodeInfo?): Boolean {
        var node: AccessibilityNodeInfo? = node ?: return false
        while (node != null) {
            if (node.isScrollable) {
                node.performAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD)
                return true
            }
            node = node.parent
        }
        return false
    }

    //对第几个节点向上滚动
    fun performScrollUp(node: AccessibilityNodeInfo?, index: Int): Boolean {
        if (node == null) return false
        val canScrollNodeList = findCanScrollNode(node)
        if (canScrollNodeList.size > index) {
            canScrollNodeList[index].performAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD)
            sleep(SCROLL_INTERVAL)
            return true
        }
        return false
    }

    //对第几个节点向下滚动
    fun performScrollDown(node: AccessibilityNodeInfo?, index: Int): Boolean {
        if (node == null) return false
        val canScrollNodeList = findCanScrollNode(node)
        if (canScrollNodeList.size > index) {
            canScrollNodeList[index].performAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD)
            sleep(SCROLL_INTERVAL)
            return true
        }
        return false
    }

    //返回可滚动元素集合
    fun findCanScrollNode(
        node: AccessibilityNodeInfo?,
        list: ArrayList<AccessibilityNodeInfo> = ArrayList()
    ): ArrayList<AccessibilityNodeInfo> {
        if (node == null) return list
        if (node.isScrollable) list.add(node)
        for (i in 0 until node.childCount) {
            findCanScrollNode(node.getChild(i), list)
        }
        return list
    }

    //通知栏事件进入应用
    fun gotoApp(event: AccessibilityEvent) {
        val data = event.parcelableData
        if (data != null && data is Notification) {
            val intent = data.contentIntent
            try {
                intent.send()
            } catch (e: PendingIntent.CanceledException) {
                e.printStackTrace()
            }
        }
    }

    //回退
    fun globalGoBack(service: AccessibilityService) {
        service.performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK)
    }

    //回首页
    fun globalGoHome(service: AccessibilityService) {
        service.performGlobalAction(AccessibilityService.GLOBAL_ACTION_HOME)
    }

    /**
     * 按描述寻找节点和子节点内的一个匹配项
     * @param node 节点
     * @param desc 描述
     * @param timeout 检查超时时间
     */
    fun findOneByDesc(
        node: AccessibilityNodeInfo?,
        desc: String,
        timeout: Long = 5000
    ): AccessibilityNodeInfo? {
        var node = node ?: return null
        val description = node.contentDescription?.toString()
        if (description == desc) {
            return node
        }
        val startTime = System.currentTimeMillis()
        var currentTime = startTime
        while (currentTime - startTime <= timeout) {
            val result = findOnceByDesc(node, desc)
            if (result != null) return result
            sleep(SHORT_INTERVAL)
            node = getRoot()
            currentTime = System.currentTimeMillis()
        }
        Log.e(tag, "findOneByDesc: not found: $desc")
        return null
    }

    fun findOnceByDesc(
        node: AccessibilityNodeInfo?,
        desc: String
    ): AccessibilityNodeInfo? {
        if (node == null) return null
        val description = node.contentDescription?.toString()
        if (description == desc) {
            return node
        }
        for (i in 0 until node.childCount) {
            val result = findOnceByDesc(node.getChild(i), desc)
            if (result != null) return result
        }
        return null
    }

    /**
     * 按文本(关键词)寻找节点和子节点内的一个匹配项
     * @param node 节点
     * @param text 关键词
     * @param timeout 检查超时时间
     */
    fun findOneByText(
        node: AccessibilityNodeInfo?,
        text: String,
        exact: Boolean = false,
        timeout: Long = 5000,
        root: Boolean = true
    ): AccessibilityNodeInfo? {
        var node = node ?: return null
        val startTime = System.currentTimeMillis()
        var currentTime = startTime
        while (currentTime - startTime <= timeout) {
            val textViewList = node.findAccessibilityNodeInfosByText(text)
            LogUtils.v("text: $text count: " + textViewList.size)
            if (textViewList != null && textViewList.size > 0) {
                for (textView in textViewList) {
                    if (textView.text == text) {
                        return textView
                    }
                }
                if (!exact) {
                    return textViewList[0]
                }
            }
            sleep(SHORT_INTERVAL)
            if (root) {
                node = getRoot()
            } else {
                node.refresh()
            }
            currentTime = System.currentTimeMillis()
        }
        Log.e(tag, "findOneByText: not found: $text")
        return null
    }

    fun findOnceByText(
        node: AccessibilityNodeInfo?,
        text: String,
        exact: Boolean = false
    ): AccessibilityNodeInfo? {
        return findOneByText(node, text, exact, 0)
    }

    /**
     * 按文本(关键词)寻找节点和子节点内的所有匹配项
     * @param node 节点
     * @param text 关键词
     * @param timeout 检查超时时间
     */
    fun findAllByText(
        node: AccessibilityNodeInfo?,
        text: String,
        exact: Boolean = false,
        timeout: Long = 5000,
        root: Boolean = true
    ): List<AccessibilityNodeInfo> {
        var node = node ?: return arrayListOf()
        val startTime = System.currentTimeMillis()
        var currentTime = startTime
        while (currentTime - startTime <= timeout) {
            val tvList = node.findAccessibilityNodeInfosByText(text)
            if (tvList != null && tvList.size > 0) {
                if (!exact) {
                    return tvList
                } else if (tvList.count { it.text == text } > 0) {
                    return tvList.filter { it.text == text }
                }
            }
            sleep(SHORT_INTERVAL)
            if (root) {
                node = getRoot()
            } else {
                node.refresh()
            }
            currentTime = System.currentTimeMillis()
        }
        Log.e(tag, "findAllByText: not found: $text")
        return arrayListOf()
    }

    /**
     * 按类名寻找节点和子节点内的一个匹配项
     * node 节点
     * clazz 类名
     * limitDepth 深度 限制深度搜索深度必须匹配提供值且类名相同才返回 不填默认不限制
     * timeout 检查超时时间
     */
    fun findOneByClazz(
        node: AccessibilityNodeInfo?,
        clazz: String,
        limitDepth: Int? = null,
        depth: Int = 0,
        timeout: Long = 5000,
        root: Boolean = true
    ): AccessibilityNodeInfo? {
        var node = node ?: return null
        if (node.className == clazz) {
            if (limitDepth == null || limitDepth == depth)
                return node
        }
        val startTime = System.currentTimeMillis()
        var currentTime = startTime
        while (currentTime - startTime <= timeout) {
            val result = findOnceByClazz(node, clazz, limitDepth, depth)
            LogUtils.v("clazz: $clazz result == null: ${result == null}")
            if (result != null) return result
            sleep(SHORT_INTERVAL)
            if (root) {
                node = getRoot()
            } else {
                node.refresh()
            }
            currentTime = System.currentTimeMillis()
        }
        LogUtils.e("findOneByClazz Exception()")
        Exception().printStackTrace()
        return null
    }

    /**
     * 按类名寻找节点和子节点内的一个匹配项
     * node 节点
     * clazz 类名
     * limitDepth 深度 限制深度搜索深度必须匹配提供值且类名相同才返回 不填默认不限制
     */
    fun findOnceByClazz(
        node: AccessibilityNodeInfo?,
        clazz: String,
        limitDepth: Int? = null,
        depth: Int = 0
    ): AccessibilityNodeInfo? {
        if (node == null) return null
        if (node.className == clazz) {
            if (limitDepth == null || limitDepth == depth)
                return node
        }
        for (i in 0 until node.childCount) {
            val result = findOnceByClazz(node.getChild(i), clazz, limitDepth, depth + 1)
            if (result != null) return result
        }
        return null
    }

    /**
     * 按类名寻找节点和子节点内的所有匹配项
     * node 节点
     * clazz 类名
     * limitDepth 深度 限制深度搜索深度必须匹配提供值且类名相同才返回 不填默认不限制
     */
    fun findAllByClazz(
        node: AccessibilityNodeInfo?,
        clazz: String,
        list: ArrayList<AccessibilityNodeInfo> = ArrayList(),
        timeout: Long = 5000,
        root: Boolean = true,
        minSize: Int = 1
    ): ArrayList<AccessibilityNodeInfo> {
        var node = node ?: return list
        val startTime = System.currentTimeMillis()
        var currentTime = startTime
        while (currentTime - startTime <= timeout) {
            val result = findAllOnceByClazz(node, clazz)
            LogUtils.v("clazz: $clazz count: " + result.size)
            if (result.size >= minSize) return result
            sleep(SHORT_INTERVAL)
            if (root) {
                node = getRoot()
            } else {
                node.refresh()
            }
            currentTime = System.currentTimeMillis()
        }
        LogUtils.e("findAllByClazz Exception()")
        Exception().printStackTrace()
        return list
    }

    /**
     * 按类名寻找节点和子节点内的所有匹配项
     * node 节点
     * clazz 类名
     * limitDepth 深度 限制深度搜索深度必须匹配提供值且类名相同才返回 不填默认不限制
     */
    fun findAllOnceByClazz(
        node: AccessibilityNodeInfo?,
        clazz: String,
        list: ArrayList<AccessibilityNodeInfo> = ArrayList()
    ): ArrayList<AccessibilityNodeInfo> {
        if (node == null) return list
        if (node.className == clazz) list.add(node)
        for (i in 0 until node.childCount) {
            findAllOnceByClazz(node.getChild(i), clazz, list)
        }
        return list
    }

    /**
     * 查找节点的前兄弟节点
     * node 节点
     */
    fun findFrontNode(node: AccessibilityNodeInfo?): AccessibilityNodeInfo? {
        if (node == null) return null
        var parent: AccessibilityNodeInfo? = node.parent
        var son: AccessibilityNodeInfo? = node
        while (parent != null) {
            var index = -1
            for (i in 0 until parent.childCount) {
                if (parent.getChild(i) == son) {
                    index = i
                    break
                }
            }
            if (index > 0) {
                return parent.getChild(index - 1)
            }
            son = parent
            parent = parent.parent
        }
        return null
    }

    /**
     * 查找节点的后兄弟节点
     * node 节点
     */
    fun findBackNode(node: AccessibilityNodeInfo?): AccessibilityNodeInfo? {
        if (node == null) return null
        var parent: AccessibilityNodeInfo? = node.parent
        var son: AccessibilityNodeInfo? = node
        while (parent != null) {
            var index = -1
            for (i in 0 until parent.childCount) {
                if (parent.getChild(i) == son) {
                    index = i
                    break
                }
            }
            if (index < parent.childCount - 1) {
                return parent.getChild(index + 1)
            }
            son = parent
            parent = parent.parent
        }
        return null
    }

    /**
     * 深度搜索打印节点及其子节点
     * node 节点
     */
    fun printNodeClazzTree(
        node: AccessibilityNodeInfo?,
        printText: Boolean = true,
        depth: Int = 0
    ) {
        if (node == null) return
        var s = ""
        for (i in 0 until depth) {
            s += "---"
        }
        Log.d(tag, "$s depth: $depth className: " + node.className)
        if (printText && node.text != null) {
            Log.d(tag, "$s depth: $depth text: " + node.text)
        }
        if (printText && node.contentDescription != null) {
            Log.d(tag, "$s depth: $depth desc: " + node.contentDescription)
        }
        for (i in 0 until node.childCount) {
            printNodeClazzTree(node.getChild(i), printText, depth + 1)
        }
    }

    /**
     * 获取前台窗口
     */
    fun getRoot(): AccessibilityNodeInfo {
        while (true) {
            val tempRoot = service?.rootInActiveWindow
            val root = service?.rootInActiveWindow
            if (tempRoot != root) {
                LogUtils.e("tempRoot != root")
            }
            if (root != null) {
                return root
            }
            sleep(1000)
        }
    }
}