外挂三部曲(一) —— Android 7.0 以上,使用辅助功能模拟点击对应坐标

·  阅读 1407

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第3天,点击查看活动详情

在 Android 中,有个非常强大的功能,那就是辅助功能。辅助功能是用于服务残障人士的。比如对于视障人士来说,辅助功能可以帮助他们读出屏幕上的文字或图片(阅读图片时会播放其 ContentDescription 属性)。

除此之外,辅助功能还可以模拟点击,模拟手势等等。

这篇文章我们就来学习辅助功能的基本用法。

一、新建 MyAccessibilityService 类

首先,新建一个 MyAccessibilityService 类,继承自系统的 AccessibilityService 类:

class MyAccessibilityService : AccessibilityService() {
    override fun onAccessibilityEvent(accessibilityEvent: AccessibilityEvent?) {
    }

    override fun onInterrupt() {
    }
}

继承 AccessibilityService 后,需要实现两个方法 onAccessibilityEvent 和 onInterrupt。

onAccessibilityEvent 方法中,带有一个参数 AccessibilityEvent,当界面发生改变时,这个方法就会被调用,界面改变的具体信息就会包含在这个参数中。

onInterrupt 方法辅助服务被中断了。

我们暂时先在这两个方法中简单地打印一行日志,待会再在其中添加具体的功能。

二、注册 Service

写好 MyAccessibilityService 类后,需要在 AndroidManifest 中注册。注册辅助服务和注册一般的服务略有区别:

<service
    android:name=".MyAccessibilityService"
    android:description="@string/description_in_manifest"
    android:exported="true"
    android:label="@string/label_in_manifest"
    android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
    <intent-filter>
        <action android:name="android.accessibilityservice.AccessibilityService" />
    </intent-filter>
    <meta-data
        android:name="android.accessibilityservice"
        android:resource="@xml/accessibility_config" />
  • 首先是需要声明一个 label,这个 label 是在系统的辅助功能设置中显示的名字
  • description 属性可以不写,指的是在辅助功能设置中显示的该辅助功能的描述
  • permission 属性必须写,表示这个服务需要绑定 AccssibilityService
  • 在这个 service 中,有一个 inter-filter,这个也是必须写的,不妨记作固定格式
  • 还有一个 meta-data,其中的 resource 属性指向一个 xml 文件,这个文件中可以配置允许这个辅助功能做哪些事

xml 文件如下:

<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
    android:accessibilityEventTypes="typeAllMask"
    android:accessibilityFeedbackType="feedbackGeneric"
    android:canPerformGestures="true"
    android:canRetrieveWindowContent="true"
    android:description="@string/description_in_xml"
    android:notificationTimeout="100" />

AndroidManifest 和 xml 中,用到的字符串资源文件如下:

<string name="label_in_manifest">Label in manifest</string>
<string name="description_in_manifest">Description in manifest</string>
<string name="description_in_xml">Description in xml</string>

这些都设置好之后,这个 Service 就注册成功了。

现在就可以运行一下看看效果了。

三、开启辅助服务

此时运行程序,会发现没有任何 onAccessibilityEvent 事件打出。这是因为辅助功能是一项比较危险的功能,默认是关闭的。需要到系统设置中手动打开才可以使用。

通过图中的三个步骤,确保 Use Label in manifest 的开关是打开的,我们的辅助功能就被正式启用了。

从图中我们也可以看出注册 service 时写的字符串各自的使用场景。

在程序中,也可以通过代码到达辅助功能设置页面,代码如下:

object AccessibilitySettingUtils {
    fun jumpToAccessibilitySetting(context: Context) {
        val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)
        context.startActivity(intent)
    }
}

开启辅助功能后,点击桌面就会在 Log 控制台收到以下消息:

D/~~~: accessibilityEvent: EventType: TYPE_WINDOW_CONTENT_CHANGED; EventTime: 101990739; PackageName: com.google.android.apps.nexuslauncher; MovementGranularity: 0; Action: 0; ContentChangeTypes: [CONTENT_CHANGE_TYPE_SUBTREE]; WindowChangeTypes: [] [ ClassName: android.widget.FrameLayout; Text: []; ContentDescription: null; ItemCount: -1; CurrentItemIndex: -1; Enabled: true; Password: false; Checked: false; FullScreen: false; Scrollable: false; BeforeText: null; FromIndex: -1; ToIndex: -1; ScrollX: 0; ScrollY: 0; MaxScrollX: 0; MaxScrollY: 0; ScrollDeltaX: -1; ScrollDeltaY: -1; AddedCount: -1; RemovedCount: -1; ParcelableData: null ]; recordCount: 0

这表示我们接收到了一个 accessibilityEvent 消息,他的类型是 TYPE_WINDOW_CONTENT_CHANGED,意思是窗口内容发生了变化,PackageName 中表示这个变化的内容所在的包名。

说明我们的辅助功能已经开始工作了。

四、点击对应坐标

想要查看屏幕上的坐标,可以在开发人员选项中打开显示坐标的设置:

pointer location

打开这个设置后,每次点击屏幕,都会在顶部显示当前点击的位置坐标。

点击对应坐标的代码如下:

