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 测试中的优势:

  1. 无需修改被测应用:无障碍服务直接作用于系统界面层,无需对被测应用进行任何代码改动,天然适用于黑盒测试场景。

  2. 支持跨应用操作:能够感知并操作多个应用的界面,适合测试涉及应用间跳转、系统弹窗等复杂场景。

  3. 强大的界面分析能力:通过 AccessibilityNodeInfo 可访问当前界面完整的控件层级结构与属性信息,支持精准的元素识别与布局验证。

  4. 自动化测试灵活性高:可模拟用户各种操作(如点击、输入、滑动),结合逻辑判断,实现高度自定义的测试脚本与任务流程。

  5. 良好的兼容性:无障碍服务作为 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.devor: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())
        }
    }
}
相关推荐
阿巴斯甜2 小时前
Android 报错:Zip file '/Users/lyy/develop/repoAndroidLapp/l-app-android-ble/app/bu
android
Kapaseker3 小时前
实战 Compose 中的 IntrinsicSize
android·kotlin
xq95274 小时前
Andorid Google 登录接入文档
android
黄林晴5 小时前
告别 Modifier 地狱,Compose 样式系统要变天了
android·android jetpack
冬奇Lab18 小时前
Android触摸事件分发、手势识别与输入优化实战
android·源码阅读
城东米粉儿21 小时前
Android MediaPlayer 笔记
android
Jony_21 小时前
Android 启动优化方案
android
阿巴斯甜21 小时前
Android studio 报错:Cause: error=86, Bad CPU type in executable
android
张小潇21 小时前
AOSP15 Input专题InputReader源码分析
android
_小马快跑_1 天前
Kotlin | 协程调度器选择:何时用CoroutineScope配置,何时用launch指定?
android