当前位置:首页 > 网站源码 > 正文内容

android项目源码下载(android项目源代码)

网站源码1年前 (2023-11-08)271

安卓进阶涨薪训练营,让一部分人先进大厂

大家好,我是皇叔,最近开了一个安卓进阶涨薪训练营,可以帮助大家突破技术&职场瓶颈,从而度过难关,进入心仪的公司。

详情见文章: 没错!皇叔开了个训练营

前言

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

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

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

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

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

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

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

模拟点击

应用外截屏

图片识别

模拟点击

应用外截屏

图片识别

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

模拟点击

新建 MyAccessibilityService 类

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

classMyAccessibilityService: AccessibilityService{

展开全文

override fun onAccessibilityEvent(accessibilityEvent: AccessibilityEvent?){

}

override fun onInterrupt{

}

}

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

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

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

注册 Service

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

<service

android:name= ".MyAccessibilityService"

android:deion= "@string/deion_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 是在系统的辅助功能设置中显示的名字

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

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

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

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

首先是需要声明一个 label,这个 label 是在系统的辅助功能设置中显示的名字

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

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:deion= "@string/deion_in_xml"

android:notificationTimeout= "100"/>

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

<string name= "label_in_manifest">Label in manifest</string>

<string name= "deion_in_manifest">Deion in manifest</string>

<string name= "deion_in_xml">Deion 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: []; ContentDeion: 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 中表示这个变化的内容所在的包名。

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

点击对应坐标

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

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

object ClickUtils {

fun click(accessibilityService: AccessibilityService, x: Float, y: Float){

Log.d( "~~~", "click: ($x, $y)")

val builder = GestureDeion.Builder

val path = Path

path.moveTo(x, y)

path.lineTo(x, y)

builder.addStroke(GestureDeion.StrokeDeion(path, 0, 1))

val gesture = builder.build

accessibilityService.dispatchGesture(gesture, object : AccessibilityService.GestureResultCallback {

override fun onCancelled(gestureDeion: GestureDeion){

super.onCancelled(gestureDeion)

}

override fun onCompleted(gestureDeion: GestureDeion){

super.onCompleted(gestureDeion)

}

}, null)

}

}

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

通过 GestureDeion 的 Builder 构建一个手势,通过 Builder 的 addStoke 方法传入一条 path,这条 path 我们设置为从 (x, y) 坐标移动到 (x, y) 坐标。StrokeDeion 的后两个参数表示 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>

这个页面的效果图:

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

buildFeatures {

viewBinding true

}

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

classMainActivity: 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"。待会我们就模拟点击这个按钮。

第一个按钮 btnJumpToSettings 的作用是点击跳转到辅助服务设置页

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

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

