Android 无障碍服务
1、无障碍服务介绍
无障碍服务是 Android 框架的一项功能,旨在代表 Android 设备上安装的应用向用户提供备选导航反馈。无障碍服务可以代表应用与用户通信,例如,通过将文字转换为语音,或在用户将鼠标悬停在屏幕的重要区域时提供触感反馈
1.1、 什么无障碍服务
无障碍服务(Accessibility Service)是 Android 系统提供的一种特殊服务,旨在帮助残障用户更好地使用设备。它通过监控系统事件(如界面变化、用户操作)并提供辅助功能(如语音反馈、界面操作)来增强用户体验。
在 Android 测试中,无障碍服务不仅可以用于辅助功能开发,还可以用于自动化测试、界面分析和监控。通过无障碍服务,开发者可以模拟用户操作、获取界面信息,甚至实现跨应用的自动化测试。
1.2、常见无障碍服务示例
-
Switch Access(开关控制):可让行动不便的 Android 用户使用一个或多个开关与设备互动。
-
Voice Access(Beta 版):可让行动不便的 Android 用户通过语音指令控制设备。
-
TalkBack:一种屏幕阅读器,通常供视障用户或盲人用户使用。
-
AccessibilityService(自定义)
1.2.1、什么是 Switch Access(开关控制)?
Switch Access 是 Android 系统提供的一种无障碍功能,旨在帮助存在行动障碍的用户使用一种或多种“开关设备”来控制手机,而无需直接触摸屏幕。
Switch Access 的核心特点
特性
说明
非接触式操作
用户可通过物理按钮、蓝牙设备或摄像头表情检测等“开关”来操作界面
逐项扫描模式
系统自动高亮屏幕上的可操作项,用户点击“开关”即可选择
完整交互控制
包括点击、滑动、输入、返回、主屏幕、通知栏等全部操作均可通过开关完成
自定义开关行为
可配置多个开关分别控制“选中”“确认”等功能
兼容性
内置于 Android 系统,大多数设备从 Android 5.0 开始支持
Switch Access适用人群
Switch Access 主要面向以下人群:
-
手部活动受限、无法精准触控屏幕的用户;
-
需要辅助设备(如轮椅按钮、头部控制器、眼动追踪等)的人;
-
长期使用外部开关设备交互的重度无障碍用户。
Switch Access 支持的控制方式
控制方式
说明
蓝牙物理开关
如按钮、外接键盘、辅助设备
屏幕区域点击
屏幕模拟开关点击(测试或轻度辅助用)
摄像头动作
利用人脸表情、眨眼等作为输入(Android 12+)
键盘按键
支持配置如 Space, Enter, Volume Up/Down 等为开关操作键
Switch Access开启方式
1.2.2、什么是 Voice Access(语音控制)?
Voice Access 是 Google 提供的一种无障碍辅助服务,允许用户通过语音命令完全控制 Android 设备,无需触控屏幕。
语音访问主要服务于手部或身体行动不便的用户,也可用于解放双手的场景(如驾驶、烹饪时操作手机)。
Voice Access 的核心能力
功能
描述
语音控制 UI 操作
点击按钮、滑动屏幕、返回主页、打开通知栏等全部可通过语音完成
自动编号控件
为界面中每个可操作控件打上数字编号,用户只需说出“点击 5”等命令即可
支持文字输入
可以语音输入文本、修改、删除、选择等
连续命令操作
支持“滚动到底部”、“点击下一页”、“打开设置”等连续操作
自然语言指令识别
可使用“回到上一页”“打开微信”“说一下电量”等自然语言命令
Voice Access适用人群
-
肢体障碍用户,无法触控或操作屏幕;
-
手部临时不便(如骨折、术后恢复等);
-
希望通过语音操控设备的普通用户;
-
驾驶或手忙时需要免触交互的场景。
Voice Access开启方式
1.2.3、什么是 TalkBack?
TalkBack 是 Android 系统内置的屏幕阅读器(Screen Reader),为视力障碍用户提供语音反馈,帮助他们感知、理解并操作手机界面。
“
简单来说:TalkBack 让“看不见屏幕”的用户,听见并操作屏幕内容。
TalkBack核心功能
功能
说明
语音朗读内容
朗读文本、按钮名称、提示信息、通知等
朗读焦点控件属性
读取组件类型、状态(选中/不可用)、位置等
手势操作导航
通过滑动手势导航焦点(上下左右滑动)
支持输入朗读
输入框输入时同步朗读文字内容
辅助操作功能
长按、双击、切换按钮、滑动等均支持辅助反馈
TalkBack适用用户
-
视力障碍者(全盲或弱视);
-
临时不便查看屏幕者;
-
需要通过听觉完成 UI 操作的用户。
TalkBack开启方式
TalkBack开发者需要注意什么?
要点
建议
提供 contentDescription
所有按钮、图片、非文本控件必须加描述
避免重复冗余朗读
不要将描述和可见文本内容重复添加
支持焦点导航
控件需具备 focus 属性,并正确响应焦点事件
尽量使用原生控件
自定义 View 需实现 AccessibilityDelegate
测试体验顺序
逻辑顺序应符合阅读/操作习惯,从上到下、从左到右
1.2.4、什么是 AccessibilityService(自定义无障碍服务)?
AccessibilityService 是 Android 提供的一种系统级服务接口,允许开发者监听和控制全局界面交互,以便辅助操作、自动化任务或提供无障碍支持。
“
简单理解:它是开发者级的“屏幕代理”,可以读取界面元素并模拟操作。
AccessibilityService(自定义无障碍服务)可以做什么?
功能
描述
监听界面变化
接收系统或其他 App 的界面事件,如点击、焦点变化、内容变化
获取控件结构
获取任意 App 当前界面的控件层级树(AccessibilityNodeInfo)
模拟用户操作
实现点击、滑动、输入、返回、长按等手势操作
跨应用自动化
可跳转 App、点击系统弹窗、执行自动流程
读取控件属性
获取控件文本、类型、ID、位置、状态等
无UI自动执行
可在后台运行任务,甚至在目标 App 不配合的情况下自动执行流程
自定义无障碍服务适用于
-
自动化开发 / 测试辅助工具;
-
无障碍辅助 App 开发;
-
面向 Android 系统服务层交互的高级控制。
与其他无障碍服务对比
工具
控制方式
服务对象
是否语音反馈
是否模拟点击
是否可跨应用
TalkBack
手势导航+朗读
视障用户
是
是
是
Switch Access
外部开关
肢体障碍
否
是
是
Voice Access
语音命令
肢体不便
是
是
是
AccessibilityService(自定义)
脚本/逻辑控制
开发者/自动化
否
是
是
功能对比:
功能点
自定义 AccessibilityService
TalkBack / VoiceAccess
控件监听
精细控制
不提供 API
自动操作
可模拟各种用户动作
仅用户语音/手势触发
跨应用
支持
支持
UI 控制
可隐藏/后台运行
无控制权限
自动化脚本
支持逻辑判断、循环等
无脚本逻辑能力
面向开发者
开发者工具
面向终端用户
1.3、 无障碍服务在 Android 测试中的重要性
无障碍服务在 Android 测试中的优势:
-
无需修改被测应用:无障碍服务直接作用于系统界面层,无需对被测应用进行任何代码改动,天然适用于黑盒测试场景。
-
支持跨应用操作:能够感知并操作多个应用的界面,适合测试涉及应用间跳转、系统弹窗等复杂场景。
-
强大的界面分析能力:通过 AccessibilityNodeInfo 可访问当前界面完整的控件层级结构与属性信息,支持精准的元素识别与布局验证。
-
自动化测试灵活性高:可模拟用户各种操作(如点击、输入、滑动),结合逻辑判断,实现高度自定义的测试脚本与任务流程。
-
良好的兼容性:无障碍服务作为 Android 框架的系统能力,在绝大多数设备与系统版本上都能稳定运行。
目前,一些主流的 Android 自动化框架,或多或少都使用到了无障碍服务的功能,比如:
-
Google 官方提供的自动化测试框架 UI Automator 本身就是构建在无障碍服务之上;
-
Google 官方提供的 Android 白盒测试框架,主要依赖于视图层级(View Hierarchy),但在某些场景下会使用无障碍服务来增强功能。
2、UI Automator介绍
UI Automator 是 Google 在 Android 4.1 的时候推出的 Android UI 自动化测试框架。 它可以模拟用户操作(比如:点击、滑动、输入文本等)和获取应用程序的界面信息,帮助开发者构建可靠且高效的自动化测试脚本。
2.1、UI Automator特点与优势
特点
描述
跨应用测试
可跨应用操作任意界面,不局限于当前 App
强大的元素定位
支持根据 text、ID、class 等属性查找控件
模拟复杂用户交互
支持点击、长按、滑动、输入等操作
多设备支持
可连接多个设备进行测试
异步任务处理
提供等待机制处理异步加载
日志报告完善
输出详细日志与调试信息
2.2、UI Automator应用场景
-
功能自动化测试:自动执行用户场景,验证功能逻辑。
-
UI 验证:检测控件显示、状态、布局是否符合预期。
-
兼容性测试:测试不同设备和版本系统的表现。
-
跨 App 场景测试:第三方 App 监控或交互,如微信拉起支付宝、系统设置跳转等。
-
性能评估:配合统计工具进行响应时间、内存等评估。
-
用户体验测试:模拟实际用户操作,检测交互体验。
“
UI Automator 对于 WebView 构建的应用适配不是很好。
2.3、UI Automator 版本对比
UI Automator 主要版本有 1.0 和 2.0,现在大多使用的是 2.0 版本。可通过 UI Automator 官网 查看版本的变更历史。
uiautomator2--> git:https://github.com/openatx/uiautomator2
对比项
UI Automator 1.0
UI Automator 2.0
实现基础
Instrumentation
AccessibilityService
支持系统
Android 4.1+
Android 5.0+
查找方式
UiSelector
BySelector, UiObject2
查找能力
一般
更强,支持层级、动态视图
API 扩展
限制多
支持手势、等待、滚动等
性能
较慢
更快,适配复杂视图结构
2.4、辅助工具
2.4.1、UI Automator Viewer
Android SDK 自带工具,可截图当前界面并展示元素层级与属性,帮助分析 UI 结构。
“
注意:需 Java 8 环境运行,Java 11+ 可能闪退
2.4.2、Appium
在 Android 设备上进行自动化操作时,可以使用 Appium 获取 UI 元素的节点信息
2.4.2.1安装 Appium
首先,需要在本地安装 Appium。
“
安装Appium之前需要先安装好node.js,版本需要>=18.0.0
安装好node.js之后就可以通过npm安装Appium了
npm install -g appiumappium -v # 检查安装版本
2.4.2.2安装必要的依赖
-
Android SDK
-
Java JDK
-
uiautomator2 驱动
使用appium直接安装uiautomator2appium driver install uiautomator2
brew install --cask appium-inspector
2.4.2.3启动 Appium
appium server --allow-cors
2.4.2.4连接设备并使用浏览器打开 Appium Inspector
-
连接Android设备,通过命令行 adb devices 查看确保设备已连接。
-
使用浏览器打开 Appium Inspector
-
在Appium Inspector页面中的JSON Representation输入以下参数
{ \"platformName\": \"Android\", \"appium:deviceName\": \"4b3cc831\", \"appium:automationName\": \"UiAutomator2\"}
2.4.3、weditor
Weditor 是一个基于 Web 的可视化 UI 层级查看工具,主要用于 Android 无障碍开发、自动化测试等场景。它可以实时查看 Android 设备上的界面结构(UI 层级)、获取控件的各种属性,如 text、resource-id、class 等。
2.4.3.1、weditor安装
安装 Python 环境
Weditor 依赖于 Python(推荐 3.6+):
-
macOS
brew install python3
安装 Weditor
使用 pip 命令安装最新版 Weditor:
pip install -U weditor
安装完成后验证是否成功:
weditor --help
若输出帮助信息,说明安装成功
启动 Weditor 服务
weditor
2.4.4、uiauto.dev
uiauto.dev帮助你快速编写App UI自动化脚本,它由网页端+客户端组成
-
支持Android和iOS
-
支持鼠标选择控件查看属性,控件树辅助精确定位,生成选择器
-
支持找色功能,方向键微调坐标,获取RGB、HSB
uiauto.dev安装
安装Python 3.8+
安装并启动
pip3 install -U uiautodev -i https://pypi.doubanio.com/simple
uiauto.dev
or:python3 -m uiautodev
3、Android 无障碍服务AccessibilityService
3.1、AccessibilityService基础实现步骤
3.1.1. AccessibilityService声明服务
在 AndroidManifest.xml 中注册:
3.1.2.配置服务(res/xml/accessibility_service_config.xml)
3.1.3.编写类继承 AccessibilityService
class MyAccessibilityService : AccessibilityService() { override fun onAccessibilityEvent(event: AccessibilityEvent) { // 处理 UI 事件,如点击、滚动等 } override fun onInterrupt() { // 被打断时调用(如服务被系统关闭) } override fun onServiceConnected() { // 服务连接成功 }}
3.2、无障碍服务核心逻辑封装
/** * 无障碍服务核心类 * 提供对AccessibilityService的封装和扩展功能 */object AssistsCore { /** 日志标签 */ var LOG_TAG = \"assists_log\" /** 当前应用在屏幕中的位置信息缓存 */ private var appRectInScreen: Rect? = null /** * 以下是一系列用于快速判断元素类型的扩展函数 * 通过比对元素的className来判断元素类型 */ /** 判断元素是否是FrameLayout */ fun AccessibilityNodeInfo.isFrameLayout(): Boolean { return className == NodeClassValue.FrameLayout } /** 判断元素是否是ViewGroup */ fun AccessibilityNodeInfo.isViewGroup(): Boolean { return className == NodeClassValue.ViewGroup } /** 判断元素是否是View */ fun AccessibilityNodeInfo.isView(): Boolean { return className == NodeClassValue.View } /** 判断元素是否是ImageView */ fun AccessibilityNodeInfo.isImageView(): Boolean { return className == NodeClassValue.ImageView } /** 判断元素是否是TextView */ fun AccessibilityNodeInfo.isTextView(): Boolean { return className == NodeClassValue.TextView } /** 判断元素是否是LinearLayout */ fun AccessibilityNodeInfo.isLinearLayout(): Boolean { return className == NodeClassValue.LinearLayout } /** 判断元素是否是RelativeLayout */ fun AccessibilityNodeInfo.isRelativeLayout(): Boolean { return className == NodeClassValue.RelativeLayout } /** 判断元素是否是Button */ fun AccessibilityNodeInfo.isButton(): Boolean { return className == NodeClassValue.Button } /** 判断元素是否是ImageButton */ fun AccessibilityNodeInfo.isImageButton(): Boolean { return className == NodeClassValue.ImageButton } /** 判断元素是否是EditText */ fun AccessibilityNodeInfo.isEditText(): Boolean { return className == NodeClassValue.EditText } /** * 获取元素的文本内容 * @return 元素的text属性值,如果为空则返回空字符串 */ fun AccessibilityNodeInfo.txt(): String { return text?.toString() ?: \"\" } /** * 获取元素的描述内容 * @return 元素的contentDescription属性值,如果为空则返回空字符串 */ fun AccessibilityNodeInfo.des(): String { return contentDescription?.toString() ?: \"\" } /** * 初始化AssistsCore * @param application Application实例 */ fun init(application: Application) { LogUtils.getConfig().globalTag = LOG_TAG } /** * 打开系统的无障碍服务设置页面 * 用于引导用户开启无障碍服务 */ fun openAccessibilitySetting() { val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK ActivityUtils.startActivity(intent) } /** * 检查无障碍服务是否已开启 * @returntrue表示服务已开启,false表示服务未开启 */ fun isAccessibilityServiceEnabled(): Boolean { return AssistsService.instance != null } /** * 获取当前窗口所属的应用包名 * @return 当前窗口的包名,如果获取失败则返回空字符串 */ fun getPackageName(): String { return AssistsService.instance?.rootInActiveWindow?.packageName?.toString() ?: \"\" } /** * 通过id查找所有符合条件的元素 * @param id 元素的资源id * @param text 可选的文本过滤条件 * @return 符合条件的元素列表 */ fun findById(id: String, text: String? = null): List { var nodeInfos = AssistsService.instance?.rootInActiveWindow?.findById(id) ?: arrayListOf() nodeInfos = text?.let { nodeInfos.filter { if (it.txt() == text) { return@filter true } return@filter false } } ?: let { nodeInfos } return nodeInfos } /** * 在指定元素范围内通过id查找所有符合条件的元素 * @param id 元素的资源id * @return 符合条件的元素列表 */ fun AccessibilityNodeInfo?.findById(id: String): List { if (this == null) return arrayListOf() findAccessibilityNodeInfosByViewId(id)?.let { return it } return arrayListOf() } /** * 通过文本内容查找所有符合条件的元素 * @param text 要查找的文本内容 * @return 符合条件的元素列表 */ fun findByText(text: String): List { return AssistsService.instance?.rootInActiveWindow?.findByText(text) ?: arrayListOf() } /** * 根据文本查找元素 * @param searchText 可选的文本过滤条件 * @return 符合所有条件的元素列表 */ fun findBySearchText( searchText: String? = null ): List { return findBySearchText(searchText,0) } /** * 根据文本查找元素 * @param searchText 可选的文本过滤条件 * @param searchType 搜索条件类型 0:完全匹配 1:包含匹配 * @return 符合所有条件的元素列表 */ fun findBySearchText( searchText: String? = null, searchType:Int = 0 ): List { var nodeList = arrayListOf() searchText?.let { var allNodes = getAllNodes() allNodes.forEach { it -> it.logNode() if(searchType == 0){ if(!it.text.isNullOrEmpty() && it.text != \"null\"){ if(searchText == it.text.toString()){ nodeList.add(it) } } }elseif(searchType == 1){ if(!it.text.isNullOrEmpty() && it.text != \"null\"){// Log.d(\"HYLAll:\", it.text.toString() + \"size:\" + allNodes.size) if(it.text?.toString()?.contains(searchText) == true){// Log.d(\"HYLResult:\", it.text.toString()) nodeList.add(it) } } } } } return nodeList } /** * 通过文本内容查找所有符合条件的元素 * @param content 要查找的文本内容 * @return 符合条件的元素列表 */ fun findIsMatchText(content: String): AccessibilityNodeInfo? { if(haveTextView(content) == null){ val rootNode: AccessibilityNodeInfo? = AssistsService.instance?.rootInActiveWindow if (rootNode != null) { // 调用方法来解析或处理屏幕内容 val processScreenContent = processScreenContent(rootNode, content) if(processScreenContent != null){ return processScreenContent } } }else { return haveTextView(content) } return null } private fun processScreenContent( rootNode: AccessibilityNodeInfo?, content: String ): AccessibilityNodeInfo? { // 遍历节点,获取屏幕内容 if (rootNode == null) return null for (i in 0 until rootNode.childCount) { val childNode = rootNode.getChild(i) if (childNode != null) { // 获取节点的文本 var text = childNode.text if (text == null) { text = childNode.contentDescription } if (!TextUtils.isEmpty(text)) { Log.d(\"ScreenContent\", \"Text: $text${childNode.viewIdResourceName}\".trimIndent()) } if (isMatchExecuteUnit(childNode, content)) { return childNode } // 递归检查子节点 val processScreenContent = processScreenContent(childNode, content) if (processScreenContent!= null) { return processScreenContent } } } return null } private fun haveTextView(content: String): AccessibilityNodeInfo? { var rootNode: AccessibilityNodeInfo? = AssistsService.instance?.rootInActiveWindow ?: return null var targets: List? = null if (!TextUtils.isEmpty(content)) { targets = rootNode.findByText(content) } if (!targets.isNullOrEmpty()){ for (i in targets.indices) { if (isMatchExecuteUnit(targets[i], content)) { val target = targets[i] return target } } } return null } private fun isMatchExecuteUnit(rootNode: AccessibilityNodeInfo?,text:String?): Boolean { if (rootNode == null) returnfalse var textMatch = true if (text != null) { var nodeText = rootNode.text if (nodeText == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { nodeText = rootNode.hintText } if (nodeText == null) { nodeText = rootNode.contentDescription } if (nodeText == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { nodeText = rootNode.tooltipText } if (nodeText == null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { nodeText = rootNode.paneTitle } } if (nodeText == null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { nodeText = rootNode.stateDescription } } Log.d(\"ScreenContent\", \"nodeText: $nodeText${rootNode.viewIdResourceName}\".trimIndent()) rootNode.logNode() textMatch = nodeText != null && text == nodeText.toString() } return textMatch } /** * 查找所有文本完全匹配的元素 * @param text 要匹配的文本内容 * @return 文本完全匹配的元素列表 */ fun findByTextAllMatch(text: String): List { val listResult = arrayListOf() val list = AssistsService.instance?.rootInActiveWindow?.findByText(text) list?.let { it.forEach { if (TextUtils.equals(it.text, text)) { listResult.add(it) } } } return listResult } /** * 在指定元素范围内通过文本查找所有符合条件的元素 * @param text 要查找的文本内容 * @return 符合条件的元素列表 */ fun AccessibilityNodeInfo?.findByText(text: String): List { if (this == null) return arrayListOf() findAccessibilityNodeInfosByText(text)?.let { return it } return arrayListOf() } /** * 判断元素是否包含指定文本 * @param text 要检查的文本内容 * @returntrue表示包含指定文本,false表示不包含 */ fun AccessibilityNodeInfo?.containsText(text: String): Boolean { if (this == null) returnfalse getText()?.let { if (it.contains(text)) returntrue } contentDescription?.let { if (it.contains(text)) returntrue } returnfalse } /** * 获取元素的所有文本内容(包括text和contentDescription) * @return 包含所有文本内容的列表 */ fun AccessibilityNodeInfo?.getAllText(): ArrayList { if (this == null) return arrayListOf() val texts = arrayListOf() getText()?.let { texts.add(it.toString()) } contentDescription?.let { texts.add(it.toString()) } return texts } /** * 根据多个条件查找元素 * @param className 元素的类名 * @param viewId 可选的资源id过滤条件 * @param text 可选的文本过滤条件 * @param des 可选的描述文本过滤条件 * @return 符合所有条件的元素列表 */ fun findByTags( className: String, viewId: String? = null, text: String? = null, des: String? = null ): List { var nodeList = arrayListOf() getAllNodes().forEach { if (TextUtils.equals(className, it.className)) { nodeList.add(it) } } nodeList = viewId?.let { if (it.isEmpty()) return@let nodeList return@let arrayListOf().apply { addAll(nodeList.filter { return@filter it.viewIdResourceName == viewId }) } } ?: let { return@let nodeList } nodeList = text?.let { if (it.isEmpty()) return@let nodeList return@let arrayListOf().apply { addAll(nodeList.filter { return@filter it.txt() == text }) } } ?: let { return@let nodeList } nodeList = des?.let { if (it.isEmpty()) return@let nodeList return@let arrayListOf().apply { addAll(nodeList.filter { return@filter it.des() == des }) } } ?: let { return@let nodeList } return nodeList } /** * 在指定元素范围内根据多个条件查找元素 * @param className 元素的类名 * @param viewId 可选的资源id过滤条件 * @param text 可选的文本过滤条件 * @param des 可选的描述文本过滤条件 * @return 符合所有条件的元素列表 */ fun AccessibilityNodeInfo.findByTags( className: String, viewId: String? = null, text: String? = null, des: String? = null ): List { var nodeList = arrayListOf() getNodes().forEach { if (TextUtils.equals(className, it.className)) { nodeList.add(it) } } nodeList = viewId?.let { return@let arrayListOf().apply { addAll(nodeList.filter { return@filter it.viewIdResourceName == viewId }) } } ?: let { return@let nodeList } nodeList = text?.let { return@let arrayListOf().apply { addAll(nodeList.filter { return@filter it.txt() == text }) } } ?: let { return@let nodeList } nodeList = des?.let { return@let arrayListOf().apply { addAll(nodeList.filter { return@filter it.des() == des }) } } ?: let { return@let nodeList } return nodeList } /** * 查找第一个符合指定类型的父元素 * @param className 要查找的父元素类名 * @return 找到的父元素,如果未找到则返回null */ fun AccessibilityNodeInfo.findFirstParentByTags(className: String): AccessibilityNodeInfo? { val nodeList = arrayListOf() findFirstParentByTags(className, nodeList) return nodeList.firstOrNull() } /** * 递归查找符合指定类型的父元素 * @param className 要查找的父元素类名 * @param container 用于存储查找结果的列表 */ fun AccessibilityNodeInfo.findFirstParentByTags(className: String, container: ArrayList) { getParent()?.let { if (TextUtils.equals(className, it.className)) { container.add(it) } else { it.findFirstParentByTags(className, container) } } } /** * 获取当前窗口中的所有元素 * @return 包含所有元素的列表 */ fun getAllNodes(): ArrayList { val nodeList = arrayListOf() AssistsService.instance?.rootInActiveWindow?.getNodes(nodeList) return nodeList } /** * 获取指定元素下的所有子元素 * @return 包含所有子元素的列表 */ fun AccessibilityNodeInfo.getNodes(): ArrayList { val nodeList = arrayListOf() this.getNodes(nodeList) return nodeList } /** * 递归获取元素的所有子元素 * @param nodeList 用于存储子元素的列表 */ private fun AccessibilityNodeInfo.getNodes(nodeList: ArrayList) { nodeList.add(this) if (nodeList.size > 10000) return // 防止无限递归 for (index in 0 until this.childCount) { getChild(index)?.getNodes(nodeList) } } /** * 查找元素的第一个可点击的父元素 * @return 找到的可点击父元素,如果未找到则返回null */ fun AccessibilityNodeInfo.findFirstParentClickable(): AccessibilityNodeInfo? { arrayOfNulls(1).apply { findFirstParentClickable(this) return this[0] } } /** * 递归查找可点击的父元素 * @param nodeInfo 用于存储查找结果的数组 */ private fun AccessibilityNodeInfo.findFirstParentClickable(nodeInfo: Array) { if (parent?.isClickable == true) { nodeInfo[0] = parent return } else { parent?.findFirstParentClickable(nodeInfo) } } /** * 获取元素的直接子元素(不包括子元素的子元素) * @return 包含直接子元素的列表 */ fun AccessibilityNodeInfo.getChildren(): ArrayList { val nodes = arrayListOf() for (i in 0 until this.childCount) { val child = getChild(i) nodes.add(child) } return nodes } /** * 执行手势操作 * @param gesture 手势描述对象 * @param nonTouchableWindowDelay 窗口变为不可触摸后的延迟时间 * @return 手势是否执行成功 */ suspend fun dispatchGesture( gesture: GestureDescription, nonTouchableWindowDelay: Long = 100, ): Boolean { val completableDeferred = CompletableDeferred() val gestureResultCallback = object : AccessibilityService.GestureResultCallback() { override fun onCompleted(gestureDescription: GestureDescription?) { CoroutineWrapper.launch { AssistsWindowManager.touchableByAll() } completableDeferred.complete(true) } override fun onCancelled(gestureDescription: GestureDescription?) { CoroutineWrapper.launch { AssistsWindowManager.touchableByAll() } completableDeferred.complete(false) } } val runResult = AssistsService.instance?.let { AssistsWindowManager.nonTouchableByAll() delay(nonTouchableWindowDelay) runMain { it.dispatchGesture(gesture, gestureResultCallback, null) } } ?: let { returnfalse } if (!runResult) returnfalse return completableDeferred.await() } /** * 执行点击或滑动手势 * @param startLocation 起始位置坐标 * @param endLocation 结束位置坐标 * @param startTime 开始延迟时间 * @param duration 手势持续时间 * @return 手势是否执行成功 */ suspend fun gesture( startLocation: FloatArray, endLocation: FloatArray, startTime: Long, duration: Long, ): Boolean { val path = Path() path.moveTo(startLocation[0], startLocation[1]) path.lineTo(endLocation[0], endLocation[1]) return gesture(path, startTime, duration) } /** * 执行自定义路径的手势 * @param path 手势路径 * @param startTime 开始延迟时间 * @param duration 手势持续时间 * @return 手势是否执行成功 */ suspend fun gesture( path: Path, startTime: Long, duration: Long, ): Boolean { val builder = GestureDescription.Builder() val strokeDescription = GestureDescription.StrokeDescription(path, startTime, duration) val gestureDescription = builder.addStroke(strokeDescription).build() val deferred = CompletableDeferred() val runResult = runMain { return@runMain AssistsService.instance?.dispatchGesture(gestureDescription, object : AccessibilityService.GestureResultCallback() { override fun onCompleted(gestureDescription: GestureDescription) { deferred.complete(true) } override fun onCancelled(gestureDescription: GestureDescription) { deferred.complete(false) } }, null) ?: let { return@runMain false } } if (!runResult) returnfalse val result = deferred.await() return result } /** * 获取元素在屏幕中的位置信息 * @return 包含元素位置信息的Rect对象 */ fun AccessibilityNodeInfo.getBoundsInScreen(): Rect { val boundsInScreen = Rect() getBoundsInScreen(boundsInScreen) return boundsInScreen } /** * 点击元素 * @return 点击操作是否成功 */ fun AccessibilityNodeInfo.click(): Boolean { return performAction(AccessibilityNodeInfo.ACTION_CLICK) } /** * 长按元素 * @return 长按操作是否成功 */ fun AccessibilityNodeInfo.longClick(): Boolean { return performAction(AccessibilityNodeInfo.ACTION_LONG_CLICK) } /** * 在指定坐标位置执行点击手势 * @param x 横坐标 * @param y 纵坐标 * @param duration 点击持续时间 * @return 手势是否执行成功 */ suspend fun gestureClick( x: Float, y: Float, duration: Long = 10 ): Boolean { return gesture( floatArrayOf(x, y), floatArrayOf(x, y), 0, duration, ) } /** * 在元素位置执行点击手势 * @param offsetX X轴偏移量 * @param offsetY Y轴偏移量 * @param switchWindowIntervalDelay 窗口切换延迟时间 * @param duration 点击持续时间 * @return 手势是否执行成功 */ suspend fun AccessibilityNodeInfo.nodeGestureClick( offsetX: Float = ScreenUtils.getScreenWidth() * 0.01953f, offsetY: Float = ScreenUtils.getScreenWidth() * 0.01953f, switchWindowIntervalDelay: Long = 250, duration: Long = 25 ): Boolean { runMain { AssistsWindowManager.nonTouchableByAll() } delay(switchWindowIntervalDelay) val rect = getBoundsInScreen() val result = gesture( floatArrayOf(rect.left.toFloat() + offsetX, rect.top.toFloat() + offsetY), floatArrayOf(rect.left.toFloat() + offsetX, rect.top.toFloat() + offsetY), 0, duration, ) delay(switchWindowIntervalDelay) runMain { AssistsWindowManager.touchableByAll() } return result } /** * 在节点的右下角附近某一偏移位置执行点击(只用于评星🌟) * * @param offsetX 距离节点右边的偏移量(可为负数,单位 px) * @param offsetY 距离节点下边的偏移量(可为负数,单位 px) * @param switchWindowIntervalDelay 切换窗口等待时间 * @param duration 手势持续时间 */ suspend fun AccessibilityNodeInfo.nodeGestureClickAtBottomRightOffset( haveOffsetY:Boolean = true, switchWindowIntervalDelay: Long = 250, duration: Long = 500, horizontalRatio: Float = 0.95f, verticalRatio: Float = 0.95f ): Boolean { require(horizontalRatio in 0f..1f) { \"horizontalRatio必须介于0-1之间\" } require(verticalRatio in 0f..1f) { \"verticalRatio必须介于0-1之间\" } runMain { Log.d(AssistsCore.LOG_TAG, \"🛡️ 禁用窗口触摸\") AssistsWindowManager.nonTouchableByAll() } delay(switchWindowIntervalDelay) val rect = getBoundsInScreen().also { Log.d(AssistsCore.LOG_TAG, \"📐 原始区域: ${it.toShortString()}\") if (!it.isValid()) { Log.e(AssistsCore.LOG_TAG, \"❌ 无效区域: ${it.toShortString()}\") returnfalse } } val safeRight = rect.right - 1 val safeBottom = rect.bottom - 1 Log.d(AssistsCore.LOG_TAG, \"⚙️ 安全边界: right=$safeRight, bottom=$safeBottom\") val clickX = (rect.left + (safeRight - rect.left) * horizontalRatio) .coerceIn(rect.left.toFloat(), safeRight.toFloat()) .also { Log.d(AssistsCore.LOG_TAG, \"➡️ 计算X: ${rect.left} + (${safeRight - rect.left}*$horizontalRatio) = $it\") } val clickY = (rect.top + (safeBottom - rect.top) * verticalRatio) .coerceIn(rect.top.toFloat(), safeBottom.toFloat()) .also { Log.d(AssistsCore.LOG_TAG, \"⬇️ 计算Y: ${rect.top} + (${safeBottom - rect.top}*$verticalRatio) = $it\") } val intX = clickX.roundToInt().also { Log.d(AssistsCore.LOG_TAG, \"🔄 X舍入: $clickX → $it\") } val intY = clickY.roundToInt().also { Log.d(AssistsCore.LOG_TAG, \"🔄 Y舍入: $clickY → $it\") } if (intX !in rect.left..safeRight || intY !in rect.top..safeBottom) { Log.e(AssistsCore.LOG_TAG, \"\"\" ❗️ 坐标越界! 有效区域: [${rect.left},${rect.top}]-[${rect.right},${rect.bottom}] 安全边界: [right=$safeRight, bottom=$safeBottom] 实际坐标: ($intX, $intY) \"\"\".trimIndent()) returnfalse } Log.d(AssistsCore.LOG_TAG, \"🎯 执行点击 ($intX, $intY)\") var offsetY = 0 if(haveOffsetY){ offsetY = 191 }else{ offsetY = 0 } val result = gesture( floatArrayOf(clickX, clickY + offsetY), floatArrayOf(clickX, clickY + offsetY), 0, duration ).also { Log.d(AssistsCore.LOG_TAG, if (it) \"✅ 点击成功\"else\"❌ 点击失败\") } delay(switchWindowIntervalDelay) runMain { Log.d(AssistsCore.LOG_TAG, \"🔓 恢复窗口触摸\") AssistsWindowManager.touchableByAll() } return result } // Rect扩展函数 private fun Rect.isValid(): Boolean = !isEmpty && width() > 0 && height() > 0 fun Rect.toShortString(): String = \"[$left,$top]-[$right,$bottom]\" /** * 在元素位置执行点击手势 * @param offsetX X轴偏移量 * @param offsetY Y轴偏移量 * @param switchWindowIntervalDelay 窗口切换延迟时间 * @param duration 点击持续时间 * @return 手势是否执行成功 */ suspend fun AccessibilityNodeInfo.nodeGestureCenterClick( switchWindowIntervalDelay: Long = 250, duration: Long = 25 ): Boolean { runMain { AssistsWindowManager.nonTouchableByAll() } delay(switchWindowIntervalDelay) val rect = getBoundsInScreen() val result = gesture( floatArrayOf((rect.left.toFloat() + rect.right.toFloat())/2, (rect.top.toFloat() + rect.bottom.toFloat())/2), floatArrayOf((rect.left.toFloat() + rect.right.toFloat())/2, (rect.top.toFloat() + rect.bottom.toFloat())/2), 0, duration, ) delay(switchWindowIntervalDelay) runMain { AssistsWindowManager.touchableByAll() } return result } /** * 执行返回操作 * @return 返回操作是否成功 */ fun back(): Boolean { return AssistsService.instance?.performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK) ?: false } /** * 返回主屏幕 * @return 返回主屏幕操作是否成功 */ fun home(): Boolean { return AssistsService.instance?.performGlobalAction(AccessibilityService.GLOBAL_ACTION_HOME) ?: false } /** * 打开通知栏 * @return 打开通知栏操作是否成功 */ fun notifications(): Boolean { return AssistsService.instance?.performGlobalAction(AccessibilityService.GLOBAL_ACTION_NOTIFICATIONS) ?: false } /** * 显示最近任务 * @return 显示最近任务操作是否成功 */ fun recentApps(): Boolean { return AssistsService.instance?.performGlobalAction(AccessibilityService.GLOBAL_ACTION_RECENTS) ?: false } /** * 向元素粘贴文本 * @param text 要粘贴的文本 * @return 粘贴操作是否成功 */ fun AccessibilityNodeInfo.paste(text: String?): Boolean { performAction(AccessibilityNodeInfo.ACTION_FOCUS) AssistsService.instance?.let { val clip = ClipData.newPlainText(\"${System.currentTimeMillis()}\", text) val clipboardManager = (it.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager) clipboardManager.setPrimaryClip(clip) clipboardManager.primaryClip return performAction(AccessibilityNodeInfo.ACTION_PASTE) } returnfalse } /** * 选择元素中的文本 * @param selectionStart 选择起始位置 * @param selectionEnd 选择结束位置 * @return 文本选择操作是否成功 */ fun AccessibilityNodeInfo.selectionText(selectionStart: Int, selectionEnd: Int): Boolean { val selectionArgs = Bundle() selectionArgs.putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT, selectionStart) selectionArgs.putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT, selectionEnd) return performAction(AccessibilityNodeInfo.ACTION_SET_SELECTION, selectionArgs) } /** * 设置元素的文本内容 * @param text 要设置的文本 * @return 设置文本操作是否成功 */ fun AccessibilityNodeInfo.setNodeText(text: String?): Boolean { text ?: returnfalse return performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, bundleOf().apply { putCharSequence( AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, text ) }) } /** * 根据基准宽度计算实际X坐标 * @param baseWidth 基准宽度 * @param x 原始X坐标 * @return 计算后的实际X坐标 */ fun getX(baseWidth: Int, x: Int): Int { val screenWidth = ScreenUtils.getScreenWidth() return (x / baseWidth.toFloat() * screenWidth).toInt() } /** * 根据基准高度计算实际Y坐标 * @param baseHeight 基准高度 * @param y 原始Y坐标 * @return 计算后的实际Y坐标 */ fun getY(baseHeight: Int, y: Int): Int { var screenHeight = ScreenUtils.getScreenHeight() if (screenHeight > baseHeight) { screenHeight = baseHeight } return (y.toFloat() / baseHeight * screenHeight).toInt() } /** * 获取当前应用在屏幕中的位置 * @return 应用窗口的位置信息,如果未找到则返回null */ fun getAppBoundsInScreen(): Rect? { return AssistsService.instance?.let { return@let findById(\"android:id/content\").firstOrNull()?.getBoundsInScreen() } } /** * 初始化并缓存当前应用在屏幕中的位置 * @return 应用窗口的位置信息 */ fun initAppBoundsInScreen(): Rect? { return getAppBoundsInScreen().apply { appRectInScreen = this } } /** * 获取当前应用在屏幕中的宽度 * @return 应用窗口的宽度 */ fun getAppWidthInScreen(): Int { return appRectInScreen?.let { return@let it.right - it.left } ?: ScreenUtils.getScreenWidth() } /** * 获取当前应用在屏幕中的高度 * @return 应用窗口的高度 */ fun getAppHeightInScreen(): Int { return appRectInScreen?.let { return@let it.bottom - it.top } ?: ScreenUtils.getScreenHeight() } /** * 向前滚动可滚动元素 * @return 滚动操作是否成功 */ fun AccessibilityNodeInfo.scrollForward(): Boolean { return performAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) } /** * 向后滚动可滚动元素 * @return 滚动操作是否成功 */ fun AccessibilityNodeInfo.scrollBackward(): Boolean { return performAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) } suspend fun launchApp(intent: Intent): Boolean { val completableDeferred = CompletableDeferred() val view = View(AssistsService.instance).apply { setOnClickListener { runCatching { intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) AssistsService.instance?.startActivity(intent) completableDeferred.complete(true) }.onFailure { completableDeferred.complete(false) } } } runMain { AssistsWindowManager.add(view) } CoroutineWrapper.launch { delay(250) val clickResult = gestureClick(ScreenUtils.getScreenWidth() / 2.toFloat(), ScreenUtils.getScreenHeight() / 2.toFloat()) if (!clickResult) { completableDeferred.complete(false) } delay(250) runMain { AssistsWindowManager.removeView(view) } } return completableDeferred.await() } suspend fun launchApp(packageName: String): Boolean { val completableDeferred = CompletableDeferred() val view = View(AssistsService.instance).apply { setOnClickListener { runCatching { val intent = AssistsService.instance?.packageManager?.getLaunchIntentForPackage(packageName) intent?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) AssistsService.instance?.startActivity(intent) completableDeferred.complete(true) }.onFailure { completableDeferred.complete(false) } } } runMain { AssistsWindowManager.add(view) } CoroutineWrapper.launch { delay(250) val clickResult = gestureClick(ScreenUtils.getScreenWidth() / 2.toFloat(), ScreenUtils.getScreenHeight() / 2.toFloat()) if (!clickResult) { completableDeferred.complete(false) } delay(250) runMain { AssistsWindowManager.removeView(view) } } return completableDeferred.await() } /** * 在日志中输出元素的详细信息 * @param tag 日志标签 */ fun AccessibilityNodeInfo.logNode(tag: String = LOG_TAG) { StringBuilder().apply { val rect = getBoundsInScreen() append(\"-------------------------------------\\n\") append(\"位置:left=${rect.left}, top=${rect.top}, right=${rect.right}, bottom=${rect.bottom}, width=${rect.width()}, height=${rect.height()} \\n\") append(\"文本:$text \\n\") append(\"内容描述:$contentDescription \\n\") append(\"id:$viewIdResourceName \\n\") append(\"类型:${className} \\n\") append(\"是否已经获取到到焦点:$isFocused \\n\") append(\"是否可滚动:$isScrollable \\n\") append(\"是否可点击:$isClickable \\n\") append(\"是否可用:$isEnabled \\n\") Log.d(tag, toString()) } }}