当前位置:   article > 正文

Android外挂开发探索

学习安卓手游外挂的开发

3575c05db776aa6de4ff90be87c28643.jpeg

/   今日科技快讯   /

近日,爱奇艺和抖音集团宣布达成合作,将围绕长视频内容的二次创作与推广等方面展开探索。依据合作,爱奇艺将向抖音集团授权其内容资产中拥有信息网络传播权及转授权的长视频内容,用于短视频创作。双方对解说、混剪、拆条等短视频二创形态做了具体约定,将共同推动长视频内容知识产权的规范使用。

/   作者简介   /

本篇文章来自wkxjc的投稿,文章主要分享了如何利用Android中辅助功能实现一个简单的外挂,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章。

wkxjc的博客地址:

https://juejin.cn/user/1116759545357182

/   前言   /

在 Android 中,有个非常强大的功能,那就是辅助功能。

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

除此之外,辅助功能还可以模拟点击,模拟手势等等,对于我这样的懒癌人士,辅助功能可以帮助我做一些重复、机械的点击操作。

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

比如我们可以开启一个循环,不断地点击某个位置,这在某些场景中可以解放我们的手指细胞。还可以实现类似这样的点击序列:等待 3s 点击位置 A,然后等待 2s 点击两次位置 B,等待 500ms 再点击 5 次位置 C 等等。以此完成一些日常的签到打卡等功能。

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

所以想要实现一个简单的外挂,可以分三步走:

  • 模拟点击

  • 应用外截屏

  • 图片识别

接下来我们就来一步步地攻克这三个技术点。

/   模拟点击   /

新建 MyAccessibilityService 类

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

  1. class MyAccessibilityService : AccessibilityService() {
  2.     override fun onAccessibilityEvent(accessibilityEvent: AccessibilityEvent?) {
  3.     }
  4.     override fun onInterrupt() {
  5.     }
  6. }

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

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

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

注册 Service

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

  1. <service
  2.     android:name=".MyAccessibilityService"
  3.     android:description="@string/description_in_manifest"
  4.     android:exported="true"
  5.     android:label="@string/label_in_manifest"
  6.     android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
  7.     <intent-filter>
  8.         <action android:name="android.accessibilityservice.AccessibilityService" />
  9.     </intent-filter>
  10.     <meta-data
  11.         android:name="android.accessibilityservice"
  12.         android:resource="@xml/accessibility_config" />
  • 首先是需要声明一个 label,这个 label 是在系统的辅助功能设置中显示的名字

  • description 属性可以不写,指的是在辅助功能设置中显示的该辅助功能的描述

  • permission 属性必须写,表示这个服务需要绑定 AccssibilityService

  • 在这个 service 中,有一个 inter-filter,这个也是必须写的,不妨记作固定格式

  • 还有一个 meta-data,其中的 resource 属性指向一个 xml 文件,这个文件中可以配置允许这个辅助功能做哪些事

xml 文件如下:

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

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

  1. <string name="label_in_manifest">Label in manifest</string>
  2. <string name="description_in_manifest">Description in manifest</string>
  3. <string name="description_in_xml">Description in xml</string>

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

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

开启辅助服务

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

0f3ef556a7dc2e23a68baa6888bf96c2.png

6ee2ce8d325f41c841e51e48e7b91736.png

08551078ac24d53fa1742a9f2c903e13.png

通过图中的三个步骤,确保 Use Label in manifest 的开关是打开的,我们的辅助功能就被正式启用了。从图中我们也可以看出注册 service 时写的字符串各自的使用场景。在程序中,也可以通过代码到达辅助功能设置页面,代码如下:

  1. object AccessibilitySettingUtils {
  2.     fun jumpToAccessibilitySetting(context: Context) {
  3.         val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)
  4.         context.startActivity(intent)
  5.     }
  6. }

开启辅助功能后,点击桌面就会在 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 中表示这个变化的内容所在的包名。

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

点击对应坐标

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

e0fb5886a3607e1e01517f62c565ae62.png

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

  1. object ClickUtils {
  2.     fun click(accessibilityService: AccessibilityService, x: Float, y: Float) {
  3.         Log.d("~~~""click: ($x, $y)")
  4.         val builder = GestureDescription.Builder()
  5.         val path = Path()
  6.         path.moveTo(x, y)
  7.         path.lineTo(x, y)
  8.         builder.addStroke(GestureDescription.StrokeDescription(path, 01))
  9.         val gesture = builder.build()
  10.         accessibilityService.dispatchGesture(gesture, object : AccessibilityService.GestureResultCallback() {
  11.             override fun onCancelled(gestureDescription: GestureDescription) {
  12.                 super.onCancelled(gestureDescription)
  13.             }
  14.             override fun onCompleted(gestureDescription: GestureDescription) {
  15.                 super.onCompleted(gestureDescription)
  16.             }
  17.         }, null)
  18.     }
  19. }

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

通过 GestureDescription 的 Builder 构建一个手势,通过 Builder 的 addStoke 方法传入一条 path,这条 path 我们设置为从 (x, y) 坐标移动到 (x, y) 坐标。StrokeDescription 的后两个参数表示 startTime 和 duration,分别表示手势的开始时间以及持续时间,以毫秒为单位。我将其设置为 0 和 1,也就是 1ms 以内完成从 (x, y) 坐标移动到 (x, y) 坐标。

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

测试

