Android 实现屏幕录制并剥离环境音量实现

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 中稳定运行了两个版本,于是抽出了一个工具类库分享给大家,使用非常简单,代码注释比较全面,感兴趣的可以直接点击链接进行访问:https://github.com/nanchen2251/ScreenRecordHelper

使用就非常简单了,直接把 README 贴过来吧。

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。

参考文章:http://lastwarmth.win/2018/11/23/media-mix/

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,723评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,485评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,998评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,323评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,355评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,079评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,389评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,019评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,519评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,971评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,100评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,738评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,293评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,289评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,517评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,547评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,834评论 2 345