大麥 Android 選座場景效能最佳化全解析

大麥 Android 選座場景效能最佳化全解析

作者:

於世雷(藍徹)

通常情況下移動端APP由於受到裝置效能所限一般較少有場景會處理超量資料,更多的是將複雜資料處理交付給服務端。本質上降低終端強資料處理是很有必要的,降低CPU使用率、減少記憶體抖動可以大幅提升APP使用體驗。但是有時移動端也不得不處理超量資料,大麥選座就是這樣一個強資料處理場景。

大麥 Android 選座場景效能最佳化全解析

那麼

選座場景具體面對的是怎樣的超量資料呢?

上圖是測試過程中使用的“奧運體育場”在大麥APP選座場景下繪製的UI,這個體育場包含了140+個看臺,6萬+個可售座位。我們需要建立資料模型來描述一個座位,它的基本要素會包含座位id、價格id、座標、座位角度、座位位置、座位狀態 等等。結合業務場景,在購票流程中的變與不變,我們可以將座位拆分成2套資料:

座位靜態資料,它包含座位相對不變的部分,比如:座位id、價格id、座標、座位角度、座位位置。

座位動態資料,即座位狀態。它是變化最為頻繁的,比如:使用者下單時座位從可售狀態變更為已售狀態,使用者取消時座位會從已售狀態迴歸到可售狀態。

假設我們使用XML來描述座位靜態資料,使用JSON來描述座位動態資料,那麼上述體育場6萬+個座位對應的座位靜態資料的檔案大小為9。6M,座位動態資料的檔案大小為1。8M。

想象一個場景:擁有6萬+座位的超大體育場、偶像級歌手、售前幾十萬人關注、實時線上選座分鐘級別售罄,靜態資料的下載、動態資料的重新整理、資料的解析與合成,不管對於服務端還是客戶端都會是一場考驗。當然選座場景自然是不會直接傳輸這樣未經過壓縮的資料,但這也從側面說明了選座場景的資料量級,也就是說選座移動端必須要有能力快速、高效處理數以萬計座位的解析、合成、渲染,以保障超級場館實時線上搶票。

下面我總結了一些核心的提升選座場景使用者體驗和支撐超大場館實時線上選座的方案和策略。

介面預載入策略

選座場景相對於其他大麥業務使用到了更多的網路介面及CDN下載,從SKU頁進入一個選座頁最少要使用5次網路請求才能最終合併成選座UI。

渲染時:

Areainfo 網路介面 ,選座引導介面主要描述靜態看臺資訊、靜態檔案cdn資訊、開關資訊等;

StaticSeat CDN下載,靜態座位檔案網路下載;

StaticSvg CDN下載,靜態SVG檔案網路下載;

DynamicInfo 網路介面,場次資訊、票檔實時資訊;

Seatstatus 網路介面,座位實時狀態資訊。

重新整理時:

DynamicInfo 網路介面,場次資訊、票檔實時資訊;

Seatstatus 網路介面,座位實時狀態資訊。

選座中:

Precheck 網路介面,預鎖座功能;

CalcPrice 網路介面,實時算價功能。

已知渲染需要至少使用到”渲染時“標出的5個請求,正如木桶效應一樣,選座場景的頁面載入時長取決於最後一個介面的返回,而通常介面RT取決於網路繁忙程度、網路狀況、服務端處理能力等,客戶端要做的是儘可能將這些網路請求分類、前置化處理,降低網路請求併發對CPU瞬時負載。

靜態資料預載入

這裡的靜態主要主要是指“Areainfo”引導資料、”靜態座位檔案“、”靜態SVG檔案“的預載入策略,特點:他們通常在一個專案上線後很少發生變化,儲存於CDN伺服器中。靜態資料預載入策略 就是在進入選座場景之前的鏈路如SKU(場次與票檔選擇頁)進行閒時下載。這樣可以前置 ”Areainfo“、”StaticSeat“、”StaticSvg“ 請求。

網路介面預載入

一般在Android App開發過程中我們會在Activity。onCreate()方法中觸發網路請求,完成頁面渲染。在測試的過程中我們發現像選座Activity這樣的頁面,從startActivity()方法呼叫到目標activity。onCreate()之間存在50-60ms的排程、建立時間。所以我們將”DynamicInfo“、”Seatstatus“前置到startActivity(),即啟動選座場景時立即發出以上2個請求。