我们不妨写个简单的页面来测试一下。先写一个页面,包含两个按钮:

  1. <?xml version="1.0" encoding="utf-8"?>
  2. <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
  3.     xmlns:app="http://schemas.android.com/apk/res-auto"
  4.     xmlns:tools="http://schemas.android.com/tools"
  5.     android:layout_width="match_parent"
  6.     android:layout_height="match_parent"
  7.     tools:context=".MainActivity">
  8.     <Button
  9.         android:id="@+id/btn_jump_to_settings"
  10.         android:layout_width="match_parent"
  11.         android:layout_height="wrap_content"
  12.         android:text="Jump to Settings"
  13.         android:textAllCaps="false"
  14.         app:layout_constraintTop_toTopOf="parent" />
  15.     <Button
  16.         android:id="@+id/btn_test"
  17.         android:layout_width="match_parent"
  18.         android:layout_height="wrap_content"
  19.         android:text="Test"
  20.         app:layout_constraintTop_toBottomOf="@id/btn_jump_to_settings" />
  21. </androidx.constraintlayout.widget.ConstraintLayout>

这个页面的效果图:

51f17f939372a889e094edac2dc5fc88.png

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

  1. buildFeatures {
  2.     viewBinding true
  3. }

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

  1. class MainActivity : AppCompatActivity() {
  2.     override fun onCreate(savedInstanceState: Bundle?) {
  3.         super.onCreate(savedInstanceState)
  4.         val binding = ActivityMainBinding.inflate(layoutInflater)
  5.         setContentView(binding.root)
  6.         binding.btnJumpToSettings.setOnClickListener {
  7.             AccessibilitySettingUtils.jumpToAccessibilitySetting(this)
  8.         }
  9.         binding.btnTest.setOnClickListener {
  10.             Toast.makeText(this, "I'm clicked", Toast.LENGTH_SHORT).show()
  11.         }
  12.     }
  13. }
  • 第一个按钮 btnJumpToSettings 的作用是点击跳转到辅助服务设置页

  • 第二个按钮用来做测试,点击时会弹出 Toast:"I'm clicked"。待会我们就模拟点击这个按钮。

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

d46b2082785a3cba65e9e2c3fe7f9111.png

从图中可以看出,第三个按钮的坐标大约是 (622,406)。在 MyAccessibilityService 的 onServiceConnected 方法中,模拟点击此坐标:

  1. override fun onServiceConnected() {
  2.     super.onServiceConnected()
  3.     Log.d("~~~""onServiceConnected")
  4.     thread {
  5.         Thread.sleep(5000)
  6.         ClickUtils.click(this, 622f, 406f)
  7.     }
  8. }

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

开测

1696be27e0ee9007712a3b1e9cdf57a1.gif

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

注:实际上这里的点击并不局限于本应用内,之所以要返回这个页面再点击,只是为了讲解时更方便,让大家能更清楚地看到效果。

/   应用外截屏   /

应用内截屏

在讲解 Android 应用外截屏之前,我们先看一下 Android 应用内截屏。在 Android 应用内截屏非常简单,只需要获取 View 的缓存即可:

  1. fun screenShot(activity: Activity): Bitmap {
  2.     return view2Bitmap(activity.window.decorView)
  3. }
  4. fun view2Bitmap(view: View): Bitmap {
  5.     view.isDrawingCacheEnabled = true
  6.     return view.drawingCache
  7. }

本文重点讲述应用外截屏。应用外截屏其实也不复杂,只需要两步:

  • 通过 MediaProjectionManager 的 getMediaProjection 方法获取到 MediaProjection 对象。

  • 再通过 MediaProjection 的 createVirtualDisplay 方法就能截取屏幕了。

应用外截屏

构建 MediaProjectionManager 对象的方式非常简单,调用 getSystemService(MEDIA_PROJECTION_SERVICE) 方法就可以了:

private val mediaProjectionManager: MediaProjectionManager by lazy { getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager }

构建 MediaProjection 稍微复杂一点,构建 MediaProjection 对象需要两个参数,一个 resultCode,一个 resultData。

这两个参数什么意思呢,为什么需要它们呢?

这是因为截取应用外屏幕有侵犯用户隐私的风险,所以截屏之前需要获得用户的同意。所以在截屏前需要调用 startActivityForResult 方法询问用户:这个应用准备截屏了,你同意吗?

在用户同意后,onActivityResult 方法中就会携带 resultCode 和 resultData 参数。有了这两个参数,我们就可以构建 MediaProjection 对象了。

Talk is cheap, show me the code. 我们来一起写个 Demo。

首先是布局文件:

  1. <?xml version="1.0" encoding="utf-8"?>
  2. <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
  3.     xmlns:app="http://schemas.android.com/apk/res-auto"
  4.     xmlns:tools="http://schemas.android.com/tools"
  5.     android:layout_width="match_parent"
  6.     android:layout_height="match_parent"
  7.     tools:context=".MainActivity">
  8.     <SurfaceView
  9.         android:id="@+id/surfaceView"
  10.         android:layout_width="match_parent"
  11.         android:layout_height="0dp"
  12.         app:layout_constraintBottom_toTopOf="@id/btnStart"
  13.         app:layout_constraintTop_toTopOf="parent" />
  14.     <Button
  15.         android:id="@+id/btnStart"
  16.         android:layout_width="match_parent"
  17.         android:layout_height="wrap_content"
  18.         android:text="Start Screen Capture"
  19.         android:textAllCaps="false"
  20.         app:layout_constraintBottom_toTopOf="@id/btnStop"
  21.         app:layout_constraintTop_toBottomOf="@id/surfaceView" />
  22.     <Button
  23.         android:id="@+id/btnStop"
  24.         android:layout_width="match_parent"
  25.         android:layout_height="wrap_content"
  26.         android:text="Stop Screen Capture"
  27.         android:textAllCaps="false"
  28.         app:layout_constraintBottom_toBottomOf="parent" />
  29. </androidx.constraintlayout.widget.ConstraintLayout>