从图中可以看出,第三个按钮的坐标大约是 (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 方法就会立刻回调,而我们要从设置页面返回到此页面才能看到这个按钮被点击的效果,返回过程需要一点时间。

开测

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

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

应用外截屏

应用内截屏

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

fun screenShot(activity: Activity): Bitmap {

returnview2Bitmap(activity.window.decorView)

}

fun view2Bitmap(view: View): Bitmap {

view.isDrawingCacheEnabled = true

returnview.drawingCache

}

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

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

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

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

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

应用外截屏

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

privateval 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。

首先是布局文件:

<?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">

<SurfaceView

android:id= "@+id/surfaceView"

android:layout_width= "match_parent"

android:layout_height= "0dp"

app:layout_constraintBottom_toTopOf= "@id/btnStart"

app:layout_constraintTop_toTopOf= "parent"/>

<Button

android:id= "@+id/btnStart"

android:layout_width= "match_parent"

android:layout_height= "wrap_content"

android:text= "Start Screen Capture"

android:textAllCaps= "false"

app:layout_constraintBottom_toTopOf= "@id/btnStop"

app:layout_constraintTop_toBottomOf= "@id/surfaceView"/>

<Button

android:id= "@+id/btnStop"

android:layout_width= "match_parent"

android:layout_height= "wrap_content"

android:text= "Stop Screen Capture"

android:textAllCaps= "false"

app:layout_constraintBottom_toBottomOf= "parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>

效果图:

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

buildFeatures {

viewBinding true

}

在 MainActivity 中:

constval REQUEST_MEDIA_PROJECTION = 1

classMainActivity: AppCompatActivity{

privateval binding by lazy { ActivityMainBinding.inflate(layoutInflater) }

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

privatevar mediaProjection: MediaProjection? = null

privatevar virtualDisplay: VirtualDisplay? = null

override fun onCreate(savedInstanceState: Bundle?){

super.onCreate(savedInstanceState)

setContentView(binding.root)

binding.btnStart.setOnClickListener {

Log.d( "~~~", "Requesting confirmation")

startActivityForResult(mediaProjectionManager.createScreenCaptureIntent, REQUEST_MEDIA_PROJECTION)

}

binding.btnStop.setOnClickListener {

Log.d( "~~~", "Stop screen capture")

stopScreenCapture

}

}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?){

super.onActivityResult(requestCode, resultCode, data)

if(requestCode == REQUEST_MEDIA_PROJECTION) {

if(resultCode != RESULT_OK) {

Log.d( "~~~", "User cancelled")

return

}

Log.d( "~~~", "Starting screen capture")

mediaProjection = mediaProjectionManager.getMediaProjection(resultCode, data!!)

virtualDisplay = mediaProjection!!.createVirtualDisplay(

"ScreenCapture",

ScreenUtils.getScreenWidth, ScreenUtils.getScreenHeight, ScreenUtils.getScreenDensityDpi,

DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,

binding.surfaceView.holder.surface, null, null

)

}

}

privatefun stopScreenCapture{

Log.d( "~~~", "stopScreenCapture, virtualDisplay = $virtualDisplay")

virtualDisplay?.release

virtualDisplay = null

}

}

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

object ScreenUtils {

fun getScreenWidth: Int {

returnResources.getSystem.displayMetrics.widthPixels

}

fun getScreenHeight: Int {

returnResources.getSystem.displayMetrics.heightPixels

}

fun getScreenDensityDpi: Int {

returnResources.getSystem.displayMetrics.densityDpi

}

}

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

如果用户点了确认,也就是上图中的 “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 方法。

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

可以看到,直接 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 以上版本。

把 targetSdk 改成 28,

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

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

首先新建一个 Service:

classCaptureService: Service{

override fun onBind(intent: Intent?): IBinder? {

returnnull

}

}

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

<uses-permission android:name= "android.permission.FOREGROUND_SERVICE"/>

<application

...>

...

<service

android:name= ".CaptureService"

android:foregroundServiceType= "mediaProjection"/>

</application>

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

新建 MyApplication,注册前台 Notification Channel:

constval SCREEN_CAPTURE_CHANNEL_ID = "Screen Capture ID"

constval SCREEN_CAPTURE_CHANNEL_NAME = "Screen Capture"

classMyApplication: Application{

override fun onCreate{

super.onCreate

createScreenCaptureNotificationChannel

}

privatefun createScreenCaptureNotificationChannel{

val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager

// Create the channel for the notification

val screenCaptureChannel = NotificationChannel(SCREEN_CAPTURE_CHANNEL_ID, SCREEN_CAPTURE_CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW)

// Set the Notification Channel for the Notification Manager.

notificationManager.createNotificationChannel(screenCaptureChannel)

}

}

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

<application

android:name= ".MyApplication"

.../>

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

classCaptureService: Service{

override fun onCreate{

super.onCreate

startForeground( 1, NotificationCompat.Builder( this, SCREEN_CAPTURE_CHANNEL_ID).build)

}

override fun onBind(intent: Intent?): IBinder? {

returnnull

}

}

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

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

binding.btnStart.setOnClickListener {

startForegroundService(Intent( this, CaptureService:: class. java))

Log. d("~~~", " Requestingconfirmation")

startActivityForResult( mediaProjectionManager. createScreenCaptureIntent, REQUEST_MEDIA_PROJECTION)

}

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

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

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

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

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:

privateval 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 中的其他格式,但换了之后始终运行不了。而这里的报错却并不影响程序运行,所以我就任由它报红了。如果读者有更好的方案,望不吝赐教:

android项目源码下载(android项目源代码)

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

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