這樣就可以將需要併發的請求打散、按其特點進行分類、分階段執行。即可利用靜態資料不易變更的特性進行前置下載,也兼顧到動態資料的實時性獲取。

靜態資料快取

正如上述提及的”奧運體育場“,其包含了數以萬計的座位資訊、複雜的SVG檔案。解析合成都是較為耗時的操作,如果只是使用一次隨即丟棄肯定是非常浪費資源的。在選座場景搶票過程中,使用者可能會在”售前“、”售中“反覆進入”商品詳情“、”SKU“、”選座“頁面,為這類靜態資料做快取策略是非常有必要的,這可以大幅降低CDN下載壓力,提升SVG、座位結構物件的複用率,降低CPU記憶體抖動程度。

選座場景提供了BaseLoader、策略性Cache對全場景進行資料預載入、記憶體快取管理,透過對軟引用的使用,即保持了靜態資料高效可複用,也避免由於記憶體緊張而出現強未使用態的強引用靜態資料無法及時釋放的情況。

ImageLoader,支援SVG、JPG載入、快取

Seat3DVrImageDataLoader,支援VR影象下載、解密等

Seat3DVrDataLoader,支援VR結構化資料下載、解密等

SeatLoader,支援多種靜態座位資料的下載、解密、快取等

靜態資料壓縮

靜態座位資料壓縮

靜態座位資料壓縮方案,內部代號“Quantum”是大麥自研的一套針對選座靜態座位資料進行壓縮解壓的一套解決方案,其主要組成部分包含了核心解壓縮演算法、加密解密方案、資料完整性校驗等。

觀察靜態座位資料的結構,可以總結出一些特點:包含大量的數值型欄位如Long型的座位id、Int型的座位編號、Int型的座位角度、Long型的價格id、Int型的座標 以及批次重複的看臺號、排號等。數值型欄位非常適合使用差值法組合Ziazag整數演算法進行壓縮,批次重複性的看臺、排號等資訊非常適合字典方式簡化儲存。Zigzag的核心思想就是透過位運算、原碼及補碼的轉換移除數值型記憶體二進位制中“無意義的0”,僅儲存從1開始的“有效資料”,從而達成對資料的壓縮,數值絕對值越小壓縮比率越高。

以下是關於“Quantum”方案與原大麥選座場景靜態資料方案的對比。

大麥 Android 選座場景效能最佳化全解析

壓縮效率:相比於”XML+GZIP“,“Quantum+GZIP”壓縮後的靜態檔案縮減了70%以上。

解析速度:相比於”XML Pull“解析方案,“Quantum”座位解析時長總體縮減了80%以上。

靜態VR資料壓縮

靜態VR資料指的是一套描述看臺-座位-關聯的VR資料資訊的一套資料,大麥選座場景使用了Google Protocol Buffers 來儲存VR結構化資料,以”北京某劇院“ 的VR資料為例 使用JSON檔案來描述 VR結構資料的檔案大約為 640KB,當使用Protocol Buffers時 檔案的大小約為250KB,而經過GZIP壓縮後僅有8KB大小。

壓縮效率:相比於使用JSON描述VR結構化資料,使用Protocol Buffers檔案大小縮減了35%以上。實際上Protocol Buffers內部使用了Ziazag整數壓縮演算法,VR結構化資料中存在座位id屬於Long型,Protocol 雖然可以進行一定程度的壓縮,但由於其演算法對整型絕對值越小壓縮比率越高的特點,我們仍然可以透過差值法進一步縮減Protocol 壓縮比率,但從GZIP壓縮後的結果來看已經足夠小了。暫時未啟動進一步最佳化。

相關連結:

Google Developers | Protocol Buffers[1]

座位動態資料壓縮

座位的動態資料,這裡指的就是座位的狀態。假設使用 2代表可售、8代表已售,早期大麥選座場景使用JSON來描述”座位id“與”狀態“的對映關係。假定我們使用JSON格式以文字檔案來儲存“奧運體育場”座位狀態,6萬+個座位對應的狀態檔案的大小為1。8M。

