工作中遇到截屏需求,首先想到的肯定是截图所在区域的控件,通过Canvas类将View绘制成一个Bitmap,之后是要显示还是保存都可以了。但是事实上还是有一些问题存在,已知有两个问题:①不能截取到状态栏的内容吧;② 如果页面存在视频播放器,那么无法获取到播放器视频画面吧。
使用系统MediaProjection就可以解决上述两个问题。
Demo地址:https://download.csdn.net/download/bigfc/86711553
一、截屏
首先,看下最后的实现效果:
device-2022-09-24-182332
具体的实现步骤:
申请权限&注册前台服务
....
在Activity生命周期中绑定和解绑定Service
这里以绑定的形式开启服务方便Service和Activity之间的交互,而且考虑可能一个页面中可能多次触发截屏,service和activity绑定到一起,不用反复启动服务,而且可以跟页面生命周期保持一致。
class MediaProjectionActivity : AppCompatActivity() { //截屏、录屏服务 private var mScreenShortService: ScreenShortRecordService? = null ... private val connection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, iBinder: IBinder?) { if (iBinder is ScreenShortRecordService.ScreenShortBinder) { //截屏 mScreenShortService = iBinder.getService() } } override fun onServiceDisconnected(name: ComponentName?) { //no-op } } override fun onStart() { super.onStart() // 绑定服务 Intent(this, ScreenShortRecordService::class.java) .also { intent -> bindService(intent, connection, Context.BIND_AUTO_CREATE) } } override fun onStop() { super.onStop() //解绑服务 unbindService(connection) }}
点击截屏的时候通过MediaProjectionManager创建截屏Intent并启动
//截屏点击事件 fun capture(view: View) { mScreenShortService?.let { //开始截屏 mediaManager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager mediaManager.createScreenCaptureIntent().apply { startActivityForResult(this, CAPTURE_CODE) } } }
监听onActivityResult,获取到返回的intent后,调用服务的开始截屏
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (resultCode == Activity.RESULT_OK) { when (requestCode) { //截屏 CAPTURE_CODE -> { data?.let { mScreenShortService?.startShort(it, object : ScreenshotListener {override suspend fun onScreenSuc(bitmap: Bitmap) { //显示截图 showScreenshort(bitmap)} }) } } //录屏 MIRROR_CODE -> { ... } } } }
这里必须先申请成为前台服务,否则会报SecurityException异常
fun startShort(intent: Intent, callback: ScreenshotListener) { //开启通知,并申请成为前台服务 startNotification() //标记 this.isGot = false //回调 this.callback = callback mMediaProjectionManager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager //获取令牌 mMediaProjection = mMediaProjectionManager?.getMediaProjection(Activity.RESULT_OK, intent) //这里延迟一会再取 Handler(Looper.myLooper()!!).postDelayed(object : Runnable { override fun run() { //配置ImageReader configImageReader() } }, 400) }
成功获取到令牌后,就可以通过监听获取有效的ImageReader对象
@SuppressLint("WrongConstant") fun configImageReader() { val dm = resources.displayMetrics imageReader = ImageReader.newInstance( dm.widthPixels, dm.heightPixels, PixelFormat.RGBA_8888, 1 ).apply { setOnImageAvailableListener({ //这里页面帧发生变化时就会回调一次,我们只需要获取一张图片,加个标记位,避免重复 if (!isGot) { isGot = true //这里就可以保存图片了 savePicTask(it) } }, null) //把内容投射到ImageReader 的surface mMediaProjection?.createVirtualDisplay( TAG, dm.widthPixels, dm.heightPixels, dm.densityDpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC, surface, null, null ) } }
最后读取ImageReader生成Bitmap,此处就已经可以退出前台服务了,但是服务并没有解绑,下次只需要让服务重新申请前台,就可以继续下次截屏。
private fun savePicTask(reader: ImageReader) { scopeIo { var image: Image? = null try { //获取捕获的照片数据 image = reader.acquireLatestImage() val width = image.width val height = image.height //拿到所有的 Plane 数组 val planes = image.planes val plane = planes[0] val buffer: ByteBuffer = plane.buffer //相邻像素样本之间的距离,因为RGBA,所以间距是4个字节 val pixelStride = plane.pixelStride //每行的宽度 val rowStride = plane.rowStride //因为内存对齐问题,每个buffer 宽度不同,所以通过pixelStride * width 得到大概的宽度, //然后通过 rowStride 去减,得到大概的内存偏移量,不过一般都是对齐的。 val rowPadding = rowStride - pixelStride * width // 创建具体的bitmap大小,由于rowPadding是RGBA 4个通道的,所以也要除以pixelStride,得到实际的宽 val bitmap = Bitmap.createBitmap( width + rowPadding / pixelStride, height, Bitmap.Config.ARGB_8888 ) bitmap.copyPixelsFromBuffer(buffer) callback?.onScreenSuc(bitmap) //服务退出前台 stopForeground(true) mMediaProjection?.stop() } catch (e: java.lang.Exception) { e.printStackTrace() } finally { //记得关闭 image try { image?.close() } catch (e: Exception) { } } } }
二、录屏
device-2022-09-24-184104
具体实现步骤如下:
除了截屏需要前台服务权限和Service,录屏还需要存储和录音权限
...
服务绑定同截屏
点击录屏此时需要动态申请录音、存储权限
获得权限后,同样需要通过MediaProjectionManager创建录屏的Intent,并启动
//开始录屏 private fun startRecordScreen() { if (!isRecord) { //释放播放器 MediaPlayerHelper.release() isRecord = true mediaManager = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager mediaManager.createScreenCaptureIntent().apply { startActivityForResult(this, MIRROR_CODE) } btnRecord?.text = "正在录制,可随意切换界面,点击结束并播放" } else { try { isRecord = false btnRecord?.text = "点击开始屏幕录制" //停止录制 mScreenShortService?.stopRecorder() Toast.makeText(this, "开始播放", Toast.LENGTH_SHORT).show() surfaceview?.holder?.let { val file = File(path, fileName) MediaPlayerHelper.prepare( file.absolutePath, it, MediaPlayer.OnPreparedListener {Log.d(TAG, "onPrepared: ${it.isPlaying}")MediaPlayerHelper.play() }) } } catch (e: Exception) { Log.d(TAG, "mediaProjecing: $e") } } }
5、监听到返回结果是就可以调用服务,开始录制了
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (resultCode == Activity.RESULT_OK) { when (requestCode) { //截屏 CAPTURE_CODE -> { ... } //录屏 MIRROR_CODE -> { //开始录制 data?.let { mScreenShortService?.startRecorder(path, fileName, it) } } } } }
申请前台服务、申请令牌、配置MediaRecorder,开始录屏。
这里为了存储视频文件,需要传递一个文件路径和文件名,配置MediaRecorder的时候使用
//开始录屏 fun startRecorder(path: String, fileName: String, intent: Intent) { //开启通知,并申请成为前台服务 startNotification() this.isGot = false this.callback = callback mMediaProjectionManager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager //获得令牌 mMediaProjection = mMediaProjectionManager?.getMediaProjection(Activity.RESULT_OK, intent) //这里延迟一会再取 Handler(Looper.myLooper()!!).postDelayed(object : Runnable { override fun run() { //配置MediaRecorder if (configMediaRecorder(path, fileName)) { try { //开始录屏 recorder?.start() } catch (e: Exception) { e.printStackTrace() } } } }, 400) }
配置MediaRecorder
private fun configMediaRecorder(path: String, fileName: String): Boolean { //创建文件夹 val dir = File(path) if (!dir.exists()) { dir.mkdirs() } val file = File(path, fileName) if (file.exists()) { file.delete() } val dm = resources.displayMetrics recorder = MediaRecorder() recorder?.apply { setAudioSource(MediaRecorder.AudioSource.MIC) //音频载体 setVideoSource(MediaRecorder.VideoSource.SURFACE) //视频载体 setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) //输出格式 setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB) //音频格式 setVideoEncoder(MediaRecorder.VideoEncoder.H264) //视频格式 setVideoSize(dm.widthPixels, dm.heightPixels) //size setVideoFrameRate(30) //帧率 setVideoEncodingBitRate(3 * 1024 * 1024) //比特率 //设置文件位置 setOutputFile(file.absolutePath) try { prepare() virtualDisplay = mMediaProjection?.createVirtualDisplay( TAG, dm.widthPixels, dm.heightPixels, dm.densityDpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, surface, null, null ) } catch (e: Exception) { e.printStackTrace() return false } } return true }
8、经过上述步骤,手机就开始录屏了,当点击录制结束时,不要忘记关闭MediaRecorder。此时可以退出前台服务了。
//停止录制 fun stopRecorder() { recorder?.stop() recorder?.release() recorder = null mMediaProjection?.stop() //退出前台服务 stopForeground(true) }
此时已经配置的文件路径中就可以找到录屏的文件了,如下图:
最后 无论是录屏还是截屏都需要释放资源,这里不再列举了
异常情况
java.lang.SecurityException: Media projections require a foreground service of type ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
此问题是因为Android Q开始,使用中MediaProjection时必须申请一个前台服务,并且开启一个通知,用于提醒用户应用正在捕获屏幕信息,无论是服务开启的顺序错误还是未开启通知都会报这个异常。
RuntimeException: setAudioSource failed异常
录屏是需要动态申请录音权限,权限未申请通过会出现这个错误
Targeting S+ (version 31 and above) requires that one of FLAG_IMMUTABLE or FLAG_MUTABLE be specified
创建PendingIntent是在Android 版本31以上时,需要指定Flag为FLAG_IMMUTABLE 或者 FLAG_MUTABLE,否则会报这个错误,具体代码可以参考:
val pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { PendingIntent.getActivity( this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE ); } else { PendingIntent.getActivity( this, 0, notificationIntent, PendingIntent.FLAG_CANCEL_CURRENT ); }
参考链接:
Capture video and audio playback | Android Developers
绑定服务概览 | Android 开发者 | Android Developers
Foreground services | Android Developers