百万级日活 App 的屏幕录制功能是如何实现的

百万级日活 App 的屏幕录制功能是如何实现的,第1张

概述Android 从 4.0 开始就提供了手机录屏方法,但是需要 root 权限,比较麻烦不容易实现。但是从 5.0 开始,系统提供给了 App 录制屏幕的一系列方法,不需要 root 权限,只需要用户授权即可录屏,相对来说较为简单。 基本上根据 官方文档 便可以写出录屏的相关代码。 屏幕录制的基本实现步骤 在 Manifest 中申明权限 <uses-permission android:name

AndroID 从 4.0 开始就提供了手机录屏方法,但是需要 root 权限,比较麻烦不容易实现。但是从 5.0 开始,系统提供给了 App 录制屏幕的一系列方法,不需要 root 权限,只需要用户授权即可录屏,相对来说较为简单。

基本上根据 官方文档 便可以写出录屏的相关代码。

屏幕录制的基本实现步骤 在 Manifest 中申明权限
<uses-permission androID:name="androID.permission.RECORD_AUdio" /><uses-permission androID:name="androID.permission.WRITE_EXTERNAL_STORAGE"/><uses-permission androID:name="androID.permission.READ_EXTERNAL_STORAGE"/>
获取 mediaprojectionmanager 并申请权限
private val mediaprojectionmanager by lazy { activity.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as? mediaprojectionmanager }private var mediaProjection: MediaProjection? = nullif (mediaprojectionmanager == null) {    Log.d(TAG,"mediaprojectionmanager == null,当前手机暂不支持录屏")    showToast(R.string.phone_not_support_screen_record)    return}// 申请相关权限PermissionUtils.permission(PermissionConstants.STORAGE,PermissionConstants.MICROPHONE)        .callback(object : PermissionUtils.SimpleCallback {            overrIDe fun onGranted() {                Log.d(TAG,"start record")                mediaprojectionmanager?.apply {                    // 申请相关权限成功后,要向用户申请录屏对话框                    val intent = this.createScreenCaptureIntent()                    if (activity.packageManager.resolveActivity(intent,PackageManager.MATCH_DEFAulT_ONLY) != null) {                        activity.startActivityForResult(intent,REQUEST_CODE)                    } else {                        showToast(R.string.phone_not_support_screen_record)                    }                }            }            overrIDe fun onDenIEd() {                showToast(R.string.permission_denIEd)            }        })        .request()
重写 onActivityResult() 对用户授权进行处理
overrIDe fun onActivityResult(requestCode: Int,resultCode: Int,data: Intent) {    if (requestCode == REQUEST_CODE) {        if (resultCode == Activity.RESulT_OK) {            mediaProjection = mediaprojectionmanager!!.getMediaProjection(resultCode,data)            // 实测,部分手机上录制视频的时候会有d窗的出现,所以我们需要做一个 150ms 的延迟            Handler().postDelayed({                if (initRecorder()) {                    mediaRecorder?.start()                } else {                    showToast(R.string.phone_not_support_screen_record)                }            },150)        } else {            showToast(R.string.phone_not_support_screen_record)        }    }}private fun initRecorder(): Boolean {    Log.d(TAG,"initRecorder")    var result = true    // 创建文件夹    val f = file(savePath)    if (!f.exists()) {        f.mkdirs()    }    // 录屏保存的文件    savefile = file(savePath,"$savename.tmp")    savefile?.apply {        if (exists()) {            delete()        }    }    mediaRecorder = MediaRecorder()    val wIDth = Math.min(displayMetrics.wIDthPixels,1080)    val height = Math.min(displayMetrics.heightPixels,1920)    mediaRecorder?.apply {        // 可以设置是否录制音频        if (recordAudio) {            setAudioSource(MediaRecorder.AudioSource.MIC)        }        setVIDeoSource(MediaRecorder.VIDeoSource.SURFACE)        setoutputFormat(MediaRecorder.OutputFormat.MPEG_4)        setVIDeoEncoder(MediaRecorder.VIDeoEncoder.H264)        if (recordAudio){            setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB)        }        setoutputfile(savefile!!.absolutePath)        setVIDeoSize(wIDth,height)        setVIDeoEnCodingBitRate(8388608)        setVIDeoFrameRate(VIDEO_FRAME_RATE)        try {            prepare()            virtualdisplay = mediaProjection?.createVirtualdisplay("MainScreen",wIDth,height,displayMetrics.densityDpi,displayManager.VIRTUAL_disPLAY_FLAG_auto_MIRROR,surface,null,null)            Log.d(TAG,"initRecorder 成功")        } catch (e: Exception) {            Log.e(TAG,"IllegalStateException preparing MediaRecorder: ${e.message}")            e.printstacktrace()            result = false        }    }    return result}

上面可以看到,我们可以设置一系列参数,各种参数的意思就希望大家自己去观摩官方文档了。其中有一个比较重要的一点是我们通过 mediaprojectionmanager 创建了一个 Virtualdisplay,这个 Virtualdisplay 可以理解为虚拟的呈现器,它可以捕获屏幕上的内容,并将其捕获的内容渲染到 Surface 上,MediaRecorder 再进一步把其封装为 mp4 文件保存。

录制完毕,调用 stop 方法保存数据
private fun stop() {    if (isRecording) {        isRecording = false        try {            mediaRecorder?.apply {                setonErrorListener(null)                setonInfoListener(null)                setPrevIEwdisplay(null)                stop()                Log.d(TAG,"stop success")            }        } catch (e: Exception) {            Log.e(TAG,"stopRecorder() error!${e.message}")        } finally {            mediaRecorder?.reset()            virtualdisplay?.release()            mediaProjection?.stop()            Listener?.onEndRecord()        }    }}/** * if you has parameters,the recordAudio will be invalID */fun stopRecord(vIDeoDuration: Long = 0,audioDuration: Long = 0,afdd: AssetfileDescriptor? = null) {    stop()    if (audioDuration != 0L && afdd != null) {        syntheticAudio(vIDeoDuration,audioDuration,afdd)    } else {        // savefile        if (savefile != null) {            val newfile = file(savePath,"$savename.mp4")            // 录制结束后修改后缀为 mp4            savefile!!.renameTo(newfile)            // 刷新到相册            val intent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_file)            intent.data = Uri.fromfile(newfile)            activity.sendbroadcast(intent)            showToast(R.string.save_to_album_success)        }        savefile = null    }}

我们必须来看看 MediaRecorderstop() 方法的注释。

/** * Stops recording. Call this after start(). Once recording is stopped,* you will have to configure it again as if it has just been constructed. * Note that a RuntimeException is intentionally thrown to the * application,if no valID audio/vIDeo data has been received when stop() * is called. This happens if stop() is called immediately after * start(). The failure lets the application take action accordingly to * clean up the output file (delete the output file,for instance),since * the output file is not properly constructed when this happens. * * @throws IllegalStateException if it is called before start() */public native voID stop() throws IllegalStateException;

根据官方文档,stop() 如果在 prepare() 后立即调用会崩溃,但对其他情况下发生的错误却没有做过多提及,实际上,当你真正地使用 MediaRecorder 做屏幕录制的时候,你会发现即使你没有在 prepare() 后立即调用 stop(),也可能抛出 IllegalStateException 异常。所以,保险起见,我们最好是直接使用 try...catch... 语句块进行包裹。

比如你 initRecorder 中某些参数设置有问题,也会出现 stop() 出错,数据写不进你的文件。

完毕后,释放资源
fun clearall() {    mediaRecorder?.release()    mediaRecorder = null    virtualdisplay?.release()    virtualdisplay = null    mediaProjection?.stop()    mediaProjection = null}
无法绕过的环境声音

上面基本对 AndroID 屏幕录制做了简单的代码编写,当然实际上,我们需要做的地方还不止上面这些,感兴趣的可以移步到 ScreenRecordHelper 进行查看。

但这根本不是我们的重点,我们极其容易遇到这样的情况,需要我们录制音频的时候录制系统音量,但却不允许我们把环境音量录进去。

似乎我们前面初始化 MediaRecorder 的时候有个设置音频源的地方,我们来看看这个 MediaRecorder.setAudioSource() 方法都支持设置哪些东西。

从官方文档 可知,我们可以设置以下这些音频源。由于官方注释太多,这里就简单解释一些我们支持的可以设置的音频源。

//设定录音来源于同方向的相机麦克风相同,若相机无内置相机或无法识别,则使用预设的麦克风MediaRecorder.AudioSource.CAMCORDER //默认音频源MediaRecorder.AudioSource.DEFAulT ?//设定录音来源为主麦克风MediaRecorder.AudioSource.MIC//设定录音来源为语音拨出的语音与对方说话的声音MediaRecorder.AudioSource.VOICE_CALL// 摄像头旁边的麦克风MediaRecorder.AudioSource.VOICE_COMMUNICATION//下行声音MediaRecorder.AudioSource.VOICE_DOWNlink//语音识别MediaRecorder.AudioSource.VOICE_RECOGNITION//上行声音MediaRecorder.AudioSource.VOICE_UPlink

咋一看没有我们想要的选项,实际上你逐个进行测试,你也会发现,确实如此。我们想要媒体播放的音乐,总是无法摆脱环境声音的限制。

奇怪的是,我们使用华为部分手机的系统录屏的时候,却可以做到,这就感叹于 ROM 的定制性更改的神奇,当然,千奇百怪的第三方 ROM 也一直让我们 AndroID 适配困难重重。

曲线救国剥离环境声音

既然我们通过调用系统的 API 始终无法实现我们的需求:录制屏幕,并同时播放背景音乐,录制好保存的视频需要只有背景音乐而没有环境音量,我们只好另辟蹊径。

不难想到,我们完全可以在录制视频的时候不设置音频源,这样得到的视频就是一个没有任何声音的视频,如果此时我们再把音乐强行剪辑进去,这样就可以完美解决用户的需要了。

对于音视频的混合编辑,想必大多数人都能想到的是大名鼎鼎的 FFmpeg ,但如果要自己去编译优化得到一个稳定可使用的 FFmpge 库的话,需要花上不少时间。更重要的是,我们为一个如此简单的功能大大的增大我们 APK 的体积,那是万万不可的。所以我们需要把目光转移到官方的 MediaExtractor 上。

从 官方文档 来看,能够支持到 m4a 和 aac 格式的音频文件合成到视频文件中,根据相关文档我们就不难写出这样的代码。

/** * https://stackoverflow.com/questions/31572067/androID-how-to-mux-audio-file-and-vIDeo-file */private fun syntheticAudio(audioDuration: Long,vIDeoDuration: Long,afdd: AssetfileDescriptor) {    Log.d(TAG,"start syntheticAudio")    val newfile = file(savePath,"$savename.mp4")    if (newfile.exists()) {        newfile.delete()    }    try {        newfile.createNewfile()        val vIDeoExtractor = MediaExtractor()        vIDeoExtractor.setDataSource(savefile!!.absolutePath)        val audioExtractor = MediaExtractor()        afdd.apply {            audioExtractor.setDataSource(fileDescriptor,startOffset,length * vIDeoDuration / audioDuration)        }        val muxer = Mediamuxer(newfile.absolutePath,Mediamuxer.OutputFormat.muxer_OUTPUT_MPEG_4)        vIDeoExtractor.selectTrack(0)        val vIDeoFormat = vIDeoExtractor.getTrackFormat(0)        val vIDeoTrack = muxer.addTrack(vIDeoFormat)        audioExtractor.selectTrack(0)        val audioFormat = audioExtractor.getTrackFormat(0)        val audioTrack = muxer.addTrack(audioFormat)        var sawEOS = false        var frameCount = 0        val offset = 100        val sampleSize = 1000 * 1024        val vIDeoBuf = ByteBuffer.allocate(sampleSize)        val audioBuf = ByteBuffer.allocate(sampleSize)        val vIDeoBufferInfo = MediaCodec.BufferInfo()        val audioBufferInfo = MediaCodec.BufferInfo()        vIDeoExtractor.seekTo(0,MediaExtractor.SEEK_TO_CLOSEST_SYNC)        audioExtractor.seekTo(0,MediaExtractor.SEEK_TO_CLOSEST_SYNC)        muxer.start()        // 每秒多少帧        // 实测 OPPO R9em 垃圾手机,拿出来的没有 MediaFormat.KEY_FRAME_RATE        val frameRate = if (vIDeoFormat.containsKey(MediaFormat.KEY_FRAME_RATE)) {            vIDeoFormat.getInteger(MediaFormat.KEY_FRAME_RATE)        } else {            31        }        // 得出平均每一帧间隔多少微妙        val vIDeoSampleTime = 1000 * 1000 / frameRate        while (!sawEOS) {            vIDeoBufferInfo.offset = offset            vIDeoBufferInfo.size = vIDeoExtractor.readSampleData(vIDeoBuf,offset)            if (vIDeoBufferInfo.size < 0) {                sawEOS = true                vIDeoBufferInfo.size = 0            } else {                vIDeoBufferInfo.presentationTimeUs += vIDeoSampleTime                vIDeoBufferInfo.flags = vIDeoExtractor.sampleFlags                muxer.writeSampleData(vIDeoTrack,vIDeoBuf,vIDeoBufferInfo)                vIDeoExtractor.advance()                frameCount++            }        }        var sawEOS2 = false        var frameCount2 = 0        while (!sawEOS2) {            frameCount2++            audioBufferInfo.offset = offset            audioBufferInfo.size = audioExtractor.readSampleData(audioBuf,offset)            if (audioBufferInfo.size < 0) {                sawEOS2 = true                audioBufferInfo.size = 0            } else {                audioBufferInfo.presentationTimeUs = audioExtractor.sampleTime                audioBufferInfo.flags = audioExtractor.sampleFlags                muxer.writeSampleData(audioTrack,audioBuf,audioBufferInfo)                audioExtractor.advance()            }        }        muxer.stop()        muxer.release()        vIDeoExtractor.release()        audioExtractor.release()        // 删除无声视频文件        savefile?.delete()    } catch (e: Exception) {        Log.e(TAG,"mixer Error:${e.message}")        // 视频添加音频合成失败,直接保存视频        savefile?.renameTo(newfile)    } finally {        afdd.close()        Handler().post {            refreshVIDeo(newfile)            savefile = null        }    }}
于是成就了录屏帮助类 ScreenRecordHelper

经过各种兼容性测试,目前在 DAU 超过 100 万的 APP 中稳定运行了两个版本,于是抽出了一个工具类库分享给大家,使用非常简单,代码注释比较全面,感兴趣的可以直接点击链接进行访问:https://github.com/nanchen2251/ScreenRecordHelper

使用就非常简单了,直接把 [README] (https://github.com/nanchen2251/ScreenRecordHelper/blob/master/README.md) 贴过来吧。

Step 1. Add it in your root build.gradle at the end of repositorIEs:
allprojects {    repositorIEs {        ...        maven { url 'https://jitpack.io' }    }}
Step 2. Add the dependency
dependencIEs {    implementation 'com.github.nanchen2251:ScreenRecordHelper:1.0.2'}
Step 3. Just use it in your project
// start screen recordif (screenRecordHelper == null) {    screenRecordHelper = ScreenRecordHelper(this,PathUtils.getExternalStoragePath() + "/nanchen")}screenRecordHelper?.apply {    if (!isRecording) {        // if you want to record the audio,you can set the recordAudio as true        screenRecordHelper?.startRecord()    }}// You must rewrite the onActivityResultoverrIDe fun onActivityResult(requestCode: Int,data: Intent?) {    super.onActivityResult(requestCode,resultCode,data)    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LolliPOP && data != null) {        screenRecordHelper?.onActivityResult(requestCode,data)    }}    // just stop screen recordscreenRecordHelper?.apply {    if (isRecording) {        stopRecord()         }}
Step 4. if you want to mix the audio into your vIDeo,you just should do
// parameter1 -> The last vIDeo length you want// parameter2 -> the audio's duration// parameter2 -> assets resourcestopRecord(duration,afdd)
Step 5. If you still don‘t understand,please refer to the demo

由于个人水平有限,虽然目前抗住了公司产品的考验,但肯定还有很多地方没有支持全面,希望有知道的大佬不啬赐教,有任何兼容性问题请直接提 issues,Thx。

总结

以上是内存溢出为你收集整理的百万级日活 App 的屏幕录制功能是如何实现的全部内容,希望文章能够帮你解决百万级日活 App 的屏幕录制功能是如何实现的所遇到的程序开发问题。

如果觉得内存溢出网站内容还不错,欢迎将内存溢出网站推荐给程序员好友。

欢迎分享,转载请注明来源:内存溢出

原文地址: https://outofmemory.cn/web/1129510.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-05-30
下一篇 2022-05-30

发表评论

登录后才能评论

评论列表(0条)

保存