1。8M看起來並不算很大,但對於先前提到的超級火爆的專案來說,開搶瞬間會出現龐大的目標使用者在秒級區間湧入選座場景,伺服器要想在極短時間處理如此高併發和資料分發所面臨的壓力是可想而知的,即便傳輸過程中使用了壓縮方案。

因此早期使用JSON資料來表達座位狀態的選座場景不得不從緩解服務端壓力的角度改變請求策略:即“座位狀態分組請求”,進入選座頁按服務端給定的看臺分組依次、延時進行請求,比如:把6萬+個座位 按5000左右一組,每隔50ms請求一批,直到所有座位更新完畢。這意味著一個6萬+座位的場館僅其狀態的請求就可能長達600ms以上,加之其他請求、檢視渲染,這類超大型場館的選座場景很難在1s以內完整載入並渲染完畢。

因此我們必須找到新的方案,它應該具備以下特徵:一次請求可以獲得全部座位狀態、請求的資料量要足夠的小、客戶端裝置解析的效能要足夠的快。

座位動態壓縮策略

所以選座場景推出了動態壓縮方案,它本質上並不複雜,觀察原始1。8M JSON檔案,它的”大“主要是因為大量sid(座位id)的存在,JSON本身包含了一下冗餘結構。

如果移除這些sid呢?

假定座位如果在“靜態座位檔案”中是有序的,那麼服務端就可以按照座位順序堆積2、8狀態(2代表可售、8代表已售)。比如一個看臺編號為“3538263” 它有7個座位,座位狀態依次是 “2222228”,那麼它就可以由“繁複”的JSON的一部分轉變為 :“3538263”:“2222228”

而一旦某個狀態連續出現6個及以上時就可以縮寫為 (x,s),上述case即可以縮寫為 “3538263”:“(6,2)8”。字元的數量是浮動的,因此我們叫它動態壓縮方案。

大麥 Android 選座場景效能最佳化全解析

對比原JSON方案,我們考慮一個最為複雜的場景“奧運體育場”場館座位,相鄰的座位總是一個可售一個不可售,動態壓縮無法進行縮寫最佳化,那麼使用JSON來返回6萬+座位的文字檔案大小約0。9M (即1。8M的二分之一,因為僅需要返回可售的即可)。如果使用動態壓縮方案文字的大小約67KB,我們可以簡化的認為這個檔案中儲存了6萬+個連續”2“和”8“,額外附加一下看臺id資訊。不難看出即便是最複雜的場景動態壓縮方案描述狀態的資料量也是非常小的。

那什麼場景會觸發極簡壓縮呢?

想象如下一個選座場景,剛剛開售,所有座位均未被售出,那麼動態壓縮方案就會處於極簡壓縮狀態,舉個例子: {”3538263“:”5000,2“} 即代表”3538263“看臺下的5000個座位均為可售~ 

以測試使用的“奧運體育場”最複雜場景下估算

壓縮效率:座位狀態動態壓縮檔案相比於JSON文字縮減了90%以上資料傳輸量。

解析速度:在該場景下使用一加7Pro:FastJson還原6萬+座位耗時200ms,使用動態解壓縮方案僅耗時5ms,解析時長縮短了95%以上。

檢視層級最佳化

大麥 Android 選座場景效能最佳化全解析

ViewStub

從上圖”佈局檔案“中,選座場景是大麥中使用ViewStub最多的地方,一般可以被懶載入的檢視均被轉換成ViewStub,相比於複雜的”暫不可見“檢視 ,ViewStub及其簡潔,減少初始化渲染時View物件的建立和排版。

檢視層級

從”Layout Inspector“可以看到選座場景的佈局是相對簡潔的,自上而下。”開啟過度繪製“全屏處於淺綠色。為了減少檢視層級,選座場景把App共用基類Activity多出用於Title、錯誤頁等工具性佈局都移除掉了,自身提供了非通用,但更簡潔的佈局,以減少檢視層級結構。

繪製效能最佳化

大麥 Android 選座場景效能最佳化全解析

座位點陣圖複用

圖1 為北京某場館,座位近3000個。我們可以看到一個選座場景一般會包含多個票檔,座位的顏色與其票檔的顏色一致,一個座位最終如何展示是由:color (座位顏色)、angle(座位角度)、type(座位型別:已售、可售、鎖定)、addAlpha(是否已選中)共同決定的,座位的點陣圖是在繪製時根據上面4個要素實時透過SVG繪製出來的,相同4要素的座位會複用已經建立的同一點陣圖。

