2022年都快结束了,还有人不会安卓录屏?在安卓上录制屏幕的的实现方式
前言
在我之前的文章 《以不同的形式在安卓中创建GIF动图》 中,我挖了一个坑,可以通过录制屏幕后转为 GIF 的方式来创建 GIF。只是当时我只是提了这么一个思路,并没有给出录屏的方式,所以本文的内容就是教大家如何通过调用系统 API 的方式录制屏幕。
开始实现
技术原理
在安卓 5.0 之前,我们是无法通过常规的方式来录制屏幕或者截图的,要么只能 ROOT,要么就是只能用一些很 Hack 的方式来实现。
不过在安卓 5.0 后,安卓开放了 MediaProjectionManager
、 VirtualDisplay
等 API,使得普通应用录屏成为了可能。
简单来说,录屏的流程如下:
- 拿到
MediaProjectionManager
对象 - 通过
MediaProjectionManager.createScreenCaptureIntent()
拿到请求权限的Intent
,然后用这个Intent
去请求权限并拿到一个权限许可令牌(resultData,本质上还是个 Intent)。 - 通过拿到的 resultData 创建
VirtualDisplay
投影。 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
相关文章:

2022年都快结束了,还有人不会安卓录屏?在安卓上录制屏幕的的实现方式
前言 在我之前的文章 《以不同的形式在安卓中创建GIF动图》 中,我挖了一个坑,可以通过录制屏幕后转为 GIF 的方式来创建 GIF。只是当时我只是提了这么一个思路,并没有给出录屏的方式,所以本文的内容就是教大家如何通过调用系统 A…...

px rem em rpx 区别 用法
任意浏览器的默认字体高都是16px。所有未经调整的浏览器都符合: 1em16px。那么12px0.75em,10px0.625em。为了简化font-size的换算,需要在css中的body选择器中声明Font-size62.5%,这就使em值变为 16px*62.5%10px, 这样12px1.2em, 10px1em, 也就是说只需要…...

忆享聚焦|ChatGPT、AI、网络数字、游戏……近期热点资讯一览
“忆享聚焦”栏目第十四期来啦!本栏目汇集近期互联网最新资讯,聚焦前沿科技,关注行业发展动态,筛选高质量讯息,拓宽用户视野,让您以最低的时间成本获取最有价值的行业资讯。 目录 行业资讯 1.科技部部长王志…...

[Daimayuan] 树(C++,动态规划,01背包方案数)
有一棵 n n n 个节点的以 1 1 1 号点为根的有根树。现在可以对这棵树进行若干次操作,每一次操作可以选择树上的一个点然后删掉连接这个点和它的儿子的所有边。 现在我们想知道对于每一个 k k k ( 1 ≤ k ≤ n 1≤k≤n 1≤k≤n),最少需要多少次操作能…...

如何选择源代码加密软件
(SDC沙盒)和DLP、文档加密、云桌面等,其优缺点做客观比较如下: 比较内容安全容器(SDC沙盒)DLP文档加密云桌面代表厂家*信达卖咖啡、赛门贴科亿*通、IP噶德、*盾、*途四杰、深*服设计理念以隔离容器加准入技术为基础,构…...

TO-B类软件产品差异化
产品差异化,是在市场众多同质化产品中,突出自身产品亮点的重要方式。对于客户来讲其选择是多种多样的,与其花费大量的时间研究每一家产品的特点,还不如直接选择品牌更大、价格更低的产品来的直接,因此显而易见的突出产…...

设计模式之美-实战一(上):业务开发常用的基于贫血模型的MVC架构违背OOP吗?
领域驱动设计(Domain Driven Design,简称DDD)盛行之后,这种基于贫血模型的传统的开发模式就更加被人诟病。而基于充血模型的DDD开发模式越来越被人提倡。所以,我打算用两节课的时间,结合一个虚拟钱包系统的…...