效果图:

aaca030018eb9cb005a62e253a2aa5cf.png

布局文件中,有一个 SurfaceView,待会我们将用它来展示截图内容。底部有两个按钮,一个 Start Screen Capture,一个 Stop Screen Capture,分别表示开始截图和停止截图。在 build.gradle 中开启 ViewBinding,使得引用控件更加方便:

  1. buildFeatures {
  2.     viewBinding true
  3. }

在 MainActivity 中:

  1. const val REQUEST_MEDIA_PROJECTION = 1
  2. class MainActivity : AppCompatActivity() {
  3.     private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) }
  4.     private val mediaProjectionManager: MediaProjectionManager by lazy { getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager }
  5.     private var mediaProjection: MediaProjection? = null
  6.     private var virtualDisplay: VirtualDisplay? = null
  7.     override fun onCreate(savedInstanceState: Bundle?) {
  8.         super.onCreate(savedInstanceState)
  9.         setContentView(binding.root)
  10.         binding.btnStart.setOnClickListener {
  11.             Log.d("~~~""Requesting confirmation")
  12.             startActivityForResult(mediaProjectionManager.createScreenCaptureIntent(), REQUEST_MEDIA_PROJECTION)
  13.         }
  14.         binding.btnStop.setOnClickListener {
  15.             Log.d("~~~""Stop screen capture")
  16.             stopScreenCapture()
  17.         }
  18.     }
  19.     override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
  20.         super.onActivityResult(requestCode, resultCode, data)
  21.         if (requestCode == REQUEST_MEDIA_PROJECTION) {
  22.             if (resultCode != RESULT_OK) {
  23.                 Log.d("~~~""User cancelled")
  24.                 return
  25.             }
  26.             Log.d("~~~""Starting screen capture")
  27.             mediaProjection = mediaProjectionManager.getMediaProjection(resultCode, data!!)
  28.             virtualDisplay = mediaProjection!!.createVirtualDisplay(
  29.                 "ScreenCapture",
  30.                 ScreenUtils.getScreenWidth(), ScreenUtils.getScreenHeight(), ScreenUtils.getScreenDensityDpi(),
  31.                 DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
  32.                 binding.surfaceView.holder.surface, null, null
  33.             )
  34.         }
  35.     }
  36.     private fun stopScreenCapture() {
  37.         Log.d("~~~""stopScreenCapture, virtualDisplay = $virtualDisplay")
  38.         virtualDisplay?.release()
  39.         virtualDisplay = null
  40.     }
  41. }

其中,用到的 ScreenUtils 的作用是获取屏幕的宽高和密度。代码如下:

  1. object ScreenUtils {
  2.     fun getScreenWidth(): Int {
  3.         return Resources.getSystem().displayMetrics.widthPixels
  4.     }
  5.     fun getScreenHeight(): Int {
  6.         return Resources.getSystem().displayMetrics.heightPixels
  7.     }
  8.     fun getScreenDensityDpi(): Int {
  9.         return Resources.getSystem().displayMetrics.densityDpi
  10.     }
  11. }

当点击 Start 按钮时,调用 startActivityForResult 询问用户是否同意截屏,这个方法中传入的 Intent 是 mediaProjectionManager.createScreenCaptureIntent(),这是专门用于询问用户是否同意截屏的 Intent,调用这行代码后,会弹出这样一个弹窗:

1f4d798fa9b58d4d5e25220c6829c56d.png

如果用户点了确认,也就是上图中的 “Start now” 按钮,onActivityResult 就会收到 resultCode == RESULT_OK,以及用户确认后的 data,通过这两个参数,我们就能构建出 mediaProjection 对象了。

获取到 mediaProjection 对象后,通过 createVirtualDisplay 方法开始截屏。这个方法接收多个参数,第一个参数表示 VirtualDisplay 的名字,随意传入一个字符串即可。

紧跟着的三个参数表示屏幕的宽高和密度。下一个参数 DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR 表示 VirtualDisplay 的 flag,有多种值可选,我暂时不清楚几种 flag 的区别,不妨先记做固定写法。下一个参数表示展示截图结果的 Surface,这里传入 binding.surfaceView.holder.surface,截图结果就会展示到 SurfaceView 上了。最后两个参数一个是 callback,一个是 handler,是用来处理截图的回调的,我们暂时用不上,都传入 null 即可。

需要注意的是,当 createVirtualDisplay 方法调用后,设备就会不断地获取当前屏幕,直到 createVirtualDisplay 创建的 virtualDisplay 对象被 release 才会停止截屏。所以我们在 Stop 按钮的点击事件中,调用了 virtualDisplay 的 release 方法。

整体来说代码还是很简单的,我们运行一下试试:

eb2fe6756096dc7630be658fc1a173b0.gif

可以看到,直接 crash 了...查看 Logcat 控制台:

java.lang.SecurityException: Media projections require a foreground service of type ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION

报了一个 SecurityException,Media projections 需要一个带有 ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION 类型的前台 Service。