原始方案把座位4要素組成一個字串作為Key,透過HashMap進行座位點陣圖複用,虛擬碼如下:

private final HashMap mSeatPool = new HashMap();public Bitmap get(int color, float angel, int type, boolean addAlpha) { int angelInt = (int) angel; String key = ”key_“ + color + ”_“ + angelInt + ”_“ + addAlpha + ”_“ + type; Bitmap seat = mSeatPool。get(key); if (seat == null) { seat = newBitmap(color, angel, type, addAlpha); mSeatPool。put(key, seat); } return seat;}

點陣圖複用的思路是正確的,但是原方案使用的字串作為Key帶來2個問題,1是字串拼接的本質是StringBuilder物件的建立;2是字串拼接的字元數量>16個會觸發StringBuilder的ensureCapacityInternal方法;以上述場館為例每次touch事件導致的繪製都會建立近3000個StringBuilder物件、並觸發其擴充API。反覆滑動時必然會導致記憶體快速增長、GC的頻繁發生。

我首先想到類似的場景就是Android 檢視測量中使用到的View。MeasureSpec,MeasureSpec將int值高2位儲存為Mode,低30位儲存為size。利用相同的思路作用於點陣圖複用是非常適用的,最佳化後選座使用Long值作為Key儲存座位4要素,高8位儲存點陣圖addAlpha(是否選中),9-16位儲存type(座位型別:已售、可售、鎖定),17-32位儲存angle(座位角度),最後32位儲存color(座位顏色),並且使用Android系統最佳化過的LongSparseArray替代HashMap。這有幾個好處,Key值的計算全程使用位運算,運算速度足夠的快,反覆滾動時不會再觸發StringBuilder物件的建立和擴充,記憶體不會出現激增現象,檢視滑動平均幀率提升了4幀以上。

大麥 Android 選座場景效能最佳化全解析

利用Android Studio Profiler效能分析工具,對點陣圖複用前後進行對比:

最佳化前的方案在選座檢視滑動時存在了大量的StringBuilder物件建立,導致Profiler Method檢視存在大量鋸齒。最佳化後不會在出現大量鋸齒,onDraw()中不會在出現物件建立場景。

從記憶體的角度上看,最佳化前滑動時會導致記憶體快速增長,從圖中可以看到很快即有270M增長到328M,並觸發一輪GC,而這樣的事情會在反覆滑動、縮放動畫中一直出現,這種記憶體的抖動勢必會影響到繪製效能,雖然GC幾經Google最佳化,但仍然避免不了”stop the world“。最佳化後我們可以看到不管如何滑動記憶體始終保持在270M左右。

繪製提效最佳化

如果只是一張座位的點陣圖,其繪製成本是非常低的。但是當量級足夠大時,繪製的效能將受到影響,以圖1北京某場館為例,一次onDraw()就需要繪製近3000次座位點陣圖,這對於客戶端裝置是有一定挑戰的。因此選座場景針對大場館,初始化展示時僅展示看臺圖,當用戶選中某個看臺時會自動放大適配螢幕,將處於螢幕中的看臺下的座位及其周邊可見的座位實時繪製出來。針對小場館,僅出現在螢幕中的座位會被繪製。這種小技巧對提升繪製效率非常有效。

角度計算最佳化

圖3所示,可以看到座位可能存在角度,其一般指向舞臺中心。端裝置在實時繪製座位時,需要實時計算放大縮小比率結合座位偏轉角度才能最終計算出座位的大小。

早期選座場景使用Matrix來計算角度問題,平時的繪製我們也可能使用Matrix進行相關計算,但無疑在這個地方Matrix矩陣計算是浪費效能的。相反最佳化後使用Math三角函式進行計算大幅度提升了帶角度的座位大小計算效能。

浮點資料計算最佳化

當座位大小實時計算完畢,我們發現在使用Canvas進行繪製時,如果將float轉換成int時,會帶來非常好的效能提升。但這也會帶來一定的體驗問題,為什麼呢?如果不做任何處理即將浮點轉成整型,實時滑動時就可能會出現座位由於精度問題而產生抖動。因此選座場景在這裡做了近似處理,在合理閾值內進行int轉換,即利用整型值計算優勢同時將抖動降低到不明顯可接受程度。