virtualDisplay = mediaProjection!!.createVirtualDisplay(

"ScreenCapture",

ScreenUtils.getScreenWidth, ScreenUtils.getScreenHeight, ScreenUtils.getScreenDensityDpi,

DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,

imageReader.surface, null, null

)

Handler(Looper.getMainLooper).postDelayed({

val image = imageReader.acquireLatestImage

if(image != null) {

Log.d( "~~~", "get image: $image")

} else{

Log.d( "~~~", "image == null")

}

stopScreenCapture

}, 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 对象,转换工具类如下:

object ImageUtils {

fun imageToBitmap(image: Image): Bitmap {

val bitmap = Bitmap.createBitmap(image.width, image.height, Bitmap.Config.ARGB_8888)

bitmap.copyPixelsFromBuffer(image.planes[ 0].buffer)

image.close

returnbitmap

}

}

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

<?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">

<ImageView

android:id= "@+id/iv"

android:layout_width= "match_parent"

android:layout_height= "0dp"

app:layout_constraintBottom_toTopOf= "@id/btnStart"

app:layout_constraintTop_toTopOf= "parent"/>

<Button

android:id= "@+id/btnStart"

android:layout_width= "match_parent"

android:layout_height= "wrap_content"

android:text= "Start Screen Capture"

android:textAllCaps= "false"

app:layout_constraintBottom_toTopOf= "@id/btnStop"

app:layout_constraintTop_toBottomOf= "@id/iv"/>

<Button

android:id= "@+id/btnStop"

android:layout_width= "match_parent"

android:layout_height= "wrap_content"

android:text= "Stop Screen Capture"

android:textAllCaps= "false"

app:layout_constraintBottom_toBottomOf= "parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>

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

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

运行效果如下:

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

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

只让用户同意一次

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

MainActivity 修改如下:

constval REQUEST_MEDIA_PROJECTION = 1

classMainActivity: AppCompatActivity{

privateval binding by lazy { ActivityMainBinding.inflate(layoutInflater) }

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

privatevar mediaProjection: MediaProjection? = null

privatevar virtualDisplay: VirtualDisplay? = null

privateval handler by lazy { Handler(Looper.getMainLooper) }

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

override fun onCreate(savedInstanceState: Bundle?){

super.onCreate(savedInstanceState)

setContentView(binding.root)

binding.btnStart.setOnClickListener {

startForegroundService(Intent( this, CaptureService:: class. java))

startScreenCapture

}

binding. btnStop. setOnClickListener{

Log.d( "~~~", "Stop screen capture")

stopScreenCapture

}

}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?){

super.onActivityResult(requestCode, resultCode, data)

if(requestCode == REQUEST_MEDIA_PROJECTION) {

if(resultCode != RESULT_OK) {

Log.d( "~~~", "User cancelled")

return

}

Log.d( "~~~", "Starting screen capture")

mediaProjection = mediaProjectionManager.getMediaProjection(resultCode, data!!)

setUpVirtualDisplay

}

}

privatefun startScreenCapture{

if(mediaProjection == null) {

Log.d( "~~~", "Requesting confirmation")

// This initiates a prompt dialog for the user to confirm screen projection.

startActivityForResult(mediaProjectionManager.createScreenCaptureIntent, REQUEST_MEDIA_PROJECTION)

} else{

Log.d( "~~~", "mediaProjection != null")

setUpVirtualDisplay

}

}

privatefun setUpVirtualDisplay{

Log.d( "~~~", "setUpVirtualDisplay")

virtualDisplay = mediaProjection!!.createVirtualDisplay(

"ScreenCapture",

ScreenUtils.getScreenWidth, ScreenUtils.getScreenHeight, ScreenUtils.getScreenDensityDpi,

DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,

imageReader.surface, null, null

)

handler.postDelayed({

val image = imageReader.acquireLatestImage

if(image != null) {

Log.d( "~~~", "get image: $image")

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

} else{

Log.d( "~~~", "image == null")

}

stopScreenCapture

}, 1000)

}

privatefun stopScreenCapture{

Log.d( "~~~", "stopScreenCapture, virtualDisplay = $virtualDisplay")

virtualDisplay?.release

virtualDisplay = null

}

}

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

