无障碍增强工具类,助力高效开发
前言
本文是上一次封装类的升级版本(点击这里查看),两个版本的唯一区别是本次升级版加入了超时检索控件功能!如果你用过 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)
}
}
}