如何打造易擴展的高性能圖片組件

woshislf 7年前發布 | 74K 次閱讀 高性能 iOS開發 圖形/圖像處理

內容提要

圖片組件可以說是app開發中使用最多的組件之一,它既簡單也不簡單,如何設計和開發一個具有高擴展性,高性能的圖片組件呢?本次分享將會從架構設計到性能優化等多方面,全面解析一個優秀圖片組件的設計和開發原理,以及在性能優化和架構設計方面的一些經驗和探索。

1 前言

講到圖片組件,這恐怕是我們在APP中使用最多的基礎組件之一了。隨著這幾年APP設計變化,圖片在APP中的占比越來大。瀑布流,圖片墻這樣的大面積圖片頁面也是層出不窮,用戶對流暢性的要求也是越來越高。滿滿幾屏的圖片對APP的性能來說是個不小的考驗。

所以說,一個優秀的圖片組件是十分重要的。

接下來,我將主要從架構設計,核心性能優化兩方面,分享如何開發這樣一個易擴展的高性能圖片組件。

2 從0開始打造易擴展的架構

在做軟件設計時,根據不同的抽象層次可分為三種不同層次的模式:架構模式、設計模式、代碼模式。架構關注的是軟件總體的布局,框架性結構等,是一個系統的高層次策略。在設計一個組件的時候,我們首先就應該關注到它的架構。

設計架構的時候,首先我們應該思考,要它由哪些核心模塊組成。對于一個典型的圖片組件來說,就是下載、緩存、渲染三個部分。

2.1 架構1——最簡單的圖片組件

既然有了核心,我們很容易就設計出一個最簡單的圖片組件。這個組件由四個部分組成,分別是圖片控件、內存緩存、硬盤緩存、下載器。

對于每一個圖片請求,首先同步檢查內存緩存中是否有該圖片的緩存,如果有,立即將圖片顯示出來。否則進入下一步,檢查磁盤緩存。

如果磁盤緩存中還是沒有,則由下載模塊發出網絡請求,從網絡上異步下載圖片。下載完成后,緩存到磁盤緩存和內存緩存中,并顯示。

這樣一個具有基本能力的圖片組件就搭建起來了,能滿足大部分業務的常規需求。

2.2 架構2——更符合軟件工程原則

這樣的設計有個明顯的問題,不符合我們軟件工程的思想,邏輯和UI結合在了一起,ImageView既負責顯示圖片相關的邏輯,又負責管理請求,查詢緩存,請求下載等功能。同時也缺少一個重要的接口,使得開發者請求圖片必須通過view對象,而不能直接獲取圖片。

為了解決這個問題,我們引入了一個邏輯類,ImageManager,負責圖片請求相關的邏輯,同時也提供了外部直接請求圖片的接口。這樣的設計更符合面向對象的原則。

磁盤緩存IO速度,和圖片的下載速度是比較慢的,如果我們在一個圖片加載完成之前再發出同樣的請求,不可避免的會導致重復的磁盤讀取和圖片下載。

因此,在此基礎上,我們還可以添加一個請求隊列,實現對圖片請求的去重。對于相同的請求,我們進行合并,在請求完成之后,批量進行回調。這樣就可以防止同一個url重復請求導致的多次緩存查詢和重復下載問題。

2.3 架構3——更加靈活和豐富的數據源

在到達這個階段之后,一個網絡圖片組件已經基本成型,但是我們的腳步并沒有停止,因為我們的目標是要打造一個通用的圖片組件,支持的不僅僅是網絡圖片。

我們在原有架構的基礎上做了調整,將下載模塊升級為加載模塊,將從不同位置加載的邏輯變為一個個數據源,采用設計模式中的職責鏈模式。對于每一個不同的請求分配到相應的DataSource進行處理。

同時,我們也調整了磁盤緩存的策略,它不再是請求查詢的必經路徑,比如相冊圖片,本地圖片就不需要磁盤緩存。

另一方面,我們還對封裝請求的結構體進行了改造。從原來的以url作為請求,改為以一個ImageRequest類進行封裝,URLRequest也只是Request種的一種類型。

事實上,隨著業務的發展,有些業務的圖片請求已經并不能用簡單的URL來表示了。這樣的封裝,使得請求、數據源的設計更加靈活,同時也能承載更加豐富的信息了。