运行效果:

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

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

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

图片识别

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

第一种对比方式

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

object SimilarityUtils {

fun similarity(bitmap1: Bitmap, bitmap2: Bitmap): Double {

// 获取图片所有的像素

val pixels1 = getPixels(bitmap1)

val pixels2 = getPixels(bitmap2)

// 总的像素点数以较大图片为准

val totalCount = pixels1.size.coerceAtLeast(pixels2.size)

if(totalCount == 0) return0.00

var matchCount = 0

var i = 0

while(i < pixels1.size && i < pixels2.size) {

if(pixels1[i] == pixels2[i]) {

// 统计相同的像素点数量

matchCount++

}

i++

}

// 相同的像素点数量除以总的像素点数,得到相似比例。

returnString.format( "%.2f", matchCount.toDouble / totalCount).toDouble

}

privatefun getPixels(bitmap: Bitmap): IntArray {

val pixels = IntArray(bitmap.width * bitmap.height)

// 获取每个像素的 RGB 值

bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)

returnpixels

}

}

可以看到,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)。它是计算机视觉领域中描述图片特征的一种算法,应用非常广泛。

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

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

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

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

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

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

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

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

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

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

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

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

implementation 'com.quickbirdstudios:opencv:4.5.3.0'

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

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

object SIFTUtils {

// SIFT detector

privateval siftDetector by lazy { SIFT.create }

fun similarity(bitmap1: Bitmap, bitmap2: Bitmap): Double {

// 计算每张图片的特征点

val deors1 = computeDeors(bitmap1)

val deors2 = computeDeors(bitmap2)

// 比较两张图片的特征点

val deorMatcher = DeorMatcher.create(DeorMatcher.FLANNBASED)

val matches: List<MatOfDMatch> = ArrayList

// 计算大图中包含多少小图的特征点。

// 如果计算小图中包含多少大图的特征点,结果会不准确。

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

if(bitmap1.byteCount > bitmap2.byteCount) {

deorMatcher.knnMatch(deors1, deors2, matches, 2)

} else{

deorMatcher.knnMatch(deors2, deors1, matches, 2)

}

Log.i( "~~~", "matches.size: ${matches.size}")

if(matches.isEmpty) return0.00

// 获取匹配的特征点数量

var matchCount = 0

// 邻近距离阀值,这里设置为 0.7,该值可自行调整

val nndrRatio = 0.7f

matches.forEach { match ->

val array = match.toArray

// 用邻近距离比值法(NNDR)计算匹配点数

if(array[ 0].distance <= array[ 1].distance * nndrRatio) {

matchCount++

}

}

Log.i( "~~~", "matchCount: $matchCount")

returnString.format( "%.2f", matchCount.toDouble / matches.size).toDouble

}

privatefun computeDeors(bitmap: Bitmap): MatOfKeyPoint {

val mat = Mat

Utils.bitmapToMat(bitmap, mat)

val keyPoints = MatOfKeyPoint

siftDetector.detect(mat, keyPoints)

val deors = MatOfKeyPoint

// 计算图片的特征点

siftDetector.compute(mat, keyPoints, deors)

returndeors

}

}

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

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

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

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

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

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

测试

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

然后修改 MainActivity 中的代码:

classMainActivity: AppCompatActivity{

override fun onCreate(savedInstanceState: Bundle?){

super.onCreate(savedInstanceState)

setContentView(R.layout.activity_main)

val bitmap1 = BitmapFactory.decodeResource(resources, R.drawable.img)

val bitmap2 = Bitmap.createBitmap(bitmap1, 0, 0, bitmap1.width / 2, bitmap1.height / 2)

Log.d( "~~~", "similarity: ${SIFTUtils.similarity(bitmap1, bitmap2)}")

}

}

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

非常完美!

运行代码,立马 crash:

E/AndroidRuntime: FATAL EXCEPTION: main

Process: com.example.imagesimilarity, PID: 21924

java.lang.UnsatisfiedLinkError: No implementation found forlongorg.opencv.core.Mat.n_Mat (tried Java_org_opencv_core_Mat_n_1Mat and Java_org_opencv_core_Mat_n_1Mat__)