object ClickUtils {
    fun click(accessibilityService: AccessibilityService, x: Float, y: Float) {
        Log.d("~~~", "click: ($x, $y)")
        val builder = GestureDescription.Builder()
        val path = Path()
        path.moveTo(x, y)
        path.lineTo(x, y)
        builder.addStroke(GestureDescription.StrokeDescription(path, 0, 1))
        val gesture = builder.build()
        accessibilityService.dispatchGesture(gesture, object : AccessibilityService.GestureResultCallback() {
            override fun onCancelled(gestureDescription: GestureDescription) {
                super.onCancelled(gestureDescription)
            }

            override fun onCompleted(gestureDescription: GestureDescription) {
                super.onCompleted(gestureDescription)
            }
        }, null)
    }
}

在这个工具类中,我们将 AccessibilityService 和坐标传入。

通过 GestureDescription 的 Builder 构建一个手势,通过 Builder 的 addStoke 方法传入一条 path,这条 path 我们设置为从 (x, y) 坐标移动到 (x, y) 坐标。

StrokeDescription 的后两个参数表示 startTime 和 duration,分别表示手势的开始时间以及持续时间,以毫秒为单位。我将其设置为 0 和 1,也就是 1ms 以内完成从 (x, y) 坐标移动到 (x, y) 坐标。

这样就模拟出了一个点击事件。

通过 accessibilityService 的 dispatchGesture 方法触发这个手势,这个方法接收两个参数,第一个参数是手势的具体配置,第二个参数表示手势执行的结果,包含执行完成和取消两种结果。

五、测试

我们不妨写个简单的页面来测试一下。

先写一个页面,包含两个按钮:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/btn_jump_to_settings"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Jump to Settings"
        android:textAllCaps="false"
        app:layout_constraintTop_toTopOf="parent" />
    
    <Button
        android:id="@+id/btn_test"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Test"
        app:layout_constraintTop_toBottomOf="@id/btn_jump_to_settings" />

</androidx.constraintlayout.widget.ConstraintLayout>

这个页面的效果图:

main layout

在 app/build.gradle 中,开启 ViewBinding,目的是使用这些按钮更方便:

buildFeatures {
    viewBinding true
}

在 MainActivity 中,设置按钮的点击事件:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        binding.btnJumpToSettings.setOnClickListener {
            AccessibilitySettingUtils.jumpToAccessibilitySetting(this)
        }
        binding.btnTest.setOnClickListener {
            Toast.makeText(this, "I'm clicked", Toast.LENGTH_SHORT).show()
        }
    }
}
  • 第一个按钮 btnJumpToSettings 的作用是点击跳转到辅助服务设置页
  • 第二个按钮用来做测试,点击时会弹出 Toast:"I'm clicked"。待会我们就模拟点击这个按钮。

查看一下第二个按钮的坐标位置:

location

从图中可以看出,第三个按钮的坐标大约是 (622,406)。

在 MyAccessibilityService 的 onServiceConnected 方法中,模拟点击此坐标:

override fun onServiceConnected() {
    super.onServiceConnected()
    Log.d("~~~", "onServiceConnected")
    thread {
        Thread.sleep(5000)
        ClickUtils.click(this, 622f, 406f)
    }
}

可以看到,我们在 onServiceConnected 方法中,开启了一个线程,先睡眠 5s,再调用 ClickUtils.click(this, 622f, 406f) 方法点击 (622,406)。

之所以要睡眠 5s,是因为在设置中开启了辅助服务后,onServiceConnected 方法就会立刻回调,而我们要从设置页面返回到此页面才能看到这个按钮被点击的效果,返回过程需要一点时间。

开测:

accessibilityService.gif

可以看到,我先点击了第一个按钮到达辅助服务设置页面,在开启辅助服务后,我立即返回了 MainActivity,等待几秒后,Test 按钮被自动点击了。说明我们的辅助点击功能已经正常工作了。

六、后记

辅助功能除了模拟点击,还有很多其他的玩法。由于点击时传入的是一条路径,所以它实际上可以用来模拟任意手势,并不局限于点击事件,本文只是用点击事件作为示例,希望读者可以举一反三。

辅助功能还可以解析页面上的元素,通过其 id 或者 text 来找到 AccessibilityNodeInfo,再通过 AccessibilityNodeInfo 的 performAction 来模拟点击。但这种方式有较大的局限性,不但需要知道 view 的 id 或者 text,而且对于 WebView、GameView 等常常无能为力。所以我更喜欢用传入坐标的方式来模拟点击。

需要注意的是,dispatchGesture 方法只能在 Android 7.0 以上使用,所以本文讲解的内容只适合 Android 7.0 以上。

传入坐标的方式非常强大,它不局限于本应用内,它就像模拟出了一只手,可以在任何时刻帮助我们点击屏幕的任何位置。

本文只是模拟点击了一次屏幕,实际上我们可以开启一个循环不停地点击。比如可以实现这样的点击序列:等待 3s 点击位置 A,然后等待 2s 点击两次位置 B,等待 500ms 再点击 5 次位置 C 等等。让辅助功能帮我们做一些重复的工作。

缺点是它不知道当前页面显示的内容是什么,这一点可以通过图片识别来解决。

我使用辅助服务主要是为了给自己的手机添加一些好玩的功能,配合上图片识别,可以做很多的事情,其乐无穷。

下一篇文章计划写如何在任意时刻截取手机屏幕(不局限于本应用内),为图片识别做准备,敬请期待。

七、参考文章

Android辅助功能原理与基本使用详解-AccessibilityService

继续阅读:

外挂三部曲(二) —— Android 应用外截屏

外挂三部曲(三) —— Android 图片相似度对比

分类:
Android
标签:
分类:
Android
标签:
收藏成功!
已添加到「」, 点击更改