當然設置url的接口還是保留的,提供了組件最簡單的使用方式,本質上卻改為創建了一個URLRequest。

2.4 架構4——支持圖片處理

這樣一個架構是否滿足了所有的需求呢?顯然還不夠。有的圖片并不是直接展示,而是需要進行處理,比如套用一個濾鏡。前面的架構設計缺少了在圖片顯示之前對圖片進行處理的基本能力。

因此,我們在原有的架構上再添加一個模塊,圖像處理模塊,一方面解決上述的圖片處理的問題。另一方面,在這個模塊,對加載的圖片進行一次繪制。這是由于iOS

的特性,UIImage加載之后并沒有立即解碼,而是在顯示或其他需要的時候解碼,我們需要進行一次繪制,強制系統進行解碼。

2.5 架構5——第三方解碼器

上面的架構已經比較完善了,但是隨著業務發展,我們對圖片要求也變高了。我們希望使用更新的圖片格式,以滿足對質量的要求。這就需要接入第三方解碼器。

于是這里我們再增加一個解碼器模塊,用于提供對數據的解碼支持。當然,并不是所有的數據源返回都是二進制數據,所以解碼器也不是必經的路徑。我們根據數據源返回的數據類型判斷。對于返回UIImage的數據源我們直接使用,對于返回NSData的數據源則通過解碼器解碼。

3 向更高的性能前進

作為一個基礎組件,功能是核心,效率是根本。我們設計這個組件的初衷是什么?是提高效率。這里有兩層含義,一方面是提高開發效率,一方面是提高執行效率。可以說,沒有效率的圖片組件是沒有價值的。

3.1 渲染性能優化

要提高效率,首先應該要找出性能的瓶頸,包括CPU、內存、IO等各個方面。接下來我們首先從圖片渲染下手,講講到底是誰吃掉了我們的CPU,而我們又應該如何避免。

3.1.1 誰吃掉了我們的CPU

因此我們構造了一個常見的圖片墻場景,很多圖片Cell,進行上下滑動。這種圖片墻在我們平時用的APP中是非常常見的,同時也是對性能挑戰較大的場景。

我們使用Instruments進行分析。通過TimeProfile,我們可以輕易的查看一段時間內各個函數占用的CPU。我們觀察了在滑動過程中的CPU占用情況。

結果非常驚人,在滑動過程中,主線程高達79%的CPU時間消耗在了一個函數上

CA::Render::prepare_image,它到底是什么,它做了什么事?

通過觀察,我們發現里面實際調用了圖片解碼函數CA::Render::create_image_from_provider,將圖片進行解碼。原因是UIImage在加載的時候實際上并沒有對圖片進行解碼,而是延遲到圖片被顯示或者其他需要解碼的時候。這種策略節約了內存,但是卻會在顯示的時候占用大量的主線程CPU時間進行解碼,導致界面卡頓。

3.1.2 優化

那問題發現了,我們就應該思考一下解決方案。如果我們不在主線程進行解碼,而是在后臺線程預先解碼會有什么樣的改變呢?我們通過CoreGraphic繪制UIImage,促使UIImage強制解碼,然后再次觀察滑動過程中的CPU占用。

效果十分明顯,滑動過程中的CPU消耗降低了四分之三,UIImage不再需要在顯示的時候進行解碼了。這代表我們的優化方案取得了成效。

3.1.3 解碼API性能對比(單線程)

UIImage解碼的方法有很多種,那到底哪種效率高呢?我們應該使用多線程解碼還是單線程呢?是否有Alpha通道對圖片的解碼有影響嗎?為了解答這些問題,我們做了一個測試,選取了4種常用的API進行解碼性能對比。

1、 使用UIGraphic創建Context,并調用UIImage的drawInRect函數。這個方法雖然是UI開頭的,但是確實線程安全的,我們可以在文檔找到相應的資料。事實上從iOS4開始, UIImage,UIFont的繪制函數已經是線程安全的了。

2、 創建帶Alpha的CGContext,然后把CGImage繪制上去

3、 創建不帶Alpha的CGContext,然后把CGImage繪制上去

4、 ImageIO創建UIImage有個選項shouldCacheImmediately,設為true之后創建UIImage同時就會進行解碼

下面是解碼50張1000*1000圖片的耗時圖。進行對比,我們可以發現:

1、 使用ImageIO進行解碼的效率是遠高于其他方式的。

2、 CGContext是否帶Alpha,不會對繪制時間有明顯的影響。

