在安卓上录制屏幕的的实现方式

前言

在我之前的文章 《以不同的形式在安卓中创建GIF动图》 中,我挖了一个坑,可以通过录制屏幕后转为 GIF 的方式来创建 GIF。只是当时我只是提了这么一个思路,并没有给出录屏的方式,所以本文的内容就是教大家如何通过调用系统 API 的方式录制屏幕。

开始实现

技术原理

在安卓 5.0 之前,我们是无法通过常规的方式来录制屏幕或者截图的,要么只能 ROOT,要么就是只能用一些很 Hack 的方式来实现。

不过在安卓 5.0 后,安卓开放了 MediaProjectionManagerVirtualDisplay 等 API,使得普通应用录屏成为了可能。

简单来说,录屏的流程如下:

  1. 拿到 MediaProjectionManager 对象
  2. 通过 MediaProjectionManager.createScreenCaptureIntent() 拿到请求权限的 Intent ,然后用这个 Intent 去请求权限并拿到一个权限许可令牌(resultData,本质上还是个 Intent)。
  3. 通过拿到的 resultData 创建 VirtualDisplay投影。
  4. VirtualDisplay 将图像数据渲染至 Surface 中,最终,我们可以将 Surface 的数据流写入并编码至视频文件。(Surface 可以由 MediaCodec 创建,而 MediaMuxer 可以将 MediaCodec 的数据编码至视频文件中)

从上面的流程可以看出,其实核心思想就是通过 VirtualDisplay 拿到当前屏幕的数据,然后绕一圈将这个数据写入视频文件中。

VirtualDisplay 顾名思义,其实是用来做虚拟屏幕或者说投影的,但是这里并不妨碍我们通过它来录屏啊。

不过由于我们是通过虚拟屏幕来实现录屏的,所以如果应用声明了禁止投屏或使用虚拟屏幕,那么我们录制的内容将是空白的(黑屏)。

准备工作

明白了实现原理之后,我们需要来做点准备工作。

首先是做好界面布局,在主入口编写布局:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
        val context = LocalContext.current

        ScreenRecordTheme {
            // A surface container using the 'background' color from the theme
            Surface(
                modifier = Modifier.fillMaxSize(),
                color = MaterialTheme.colors.background
            ) {
                Column(
                    modifier = Modifier.fillMaxSize(),
                    verticalArrangement = Arrangement.Center,
                    horizontalAlignment = Alignment.CenterHorizontally
                ) {
                    Button(onClick = {
                        startServer(context)
                    }) {
                        Text(text = "启动")
                    }
                }
            }
        }
    }
}

布局很简单,就是居中显示一个启动按钮,点击按钮后启动录屏服务(Server),这里因为我们的需求是需要录制所有应用界面,而非本APP的界面,所以需要使用一个前台服务并显示一个悬浮按钮用于控制录屏开始与结束。

所以我们需要添加悬浮窗权限,并动态申请:

添加权限: <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />

检查并申请权限:

if (Settings.canDrawOverlays(context)) {
    // ……
    // 已有权限
}
else {
    // 跳转到系统设置手动授予权限(这里其实可以直接跳转到当前 APP 的设置页面,但是不同的定制 ROM 设置页面路径不一样,需要适配,所以我们直接跳转到系统通用设置让用户自己找去)
    Toast.makeText(context, "请授予“显示在其他应用上层”权限后重试", Toast.LENGTH_LONG).show()
    val intent = Intent(
        Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
        Uri.parse("package:${context.packageName}")
    )
    context.startActivity(intent)
}

悬浮界面权限拿到后就是申请投屏权限。

首先,定义 Activity Result Api,并在获取到权限后将 ResultData 传入 Server,最后启动 Server:

private lateinit var requestMediaProjectionLauncher: ActivityResultLauncher<Intent>


override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    
    // ……

    requestMediaProjectionLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
        if (it.resultCode == Activity.RESULT_OK && it.data != null) {
            OverlayService.setData(it.data!!)
            startService(Intent(this, OverlayService::class.java))
        }
        else {
            Toast.makeText(this, "未授予权限", Toast.LENGTH_SHORT).show()
        }
    }
}

然后,在按钮的点击回调中启动这个 Launcher:

val mediaProjectionManager = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
requestMediaProjectionLauncher.launch(
    mediaProjectionManager.createScreenCaptureIntent()
)

在这里我们通过 getSystemService 方法拿到了 MediaProjectionManager ,并通过 mediaProjectionManager.createScreenCaptureIntent() 拿到请求权限的 Intent。

最终在授予权限后启动录屏 Server。

但是,这里有一点需要特别注意,由于安卓系统限制,我们必须使用前台 Server 才能投屏,并且还需要为这个前台 Server 显式设置一个通知用于指示 Server 正在运行中,否则将会抛出异常。

所以,添加前台服务权限:

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

然后在我们的录屏服务中声明前台服务类型:

