化全攻略:幀率、Buffer、功耗與實戰(zhàn)排障)
上個月幫一個團(tuán)隊排查攝像頭啟動慢的問題用戶反饋從點擊圖標(biāo)到預(yù)覽畫面出現(xiàn)要等將近兩秒而且打開相機(jī)后手機(jī)溫度明顯上升。拆開看trace才發(fā)現(xiàn)光Buffer分配和格式轉(zhuǎn)換就耗掉了700多毫秒真正留給ISP出圖的時間反而不多。這種問題在Android Camera場景里太典型了——硬件參數(shù)看起來很強(qiáng)實際體驗卻被軟件鏈路的低效拖垮。這篇文章我會結(jié)合Camera的多層架構(gòu)把性能優(yōu)化的關(guān)鍵路徑、Buffer管理、功耗控制、以及排查手段完整講一遍所有方案都是我在真機(jī)上調(diào)過的可以直接當(dāng)成一份排查手冊用。1. 先摸清Camera全鏈路架構(gòu)性能瓶頸幾乎沒在相機(jī)本身做性能優(yōu)化最大的忌諱是拿到問題就猜猜錯了方向后面全白干。Camera的性能問題更是如此因為一幀畫面從光線進(jìn)入傳感器到最終呈現(xiàn)在屏幕上中間要穿越應(yīng)用進(jìn)程、Framework服務(wù)、HAL層、內(nèi)核驅(qū)動四條防線哪一層堵住都會表現(xiàn)為卡頓、掉幀、延遲大。1.1 一條預(yù)覽幀從傳感器到屏幕要經(jīng)過哪些環(huán)節(jié)先看一條最基礎(chǔ)的預(yù)覽幀鏈路。Sensor采集到RAW數(shù)據(jù)后ISP做去噪、壞點校正、色彩插值輸出YUV或RAW幀這份幀數(shù)據(jù)被放進(jìn)BufferQueue應(yīng)用層通過SurfaceTexture或ImageReader取出來做業(yè)務(wù)處理最后要么交給SurfaceFlinger去合成顯示要么交給編碼器去錄制。在這個鏈路上幀數(shù)據(jù)的流動是典型的生產(chǎn)者-消費(fèi)者模型。HAL層是生產(chǎn)者應(yīng)用層是消費(fèi)者中間的BufferQueue是緩沖區(qū)。一個最基本的優(yōu)化原則就是生產(chǎn)者和消費(fèi)者的速度要匹配任何一方掉了鏈子幀率就會立刻波動。1.2 各層性能損耗的關(guān)鍵環(huán)節(jié)我按層級把典型的性能損耗點整理了一下你會發(fā)現(xiàn)真正拖后腿的往往是那些不起眼的中間環(huán)節(jié)。層級典型瓶頸表現(xiàn)應(yīng)用層主線程做耗時操作、大圖Bitmap反復(fù)創(chuàng)建點擊快門卡頓、預(yù)覽掉幀F(xiàn)ramework層CameraService線程調(diào)度、BufferQueue阻塞ANR、Surface狀態(tài)異常HAL層ISP 3A算法耗時、降噪多幀合成耗時出圖慢、夜景模式處理時間長內(nèi)核驅(qū)動層Sensor上電、I2C配置耗時、CSI傳輸帶寬不足啟動慢、高分辨率卡頓這里要特別提一下ISP的3A算法也就是自動曝光、自動對焦、自動白平衡。3A需要連續(xù)采集多幀統(tǒng)計信息來做收斂在暗光環(huán)境下迭代次數(shù)會明顯增加單幀的統(tǒng)計耗時可能從1毫秒漲到5毫秒以上。如果你發(fā)現(xiàn)暗光下預(yù)覽幀率掉得厲害大概率是3A收斂花了太多時間而不是Sensor本身跑不動。1.3 判斷瓶頸歸屬的優(yōu)先級順序很多開發(fā)者一提到優(yōu)化相機(jī)就想直接把HAL層重寫一遍這是不對的。我的經(jīng)驗是先查數(shù)據(jù)路徑再查業(yè)務(wù)邏輯最后才碰底層配置。數(shù)據(jù)路徑指的是幀能不能順暢地從HAL流到應(yīng)用層業(yè)務(wù)邏輯指的是你的應(yīng)用拿到幀之后做了什么底層配置才是HAL的tuning參數(shù)和驅(qū)動行為。排查的時候優(yōu)先級反著來先用Perfetto看全局確認(rèn)瓶頸在APP進(jìn)程還是系統(tǒng)服務(wù)再逐層往下定位。這個思路貫穿全篇后面所有優(yōu)化手段都是圍繞這個排查順序展開的。2. 幀率與時延優(yōu)化從Pipeline層面壓榨每一幀的耗時幀率上不去、時延壓不下來這是Camera優(yōu)化最高頻的兩個訴求。很多項目把目標(biāo)定成預(yù)覽要達(dá)到60fps、拍照零快門時延但實現(xiàn)路徑完全想反了——只顧著調(diào)大ISP頻率沒有先做管線層面的減法。2.1 幀率、時延、吞吐量的權(quán)衡邏輯先理清三個概念。幀率代表每秒鐘能出多少幀時延代表從觸發(fā)到結(jié)果的時間差吞吐量代表單位時間能處理的數(shù)據(jù)量。它們之間不是獨立的幀率提升后單幀的處理預(yù)算會縮短時延不一定同步下降甚至因為CPU搶占導(dǎo)致時延升高。舉個具體數(shù)字60fps意味著每幀只有16.6毫秒的預(yù)算這16.6毫秒里要完成曝光、ISP處理、Buffer傳遞、應(yīng)用回調(diào)、顯示合成全套動作。任何一環(huán)超過16.6毫秒掉幀立刻出現(xiàn)。而到了暗光環(huán)境曝光時間可能直接占掉10毫秒以上這時候如果HAL還堅持用高幀率配置畫面就會快速劣化。所以幀率優(yōu)化的第一步不是硬拉頻率而是根據(jù)場景動態(tài)調(diào)整目標(biāo)幀率。我通常在應(yīng)用層實現(xiàn)一個幀率分級策略光線充足時預(yù)覽60fps保證滑動取景的流暢度中等光線降為30fps把CPU預(yù)算留給降噪算法暗光環(huán)境降到24fps以下同時開多幀合成提升畫質(zhì)這個策略在底層只需要通過android.control.aeTargetFpsRange設(shè)置不同的range但帶來的體驗提升非常明顯用戶不會感知到預(yù)覽變卡反而會覺得暗光下畫面更干凈。2.2 請求隊列與流配置對幀率的影響Camera2的Repeating Request機(jī)制是另一個經(jīng)常被誤解的點。很多初學(xué)者會誤以為并發(fā)請求越多越好實際恰恰相反。每一個CaptureRequest提交到HAL之后HAL需要按照優(yōu)先級調(diào)度。如果你同時提交了預(yù)覽流、拍照流、分析流三條流的請求HAL內(nèi)部的ISP帶寬會被平分單條流的幀率自然下降。我之前在一個項目里就遇到過后臺加了一個人臉檢測流之后預(yù)覽幀率直接從60fps掉到30fps而人臉檢測本身每秒只需要5幀。這背后其實是對Stream配置的理解問題。在createCaptureSession時每個Surface的尺寸和格式?jīng)Q定了HAL分配的ISP通道。合理的做法是預(yù)覽流使用屏幕分辨率同比例的YUV流保持全幀率拍照流獨立高分辨率流不需要持續(xù)出幀分析流用低分辨率流比如640x480并通過setMaxImages限制緩沖數(shù)量另一個容易被忽略的點是aeTargetFpsRange和control.aeMode的聯(lián)動。如果應(yīng)用把AE目標(biāo)幀率設(shè)成30fps即使物理Sensor支持60fps出幀HAL也會按照30fps的節(jié)奏做曝光控制。這個配置要在RepeatingRequest里持續(xù)攜帶不是設(shè)置一次就完事。2.3 減少阻塞的關(guān)鍵路徑優(yōu)化真正導(dǎo)致時延飆升的元兇是阻塞而阻塞最常出現(xiàn)在三個地方ScreenCapture的等待、BufferQueue的背壓、以及應(yīng)用線程的調(diào)度延遲。背壓問題尤其值得一提。BufferQueue有容量上限生產(chǎn)速度長期大于消費(fèi)速度時生產(chǎn)者會被迫阻塞。Camera里最典型的一幕是App在onImageAvailable回調(diào)里做耗時操作占著消費(fèi)線程不放底層ISP已經(jīng)把Buffer填滿了后續(xù)幀只能排隊甚至丟棄。這個問題的解藥是讓ImageReader的處理線程職責(zé)單一化只負(fù)責(zé)取幀和分發(fā)絕對不要在這個回調(diào)里做圖像處理。線程調(diào)度延遲是另一個隱性殺手。Android在移動設(shè)備上默認(rèn)開啟了CFS調(diào)度器CameraProvider線程和App主線程的優(yōu)先級如果不調(diào)整很容易被前臺渲染任務(wù)搶占。實踐中我會把CameraHandler的線程優(yōu)先級提到THREAD_PRIORITY_DISPLAY同時避免在這個線程上執(zhí)行Binder同步調(diào)用。Binder調(diào)用一旦發(fā)生IPC阻塞可能直接引發(fā)幾百毫秒的延遲這在快門時延優(yōu)化上是致命的。3. Buffer管理是幀率優(yōu)化的重災(zāi)區(qū)深挖丟幀和內(nèi)存抖動如果說幀率優(yōu)化是從管線層面做加法減法Buffer管理就是從內(nèi)存層面避免自爆。很多Camera項目的OOM和掉幀其實都是Buffer策略設(shè)計不合理造成的這塊太值得單獨拿出來講了。3.1 為什么Buffer分配會拖垮CameraAndroid Camera的Buffer管理和Java堆的GC不同它走的是GraphicBuffer這套機(jī)制內(nèi)存來自SurfaceFlinger或ION分配器。你每次創(chuàng)建一張Image底層就是一次完整的Buffer分配涉及ION內(nèi)存申請、映射、緩存同步代價比new一個Bitmap對象高出幾個數(shù)量級。設(shè)想一個最簡單的預(yù)覽場景如果每幀出圖都新建Buffer、用完釋放60fps時一秒就產(chǎn)生60次分配和釋放。這種高頻率的內(nèi)存抖動不僅拖慢單幀耗時還會觸發(fā)底層內(nèi)存池碎片化最終表現(xiàn)為系統(tǒng)內(nèi)存充足但大塊Buffer分配失敗。3.2 復(fù)用機(jī)制與BufferQueue的正確打開方式避免Buffer抖動的手段就是復(fù)用。Camera2的流程里ImageReader的Buffer池天然支持復(fù)用但你得正確地設(shè)置參數(shù)和持有方式。關(guān)鍵參數(shù)是setMaxImages。這個值代表ImageReader最多能同時持有多少個Buffer。設(shè)得太小比如設(shè)成1消費(fèi)線程來不及處理生產(chǎn)者就會阻塞設(shè)得太大會浪費(fèi)內(nèi)存尤其是高分辨率場景一個1080p的YUV Buffer就占3MB甚至更多。我常用的經(jīng)驗值是這樣使用場景maxImages取值說明預(yù)覽流2~3生產(chǎn)消費(fèi)速度基本匹配不需要太多緩沖拍照流3~5多幀合成需要同時持有多個原始幀分析流2盡量降低內(nèi)存占用分析幀不需要高吞吐拿到Image之后記得用image.getPlanes()拿到數(shù)據(jù)后立即image.close()這個close不是銷毀Buffer而是把Buffer釋放回ImageReader的池子供下一輪復(fù)用。3.3 預(yù)處理幀寬度對齊的玄學(xué)問題這塊是我踩過很深的坑。曾經(jīng)有個項目做圖像識別發(fā)現(xiàn)識別速度突然掉了一半排查到最后發(fā)現(xiàn)是圖像寬度沒做對齊導(dǎo)致的。很多圖像算法庫要求輸入的圖像寬度是16或32的整數(shù)倍不滿足時就得做crop或padding。如果你在Java層用Bitmap做crop生成的臨時Bitmap會占用大量的Native堆內(nèi)存而且Crop通常需要在主線程做進(jìn)一步加劇卡頓。正確做法是先通過StreamConfigurationMap.getOutputSizes()拿到Camera支持的輸出尺寸從里面挑一個滿足算法對齊要求的分辨率直接讓Camera輸出對齊后的尺寸這樣底層ISP開銷最小也完全避免Java層的二次裁剪。這個思路同樣適用于NV21轉(zhuǎn)RGB這種格式轉(zhuǎn)換操作能提前在底層解決就不要放到應(yīng)用層處理。4. 功耗與發(fā)熱性能優(yōu)化的另一把尺子性能優(yōu)化不能只看幀率和時延功耗是這個游戲里不能回避的另一半。一臺手機(jī)如果打開相機(jī)5分鐘就發(fā)燙降頻幀率再高也是曇花一現(xiàn)用戶體驗照樣崩潰。4.1 Camera功耗分布Camera場景的功耗大頭是這幾塊Sensor感光與模組供電、ISP與DSP運(yùn)算、內(nèi)存帶寬消耗、以及屏幕常亮顯示。其中ISP和內(nèi)存帶寬兩塊在App層面最能施加影響。以4K30視頻錄制為例編碼器需要持續(xù)的30fps輸入每幀數(shù)據(jù)量大概在12MB左右一秒的帶寬需求就是360MB。這么高的帶寬會拉動DRAM頻率和總線頻率上調(diào)功耗顯著增加。如果你在錄4K的同時還開了一個1080p的分析流帶寬幾乎翻倍這也是為什么很多手機(jī)同時開錄像和掃碼會發(fā)熱嚴(yán)重的原因。4.2 降功耗的實用手段降功耗不是無腦降低分辨率而是根據(jù)業(yè)務(wù)場景精細(xì)化配置。我整理過幾個在真實項目中驗證有效的策略第一限制分析流的幀率。人臉檢測、掃碼這些分析任務(wù)不需要30fps例如通過setRepeatingRequest時在CONTROL_AE_TARGET_FPS_RANGE之外再配合Surface自身的幀率限制只給分析Surface發(fā)5fps的幀。這個方法我在多個項目里用過預(yù)處理功耗能省一半以上。第二調(diào)整YUV格式。如果業(yè)務(wù)不需要高精度的色彩還原可以把YUV_420_888換成NV12或灰度格式減少數(shù)據(jù)量?;叶雀袷接绕溥m合純紋理分析的場景數(shù)據(jù)量直接降三分之二帶寬和內(nèi)存占用都會大幅下降。第三利用Thermal狀態(tài)動態(tài)降級。注冊PowerManager.ThermalStatusListener當(dāng)系統(tǒng)溫度升高時主動降低預(yù)覽幀率或畫質(zhì)檔位而不是等系統(tǒng)強(qiáng)殺。這個做法雖然看似保守但用戶感受到的是相機(jī)發(fā)熱了但還能用而不是相機(jī)卡死在黑屏界面。4.3 用FrameStats做功耗基準(zhǔn)測試功耗優(yōu)化的驗證需要量化。我常用的方式是抓取一份長時間運(yùn)行的FrameMetrics數(shù)據(jù)統(tǒng)計平均幀耗時和幀率分布結(jié)合功耗模型估算內(nèi)存帶寬占用然后用溫升曲線輔助判斷。具體操作方式是開啟adb shell dumpsys gfxinfo packageName framestats持續(xù)收集幾分鐘的數(shù)據(jù)重點看FRAME_COMPLETED時間戳的間隔分布。如果間隔出現(xiàn)周期性跳變說明BufferQueue存在周期性阻塞這時候配合Perfetto看Buffer狀態(tài)就能快速定位。5. 實戰(zhàn)排障用Systrace和Perfetto定位卡頓的完整思路紙上談兵講再多配置不如動手排查一次實際問題。這個部分我用一次真實的相機(jī)卡頓排查過程來演示完整思路。5.1 抓取一份有效的tracePerfetto是現(xiàn)在排查Android性能問題的首選工具Camera場景也不例外。抓取的時候要注意幾點否則trace是不完整的抓取時間控制在10~15秒太長文件巨大不便于分析太短又捕捉不到問題窗口抓取前把問題操作完整復(fù)現(xiàn)一遍如果問題涉及SurfaceFlinger合成還要在adb shell setprop debug.sf.enable_gl_backpressure 1之后再抓。在Perfetto里我重點關(guān)注的Track包括App主線程、CameraProvider線程、SurfaceFlinger的Composition線程以及HAL側(cè)的CameraProvider2.4服務(wù)線程。5.2 關(guān)鍵線程與關(guān)鍵Callback怎么讀拿到trace之后先不要急著看CPU占用率先從Buffer流轉(zhuǎn)的角度走一遍。先看App主線程有沒有長時間卡頓再看CameraProvider線程的空閑比例——如果CameraProvider線程長期跑滿說明HAL側(cè)的幀生產(chǎn)環(huán)節(jié)有問題再看App消費(fèi)幀的Callback是否頻繁被阻塞比如onImageAvailable回調(diào)周期明顯拉長往往是消費(fèi)側(cè)處理不過來。我印象很深刻的一個案例是某機(jī)型預(yù)覽卡頓trace顯示SurfaceFlinger的Composition線程每幀耗時都超過30毫秒而CameraProvider線程產(chǎn)出很正常。最后定位到是應(yīng)用在預(yù)覽Surface上疊加了一個全屏模糊效果導(dǎo)致每個Layer都需要GPU反復(fù)處理SurfaceFlinger直接成了瓶頸。把模糊效果去掉之后幀率恢復(fù)正常。5.3 一個典型的Buffer阻塞案例拆解再分享一個典型的BufferQueue阻塞案例。某App在每秒30fps的預(yù)覽中周期性出現(xiàn)丟幀Perfetto中看到BufferQueue的dequeueBuffer調(diào)用頻繁blocked。順著BufferQueue的狀態(tài)一路查下去發(fā)現(xiàn)App側(cè)的消費(fèi)者線程在OnImageAvailable的同一個線程里執(zhí)行了RGB轉(zhuǎn)換和上傳紋理的操作單次耗時接近50毫秒導(dǎo)致Buffer消費(fèi)速度遠(yuǎn)低于生產(chǎn)速度BufferQueue很快就塞滿了。底層HAL的幀無處可放只能丟幀自保。這個案例的解決方案很直接把取幀和圖像處理徹底分離用兩個線程各司其職。取幀線程只負(fù)責(zé)拿Image、保存引用、快速close圖像處理線程負(fù)責(zé)后續(xù)的轉(zhuǎn)換和算法邏輯。這個改動上線之后丟幀問題完全消除。6. 從Framework到HAL層幾個容易被忽視的優(yōu)化點前面講完了排查思路最后補(bǔ)幾個我在底層調(diào)優(yōu)中認(rèn)為最容易被應(yīng)用層開發(fā)者忽略卻影響巨大的點。6.1 Camera HAL與ISP的配合問題HAL層涉及大量ISP相關(guān)的調(diào)優(yōu)很多參數(shù)是平臺相關(guān)的這里不展開具體數(shù)值但有一個原則值得強(qiáng)調(diào)盡量不要在應(yīng)用層繞過HAL去做像素級別的后處理。很多App為了追求美顏效果自己實現(xiàn)了一套降噪或磨皮算法在onImageAvailable回調(diào)里對每一幀做全像素遍歷。這種做法不僅占CPU而且效率遠(yuǎn)低于ISP里現(xiàn)成的硬件降噪模塊。如果你確實需要做美化處理正確姿勢是把需求提給HAL層的tuning工程師通過調(diào)整ISP參數(shù)實現(xiàn)而不是用CPU硬扛。6.2 JVM堆與大圖Bitmap的內(nèi)存管理Camera應(yīng)用是Java堆內(nèi)存消耗大戶尤其是拍照后需要預(yù)覽大圖時一個2400萬像素的Bitmap在ARGB8888格式下需要96MB內(nèi)存直接把應(yīng)用堆打爆。建議在圖片解碼階段就做好降采樣用BitmapFactory.Options的inSampleSize先縮到屏幕尺寸而不是加載原圖再做壓縮。備選方案是把大圖轉(zhuǎn)成Hardware Bitmap讓它直接借用GraphicBuffer的內(nèi)存池繞開Java堆這個方案對減少GC壓力效果理想。6.3 分辨率選擇與算法對齊的隱性約束最后再說一次對齊問題因為這真的容易犯。Camera輸出的YUV幀寬度不一定是算法模型輸入的期望值盡量在Camera配置階段找到匹配的分辨率避免在Pipeline中途做Crop。這里的核心原則是所有轉(zhuǎn)換和裁剪盡量發(fā)生在最底層越早處理越省內(nèi)存和帶寬。如果算法要求輸入224x224而Camera輸出的是640x480直接在HAL層做裁剪輸出224x224比在應(yīng)用層取到整幀再縮放高效太多。val map characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP) val targetSize map.getOutputSizes(ImageFormat.YUV_420_888) .minByOrNull { abs(it.width - 224) abs(it.height - 224) }這段代碼會遍歷所有支持的YUV輸出尺寸挑出和算法輸入最接近的一個。雖然只是幾行代碼但能省掉中間整幀的傳輸帶寬和縮放開銷實際體感差距很大。6.4 多攝協(xié)作場景的帶寬分配策略現(xiàn)在的手機(jī)普遍是多攝方案主攝、廣角、長焦各自獨立。多攝同時出流時帶寬分配尤其需要權(quán)衡。我之前處理過一個項目主攝錄像和長焦取流同時進(jìn)行總線帶寬被打滿結(jié)果兩路流的幀率都不穩(wěn)定。解決思路是給高優(yōu)先級流分配更高的帶寬權(quán)重比如主攝走獨立的MIPI通道長焦和廣角共享另一路同時降低輔助流的分辨率和幀率。這個需要在HAL層配置時明確區(qū)分Stream的使用場景我把這類參數(shù)的調(diào)整歸納為連接的流越少越好單流的負(fù)載越均勻越好。比如在HAL層的configureStreams里按業(yè)務(wù)場景分出主用流與備用流備用流始終啟用低功耗模式。比起全部流都全速跑這套策略在幀率和功耗之間找到了一個更好的平衡點。寫在最后的幾點心得做了這么多年Camera性能優(yōu)化我的體會是這個領(lǐng)域沒有銀彈每一臺設(shè)備的ISP行為、HAL實現(xiàn)、傳感器特性都不完全相同A機(jī)型上的優(yōu)化手段搬到B機(jī)型上可能完全失效。但通用的方法論是穩(wěn)定的。把幀率、時延、Buffer、功耗四件事拆開看每一件事都有清晰的優(yōu)化路徑不要試圖用一個大而全的方案解決所有問題。補(bǔ)一個長期受益的習(xí)慣優(yōu)化前后都要保留數(shù)據(jù)記錄。幀率曲線、時延分布、溫升數(shù)據(jù)都存下來做成對比圖表。沒有量化就沒有說服力多個項目迭代對比下來你會發(fā)現(xiàn)自己對Camera系統(tǒng)的感知比任何人都敏感。最后分享一個探測器小技巧優(yōu)化完記得用adb shell dumpsys media.camera看一下當(dāng)前Camera服務(wù)的狀態(tài)里面能看到各個Session的活躍情況對確認(rèn)問題是否遺留很有幫助。