作者:閑魚技術-鄰雲
背景
在做圖檔、視訊相關功能的時候,相冊是一個繞不開的話題,因為大家基本都有從相冊擷取圖檔或者視訊的需求。最直接的方式是調用系統相冊接口,基本功能是滿足的,一些進階功能就不行了,例如自定義UI、多選圖檔等。
我們調研了官方的image_picker,它也是調用系統的相冊接口來處理的,可定制程度不高,不能滿足我們的要求。是以我們選擇自己來開發Flutter相冊元件。
我們的元件需要有如下的功能:
- 在app内完成圖檔、視訊的選取,完全不用依賴系統相冊元件
- 可以多選圖檔,支援指定標明圖檔的總數目
- 在多選的時候UI反應出選擇的序号。
- 可以控制視訊、圖檔的選擇。例如:隻讓使用者選擇視訊,圖檔是灰色的。
- 大圖預覽的時候可以放大縮小,也可直接加入到選取清單。
設計思路
API使用簡單,功能豐富靈活,具有較高的訂制性。業務方可以選擇完全接入元件,也可以選擇在元件上面進行UI定制。
Flutter做UI展現層,具體的資料由各Native平台提供。這種模式,天然從工程上把UI代碼和資料代碼進行了隔離。我們在開發一個native元件的時候常常會使用MVC架構。Flutter元件的開發的思路也基本類似。整體架構如下:

