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

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

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>

效果图:

扫描二维码关注公众号,回复: 14324977 查看本文章

layout

布局文件中,有一个 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,调用这行代码后,会弹出这样一个弹窗:

capture confirmation

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

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

screenCapture.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:

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)
}

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

screenCapture

可以看到,已经可以成功截图了,前文说过,当 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 中的其他格式,但换了之后始终运行不了。而这里的报错却并不影响程序运行,所以我就任由它报红了。如果读者有更好的方案,望不吝赐教:

image.png

最后一个参数表示最多保存几张图片,我们传入 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))

运行效果如下:

screenCapture once

可以看到,点击 Start 按钮后,等待 1s 后,就完成了截图,并且展示到了 ImageView 上。

这里的截图并不局限于本应用内,不妨看一个截取应用外屏幕的效果:(注:我在录制这个效果时将截图等待时间延长到了 3s,以保证截图时完全退到了桌面)

screenCapture outside

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

四、只让用户同意一次

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

我们修改一下 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 截屏即可。

运行效果:

screenCapture

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

五、后记

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

由于这种截屏方式不局限于本应用内,所以可以在后台默默地不断截取屏幕。下一篇文章我准备介绍一点基本的图像识别技术。

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

敬请期待。

六、参考文章

Google 官方截屏 Demo

猜你喜欢

转载自juejin.im/post/7113203120229842981