江西新农村建设权威网站,进博会入口,wordpress 当前列表数,在线制作广告前言
在我之前的文章 《以不同的形式在安卓中创建GIF动图》 中#xff0c;我挖了一个坑#xff0c;可以通过录制屏幕后转为 GIF 的方式来创建 GIF。只是当时我只是提了这么一个思路#xff0c;并没有给出录屏的方式#xff0c;所以本文的内容就是教大家如何通过调用系统 A…前言
在我之前的文章 《以不同的形式在安卓中创建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:nameandroid.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: ActivityResultLauncherIntentoverride 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:nameandroid.permission.FOREGROUND_SERVICE /
然后在我们的录屏服务中声明前台服务类型
serviceandroid:name.overlay.OverlayServiceandroid:enabledtrueandroid:exportedfalseandroid:foregroundServiceTypemediaProjection /最后我们需要为这个服务绑定并显示一个通知
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 ScreenRecorderprivate 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, 创建虚拟屏幕的 DPImediaProjection: MediaProjection?, 用于创建虚拟屏幕的 mediaProjectionsavePath: 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(thisOverlayService, 录制失败, Toast.LENGTH_LONG).show()}}
}这里我把开始录屏放到了协程中实际上由于我们的程序是运行在 Server 中所以并不是必须在协程中运行。
总结
自此在安卓中录屏的方法已经全部介绍完毕。
实际上同样的原理我们也可以用于实现截图。
截图和录屏不同的地方在于创建虚拟屏幕时改为使用 ImageReader 创建然后就可以从 ImageReader 获取到 Bitmap。
最后附上完整的 demo 地址 ScreenRecord