iosapp和安卓app源代码一样吗(安卓源代码贡献排行)
安卓进阶涨薪训练营,让一部分人先进大厂
大家好,我是皇叔,最近开了一个安卓进阶涨薪训练营,可以帮助大家突破技术&职场瓶颈,从而度过难关,进入心仪的公司。
详情见文章: 没错!皇叔开了个训练营
前言
在 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 中的其他格式,但换了之后始终运行不了。而这里的报错却并不影响程序运行,所以我就任由它报红了。如果读者有更好的方案,望不吝赐教:
最后一个参数表示最多保存几张图片,我们传入 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%,说明我们的程序已经正常工作了。
后记
到这里,我们的外挂三部曲就完结了。这三章讲述了三个独立的技术点:模拟点击、应用外截屏、图像识别。这些技术对用户而言有些风险,所以通常都需要用户手动授权。比如模拟点击前需要用户开启辅助功能,截取屏幕前需要用户同意应用读取屏幕。
为什么没有讲他们的综合运用呢?这实际上是我无奈之举。这些技术综合运用起来像是黑魔法,有些黑科技成分,不便细讲,我平时也只运用在自己的个人手机上,让它们帮我做一些机械的重复工作。这几篇文章只是给大家介绍锤子、钉子、板子,如何用它们制作桌椅板凳还需要读者亲自动手。
为了防止失联,欢迎关注我防备的小号
微信改了推送机制,真爱请星标本公号👇