博客 / 詳情

返回

從富文本窺探蘋果的代碼秘密

image.png

背景
在我們的業務場景下,為突出諸如 “利益點”和“利率” 等特性以推動訂單成交,引入了 “富文本” 這一概念。富文本具備豐富格式的文本展示與編輯功能。然而,恰是由於富文本具有 “多樣式”“複雜排版” 等特質,致使其在複雜元素渲染過程中會耗費更多系統資源。相較於簡潔的純文本,富文本在加載與顯示時或許會產生延遲現象,尤其是處理大量富文本內容或在較老舊的 iOS 設備上,延遲表現得更為顯著。我們項目內長期存在這一問題,對用户的使用體驗及交互效率造成了一定影響。

現狀:
直接上視頻:
image.png

注意看文案:“注意看我 我會閃爍...”這句話,可以非常直觀的看到,伴隨着每次的刷新,中間側的富文本都會有一個閃動。

為什麼會產生這種情況?
富文本包含多種格式信息,如字體、字號、顏色、段落樣式、對齊方式、圖片、表格等。簡單的純文本可能只存儲字符編碼序列,而富文本要記錄每個字符或段落對應的格式屬性。以 HTML 為例,一段帶有加粗、斜體和不同顏色的文本,會有大量的標籤(如、<span style="color:red">等)來描述這些格式。在解析富文本時,軟件或系統需要花費更多的時間和計算資源來解讀這些格式標記。需要識別每個標籤的含義,按照標籤要求正確地顯示文本內容。

僅將標籤式的 HTML 格式轉化為能夠被 iOS 系統直接加載的 UI 控件,就已經是一種對系統資源消耗極大的情況。但在我們的項目中,為了推動訂單達成交易轉化,非常頻繁的使用到了“刪除線”“下劃線”等元素。在iOS視圖的疊加邏輯下,這會非常平常頻繁的觸發一個iOSer的噩夢 ——離屏渲染。

離屏渲染:
在大部分計算機視圖的疊加中,都遵循下圖,油畫算法。