前台 Service

我在编写这个 Demo 时,targetSdk 设置的是最新的版本:31,事实上,如果读者在编写此 Demo 时,targetSdk 的版本在 28 或以下,就不会遇到这个错误,此时就已经能正常截屏了。

只有 targetSdk 在 28 以上时,才会出现这个错误。SDK 28 代表 Android 9.0,在 Android 9.0 以后,才要求截屏时必须运行一个前台 Service。

所以修复这个 crash 有两种方案:

  • 把 targetSdk 改成 28,

  • 创建前台 Service,适配 Android 9.0 以上版本。

我更倾向于第二种方案,因为这个项目是我写给自己练手的,我希望用最新的 API;并且将截图功能放到 Service 中其实也更符合我的需求。

首先新建一个 Service:

  1. class CaptureService : Service() {
  2.     override fun onBind(intent: Intent?): IBinder? {
  3.         return null
  4.     }
  5. }

在 AndroidManifest 中,添加 FOREGROUND_SERVICE 权限,注册此 Service:

  1. <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
  2. <application
  3.     ...>
  4.     ...
  5.     <service
  6.         android:name=".CaptureService"
  7.         android:foregroundServiceType="mediaProjection" />
  8. </application>

此 Service 需要添加 android:foregroundServiceType="mediaProjection" 属性,表示这是用于截屏的 Service。

新建 MyApplication,注册前台 Notification Channel:

  1. const val SCREEN_CAPTURE_CHANNEL_ID = "Screen Capture ID"
  2. const val SCREEN_CAPTURE_CHANNEL_NAME = "Screen Capture"
  3. class MyApplication : Application() {
  4.     override fun onCreate() {
  5.         super.onCreate()
  6.         createScreenCaptureNotificationChannel()
  7.     }
  8.     private fun createScreenCaptureNotificationChannel() {
  9.         val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
  10.         // Create the channel for the notification
  11.         val screenCaptureChannel = NotificationChannel(SCREEN_CAPTURE_CHANNEL_ID, SCREEN_CAPTURE_CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW)
  12.         // Set the Notification Channel for the Notification Manager.
  13.         notificationManager.createNotificationChannel(screenCaptureChannel)
  14.     }
  15. }

不要忘了在 AndroidManifest 中声明此 Application:

  1. <application
  2.     android:name=".MyApplication"
  3.     .../>

然后,在 CaptureService 中,启用前台通知:

  1. class CaptureService : Service() {
  2.     override fun onCreate() {
  3.         super.onCreate()
  4.         startForeground(1, NotificationCompat.Builder(this, SCREEN_CAPTURE_CHANNEL_ID).build())
  5.     }
  6.     override fun onBind(intent: Intent?): IBinder? {
  7.         return null
  8.     }
  9. }

这样就写好了一个前台 Service。

修改 MainActivity 中的代码,点击 Start 后,先启动 Service,再调用截屏:

  1. binding.btnStart.setOnClickListener {
  2.     startForegroundService(Intent(this, CaptureService::class.java))
  3.     Log.d("~~~""Requesting confirmation")
  4.     startActivityForResult(mediaProjectionManager.createScreenCaptureIntent(), REQUEST_MEDIA_PROJECTION)
  5. }

此时运行就不会报错了,效果如下:

34f1944fda3cd56f9b2045186de61f86.gif

可以看到,已经可以成功截图了,前文说过,当 createVirtualDisplay 方法调用后,设备就会不断地获取当前屏幕,所以才会看到截图画面层层叠叠的效果。

在 Google 官方提供的截图 Demo 中,运行效果也是类似的,感兴趣的读者可以在 github 上查看 Google 官方的 Demo:

https://github.com/android/media-samples/tree/main/ScreenCapture

注:只要启动了这样一个前台 Service,即使没有把截屏逻辑移到 Service 中,也已经可以正常截屏了。但更好的做法是把截图逻辑移到 Service 中,感兴趣的读者可以自行实现。

截图一次并取其 Bitmap

虽然现在截图成功了,但运行效果并不是我们想要的。一般我们想要的效果是,截图一次并取其 Bitmap。

为了实现这个效果,我们需要使用一个新的类:ImageReader。ImageReader 中包含一个 Surface 对象,在 createVirtualDisplay 方法中,将 binding.surfaceView.holder.surface 替换成 ImageReader 的 Surface 对象,就可以将截图结果记录到 ImageReader 中了。

创建 ImageReader:

private val imageReader by lazy { ImageReader.newInstance(ScreenUtils.getScreenWidth(), ScreenUtils.getScreenHeight(), PixelFormat.RGBA_8888, 1) }

创建时需要传入屏幕的宽高,第三个参数表示图片的格式,这里传入的是 PixelFormat.RGBA_8888。

注:实际上写 PixelFormat.RGBA_8888 时,Android Studio 会报错,因为它预期的是传入一个 ImageFormat。PixelFormat.RGBA_8888 对应的常量是 1,但 ImageFormat 中没有对应常量 1 的格式。我尝试过换成 ImageFormat 中的其他格式,但换了之后始终运行不了。而这里的报错却并不影响程序运行,所以我就任由它报红了。如果读者有更好的方案,望不吝赐教:

00d2c831c085f7de93a95d7cfdfc00f1.png

最后一个参数表示最多保存几张图片,我们传入 1 就可以了。