可以看出,在Flutter側是一個典型的MVC架構,這裡Widget就是View,View和Model綁定,在Model改變的時候View會重新build反映出Model的變化。View的事件會觸發Controller去Native擷取資料然後更新Model。Native和Flutter通過Method Channel進行通信,兩層之間沒有強依賴關系,隻需要按約定的協定進行通信即可。
Native側的組成部分,UIAdapter主要是負責機型的适配、劉海屏、全面屏之類的識别。Permission負責媒體讀寫權限的申請處理。Cache主要負責緩存GPU紋理,在大圖預覽的時候提高響應速度。Decoder負責解析Bitmap,OpenGL負責Bitmap轉紋理。
需要說明的是:我們的這一套實作依賴于flutter外接紋理。在整個相冊元件看到的大多數圖檔都是一個GPU紋理,這樣給java堆記憶體的占用相對于以前的相冊實作有大幅的降低。在低端機上面如果使用原生的系統相冊,由于記憶體的原因,app有被系統殺掉的風險。現象就是,從系統相冊傳回,app重新啟動了。使用Flutter相冊元件,在低端機上面體驗會有所改觀。
一些細節
1分頁加載
相冊清單需要加載大量圖檔,Flutter的GridView元件有好幾個構造函數,比較容易犯的錯誤是使用了第一個函數,這需要在一開始就提供大量的widget。應該選擇第二個構造函數,GridView在滑動的時候會回調IndexedWidgetBuilder來擷取widget,相當于一種懶加載。
GridView.builder({
...
List<Widget> children = const <Widget>[],
...
})
GridView.builder({
...
@required IndexedWidgetBuilder itemBuilder,
int itemCount,
...
})
滑動過程中,圖檔滑過後,也就是不可見的時候要進行資源的回收,我們這裡這裡對應的就是紋理的删除。不斷的滑動GridView,記憶體在上升後會處于穩定,不會一直增長。如果快速的來回滑動紋理會反複的建立和删除,這樣會有記憶體的抖動,體驗不是很好。
于是,我們維護了一個圖檔的狀态機,狀态有None,Loading,Loaded,Wait_Dispose,Disposed。開始加載的時候,狀态從None進入Loading,這個時候使用者看到的是空白或者是占位圖,當資料回調回來會把狀态設定為Loaded的這時候會重新build widget樹來顯示圖檔icon,當使用者滑走的時候狀态進入 Wait_Dispose,這時候并不會馬上Dispose,如果使用者又滑回來則會從Wait_Dispose進入Loaded狀态,不會繼續Dispose。如果使用者沒有往回滑則會從Wait_Dispose進入Disposed狀态。當進入Disposed狀态後,再需要顯示該圖檔的時候就需要重新走加載流程了。
2 相冊大圖展示:
當點選GridView的某張圖檔的時候會進行這張圖檔的大圖展示,友善使用者檢視的更清楚。我們知道相機拍攝的圖檔分辨率都是很高的,如果完全加載,記憶體會有很大的開銷,是以我們在Decode Bitmap的時候進行了縮放,最高隻到1080p。大圖展示可以概括為三個步驟。
- 1 從檔案Decode出Bitmap
- 2 Bitmap轉換成為紋理,并釋放Bitmap
- 3 紋理交給Flutter進行展示
在步驟1中,Android原生的Bitmap Decode經驗同樣适用,先Decode出Bitmap的寬高,然後根據要展示的大小計算出縮放倍數, 然後Decode出需要的Bitmap。
Android相冊的圖檔大多是有旋轉角度的,如果不處理直接顯示,會出現照片旋轉90度的問題,是以需要對Bitmap進行旋轉,采用Matrix旋轉一張1080p的圖檔在我的測試機器上面大概需要200ms,如果使用OpenGL的紋理坐标進行旋轉,大于隻需要10ms左右,是以采用OpenGl進行紋理的旋轉是一個較好的選擇。
在進行大圖預覽的時候會進入一個水準滑動的PageView,Flutter的PageView一般來說是不會去主動加載相鄰的page的。舉個例子,在顯示index是5的page的時候index為4,6的page也不會提前建立的。這裡有一個取巧的辦法,對于PageController的viewportFraction參數我們可以設定成為0.9999。對于前面這個例子,就是在顯示index是5的page的時候,index為4,6的page也需要顯示0.0001。這樣index為4,6的page顯示不到1個像素,基本上看不出來:
PageController(viewportFraction=0.9999)
還有另外一種辦法,就是在Native側做預加載。例如:在加載第5張圖檔的時候,相鄰的4,6的圖檔紋理提前進行加載,當滑動到4,6的時候直接使用緩存的紋理。
紋理緩存後,一個直接的問題:什麼時候釋放紋理?等到預覽頁面退出的時候釋放所有的紋理顯示不是很合适,如果使用者一直浏覽記憶體則會無限增長。是以,我們維護了一個5個紋理的LRU緩存,在滑動過程中,最老的紋理會被釋放掉。在頁面退出的時候整個LRU的緩存會進行銷毀。
3 關于記憶體
相冊圖檔使用GPU紋理,會大幅減少Java堆記憶體的占用,對整個app的性能有一定的提升。需要注意的是,GPU的記憶體是有限的需要在使用完畢後及時删除,不然會有記憶體的洩漏的風險。另外,在Android平台删除紋理的時候需要保證在GPU線程進行,不然删除是沒有效果的。
在華為P8,Android5.0上面進行了對比測試,Flutter相冊和原native相冊總記憶體占用基本一緻,在GridView清單頁面,新增最大記憶體13M左右。它們的差別在于原native相冊使用的是Java堆記憶體,Flutter相冊使用的是Native記憶體。
總結
相冊元件API簡單、易用,高度可定制。Flutter側層次分明,有UI訂制需求的可以重寫Widget來達到目的。另外這是一個不依賴于系統相冊的相冊元件,自身是完備的,能夠和現有的app保持UI、互動的一緻性。同時為後面支援更多和相冊相關的玩法打好基礎。
後續計劃
由于我們使用的是GPU紋理,可以考慮支援顯示高清4K圖檔,而且用戶端記憶體不會有太大的壓力。但是4k圖檔的Bitmap轉紋理需消耗更多的時間,UI互動上面需要做些loading狀态的支援。
元件功能豐富,穩定後,進行開源,回饋給社群。