ChatGPT如何训练自己的模型
ChatGPT是一种自然语言处理模型,它的任务是生成自然流畅的对话。如果想要训练自己的ChatGPT模型,需要进行大量的数据收集、预处理、配置训练环境、模型训练、模型评估等过程。本文将详细介绍这些过程,帮助读者了解如何训练一个高品质的ChatGP…...

springboot使用线程池的实际应用(一)
在实际Spring Boot项目中,我们可以使用Java的原生多线程或者使用Spring自带的线程池进行多线程编程。多线程的好处在于能够提高应用程序的运行效率,特别是在某些计算密集型场景下。以下是一些使用多线程的典型场景: 并发处理请求:…...

ESP-8266学习笔记
1、学习地址 【XMF09F系列资源】基于MicroPython的ESP8266物联网应用开发-赛教资源目录汇总-小蜜蜂笔记 Quick reference for the ESP8266 — MicroPython latest documentation 2、MicroPython及相关开发资源 3、固件烧录与uPyLoader的使用 烧录教程参考: https://www.…...

Java泛型简单的使用
前言 Java里面的泛型在实际开发中运用的很多,学过C的同学一定知道C的模板,而Java中的泛型,一定程度上和它还是挺像的。 相信写Java的人,大都有用过List的实现类ArrayList。在Java没有泛型之前,它的内部是一个Object的…...

深度探索:Qt CMake工程编译后的自动打包策略
深度探索:Qt CMake工程编译后的自动打包策略 1. 引言(Introduction)1.1 Qt和CMake的基本概念(Basic Concepts of Qt and CMake)1.2 自动打包的重要性(Importance of Automatic Packaging) 2. Qt…...

2.7 编译型和解释型
2.7 编译型和解释型 前面我们使用java和javac命令把Hello,World!在控制台输出。那为什么输出,这里我们需要掌握两个知识点。编译型语言和解释型语言。在计算机的高级编程语言就分为编译型语言和解释型语言。而我们的Java既有编译型的特点也有…...

校园网自动登陆(河南科技学院)
1. 介绍 河南科技学院校园网自动登陆(新乡的很多系统相似,可能也可以用?),java版。可以实现电脑,路由器,软路由的自动认证wifi,后续会上传docker版本的。 源码地址 github:https://…...

C++11 override和final关键字
C11中的override和final关键字是为了增强代码的编译时类型检查和面向对象设计中的继承机制。 override关键字用于显示地表明派生类中的成员函数覆盖了基类中的虚函数。当派生类中的函数与基类中的虚函数签名不同或者没有使用override关键字时,编译器会给出警告或错…...

kafka的log存储解析
kafka的log存储解析——topic的分区partition分段segment以及索引等 引言Kafka中的Message是以topic为基本单位组织的,不同的topic之间是相互独立的。每个topic又可以分成几个不同的partition(每个topic有几个partition是在创建topic时指定 的),每个…...

4.文件系统
组成 Linux:一切皆文件 索引节点(I-node) I-node(Index Node):文件系统的内部数据结构,用于管理文件的元数据和数据块。 文件的元数据:包括文件的权限、拥有者、大小、时间戳、索引…...

Shell脚本case in esac分支语句应用
记录:434 场景:Shell脚本case in esac分支语句应用。 版本:CentOS Linux release 7.9.2009。 1.case in esac格式 格式: case 值 in 模式1)expression;; 模式2)expression;; 模式n)expression;; esac 解析:case…...

【线性dp必学四道题】线性dp四道经典例题【最长上升子序列】、【最长公共子序列】、【最长公共上升子序列(maxv的由来)】【最长公共子串】
【最长上升子序列】、【最长公共子序列】、【最长公共上升子序列】 最长上升子序列f[i] 表示以i结尾的最长子序列 最长公共子序列f[i][j] 表示 a前i 和 b前j个 最长公共长度 最长公共上升子序列f[i][j]代表所有a[1 ~ i]和b[1 ~ j]中以b[j]结尾的公共上升子序列的集合 最长公共子…...

