本文由微信開發團隊工程師「virwu」分享。html
近期,微信小遊戲支持了視頻號一鍵開播,將微信升級到最新版本,打開騰訊系小遊戲(如跳一跳、歡樂鬥地主等),在右上角菜單就能夠看到發起直播的按鈕一鍵成爲遊戲主播了(以下圖所示)。android
然而微信小遊戲出於性能和安全等一系列考慮,運行在一個獨立的進程中,在該環境中不會初始化視頻號直播相關的模塊。這就意味着小遊戲的音視頻數據必須跨進程傳輸到主進程進行推流,給咱們實現小遊戲直播帶來了一系列挑戰。小程序
本文是系列文章中的第5篇:api
《直播系統聊天技術(一):百萬在線的美拍直播彈幕系統的實時推送技術實踐之路》安全
《直播系統聊天技術(二):阿里電商IM消息平臺,在羣聊、直播場景下的技術實踐》微信
《直播系統聊天技術(三):微信直播聊天室單房間1500萬在線的消息架構演進之路》markdown
《直播系統聊天技術(四):百度直播的海量用戶實時消息系統架構演進實踐》微信開發
《直播系統聊天技術(五):微信小遊戲直播在Android端的跨進程渲染推流實踐》(* 本文)架構
小遊戲直播本質上就是把主播手機屏幕上的內容展現給觀衆,天然而然地咱們能夠想到採用系統的錄屏接口MediaProjection進行視頻數據的採集。app
這種方案有這些優勢:
可是最終這個方案被否決了,主要出於如下考慮:
轉念一想,既然小遊戲的渲染徹底是由咱們控制的,爲了更好的直播體驗,可否將小遊戲渲染的內容跨進程傳輸到主進程來進行推流呢?
爲了更好地描述咱們採用的方案,這裏先簡單介紹一下小遊戲的渲染架構:
能夠看到圖中左半邊表示在前臺的小遊戲進程,其中MagicBrush爲小遊戲渲染引擎,它接收來自於小遊戲代碼的渲染指令調用,將畫面渲染到在屏的SurfaceView提供的Surface上。整個過程主進程在後臺不參與。
小遊戲以前支持過遊戲內容的錄製,和直播原理上相似,都須要獲取當前小遊戲的畫面內容。
錄屏啓用時小遊戲會切換到以下的模式進行渲染:
能夠看到,MagicBrush的輸出目標再也不是在屏的SurfaceView,而是Renderer產生的一個SurfaceTexture。
這裏先介紹一下Renderer的做用:
Renderer是一個獨立的渲染模塊,表示一個獨立的GL環境,它能夠建立SurfaceTexture做爲輸入,收到SurfaceTexture的onFrameAvailable回調後經過updateTexImage方法將圖像數據轉換爲類型是GL_TEXTURE_EXTERNAL_OES的紋理參與後續的渲染過程,並能夠將渲染結果輸出到另外一個Surface上。
下面逐步對圖中過程進行解釋:
1)
MagicBrush接收來自小遊戲代碼的渲染指令調用,將小遊戲內容渲染到第一個Renderer所建立的SurfaceTexture上;
2)
隨後這個Renderer作了兩件事情:
3)
第二個Renderer將第一個Renderer提供的紋理渲染到mp4編碼器提供的輸入SurfaceTexture上,最終編碼器編碼產生mp4錄屏文件。
能夠看到,錄屏方案中經過一個Renderer負責將遊戲內容上屏,另外一個Renderer將一樣的紋理渲染到編碼器上的方式實現了錄製遊戲內容,直播其實相似,是否是隻要將編碼器替換爲直播的推流模塊就能夠了呢?
**確實如此,但還缺乏關鍵的一環:**推流模塊運行在主進程,咱們須要實現跨進程傳輸圖像數據!如何跨進程呢?
**說到跨進程:**可能咱們腦海裏蹦出的第一反應就是Binder、Socket、共享內存等等傳統的IPC通訊方法。但仔細一想,系統提供的SurfaceView是很是特殊的一個View組件,它不通過傳統的View樹來參與繪製,而是直接經由系統的SurfaceFlinger來合成到屏幕上,而SurfaceFlinger運行在系統進程上,咱們繪製在SurfaceView所提供的Surface上的內容必然是能夠跨進程進行傳輸的,而Surface跨進程的方法很簡單——它自己就實現了Parcelable接口,這意味着咱們能夠用Binder直接跨進程傳輸Surface對象。
因而咱們有了下面這個初步方案:
**能夠看到:**第3步再也不是渲染到mp4編碼器上,而是渲染到主進程跨進程傳輸過來的Surface上,主進程的這個Surface是經過一個Renderer建立的SurfaceTexture包裝而來的,如今小遊戲進程做爲生產者向這個Surface渲染畫面。當一幀渲染完畢後,主進程的SurfaceTexture就會收到onFrameAvailable回調通知圖像數據已經準備完畢,隨之經過updateTexImage獲取到對應的紋理數據,這裏因爲直播推流模塊只支持GL_TEXTURE_2D類型的紋理,這裏主進程Renderer會將GL_TEXTURE_EXTERNAL_OES轉換爲GL_TEXTURE_2D紋理後給到直播推流編碼器,完成推流過程。
**通過一番改造:**上述方案成功地實現了將小遊戲渲染在屏幕上的同時傳遞給主進程進行推流,但這真的是最優的方案嗎?
思考一番,發現上述方案中的Renderer過多了,小遊戲進程中存在兩個,一個用於渲染上屏,一個用於渲染到跨進程而來的Surface上,主進程中還存在一個用於轉換紋理以及調用推流模塊。若是要同時支持錄屏,還須要在小遊戲進程再起一個Renderer用於渲染到mp4編碼器,過多的Renderer意味着過多的額外渲染開銷,會影響小遊戲運行性能。
縱觀整個流程,其實只有主進程的Renderer是必要的,小遊戲所使用的額外Render無非就是想同時知足渲染上屏和跨進程傳輸,讓咱們大開腦洞——既然Surface自己就不受進程的約束,那咱們乾脆把小遊戲進程的在屏Surface傳遞到主進程進行渲染上屏吧!
最終咱們大刀闊斧地砍掉了小遊戲進程的兩個冗餘Renderer,MagicBrush直接渲染到了跨進程傳遞而來的Surface上,而主進程的Renderer在負責紋理類型轉換的同時也負責將紋理渲染到跨進程傳遞而來的小遊戲進程的在屏Surface上,實現畫面的渲染上屏。
最終所須要的Renderer數量從原來的3個減小到了必要的1個,在架構更加清晰的同時提高了性能。
後續須要同時支持錄屏時,只要稍做改動,將mp4編碼器的輸入SurfaceTexture也跨進程傳遞到主進程,再新增一個Renderer渲染紋理到它上面就好了(以下圖所示)。
到這裏,不由有點擔憂,跨進程傳輸和渲染Surface的這套方案的兼容性會不會有問題呢?
實際上,雖然並不常見,可是官方文檔裏面是有說明能夠跨進程進行繪製的:
SurfaceView combines a surface and a view. SurfaceView's view components are composited by SurfaceFlinger (and not the app), enabling rendering from a separate thread/process and isolation from app UI rendering.
而且Chrome以及Android O之後的系統WebView都有使用跨進程渲染的方案。
在咱們的兼容性測試中,覆蓋了Android 5.1及之後的各個主流系統版本和機型,除了Android 5.x機型上出現了跨進程渲染黑屏的問題外,其他都可以正常渲染上屏和推流。
**性能方面:**咱們使用了WebGL水族館的Demo進行了性能測試,能夠看到對於平均幀率的影響在15%左右,主進程的CPU由於渲染和推流有所升高,奇怪的是小遊戲進程的CPU開銷卻出現了一些降低,這裏降低的緣由暫時尚未確認,懷疑與上屏操做移到主進程相關,也不排除是統計方法的影響。
爲了實現不錄製主播端的評論掛件,咱們從小遊戲渲染流程入手,藉助於Surface跨進程渲染和傳輸圖像的能力,把小遊戲渲染上屏的過程移到了主進程,並同時生成紋理進行推流,在兼容性和性能上達到了要求。
在音頻採集方案中,咱們注意到在Android 10及以上系統提供了AudioPlaybackCapture方案容許咱們在必定的限制內對系統音頻進行採集。當時預研的一些結論以下。
捕獲方 - 進行捕獲的條件:
被捕獲方 - 能夠被捕獲的條件:
**總的來講:**Android 10及以上可使用AudioPlaybackCapture方案進行音頻捕獲,但考慮到Android 10這個系統版本限制太高,最終咱們選擇了本身來採集並混合小遊戲內播放的全部音頻。
如今,老問題又擺在了咱們眼前:小遊戲混合後的音頻數據在小遊戲進程,而咱們須要把數據傳輸到主進程進行推流。
與通常的IPC跨進程通訊用於方法調用不一樣:在這個場景下,咱們須要頻繁地(40毫秒一次)傳輸較大的數據塊(16毫秒內的數據量在8k左右)。
**同時:**因爲直播的特性,這個跨進程傳輸過程的延遲須要儘量地低,不然就會出現音畫不一樣步的狀況。
**爲了達到上述目標:**咱們對Binder、LocalSocket、MMKV、SharedMemory、Pipe這幾種IPC方案進行了測試。在搭建的測試環境中,咱們在小遊戲進程模擬真實的音頻傳輸的過程,每隔16毫秒發送一次序列化後的數據對象,數據對象大小分爲3k/4M/10M三擋,在發送前儲存時間戳在對象中;在主進程中接收到數據並完成反序列化爲數據對象的時刻做爲結束時間,計算傳輸延遲。
最終獲得了以下結果:
**注:**其中XIPCInvoker(Binder)和MMKV在傳輸較大數據量時耗時過長,不在結果中展現。
對於各個方案的分析以下(卡頓率表示延遲>2倍平均延遲且>10毫秒的數據佔總數的比例):
**能夠看到:**LocalSocket方案在各個狀況下的傳輸延遲表現都極爲優異。差別的緣由主要是由於裸二進制數據在跨進程傳輸到主進程後,仍須要進行一次數據拷貝操做來反序列化爲數據對象,而使用LocalSocket時能夠藉助於ObjectStream和Serializeable來實現流式的拷貝,相比與其餘方案的一次性接收數據後再拷貝節約了大量的時間(固然其餘方案也能夠設計成分塊流式傳輸同時拷貝,但實現起來有必定成本,不如ObjectStream穩定易用)。
咱們也對LocalSocket進行了兼容性與性能測試,未出現不能傳輸或斷開鏈接的狀況,僅在三星S6上平均延遲超過了10毫秒,其他機型延遲均在1毫秒左右,能夠知足咱們的預期。
經常使用的Binder的跨進程安全性有系統實現的鑑權機制來保證,LocalSocket做爲Unix domain socket的封裝,咱們必須考慮它的安全性問題。
論文《The Misuse of Android Unix Domain Sockets and Security Implications》較爲詳細地分析了Android中使用LocalSocket所帶來的安全風險。
**PS:**論文原文附件下載(請今後連接的4.3節處下載:www.52im.net/thread-3594…)
**總結論文所述:**因爲LocalSocket自己缺少鑑權機制,任意一個應用均可以進行鏈接,從而截取到數據或是向接收端傳遞非法數據引起異常。
針對這個特色,咱們能夠作的防護方法有兩種:
爲了兼容Android 10如下的機型也能直播,咱們選擇本身處理小遊戲音頻的採集,並經過對比評測,選用了LocalSocket做爲跨進程音頻數據傳輸的方案,在延遲上知足了直播的需求。
同時,經過一些對抗措施,能夠有效規避LocalSocket的安全風險。
回頭來看,雖然整個方案看起來比較通順,可是在實現的過程當中仍是因爲多進程的緣由踩過很多坑,下面就分享其中兩個比較主要的。
在剛實現跨進程渲染推流的方案後,咱們進行了一輪性能與兼容性測試,在測試中發現,部分中低端機型上幀率降低很是嚴重(以下圖所示)。
復現後查看小遊戲進程渲染的幀率(即小遊戲進程繪製到跨進程而來的Surface上的幀率)發現能夠達到不開直播時的幀率。
而咱們所用的測試軟件PerfDog所記錄的是在屏Surface的繪製幀率,這就說明性能降低不是直播開銷太高引發的小遊戲代碼執行效率降低,而是主進程上屏Renderer效率過低。
因而咱們對主進程直播時運行效率進行了Profile,發現耗時函數爲glFinish。
而且有兩次調用:
若是將第一次調用去掉,直播SDK內部的此次則會耗時100多毫秒。
爲了弄清爲何這個GL指令耗時這麼久,咱們先看看它的描述:
glFinish does not return until the effects of all previously called GL commands are complete.
**描述很簡單:**它會阻塞直到以前調用的全部GL指令所有完成。
**這麼看來是以前的GL指令太多了?**可是GL指令隊列是以線程爲維度隔離的,在主進程的Renderer線程中,glFinish前只會執行紋理類型轉換的很是少許的GL指令,和騰訊雲的同窗瞭解到推流接口也不會在本線程執行不少GL指令,如此少許的GL指令怎麼會使glFinish阻塞這麼久呢?等等,大量GL指令?小遊戲進程此時不就正在執行大量GL指令嗎,難道是小遊戲進程的大量GL指令致使了主進程的glFinsih耗時過長?
**這樣的猜想不無道理:**雖然GL指令隊列是按線程隔離的,但處理指令的GPU只有一個,一個進程的GL指令過多致使另外一個進程在須要glFinish時阻塞太久。Google了一圈沒找到有相關的描述,須要本身驗證這個猜想。
**從新觀察一遍上面的測試數據:**發現直播前能達到60幀的狀況下,直播後也能達到60幀左右,這是否是就說明在小遊戲的GPU負載較低時glFinish的耗時也會降低呢?
**在性能降低嚴重的機型上:**控制其餘變量不變嘗試運行低負載的小遊戲,發現glFinsih的耗時成功降低到了10毫秒左右,這就印證了上面的猜想——確實是小遊戲進程正在執行的大量GL指令阻塞了主進程glFinish的執行。
該如何解決呢?小遊戲進程的高負載沒法改變,那能讓小遊戲在一幀渲染完成之後停住等主進程的glFinish完成後再渲染下一幀嗎?
**這裏通過了各類嘗試:**OpenGL的glFence同步機制沒法跨進程使用;因爲GL指令是異步執行的,經過跨進程通訊加鎖鎖住小遊戲的GL線程並不能保證主進程執行glFinish時小遊戲進程的指令已經執行完,而能保證這點只有經過給小遊戲進程加上glFinish,但這會使得雙緩衝機制失效,致使小遊戲渲染幀率的大幅降低。
既然glFinish所帶來的阻塞沒法避免,那咱們回到問題的開始:爲何須要glFinish?因爲雙緩衝機制的存在,通常來講並不須要glFinish來等待以前的繪製完成,不然雙緩衝就失去了意義。兩次glFinish中,第一次紋理處理的調用能夠直接去掉,第二次騰訊雲SDK的調用通過溝通,發現是爲了解決一個歷史問題引入的,能夠嘗試去掉。在騰訊雲同窗的幫助下,去掉glFinish後,渲染的幀率終於和小遊戲輸出的幀率一致,通過兼容性和性能測試,沒有發現去掉glFinish帶來的問題。
這個問題最終的解法很簡單:但分析問題緣由的過程實際上作了很是多的實驗,同一個應用中一個高GPU負載的進程會影響到另外一個進程的glFinish耗時的這種場景確實也很是少見,能參考的資料很少。這個過程也讓我深入體會到了glFinish使得雙緩衝機制失效所帶來的性能影響是巨大的,在使用OpenGL進行渲染繪製時對於glFinish的使用應當很是謹慎。
在測試過程當中:咱們發現不管以多少的幀率向直播SDK發送畫面,觀衆端看到的畫面幀率始終只有16幀左右,排除後臺緣由後,發現是編碼器編碼的幀率不足致使的。經騰訊雲同窗測試同進程內編碼的幀率是能夠達到設定的30幀的,那麼說明仍是多進程帶來的問題,這裏編碼是一個很是重的操做,須要消耗比較多的CPU資源,因此咱們首先懷疑的就是後臺進程優先級的問題。
爲了確認問題:
**綜上:**能夠確認幀率降低就是因爲後臺進程(以及其擁有的線程)的優先級太低致使的。
提升線程優先級的作法在微信裏比較常見,例如:小程序的JS線程以及小遊戲的渲染線程都會在運行時經過
android.os.Process.setThreadPriority
方法設置線程的優先級。騰訊雲SDK的同窗很快提供了接口供咱們設置線程優先級,但當咱們真正運行起來時,卻發現編碼的幀率僅從16幀提升到了18幀左右,是哪裏出問題了呢?
**前面提到:**咱們經過chrt命令設置線程優先級是有效的,但android.os.Process.setThreadPriority這個方法設置的線程優先級對應的是renice這個命令設置的nice值。仔細閱讀chrt的manual後,發現以前測試時的理解有誤,以前直接用chrt -p [pid] [priority]的命令設置優先級,卻沒有設置調度策略這個參數,致使該線程的調度策略從Linux默認的SCHED_OTHER改成了命令缺省設置的SCHED_RR,而SCHED_RR是一種「實時策略」,致使線程的調度優先級變得很是高。
**實際上:**經過renice(也就是android.os.Process.setThreadPriority)設置的線程優先級,對於後臺進程所擁有線程來講沒有太大的幫助。
其實早有人解釋過這一點:
To address this, Android also uses Linux cgroups in a simple way to create more strict foreground vs. background scheduling. The foreground/default cgroup allows thread scheduling as normal. The background cgroup however applies a limit of only some small percent of the total CPU time being available to all threads in that cgroup. Thus if that percentage is 5% and you have 10 background threads all wanting to run and one foreground thread, the 10 background threads together can only take at most 5% of the available CPU cycles from the foreground. (Of course if no foreground thread wants to run, the background threads can use all of the available CPU cycles.)
關於線程優先級的設置,感興趣的同窗能夠看看另外一位大佬的文章:《Android的離奇陷阱 — 設置線程優先級致使的微信卡頓慘案》。
**最終:**爲了提升編碼幀率並防止後臺主進程被殺,咱們最終仍是決定直播時在主進程建立一個前臺Service。
多進程是一把雙刃劍,在給咱們帶來隔離性和性能優點的同時也帶來了跨進程通訊這一難題,所幸藉助系統Surface的能力和多種多樣的跨進程方案能夠較好地解決小遊戲直播中所遇到的問題。
**固然:**解決跨進程問題最好的方案是避免跨進程,咱們也考慮了將視頻號直播的推流模塊運行在小遊戲進程的方案,但出於改形成本的考慮而沒有選擇這一方案。
**同時:**此次對於SurfaceView跨進程渲染的實踐也對其餘業務有必定參考價值——對於一些內存壓力較大或是安全風險較高,又須要進行SurfaceView渲染繪製的場景,能夠把邏輯放到獨立的進程,再經過跨進程渲染的方式繪製到主進程的View上,在得到獨立進程優點的同時又避免了進程間跳轉所帶來的體驗的割裂。(本文同步發佈於:www.52im.net/thread-3594…)