<service
    android:name=".overlay.OverlayService"
    android:enabled="true"
    android:exported="false"
    android:foregroundServiceType="mediaProjection" />

最后,我们需要为这个服务绑定并显示一个通知:

private fun initRunningTipNotification() {
    val builder = Notification.Builder(this, "running")

    builder.setContentText("录屏运行中")
        .setSmallIcon(R.drawable.ic_launcher_foreground)

    val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
    val channel = NotificationChannel(
        "running",
        "显示录屏状态",
        NotificationManager.IMPORTANCE_DEFAULT
    )
    notificationManager.createNotificationChannel(channel)
    builder.setChannelId("running")
    startForeground(100, builder.build())
}

需要注意的是,这里我们为了方便讲解,直接将创建和显示通知都放到了点击悬浮按钮后,并且停止录屏后也没有销毁通知。

各位在使用的时候需要根据自己需求改一下。

自此,准备工作完成。

哦,对了,关于如何使用 Compose 显示悬浮界面,因为不是本文重点,而且我也是直接套大佬的模板,所以这里就不做讲解了,感兴趣的可以自己看源码。

下面开始讲解如何录屏。

开始录屏

首先,我们编写了一个简单的帮助类 ScreenRecorder

class ScreenRecorder(
    private var width: Int,
    private var height: Int,
    private val frameRate: Int,
    private val dpi: Int,
    private val mediaProjection: MediaProjection?,
    private val savePath: String
) {
    private var encoder: MediaCodec? = null
    private var surface: Surface? = null
    private var muxer: MediaMuxer? = null
    private var muxerStarted = false
    private var videoTrackIndex = -1
    private val bufferInfo = MediaCodec.BufferInfo()
    private var virtualDisplay: VirtualDisplay? = null

    private var isStop = false

    /**
     * 停止录制
     * */
    fun stop() {
        isStop = true
    }

    /**
     * 开始录制
     * */
    fun start() {
        try {
            prepareEncoder()

            muxer = MediaMuxer(savePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)

            virtualDisplay = mediaProjection!!.createVirtualDisplay(
                "$TAG-display",
                width,
                height,
                dpi,
                DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC,
                surface,
                null,
                null
            )

            recordVirtualDisplay()
        } finally {
            release()
        }
    }


    private fun recordVirtualDisplay() {
        while (!isStop) {
            val index = encoder!!.dequeueOutputBuffer(bufferInfo, TIMEOUT_US.toLong())
            if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
                resetOutputFormat()
            } else if (index == MediaCodec.INFO_TRY_AGAIN_LATER) {
                //Log.d(TAG, "retrieving buffers time out!");
                //delay(10)
            } else if (index >= 0) {
                check(muxerStarted) { "MediaMuxer dose not call addTrack(format) " }
                encodeToVideoTrack(index)
                encoder!!.releaseOutputBuffer(index, false)
            }
        }
    }

    private fun encodeToVideoTrack(index: Int) {
        var encodedData = encoder!!.getOutputBuffer(index)
        if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
            bufferInfo.size = 0
        }
        if (bufferInfo.size == 0) {
            encodedData = null
        }
        if (encodedData != null) {
            encodedData.position(bufferInfo.offset)
            encodedData.limit(bufferInfo.offset + bufferInfo.size)
            muxer!!.writeSampleData(videoTrackIndex, encodedData, bufferInfo)
        }
    }

    private fun resetOutputFormat() {
        check(!muxerStarted) { "output format already changed!" }
        val newFormat = encoder!!.outputFormat
        videoTrackIndex = muxer!!.addTrack(newFormat)
        muxer!!.start()
        muxerStarted = true
    }

    private fun prepareEncoder() {
        val format = MediaFormat.createVideoFormat(MIME_TYPE, width, height)
        format.setInteger(
            MediaFormat.KEY_COLOR_FORMAT,
            MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
        )
        format.setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE)
        format.setInteger(MediaFormat.KEY_FRAME_RATE, frameRate)
        format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, IFRAME_INTERVAL)
        encoder = MediaCodec.createEncoderByType(MIME_TYPE)
        encoder!!.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
        surface = encoder!!.createInputSurface()
        encoder!!.start()
    }

    private fun release() {
        if (encoder != null) {
            encoder!!.stop()
            encoder!!.release()
            encoder = null
        }
        if (virtualDisplay != null) {
            virtualDisplay!!.release()
        }
        mediaProjection?.stop()
        if (muxer != null) {
            muxer?.stop()
            muxer?.release()
            muxer = null
        }
    }

    companion object {
        private const val TAG = "el, In ScreenRecorder"

        private const val MIME_TYPE = "video/avc" // H.264 Advanced Video Coding
        private const val IFRAME_INTERVAL = 10 // 10 seconds between I-frames
        private const val BIT_RATE = 6000000
        private const val TIMEOUT_US = 10000
    }
}