3、 UIGraphic解碼效率稍低,但和CGContext方法差別不大,主要原因可能是由于線程安全的方法加鎖引起。

3.1.4 解碼API性能對比(多線程)

那如果我們使用多線程進行解碼會有什么不同呢?于是我們再次做了測試。

1、 使用UIGraphic和CGContext的解碼時間明顯減短,接近單線程ImageIO的解碼時間。

2、 ImageIO的解碼時間明顯增長,這個十分令人驚訝。

我們得出結論,使用ImageIO單線程解碼,已經能最大限度的發揮硬件的運算能力,多線程并不能能夠有解碼能力的提升。

3.2 內存占用優化

對于圖片,可能比較少有人會關注它的內存,事實上對于iOS這樣對App管理比較嚴格的系統,我們更應該小心。在實驗室,我們對app占用的內存和穩定性之間的關系進行了測試,拿iPhone6這樣的機型舉例,使用300M以上的內存,就會對程序的穩定性產生明顯的影響。

想要了優化,我們首先應該了解iOS的內存。

3.2.1 內存的類型

打開Instruments中的Activity Monitor,可以發現iOS的內存分為5欄:Physical Memory Wired,Active,Inactive,Used,Free。

那么這些到底有什么用呢?互相之間有什么區別呢?

3.2.2 不同類型內存的區別

Wired內存被系統使用,幾乎無法被直接操作。應用程序無法直接使用Wired內存。但是也有一些API會使用這部分內存,手機沒有獨立顯存,GPU使用的共享顯存也屬于這個部分。它無法被Allocations顯示出來,所以我們使用Allocations測試的內存和APP實際使用的內存

Active內存是當前正在運行的應用程序使用的內存。由于虛擬內存的幫助,并不是一個程序的所有內存都被包含在這里。如果你打開Activity Monitor,你可以看到應用程序真正占用的物理內存和虛擬內存。當沒有Inactive和Free內存的時候,就會觸發操作系統的頁面置換,在別的程序需要使用該內存之前,將內存寫入磁盤。

Inactive內存是一個最近剛剛被一個不在運行的程序使用過的內存。由于局部性原理(Temporal Locality),操作系統保留對這塊內存的追蹤。這使得啟動一個內存被追蹤的程序將會非常迅速。Inactive內存將會在別的應用程序需要內存的時候被回收。

Free內存就是字面的意思,空閑內存。

似乎少講了一個:Physical Memory Used。顧名思義,被使用的物理內存,它等于Wired+Active+Inactive。

3.2.3 占用內存的3種方式

大家都知道加載圖片會占用內存,但是很少人知道具體會如何占用內存。事實上,圖片有3種方式占用內存。

常見的解碼方式有以下幾種

1、使用UIGraphic配合UIImage 的drawInRect

2、使用CGContext配合CGContextDrawImage

3、使用CGImageSource的shouldCacheImmediately

4、設置到UIImageView的image屬性中,然后顯示

前三種可以分為一類,強制解碼

第四種由于蘋果對于UIImageView的特殊優化需要單獨分為一類

還有一類是CoreAnimation對非字節對齊圖片的的copy

對于情況1:一個傳統的RGBA圖像,系統在分配了一塊4*width*height大小的內存,被稱為CG Raster Data,用于存儲圖像數據。占用ActiveMemory。

對于情況2:事情就不是這樣了,由于蘋果進行的優化,它們占用的不再是ActiveMemory而是WiredMemory。這就表示這部分內存不會受到系統對APP的內存限制,導致memory warning 甚至被系統殺死。

而且,在解碼過程中,蘋果似乎使用了紋理壓縮算法,使得UIImageView解碼的圖片占用的實際內存約為我們自己解碼占用內存的50%左右。

經過測試,在iphone6上,如果一個APP內存中只存儲了圖片,使用UIImageView的方式大約可以使用4倍于使用其他解碼方式的內存數量。

對于情況3:字節對齊,可能很多人沒有去了解,它占用的也是WiredMemory。我們就來講講什么是字節對齊,為什么要進行字節對齊。

3.2.4 字節對齊

在iOS上,有一個很容易被大家忽略的內存占用。CoreAnimation在顯示圖像的時候,會對沒有字節對齊的圖片進行copy