硬體加速

早期的選座場景並未開啟硬體加速,你可能會驚訝於檢視為什麼沒有開啟硬體加速,因為絕大多數場景我們並沒有主動關心過硬體加速,因為系統預設的View和一般的自定義View預設就是開啟的。

為什麼早期的選座場景要主動關閉硬體加速呢?

從選座場景上看我們使用了大量的SVG,無論是場館底圖、場館圖、座位點陣圖都使用了SVG進行繪製,我們可以簡單的把一個SVG看成一個有序的、可繪製的指令集合,在Android中我們需要一個簡單的可被複用的繪製“容器”,而這個“容器”就是android。graphics。Picture。檢視Picture的類描述我們會發現它真的很適合用來記錄可繪製指令,但它仍然存在一些問題,以下是類官方描述:

A Picture records drawing calls (via the canvas returned by beginRecording) and can then play them back into Canvas (via draw(Canvas) or Canvas。drawPicture(Picture))。For most content (e。g。 text, lines, rectangles), drawing a sequence from a picture can be faster than the equivalent API calls, since the picture performs its playback without incurring any method-call overhead。

Note: Prior to API level 23 a picture cannot be replayed on a hardware accelerated canvas。

從類描述中的“Note”可以看到在API Level 23以下的裝置無法使用硬體加速,查閱Google開發者文件也可以看到,下方圖片是Google對可支援硬體加速繪製指令、以及第一個支援的Api Level進行了一段描述。可以發現在API 28是一個分水嶺,絕大多數指令在28及以上得到了支援。

大麥 Android 選座場景效能最佳化全解析

硬體加速方案:

最佳化方案對API Level進行區分即可,28以下的裝置仍然保持早期的使用軟體層進行繪製的方案,對始終不支援硬體加速的繪製指令進行了評估確保我們的生產SVG工具中不會出現這些指令,並添加了遠端開關以確保遇到問題可實時關閉硬體加速功能,當裝置API LEVEL >=28時開啟硬體加速。此後選座Android場景的滑動平均幀率終於在這個階段突破了50FPS。

相關連結:

Android開發者 | 硬體加速[2]

執行緒任務處理

在介面的預載入策略中有提到選座場景對選座”渲染時“使用到的請求根據其請求的資料特點、時效要求進行了分階段、策略性前置。使得渲染時不會在選座頁面的onCreate()同時併發5個以上的網路請求。從效能分析工具Perfetto我們可以看到主執行緒、靜態座位資料下載解析執行緒、靜態SVG下載解析執行緒等的時間軸即任務處理情況。

從Perfetto中我可以觀察到分散的策略是生效的,靜態座位、SVG在SKU頁閒時觸發了下載並完成了解析、記憶體快取。

從Perfetto中關鍵的渲染任務相關執行緒無明顯的阻塞現象發生。

在進入選座頁的同時DynamicInfo 請求、SeatStatus 請求很快被髮起。

座位動態解壓縮方案在座位合成過程中非常快速的完成了座位狀態合成。

大麥 Android 選座場景效能最佳化全解析

相關工具連結:

Perfetto工具[3]

總結

下圖是21年選座場景在效能監控工具上頁面載入時長、滑動平均幀率。可以看到頁面載入時長呈現明顯的下降趨勢,而滑動平均幀率呈現明顯的上升趨勢。實際上大麥選座場景的頁面載入速度超過了APP自身 80%以上的核心頁面,而選座場景同時也是這些核心頁面中網路使用、資料處理最為複雜的頁面之一。

大麥 Android 選座場景效能最佳化全解析

截止2022年6月份 ,頁面平均載入時長由20年的850ms下降到當前的320ms,頁面滑動平均幀率由20年的38幀上升到54幀。選座場景的最佳化並不是一蹴而就的,而是經歷了長期的、持續的效能最佳化~

以此篇文章,整理記錄一下選座場景在持續提升使用者體驗過程中做過的一些努力 ~

參考資料

[1]

Google Developers | Protocol Buffers:

https://developers。google。com/protocol-buffers/

[2]

Android開發者 | 硬體加速:

https://developer。android。google。cn/guide/topics/graphics/hardware-accel

[3]

Perfetto工具:

https://ui。perfetto。dev/#