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())
        }
    }
}
相关推荐
东风西巷32 分钟前
NealFun安卓版:创意无限,娱乐至上
android·人工智能·智能手机·娱乐·软件需求
小李飞飞砖6 小时前
Sophix、Tinker 和 Robust 三大主流 Android 热修复框架的详细对比
android
感觉不怎么会7 小时前
Android 12 - 部分相机横屏显示方案
android
人生游戏牛马NPC1号9 小时前
学习 Flutter (一)
android·学习·flutter
fundroid9 小时前
Swift 进军 Android,Kotlin 该如何应对?
android·ios
前端世界10 小时前
鸿蒙系统安全机制全解:安全启动 + 沙箱 + 动态权限实战落地指南
android·安全·harmonyos
_一条咸鱼_12 小时前
Vulkan入门教程:源码级解析
android·面试·android jetpack
嘉小华12 小时前
ThreadLocal 详解
android
wkj00113 小时前
php 如何通过mysqli操作数据库?
android·数据库·php
kymjs张涛14 小时前
零一开源|前沿技术周报 #7
android·前端·ios