為了性能,底層渲染圖像時不是一個像素一個像素渲染,而是一塊一塊渲染,數據是一塊塊地取,就可能遇到這一塊連續的內存數據里結尾的數據不是圖像的內容,是內存里其他的數據。所以在渲染之前CoreAnimation要把數據拷貝一份進行處理,確保每一塊都是圖像數據,對于不足一塊的數據置空。

塊的大小跟CPU 的緩存有關,ARMv7是32byte,A9是64byte,在A9下CoreAnimation應該是按64byte作為一塊數據去讀取和渲染,讓圖像數據對齊64byte就可以避免CoreAnimation再拷貝一份數據。能節約內存和進行copy的時間。

3.2.5 繪制成需要的大小

除了從系統和API層面對內存進行優化,我們還可以從圖片的使用方式上進行優化。

對于常規的APP來說,大部分圖片的顯示區域大小是固定的,在圖片顯示出來之后就不會進行變化了。所以保存并展示一張比顯示區域大的圖片是十分浪費的。所以,對于這樣的圖片,如果我們在圖片顯示之前根據顯示區域的大小進行縮放,并保存縮放過的圖片。不但能在顯示的時候省去縮放運算的開銷,還能節約大量的內存。

3.2.6 圖片解碼顯示流程

綜合上面所述的優化方式,我們對于圖片設計了這樣的處理流程。

對于輸入圖片,我們首先判斷是否需要支持無極縮放。如果不是,則通過CGContext繪制成顯示區域的大小。否則,則通過UIImageView的drawViewHierarchyInRect進行解碼,并通過UI縮放實現各種縮放效果。

對于圖片顯示區域變化的處理,如果是原大小解碼的,則直接進行UI縮放,這種方法能支持使用動畫。否則則重新發起圖片請求,對圖片進行重新繪制。

3.3 緩存優化

緩存是圖片組件的核心模塊之一,好的緩存模塊能提高圖片的利用率,減少資源重復加載的開銷。對于緩存我們的核心指標就是緩存的增刪查找速度和緩存的命中率。

3.3.1 內存緩存

我們通過3個手段提高這兩個指標

第一:使用LRU+FIFO雙隊列的改進算法,提高緩存的命中率,解決進入新頁面的突發大量圖片的緩存污染問題。

第二:使用緩存模糊匹配算法。對于圖片請求,如果發現緩存中有比請求大小更大的圖片,則也視為命中緩存。

第三:使用C++編寫緩存,組合鏈表和哈希表的存儲結構,可以把LRU隊列的增刪查的時間復雜度將為O(1)

3.3.2 緩存模塊架構

現在我們具體來看一下緩存的設計。緩存模塊由一個HashTable,一個FIFO隊列,一個LRU隊列組成。

通過哈希表實現緩存的快速查詢,通過FIFO隊列和LRU隊列實現緩存的淘汰邏輯。緩存的每個節點也是一個哈希表。也就是說對于每次緩存查詢我們最多需要進行兩次哈希表的查詢就可以定位目標了。

每次查詢,通過圖片的url定位到一級緩存節點,再通過圖片屬性定位到二級緩存節點。那這里的屬性指的是圖片的大小,經過的處理步驟等參數。

比如第一張圖片的屬性就是大小為100X100,灰度圖。第二章圖片的屬性是100X100經過AspectFit縮放過的圖片。

如果開啟緩存的模糊匹配,那么在定位2級節點的時候,只要找到圖片大小大于請求大小的同類圖片,就會被視為緩存命中。

解釋了為什么需要二級緩存,我們再講講FIFO+LRU雙隊列的作用。這主要是為了解決突發的大量圖片請求對緩存污染的問題。

對于單LRU隊列,想象這樣一個場景,用戶進入了一個大量圖片的頁面后返回,大量的新圖片涌入,直接將LRU隊列清空。使用雙隊列則可以避免類似的問題,大量的新圖片只會清空FIFO隊列,而LRU隊列中保存的數據還繼續存在。

4 總結與后續

圖形圖像作為一個APP開發中的基礎的部分,其實有很多東西可以深挖。

后面我應該還會寫一篇關于iOS視頻AR全景特效的文章,分析實時渲染技術在APP開發中的應用,如何構造一個復雜軌跡的粒子系統,如何使用紋理壓縮技術,大幅度降低圖片的內存開銷等。

 

來自:https://zhuanlan.zhihu.com/p/26955368

 

 本文由用戶 woshislf 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!