百萬級日活 App 的屏幕錄製功能是如何實現的
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
}
}
我們必須來看看 MediaRecorder
對 stop()
方法的註釋。
/**
* 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 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/
https://juejin.im/post/5afaee7df265da0ba2672608
【精選推薦文章】
如何讓商品強力曝光呢? 網頁設計公司幫您建置最吸引人的網站,提高曝光率!!
想要讓你的商品在網路上成為最夯、最多人討論的話題?
網頁設計公司推薦更多不同的設計風格,搶佔消費者視覺第一線
不管是台北網頁設計公司、台中網頁設計公司,全省皆有專員為您服務
想知道最厲害的台北網頁設計公司推薦、台中網頁設計公司推薦專業設計師"嚨底家"!!