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

news/2025/2/13 0:55:55/

前言

在我之前的文章 《以不同的形式在安卓中创建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.currentScreenRecordTheme {// A surface container using the 'background' color from the themeSurface(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" />

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

<serviceandroid: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 NotificationManagerval 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? = nullprivate var surface: Surface? = nullprivate var muxer: MediaMuxer? = nullprivate var muxerStarted = falseprivate var videoTrackIndex = -1private val bufferInfo = MediaCodec.BufferInfo()private var virtualDisplay: VirtualDisplay? = nullprivate 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!!.outputFormatvideoTrackIndex = 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 Codingprivate const val IFRAME_INTERVAL = 10 // 10 seconds between I-framesprivate const val BIT_RATE = 6000000private 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 WindowManagerval screenHeight = windowManager.currentWindowMetrics.bounds.height()val screenWidth = windowManager.currentWindowMetrics.bounds.width()return IntSize(screenWidth, screenHeight)
}

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

java.lang.IllegalArgumentExceptionat 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 MediaProjectionManagerreturn 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


http://www.ppmy.cn/news/92710.html

相关文章

考研C语言第四章

4.1 关系表达式与逻辑表达式 ps&#xff1a; 算术运算符&#xff1a;加减乘除等 关系运算符&#xff1a;比大小的 逻辑与逻辑或 非&#xff01;的运算级别&#xff08;应该&#xff09;最高 4.2 if-else #include <stdio.h> //上课这个写while的原因是方便一次一次…

2023-05-27 Unity 2进制4——类对象的序列化与反序列化

文章目录 一、序列化二、反序列化 一、序列化 &#xff08;一&#xff09;声明类对象 ​ 如果要使用 C# 自带的序列化 2 进制方法&#xff0c;申明类时需要添加[System.Serializable]特性。 [System.Serializable] public class Person {public int age 1;public string na…

Systrace系列6 —— Input 解读

本文主要是对 Systrace 中的 Input 进行简单介绍,介绍其 Input 的流程; Systrace 中 Input 信息的体现 ,以及如何结合 Input 信息,分析与 Input 相关的问题。 在Android 基于 Choreographer 的渲染机制详解 这篇文章中,我有讲到,Android App 的主线程运行的本质是靠 Mess…

DRF之过滤,排序,分页

一、权限组件源码解读 1.继承了APIView 才有的---》执行流程---》dispatch中----》三大认证 APIView的dispatch def initial(self, request, *args, **kwargs):self.perform_authentication(request)self.check_permissions(request)self.check_throttles(request) 2 读…

查找算法之散列表

一.说明 刚好复习数据结构&#xff0c;前面几篇博客我们知道了顺序查找、二分查找、分块查找、树形查找&#xff08;二叉排序树、平衡二叉树、红黑树、B树和B树&#xff09;&#xff0c;这一篇博客介绍常用查找算法中的最后一个算法——散列表&#xff08;哈希查找&#xff09…

怎么导入别人的android项目

到期末了好多同学都问我怎么把别人的安卓项目导进自己电脑里面&#xff0c;今天我来统一解答一下&#xff0c;希望有所帮助。 1.删除项目中原有的自动构建的文件 去到要导入项目的目录下把 .idea .gradle与build 三个文件夹&#xff0c;*.iml&#xff0c;local.properties删除…

MATLAB算法实战应用案例精讲-【数模应用】灰色预测模型(补充篇)(附Java、python和MATLAB代码)

目录 前言 几个相关概念 知识储备 ⒈生成数 ⒉关联度 灰色模型GM(n,h)

JAVA中的深情哥-Exception(异常)-上

文章目录 目录 文章目录 前言 一&#xff0c;Exception的起源 二&#xff0c;异常类 三&#xff0c;自定义异常 总结 前言 大家好,我是最爱吃兽奶,今天给大家介绍一下java中的深情哥 - Exception 秋风依依秋水寒&#xff0c;一点离愁两黯然&#xff1b;今生默默唯轻舞&a…