2022年都快结束了,还有人不会安卓录屏?在安卓上录制屏幕的的实现方式

持续创作,加速生长!这是我参与「日新计划 10 月更文应战」的第14天,点击查看活动详情

前言

在我之前的文章 《以不同的形式在安卓中创立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