追寻幸福:探索幸福的关键特征和行为
目录 1. 积极的心态 2. 良好的人际关系 3. 自我接纳和自尊 4. 追求意义和目标 5. 健康的身心状态 6. 感知和实现个人价值 幸福是一个主观的感受,因此不同的人对于幸福的定义和追求方式可能会有所不同。然而,有一些共同的特点和行为模式,…...

Redis-02-集群
一、redis5搭建集群 1.1、案例:搭建6台redis主机,配置如下 redis并发量:https://www.gxlcms.com/redis-350423.html主机IP:192.168.168.60~65修改redis配置文件hash槽移动,槽内的数据也随之移动 [root60 ~]# vim /e…...

【2023 · CANN训练营第一季】MindSpore模型快速调优攻略 第三章——MindSpore云上调试调优
1.ModelArts云上调试调优 ModelArts密钥初始化 详细教程: 初始化OBS服务 创建训练作业 2.MindSpore IDE插件效率提升 通过智能代码块推荐、代码自动补全等特性,提升MindSpore脚本开发效率,对接ModelArts云服务,实现模型训…...

python笔记17_实例演练_二手车折旧分析p2
…… 书接上文 4.车辆等级维度 探查车龄为5年的车辆,折旧价值与车辆等级的关系。 # 筛选出车龄为5的数据创建新表 data_age5 data[data[age] 5] data_age5 # 分组聚合计算均值 data_car_level data_age5.groupby(car_level_name)[lowest_price].mean().reset…...

android 12.0长按Power弹出关机对话框去掉屏幕截图和紧急呼救功能
1.概述 在12.0的系统长按关机键,会弹出关机的对话框,关机对话框里面由关机重启截图和紧急呼叫等功能,而由于开发功能需求要求去掉屏幕截图和紧急呼叫等功能,所以就要先找到关机对框的代码 然后实现功能 功能分析: 长按电源键弹出关机对话框,通过adb shell命令发现 就是f…...

2023年下半年软考高级需要报班吗?
首先,对于软考高级考试报班与否的问题,需要根据自身的情况来做出决定。如果你有较强的自学能力,且具备丰富的实际工作经验和技术知识,那么不报班也完全可以自学备考。但如果你对软件工程的知识掌握程度较低,或者时间紧…...

使用WordPress提高企业敏捷性
喜欢WordPress的原因有很多:该平台非常适合内容管理以及控制预算。此外, 在 提高开发效率和简化项目管理方面,WordPress可以通过多种方式提供帮助。 对于任何企业业务,目标始终是在不影响质量的情况下更快地启动项目、发布修复和…...

SSM编程---Day 07
目录 SpringMVC 一、概念 二、springMVC的请求处理流程 三、mvc:annotation-driven 标签的作用 四、HandlerMapping、Handler和HandlerAdapter的介绍 五、SpringMVC 体系结构 六、SpringMVC的常用注解 七、view和controller之间的传值 SpringMVC 一、概念 1、 Spring…...

Seata术语
1.什么是Seata Seata是一款开源的分布式事务解决方案,致力于在微服务架构下提供高性能和简单易用的分布式事务服务。 官网 2.Seata能干嘛 一个典型的分布式事务过程 分布式事务处理过程的一ID三组件模型: Transaction ID XID 全局唯一的事务ID三组…...

【Axure教程】通过文本框维护下拉列表选项
下拉列表(Dropdown List)是一种常见的用户界面元素,用于提供一组选项供用户选择。它通常以一个展开的列表形式出现,用户可以点击或选择列表中的一个选项。一般来说,他的选项值是由系统代码组成的,所以一般是…...

【C++】基础知识--输入/输出(5)
前面部分的示例程序几乎没有提供与用户的交互(如果有的话)。他们只是在屏幕上打印简单的值,但标准库提供了许多其他方式通过其输入/输出功能与用户交互。本节将简要介绍一些最有用的方法。 cin标准输入cout标准输出cerr标准错误(输…...