本篇文章已授權(quán)微信公眾號 guolin_blog (郭霖)獨家發(fā)布
這次就來梳理一下 Android 的屏幕刷新機制官套,把我這段時間因為研究動畫而梳理出來的一些關(guān)于屏幕刷新方面的知識點分享出來瓢喉,能力有限,有錯的地方還望指點一下。另外,內(nèi)容有點多,畢竟要講清楚不容易并蝗,所以慢慢看哈。
提問環(huán)節(jié)
閱讀源碼還是得帶著問題或目的性的去閱讀秸妥,這樣閱讀過程中比較有條理性滚停,不會跟偏或太深入,所以粥惧,還是先來幾個問題吧:
大伙都清楚键畴,Android 每隔 16.6ms 會刷新一次屏幕。
Q1:但是大伙想過沒有突雪,這個 16.6ms 刷新一次屏幕到底是什么意思呢起惕?是指每隔 16.6ms 調(diào)用 onDraw() 繪制一次么?
Q2:如果界面一直保持沒變的話咏删,那么還會每隔 16.6ms 刷新一次屏幕么惹想?
Q3:界面的顯示其實就是一個 Activity 的 View 樹里所有的 View 都進行測量、布局督函、繪制操作之后的結(jié)果呈現(xiàn)嘀粱,那么如果這部分工作都完成后,屏幕會馬上就刷新么辰狡?
Q4:網(wǎng)上都說避免丟幀的方法之一是保證每次繪制界面的操作要在 16.6ms 內(nèi)完成锋叨,但如果這個 16.6ms 是一個固定的頻率的話,請求繪制的操作在代碼里被調(diào)用的時機是不確定的啊搓译,那么如果某次用戶點擊屏幕導(dǎo)致的界面刷新操作是在某一個 16.6ms 幀快結(jié)束的時候悲柱,那么即使這次繪制操作小于 16.6 ms锋喜,按道理不也會造成丟幀么些己?這又該如何理解?
Q5:大伙都清楚嘿般,主線程耗時的操作會導(dǎo)致丟幀段标,但是耗時的操作為什么會導(dǎo)致丟幀?它是如何導(dǎo)致丟幀發(fā)生的炉奴?
本篇主要就是搞清楚這幾個問題逼庞,分析的源碼基本只涉及 ViewRootImpl 和 Choreographer 這兩個類。
源碼分析
ps:本篇分析的源碼均是 android-25 版本瞻赶,版本不一樣赛糟,源碼可能會有些許差異派任,大伙過的時候注意一下。
基本概念
首先璧南,先來過一下一些基本概念掌逛,摘抄自網(wǎng)上文章android屏幕刷新顯示機制:
在一個典型的顯示系統(tǒng)中,一般包括CPU司倚、GPU豆混、display三個部分, CPU負責計算數(shù)據(jù)动知,把計算好數(shù)據(jù)交給GPU,GPU會對圖形數(shù)據(jù)進行渲染皿伺,渲染好后放到buffer里存起來,然后display(有的文章也叫屏幕或者顯示器)負責把buffer里的數(shù)據(jù)呈現(xiàn)到屏幕上盒粮。
顯示過程鸵鸥,簡單的說就是CPU/GPU準備好數(shù)據(jù),存入buffer拆讯,display每隔一段時間去buffer里取數(shù)據(jù)脂男,然后顯示出來。display讀取的頻率是固定的种呐,比如每個16ms讀一次宰翅,但是CPU/GPU寫數(shù)據(jù)是完全無規(guī)律的。
上述內(nèi)容概括一下爽室,大體意思就是說汁讼,屏幕的刷新包括三個步驟:CPU 計算屏幕數(shù)據(jù)、GPU 進一步處理和緩存阔墩、最后 display 再將緩存中(buffer)的屏幕數(shù)據(jù)顯示出來嘿架。
(ps:開發(fā)過程中應(yīng)該接觸不到 GPU、display 這些層面的東西啸箫,所以我把這部分工作都稱作底層的工作了耸彪,下文出現(xiàn)的底層指的就是除了 CPU 計算屏幕數(shù)據(jù)之外的工作。)
對于 Android 而言忘苛,第一個步驟:CPU 計算屏幕數(shù)據(jù)指的也就是 View 樹的繪制過程蝉娜,也就是 Activity 對應(yīng)的視圖樹從根布局 DecorView 開始層層遍歷每個 View,分別執(zhí)行測量扎唾、布局召川、繪制三個操作的過程。
也就是說胸遇,我們常說的 Android 每隔 16.6ms 刷新一次屏幕其實是指:底層以固定的頻率荧呐,比如每 16.6ms 將 buffer 里的屏幕數(shù)據(jù)顯示出來。
如果還不清楚,那再看一張網(wǎng)上很常見的圖(摘自上面同一篇文章):
結(jié)合這張圖倍阐,再來講講 16.6 ms 屏幕刷新一次的意思概疆。
Display 這一行可以理解成屏幕,所以可以看到峰搪,底層是以固定的頻率發(fā)出 VSync 信號的届案,而這個固定頻率就是我們常說的每 16.6ms 發(fā)送一個 VSync 信號,至于什么叫 VSync 信號罢艾,我們可以不用深入去了解楣颠,只要清楚這個信號就是屏幕刷新的信號就可以了。
繼續(xù)看圖咐蚯,Display 黃色的這一行里有一些數(shù)字:0, 1, 2, 3, 4
童漩,可以看到每次屏幕刷新信號到了的時候,數(shù)字就會變化春锋,所以這些數(shù)字其實可以理解成每一幀屏幕顯示的畫面矫膨。也就是說,屏幕每一幀的畫面可以持續(xù) 16.6ms期奔,當過了 16.6ms侧馅,底層就會發(fā)出一個屏幕刷新信號,而屏幕就會去顯示下一幀的畫面呐萌。
以上都是一些基本概念馁痴,也都是底層的工作,我們了解一下就可以了肺孤。接下去就還是看這圖罗晕,然后講講我們 app 層該干的事了:
繼續(xù)看圖,CPU 藍色的這行赠堵,上面也說過了小渊,CPU 這塊的耗時其實就是我們 app 繪制當前 View 樹的時間,而這段時間就跟我們自己寫的代碼有關(guān)系了茫叭,如果你的布局很復(fù)雜酬屉,層次嵌套很多,每一幀內(nèi)需要刷新的 View 又很多時揍愁,那么每一幀的繪制耗時自然就會多一點呐萨。
繼續(xù)看圖吗垮,CPU 藍色這行里也有一些數(shù)字锨络,其實這些數(shù)字跟 Display 黃色的那一行里的數(shù)字是對應(yīng)的掠归,在 Display 里我們解釋過這些數(shù)字表示的是每一幀的畫面,那么在 CPU 這一行里,其實就是在計算對應(yīng)幀的畫面數(shù)據(jù)音诈,也叫屏幕數(shù)據(jù)喇聊。也就是說摆屯,在當前幀內(nèi)糊饱,CPU 是在計算下一幀的屏幕畫面數(shù)據(jù)蓖扑,當屏幕刷新信號到的時候拆宛,屏幕就去將 CPU 計算的屏幕畫面數(shù)據(jù)顯示出來敢艰;同時 CPU 也接收到屏幕刷新信號,所以也開始去計算下一幀的屏幕畫面數(shù)據(jù)瞳浦。
CPU 跟 Display 是不同的硬件废士,它們是可以并行工作的叫潦。要理解的一點是,我們寫的代碼官硝,只是控制讓 CPU 在接收到屏幕刷新信號的時候開始去計算下一幀的畫面工作矗蕊。而底層在每一次屏幕刷新信號來的時候都會去切換這一幀的畫面,這點我們是控制不了的氢架,是底層的工作機制傻咖。之所以要講這點,是因為岖研,當我們的 app 界面沒有必要再刷新時(比如用戶不操作了卿操,當前界面也沒動畫),這個時候孙援,我們 app 是接收不到屏幕刷新信號的害淤,所以也就不會讓 CPU 去計算下一幀畫面數(shù)據(jù),但是底層仍然會以固定的頻率來切換每一幀的畫面拓售,只是它后面切換的每一幀畫面都一樣窥摄,所以給我們的感覺就是屏幕沒刷新。
所以础淤,我覺得上面那張圖還可以再繼續(xù)延深幾幀的長度崭放,這樣就更容易理解了:
我在那張圖的基礎(chǔ)上延長了幾幀,我想這樣應(yīng)該可以更容易理解點鸽凶。
看我畫的這張圖币砂,前三幀跟原圖一樣,從第三幀之后玻侥,因為我們的 app 界面不需要刷新了(用戶不操作了道伟,界面也沒有動畫),那么這之后我們 app 就不會再接收到屏幕刷新信號了使碾,所以也就不會再讓 CPU 去繪制視圖樹來計算下一幀畫面了蜜徽。但是,底層還是會每隔 16.6ms 發(fā)出一個屏幕刷新信號票摇,只是我們 app 不會接收到而已拘鞋,Display 還是會在每一個屏幕刷新信號到的時候去顯示下一幀畫面,只是下一幀畫面一直是第4幀的內(nèi)容而已矢门。
好了盆色,到這里 Q1灰蛙,Q2,Q3 都可以先回答一半了隔躲,那么我們就先稍微來梳理一下:
我們常說的 Android 每隔 16.6 ms 刷新一次屏幕其實是指底層會以這個固定頻率來切換每一幀的畫面摩梧。
這個每一幀的畫面也就是我們的 app 繪制視圖樹(View 樹)計算而來的,這個工作是交由 CPU 處理宣旱,耗時的長短取決于我們寫的代碼:布局復(fù)不復(fù)雜仅父,層次深不深,同一幀內(nèi)刷新的 View 的數(shù)量多不多浑吟。
CPU 繪制視圖樹來計算下一幀畫面數(shù)據(jù)的工作是在屏幕刷新信號來的時候才開始工作的笙纤,而當這個工作處理完畢后,也就是下一幀的畫面數(shù)據(jù)已經(jīng)全部計算完畢组力,也不會馬上顯示到屏幕上省容,而是會等下一個屏幕刷新信號來的時候再交由底層將計算完畢的屏幕畫面數(shù)據(jù)顯示出來。
當我們的 app 界面不需要刷新時(用戶無操作燎字,界面無動畫)腥椒,app 就接收不到屏幕刷新信號所以也就不會讓 CPU 再去繪制視圖樹計算畫面數(shù)據(jù)工作,但是底層仍然會每隔 16.6 ms 切換下一幀的畫面候衍,只是這個下一幀畫面一直是相同的內(nèi)容寞酿。
這部分雖然說是一些基本概念,但其實也包含了一些結(jié)論了脱柱,所以可能大伙看著會有些困惑:為什么界面不刷新時 app 就接收不到屏幕刷新信號了伐弹?為什么繪制視圖樹計算下一幀畫面的工作會是在屏幕刷新信號來的時候才開始的?等等榨为。
emmm惨好,有這些困惑很棒,這樣随闺,我們下面一起過源碼時日川,大伙就更有目的性了仔役,這樣過源碼我覺得效率是比較高一點的打却。繼續(xù)看下去稠屠,跟著過完源碼蒂窒,你就清楚為什么了。好了蹭睡,那我們下面就開始過源碼了善炫。
ViewRootImpl 與 DecorView 的綁定
閱讀源碼從哪開始看起一直都是個頭疼的問題勒叠,所以找一個合適的切入點來跟的話欧漱,整個梳理的過程可能會順暢一點职抡。本篇是研究屏幕的刷新,那么建議就是從某個會導(dǎo)致屏幕刷新的方法入手误甚,比如 View#invalidate()
缚甩。
View#invalidate()
是請求重繪的一個操作谱净,所以我們切入點可以從這個方法開始一步步跟下去。我們在上一篇博客View 動畫 Animation 運行原理解析已經(jīng)分析過 View#invalidate()
這個方法了擅威。
想再過一遍的可以再去看看壕探,我們這里就直接說結(jié)論了。我們跟著 invalidate()
一步步往下走的時候郊丛,發(fā)現(xiàn)最后跟到了 ViewRootImpl#scheduleTraversals()
就停止了李请。而 ViewRootImpl 就是今天我們要介紹的重點對象了。
大伙都清楚宾袜,Android 設(shè)備呈現(xiàn)到界面上的大多數(shù)情況下都是一個 Activity捻艳,真正承載視圖的是一個 Window驾窟,每個 Window 都有一個 DecorView庆猫,我們調(diào)用 setContentView()
其實是將我們自己寫的布局文件添加到以 DecorView 為根布局的一個 ViewGroup 里,構(gòu)成一顆 View 樹绅络。
這些大伙都清楚月培,每個 Activity 對應(yīng)一顆以 DecorView 為根布局的 View 樹,但其實 DecorView 還有 mParent恩急,而且就是 ViewRootImpl杉畜,而且每個界面上的 View 的刷新,繪制衷恭,點擊事件的分發(fā)其實都是由 ViewRootImpl 作為發(fā)起者的此叠,由 ViewRootImpl 控制這些操作從 DecorView 開始遍歷 View 樹去分發(fā)處理。
在上一篇動畫分析的博客里随珠,分析 View#invalidate()
時灭袁,也可以看到內(nèi)部其實是有一個 do{}while() 循環(huán)來不斷尋找 mParent,所以最終才會走到 ViewRootImpl 里去窗看,那么可能大伙就會疑問了茸歧,為什么 DecorView 的 mParent 會是 ViewRootImpl 呢?換個問法也就是显沈,在什么時候?qū)?DevorView 和 ViewRootImpl 綁定起來软瞎?
Activity 的啟動是在 ActivityThread 里完成的,handleLaunchActivity()
會依次間接的執(zhí)行到 Activity 的 onCreate()
, onStart()
, onResume()
拉讯。在執(zhí)行完這些后 ActivityThread 會調(diào)用 WindowManager#addView()
涤浇,而這個 addView()
最終其實是調(diào)用了 WindowManagerGlobal 的 addView()
方法,我們就從這里開始看:
WindowManager 維護著所有 Activity 的 DecorView 和 ViewRootImpl魔慷。這里初始化了一個 ViewRootImpl芙代,然后調(diào)用了它的 setView()
方法,將 DevorView 作為參數(shù)傳遞了進去盖彭。所以看看 ViewRootImpl 中的 setView()
做了什么:
在 setView()
方法里調(diào)用了 DecorView 的 assignParent()
方法纹烹,所以去看看 View 的這個方法:
參數(shù)是 ViewParent页滚,而 ViewRootImpl 是實現(xiàn)了 ViewParent 接口的,所以在這里就將 DecorView 和 ViewRootImpl 綁定起來了铺呵。每個Activity 的根布局都是 DecorView,而 DecorView 的 parent 又是 ViewRootImpl片挂,所以在子 View 里執(zhí)行 invalidate()
之類的操作幻林,循環(huán)找 parent 時,最后都會走到 ViewRootImpl 里來音念。
跟界面刷新相關(guān)的方法里應(yīng)該都會有一個循環(huán)找 parent 的方法沪饺,或者是不斷調(diào)用 parent 的方法,這樣最終才都會走到 ViewRootImpl 里闷愤,也就是說實際上 View 的刷新都是由 ViewRootImpl 來控制的整葡。
即使是界面上一個小小的 View 發(fā)起了重繪請求時,都要層層走到 ViewRootImpl讥脐,由它來發(fā)起重繪請求遭居,然后再由它來開始遍歷 View 樹,一直遍歷到這個需要重繪的 View 再調(diào)用它的 onDraw()
方法進行繪制旬渠。
我們重新看回 ViewRootImpl 的 setView()
這個方法俱萍,這個方法里還調(diào)用了一個 requestLayout()
方法:
這里調(diào)用了一個 scheduleTraversals()
,還記得當 View 發(fā)起重繪操作 invalidate()
時告丢,最后也調(diào)用了 scheduleTraversals()
這個方法么枪蘑。其實這個方法就是屏幕刷新的關(guān)鍵,它是安排一次繪制 View 樹的任務(wù)等待執(zhí)行岖免,具體后面再說岳颇。
也就是說,其實打開一個 Activity觅捆,當它的 onCreate---onResume 生命周期都走完后赦役,才將它的 DecoView 與新建的一個 ViewRootImpl 對象綁定起來,同時開始安排一次遍歷 View 任務(wù)也就是繪制 View 樹的操作等待執(zhí)行栅炒,然后將 DecoView 的 parent 設(shè)置成 ViewRootImpl 對象掂摔。
這也就是為什么在 onCreate---onResume
里獲取不到 View 寬高的原因,因為在這個時刻 ViewRootImpl 甚至都還沒創(chuàng)建赢赊,更不用說是否已經(jīng)執(zhí)行過測量操作了乙漓。
還可以得到一點信息是,一個 Activity 界面的繪制释移,其實是在 onResume()
之后才開始的叭披。
ViewRootImpl#scheduleTraversals
到這里,我們梳理清楚了玩讳,調(diào)用一個 View 的 invalidate()
請求重繪操作涩蜘,內(nèi)部原來是要層層通知到 ViewRootImpl 的 scheduleTraversals()
里去嚼贡。而且打開一個新的 Activity,它的界面繪制原來是在 onResume()
之后也層層通知到 ViewRootImpl 的 scheduleTraversals()
里去同诫。雖然其他關(guān)于 View 的刷新操作粤策,比如 requestLayout()
等等之類的方法我們還沒有去看,但我們已經(jīng)可以大膽猜測误窖,這些跟 View 刷新有關(guān)的操作最終也都會層層走到 ViewRootImpl 中的 scheduleTraversals()
方法里去的叮盘。
那么這個方法究竟干了些什么,我們就要好好來分析了:
mTraversalScheduled 這個 boolean 變量的作用等會再來看霹俺,先看看 mChoreographer.postCallback()
這個方法柔吼,傳入了三個參數(shù),第二個參數(shù)是一個 Runnable 對象丙唧,先來看看這個 Runnable:
這個 Runnable 做的事很簡單愈魏,就調(diào)用了一個方法,doTraversal()
:
看看這個方法做的事艇棕,跟 scheduleTraversals()
正好相反蝌戒,一個將變量置成 true串塑,這里置成 false沼琉,一個是 postSyncBarrier()
,這里是 removeSyncBarrier()
桩匪,具體作用等會再說打瘪,繼續(xù)先看看 performTraversals()
,這個方法也是屏幕刷新的關(guān)鍵:
View 的測量傻昙、布局闺骚、繪制三大流程都是交由 ViewRootImpl 發(fā)起,而且還都是在 performTraversals()
方法中發(fā)起的妆档,所以這個方法的邏輯很復(fù)雜僻爽,因為每次都需要根據(jù)相應(yīng)狀態(tài)判斷是否需要三個流程都走,有時可能只需要執(zhí)行 performDraw()
繪制流程贾惦,有時可能只執(zhí)行 performMeasure()
測量和 performLayout()
布局流程(一般測量和布局流程是一起執(zhí)行的)胸梆。不管哪個流程都會遍歷一次 View 樹,所以其實界面的繪制是需要遍歷很多次的须板,如果頁面層次太過復(fù)雜碰镜,每一幀需要刷新的 View 又很多時,耗時就會長一點习瑰。
當然绪颖,測量、布局甜奄、繪制這些流程在遍歷時并不一定會把整顆 View 樹都遍歷一遍柠横,ViewGroup 在傳遞這些流程時窃款,還會再根據(jù)相應(yīng)狀態(tài)判斷是否需要繼續(xù)往下傳遞。
了解了 performTraversals()
是刷新界面的源頭后牍氛,接下去就需要了解下它是什么時候執(zhí)行的雁乡,和 scheduleTraversals()
又是什么關(guān)系?
performTraversals()
是在 doTraversal()
中被調(diào)用的糜俗,而 doTraversal()
又被封裝到一個 Runnable 里踱稍,那么關(guān)鍵就是這個 Runnable 什么時候被執(zhí)行了?
Choreographer
scheduleTraversals()
里調(diào)用了 Choreographer 的 postCallback()
將 Runnable 作為參數(shù)傳了進去悠抹,所以跟進去看看:
因為 postCallback()
調(diào)用 postCallbackDelayed()
時傳了 delay = 0 進去珠月,所以在 postCallbackDelayedInternal()
里面會先根據(jù)當前時間戳將這個 Runnable 保存到一個 mCallbackQueue 隊列里,這個隊列跟 MessageQueue 很相似楔敌,里面待執(zhí)行的任務(wù)都是根據(jù)一個時間戳來排序啤挎。然后走了 scheduleFrameLocked()
方法這邊,看看做了些什么:
如果代碼走了 else 這邊來發(fā)送一個消息卵凑,那么這個消息做的事肯定很重要庆聘,因為對這個 Message 設(shè)置了異步的標志而且用了sendMessageAtFrontOfQueue()
方法,這個方法是將這個 Message 直接放到 MessageQueue 隊列里的頭部勺卢,可以理解成設(shè)置了這個 Message 為最高優(yōu)先級伙判,那么先看看這個 Message 做了些什么:
所以這個 Message 最后做的事就是 scheduleVsyncLocked()
。我們回到 scheduleFrameLocked()
這個方法里黑忱,當走 if 里的代碼時宴抚,直接調(diào)用了 scheduleVsyncLocked()
,當走 else 里的代碼時甫煞,發(fā)了一個最高優(yōu)先級的 Message菇曲,這個 Message 也是執(zhí)行 scheduleVsyncLocked()
。既然兩邊最后調(diào)用的都是同一個方法抚吠,那么為什么這么做呢常潮?
關(guān)鍵在于 if 條件里那個方法,我的理解那個方法是用來判斷當前是否是在主線程的楷力,我們知道主線程也是一直在執(zhí)行著一個個的 Message喊式,那么如果在主線程的話,直接調(diào)用這個方法弥雹,那么這個方法就可以直接被執(zhí)行了垃帅,如果不是在主線程,那么 post 一個最高優(yōu)先級的 Message 到主線程去剪勿,保證這個方法可以第一時間得到處理贸诚。
那么這個方法是干嘛的呢,為什么需要在最短時間內(nèi)被執(zhí)行呢,而且只能在主線程酱固?
調(diào)用了 native 層的一個方法械念,那跟到這里就跟不下去了。
那到這里运悲,我們先來梳理一下:
到這里為止龄减,我們知道一個 View 發(fā)起刷新的操作時,會層層通知到 ViewRootImpl 的 scheduleTraversals() 里去班眯,然后這個方法會將遍歷繪制 View 樹的操作 performTraversals() 封裝到 Runnable 里希停,傳給 Choreographer,以當前的時間戳放進一個 mCallbackQueue 隊列里署隘,然后調(diào)用了 native 層的一個方法就跟不下去了宠能。所以這個 Runnable 什么時候會被執(zhí)行還不清楚。那么磁餐,下去的重點就是搞清楚它什么時候從隊列里被拿出來執(zhí)行了违崇?
接下去只能換種方式繼續(xù)跟了,既然這個 Runnable 操作被放在一個 mCallbackQueue 隊列里诊霹,那就從這個隊列著手羞延,看看這個隊列的取操作在哪被執(zhí)行了:
還記得我們說過在 ViewRootImpl 的 scheduleTraversals()
里會將遍歷 View 樹繪制的操作封裝到 Runnable 里,然后調(diào)用 Choreographer 的 postCallback()
將這個 Runnable 放進隊列里么脾还,而當時調(diào)用 postCallback()
時傳入了多個參數(shù)伴箩,這是因為 Choreographer 里有多個隊列,而第一個參數(shù) Choreographer.CALLBACK_TRAVERSAL 這個參數(shù)是用來區(qū)分隊列的荠呐,可以理解成各個隊列的 key 值赛蔫。
那么這樣一來砂客,就找到關(guān)鍵的方法了:doFrame()
泥张,這個方法里會根據(jù)一個時間戳去隊列里取任務(wù)出來執(zhí)行,而這個任務(wù)就是 ViewRootImpl 封裝起來的 doTraversal()
操作鞠值,而 doTraversal()
會去調(diào)用 performTraversals()
開始根據(jù)需要測量媚创、布局、繪制整顆 View 樹彤恶。所以剩下的問題就是 doFrame()
這個方法在哪里被調(diào)用了钞钙。
有幾個調(diào)用的地方,但有個地方很關(guān)鍵:
關(guān)鍵的地方來了声离,這個繼承自 DisplayEventReceiver 的 FrameDisplayEventReceiver 類的作用很重要芒炼。跟進去看注釋,我只能理解它是用來接收底層信號用的术徊。但看了網(wǎng)上的解釋后本刽,所有的都理解過來了:
FrameDisplayEventReceiver繼承自DisplayEventReceiver接收底層的VSync信號開始處理UI過程。VSync信號由SurfaceFlinger實現(xiàn)并定時發(fā)送。FrameDisplayEventReceiver收到信號后子寓,調(diào)用onVsync方法組織消息發(fā)送到主線程處理暗挑。這個消息主要內(nèi)容就是run方法里面的doFrame了,這里mTimestampNanos是信號到來的時間參數(shù)斜友。
也就是說炸裆,onVsync()
是底層會回調(diào)的,可以理解成每隔 16.6ms 一個幀信號來的時候鲜屏,底層就會回調(diào)這個方法烹看,當然前提是我們得先注冊,這樣底層才能找到我們 app 并回調(diào)洛史。當這個方法被回調(diào)時听系,內(nèi)部發(fā)起了一個 Message,注意看代碼對這個 Message 設(shè)置了 callback 為 this虹菲,Handler 在處理消息時會先查看 Message 是否有 callback靠胜,有則優(yōu)先交由 Message 的 callback 處理消息,沒有的話再去看看Handler 有沒有 callback毕源,如果也沒有才會交由 handleMessage()
這個方法執(zhí)行浪漠。
這里這么做的原因,我猜測可能 onVsync()
是由底層回調(diào)的霎褐,那么它就不是運行在我們 app 的主線程上址愿,畢竟上層 app 對底層是隱藏的。但這個 doFrame()
是個 ui 操作冻璃,它需要在主線程中執(zhí)行响谓,所以才通過 Handler 切到主線程中。
還記得我們前面分析 scheduleTraversals()
方法時省艳,最后跟到了一個 native 層方法就跟不下去了么娘纷,現(xiàn)在再回過來想想這個 native 層方法的作用是什么,應(yīng)該就比較好猜測了跋炕。
英文不大理解赖晶,大體上可能是說安排接收一個 vsync 信號。而根據(jù)我們的分析辐烂,如果這個 vsync 信號發(fā)出的話遏插,底層就會回調(diào) DisplayEventReceiver 的 onVsync()
方法。
那如果只是這樣的話纠修,就有一點說不通了胳嘲,首先上層 app 對于這些發(fā)送 vsync 信號的底層來說肯定是隱藏的,也就是說底層它根本不知道上層 app 的存在扣草,那么在它的每 16.6ms 的幀信號來的時候了牛,它是怎么找到我們的 app吁系,并回調(diào)它的方法呢?
這就有點類似于觀察者模式白魂,或者說發(fā)布-訂閱模式汽纤。既然上層 app 需要知道底層每隔 16.6ms 的幀信號事件,那么它就需要先注冊監(jiān)聽才對福荸,這樣底層在發(fā)信號的時候蕴坪,直接去找這些觀察者通知它們就行了。
這是我的理解敬锐,所以背传,這樣一來,scheduleVsync()
這個調(diào)用到了 native 層方法的作用大體上就可以理解成注冊監(jiān)聽了台夺,這樣底層也才找得到上層 app径玖,并在每 16.6ms 刷新信號發(fā)出的時候回調(diào)上層 app 的 onVsync() 方法。這樣一來颤介,應(yīng)該就說得通了梳星。
還有一點,scheduleVsync()
注冊的監(jiān)聽應(yīng)該只是監(jiān)聽下一個屏幕刷新信號的事件而已滚朵,而不是監(jiān)聽所有的屏幕刷新信號冤灾。比如說當前監(jiān)聽了第一幀的刷新信號事件,那么當?shù)谝粠乃⑿滦盘杹淼臅r候辕近,上層 app 就能接收到事件并作出反應(yīng)韵吨。但如果還想監(jiān)聽第二幀的刷新信號,那么只能等上層 app 接收到第一幀的刷新信號之后再去監(jiān)聽下一幀移宅。
雖然現(xiàn)在能力還不足以跟蹤到 native 層归粉,這些結(jié)論雖然是猜測的,但都經(jīng)過調(diào)試漏峰,對注釋糠悼、代碼理解之后梳理出來的結(jié)論,跟原理應(yīng)該不會偏差太多芽狗,這樣子的理解應(yīng)該是可以的绢掰。
本篇內(nèi)容確實有點多,所以到這里還是繼續(xù)來先來梳理一下目前的信息童擎,防止都忘記上面講了些什么:
我們知道一個 View 發(fā)起刷新的操作時,最終是走到了 ViewRootImpl 的 scheduleTraversals() 里去攻晒,然后這個方法會將遍歷繪制 View 樹的操作 performTraversals() 封裝到 Runnable 里顾复,傳給 Choreographer,以當前的時間戳放進一個 mCallbackQueue 隊列里鲁捏,然后調(diào)用了 native 層的方法向底層注冊監(jiān)聽下一個屏幕刷新信號事件芯砸。
當下一個屏幕刷新信號發(fā)出的時候,如果我們 app 有對這個事件進行監(jiān)聽,那么底層它就會回調(diào)我們 app 層的 onVsync() 方法來通知假丧。當 onVsync() 被回調(diào)時双揪,會發(fā)一個 Message 到主線程,將后續(xù)的工作切到主線程來執(zhí)行包帚。
切到主線程的工作就是去 mCallbackQueue 隊列里根據(jù)時間戳將之前放進去的 Runnable 取出來執(zhí)行渔期,而這些 Runnable 有一個就是遍歷繪制 View 樹的操作 performTraversals()。在這次的遍歷操作中渴邦,就會去繪制那些需要刷新的 View疯趟。
所以說,當我們調(diào)用了 invalidate()谋梭,requestLayout()信峻,等之類刷新界面的操作時,并不是馬上就會執(zhí)行這些刷新的操作瓮床,而是通過 ViewRootImpl 的 scheduleTraversals() 先向底層注冊監(jiān)聽下一個屏幕刷新信號事件盹舞,然后等下一個屏幕刷新信號來的時候,才會去通過 performTraversals() 遍歷繪制 View 樹來執(zhí)行這些刷新操作隘庄。
過濾一幀內(nèi)重復(fù)的刷新請求
整體上的流程我們已經(jīng)梳理出來的矾策,但還有幾點問題需要解決。我們在一個 16.6ms 的一幀內(nèi)峭沦,代碼里可能會有多個 View 發(fā)起了刷新請求贾虽,這是非常常見的場景了,比如某個動畫是有多個 View 一起完成吼鱼,比如界面發(fā)生了滑動等等蓬豁。
按照我們上面梳理的流程,只要 View 發(fā)起了刷新請求最終都會走到 ViewRootImpl 中的 scheduleTraversals()
里去菇肃,是吧地粪。而這個方法又會封裝一個遍歷繪制 View 樹的操作 performTraversals()
到 Runnable 然后扔到隊列里等刷新信號來的時候取出來執(zhí)行,沒錯吧琐谤。
那如果多個 View 發(fā)起了刷新請求蟆技,豈不是意味著會有多次遍歷繪制 View 樹的操作?
其實斗忌,這點不用擔心质礼,還記得我們在最開始分析 scheduleTraverslas()
的時候先跳過了一些代碼么?現(xiàn)在我們回過來繼續(xù)看看這些代碼:
我們上面分析的 scheduleTraversals()
干的那一串工作织阳,前提是 mTraversalScheduled 這個 boolean 類型變量等于 false 才會去執(zhí)行眶蕉。那這個變量在什么時候被賦值被 false 了呢:
只有三個被賦值為 false 的地方,一個是上圖的 doTraversal()
唧躲,還有就是聲明時默認為 false造挽,剩下一個是在取消遍歷繪制 View 操作 unscheduleTraversals()
里碱璃。這兩個可以先不去看,就看看 doTraversal()
饭入。還記得這個方法吧嵌器,就是在 scheduleTraversals()
中封裝到 Runnable 里的那個方法。
也就是說谐丢,當我們調(diào)用了一次 scheduleTraversals()
之后爽航,直到下一個屏幕刷新信號來的時候,doTraversal()
被取出來執(zhí)行庇谆。在這期間重復(fù)調(diào)用 scheduleTraversals()
都會被過濾掉的岳掐。那么為什么需要這樣呢?
其實饭耳,想想就能明白了串述。View 最終是怎么刷新的呢,就是在執(zhí)行 performTraversals()
遍歷繪制 View 樹過程中層層遍歷到需要刷新的 View寞肖,然后去繪制它的吧纲酗。既然是遍歷,那么不管上一幀內(nèi)有多少個 View 發(fā)起了刷新的請求新蟆,在這一次的遍歷過程中全部都會去處理的吧觅赊。這也是我們從代碼上看到的,每一個屏幕刷新信號來的時候琼稻,只會去執(zhí)行一次 performTraversals()
库正,因為只需遍歷一遍尼夺,就能夠刷新所有的 View 了腿短。
而 performTraversals()
會被執(zhí)行的前提是調(diào)用了 scheduleTraversals()
來向底層注冊監(jiān)聽了下一個屏幕刷新信號事件晕拆,所以在同一個 16.6ms 的一幀內(nèi),只需要第一個發(fā)起刷新請求的 View 來走一遍 scheduleTraversals()
干的事就可以了嘀掸,其他不管還有多少 View 發(fā)起了刷新請求紫岩,沒必要再去重復(fù)向底層注冊監(jiān)聽下一個屏幕刷新信號事件了,反正只要有一次遍歷繪制 View 樹的操作就可以對它們進行刷新了睬塌。
postSyncBarrier()---同步屏障消息
還剩最后一個問題泉蝌,scheduleTraversals()
里我們還有一行代碼沒分析。這個問題是這樣的:
我們清楚主線程其實是一直在處理 MessageQueue 消息隊列里的 Message揩晴,每個操作都是一個 Message勋陪,打開 Activity 是一個 Message,遍歷繪制 View 樹來刷新屏幕也是一個 Message文狱。
而且粥鞋,上面梳理完我們也清楚,遍歷繪制 View 樹的操作是在屏幕刷新信號到的時候瞄崇,底層回調(diào)我們 app 的 onVsync()
呻粹,這個方法再去將遍歷繪制 View 樹的操作 post 到主線程的 MessageQueue 中去等待執(zhí)行。主線程同一時間只能處理一個 Message苏研,這些 Message 就肯定有先后的問題等浊,那么會不會出現(xiàn)下面這種情況呢:
也就是說,當我們的 app 接收到屏幕刷新信號時摹蘑,來不及第一時間就去執(zhí)行刷新屏幕的操作筹燕,這樣一來,即使我們將布局優(yōu)化得很徹底衅鹿,保證繪制當前 View 樹不會超過 16ms撒踪,但如果不能第一時間優(yōu)先處理繪制 View 的工作,那等 16.6 ms 過了大渤,底層需要去切換下一幀的畫面了制妄,我們 app 卻還沒處理完,這樣也照樣會出現(xiàn)丟幀了吧泵三。而且這種場景是非常有可能出現(xiàn)的吧耕捞,畢竟主線程需要處理的事肯定不僅僅是刷新屏幕的事而已,那么這個問題是怎么處理的呢烫幕?
所以我們繼續(xù)回來看 scheduleTraversals()
:
在邏輯走進 Choreographer 前會先往隊列里發(fā)送一個同步屏障俺抽,而當 doTraversal()
被調(diào)用時才將同步屏障移除。這個同步屏障又涉及到消息機制了较曼,不深入了磷斧,這里就只給出結(jié)論。
這個同步屏障的作用可以理解成攔截同步消息的執(zhí)行捷犹,主線程的 Looper 會一直循環(huán)調(diào)用 MessageQueue 的 next()
來取出隊頭的 Message 執(zhí)行弛饭,當 Message 執(zhí)行完后再去取下一個。當 next()
方法在取 Message 時發(fā)現(xiàn)隊頭是一個同步屏障的消息時伏恐,就會去遍歷整個隊列孩哑,只尋找設(shè)置了異步標志的消息,如果有找到異步消息翠桦,那么就取出這個異步消息來執(zhí)行横蜒,否則就讓 next()
方法陷入阻塞狀態(tài)。如果 next()
方法陷入阻塞狀態(tài)销凑,那么主線程此時就是處于空閑狀態(tài)的丛晌,也就是沒在干任何事。所以斗幼,如果隊頭是一個同步屏障的消息的話澎蛛,那么在它后面的所有同步消息就都被攔截住了,直到這個同步屏障消息被移除出隊列蜕窿,否則主線程就一直不會去處理同步屏幕后面的同步消息谋逻。
而所有消息默認都是同步消息呆馁,只有手動設(shè)置了異步標志,這個消息才會是異步消息毁兆。另外浙滤,同步屏障消息只能由內(nèi)部來發(fā)送,這個接口并沒有公開給我們使用气堕。
最后纺腊,仔細看上面 Choreographer 里所有跟 message 有關(guān)的代碼,你會發(fā)現(xiàn)茎芭,都手動設(shè)置了異步消息的標志揖膜,所以這些操作是不受到同步屏障影響的。這樣做的原因可能就是為了盡可能保證上層 app 在接收到屏幕刷新信號時梅桩,可以在第一時間執(zhí)行遍歷繪制 View 樹的工作壹粟。
因為主線程中如果有太多消息要執(zhí)行,而這些消息又是根據(jù)時間戳進行排序摘投,如果不加一個同步屏障的話煮寡,那么遍歷繪制 View 樹的工作就可能被迫延遲執(zhí)行,因為它也需要排隊犀呼,那么就有可能出現(xiàn)當一幀都快結(jié)束的時候才開始計算屏幕數(shù)據(jù)幸撕,那即使這次的計算少于 16.6ms,也同樣會造成丟幀現(xiàn)象外臂。
那么坐儿,有了同步屏障消息的控制就能保證每次一接收到屏幕刷新信號就第一時間處理遍歷繪制 View 樹的工作么?
只能說宋光,同步屏障是盡可能去做到貌矿,但并不能保證一定可以第一時間處理。因為罪佳,同步屏障是在 scheduleTraversals()
被調(diào)用時才發(fā)送到消息隊列里的逛漫,也就是說,只有當某個 View 發(fā)起了刷新請求時赘艳,在這個時刻后面的同步消息才會被攔截掉酌毡。如果在 scheduleTraversals()
之前就發(fā)送到消息隊列里的工作仍然會按順序依次被取出來執(zhí)行。
界面刷新控制者--ViewRootImpl
最后蕾管,就是上文經(jīng)常說的一點枷踏,所有跟界面刷新相關(guān)的操作,其實最終都會走到 ViewRootImpl 中的 scheduleTraversals()
去的掰曾。
大伙可以想想旭蠕,跟界面刷新有關(guān)的操作有哪些,大概就是下面幾種場景吧:
- invalidate(請求重繪)
- requestLayout(重新布局)
- requestFocus(請求焦點)
- startActivity(打開新界面)
- onRestart(重新打開界面)
- KeyEvent(遙控器事件,本質(zhì)上是焦點導(dǎo)致的刷新)
- Animation(各種動畫掏熬,本質(zhì)上是請求重繪導(dǎo)致的刷新)
- RecyclerView滑動(頁面滑動佑稠,本質(zhì)上是動畫導(dǎo)致的刷新)
- setAdapter(各種adapter的更新)
- ...
在上一篇分析動畫的博客里,我們跟蹤了 invalidate()
孽江,確實也是這樣讶坯,至于其他的我并沒有一一去驗證番电,大伙有興趣可以看看岗屏,我猜測,這些跟界面刷新有關(guān)的方法內(nèi)部要么就是一個 do{}while() 循環(huán)尋找 mParent漱办,要么就是直接不斷的調(diào)用 mParent 的方法这刷。而一顆 View 樹最頂端的 mParent 就是 ViewRootImpl,所以這些跟界面刷新相關(guān)的方法娩井,在 ViewRootImpl 肯定也是可以找到的:
其實暇屋,以前我一直以為如果界面上某個小小的 View 發(fā)起了 invalidate()
重繪之類的操作,那么應(yīng)該就只是它自己的 onLayout()
, onDraw()
被調(diào)用來重繪而已洞辣。最后才清楚咐刨,原來,即使再小的 View扬霜,如果發(fā)起了重繪的請求定鸟,那么也需要先層層走到 ViewRootImpl 里去,而且還不是馬上就執(zhí)行重繪操作著瓶,而是需要等待下一個屏幕刷新信號來的時候联予,再從 DecorView 開始層層遍歷到這些需要刷新的 View 里去重繪它們。
總結(jié)
本篇篇幅確實很長材原,因為這部分內(nèi)容要理清楚不容易沸久,要講清楚更不容易,大伙如果有時間余蟹,可以靜下心來慢慢看卷胯,從頭看下來,我相信威酒,多少會有些收獲的窑睁。如果沒時間,那么也可以直接看看總結(jié)兼搏。
- 界面上任何一個 View 的刷新請求最終都會走到 ViewRootImpl 中的 scheduleTraversals() 里來安排一次遍歷繪制 View 樹的任務(wù)卵慰;
- scheduleTraversals() 會先過濾掉同一幀內(nèi)的重復(fù)調(diào)用,在同一幀內(nèi)只需要安排一次遍歷繪制 View 樹的任務(wù)即可佛呻,這個任務(wù)會在下一個屏幕刷新信號到來時調(diào)用 performTraversals() 遍歷 View 樹裳朋,遍歷過程中會將所有需要刷新的 View 進行重繪;
- 接著 scheduleTraversals() 會往主線程的消息隊列中發(fā)送一個同步屏障,攔截這個時刻之后所有的同步消息的執(zhí)行鲤嫡,但不會攔截異步消息送挑,以此來盡可能的保證當接收到屏幕刷新信號時可以盡可能第一時間處理遍歷繪制 View 樹的工作;
- 發(fā)完同步屏障后 scheduleTraversals() 才會開始安排一個遍歷繪制 View 樹的操作暖眼,作法是把 performTraversals() 封裝到 Runnable 里面惕耕,然后調(diào)用 Choreographer 的 postCallback() 方法;
- postCallback() 方法會先將這個 Runnable 任務(wù)以當前時間戳放進一個待執(zhí)行的隊列里诫肠,然后如果當前是在主線程就會直接調(diào)用一個native 層方法司澎,如果不是在主線程,會發(fā)一個最高優(yōu)先級的 message 到主線程栋豫,讓主線程第一時間調(diào)用這個 native 層的方法挤安;
- native 層的這個方法是用來向底層注冊監(jiān)聽下一個屏幕刷新信號,當下一個屏幕刷新信號發(fā)出時丧鸯,底層就會回調(diào) Choreographer 的onVsync() 方法來通知上層 app蛤铜;
- onVsync() 方法被回調(diào)時,會往主線程的消息隊列中發(fā)送一個執(zhí)行 doFrame() 方法的消息丛肢,這個消息是異步消息围肥,所以不會被同步屏障攔截住蜂怎;
- doFrame() 方法會去取出之前放進待執(zhí)行隊列里的任務(wù)來執(zhí)行穆刻,取出來的這個任務(wù)實際上是 ViewRootImpl 的 doTraversal() 操作;
- 上述第4步到第8步涉及到的消息都手動設(shè)置成了異步消息派敷,所以不會受到同步屏障的攔截蛹批;
- doTraversal() 方法會先移除主線程的同步屏障,然后調(diào)用 performTraversals() 開始根據(jù)當前狀態(tài)判斷是否需要執(zhí)行performMeasure() 測量篮愉、perfromLayout() 布局腐芍、performDraw() 繪制流程,在這幾個流程中都會去遍歷 View 樹來刷新需要更新的View试躏;
再來一張時序圖結(jié)尾猪勇,大伙想自己過源碼時可以跟著時序圖來颠蕴,建議在電腦上閱讀:
QA
Q1:Android 每隔 16.6 ms 刷新一次屏幕到底指的是什么意思泣刹?是指每隔 16.6ms 調(diào)用 onDraw() 繪制一次么椅您?
Q2:如果界面一直保持沒變的話,那么還會每隔 16.6ms 刷新一次屏幕么员舵?
答:我們常說的 Android 每隔 16.6 ms 刷新一次屏幕其實是指底層會以這個固定頻率來切換每一幀的畫面庄拇,而這個每一幀的畫面數(shù)據(jù)就是我們 app 在接收到屏幕刷新信號之后去執(zhí)行遍歷繪制 View 樹工作所計算出來的屏幕數(shù)據(jù)。而 app 并不是每隔 16.6ms 的屏幕刷新信號都可以接收到韭邓,只有當 app 向底層注冊監(jiān)聽下一個屏幕刷新信號之后措近,才能接收到下一個屏幕刷新信號到來的通知。而只有當某個 View 發(fā)起了刷新請求時仍秤,app 才會去向底層注冊監(jiān)聽下一個屏幕刷新信號熄诡。
也就是說,只有當界面有刷新的需要時诗力,我們 app 才會在下一個屏幕刷新信號來時,遍歷繪制 View 樹來重新計算屏幕數(shù)據(jù)我抠。如果界面沒有刷新的需要苇本,一直保持不變時,我們 app 就不會去接收每隔 16.6ms 的屏幕刷新信號事件了菜拓,但底層仍然會以這個固定頻率來切換每一幀的畫面瓣窄,只是后面這些幀的畫面都是相同的而已。
Q3:界面的顯示其實就是一個 Activity 的 View 樹里所有的 View 都進行測量纳鼎、布局俺夕、繪制操作之后的結(jié)果呈現(xiàn),那么如果這部分工作都完成后贱鄙,屏幕會馬上就刷新么劝贸?
答:我們 app 只負責計算屏幕數(shù)據(jù)而已,接收到屏幕刷新信號就去計算逗宁,計算完畢就計算完畢了映九。至于屏幕的刷新,這些是由底層以固定的頻率來切換屏幕每一幀的畫面瞎颗。所以即使屏幕數(shù)據(jù)都計算完畢件甥,屏幕會不會馬上刷新就取決于底層是否到了要切換下一幀畫面的時機了。
Q4:網(wǎng)上都說避免丟幀的方法之一是保證每次繪制界面的操作要在 16.6ms 內(nèi)完成哼拔,但如果這個 16.6ms 是一個固定的頻率的話引有,請求繪制的操作在代碼里被調(diào)用的時機是不確定的啊,那么如果某次用戶點擊屏幕導(dǎo)致的界面刷新操作是在某一個 16.6ms 幀快結(jié)束的時候倦逐,那么即使這次繪制操作小于 16.6 ms譬正,按道理不也會造成丟幀么?這又該如何理解?
答:之所以提了這個問題导帝,是因為之前是以為如果某個 View 發(fā)起了刷新請求守谓,比如調(diào)用了 invalidte()
,那么它的重繪工作就馬上開始執(zhí)行了您单,所以以前在看網(wǎng)上那些介紹屏幕刷新機制的博客時斋荞,經(jīng)常看見下面這張圖:
那個時候就是不大理解虐秦,為什么每一次 CPU 計算的工作都剛剛好是在每一個信號到來的那個瞬間開始的呢平酿?畢竟代碼里發(fā)起刷新屏幕的操作是動態(tài)的,不可能每次都剛剛好那么巧悦陋。
梳理完屏幕刷新機制后就清楚了蜈彼,代碼里調(diào)用了某個 View 發(fā)起的刷新請求,這個重繪工作并不會馬上就開始俺驶,而是需要等到下一個屏幕刷新信號來的時候才開始幸逆,所以現(xiàn)在回過頭來看這些圖就清楚多了。
Q5:大伙都清楚暮现,主線程耗時的操作會導(dǎo)致丟幀还绘,但是耗時的操作為什么會導(dǎo)致丟幀?它是如何導(dǎo)致丟幀發(fā)生的栖袋?
答:造成丟幀大體上有兩類原因拍顷,一是遍歷繪制 View 樹計算屏幕數(shù)據(jù)的時間超過了 16.6ms;二是塘幅,主線程一直在處理其他耗時的消息昔案,導(dǎo)致遍歷繪制 View 樹的工作遲遲不能開始,從而超過了 16.6 ms 底層切換下一幀畫面的時機电媳。
第一個原因就是我們寫的布局有問題了踏揣,需要進行優(yōu)化了。而第二個原因則是我們常說的避免在主線程中做耗時的任務(wù)匆背。
針對第二個原因呼伸,系統(tǒng)已經(jīng)引入了同步屏障消息的機制,盡可能的保證遍歷繪制 View 樹的工作能夠及時進行钝尸,但仍沒辦法完全避免括享,所以我們還是得盡可能避免主線程耗時工作。
其實第二個原因珍促,可以拿出來細講的铃辖,比如有這種情況, message 不怎么耗時猪叙,但數(shù)量太多娇斩,這同樣可能會造成丟幀仁卷。如果有使用一些圖片框架的,它內(nèi)部下載圖片都是開線程去下載犬第,但當下載完成后需要把圖片加載到綁定的 view 上锦积,這個工作就是發(fā)了一個 message 切到主線程來做,如果一個界面這種 view 特別多的話歉嗓,隊列里就會有非常多的 message丰介,雖然每個都 message 并不怎么耗時,但經(jīng)不起量多啊鉴分。后面有時間的話哮幢,看看要不要專門整理一篇文章來講卡頓和丟幀的事。
推薦閱讀(大神博客)
破譯Android性能優(yōu)化中的16ms問題
android屏幕刷新顯示機制
Android Choreographer 源碼分析
最近剛開通了公眾號志珍,想激勵自己堅持寫作下去橙垢,初期主要分享原創(chuàng)的Android或Android-Tv方面的小知識,感興趣的可以點一波關(guān)注伦糯,謝謝支持~~