那些年
早在閑魚使用 Flutter 之初,圖片就是我們核心關注和重點優化的功能。圖片展示體驗的好壞會對閑魚用戶的使用體驗產生巨大影響。你們是否也曾遇到過:
- 圖片加載內存占用過多?
- 使用 Flutter 以后本地資源重復,利用率不高?
- 混合方案下 Flutter 原生圖片加載效率不高?
針對上述問題,從第一版 Flutter 業務上線開始,閑魚對圖片框架的優化就從未停止。從開始的原生優化,到后面黑科技的外接紋理;從內存占用,到包大??;文本會逐一介紹。希望其中的優化思路和手段,能給大家帶去一些啟發。
原生模式
從技術層面看圖片加載,其實簡單來說,追求的是無非是加載的效率的最大化——用盡可能小的資源成本,盡可能快地加載盡可能多的圖片。
閑魚圖片的第一個版本其實基本上是純原生的方案。如果你不想魔改很多底層的邏輯,原生方案肯定是最簡單和經濟的方案。原生方案的功能模塊如下:
如果你啥都沒做直接上了,那么你可能會發現效果并沒有達到你預期的那么美好。那么如果從原生的方案入手,我們有哪些具體的優化手段呢?
設置圖片緩存
沒錯猜對了,是緩存。對于圖片加載,最能想到的方案就是使用緩存。首先原生 Image 的組件是支持自定義圖片緩存的,具體的實現類是 ImageCache。ImageCache 的設置維度是兩個方向:
-
緩存圖片的張數。通過 maximumSize 設置。默認是 1000 張。
-
緩存空間的大小。通過 maximumSizeBytes 來設置。默認值 100M。相比張數的限制,其實大小的設置方式更加符合我們的最終的預期。
通過合理設置 ImageCache 的大小,能充分利用緩存機制加速圖片加載。不僅如此,閑魚在這個點上還做了額外兩個重要優化:
低端手機適配
在上線以后,我們陸續收到線上輿情的反饋,發現全部機型設置同一個緩存大小的做法并非最優。特別是大緩存設置在低端機器上面,不僅會出現體驗變差,甚至還會影響穩定性?;趯嶋H情況,我們實現了一個能從 Native 側獲取機器基礎信息的 Flutter 插件。通過獲取的信息,我們根據不同手機的配置設置不同的緩存策略。在低端機器上面適當降低圖片緩存的大小,同時在高端手機上將其適當放大。這樣能在不同配置的手機上獲取最優的緩存性能。
磁盤緩存
熟悉 APP 開發的同學都知道,成熟的圖片加載框架一般都有多級緩存。除了常見的內存緩存,一般都會配置一個文件緩存。從加載效率上來說,是通過空間換時間,提升加載速度。從穩定性來說,這又不會過分占用寶貴的內存資源,出現 OOM。但是可惜的是,Flutter 自帶的圖片加載框架并沒有獨立的磁盤緩存。所以我們在原生方案的基礎上擴展了磁盤緩存能力。
在具體的架構實現上,我們并沒有完全自己擼一個磁盤緩存。我們的策略還是復用現有能力。首先我們將 Native 圖片加載框架的磁盤緩存的功能通過接口暴露出來。然后通過橋接的方式,將 Native 磁盤緩存能力嫁接到 Flutter 層。Flutter 側進行圖片加載的時候,如果內存沒有命中,就去磁盤緩存中進行二次搜索。如果都沒有命中才會走網絡請求。
通過增加磁盤緩存,Flutter 圖片加載效率進一步提升。
設置 CDN 優化
CDN 優化是另一個非常重要圖片優化手段。CDN 優化的效率提升主要是:最小化傳輸圖片的大小。常見策略包括:
根據顯示大小裁剪
簡單來說,你要加載圖片的真實尺寸,可能會大于你實際展示窗口的大小。那么你就沒必要加載完整大圖,你只需要加載一個能覆蓋窗口大小的圖片即可。通過這種方式,裁剪掉不需要的部分,就能最小化傳輸圖片的大小。從端側角度來說,一來可以提升加載速度,二來可以降低內存占用。
適當壓縮圖片大小
這里主要是根據實際情況增加圖片壓縮的比例。在不影響顯示效果的情況下,通過壓縮進一步降低圖片的大小。
圖片格式
建議優先使用 webp 這樣格式,圖片資源相對小。Flutter 原生支持 webp(包括動圖)。這里特別強調一下 webp 動圖不僅大小要比 gif 小很多,而且還對透明效果有更好的支持。webp 動圖是 gif 方案比較理想的一種替代方案。
基于上述原因,閑魚圖片框架在 Flutter 側實現了一套 CDN 尺寸匹配的算法。通過該算法,請求圖片會根據實際顯示的大小,自動匹配到最合適的尺寸上并適當壓縮。如果圖片格式允許,圖片盡可能轉化成 webp 格式下發。這樣 CDN 圖片的傳輸就能盡可能高效。
其他優化
除了上面的策略,Flutter 還有一些其他的手段可以優化圖片的性能。
圖片預加載
如果你想在展示的圖片的盡可能的快,官方也提供了一套預加載的機制:precacheImage。precacheImage 能預先將圖片加載到內存,真正使用的時候就能秒出了。
Element 復用優化
其實這個算是一個 Flutter 通用的優化方案。復寫 didWidgetUpdate 方案,通過比較前后兩次 widget 中針對圖片的描述是否一致,來決定是否重新渲染 Element。這樣能避免同一個圖片,不必要的反復渲染。
長列表優化
一般情況下,Listview 是 flutter 最為常見的滾動容器。在 Listview 中的性能好壞,直接影響最終的用戶體驗。
Flutter 的 Listview 跟 Native 的實現思路并不相同。其最大的特點是有一個 viewPort 的概念。超出 viewPort 的部分會被強制回收掉。
基于上述的原理,我們有兩點建議:
1)cell 拆分
盡量避免大型的 cell 出現,這樣能大幅降低 cell 頻繁創建過程中的性能損耗。其實這里影響的不僅僅是圖片加載過程。文字,視頻等其他組件也都應該避免 cell 過于復雜導致的性能問題。
2)合理使用緩沖區
ListView 可以通過設置 cacheExtent 來設置預先加載的內容大小。通過預先加載可以提升 view 渲染的速度。但是這個值需要合理設置,并非越大越好。因為預加載緩存越大,對頁面整體內存的壓力就越大。
該方案的不足
這里需要客觀指出:如果是一個純 Flutter APP,原生方案是完善,夠用的。但是如果從混合 APP 的角度來說,有如下兩個缺陷:
1)無法復用 Native 圖片加載能力
毫無疑問,原生的圖片方案是完全獨立的圖片加載方案。對于一個混合 APP 來說,原生方案和 Native 的圖片框架相互獨立,能力無法復用。例如 CDN 裁剪 & 壓縮等能力需要重復建設。特別是 Native一些獨特的圖片解碼能力,Flutter 就很難使用。這會造成 APP 范圍內的圖片格式的支持不統一。
2)內存性能不足
從整個 APP 的視角來說,采用原生圖片方案的情況下,其實我們維護了兩個大的緩存池:一個是 Native 的圖片緩存,一個是 Flutter 側的圖片緩存。兩個緩存無法互通,這無疑是一個巨大的浪費。特別是對內存的峰值內存性能產生了非常大的壓力。
打通 Native
經過多輪優化,基于原生的方案已經獲得了非常大的性能提升。但是整個 APP 的內存水位線依然比較高(特別是 Ios 端)?,F實的壓力迫使我們繼續對圖片框架進行更深度的優化?;谏鲜鲈桨溉秉c的分析,我們有了一個大膽的想法:能否完全復用 Native 的圖片加載能力?
外接紋理
怎樣打通 Flutter 和 Native 的圖片能力?我們想到了外接紋理。外接紋理并非是 Flutter 自有的技術,它是音視頻領域常用的一種性能優化手段。
這個階段我們基于 shared-Context 的方案實現了 Flutter 和 Native 的紋理外接。通過該方案,Flutter 可以通過共享紋理的方式,拿到 Native 圖片庫加載好的圖片并展示。為了實現這個紋理共享的通道,我們對 engine 層做了深度定制。細節過程如下:
該方案不僅打通了 Native 和 Flutter 的圖片架構,整個過程圖片加載的性能也得到了優化。
外接紋理是閑魚圖片方案的一次大跨越。通過該技術,我們不僅實現圖片方案的本地能力復用,而且還能實現視頻能力的紋理外接。這避免了大量重復的建設,提升了整個 APP 的性能。
多頁面內存優化
這個優化策略真真是被逼出來的。在對線上數據分析以后,我們發現 Flutter 頁面棧有一個非常有意思的特點:多頁面棧情況下,底層的頁面不會被釋放。即便是在內存非常緊張的情況下,也不會執行回收。這樣就會導致一個問題:隨著頁面的增多,內存消耗會線性增長。這里占比最高的就是圖片資源的占比了。
是不是可以在頁面處于頁面棧底層的時候直接回收掉該頁面內的圖片呢?
在這個想法的驅動下,我們對圖片架構進行了新一輪的優化。整個圖片框架中的圖片都會監聽頁面棧的變化。當方發現自己已經處于非棧頂的時候,就自動回收掉對應的圖片紋理釋放資源。這種方案能使圖片占用的內存大小不會隨著頁面數的變多呈現持續線性增長。原理如下:
需要注意的是:這個階段頁面判斷位置其實是需要頁面棧(具體來說就是混合棧)提供額外的接口來實現的。系統之間的耦合相對較高。
意外收獲:包大小
打通 Native 和 Flutter 側圖片框架以后,我們發現了一個意外收獲:Native 和 Flutter 可以共用本地圖片資源了。也就是說,我們不再需要將相同的圖片資源在 Flutter 和 Native 側各保留一份了。這樣能大幅提升本地資源的復用率,從而降低整體的包大小?;谶@個方案,我們實現了一套資源管理的功能,腳本能自動同步不同端的本地圖片資源。通過這樣提升本地資源利用率,降低包大小。
其他優化——PlaceHolder 強化
原生的 Image 是沒有 PlaceHolder 功能的。如果想用原生方案的話,需要使用 FadeInImage。針對閑魚的場景我們有很多定制,所以我們自己實現了一套 PlaceHolder 的機制。
從核心功能上來說,我們引入了加載狀態的概念分為:
- 未初始化
- 加載中
- 加載完成
針對不同的狀態,可以細粒度的控制 PlaceHolder 的展示邏輯。
整體架構
該方案的不足
畢竟改了 engine
隨著閑魚業務的不斷推進,engine 的升級的成本是我們必須要考慮的事情。能否不改 engine 實現同樣的功能是我們核心的述求(PS:我承認我們是貪心的)。
通道性能還有優化空間
外接紋理的方案需要通過橋的方式跟 Native 的能力做通信。這里包括圖片請求的傳遞和圖片加載各種狀態的同步。特別是在 listview 快速滑動的時候,通過橋發送的數據量還是可觀的。當前方案每個圖片加載時都會單獨進行橋的調用。在圖片數量比較多的情況下,這顯然會是一個瓶頸。
耦合過多
在實現圖片回收方案的時候,目前方案需要棧提供是否在棧底層的接口。這里就產生方案耦合,很難抽象出一個獨立干凈的圖片加載方案。
Clean & Efficient
時間來到了 2020 年,隨著對 Flutter 基礎能力理解的逐步深入,我們實現了一個整體方案更優的圖片框架。
無侵入外接紋理
外接紋理可以不用修改 engine 么?答案是肯定的。
其實 Flutter 是提供了官方的外接紋理方案的。
而且 Native 操作的 texture 和 Flutter 側顯示的 texture 在底層是同一對象,并沒有產生額外的數據 copy。這樣就保證了紋理共享的足夠高效。那為什么閑魚之前會單獨基于 shared-Context 自己實現一套呢?1.12 版本之前,官方 Ios 的外接紋理方案有性能問題。每次渲染的過程中(不管紋理是否有更新)都會頻繁獲取 CVPixelBuffer,造成不必要的性能損耗(過程有加鎖損耗)。該問題已經在 1.12 版本中修復(官方 commit 地址),這樣官方方案也足夠滿足需求。在這樣的背景下,我們重新啟用官方方案來實現外接紋理功能。
獨立的內存優化
之前提到過,老版本的基于頁面棧的圖片資源回收需要強依賴棧功能的接口。一方面產生了不必要的依賴,更重要的是,整體方案無法獨立成通用方案。為了解決這個問題,我們對 Flutter 底層進行了深入的研究。我們發現 Flutter 的 layer 層可以穩定感知到頁面棧的變化。
然后每個頁面通過 context 獲取的 router 對象作為標識對一個頁面中的所有的圖片對象進行重新組織。所有獲取到同一個 router 對象的標識成同一個頁面。這樣就能以頁面為單位對所有的圖片進行管理。整體上通過 LRU 的算法來模擬虛擬頁面棧結構。這樣就能對棧底頁面的圖片資源實現回收了。
其他優化
通道的高度復用
首先我們以一幀為單位對這一幀中的圖片請求進行聚合,然后在一次通道請求中傳遞給 Native 的圖片加載框架。這樣能避免頻繁的橋調用。特別在快速滾動等場景下優化效果尤為明顯。
高效的紋理復用
使用外接紋理進行圖片加載以后,我們發現復用紋理可以進一步提升性能。舉一個簡單的場景。我們知道電商場景中,商品展示經常會有標簽,打底圖這樣的圖片。這類圖片往往在不同的商品上會出現大量重復。這時候,可以將已經渲染好的紋理,直接復用給不同的顯示組件。這樣能進一步優化 GPU 內存的占用,避免重復創建。為了精確對紋理進行管理,我們引入了引用計數的算法來管理紋理的復用。通過這些方案,我們實現了紋理跨頁面高效復用。
此外,我們將紋理和請求的映射關系移動到了 Flutter 側。這樣能在最短路徑上完成紋理的復用,進一步減少了橋的通信的壓力。
整體架構
優化效果
由于最新的版本目前還在灰度,具體數據后續會寫文跟大家詳細介紹。下屬數據主要以方案二為主。
內存優化
通過打通 Native,相比于首次上線版本,在顯示效果不變的情況下,Ios 的 abort 率降低 25%,用戶體驗明顯提升。
多頁面棧內存優化
多頁面棧的內存優化,在多頁面場景下對內存優化作用明顯。我們做了一個極限試驗效果如下(測試環境,非閑魚 APP):
可見多頁面棧的優化,可以將多 Flutter 頁面的內存占用控制得更好。
包大小減少
通過接入外接紋理,本地資源得到了更好的復用,包大小降低 1M。早期閑魚接入 Flutter,會以改造現有頁面為切入點。資源重復情況比較嚴重,但是隨著閑魚 Flutter 新業務越來越多。Flutter 和 Native 的重復資源越來越少。外接紋理對包大小的影響已經逐步變弱。
后續計劃
這是一場沒有盡頭的旅行,我們對閑魚圖片的優化還會持續。特別是我們最新的方案,受限篇幅,本文只是做了初步介紹。更多技術細節,包括測試數據,我們隨后還會專門寫文繼續給大家做介紹。方案完善以后,我們也會逐步開源。