油畫算法(Painter's Algorithm)也被稱為畫家算法,是一種在計算機圖形學中用於解決可見性問題的圖形渲染算法。其基本思想源於傳統繪畫過程,就像畫家在作畫時,先畫遠處的背景,再畫近處的物體,這樣近處的物體自然會覆蓋遠處的部分,從而確定最終畫面的可見部分。
image.png
圖2.油畫算法

上面這段話是GPT寫的,説人話就是:先畫山(最底層/最遠處),再畫草地(第二層),最後畫樹(最頂層)。

這樣的好處是:當渲染較近的樹木時,其像素會覆蓋掉之前渲染的山川在相同位置上的像素,從而模擬出近物遮擋遠物的視覺效果。並且它主要依靠物體的深度排序來確定渲染順序,在物體數量較少、深度關係簡單的場景中,計算資源的消耗相對較少。

但如果在完成樹的繪製之後,我們又想要改變山的形狀,顏色,這個時候視圖“山”,已經被草地和樹遮蓋住了,無法直接修改。而iOS對此的改進措施既是:離屏渲染。

正常渲染的流程是:APP中的數據經過CPU計算和GPU渲染後,將結果存放在幀緩衝區,利用視頻控制器從幀緩衝區中取出,並顯示到屏幕上。

離屏渲染(offscreen-rendering)顧名思義為屏幕外的渲染,即渲染的結果不會直接呈現到當前屏幕上,而是等待合適的時機才會被顯示。譬如上述“完成樹的繪製後,又要改變山的背景”,計算機是無法把渲染結果直接寫入frame buffer,而是先暫存在另外的內存區域,之後再寫入frame buffer,那麼這個過程被稱之為離屏渲染。
image.png

在先前對 iOS 官方文檔的研讀過程中,得知僅有某些特定場景,例如圓角、遮罩、透明度等會觸發離屏渲染,而對於富文本是否會觸發離屏渲染,並沒有得到蘋果官方的驗證。但在借用OffScreen 等工具的幫助下,確認了這一點(標綠的即為首頁卡片中觸發了離屏渲染的場景)

image.png
(有背景色的即為觸發離屏渲染的場景)
image.png

GPU 操作高度流水線化,正常時向幀緩衝區(frame buffer)有序地輸出計算工作,當突然接收到"輸出到另一塊內存"的指令時,流水線中正在進行的所有操作被迫丟棄,轉而服務於當前的這一操作指令。完成後,再將計算好的所有內容copy回幀緩衝區(frame buffer)並清空臨時內存。在這個操作中,系統會做以下幾件事

  1. 開闢一個臨時的空間。
  2. 上下文切換
  3. 內存拷貝
  4. ...

以上每一條對CPU & GPU來説都是極其承重的包袱。分析這正是導致項目中的富文本出現閃爍的原因之一。

層級嵌套過深
在其它需求開發的過程中,針對首頁卡片的視圖層進行了剖析與梳理。結果令人震驚,僅僅一個首頁卡片,其層級嵌套竟然高達 9 層。如果將 iOS 本身的 window 等系統層級也計算進來,那麼就會有十幾層的嵌套。如此高的層級嵌套很難不引發性能問題。

image.png
(雖然馬賽克,但是不影響我們理解視圖層級之深對吧)

其次,我們項目中為了解決"多設備"”多分辨率視圖“的UI問題,引入了三方庫”Masonry“。

Masonry 是一個輕量級的佈局框架,它使用簡潔的鏈式編程語法來創建和更新視圖佈局。提供了強大的自動佈局功能,能夠很好地適應不同屏幕尺寸和設備方向。但是在諸多的優點下有一個非常致命的缺陷:在一些非常複雜的佈局場景中,大量使用 Masonry 會導致性能下降。因為每次更新佈局時,Masonry 都需要重新計算和調整視圖的約束,會消耗較多的計算資源。
分析是導致項目中的富文本出現閃爍的原因之一。

改進措施
改進UI層級?
理論上這是最好的解決辦法了,但是貿然去改動UI層級是非常有風險的一件事,並且冗長的工期怕也是產品不能接收的,測試同學也得執行一遍所有的用例。為了一行富文本的展示,去站到代碼質量,產品,測試的對立面,確實得不償失。

預排版,提前計算?
Masonry 在多層嵌套的UI層級下有性能問題的核心原因是:
為了正確地應用約束和渲染視圖,Masonry 需要遍歷整個 UI 層級結構。在多層嵌套的情況下,遍歷的路徑變長,深度增加。就像在一個有很多分支的樹形結構中尋找葉子節點一樣,需要花費更多的時間來遍歷每個節點。那麼我們把這個計算過程置前,或者説,這個計算過程由我們自己來計算,不再交給Masonry 處理。
image.png
圖7.預計算

業務場景下,富文本最多會由兩個”子富文本“ & 一個”分割線“的image拼接而成。這裏根據不同的情況, 提前對子view的位置進行了計算。直接賦值給Masonry 去佈局。

採用更為輕量級的富文本對象

渲染過慢的原因之一,富文本對象是一個非常重的對象,通常包含了大量的屬性和信息。例如,除了基本的文本內容外,它還可能有字體、字號、顏色、段落格式、對齊方式、鏈接、圖片、表格等諸多屬性。這些豐富的屬性使得富文本對象在存儲和處理時佔用大量的資源,就像一個裝滿各種複雜工具和材料的大箱子。若要減少系統處理的信息量,只使用需要用到的屬性即可,就像是隻從大箱子中挑選當前任務所需的工具和材料。譬如只是簡單地顯示一段富文本的標題部分,只提取文本內容和字號、字體等基本屬性進行渲染,就可以避免處理那些與當前任務無關的鏈接屬性、複雜的段落縮進等屬性,從而加快渲染速度。

但是他的維護成本真的是太太太高了。首先,確定哪些屬性是真正需要用到的這個過程本身就需要耗費大量的精力。其次,iOS的系統更新對開發者來説就是純黑盒,我們無法猜測apple官方會在什麼時間點針對富文本新增or刪除什麼樣的屬性。最重要的,我們並不知道富文本內部屬性的關係和依賴。例如,如果只選擇了字體和字號屬性進行渲染,但是在某些情況下,字體顏色也可能會影響到顯示效果,這就需要額外的代碼來判斷是否需要添加字體顏色屬性。這種複雜的邏輯關係會使代碼變得難以理解和維護,bug率必定飛昇。

離屏渲染的避免 or 減少?
得益於iOS對系統安全的絕對保護,iOS代碼是不開源的。我們並不能直觀的看到iOS離屏渲染的執行情況。所以要想直接第一角度為離屏渲染減負是非常寬泛,複雜且不現實的事兒。既然減少不了單次離屏渲染的耗時,那就珍惜GPU成果,將其緩存下來,以減少離屏渲染的頻次。

這裏簡單闡述一下項目中富文本的邏輯,服務端給到的富文本有兩種情況

  1. 一條富文本文案。
  2. 兩條富文本文案拼接起來的,中間用 && 進行分割。

針對這種情況,添加了兩層緩存。第一層緩存僅記錄最近一次富文本的結果,不對&&的情況進行區分。緩存中記錄了普通文本對象、計算後產生的富文本對象、富文本佈局位置等信息。第二層緩存是一個key-value的字典數據結構,同樣保存上述所有信息。不一樣的是,二級緩存所有已經計算過的富文本。防止出現:當富文本是由A,B兩條富文本拼接而成的,A有改動,B沒有改動的情況,A從緩存中取值,B重新計算。最小化CPU的操作頻次。

核心代碼:
image.png
圖8.緩存

成果
一套組合拳下來,效果顯著,直接上圖

當刷新時富文本文案沒有變動:
image.png

當刷新時富文本文案有變動:
image.png

左半部分富文本文案不變動,右半部分富文本文案新增
image.png

易用性封裝
為了便於日後的富文本場景的簡易開發,封裝了PPDHTMLLabel,可以直接通過一個方法實現一個不閃動,高性能的富文本視圖。調用方法如下:
image.png

無心插柳柳成蔭:
上述一套操作下來,幫CPU + GPU減負很多,將視圖的性能消耗降低在了觸發離屏渲染的閾值以下。
image.png

猜測小case
在寫這篇文章的同時,發現了一個非常有意思的case
image.png
為了更清晰的展示富文本的閃動,我將首頁的刷新動畫慢放了16倍。發現在富文本沒有被計算出來之前,蘋果為了不出現白屏的情況,會先把渲染的文本直接賦值上頁面上。

最重要的幾幀見下圖:
image.png

一個更大膽的猜測是,這種富文本的耗時計算,甚至並不是,在子線程中計算然後callback回來主線程刷新UI。而是直接在主線程計算並刷新的!!!因為在展示普通文本的那幾幀畫面時,這個頁面是完全卡死的,沒有任何動畫效果,這非常符合主線程做耗時操作卡死UI的特徵。看來蘋果都有垃圾代碼,那我寫點bug也是情有可原的吧。(手動狗頭) 玩笑歸玩笑,其實這種表現倒也是符合Apple近年來的策略方針,Apple一直在致力於推進SwiftUI,這種webView + H5方案或者標籤語言轉富文本的操作與基於原生的SwiftUI是互為對立面的。那蘋果對這方面不上心也就情有可原了。

歐盟努力了十多年,致力於推動蘋果將 Lightning 充電線改為 Type-C 。終於在23年的9月份。在iPhone 15系列機型上成功落地。希望反壟斷組織繼續努力,早日督促蘋果開源,在我”有生之年“可以驗證下自己的猜測。

作者簡介
nuc_zb,移動研發高級工程師

招聘信息
image.png
拍碼場

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.