開發與維運

一個方案提升Flutter內存利用率(乾貨)

作者:閒魚技術——靖書

背景

我們閒魚使用的圖片方案是自研的外接紋理方案:

  • Android側創建SurfaceTexture,通過FlutterJNI註冊到Flutter engine裡,最後返回texture id給Flutter應用層,應用層使用Texture Widget和textue id去顯示圖片紋理。
  • 紋理數據則是在Android側,通過OpenGL將圖片紋理寫入到SurfaceTexture,然後通過Flutter engine裡的共享內存,將紋理數據傳入到應用層,最終交給Skia渲染。

Alt text

這裡面存在的問題:
Flutter應用層的紋理數據沒有緩存,每次都需要重新將Bitmap數據渲染成紋理,再交給Flutter應用層使用。Native圖片加載會內存緩存,Flutter自身提供的圖片庫也存在緩存,這2個緩存相互隔離,佔用很大的內存空間。而且Flutter圖片緩存基本都是存放的本地資源圖,而我們Flutter頁面上大部分其實都是網絡下載的外接紋理圖片,導致緩存資源利用率很低。

分析

針對上述的3個問題,我們先拋開技術實現,假設下要解決這3個問題,最理想的一個解決方案是什麼:

  • 紋理沒有緩存,那我們在應用層增加一個紋理的內存緩存就解決了。
  • 當上層的應用層已經緩存紋理,那Native側的Bitmap的內存緩存也可以被去掉,只保留圖片資源的磁盤緩存。
  • 整個App的內存緩存,只有紋理緩存,Flutter的ImageCache緩存,為了避免內存資源的浪費,將這2個緩存合成一個

所以最理想的解決方案:
整個App內只存在一個內存緩存,並且它既能緩存紋理,也能緩存Flutter的Image Widget加載的圖片數據。

解決方案

ImageCache是官方提供的,我們沒辦法去掉,而且閒魚App裡也有一些地方使用Image Widget。現在解決方案就變成:
將紋理數據也放到ImageCache裡緩存。使用紋理時,先從imageCache裡取。

我們先看下現有的Flutter圖片加載邏輯,以及圖片是如何緩存的
Alt text

從圖中可以看到,Flutter的圖片加載,都會調用ImageCache.putIfAbsent方法,通過該方法取緩存,沒命中緩存則會使用傳入有的loader方法,去構造對應的ImageStreamCompleter,由ImageStreamCompleter去完成圖片加載的邏輯。

當命中緩存時,putIfAbsent方法會直接返回ImageStreamCompleter,該對象裡持有了imageInfo,ImageWidget直接拿imageInfo的ui.Image去渲染。

方案一:擴展ImageCache,緩存紋理

ImageCache對外提供取緩存方法就一個putIfAbsent
Alt text

一開始我們想的是按照該方法參數,構建對應的key,loader,以及ImageStreamCompleter,然後也使用putIfAbsent方法去取緩存。

嘗試過後發現不行,如下圖所示,當圖片下載解碼成功後,會回調這個listener方法,在該方法中,會將圖片存放進ImageCache的緩存隊列
Alt text

這個listener回調有2個參數,ImageInfo裡面存放著圖片數據ui.Image。
Alt text

我們應用層根本沒辦法去構造 ui.Image,因為該類是Flutter engine底層完成圖片解碼之後set到應用層的。應用層根本沒辦法去主動set值。這樣就導致在listener裡,無法計算出imageSize的值,自然也沒辦法存到緩存裡。

方案二:自定義ImageCache

因為ImageCache的緩存隊列是私有的,只有putIfAbsent方法可以往裡面存數據。那我們只有另外一條路,從ImageCache的源碼入手,去自定義imageCache,然後對其進行功能擴展。

將ImageCache替換成我們自定義的

因為Flutter提供的ImageCache沒辦法修改代碼,所以我們直接把ImageCache的源碼copy出來一份,繼承ImageCache,然後將PaintingBinding的imageCache替換成自定義的。

Alt text
如圖所示:Flutter的PaintingBinding有暴露出createImageCache的方法,我們繼承WidgetsFlutterBinding,重寫該方法返回我們自己的ImageCache, 另外在這裡還可以針對ImageCache的各種緩存大小做設置。

對ImageCache進行功能擴展

為了儘可能不修改ImageCache的代碼,我們直接定義了新的緩存紋理的方法,對齊了putIfAbsent方法的邏輯,核心代碼邏輯如下:
Alt text

Alt text

該方法主要是參考putIfAbsent的邏輯來實現的,為了將紋理也緩存進ImageCache,主要做了以下幾個關鍵擴展:

  1. TextureCacheKey是唯一標識紋理的key,該key是主要是根據寬高,url來判斷是否是同一個紋理的。
  2. TextureImageStreamCompleter 則是紋理的管理類,該類繼承ImageStreamCompleter,內部持有紋理數據和下載成功的回調。當命中緩存時,返回該對象給應用層,並從中拿到紋理id交給Texture Widget渲染
  3. 當沒有命中緩存時,會調用傳入的loader方法構造TextureImageStreamCompleter,並且會執行紋理的加載邏輯。同時會構造一個listener回調,註冊進TextureImageStreamCompleter。
  4. 當紋理加載成功時,會執行listener方法回調,該方法裡主要是計算紋理大小,將它放入緩存隊列裡,檢查緩存大小是否超過最大值,超過則淘汰之前最久未使用的紋理。

這裡要注意的一個點
因為普通的圖片是dart對象,會被Dart VM自動回收,但是我們的紋理對象真實的數據是在Engine的共享內存裡,所以這裡需要手動的管理紋理的釋放,我們對紋理對了引用計數,只有當沒有widget持有紋理時,引用計數為0時,才會真正的釋放。

同理,上層Texture Widget 在dispose時,也會調用下ImageCache提供的接口,看下當前使用的紋理是否被緩存或者正在被使用。只有否的時候才會真正的釋放紋理

效果

我們採用搜索結果頁作為測試頁面,該頁面存在很多寶貝大圖,以及各種重複的標籤小圖。使用華為榮耀20來測試優化前後的物理內存佔用。

操作步驟是:打開app,進入搜索結果頁,搜索相同的關鍵字後進入搜索結果頁,然後靜默10s後滑動瀏覽100條數據,最後停止操作。期間每秒採樣一次物理內存,一共持續100s,得出如下的數據

Alt text

藍色曲線是優化前的內存佔用,橘黃色曲線是優化後,進入時可以看到佔用的內存基本一致。滑動時內存佔用下降是因為出發了GC回收App的內存導致的。總體上看,優化後總的內存佔用比優化前要少,因為GC導致的毛刺也比優化前要少。

展望

上述的方案雖然實現了一個App內一個內存緩存,並且將紋理和Flutter圖片都存進去了,節省了內存空間,提高了內存使用率,但還是侵入了ImageCache源碼,後續flutter engine的升級和代碼維護,需要有額外的工作。

此外因為Flutter側加載原生圖片,都走的putIfAbsent方法,並且因為加載原生圖片都走的原圖加載,我們app內時不時存在著這種情況,一張圖片可能會佔用好幾M的內存,所以我們直接在putIfAbsent加上了大圖監控的方法,當發現加載的圖片大小超過2M時,會進行數據上報,包括圖片的url,圖片使用信息,圖片大小等。通過該方式,我們發現了好幾例圖片使用不當的情況:直接使用Image.network加載原圖,或者是Image.asset加載一張很大的本地資源。

Leave a Reply

Your email address will not be published. Required fields are marked *