在这个类中,接收以下构造参数:

  • width: Int, 创建虚拟屏幕以及写入的视频宽度
  • height: Int, 创建虚拟屏幕以及写入的视频高度
  • frameRate: Int, 写入的视频帧率
  • dpi: Int, 创建虚拟屏幕的 DPI
  • mediaProjection: MediaProjection?, 用于创建虚拟屏幕的 mediaProjection
  • savePath: String, 写入的视频文件路径

我们可以通过调用 start() 方法开始录屏;调用 stop() 方法停止录屏。

调用 start() 后,会首先调用 prepareEncoder() 方法。该方法主要用途是按照给定参数创建 MediaCodec ,并通过 encoder!!.createInputSurface() 创建一个 Surface 以供后续接收虚拟屏幕的图像数据。

预先设置完成后,按照给定路径创建 MediaMuxer;将参数和之前创建的 surface 传入,创建一个新的虚拟屏幕,并开始接受图像数据。

最后,循环从上面创建的 MediaCodec 中逐帧读出有效图像数据并写入 MediaMuxer 中,即写入视频文件中。

看起来可能比较绕,但是理清楚之后还是非常简单的。

接下来就是如何去调用这个帮助类。

在调用之前,我们需要预先准备好需要的参数:

val savePath = File(externalCacheDir, "${System.currentTimeMillis()}.mp4").absolutePath
val screenSize = getScreenSize()
val mediaProjection = getMediaProjection()
  • savePath 表示写入的视频文件路径,这里我偷懒直接写成了 APP 的缓存目录,如果想要导出到其他地方,记得处理好运行时权限。
  • screenSize 表示的是当前设备的屏幕尺寸
  • mediaProjection 表示请求权限后获取到的权限“令牌”

getScreenSize() 中,我获取了设备的屏幕分辨率:

private fun getScreenSize(): IntSize {
    val windowManager = getSystemService(WINDOW_SERVICE) as WindowManager

    val screenHeight = windowManager.currentWindowMetrics.bounds.height()
    val screenWidth = windowManager.currentWindowMetrics.bounds.width()

    return IntSize(screenWidth, screenHeight)
}

但是如果我直接把这个分辨率传给帮助类创建 MediaCodec 的话会报错:

java.lang.IllegalArgumentException
    at android.media.MediaCodec.native_configure(Native Method)
    at android.media.MediaCodec.configure(MediaCodec.java:2214)
    at android.media.MediaCodec.configure(MediaCodec.java:2130)

不过,这个问题只在某些分辨率较高的设备上出现,猜测是不支持高分辨率视频写入吧,所以我实际上使用时是直接写死一个较小的分辨率,而不是使用设备的分辨率。

然后,在 getMediaProjection() 中,我们通过申请到的权限令牌生成 MediaProjection

private fun getMediaProjection(): MediaProjection? {
    if (resultData == null) {
        Toast.makeText(this, "未初始化!", Toast.LENGTH_SHORT).show()
    } else {
        try {
            val mediaProjectionManager = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
            return mediaProjectionManager.getMediaProjection(Activity.RESULT_OK, resultData!!)
        } catch (e: IllegalStateException) {
            Log.e(TAG, "getMediaProjection: ", e)
            Toast.makeText(this, "ERR: ${e.stackTraceToString()}", Toast.LENGTH_LONG).show()
        }
        catch (e: NullPointerException) {
            Log.e(TAG, "getMediaProjection: ", e)
        }
        catch (tr: Throwable) {
            Log.e(TAG, "getMediaProjection: ", tr)
            Toast.makeText(this, "ERR: ${tr.stackTraceToString()}", Toast.LENGTH_LONG).show()
        }
    }

    return null
}

最后,通过上面生成的这两个参数初始化录屏帮助类,然后调用 start()

// 这里如果直接使用屏幕尺寸会报错 java.lang.IllegalArgumentException
recorder = ScreenRecorder(
    886, // screenSize.width,
    1920, // screenSize.height,
    24,
    1,
    mediaProjection,
    savePath
)

CoroutineScope(Dispatchers.IO).launch {
    try {
        recorder.start()
    } catch (tr: Throwable) {
        Log.e(TAG, "startScreenRecorder: ", tr)
        recorder.stop()
        withContext(Dispatchers.Main) {
            Toast.makeText(this@OverlayService, "录制失败", Toast.LENGTH_LONG).show()
        }
    }
}

这里我把开始录屏放到了协程中,实际上由于我们的程序是运行在 Server 中,所以并不是必须在协程中运行。

总结

自此,在安卓中录屏的方法已经全部介绍完毕。

实际上,同样的原理我们也可以用于实现截图。

截图和录屏不同的地方在于,创建虚拟屏幕时改为使用 ImageReader 创建,然后就可以从 ImageReader 获取到 Bitmap。

最后附上完整的 demo 地址: ScreenRecord