创建好 ImageReader 后,接下来替换掉 createVirtualDisplay 方法中的参数,并获取 imageReader 中的截图结果:

  1. virtualDisplay = mediaProjection!!.createVirtualDisplay(
  2.     "ScreenCapture",
  3.     ScreenUtils.getScreenWidth(), ScreenUtils.getScreenHeight(), ScreenUtils.getScreenDensityDpi(),
  4.     DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
  5.     imageReader.surface, null, null
  6. )
  7. Handler(Looper.getMainLooper()).postDelayed({
  8.     val image = imageReader.acquireLatestImage()
  9.     if (image != null) {
  10.         Log.d("~~~""get image: $image")
  11.     } else {
  12.         Log.d("~~~""image == null")
  13.     }
  14.     stopScreenCapture()
  15. }, 1000)

可以看到,代码中先是将 imageReader.surface 传入了 createVirtualDisplay 方法中,使得截图结果记录到 ImageReader 中。再等待了 1s 钟,然后调用 imageReader.acquireLatestImage() 获取 imageReader 中记录的截图结果,它是一个 Image 对象。之所以等待 1s 是因为截图需要一定的时间,并且在获取到截图结果后,我们需要调用 stopScreenCapture 将 virtualDisplay 对象释放掉,否则这里会一直截图。并且如果不释放的话,在下一次截图时会报以下错误:

java.lang.IllegalStateException: maxImages (1) has already been acquired, call #close before acquiring more.

获取到 Image 对象后,可以将其转换成 Bitmap 对象,转换工具类如下:

  1. object ImageUtils {
  2.     fun imageToBitmap(image: Image): Bitmap {
  3.         val bitmap = Bitmap.createBitmap(image.width, image.height, Bitmap.Config.ARGB_8888)
  4.         bitmap.copyPixelsFromBuffer(image.planes[0].buffer)
  5.         image.close()
  6.         return bitmap
  7.     }
  8. }

这样我们就实现了截图一次并取其 Bitmap。不妨将这个 Bitmap 设置到 ImageView 上,看看效果。首先修改布局文件:

  1. <?xml version="1.0" encoding="utf-8"?>
  2. <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
  3.     xmlns:app="http://schemas.android.com/apk/res-auto"
  4.     xmlns:tools="http://schemas.android.com/tools"
  5.     android:layout_width="match_parent"
  6.     android:layout_height="match_parent"
  7.     tools:context=".MainActivity">
  8.     <ImageView
  9.         android:id="@+id/iv"
  10.         android:layout_width="match_parent"
  11.         android:layout_height="0dp"
  12.         app:layout_constraintBottom_toTopOf="@id/btnStart"
  13.         app:layout_constraintTop_toTopOf="parent" />
  14.     <Button
  15.         android:id="@+id/btnStart"
  16.         android:layout_width="match_parent"
  17.         android:layout_height="wrap_content"
  18.         android:text="Start Screen Capture"
  19.         android:textAllCaps="false"
  20.         app:layout_constraintBottom_toTopOf="@id/btnStop"
  21.         app:layout_constraintTop_toBottomOf="@id/iv" />
  22.     <Button
  23.         android:id="@+id/btnStop"
  24.         android:layout_width="match_parent"
  25.         android:layout_height="wrap_content"
  26.         android:text="Stop Screen Capture"
  27.         android:textAllCaps="false"
  28.         app:layout_constraintBottom_toBottomOf="parent" />
  29. </androidx.constraintlayout.widget.ConstraintLayout>

唯一的修改是把之前布局文件中的 SurfaceView 换成了 ImageView,id 也对应换成了 iv。然后将获取到的 Image 转成 Bitmap,并设置到 ImageView 上:

binding.iv.setImageBitmap(ImageUtils.imageToBitmap(image))

运行效果如下:

953bc6cc0f158f4f790115b6b1686b68.gif

可以看到,点击 Start 按钮后,等待 1s 后,就完成了截图,并且展示到了 ImageView 上。这里的截图并不局限于本应用内,不妨看一个截取应用外屏幕的效果:(注:我在录制这个效果时将截图等待时间延长到了 3s,以保证截图时完全退到了桌面)

91dd344484d21fd796460ac1c7e2783e.gif

可以看到,确实可以截取到应用外的屏幕。

只让用户同意一次

现在的截图还有一个问题,每次截图前都会询问用户是否同意截图。虽然我们可以通过上文介绍的模拟点击帮用户点同意,但更好的做法是将用户同意的结果保存起来,下次截图前直接使用即可。我们修改一下 Demo 看看效果。

