持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第4天,点击查看活动详情
在讲解 Android 应用外截屏之前,我们先看一下 Android 应用内截屏。在 Android 应用内截屏非常简单,只需要获取 View 的缓存即可:
fun screenShot(activity: Activity): Bitmap {
return view2Bitmap(activity.window.decorView)
}
fun view2Bitmap(view: View): Bitmap {
view.isDrawingCacheEnabled = true
return view.drawingCache
}
本文重点讲述应用外截屏。应用外截屏其实也不复杂,只需要两步:
- 通过 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。
首先是布局文件:
<?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 中:
const val REQUEST_MEDIA_PROJECTION = 1
class MainActivity : AppCompatActivity() {
private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) }
private val mediaProjectionManager: MediaProjectionManager by lazy { getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager }
private var mediaProjection: MediaProjection? = null
private var 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
)
}
}
private fun stopScreenCapture() {
Log.d("~~~", "stopScreenCapture, virtualDisplay = $virtualDisplay")
virtualDisplay?.release()
virtualDisplay = null
}
}
其中,用到的 ScreenUtils 的作用是获取屏幕的宽高和密度。代码如下:
object ScreenUtils {
fun getScreenWidth(): Int {
return Resources.getSystem().displayMetrics.widthPixels
}
fun getScreenHeight(): Int {
return Resources.getSystem().displayMetrics.heightPixels
}
fun getScreenDensityDpi(): Int {
return Resources.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 以上版本。
我更倾向于第二种方案,因为这个项目是我写给自己练手的,我希望用最新的 API;并且将截图功能放到 Service 中其实也更符合我的需求。
首先新建一个 Service:
class CaptureService : Service() {
override fun onBind(intent: Intent?): IBinder? {
return null
}
}
在 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:
const val SCREEN_CAPTURE_CHANNEL_ID = "Screen Capture ID"
const val SCREEN_CAPTURE_CHANNEL_NAME = "Screen Capture"
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
createScreenCaptureNotificationChannel()
}
private fun 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 中,启用前台通知:
class CaptureService : Service() {
override fun onCreate() {
super.onCreate()
startForeground(1, NotificationCompat.Builder(this, SCREEN_CAPTURE_CHANNEL_ID).build())
}
override fun onBind(intent: Intent?): IBinder? {
return null
}
}
这样就写好了一个前台 Service。
修改 MainActivity 中的代码,点击 Start 后,先启动 Service,再调用截屏:
binding.btnStart.setOnClickListener {
startForegroundService(Intent(this, CaptureService::class.java))
Log.d("~~~", "Requesting confirmation")
startActivityForResult(mediaProjectionManager.createScreenCaptureIntent(), REQUEST_MEDIA_PROJECTION)
}
此时运行就不会报错了,效果如下:
可以看到,已经可以成功截图了,前文说过,当 createVirtualDisplay 方法调用后,设备就会不断地获取当前屏幕,所以才会看到截图画面层层叠叠的效果。
在 Google 官方提供的截图 Demo 中,运行效果也是类似的,感兴趣的读者可以在 github 上查看 Google 官方的 Demo:github.com/android/med…
注:只要启动了这样一个前台 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 中的其他格式,但换了之后始终运行不了。而这里的报错却并不影响程序运行,所以我就任由它报红了。如果读者有更好的方案,望不吝赐教:
最后一个参数表示最多保存几张图片,我们传入 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()
return bitmap
}
}
这样我们就实现了截图一次并取其 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 修改如下:
const val REQUEST_MEDIA_PROJECTION = 1
class MainActivity : AppCompatActivity() {
private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) }
private val mediaProjectionManager: MediaProjectionManager by lazy { getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager }
private var mediaProjection: MediaProjection? = null
private var virtualDisplay: VirtualDisplay? = null
private val handler by lazy { Handler(Looper.getMainLooper()) }
private val 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()
}
}
private fun 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()
}
}
private fun 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)
}
private fun stopScreenCapture() {
Log.d("~~~", "stopScreenCapture, virtualDisplay = $virtualDisplay")
virtualDisplay?.release()
virtualDisplay = null
}
}
主要修改在于多了一个 startScreenCapture 方法,在这个方法中,先判断 mediaProjection 是否已经存在,如果不存在,则执行刚才的逻辑,调用 startActivityForResult 请求用户同意截屏。
如果已经存在,则直接调用 createVirtualDisplay 截屏即可。
运行效果:
这样就实现了用户只需同意一次截屏权限,应用就能多次截屏的功能。
五、后记
通过上文介绍的模拟点击,在获取截屏权限时,可以实现自动点击同意。然后就可以愉快地多次截屏了。
由于这种截屏方式不局限于本应用内,所以可以在后台默默地不断截取屏幕。下一篇文章我准备介绍一点基本的图像识别技术。
我采用的方式是对比图片的相似度,以达到知道当前在哪一屏的效果,然后就能通过辅助功能点击这一屏中设定好的坐标了。
敬请期待。
六、参考文章
继续阅读: