前言
界面展示類型的輪子往往定制性需求比較多芭挽,常常讓人抓耳撓腮。這種接近業(yè)務(wù)的輪子如何設(shè)計才能兼顧便捷性和拓展性蝗肪?如何有效的優(yōu)化性能袜爪?如何控制內(nèi)存不至于 OOM ?本文以 YBImageBrowser 的重構(gòu)為切入點(diǎn)薛闪,盡量抽象提煉辛馆,談?wù)劰P者對以上問題的思考。
YBImageBrowser 是筆者 2018 年 4 月發(fā)布的開源項(xiàng)目豁延。時隔一年多昙篙,接近 1.3k stars,處理了 100+ issues诱咏,200+ commits苔可,30+ releases,兩次深度重構(gòu)袋狞。重構(gòu)的原因很簡單焚辅,無法忍受自己寫的拙劣代碼 ??映屋,另一方面這份代碼也承載了某種情懷。
下面就從幾個方面談?wù)勥@次重構(gòu)引出的值得分享的東西同蜻。
一棚点、圖片處理流程
一張圖片展示到屏幕上的流程:
對高清圖進(jìn)行壓縮和裁剪避免圖片超過最大支持紋理時 CPU 在主線程對圖片額外處理,同時也降低了圖片解碼后的內(nèi)存占用埃仪。
在這個流程中乙濒,有網(wǎng)絡(luò)下載、讀磁盤等 IO 密集型任務(wù)卵蛉,也有壓縮颁股、裁剪、解碼等 CPU 密集型任務(wù)傻丝,可見圖片的處理過程非常消耗硬件資源甘有,當(dāng)出現(xiàn)大量的圖片同時處理時將面臨一些挑戰(zhàn),如何減輕 CPU 的壓力葡缰、如何減輕內(nèi)存的負(fù)擔(dān)亏掀,時間和空間總是需要權(quán)衡取舍,有時它們互補(bǔ)泛释,有時它們互斥滤愕。
二、架構(gòu)設(shè)計
界面展示類型組件需要有良好的深度定制性怜校,這就對架構(gòu)設(shè)計要求較高间影,難點(diǎn)在于區(qū)分變量與不變量,各模塊職責(zé)劃分茄茁,以及合理的抽象魂贬。總的來說思維方向是不變的裙顽,落地到代碼需要做很多的變化和取舍付燥。
1、IOP 思想
IOP 是一個大家都知道的理念愈犹,但是落地到一個 UI 類型的組件中該如何實(shí)施键科?
保證深度定制性
YBImageBrowser 中默認(rèn)有圖片展示、視頻播放兩個模塊甘萧,當(dāng)用戶不滿足于此萝嘁,比如可能需要加入一個廣告模塊(類似于微博圖片瀏覽器最后一頁)。為了拓展起來無障礙扬卷,配置數(shù)據(jù)源時就不能用具體的類型牙言,抽象出一個協(xié)議:
/// 數(shù)據(jù)源數(shù)組
@property (nonatomic, copy) NSArray<id<YBIBDataProtocol>> *dataSourceArray;
如此用戶便能自由的拓展其它模塊,只是協(xié)議方法必須要明確職責(zé)怪得,不能包含具體模塊的業(yè)務(wù)咱枉。比如默認(rèn)實(shí)現(xiàn)的YBIBImageData<YBIBDataProtocol>
類卑硫,圖片解碼壓縮等屬于這個模塊獨(dú)有的功能,所以不能讓<YBIBDataProtocol>
協(xié)議有所感知蚕断。
當(dāng)然欢伏,使用繼承方式利用多態(tài)特性也能具有拓展能力,這樣的好處是能提供默認(rèn)實(shí)現(xiàn)亿乳,缺點(diǎn)是不夠靈活硝拧,侵入性過強(qiáng)。單從制定規(guī)則的角度說葛假,使用協(xié)議遠(yuǎn)比基類來得好障陶。
兼顧便捷性
圖片瀏覽器上面通常有一些工具欄,比如頁碼指示器聊训、長按彈出的表單等抱究,這些東西首先肯定要保證定制性,所以抽象一個協(xié)議是理所當(dāng)然的:
@property (nonatomic, copy) NSArray<id<YBIBToolViewHandler>> *toolViewHandlers;
使用數(shù)組是因?yàn)榭赡苡卸鄠€協(xié)議實(shí)現(xiàn)者带斑。既然這是約束工具視圖的協(xié)議鼓寺,為什么要用id
類型而不是UIView
?因?yàn)榭紤]到可能用戶需要用一個中介者來實(shí)現(xiàn)這個協(xié)議勋磕,然后由這個中介者管理所有的UIView
妈候。
部分用戶是不希望由自己來實(shí)現(xiàn)這種功能的,所以有必要提供一個默認(rèn)實(shí)現(xiàn)類挂滓,默認(rèn)實(shí)現(xiàn)類中可能有與協(xié)議無關(guān)的屬性配置州丹,所以將其暴露出來讓用戶可以便捷的配置(這里是與協(xié)議同名的實(shí)現(xiàn)類):
// 可以修改其屬性
@property (nonatomic, weak, readonly) YBIBToolViewHandler *defaultToolViewHandler;
協(xié)議如何設(shè)計
協(xié)議可以讓組件主體向其它子模塊發(fā)送消息,但是當(dāng)子模塊想通過協(xié)議獲取組件主體的信息如何做杂彭?
直接通過協(xié)議方法:
@protocol
- (void)setName:(NSString *)name;
@end
這種方式需要子模塊將信息持有起來,更簡潔的方式是直接定義一個屬性:
@protocol
@property NSString *name;
@end
上面這兩種方式在name
這個屬性是常量時比較簡單吓揪,若name
會動態(tài)變化呢亲怠?那必然還需要寫更新邏輯。獲取動態(tài)信息更優(yōu)雅的方式是使用閉包:
@protocol
@property NSString *(^name)(void);
@end
主體中實(shí)現(xiàn)anyObject.name = ^NSString*{ return 動態(tài)計算結(jié)果 }
柠辞,在子模塊中团秽,只需要調(diào)用self.name()
就能獲取到實(shí)時的數(shù)據(jù)了。
有些方法可能會出現(xiàn)在多個協(xié)議中叭首,那抽象一些基協(xié)議就很有必要了习勤,這樣的好處不止是少寫幾句方法,在調(diào)用協(xié)議方法時還能復(fù)用代碼焙格,比如:
- (void)implementProtocolA:(id<ProtocolA>)anyObject {
協(xié)議有關(guān)图毕、具體類型無關(guān)的方法調(diào)用
}
2、解耦的意義
需要理解解耦的目的眷唉,并不是一說架構(gòu)就是解耦予颤。
解開耦合的兩個模塊互相了解很少囤官,就像男女朋友,“分手”比較容易蛤虐,不拖泥帶水党饮;而互相關(guān)聯(lián)的兩個模塊就像夫妻,多數(shù)情況下他們一生不會分離驳庭,要分離就叫“離婚”刑顺,關(guān)乎兩邊家庭,還要走法律程序饲常,非常復(fù)雜蹲堂。
所以,模塊與模塊之間是否需要解耦不皆,判斷它們是要做夫妻還是做男女朋友贯城。
比如 YBImageBrowser 中的旋轉(zhuǎn)處理類、數(shù)據(jù)中介者霹娄,它們就不需要解耦能犯,抽離出來的目的只是為了方便管理和瘦身。
3犬耻、離散配置與集約配置
對于不同的實(shí)例對象踩晶,它們的功能最好能離散配置,集約配置僅作為便捷管理枕磁,這樣才能保證場景覆蓋完全渡蜻。
比如使用 YBImageBrowser 時,用戶需要對特定某張圖片進(jìn)行單獨(dú)的預(yù)處理计济,就需要能離散配置茸苇。
4、談?wù)?SDWebImage 和 YYImage
SDWebImage 5.0 優(yōu)化了很多東西沦寂,很重要的一點(diǎn)是將很多集約配置的功能改為了離散配置学密。以前只能在進(jìn)入 YBImageBrowser 時緩存配置,然后更改為組件需要的配置传藏,退出 YBImageBrowser 時將緩存的配置還原腻暮,非常蛋疼。
還更新了一個重要的類 SDAnimatedImage毯侦,大概看了一下源碼哭靖,兩個缺點(diǎn):不能支持普通圖片,意味著要明確某張圖片是動圖侈离,才能用這個類(組件中為了讓用戶對圖片類型無感知试幽,筆者就需要拓展普通圖片的處理,成本較高)霍狰;解碼之前也沒有暴露一個根據(jù)圖片大小決議是否解碼的接口抡草,所以在處理超清大圖不夠靈活饰及。而對于 YYImage,只有一個問題康震,就是沒有暴露是否解碼的接口燎含。
所以最終還是選擇了 YYImage,更改了源碼讓其可以通過圖片的大小來動態(tài)判斷是否解碼腿短。直接使用 SDWebImage 的下載模塊和緩存模塊屏箍,避免其框架內(nèi)部的額外圖片處理浪費(fèi)資源。
5橘忱、構(gòu)建子依賴
為了讓用戶可選集成赴魁,特意做了子依賴,默認(rèn)是沒有視頻播放模塊的钝诚,方便了對代碼量要求嚴(yán)格的團(tuán)隊(duì)颖御。
三、性能優(yōu)化
UI 類型組件的性能優(yōu)化凝颇,涉及算法復(fù)雜度的一般較少潘拱,多數(shù)情況都是利用硬件的能力進(jìn)行立竿見影的優(yōu)化。
1拧略、任務(wù)異步化
最容易想到的一步就是把處理圖片的任務(wù)盡量放子線程芦岂,這會讓主線程倍感輕松。而有時由于各種原因垫蛆,外部需要先進(jìn)行一些阻塞主線程的操作(比如訪問磁盤 IO)禽最,然后才將結(jié)果寫入。這時可以提供一個閉包袱饭,類似于:
data = ^UIImage *{
return [UIImage customGetImageMethode];
}
如此川无,閉包代碼執(zhí)行線程就由組件控制了,同時還避免了持有讀取的這塊內(nèi)存虑乖,不過前提是這個閉包包含的是方法而不是結(jié)果舀透。
任務(wù)放異步線程時,線程上下文切換等會消耗一些時間决左,所以一般會降低任務(wù)的執(zhí)行速度,得益于多核設(shè)備走贪,讓我們可以在保證任務(wù)不阻塞主線程的前提下提升執(zhí)行效率佛猛,不過這需要在多個任務(wù)或者任務(wù)支持拆分時才能變得更快。
YBImageBrowser 幾乎將所有的耗時任務(wù)異步化了坠狡,對共有變量的修改都保證在同一線程所以避免了使用鎖继找。
2、分步緩存
YBImageBrowser 使用類實(shí)例來配置數(shù)據(jù)逃沿,數(shù)據(jù)處理后交付給 UICollectionViewCell 顯示婴渡。這也是很多 UI 類型組件的做法幻锁,將數(shù)據(jù)和界面分離,大概如下:
在數(shù)據(jù)的處理過程中边臼,需要將一些狀態(tài)交付給 Cell 做界面提示(比如 Loading哄尔、Toast),很多時候處理完成一個數(shù)據(jù)并非只有一個任務(wù)柠并。
對于 YBImageBrowser 來說岭接,每一張圖片都有一個這樣的數(shù)據(jù)模型,所以可能某些數(shù)據(jù)模型的任務(wù)會被中斷(這是后面要講的優(yōu)化)臼予,被中斷任務(wù)的數(shù)據(jù)模型有兩個結(jié)果鸣戴,一個是釋放,一個是待命粘拾。
當(dāng)這個數(shù)據(jù)模型是待命狀態(tài)窄锅,未來某一時刻恢復(fù)使用時,如果它之前做的“努力”白費(fèi)了缰雇,就需要返工做之前做過的任務(wù)入偷。所以為了減少重復(fù)“勞動”,可以對任務(wù)處理流程中產(chǎn)生的中間數(shù)據(jù)進(jìn)行緩存寓涨,恢復(fù)使用時直接從上次中斷的節(jié)點(diǎn)開始任務(wù)盯串,以此來優(yōu)化性能,典型的空間換時間戒良。
同時体捏,需要定義一些變量或枚舉,標(biāo)識當(dāng)前的狀態(tài)糯崎,在進(jìn)入不可重入異步任務(wù)之前做個判斷几缭,避免發(fā)起多個相同的異步任務(wù)。
3沃呢、數(shù)據(jù)預(yù)加載
預(yù)加載是一個常規(guī)的優(yōu)化思路年栓,UI 類型組件的數(shù)據(jù)預(yù)加載往往可以放在動畫轉(zhuǎn)場、數(shù)據(jù)內(nèi)容將要顯示時薄霜。
YBImageBrowser 會在開始轉(zhuǎn)場動畫時立即加載目標(biāo)數(shù)據(jù)模型某抓,一般零點(diǎn)幾秒的動效就能讓用戶無感知預(yù)加載了;在加載某一個數(shù)據(jù)模型時惰瓜,還會“均分”加載兩邊的數(shù)據(jù)模型否副,當(dāng)用戶滑動不是很快時,多數(shù)情況下一張圖片已經(jīng)加載好了崎坊。
預(yù)加載的邏輯需要根據(jù)具體的業(yè)務(wù)需求來處理备禀,圖片瀏覽器的滑動不會跳躍,所以預(yù)加載臨近的數(shù)據(jù)模型是個不錯的選擇。
4曲尸、任務(wù)中斷
前面也說了處理圖片的任務(wù)對硬件的消耗很大赋续,在使用圖片瀏覽器時,用戶快速的滑動圖片另患,將會讓大量的數(shù)據(jù)模型發(fā)起處理流程纽乱,這對 CPU 會造成巨大的壓力,也會讓內(nèi)存峰值飆升柴淘。
所以我們需要及時的中斷不重要的任務(wù)迫淹,騰出 CPU 資源和內(nèi)存做優(yōu)先級高的事,那么怎么判斷優(yōu)先級高低为严?
判斷優(yōu)先級高低需要根據(jù)具體的業(yè)務(wù)敛熬,在圖片瀏覽器中,數(shù)據(jù)模型有一個代理delegate
用來接收處理結(jié)果第股,這個代理就是 Cell应民。筆者假定:當(dāng)delegate
對象從有到無時,說明這個數(shù)據(jù)模型的任務(wù)可以中斷了夕吻。
delegate
從有到無體現(xiàn)到界面上就是诲锹,用戶滑到了某張圖片,然后又劃開了這張圖片涉馅,那么就可以認(rèn)為归园,用戶短時間內(nèi)再滑回這種圖片的幾率較小。當(dāng)然這種假定是不嚴(yán)謹(jǐn)?shù)闹煽螅彩菣?quán)宜之計庸诱。
可重入方法的處理
對于同一個數(shù)據(jù)模型來說,它的異步任務(wù)可能是允許重入的晤揣,比如圖片瀏覽器的裁剪功能桥爽,有可能上一個裁剪任務(wù)未完成,下一個任務(wù)就發(fā)起了昧识,而上一個任務(wù)結(jié)果已經(jīng)沒有意義了钠四,那及時的中斷上一個任務(wù)就非常有必要了。
這種處理方案就比較傳統(tǒng)了跪楞,使用一個計數(shù)器遞增:
int32_t value = [_cuttingSentinel increase];
BOOL (^isCancelled)(void) = ^BOOL(void) {
return value != self->_cuttingSentinel.value;
};
然后在異步任務(wù)的過程中缀去,添加足夠多的if(isCancelled()) return
,盡量降低中斷的粒度就行了甸祭。
四朵耕、內(nèi)存優(yōu)化
圖片處理不光是一個 CPU 敏感的業(yè)務(wù),還是一個內(nèi)存敏感的業(yè)務(wù)淋叶,所以在上面做了提升性能的各種方案過后,還需要對內(nèi)存進(jìn)行控制,不然很容易就內(nèi)存警告或 OOM 了煞檩。
1处嫌、數(shù)據(jù)模型無關(guān)的緩存設(shè)計
在需要讓用戶配置不定數(shù)量的數(shù)據(jù)模型的組件設(shè)計中,一般使用數(shù)組或代理方法的方式配置斟湃,數(shù)組的特點(diǎn)就是始終會持有所有數(shù)據(jù)模型熏迹,代理的特點(diǎn)就是用完數(shù)據(jù)模型即扔掉。
那么凝赛,若數(shù)據(jù)模型緩存的數(shù)據(jù)將會占有大量的內(nèi)存怎么辦注暗?我們需要一套內(nèi)存管理及淘汰策略,那么如何來設(shè)計呢墓猎?
最容易想到的方案
既然數(shù)據(jù)模型緩存有大內(nèi)存捆昏,直接將數(shù)據(jù)模型釋放不就行了,那么就必須要讓用戶使用代理的方式配置數(shù)據(jù)源毙沾,才能用完就釋放骗卜。但是這樣又帶來一個問題,如果用一個釋放一個左胞,那么用戶切換到上一個數(shù)據(jù)又得重新加載了寇仓。
所以,還需要做一個局部的緩存烤宙,將一定數(shù)量的數(shù)據(jù)模型緩存起來(比如緩存 9 個)遍烦,最大限度保證用戶體驗(yàn)。
但是躺枕,這種方案無法支持?jǐn)?shù)組服猪。
優(yōu)化過后的方案
筆者建議的方式是,使用與數(shù)據(jù)模型無關(guān)的內(nèi)存管理策略屯远,具體做法如下:
1蔓姚、定義一個內(nèi)存管理中介者。
2慨丐、定義所有數(shù)據(jù)模型可訪問的內(nèi)存管理散列容器(key:數(shù)據(jù)模型地址, value:大內(nèi)存數(shù)據(jù))坡脐。
3、定義散列容器的數(shù)據(jù)淘汰策略(比如直接用 NSCache 控制數(shù)量)
4房揭、將數(shù)據(jù)模型產(chǎn)生的大內(nèi)存數(shù)據(jù)存入散列容器备闲,而自身不去引用。
5捅暴、使用時從散列容器中拿數(shù)據(jù)恬砂。
6、數(shù)據(jù)模型釋放時從散列容器中刪除數(shù)據(jù)蓬痒。
如此泻骤,便可以同時支持?jǐn)?shù)組和代理配置方式,進(jìn)行無障礙緩存淘汰了。
中介者是否使用單例
這個圖片內(nèi)存管理中介者第一反應(yīng)可能是做一個單例狱掂,實(shí)際上不需要演痒,如果是單例的話可能由于數(shù)據(jù)模型的內(nèi)存泄漏而導(dǎo)致單例內(nèi)存清除不徹底,做為一個常駐內(nèi)存是非城鞑遥可怕的鸟顺。
所以中介者應(yīng)該每一個圖片瀏覽器單獨(dú)一個,然后跟隨圖片瀏覽器的釋放而釋放器虾,把主要責(zé)任給一個人而不是分?jǐn)偟剿腥艘彩且粋€理念吧讯嫂,況且在這個場景下,跟隨圖片瀏覽器釋放是一個保底方式(數(shù)據(jù)模型釋放異常)兆沙。
正在使用數(shù)據(jù)的防護(hù)
這種方案帶來的問題就是當(dāng)前數(shù)據(jù)正在使用欧芽,然后中介者就將它釋放了,這會帶來異常挤悉,所以筆者額外使用一個散列容器來持有目前不能釋放的數(shù)據(jù)渐裸,當(dāng)數(shù)據(jù)模型的失去delegate
時,移除這個散列容器對應(yīng)的數(shù)據(jù)装悲。
2昏鹃、圖片裁剪的優(yōu)化
當(dāng)圖片過大需要壓縮顯示,放大時圖片的”可視區(qū)域”表明了在原始圖片中的大小和位置诀诊,裁剪原圖的這個區(qū)域以顯示清晰圖片洞渤。當(dāng)原圖很大時這個區(qū)域也會比較大,如果直接讓原圖繪制在這樣一個上下文中會消耗很大的內(nèi)存属瓣,我們需要的僅僅是屏幕所顯示的大小就行了载迄,所以要將這個“可視區(qū)域”的較大圖繪制到更小的上下文中。
目前的解決方案是:先CGImageCreateWithImageInRect(...)
生成CGImageRef
(未解碼時不會造成內(nèi)存負(fù)擔(dān))抡蛙,然后將這個比較大的”可視區(qū)域”縮小到屏幕的大小范圍护昧,最后將CGImageRef
繪制上去。
然而當(dāng)觸發(fā)裁剪的比例比較小時(比如放大 0.1 倍就觸發(fā)裁剪)粗截,仍然會消耗很大的 CPU 資源惋耙,為了減輕這種問題,組件內(nèi)部讓觸發(fā)裁剪的縮放比例與原圖的比例正相關(guān)動態(tài)變化熊昌。
3绽榛、低內(nèi)存設(shè)備降低性能
這是最后的補(bǔ)救措施,低內(nèi)存設(shè)備確實(shí)在加載數(shù)張比較清晰的圖片時會非常吃力婿屹,CPU 也不給力灭美,所以組件內(nèi)部在物理內(nèi)存較小的設(shè)備中直接降低性能,減少內(nèi)存占用昂利。
后語
每過段時間都會審視自己的代碼届腐,重構(gòu)是個苦力活铁坎,特別是代碼量比較大,邏輯較為復(fù)雜的項(xiàng)目犁苏,別看這篇文章三言兩語厢呵,因?yàn)槎际切┙Y(jié)論。
做完過后確實(shí)是有所收益的傀顾,但是收益不大,耗費(fèi)了大量的腦細(xì)胞碌奉,很多時間可能都是糾結(jié)于系統(tǒng)框架的各種坑短曾。對于 YBImageBrowser 理論上我是不會再大規(guī)模重構(gòu)了,花費(fèi)了太多時間赐劣。
天天看到各位大佬們刷題嫉拐,心里比較慌,所以還是要選擇一個收益比較大的方式學(xué)習(xí)魁兼,接下來開始加入刷題大軍婉徘?