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了
go
npm install -g appium
appium -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
go
appium server --allow-cors
2.4.2.4连接设备并使用浏览器打开 Appium Inspector
-
连接Android设备,通过命令行 adb devices 查看确保设备已连接。
-
使用浏览器打开 Appium Inspector
-
在Appium Inspector页面中的JSON Representation输入以下参数
go
{
"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
go
brew install python3
安装 Weditor
使用 pip 命令安装最新版 Weditor:
go
pip install -U weditor
安装完成后验证是否成功:
go
weditor --help
若输出帮助信息,说明安装成功
启动 Weditor 服务
go
weditor

2.4.4、uiauto.dev
uiauto.dev帮助你快速编写App UI自动化脚本,它由网页端+客户端组成
-
支持Android和iOS
-
支持鼠标选择控件查看属性,控件树辅助精确定位,生成选择器
-
支持找色功能,方向键微调坐标,获取RGB、HSB
uiauto.dev安装
安装Python 3.8+
安装并启动
go
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 中注册:
go
<service
android:name=".MyAccessibilityService"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService"/>
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config" />
</service>
3.1.2.配置服务(res/xml/accessibility_service_config.xml)
go
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeAllMask"
android:packageNames="com.target.app"
android:accessibilityFeedbackType="feedbackGeneric"
android:notificationTimeout="100"
android:canRetrieveWindowContent="true"
android:description="@string/accessibility_service_description" />
3.1.3.编写类继承 AccessibilityService
go
class MyAccessibilityService : AccessibilityService() {
override fun onAccessibilityEvent(event: AccessibilityEvent) {
// 处理 UI 事件,如点击、滚动等
}
override fun onInterrupt() {
// 被打断时调用(如服务被系统关闭)
}
override fun onServiceConnected() {
// 服务连接成功
}
}
3.2、无障碍服务核心逻辑封装
go
/**
* 无障碍服务核心类
* 提供对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<AccessibilityNodeInfo> {
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<AccessibilityNodeInfo> {
if (this == null) return arrayListOf()
findAccessibilityNodeInfosByViewId(id)?.let {
return it
}
return arrayListOf()
}
/**
* 通过文本内容查找所有符合条件的元素
* @param text 要查找的文本内容
* @return 符合条件的元素列表
*/
fun findByText(text: String): List<AccessibilityNodeInfo> {
return AssistsService.instance?.rootInActiveWindow?.findByText(text) ?: arrayListOf()
}
/**
* 根据文本查找元素
* @param searchText 可选的文本过滤条件
* @return 符合所有条件的元素列表
*/
fun findBySearchText(
searchText: String? = null
): List<AccessibilityNodeInfo> {
return findBySearchText(searchText,0)
}
/**
* 根据文本查找元素
* @param searchText 可选的文本过滤条件
* @param searchType 搜索条件类型 0:完全匹配 1:包含匹配
* @return 符合所有条件的元素列表
*/
fun findBySearchText(
searchText: String? = null,
searchType:Int = 0
): List<AccessibilityNodeInfo> {
var nodeList = arrayListOf<AccessibilityNodeInfo>()
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<AccessibilityNodeInfo>? = 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<AccessibilityNodeInfo> {
val listResult = arrayListOf<AccessibilityNodeInfo>()
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<AccessibilityNodeInfo> {
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<String> {
if (this == null) return arrayListOf()
val texts = arrayListOf<String>()
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<AccessibilityNodeInfo> {
var nodeList = arrayListOf<AccessibilityNodeInfo>()
getAllNodes().forEach {
if (TextUtils.equals(className, it.className)) {
nodeList.add(it)
}
}
nodeList = viewId?.let {
if (it.isEmpty()) return@let nodeList
return@let arrayListOf<AccessibilityNodeInfo>().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<AccessibilityNodeInfo>().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<AccessibilityNodeInfo>().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<AccessibilityNodeInfo> {
var nodeList = arrayListOf<AccessibilityNodeInfo>()
getNodes().forEach {
if (TextUtils.equals(className, it.className)) {
nodeList.add(it)
}
}
nodeList = viewId?.let {
return@let arrayListOf<AccessibilityNodeInfo>().apply {
addAll(nodeList.filter {
return@filter it.viewIdResourceName == viewId
})
}
} ?: let {
return@let nodeList
}
nodeList = text?.let {
return@let arrayListOf<AccessibilityNodeInfo>().apply {
addAll(nodeList.filter {
return@filter it.txt() == text
})
}
} ?: let { return@let nodeList }
nodeList = des?.let {
return@let arrayListOf<AccessibilityNodeInfo>().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<AccessibilityNodeInfo>()
findFirstParentByTags(className, nodeList)
return nodeList.firstOrNull()
}
/**
* 递归查找符合指定类型的父元素
* @param className 要查找的父元素类名
* @param container 用于存储查找结果的列表
*/
fun AccessibilityNodeInfo.findFirstParentByTags(className: String, container: ArrayList<AccessibilityNodeInfo>) {
getParent()?.let {
if (TextUtils.equals(className, it.className)) {
container.add(it)
} else {
it.findFirstParentByTags(className, container)
}
}
}
/**
* 获取当前窗口中的所有元素
* @return 包含所有元素的列表
*/
fun getAllNodes(): ArrayList<AccessibilityNodeInfo> {
val nodeList = arrayListOf<AccessibilityNodeInfo>()
AssistsService.instance?.rootInActiveWindow?.getNodes(nodeList)
return nodeList
}
/**
* 获取指定元素下的所有子元素
* @return 包含所有子元素的列表
*/
fun AccessibilityNodeInfo.getNodes(): ArrayList<AccessibilityNodeInfo> {
val nodeList = arrayListOf<AccessibilityNodeInfo>()
this.getNodes(nodeList)
return nodeList
}
/**
* 递归获取元素的所有子元素
* @param nodeList 用于存储子元素的列表
*/
private fun AccessibilityNodeInfo.getNodes(nodeList: ArrayList<AccessibilityNodeInfo>) {
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<AccessibilityNodeInfo>(1).apply {
findFirstParentClickable(this)
return this[0]
}
}
/**
* 递归查找可点击的父元素
* @param nodeInfo 用于存储查找结果的数组
*/
private fun AccessibilityNodeInfo.findFirstParentClickable(nodeInfo: Array<AccessibilityNodeInfo?>) {
if (parent?.isClickable == true) {
nodeInfo[0] = parent
return
} else {
parent?.findFirstParentClickable(nodeInfo)
}
}
/**
* 获取元素的直接子元素(不包括子元素的子元素)
* @return 包含直接子元素的列表
*/
fun AccessibilityNodeInfo.getChildren(): ArrayList<AccessibilityNodeInfo> {
val nodes = arrayListOf<AccessibilityNodeInfo>()
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<Boolean>()
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<Boolean>()
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<Boolean>()
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<Boolean>()
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())
}
}
}