美文网首页Android技术知识
Android进阶笔记:百万级日活 App 的屏幕录制功能是如何

Android进阶笔记:百万级日活 App 的屏幕录制功能是如何

作者: 唐唐_1388 | 来源:发表于2021-01-25 21:16 被阅读0次

    作者:nanchen2251

    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? = null
    if (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)
                // 实测,部分手机上录制视频的时候会有弹窗的出现,所以我们需要做一个 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 中稳定运行了两个版本,于是抽出了一个工具类库分享给大家,使用非常简单,代码注释比较全面,感兴趣的可以直接点击链接进行访问:github.com/nanchen2251…

    使用就非常简单了,直接把 [README] (github.com/nanchen2251…) 贴过来吧。

    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 record
    if (screenRecordHelper == null) {
        screenRecordHelper = ScreenRecordHelper(this, null, 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 onActivityResult
    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && data != null) {
            screenRecordHelper?.onActivityResult(requestCode, resultCode, data)
        }
    }
    
    // just stop screen record
    screenRecordHelper?.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 resource
    stopRecord(duration, audioDuration, afdd)
    
    Step 5. If you still don't understand, please refer to the demo

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

    参考文章:lastwarmth.win/2018/11/23/… juejin.im/post/684490…

    文末

    感谢大家关注我,分享Android干货,交流Android技术。
    对文章有何见解,或者有何技术问题,都可以在评论区一起留言讨论,我会虔诚为你解答。
    Android架构师系统进阶学习路线、58万字学习笔记、教学视频免费分享地址:我的GitHub

    相关文章

      网友评论

        本文标题:Android进阶笔记:百万级日活 App 的屏幕录制功能是如何

        本文链接:https://www.haomeiwen.com/subject/dlgizktx.html