MainActivity 修改如下:

  1. const val REQUEST_MEDIA_PROJECTION = 1
  2. class MainActivity : AppCompatActivity() {
  3.     private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) }
  4.     private val mediaProjectionManager: MediaProjectionManager by lazy { getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager }
  5.     private var mediaProjection: MediaProjection? = null
  6.     private var virtualDisplay: VirtualDisplay? = null
  7.     private val handler by lazy { Handler(Looper.getMainLooper()) }
  8.     private val imageReader by lazy { ImageReader.newInstance(ScreenUtils.getScreenWidth(), ScreenUtils.getScreenHeight(), PixelFormat.RGBA_8888, 1) }
  9.     override fun onCreate(savedInstanceState: Bundle?) {
  10.         super.onCreate(savedInstanceState)
  11.         setContentView(binding.root)
  12.         binding.btnStart.setOnClickListener {
  13.             startForegroundService(Intent(this, CaptureService::class.java))
  14.             startScreenCapture()
  15.         }
  16.         binding.btnStop.setOnClickListener {
  17.             Log.d("~~~""Stop screen capture")
  18.             stopScreenCapture()
  19.         }
  20.     }
  21.     override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
  22.         super.onActivityResult(requestCode, resultCode, data)
  23.         if (requestCode == REQUEST_MEDIA_PROJECTION) {
  24.             if (resultCode != RESULT_OK) {
  25.                 Log.d("~~~""User cancelled")
  26.                 return
  27.             }
  28.             Log.d("~~~""Starting screen capture")
  29.             mediaProjection = mediaProjectionManager.getMediaProjection(resultCode, data!!)
  30.             setUpVirtualDisplay()
  31.         }
  32.     }
  33.     private fun startScreenCapture() {
  34.         if (mediaProjection == null) {
  35.             Log.d("~~~""Requesting confirmation")
  36.             // This initiates a prompt dialog for the user to confirm screen projection.
  37.             startActivityForResult(mediaProjectionManager.createScreenCaptureIntent(), REQUEST_MEDIA_PROJECTION)
  38.         } else {
  39.             Log.d("~~~""mediaProjection != null")
  40.             setUpVirtualDisplay()
  41.         }
  42.     }
  43.     private fun setUpVirtualDisplay() {
  44.         Log.d("~~~""setUpVirtualDisplay")
  45.         virtualDisplay = mediaProjection!!.createVirtualDisplay(
  46.             "ScreenCapture",
  47.             ScreenUtils.getScreenWidth(), ScreenUtils.getScreenHeight(), ScreenUtils.getScreenDensityDpi(),
  48.             DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
  49.             imageReader.surface, null, null
  50.         )
  51.         handler.postDelayed({
  52.             val image = imageReader.acquireLatestImage()
  53.             if (image != null) {
  54.                 Log.d("~~~""get image: $image")
  55.                 binding.iv.setImageBitmap(ImageUtils.imageToBitmap(image))
  56.             } else {
  57.                 Log.d("~~~""image == null")
  58.             }
  59.             stopScreenCapture()
  60.         }, 1000)
  61.     }
  62.     private fun stopScreenCapture() {
  63.         Log.d("~~~""stopScreenCapture, virtualDisplay = $virtualDisplay")
  64.         virtualDisplay?.release()
  65.         virtualDisplay = null
  66.     }
  67. }

主要修改在于多了一个 startScreenCapture 方法,在这个方法中,先判断 mediaProjection 是否已经存在,如果不存在,则执行刚才的逻辑,调用 startActivityForResult 请求用户同意截屏。如果已经存在,则直接调用 createVirtualDisplay 截屏即可。

运行效果:

0ba5098fe0de31b0976331bce061ccf4.gif

这样就实现了用户只需同意一次截屏权限,应用就能多次截屏的功能。

通过上文介绍的模拟点击,在获取截屏权限时,可以实现自动点击同意。然后就可以愉快地多次截屏了。

由于这种截屏方式不局限于本应用内,所以可以在后台默默地不断截取屏幕。接下来我们再学习一点基本的图像识别技术,把截取到的屏幕利用起来。

/   图片识别   /

我采用的方式是对比图片的相似度,以达到知道当前在哪一屏的效果,然后就能通过辅助功能点击这一屏中设定好的坐标了

第一种对比方式

