百萬級日活 App 的螢幕錄製功能是如何實現的

nanchen2251發表於2019-06-25

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/5afaee…


我是南塵,只做比心的公眾號,歡迎關注我。

南塵,GitHub 7k Star,各大技術 Blog 論壇常客,出身 Android,但不僅僅是 Android。寫點技術,也吐點情感。做不完的開源,寫不完的矯情,你就聽聽我吹逼,不會錯~

百萬級日活 App 的螢幕錄製功能是如何實現的

相關文章