天天看點

一個方案提升Flutter記憶體使用率(幹貨)

作者:閑魚技術——靖書

背景

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

  • Android側建立SurfaceTexture,通過FlutterJNI注冊到Flutter engine裡,最後傳回texture id給Flutter應用層,應用層使用Texture Widget和textue id去顯示圖檔紋理。
  • 紋理資料則是在Android側,通過OpenGL将圖檔紋理寫入到SurfaceTexture,然後通過Flutter engine裡的共享記憶體,将紋理資料傳入到應用層,最終交給Skia渲染。
一個方案提升Flutter記憶體使用率(幹貨)

這裡面存在的問題:

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圖檔加載邏輯,以及圖檔是如何緩存的

一個方案提升Flutter記憶體使用率(幹貨)

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

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

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

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

一個方案提升Flutter記憶體使用率(幹貨)

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

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

一個方案提升Flutter記憶體使用率(幹貨)

這個listener回調有2個參數,ImageInfo裡面存放着圖檔資料ui.Image。

一個方案提升Flutter記憶體使用率(幹貨)

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

方案二:自定義ImageCache

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

将ImageCache替換成我們自定義的

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

一個方案提升Flutter記憶體使用率(幹貨)

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

對ImageCache進行功能擴充

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

一個方案提升Flutter記憶體使用率(幹貨)
一個方案提升Flutter記憶體使用率(幹貨)

該方法主要是參考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,得出如下的資料

一個方案提升Flutter記憶體使用率(幹貨)

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

展望

上述的方案雖然實作了一個App内一個記憶體緩存,并且将紋理和Flutter圖檔都存進去了,節省了記憶體空間,提高了記憶體使用率,但還是侵入了ImageCache源碼,後續flutter engine的更新和代碼維護,需要有額外的工作。

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

繼續閱讀