作者:彭泰強(qiáng)
1 評(píng)價(jià)指標(biāo)&優(yōu)化成果
要做性能優(yōu)化,首先得知道性能怎么度量、怎么表示。因?yàn)樾阅苁且粋€(gè)很抽象的詞,我們必須把它量化、可視化。那么,因?yàn)槭荱I組件優(yōu)化,我首先選用了GPU呈現(xiàn)模式分析這一工具。
在手機(jī)上的開(kāi)發(fā)者模式里可以開(kāi)啟GPU呈現(xiàn)(渲染)模式分析這一工具,有的系統(tǒng)也把它叫hwui什么什么的,自己找一下,開(kāi)啟后,屏幕上會(huì)展示一個(gè)直方圖,直觀來(lái)看就是有很多豎條,每一個(gè)豎條代表一個(gè)渲染幀,這些豎條的高度代表渲染的每個(gè)階段渲染前一幀所用的相對(duì)時(shí)間。屏幕底端會(huì)有根綠線,代表的就是16.6ms,而一幀的渲染時(shí)間超出這個(gè)綠線,就有可能發(fā)生所謂的掉幀。再說(shuō)簡(jiǎn)單點(diǎn),每個(gè)豎條越低越好。
這些信息大致可以告訴我們:
- 1、當(dāng)前渲染一幀的時(shí)間是否在合理的范圍內(nèi)。
- 2、渲染的每個(gè)階段的耗時(shí),從而了解應(yīng)該優(yōu)化哪些方面來(lái)提高應(yīng)用的渲染性能。
關(guān)于這個(gè)工具就簡(jiǎn)單介紹到這里,更詳細(xì)的內(nèi)容可以自己看 官方文檔(官方文檔同樣也給出了對(duì)應(yīng)顏色豎條的排查問(wèn)題的思路)
。
那么,我們回到翻頁(yè)組件。在之前發(fā)布的v1.0.1版本中,繪制流程是先計(jì)算所有點(diǎn),構(gòu)建Path,然后老老實(shí)實(shí)的一個(gè)圖層一個(gè)圖層繪制(例如,先繪制最底層的下一頁(yè)內(nèi)容,再繪制翻起來(lái)的這一頁(yè)內(nèi)容,再繪制陰影,再繪制頁(yè)面光澤等等...)
,現(xiàn)在我們打開(kāi)工具,看一下,這一看簡(jiǎn)直嚇了一跳,如左圖所示。最底下的綠線是16.6ms,然而,一翻頁(yè)之后,滿屏的豎條出現(xiàn)了,這高度也太嚇人了,遠(yuǎn)超合理的范圍,每一幀都花費(fèi)了很多時(shí)間,相當(dāng)于嚴(yán)重掉幀了。這下,不得不開(kāi)始優(yōu)化了。
那么,經(jīng)過(guò)這一周的思考和優(yōu)化,最后的結(jié)果就是右圖了,雖然還有好幾個(gè)可以優(yōu)化的大點(diǎn),但是我懶得寫(xiě)了,就先優(yōu)化到這里吧,定為v1.1.0版本。
接下來(lái)講一下優(yōu)化的思路和過(guò)程供大家參考,如果有不足之處也歡迎討論和批評(píng)指正。
2 思考
那么現(xiàn)在知道了性能問(wèn)題很糟糕,想要優(yōu)化,首先得搞清楚能優(yōu)化哪些方面,然后是怎么去優(yōu)化,下面是一些思考的角度。
注:本節(jié)僅僅是思考的過(guò)程,具體的實(shí)現(xiàn)細(xì)節(jié)方案之類的在后面討論,甚至,可能有一些本節(jié)列出的優(yōu)化角度經(jīng)驗(yàn)證后會(huì)是不可實(shí)現(xiàn)或?qū)崿F(xiàn)成本巨大的,最后放棄了。
2.1 Compose
這個(gè)UI組件首先是一個(gè)Jetpack Compose編寫(xiě)的組件,那么Compose的部分是否存在能優(yōu)化的大項(xiàng)?
1. 是否存在過(guò)度重組
關(guān)于Compose,首先能想到的一點(diǎn)就是重組次數(shù)是否合理,即,是否存在過(guò)度重組、不必要的重組的情況。其實(shí)在之前的第一版代碼實(shí)現(xiàn)時(shí),我就考慮了這一點(diǎn),使用官方的Layout Inspector(默認(rèn)在Android Studio的右下角),可以去檢查每個(gè)@Composable重組的情況,確認(rèn)他們是否合理,所幸,并沒(méi)有發(fā)生過(guò)度重組的情況。這個(gè)優(yōu)化點(diǎn)直接跳過(guò)。
2. 降低重組頻率、提高重組速度
現(xiàn)在的事實(shí)是:由于手指每次哪怕移動(dòng)一丁點(diǎn),都會(huì)觸發(fā)手勢(shì)監(jiān)聽(tīng)(從log看我的小破測(cè)試機(jī)大概是10-12ms會(huì)觸發(fā)一次onDrag回調(diào))
,進(jìn)而觸發(fā)重組,然后重新進(jìn)行屏幕上所有點(diǎn)的計(jì)算(而這個(gè)計(jì)算是很耗時(shí)的)
,計(jì)算完后,再根據(jù)計(jì)算結(jié)果調(diào)用canvas API進(jìn)行繪制。
基于上面的這個(gè)事實(shí)流程,簡(jiǎn)單思考過(guò)后,便能發(fā)現(xiàn)幾個(gè)也許可以優(yōu)化的角度:
- 耗時(shí)計(jì)算能否放到子線程?那么耗時(shí)計(jì)算放到子線程計(jì)算完后,如何把結(jié)果給回Compose觸發(fā)UI更新?如果這個(gè)想法可以實(shí)現(xiàn),那么就可以提高重組的速度。
- onDrag的回調(diào)觸發(fā)是否過(guò)于頻繁了?也許沒(méi)必要這么頻繁地觸發(fā)手勢(shì)監(jiān)聽(tīng),也就是說(shuō),并非每次onDrag都去觸發(fā)耗時(shí)計(jì)算,而是有一個(gè)頻率上的降低,保證UI看起來(lái)還是連貫的即可。這樣可以直接降低重組頻率。
3、去掉重組中的多余代碼
這一點(diǎn)是很trick的一個(gè)點(diǎn),例如,如果你在一個(gè)可能會(huì)頻繁重組的@Composable塊中輸出了Log,甚至多條Log,一方面確實(shí)會(huì)影響重組的性能,另一方面,如果這些Log中含有State變量,甚至可能會(huì)導(dǎo)致不必要的重組發(fā)生。
因此,如果非要想在@Composable塊中用Log調(diào)試,請(qǐng)?jiān)谕瓿删幋a后把這些Log都刪掉或者注釋掉。
關(guān)于Compose部分,能優(yōu)化的點(diǎn)我暫時(shí)就想到這些。
2.2 Bitmap
下一個(gè)方向是Bitmap方向,因?yàn)檎麄€(gè)翻頁(yè)組件,不論是算法(例如扭曲算法、曲線邊緣算法)還是Compose側(cè)實(shí)現(xiàn)都與Bitmap有關(guān),那么肯定得好好盤(pán)一盤(pán)這個(gè)Bitmap相關(guān)的部分。
2.2.1 組件中有關(guān)Bitmap的部分
這里先簡(jiǎn)單提一下組件實(shí)現(xiàn)中涉及到Bitmap的部分,以免不知道后面的優(yōu)化部分在說(shuō)什么。
首先,為什么會(huì)用到Bitmap?因?yàn)橛幸粋€(gè)需求是要實(shí)現(xiàn)類似紙張翻起來(lái)的一個(gè)文字扭曲效果,如下圖右側(cè)所示。
這個(gè)扭曲的實(shí)現(xiàn)思路用到了Canvas的drawBitmapMesh方法,也就是說(shuō),組件要把任意自定義的@Composable內(nèi)容進(jìn)行“截屏”操作,繪制成一張Bitmap,然后對(duì)這張Bitmap調(diào)用drawBitmapMesh進(jìn)行扭曲(既然是“截屏”操作,這也就解釋了為什么組件支持顯示任意的非動(dòng)態(tài)的內(nèi)容)
。
在每次頁(yè)面內(nèi)容發(fā)生改變(例如當(dāng)前頁(yè)面內(nèi)容有變化、或者翻頁(yè)了)
時(shí),就要去重繪前一頁(yè)、當(dāng)前頁(yè)、后一頁(yè)這三張Bitmap,且這三張Bitmap都是同樣大小的,與組件大小一樣大。
組件中涉及Bitmap的部分就先說(shuō)到這里,接下來(lái)回到我們的優(yōu)化思路中。
1、Bitmap的Config
那么,首先想到的是與項(xiàng)目無(wú)關(guān)的,Bitmap本身的優(yōu)化,例如一些常見(jiàn)的思路:
- 加載圖片時(shí),對(duì)圖片進(jìn)行下采樣,減少加載的消耗,減少內(nèi)存占用,且提高加載速度
(這個(gè)思路我沒(méi)有去管,因?yàn)榻M件實(shí)現(xiàn)中,Bitmap都是由截屏操作生成的,就是屏幕大小,但其實(shí)感覺(jué)可能可以針對(duì)不同尺寸的屏幕去做適配?因?yàn)槿绻聊缓芨咔?,生成的圖片也會(huì)很大,但實(shí)際上也許不需要那么大。而因?yàn)槲覜](méi)有測(cè)試設(shè)備(我的設(shè)備是一個(gè)720*1600的低端機(jī)),所以這一塊暫時(shí)沒(méi)去管。)
- 既然是翻頁(yè)組件,那頁(yè)的紙張肯定是不透明的,既然如此,在截屏生成Bitmap時(shí),我們就不需要有透明度的Bitmap了,也就是Bitmap其實(shí)可以采用RGB565格式,而不用ARGB8888,這樣,內(nèi)存占用直接減小了一半,后續(xù)對(duì)Bitmap的操作速度也會(huì)快些。
- 關(guān)于下采樣,類似地,在drawBitmapMesh時(shí),會(huì)需要設(shè)置mesh的格點(diǎn)數(shù),同樣,減少這個(gè)格點(diǎn)數(shù)也會(huì)導(dǎo)致性能提高。
2、Bitmap的復(fù)用
既然組件后續(xù)繪制需要涉及到的Bitmap的數(shù)量是固定的,就只有3張(前一頁(yè)、當(dāng)前頁(yè)、后一頁(yè)),而且,實(shí)際的絕大部分場(chǎng)景下,翻頁(yè)組件的大小都是固定的,不會(huì)輕易變化,那么就可以想到這3張Bitmap其實(shí)可以復(fù)用,也就是繪制新的Bitmap時(shí)把新的像素直接覆蓋在原來(lái)Bitmap分配的內(nèi)存上,這樣就不用每次翻頁(yè)或者refresh頁(yè)面時(shí)都先recycle再重新create,只要組件大小不發(fā)生變化,就可以避免多余的內(nèi)存回收和再分配。
此外,既然說(shuō)到了復(fù)用,同樣也能聯(lián)想到一些其他的可復(fù)用的大對(duì)象,例如繪制時(shí)用到的Canvas和Path等,因?yàn)樗鼈兊膭?chuàng)建回收也是在native的,這樣可以減少創(chuàng)建和回收帶來(lái)的消耗。
2.3 繪制
作為一個(gè)UI組件,另一個(gè)思考方向就是UI組件的一些常見(jiàn)優(yōu)化點(diǎn)。
1、布局是否嵌套過(guò)深
如果組件的布局嵌套太深,肯定影響性能,但所幸這個(gè)組件并沒(méi)有這個(gè)情況(PTQBookPageViewInner中也只有一個(gè)Box和Canvas)
,所以這個(gè)優(yōu)化點(diǎn)直接跳過(guò)。
2、是否存在過(guò)度繪制
過(guò)度繪制就是指,由于代碼編寫(xiě)不當(dāng),導(dǎo)致同一個(gè)像素點(diǎn)被反復(fù)更新。我們只能看見(jiàn)最上面的圖層,因此可以去考慮是否存在大量的被掩蓋的區(qū)域,既然這些區(qū)域是不可見(jiàn)的,那它們本身就不應(yīng)該被繪制。
這里同樣有一個(gè)工具,系統(tǒng)自帶的,叫 調(diào)試GPU過(guò)度繪制,在開(kāi)發(fā)者選項(xiàng)中打開(kāi)這個(gè)工具,它就會(huì)在屏幕上顯示我們過(guò)度繪制的區(qū)域。
現(xiàn)在讓我們看看優(yōu)化前的區(qū)域(下左圖),一片紅,那么基本上可以確定了,這也是一個(gè)可優(yōu)化的大點(diǎn)。
在實(shí)際開(kāi)發(fā)時(shí),有一些過(guò)度繪制是無(wú)法避免的,因此我們要做的就是盡可能地減少過(guò)度繪制,在思考優(yōu)化方案過(guò)后,做到了下右圖的效果,減少了一些過(guò)度繪制,提高了性能。
3、耗時(shí)的繪制
一些Canvas和Path的API可能會(huì)相對(duì)來(lái)說(shuō)比較耗時(shí),我們應(yīng)該盡可能減少此類API的調(diào)用
目前我能想到的可優(yōu)化的點(diǎn)和思路就是這些,下面開(kāi)始實(shí)現(xiàn)。
3 實(shí)現(xiàn)
一些細(xì)節(jié)就不提了,例如什么Path的復(fù)用之類的,本節(jié)就講一些主要的部分。
3.1 BitmapController優(yōu)化
這個(gè)部分主要的改動(dòng)是Bitmap的復(fù)用,以及Bitmap的create流程(這個(gè)是代碼上的優(yōu)化,不涉及性能)
。
在PTQBookPageBitmapController內(nèi),使用一個(gè)大小為3的數(shù)組作為Bitmap的復(fù)用池。
private val bitmapBuffer = arrayOfNulls<Bitmap?>(3)
在AbstractComposeView重寫(xiě)的dispatchDraw中調(diào)用controller的renderAndSave,而renderAndSave會(huì)提供一個(gè)Canvas,這個(gè)Canvas已經(jīng)把Bitmap準(zhǔn)備好了,如果可以繪制,則由super.dispatchDraw繪制。
override fun dispatchDraw(canvas: Canvas?) {
controller.renderThenSave(width, height) {
super.dispatchDraw(it)
}
}
看看renderThenSave的實(shí)現(xiàn)。
fun renderThenSave(width: Int, height: Int, render: (drawable: Canvas) -> Unit) {
//如果不再需要bitmap,則不再繪制了
if (needBitmapPages.isEmpty() || width <= 0 || height <= 0) {
return
}
//當(dāng)前需要繪制第幾頁(yè)的
val first = needBitmapPages.first()
//這里判斷是否需要重新創(chuàng)建Bitmap而不是從復(fù)用池去取
var needNew = false
if (bitmapBuffer[first.second] == null) {
needNew = true
} else {
//新的大小發(fā)生變化(因?yàn)閏onfig不變,所以bitmap的大小可以認(rèn)為只受width, height影響,而不再去計(jì)算allocationByteCount)
bitmapBuffer[first.second]!!.let {
if (width != it.width || height != it.height) {
it.recycle()
needNew = true
}
}
}
//如果需要新創(chuàng)建,則創(chuàng)建一個(gè)RGB565格式的Bitmap
if (needNew) {
bitmapBuffer[first.second] = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
}
canvas.let {
//Canvas設(shè)置Bitmap以在dispatchDraw時(shí)把內(nèi)容繪制到Bitmap上
it.setBitmap(bitmapBuffer[first.second]!!)
//一切準(zhǔn)備就緒后,才會(huì)回調(diào)render,讓dispatchDraw給Bitmap填充內(nèi)容
render(it)
//記得清掉引用
it.setBitmap(null)
}
//如果還需要繪制下一張,則繼續(xù),否則流程終止
needBitmapPages.removeFirst()
if (needBitmapPages.isEmpty()) return
exeRecompositionBlock?.let { it() }
}
Bitmap復(fù)用的邏輯就說(shuō)到這里,接下來(lái)我們來(lái)說(shuō)說(shuō)過(guò)度繪制的優(yōu)化。
3.2 繪制優(yōu)化
在2.3節(jié)的第2點(diǎn)中我們提到,過(guò)度繪制是一個(gè)可優(yōu)化大項(xiàng),同時(shí)第3點(diǎn)中提到,Path太大也會(huì)影響Path API的調(diào)用耗時(shí)。因此這一小節(jié)主要對(duì)這兩個(gè)情況進(jìn)行優(yōu)化。
對(duì)于過(guò)度繪制,我檢查了繪制的代碼,對(duì)于有重疊的繪制區(qū)域則盡可能地減少重復(fù)區(qū)域的繪制,這一部分的具體代碼就不貼了,代碼都在PTQBookPageViewInner的Canvas這個(gè)@Composable中。
關(guān)于過(guò)度繪制這部分,我本來(lái)想直接用一張Bitmap來(lái)“合成”扭曲圖和底圖的,這樣就可以減少繪制次數(shù),但經(jīng)過(guò)嘗試后失敗了。代碼中注釋掉的有關(guān)synthesizedBitmap的部分就是這個(gè)失敗的嘗試。
而對(duì)于Path太大的問(wèn)題,我們思考一下,根據(jù)我們抽象出來(lái)的頁(yè)面點(diǎn)模型,當(dāng)頁(yè)面接近垂直狀態(tài)時(shí),這時(shí)Path的端點(diǎn)會(huì)向上延伸到很遠(yuǎn)很遠(yuǎn)處,我用log看過(guò)了,甚至有的點(diǎn)的y坐標(biāo)到了20-30萬(wàn),這就很夸張了,所以Path太大的主要影響因素就是垂直的時(shí)候,因此我們需要對(duì)部分方便計(jì)算(有的地方是曲線不太好計(jì)算,得設(shè)計(jì)算法但我懶得想了)的繪制圖層進(jìn)行專門(mén)的垂直處理,以buildPath函數(shù)中的shadow3的Path的構(gòu)建為例,我針對(duì)越界的線與組件邊框范圍求了交點(diǎn),以避免過(guò)大的Path點(diǎn)出現(xiàn),代碼如下。
//shadow3
pathResult.shadowPaths[2].apply {
moveTo(W)
lineTo(S1)
//若接近垂直,則直接畫(huà)成矩形,否則畫(huà)梯形
if (((T1.y - O.y) / (C.y - O.y)).absoluteValue > shadow3VerticalThreshold) {
lineTo(S1.copy(y = (C.y - O.y).absoluteValue - S1.y))
lineTo(W.copy(y = (C.y - O.y).absoluteValue - W.y))
} else {
/**
* @since v1.1.0 越界繪制優(yōu)化:如果Z在BC內(nèi),則直接畫(huà)線,否則求交點(diǎn)
*/
//給一組log數(shù)據(jù)供參考
//buildPath: C.y:0 O.y:1600 upsideDown:true W: Point(x=376.90134, y=0.0) S1: Point(x=523.4354, y=0.0) T1: Point(x=720.0, y=36051.1) Z: Point(x=720.0, y=62926.297)
//buildPath: C:1600.0 O.y:0 upsideDown:false W: Point(x=380.06815, y=1600.0) S1: Point(x=526.1546, y=1600.0) T1: Point(x=720.0, y=-46201.938) Z: Point(x=720.0, y=-82226.625)
val S1T1_OBx = Line.withKAndOnePoint(lST.k, S1).x(O.y) //S1T1交OB的x坐標(biāo)
val WZ_OBx = Line.withKAndOnePoint(lST.k, W).x(O.y)
lineTo(if (S1T1_OBx > C.x) T1 else Point(S1T1_OBx, O.y))
lineTo(if (S1T1_OBx > C.x) Z else Point(WZ_OBx, O.y))
}
close()
}
3.3 未實(shí)現(xiàn)的優(yōu)化
這一部分記錄一下未實(shí)現(xiàn)或者失敗了的優(yōu)化,但是思路我覺(jué)得可能還是會(huì)有點(diǎn)用的。
1、native層進(jìn)行圖片合成
如果說(shuō)有兩張圖片想左右拼接,或者四張圖片想左右拼接,而又比較吃性能的話,可以考慮ndk開(kāi)發(fā),直接在native層操縱圖片的像素,但我這里失敗了,因?yàn)槲倚枰扔胏anvas API對(duì)圖片處理,再去操縱像素則更沒(méi)必要了。
這里提一嘴,如果要手動(dòng)把RGB565的圖片轉(zhuǎn)為ARGB8888,每個(gè)像素的轉(zhuǎn)換方法。
RGB565是一個(gè)像素16位,從高到低分別是R5位,G6位,B5位,而ARGB8888則是32位,每個(gè)字節(jié)8位,但這里有個(gè)坑,ARGB8888從高到低分別是ABGR。
代碼如下:
static uint32_t rgb565PixelToArgb8888(uint16_t pixel) {
uint8_t r = ((pixel >> 11) & 0x1F) * 0xff / 0x1f;
uint8_t g = ((pixel >> 5) & 0x3F) * 0xff / 0x3f;
uint8_t b = (pixel & 0x1F) * 0xff / 0x1f;
return 0xff << 24 | (b & 0xff) << 16 | (g & 0xff) << 8 | (r & 0xff);
}
2、Compose中開(kāi)子線程計(jì)算,同時(shí)降低手勢(shì)的回調(diào)頻率
這個(gè)就是2.1節(jié)第2點(diǎn)提到的優(yōu)化思路,我沒(méi)去做,因?yàn)樘珣辛恕?shí)現(xiàn)的思路大概是在手勢(shì)觸發(fā)后,起一個(gè)其他線程的協(xié)程去進(jìn)行復(fù)雜計(jì)算,然后有結(jié)果了就直接用flow(collectAsState)發(fā)送給@Composable中的state變量,導(dǎo)致UI更新。而限制頻率可以嘗試用flow的debounce方法。
這一部分我沒(méi)有去實(shí)現(xiàn),因此上面僅是個(gè)設(shè)想,可能實(shí)際操作還會(huì)有其他的問(wèn)題,不過(guò)也算給個(gè)思路供參考吧。
4 結(jié)語(yǔ)
目前的組件優(yōu)化到了一個(gè)能用的程度了,文中也說(shuō)了,其實(shí)還能進(jìn)一步優(yōu)化,比如耗時(shí)計(jì)算放到新線程,或者改用C++重寫(xiě),應(yīng)該還能優(yōu)化一些,但是懶得去實(shí)現(xiàn)了。
這一趟優(yōu)化下來(lái)也確實(shí)令我學(xué)到了不少東西,已經(jīng)收獲滿滿了,不過(guò)學(xué)習(xí)的腳步不能停下,還有很多細(xì)節(jié)是需要學(xué)習(xí)的,一步步來(lái)吧。
對(duì)于復(fù)雜UI的優(yōu)化,希望文中的一些思路能幫到大家,就寫(xiě)到這里好了。文章來(lái)源:http://www.zghlxwxcb.cn/news/detail-628703.html
Android 學(xué)習(xí)筆錄
Android 性能優(yōu)化篇:https://qr18.cn/FVlo89
Android 車(chē)載篇:https://qr18.cn/F05ZCM
Android 逆向安全學(xué)習(xí)筆記:https://qr18.cn/CQ5TcL
Android Framework底層原理篇:https://qr18.cn/AQpN4J
Android 音視頻篇:https://qr18.cn/Ei3VPD
Jetpack全家桶篇(內(nèi)含Compose):https://qr18.cn/A0gajp
Kotlin 篇:https://qr18.cn/CdjtAF
Gradle 篇:https://qr18.cn/DzrmMB
OkHttp 源碼解析筆記:https://qr18.cn/Cw0pBD
Flutter 篇:https://qr18.cn/DIvKma
Android 八大知識(shí)體:https://qr18.cn/CyxarU
Android 核心筆記:https://qr21.cn/CaZQLo
Android 往年面試題錦:https://qr18.cn/CKV8OZ
2023年最新Android 面試題集:https://qr18.cn/CgxrRy
Android 車(chē)載開(kāi)發(fā)崗位面試習(xí)題:https://qr18.cn/FTlyCJ
音視頻面試題錦:https://qr18.cn/AcV6Ap
文章來(lái)源地址http://www.zghlxwxcb.cn/news/detail-628703.html
到了這里,關(guān)于Android復(fù)雜UI的性能優(yōu)化實(shí)踐 - PTQBookPageView 性能優(yōu)化記錄的文章就介紹完了。如果您還想了解更多內(nèi)容,請(qǐng)?jiān)谟疑辖撬阉鱐OY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!