at org.opencv.core.Mat.n_Mat(Native Method)

at org.opencv.core.Mat.<init>(Mat.java: 23)

at com.example.imagesimilarity.SIFTUtils.computeDeors(SIFTUtils.kt: 50)

at com.example.imagesimilarity.SIFTUtils.similarity(SIFTUtils.kt: 19)

at com.example.imagesimilarity.MainActivity.onCreate(MainActivity.kt: 38)

at android.app.Activity.performCreate(Activity.java: 8000)

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

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

MainActivity 代码修改如下:

classMainActivity: AppCompatActivity{

override fun onCreate(savedInstanceState: Bundle?){

super.onCreate(savedInstanceState)

setContentView(R.layout.activity_main)

val loaded = OpenCVLoader.initDebug

Log.d( "~~~", "loaded: $loaded")

if(loaded) {

val bitmap1 = BitmapFactory.decodeResource(resources, R.drawable.img)

val bitmap2 = Bitmap.createBitmap(bitmap1, 0, 0, bitmap1.width / 2, bitmap1.height / 2)

Log.d( "~~~", "similarity: ${SIFTUtils.similarity(bitmap1, bitmap2)}")

}

}

}

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

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

D/~~~: loaded: true

I/~~~: matches.size: 190

I/~~~: matchCount: 88

D/~~~: similarity: 0.46

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

后记

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

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

为了防止失联,欢迎关注我防备的小号

微信改了推送机制,真爱请星标本公号👇

扫描二维码推送至手机访问。

版权声明:本文由我的模板布,如需转载请注明出处。


本文链接:http://sdjcht.com/post/38636.html

分享给朋友:

“android项目源码下载(android项目源代码)” 的相关文章

十大手游交易平台app(十大手游交易平台排行榜交易猫)

十大手游交易平台app(十大手游交易平台排行榜交易猫)

今天给各位分享十大手游交易平台app的知识,其中也会对十大手游交易平台排行榜交易猫进行解释,如果能碰巧解决你现在面临的问题,别忘了关注本站,现在开始吧!本文目录一览: 1、手游交易平台哪个好 2、...

抖音直播音乐电台能赚钱吗(抖音音乐电台直播怎么做)

抖音直播音乐电台能赚钱吗(抖音音乐电台直播怎么做)

本篇文章给大家谈谈抖音直播音乐电台能赚钱吗,以及抖音音乐电台直播怎么做对应的知识点,希望对各位有所帮助,不要忘了收藏本站喔。 本文目录一览: 1、抖音电台类主播怎么赚钱 2、抖音电台主播赚钱吗...

gitub怎么注册(git账户注册)

gitub怎么注册(git账户注册)

今天给各位分享gitub怎么注册的知识,其中也会对git账户注册进行解释,如果能碰巧解决你现在面临的问题,别忘了关注本站,现在开始吧!本文目录一览: 1、Github注册以及Github Pages创...

手机怎么打开本地网络权限(如何打开本地网络权限)

手机怎么打开本地网络权限(如何打开本地网络权限)

本篇文章给大家谈谈手机怎么打开本地网络权限,以及如何打开本地网络权限对应的知识点,希望对各位有所帮助,不要忘了收藏本站喔。 本文目录一览: 1、苹果本地网络是什么意思 2、华为手机联网权限设置在哪...

国家共享资源平台网站(全国公共资源共享中心)

国家共享资源平台网站(全国公共资源共享中心)

本篇文章给大家谈谈国家共享资源平台网站,以及全国公共资源共享中心对应的知识点,希望对各位有所帮助,不要忘了收藏本站喔。 本文目录一览: 1、国家资源共享网站 2、教育部资源共享的网址 3、外交...

做短视频需要的软件小海豚(做短视频需要的软件小海豚是什么)

做短视频需要的软件小海豚(做短视频需要的软件小海豚是什么)

本篇文章给大家谈谈做短视频需要的软件小海豚,以及做短视频需要的软件小海豚是什么对应的知识点,希望对各位有所帮助,不要忘了收藏本站喔。 本文目录一览: 1、小海豚是拿里干嘛用的 2、小海豚视频剪辑软...