第一种对比方式是:取出两张 bitmap 中的所有像素,然后一一进行对比。匹配的点除以总点数就能得到一个相似度。代码如下:

  1. object SimilarityUtils {
  2.     fun similarity(bitmap1: Bitmap, bitmap2: Bitmap): Double {
  3.         // 获取图片所有的像素
  4.         val pixels1 = getPixels(bitmap1)
  5.         val pixels2 = getPixels(bitmap2)
  6.         // 总的像素点数以较大图片为准
  7.         val totalCount = pixels1.size.coerceAtLeast(pixels2.size)
  8.         if (totalCount == 0return 0.00
  9.         var matchCount = 0
  10.         var i = 0
  11.         while (i < pixels1.size && i < pixels2.size) {
  12.             if (pixels1[i] == pixels2[i]) {
  13.                 // 统计相同的像素点数量
  14.                 matchCount++
  15.             }
  16.             i++
  17.         }
  18.         // 相同的像素点数量除以总的像素点数,得到相似比例。
  19.         return String.format("%.2f", matchCount.toDouble() / totalCount).toDouble()
  20.     }
  21.     private fun getPixels(bitmap: Bitmap): IntArray {
  22.         val pixels = IntArray(bitmap.width * bitmap.height)
  23.         // 获取每个像素的 RGB 值
  24.         bitmap.getPixels(pixels, 0, bitmap.width, 00, bitmap.width, bitmap.height)
  25.         return pixels
  26.     }
  27. }

可以看到,similarity 函数接收两个 Bitmap,返回一个 Double 值,这个值的取值范围是 0.00~1.00,表示相似度。

首先通过 bitmap.getPixels 取出所有的像素点,以其中较多的像素点作为总点数。

然后通过 pixels1[i] == pixels2[i] 对比每个像素点,如果相同则 matchCount 加一,最后用 matchCount / totalCount 计算出相似度。

这种比较方式特别直观,容易理解,通过每个像素点依次比较得出相似度。我们也很容易想到它的缺点:如果第二张图片是由第一张图片缩放、变形、旋转等变换得来的,那么每个像素点可能都无法匹配上,所以相似度会很低很低。

也就是说,这个算法几乎只能用于比较图片是否一模一样,只要两张图的像素点有细微的错位,比较结果就会完全不准确。

不过其实这种算法已经能够满足我们的需求了,只要我们每次都取一样的 Bitmap 进行比较就可以了。只要保证整张图都一样,或者从 Bitmap 裁剪出的固定区域一样就可以了。此时比较结果可以供我们正常使用。

但更好的做法是通过 SIFT 算法计算相似度。

通过 SIFT 算法计算相似度

SIFT 算法指的是尺度不变特征转换 (Scale Invariant Feature Transform)。它是计算机视觉领域中描述图片特征的一种算法,应用非常广泛。

这个算法是由一些大神们研究出来的,由于本文不是在写论文,所以我也不会对这个算法进行深究,简单介绍一下它的大概原理:

先将图片映射为空间中的坐标:

c7cb1ec27a04ff7680fc767c05be6e65.png

再从所有坐标中过滤出其中的特征点:

ffc8529e36c2e5c5569e9ecc7b37f225.png

再为特征点分配一个方向值,使得图片变形后仍然能够正确匹配:

182791ab5ecedf5848ac746cc6b0f978.png

将这些信息转换成数学描述:

cd0889b61220fb116bd2d923dcfafa9b.png

注:算法原理的这段内容,只是我个人一点粗浅的理解,可能和算法的实际实现有出入。但这个算法的实现不是本文的重点,重点在于这个算法可以用于对比两张图片的相似度。所以于我而言,我愿将其称之为魔法。

这个算法被封装在 OpenCV 库中,所以使用前需要导入 OpenCV 库。

OpenCV 官方没有提供 gradle 导入的方式,所以网上有许多导入 OpenCV 库的教程,讲的都是去下载 OpenCV 的源码,再通过 Module 的方式加入项目中。

但国外有民间大佬为我们封装了 gradle 导入的方式,大佬封装的 github 地址:

https://github.com/quickbirdstudios/opencv-android

所以现在我们可以直接在 build.gradle 中直接导入 OpenCV 库:

implementation 'com.quickbirdstudios:opencv:4.5.3.0'

需要注意的是,OpenCV 库非常大,导入这个库会让 apk 的体积增加 100 多 M,所以要慎用。

有了 OpenCV 库,就可以编写图片相似度对比工具类了:

  1. object SIFTUtils {
  2.     // SIFT detector
  3.     private val siftDetector by lazy { SIFT.create() }
  4.     fun similarity(bitmap1: Bitmap, bitmap2: Bitmap): Double {
  5.         // 计算每张图片的特征点
  6.         val descriptors1 = computeDescriptors(bitmap1)
  7.         val descriptors2 = computeDescriptors(bitmap2)
  8.         // 比较两张图片的特征点
  9.         val descriptorMatcher = DescriptorMatcher.create(DescriptorMatcher.FLANNBASED)
  10.         val matches: List<MatOfDMatch> = ArrayList()
  11.         // 计算大图中包含多少小图的特征点。
  12.         // 如果计算小图中包含多少大图的特征点,结果会不准确。
  13.         // 比如:若小图中的 50 个点都包含在大图中的 100 个特征点中,则计算出的相似度为 100%,显然不符合我们的预期
  14.         if (bitmap1.byteCount > bitmap2.byteCount) {
  15.             descriptorMatcher.knnMatch(descriptors1, descriptors2, matches, 2)
  16.         } else {
  17.             descriptorMatcher.knnMatch(descriptors2, descriptors1, matches, 2)
  18.         }
  19.         Log.i("~~~""matches.size: ${matches.size}")
  20.         if (matches.isEmpty()) return 0.00
  21.         // 获取匹配的特征点数量
  22.         var matchCount = 0
  23.         // 邻近距离阀值,这里设置为 0.7,该值可自行调整
  24.         val nndrRatio = 0.7f
  25.         matches.forEach { match ->
  26.             val array = match.toArray()
  27.             // 用邻近距离比值法(NNDR)计算匹配点数
  28.             if (array[0].distance <= array[1].distance * nndrRatio) {
  29.                 matchCount++
  30.             }
  31.         }
  32.         Log.i("~~~""matchCount: $matchCount")
  33.         return String.format("%.2f", matchCount.toDouble() / matches.size).toDouble()
  34.     }
  35.     private fun computeDescriptors(bitmap: Bitmap): MatOfKeyPoint {
  36.         val mat = Mat()
  37.         Utils.bitmapToMat(bitmap, mat)
  38.         val keyPoints = MatOfKeyPoint()
  39.         siftDetector.detect(mat, keyPoints)
  40.         val descriptors = MatOfKeyPoint()
  41.         // 计算图片的特征点
  42.         siftDetector.compute(mat, keyPoints, descriptors)
  43.         return descriptors
  44.     }
  45. }

在这个类中,同样有一个 similarity 方法,接收两个 Bitmap,返回一个 0.00~1.00 的 Double 型数据,表示图片的相似度。

首先通过 SIFT.create() 构建出用 SIFT 算法实现的图片检测器 siftDetector,再通过 siftDetector.compute 计算出图片的特征点。

再通过 DescriptorMatcher.create 构建出 descriptorMatcher 对象,通过 descriptorMatcher.knnMatch 方法比较出两张图片相似的特征点数量。

这里比较时有一个 if 条件判断,它的作用是保证比较的是大图中包含多少小图中的特征点。因为如果计算小图中包含多少大图的特征点,结果会不准确。

比如:若小图中的 50 个点都包含在大图中的 100 个特征点中,则计算出的相似度为 100%,显然不符合我们的预期。

最后通过 array[0].distance <= array[1].distance * nndrRatio 判断特征点是否相似,统计出相似的特征点数量后,通过 matchCount / matches.size 计算出相似度。

测试

先在 res/drawable 文件夹下放一张图片,比如我放了一张我的头像,命名为 img.png:

df6d0adee3eec05fae18a2adaa3c6aa6.png

然后修改 MainActivity 中的代码:

  1. class MainActivity : AppCompatActivity() {
  2.     override fun onCreate(savedInstanceState: Bundle?) {
  3.         super.onCreate(savedInstanceState)
  4.         setContentView(R.layout.activity_main)
  5.         val bitmap1 = BitmapFactory.decodeResource(resources, R.drawable.img)
  6.         val bitmap2 = Bitmap.createBitmap(bitmap1, 00, bitmap1.width / 2, bitmap1.height / 2)
  7.         Log.d("~~~""similarity: ${SIFTUtils.similarity(bitmap1, bitmap2)}")
  8.     }
  9. }

首先通过 BitmapFactory.decodeResource 将 res/drawable 文件夹中的图片取出来,转换成 Bitmap,构建出 bitmap1。bitmap2 由 bitmap1 裁剪而来,通过 Bitmap.createBitmap 方法,从 bitmap1 的 (0, 0) 位置开始,裁剪出宽为原图一半、高为原图一半的 Bitmap。然后调用 SIFTUtils.similarity(bitmap1, bitmap2) 比较两张图片的相似度。

非常完美!

运行代码,立马 crash:

  1. E/AndroidRuntime: FATAL EXCEPTION: main
  2.     Process: com.example.imagesimilarity, PID: 21924
  3.     java.lang.UnsatisfiedLinkError: No implementation found for long org.opencv.core.Mat.n_Mat() (tried Java_org_opencv_core_Mat_n_1Mat and Java_org_opencv_core_Mat_n_1Mat__)
  4.         at org.opencv.core.Mat.n_Mat(Native Method)
  5.         at org.opencv.core.Mat.<init>(Mat.java:23)
  6.         at com.example.imagesimilarity.SIFTUtils.computeDescriptors(SIFTUtils.kt:50)
  7.         at com.example.imagesimilarity.SIFTUtils.similarity(SIFTUtils.kt:19)
  8.         at com.example.imagesimilarity.MainActivity.onCreate(MainActivity.kt:38)
  9.         at android.app.Activity.performCreate(Activity.java:8000)

果然凡事都没有一帆风顺的。这个报错大致意思是没有找到 OpenCV 中的某个方法的具体实现。奇了怪了,我们明明已经导入过 OpenCV 库了。

查询一番后,在 StackOverflow 上找到了答案,原因是 OpenCV 使用前需要先初始化。

MainActivity 代码修改如下:

  1. class MainActivity : AppCompatActivity() {
  2.     override fun onCreate(savedInstanceState: Bundle?) {
  3.         super.onCreate(savedInstanceState)
  4.         setContentView(R.layout.activity_main)
  5.         val loaded = OpenCVLoader.initDebug()
  6.         Log.d("~~~""loaded: $loaded")
  7.         if (loaded) {
  8.             val bitmap1 = BitmapFactory.decodeResource(resources, R.drawable.img)
  9.             val bitmap2 = Bitmap.createBitmap(bitmap1, 00, bitmap1.width / 2, bitmap1.height / 2)
  10.             Log.d("~~~""similarity: ${SIFTUtils.similarity(bitmap1, bitmap2)}")
  11.         }
  12.     }
  13. }

在 onCreate 方法中,先调用 OpenCVLoader.initDebug 方法初始化 OpenCV,通过其返回值判断是否加载成功,当加载成功后再执行我们刚才的比较相似度逻辑。

运行程序,Logcat 控制台输出如下:

  1. D/~~~: loaded: true
  2. I/~~~: matches.size: 190
  3. I/~~~: matchCount: 88
  4. D/~~~: similarity: 0.46

表示两张图片的相似度为 46%,说明我们的程序已经正常工作了。

/   后记   /

到这里,我们的外挂三部曲就完结了。这三章讲述了三个独立的技术点:模拟点击、应用外截屏、图像识别。这些技术对用户而言有些风险,所以通常都需要用户手动授权。比如模拟点击前需要用户开启辅助功能,截取屏幕前需要用户同意应用读取屏幕。

为什么没有讲他们的综合运用呢?这实际上是我无奈之举。这些技术综合运用起来像是黑魔法,有些黑科技成分,不便细讲,我平时也只运用在自己的个人手机上,让它们帮我做一些机械的重复工作。这几篇文章只是给大家介绍锤子、钉子、板子,如何用它们制作桌椅板凳还需要读者亲自动手。

推荐阅读:

我的新书,《第一行代码 第3版》已出版!

我为Android版Microsoft Edge所带来的变化

安卓13来了,快!扶起我来!

欢迎关注我的公众号

学习技术或投稿

9d1fedff1f9b145266b5d7fb918a38b6.png

a210f702b9d0093282a43351737fcc49.jpeg

长按上图,识别图中二维码即可关注

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/花生_TL007/article/detail/341287
推荐阅读
相关标签
  

闽ICP备14008679号