:MediaProjection與MediaCodec編碼傳輸全解)
簡介Android端投屏demo是一份面向Android開發(fā)者的示例工程其核心是演示基于MediaProjection API的手機(jī)屏幕鏡像到電視、電腦或投影儀等設(shè)備的完整流程。壓縮包大小33.55MB共2000個文件以class、png、xml、json、jar、java等為主涵蓋編譯產(chǎn)物、界面資源、配置清單、源代碼及輔助腳本便于直接導(dǎo)入項目分析運(yùn)行。已有7795人學(xué)習(xí)下載。項目代碼中詳細(xì)實現(xiàn)了運(yùn)行時權(quán)限申請、MediaProjectionManager獲取、ScreenCaptureService搭建、VirtualDisplay與ImageReader捕獲屏幕幀、MediaCodec編碼及音視頻傳輸?shù)汝P(guān)鍵環(huán)節(jié)并附帶了錄制與回放功能。開發(fā)者可據(jù)此快速搭建投屏應(yīng)用理解屏幕捕獲、編碼和跨設(shè)備傳輸?shù)耐暾溌吠瑫r參考其目錄組織與資源管理方式為二次開發(fā)或性能優(yōu)化提供直接樣板。 手機(jī)投屏這個需求說大不大說小真不小。會議共享、遠(yuǎn)程協(xié)助、游戲觀戰(zhàn)、多屏協(xié)作哪個場景離了“把這塊屏幕搬到另一塊屏幕上”都不行。我最早接觸“Android端投屏demo”時以為就是調(diào)個系統(tǒng)API的事真動手才發(fā)現(xiàn)屏幕采集、編碼、傳輸、解碼整條鏈路上每一步都有隱藏關(guān)卡。這篇文章把我自己從零擼出來的一套可運(yùn)行Android投屏demo完整拆開講核心鏈路怎么設(shè)計、關(guān)鍵代碼怎么落、黑屏和權(quán)限這些坑分別怎么踩過去都會聊到。適合正準(zhǔn)備做投屏功能、或者想拿一個最小可用demo做二次開發(fā)的Android開發(fā)者參考。我采用的技術(shù)主路線是系統(tǒng)自帶的MediaProjection采集屏幕用MediaCodec硬編碼成H.264走自建的Socket通道發(fā)給接收端接收端拿到碼流后硬解并渲染到Surface。這套方案鏈路短、可控性強(qiáng)延遲基本能壓在100毫秒左右不依賴第三方SDK也不碰廠商私有協(xié)議。接下來我按照從方案選型到代碼落地、再到排坑調(diào)優(yōu)的順序把每一步都掰開揉碎講清楚。1. 項目概述這個投屏demo到底解決了什么問題1.1 典型使用場景與用戶痛點投屏demo本身不是一個完整產(chǎn)品它更像是投屏功能的“最小可行性驗證”。我見過最典型的幾種需求分別是手機(jī)畫面實時共享到會議室大屏解決開會時一群人圍著一塊小屏幕看PPT的尷尬工程師遠(yuǎn)程幫客戶排查手機(jī)問題需要看到對方屏幕才能定位是哪個界面卡了還有游戲玩家想把操作畫面分享給隊友觀看對延遲特別敏感。這些場景共同的要求是實時性要好、畫面不能糊、接入成本要低。很多現(xiàn)成方案要么綁死了特定硬件比如必須用同一品牌電視要么走云端中轉(zhuǎn)導(dǎo)致延遲高到?jīng)]法用。自己做一套基于局域網(wǎng)傳輸?shù)耐镀羋emo就能在最基礎(chǔ)的層面把這些問題驗證清楚后續(xù)無論往哪個方向擴(kuò)展都有底子。1.2 主流投屏方案對比為什么我選自研市面上常見的投屏技術(shù)路線有好幾條我整理了一張對比表方便你快速判斷自己的場景適合哪條路方案傳輸基礎(chǔ)延遲表現(xiàn)優(yōu)點缺點MiracastWi-Fi Direct100~200ms系統(tǒng)級支持無需裝App廠商兼容性參差擴(kuò)展性差Google CastWi-Fi局域網(wǎng)200ms以上生態(tài)成熟支持后臺播放定制困難需要認(rèn)證DLNA/AirPlay局域網(wǎng)200~500ms多媒體場景體驗好屏幕鏡像支持弱實時性差自研協(xié)議本項目Socket/局域網(wǎng)80~150ms完全可控、可深度定制需要自己處理編碼和傳輸細(xì)節(jié)從表里能看出來自研方案的核心優(yōu)勢不是“技術(shù)碾壓”而是可控性。你可以自由決定分辨率、碼率、是否加密、是否支持雙向控制也可以針對特定硬件做優(yōu)化。我在做這個demo時的目標(biāo)很明確不需要覆蓋所有設(shè)備只需要在自己的Android手機(jī)和接收端之間建立一條足夠穩(wěn)、足夠快的通路。1.3 為什么這套鏈路值得自己寫一遍很多剛?cè)腴T的朋友問過我“直接用scrcpy改一改不行嗎”行但改完你得到的還是scrcpy的思路。自己寫一套MediaProjection加MediaCodec的鏈路你會被迫理解每一幀畫面是怎么從屏幕進(jìn)入編碼器、變成二進(jìn)制流、穿過網(wǎng)絡(luò)、再被還原成畫面的。這些知識點在面試、性能調(diào)優(yōu)、適配問題排查中都能直接復(fù)用。2. 核心鏈路拆解從采集到顯示的四段式架構(gòu)2.1 采集端MediaProjection和VirtualDisplay的原理與局限Android屏幕采集的官方入口是MediaProjection它本質(zhì)上是系統(tǒng)給你發(fā)的一張“截屏許可證”。調(diào)用createScreenCaptureIntent()會彈出一個用戶確認(rèn)對話框只有用戶點了允許系統(tǒng)才會把屏幕內(nèi)容授權(quán)給你。這個設(shè)計是為了防止App在后臺偷偷錄屏。拿到授權(quán)之后需要創(chuàng)建一個VirtualDisplay虛擬顯示器。你可以把它理解成系統(tǒng)里憑空多出來的一塊“隱形屏幕”系統(tǒng)會把真實屏幕的內(nèi)容鏡像到這個虛擬屏幕上。創(chuàng)建時需要傳一個Surface作為輸出目標(biāo)這個Surface可以直接是編碼器的輸入Surface也可以是一個普通Surface供你讀取像素數(shù)據(jù)。我在demo里選的是前者讓畫面直接從VirtualDisplay流向編碼器中間不經(jīng)過CPU拷貝效率最高。需要注意的一個坑是MediaProjection的授權(quán)是一次性的進(jìn)程被殺或者用戶主動撤銷后需要重新發(fā)起授權(quán)流程。部分廠商系統(tǒng)還會在后臺長時間采集時自動中斷投影所以采集必須配合前臺服務(wù)使用。2.2 編碼端MediaCodec硬編H.264的關(guān)鍵參數(shù)編碼是整個鏈路里最容易出問題、也最影響畫質(zhì)和延遲的環(huán)節(jié)。我選擇H.264而不是H.265核心原因是兼容性。H.264在Android設(shè)備上的硬編解碼支持率接近100%H.265雖然在同等碼率下畫質(zhì)更好但不少老設(shè)備的硬解能力跟不上軟解又會帶來發(fā)熱和卡頓。編碼器的關(guān)鍵參數(shù)配置如下val format MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height) format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface) format.setInteger(MediaFormat.KEY_BIT_RATE, 4_000_000) format.setInteger(MediaFormat.KEY_FRAME_RATE, 30) format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1) format.setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileHigh) format.setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel32) codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)這里耗費(fèi)我最多時間的是Profile和Level的選擇。Profile決定編碼復(fù)雜度Level決定分辨率、幀率的上限區(qū)間。很多教程只寫分辨率不寫Profile結(jié)果某些設(shè)備能編不能解。我實測下來Profile High配合Level 4.0在絕大多數(shù)設(shè)備上都是安全的1080p30fps的規(guī)格正好在這個區(qū)間內(nèi)。2.3 傳輸層TCP、UDP還是WebSocket傳輸層選型是個經(jīng)典的權(quán)衡問題。TCP可靠但重傳會導(dǎo)致延遲抖動UDP快但丟包會直接造成花屏。WebSocket則是在TCP之上的協(xié)議封裝天然適合跨語言、跨端的對接場景。我在demo里優(yōu)先選擇TCP理由很實在局域網(wǎng)場景下網(wǎng)絡(luò)質(zhì)量本身比較好TCP的擁塞控制機(jī)制雖然會引入一定延遲但能夠保證畫面完整性。對于投屏這種交互場景來說“畫面完整但偶爾延遲波動”比“低延遲但畫面破損”的體驗要好得多。傳輸協(xié)議需要自己定義一個輕量幀格式。我的方案很簡單每幀數(shù)據(jù)前加8個字節(jié)的頭部前4字節(jié)存數(shù)據(jù)長度后4字節(jié)存幀類型關(guān)鍵幀或非關(guān)鍵幀。接收端先讀頭部再按長度讀取完整數(shù)據(jù)塊這樣能有效避免TCP粘包和半包問題。2.4 解碼端MediaCodec硬解與Surface渲染接收端解碼相對簡單配置一個解碼用MediaCodec把網(wǎng)絡(luò)收到的數(shù)據(jù)直接喂給它輸出Surface綁到SurfaceView或者TextureView上即可。這里有一個容易踩的坑SurfaceView的畫面更新是異步的如果View沒有正確進(jìn)入可見狀態(tài)可能會導(dǎo)致畫面卡在最后一幀。TextureView則更容易和動畫、縮放等UI操作配合代價是性能略低于SurfaceView。在demo中我默認(rèn)使用SurfaceView因為它的性能更好且投屏場景一般不需要復(fù)雜的UI疊加效果。如果你的接收端還需要繪制觸摸指令、顯示控制面板之類的疊加層再換成TextureView會更合適。3. 實操落地從零搭建一個可運(yùn)行的投屏demo3.1 環(huán)境準(zhǔn)備與工程配置開發(fā)環(huán)境方面我使用的是Android Studio最新穩(wěn)定版SDK版本支持到Android 14編譯目標(biāo)設(shè)成34。測試設(shè)備我準(zhǔn)備了兩臺一臺是小米系的手機(jī)負(fù)責(zé)實測高版本Android和廠商定制的適配問題另一臺是老舊的Android 9設(shè)備用來驗證低版本兼容性。工程結(jié)構(gòu)上我建了兩個模塊app作為采集端也就是被投屏的手機(jī)端receiver作為接收端可以是Android電視、盒子也可以是另一臺手機(jī)。如果你只想在電腦上看畫面接收端也可以替換為PC上的VLC或者ffplay但為了完整性我建議先做Android接收端方便統(tǒng)一用Android Studio調(diào)試。項目依賴很少不需要引入額外的第三方庫。整個demo的核心代碼只有三個類ScreenCaptureService負(fù)責(zé)采集和編碼StreamSender負(fù)責(zé)網(wǎng)絡(luò)發(fā)送StreamReceiver負(fù)責(zé)網(wǎng)絡(luò)接收和解碼。這種極簡結(jié)構(gòu)方便你把注意力全部放在核心鏈路上。3.2 權(quán)限申請和前臺服務(wù)聲明投屏功能必須申請以下權(quán)限FOREGROUND_SERVICE前臺服務(wù)運(yùn)行必需FOREGROUND_SERVICE_MEDIA_PROJECTIONAndroid 14新增專門用于媒體投影前臺服務(wù)POST_NOTIFICATIONSAndroid 13以上需要動態(tài)申請通知權(quán)限否則前臺服務(wù)通知不顯示Manifest里還要聲明前臺服務(wù)的類型service android:name.ScreenCaptureService android:foregroundServiceTypemediaProjection android:exportedfalse /權(quán)限申請順序是先動態(tài)申請通知權(quán)限再發(fā)起MediaProjection授權(quán)。很多新手只處理了MediaProjection的彈窗忘了通知權(quán)限結(jié)果服務(wù)在后臺跑了一會兒就被系統(tǒng)殺掉。另外從Android 14開始如果前臺服務(wù)類型沒有正確聲明為mediaProjection運(yùn)行時會直接拋出ForegroundServiceStartNotAllowedException這一條需要特別注意。3.3 采集端編碼與發(fā)送模塊實現(xiàn)采集端代碼的核心流程如下通過MediaProjectionManager發(fā)起屏幕采集授權(quán)在onActivityResult里拿到MediaProjection實例創(chuàng)建編碼器并獲取輸入Surface用輸入Surface創(chuàng)建VirtualDisplay循環(huán)從編碼器輸出緩沖區(qū)取出編碼后的數(shù)據(jù)通過Socket發(fā)送給接收端關(guān)鍵代碼片段如下// 申請屏幕采集權(quán)限 val mediaProjectionManager getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager startActivityForResult(mediaProjectionManager.createScreenCaptureIntent(), REQUEST_CAPTURE) // 在 onActivityResult 中獲取 MediaProjection 并啟動服務(wù) override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (requestCode REQUEST_CAPTURE resultCode RESULT_OK) { val serviceIntent Intent(this, ScreenCaptureService::class.java) serviceIntent.putExtra(resultCode, resultCode) serviceIntent.putExtra(resultData, data) startForegroundService(serviceIntent) } }在ScreenCaptureService里VirtualDisplay和編碼器的綁定是核心val inputSurface codec.createInputSurface() virtualDisplay mediaProjection.createVirtualDisplay( ScreenCastDisplay, width, height, dpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, inputSurface, null, null )當(dāng)VirtualDisplay創(chuàng)建成功后屏幕內(nèi)容就會持續(xù)不斷地流向編碼器。此時只需在單獨(dú)的線程里循環(huán)讀取編碼器輸出while (isRunning) { val index codec.dequeueOutputBuffer(bufferInfo, 10_000) if (index 0) { val buffer codec.getOutputBuffer(index) // 寫入自定義幀頭 編碼數(shù)據(jù)到Socket sendFrame(buffer, bufferInfo) codec.releaseOutputBuffer(index, false) } }這幾段代碼連起來就是一條完整的采集編碼鏈路。注意dequeueOutputBuffer的超時時間我設(shè)置為10毫秒這個值在延遲和CPU占用之間比較平衡。設(shè)置太短會導(dǎo)致循環(huán)空轉(zhuǎn)浪費(fèi)電量設(shè)置太長則可能在幀率波動時引入額外延遲。3.4 接收端解碼與顯示模塊實現(xiàn)接收端相對簡單啟動一個ServerSocket監(jiān)聽端口等待發(fā)送端連接。連接建立后從輸入流中按幀頭解析數(shù)據(jù)塊然后喂給解碼器。解碼器配置val format MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height) codec.configure(format, surface, null, 0) // surface來自SurfaceView codec.start()解碼循環(huán)while (isRunning) { val index codec.dequeueInputBuffer(10_000) if (index 0) { val inputBuffer codec.getInputBuffer(index) val bytesRead socketInputStream.read(inputBuffer!!) if (bytesRead 0) { codec.queueInputBuffer(index, 0, bytesRead, pts, 0) } } val outputIndex codec.dequeueOutputBuffer(bufferInfo, 10_000) if (outputIndex 0) { codec.releaseOutputBuffer(outputIndex, true) } }看到releaseOutputBuffer的第二個參數(shù)我傳的是true意思是讓解碼器把畫面直接渲染到Surface上。這一步有個隱藏細(xì)節(jié)如果你傳false解碼器不會自動渲染畫面就一直在緩沖區(qū)里積累表現(xiàn)出來就是“解碼在跑但屏幕不亮”。很多黑屏問題其實就出在這一個參數(shù)上。3.5 Android高版本適配要點Android 10開始強(qiáng)制分區(qū)存儲后App不再能隨意訪問外部存儲的任意目錄。如果你的投屏demo需要讀取相冊里的視頻文件再投出去切記不能直接拼/sdcard/DCIM/xxx.jpg路徑去讀而是要用MediaStore或SAF。同理如果接收端需要保存截圖或錄屏文件也要寫入應(yīng)用專屬目錄或者通過MediaStore寫入公共目錄。Android 13和14的適配重點放在通知權(quán)限和前臺服務(wù)類型上。我實測在小米HyperOS和原生Android 14上只要前臺服務(wù)類型漏了mediaProjection服務(wù)必然啟動失敗。Android 14還要求在前臺服務(wù)啟動后的短時間內(nèi)必須調(diào)用startForeground否則會拋異常。最穩(wěn)妥的做法是在onCreate里就立即調(diào)用startForeground而不是等到網(wǎng)絡(luò)連接或編碼器準(zhǔn)備好之后再調(diào)用。系統(tǒng)對前臺服務(wù)啟動窗口卡得很嚴(yán)這個時機(jī)不能賭。4. 實戰(zhàn)排坑那些讓人抓狂的問題與解法4.1 投屏黑屏排查鏈路與方法投屏黑屏是出現(xiàn)頻率最高的問題scrcpy和qtscrcpy的用戶反饋里也大量提到。我總結(jié)下來黑屏基本逃不出這幾個原因現(xiàn)象可能原因排查方法發(fā)送端黑屏自己看不到畫面VirtualDisplay與編碼器輸入Surface綁定失敗檢查Surface是否有效打印編碼器狀態(tài)接收端黑屏但CPU在跑解碼器沒有渲染到Surface檢查releaseOutputBuffer第二個參數(shù)是否為true畫面卡在第一幀解碼器缺少關(guān)鍵幀確認(rèn)編碼器配置了KEY_I_FRAME_INTERVAL花屏后黑屏網(wǎng)絡(luò)丟包導(dǎo)致碼流損壞降低碼率或改用TCP傳輸我最常遇到的情況是編碼器輸出正常、網(wǎng)絡(luò)也正常但接收端一直黑屏。后來定位到是解碼器初始化時沒有拿到正確的寬高信息。解碼器需要知道分辨率才能配置輸出緩沖區(qū)而我最初寫死了1920x1080但發(fā)送端實際采集的是手機(jī)豎屏分辨率兩者不匹配導(dǎo)致解碼器罷工。解決方法是發(fā)送端把寬高信息寫在自定義幀頭的擴(kuò)展字段里接收端動態(tài)配置。4.2 高版本Android的權(quán)限與URI坑熱詞里出現(xiàn)的content://com.ss.android.uri.key/external_root/android/data/...這類路徑本質(zhì)上是分區(qū)存儲背景下App之間共享文件的URI授權(quán)問題。你在投屏demo里如果還要處理“投屏前選一個文件先展示”這類交互就會直接碰到這個問題。一個典型報錯是FileNotFoundException原因是拿到了URI卻沒有持久化讀取權(quán)限。解決辦法是在onActivityResult里調(diào)用contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)這樣才能在后續(xù)的Service里持續(xù)讀取這個URI指向的文件。另外file://開頭的路徑在Android 7.0以上默認(rèn)會被FileUriExposedException攔截所有跨App文件共享都應(yīng)該使用FileProvider生成content://URI。4.3 老機(jī)型與低版本系統(tǒng)兼容熱詞里有“安卓4.4.2支持的遠(yuǎn)程投屏”這部分我專門驗證過。Android 5.0以下沒有MediaProjection API無法通過常規(guī)方式采集屏幕只能在root設(shè)備上讀取/dev/graphics/fb0幀緩沖或者依賴ADB的screenrecord命令抓取。4.4.2還有一種折中做法是用MediaRecorder直接錄屏到文件再用流媒體服務(wù)器轉(zhuǎn)發(fā)但延遲會高到無法接受。如果你的產(chǎn)品必須支持Android 5.0以下設(shè)備我的建議是放棄自研采集改接Miracast硬件方案。在軟件層面硬撐老設(shè)備投入產(chǎn)出比太低。這個demo可以從Android 7.0起步老設(shè)備數(shù)量已經(jīng)很少強(qiáng)行兼容反而會拖累整體體驗。4.4 延遲、幀率和畫質(zhì)的調(diào)優(yōu)思路調(diào)優(yōu)的核心思路是“先保證可用再追求極致”。當(dāng)鏈路能跑通出現(xiàn)畫面后延遲偏高是正常的需要逐段排查。我實際測試中發(fā)現(xiàn)傳輸層占用的延遲通常在20~40毫秒編碼器本身會引入30~50毫秒解碼端還有一幀的緩沖延遲。如果整體延遲超過200毫秒優(yōu)先檢查網(wǎng)絡(luò)發(fā)送端是否因為TCP擁塞而阻塞了編碼循環(huán)。一個有效的優(yōu)化手段是設(shè)置KEY_LOW_LATENCY為1部分設(shè)備的編碼器會啟用低延遲模式。但實測發(fā)現(xiàn)這個參數(shù)在部分高通芯片上是有效的在聯(lián)發(fā)科上卻沒什么變化所以不能作為萬能藥。另一個更通用的方式是降低編碼分辨率而不是降低碼率把1080p降到720p畫面銳度損失不大但編碼時間和傳輸帶寬都會顯著降低。音頻投屏我沒把它放進(jìn)這個demo里原因是音頻采集需要額外的AudioRecord權(quán)限和AAC編碼鏈路復(fù)雜度會翻倍。如果你的業(yè)務(wù)場景需要聲音建議把音頻作為一個獨(dú)立模塊后續(xù)加上先保證視頻鏈路穩(wěn)定再擴(kuò)展音頻排錯會輕松很多。最后再分享一個我踩了三次的坑不要試圖用同一個Socket同時傳視頻和觸摸控制指令。視頻數(shù)據(jù)量大會擠占控制指令的發(fā)送時機(jī)導(dǎo)致遠(yuǎn)端操作卡成幻燈片。最佳實踐是單獨(dú)開一條控制通道哪怕多占用一個端口也值得。這個小改動讓整個demo的交互體驗提升了一個檔次你按這個思路去擴(kuò)展遙控功能時會感謝這個決定。本文還有配套的精品資源點擊獲取