這篇文章介紹ZYCornerRadius解決生產中圓角帶來的離屏渲染問題的思路。
日常生產中app布局離不開美麗的圓角(RounderCorner),特別是用圓角UIImageView來做數據呈現交互,但是這種柔和易於讓人接受的視圖效果並不僅僅是改變了一個形狀那麼簡單,需要付出一定的性能代價。
相信這已經是總所周知的問題了,日常我們使用layer的兩個屬性,簡單的兩行代碼就能實現圓角的呈現
imageView.layer.cornerRadius = CGFloat(10); imageView.layer.masksToBounds = YES;
由於這樣處理的渲染機制是GPU在當前屏幕緩沖區外新開辟一個渲染緩沖區進行工作,也就是離屏渲染,這會給我們帶來額外的性能損耗,如果這樣的圓角操作達到一定數量,會觸發緩沖區的頻繁合並和上下文的的頻繁切換,性能的代價會宏觀地表現在用戶體驗上----掉幀。這也是我親身體驗過的,有一次朋友在玩我手機的時候問我為什麼會卡,看了後才發現原來是一個充滿圓形頭像的TableView。
屏幕的渲染機制這裡就不copy了,很多朋友的文章也討論過這樣的問題。這篇文章有深入介紹屏幕顯示機制。這裡順便貼一下我筆記裡記錄的會引發離屏渲染的操作,給大家做個記憶捆綁,正確與否大家可以自己思量。
The following will trigger offscreen rendering:
Any layer with a mask (layer.mask)
Any layer with layer.masksToBounds / view.clipsToBounds being true
Any layer with layer.allowsGroupOpacity set to YES and layer.opacity is less than 1.0
Any layer with a drop shadow (layer.shadow*).
Any layer with layer.shouldRasterize being true
Any layer with layer.cornerRadius, layer.edgeAntialiasingMask, layer.allowsEdgeAntialiasing
Text (any kind, including UILabel, CATextLayer, Core Text, etc).
Most of the drawing you do with CGContext in drawRect:. Even an empty implementation will be rendered offscreen.
因為這些效果均被認為不能直接呈現於屏幕,而需要在別的地方做額外的處理預合成。具體的檢測我們可以使用Instruments的CoreAnimation。
ZYCornerRadius
以下介紹ZYCornerRadius(以Category的方式工作)對UIImageView設置圓角會觸發離屏渲染的解決思路,有什麼問題和建議還請大家發issues指導更正。
先上一張性能對比圖
測試設備6P,屏幕中有40張尺寸為20*20的小圖片,使用masksToBounds切角處理時幀率大大下降至20+,使用ZYCornerRadius時幀率保持在57+,性能接近0損耗。
既然我們要避免讓GPU觸發離屏,那麼只能把兵符交給CPU,雖然CPU對圖形的處理能力不及GPU,但由於這種處理的難度不大,且代價肯定遠小於上下文切換。 其實一開始的想法就是從-drawRect下手,但是看了某篇文章(找不回來了)後打消了這個念頭,-drawRect的確存在很多性能坑。
既然不能讓控件masksToBounds,ZYCornerRadius就從圖片本身下手,我使用在UIKit中對Core Graphics有一定封裝的應用層類UIBezierPath,對圖片進行破壞性的切角,破壞性僅僅是對切去部分而言,當然這操作是在CPU內完成的,而後我只需要取到處理完成的bitmap(可為UIImage對象)交給GPU顯示於屏幕即可。
/** * @brief clip the cornerRadius with image, UIImageView must be setFrame before, no off-screen-rendered */ - (void)zy_cornerRadiusWithImage:(UIImage *)image cornerRadius:(CGFloat)cornerRadius rectCornerType:(UIRectCorner)rectCornerType { CGSize size = self.bounds.size; CGFloat scale = [UIScreen mainScreen].scale; CGSize cornerRadii = CGSizeMake(cornerRadius, cornerRadius); UIGraphicsBeginImageContextWithOptions(size, NO, scale); if (nil == UIGraphicsGetCurrentContext()) { return; } UIBezierPath *cornerPath = [UIBezierPath bezierPathWithRoundedRect:self.bounds byRoundingCorners:rectCornerType cornerRadii:cornerRadii]; [cornerPath addClip]; [image drawInRect:self.bounds]; self.image = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); }
可見,我對圖片進行了切角處理後,將得到的含圓角UIImage通過-setImage傳給了UIImageView。操作沒有觸發GPU離屏渲染,過程在CPU內完成,而後我在Demo中證實了這個方法。
順便一提這裡還存在一個性能問題,Color Blended Layers,UIGraphicsBeginImageContextWithOptions(<#cgsize>, <#bool>, <#cgfloat>)的第二個參數是透明通道的開關,true則為不透明。以下兩張圖是參數傳NO or YES在模擬器中打開了Color Blended Layers Debug所看見的區別:
一些沒有被設置為opacity的圖層,因為透明通道的存在,系統需要去計算圖層堆疊後像素點的真實顏色,在Instruments的測試中也是可以高亮標顯出來,這種性能的損耗程度我還沒有專門去測試。但是在上圖可以看見如果設置為不包含透明通道,我們圖片被剪去的部分就沒有了顏色(黑漆漆一片),這裡使用的解決方案就是在圖片上下文中先畫一層backgroundColor,缺點就是需要傳入:
/** * @brief clip the cornerRadius with image, draw the backgroundColor you want, UIImageView must be setFrame before, no off-screen-rendered */ - (void)zy_cornerRadiusWithImage:(UIImage *)image cornerRadius:(CGFloat)cornerRadius rectCornerType:(UIRectCorner)rectCornerType backgroundColor:(UIColor *)backgroundColor { CGSize size = self.bounds.size; CGFloat scale = [UIScreen mainScreen].scale; CGSize cornerRadii = CGSizeMake(cornerRadius, cornerRadius); UIGraphicsBeginImageContextWithOptions(size, YES, scale); if (nil == UIGraphicsGetCurrentContext()) { return; } UIBezierPath *cornerPath = [UIBezierPath bezierPathWithRoundedRect:self.bounds byRoundingCorners:rectCornerType cornerRadii:cornerRadii]; UIBezierPath *backgroundRect = [UIBezierPath bezierPathWithRect:self.bounds]; [backgroundColor setFill]; [backgroundRect fill]; [cornerPath addClip]; [image drawInRect:self.bounds]; self.image = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); }
傳入紅色的背景顏色,打開Color Blended Layers Debug與原先對比:
目前我們解決了離屏渲染的問題,可這並不符合實際生產,在app中顯示的網絡圖片我們不可能事先知道並且調用- (void)zy_cornerRadiusWithImage:cornerRadius:rectCornerType:來進行切角,也不可能每次都還要寫個SDWedImage的complete回調去做這個操作,我決定用swizzleMethod的辦法來處理,關於對swizzleMethod的認識,可以看看我這篇文章。
我們把對self.image切角處理放在每次layoutSubviews的時候完成,大家看到這裡頓時把我臭罵了一頓。。。在Category裡重寫-layoutSubviews的致命的,這的確會導致整個項目下所有的UIImageView都會去執行這個山寨的-layoutSubviews,別慌關掉文章,給個機會繼續看下去。
首先我們需要將使用者傳入的切角參數保存起來,供-layoutSubviews切角時使用,因為category不支持擴展屬性,所以我們可以用runtime來做:
/** * @brief set cornerRadius for UIImageView, no off-screen-rendered */ - (void)zy_cornerRadiusAdvance:(CGFloat)cornerRadius rectCornerType:(UIRectCorner)rectCornerType { objc_setAssociatedObject(self, &kRadius, @(cornerRadius), OBJC_ASSOCIATION_RETAIN_NONATOMIC); objc_setAssociatedObject(self, &kRoundingCorners, @(rectCornerType), OBJC_ASSOCIATION_RETAIN_NONATOMIC); objc_setAssociatedObject(self, &kIsRounding, @(0), OBJC_ASSOCIATION_RETAIN_NONATOMIC); [self.class swizzleMethod:@selector(layoutSubviews) anotherMethod:@selector(zy_LayoutSubviews)]; }
細心的朋友可以看見上面這段代碼裡的+swizzleMethod,我將調用了- (void)zy_cornerRadiusAdvance:cornerRadius:rectCornerType:的UIImageView對象的-layoutSubviews方法的實現轉移到了我自己的方法-zy_LayoutSubviews上,也就是說我不需要去重寫-layoutSubviews,而主動調用過-zy_cornerRadiusAdvance的UIImageView對象的-layoutSubviews的實現卻被我換成了-zy_LayoutSubviews,源代碼在Demo中有。ok,於是在-zy_LayoutSubviews中收官:
- (void)zy_LayoutSubviews { [super layoutSubviews]; NSNumber *radius = objc_getAssociatedObject(self, &kRadius); NSNumber *roundingCorners = objc_getAssociatedObject(self, &kRoundingCorners); [self zy_cornerRadiusWithImage:self.image cornerRadius:radius.floatValue rectCornerType:roundingCorners.unsignedLongValue]; }
這樣不需要離屏渲染的UIImageView圓角工具ZYCornerRadius就完成了,有問題或建議歡迎發issues交流,還希望大家star以支持啊,謝謝!
Usage:ZYCornerRadius提供兩種使用方式
Category方式:
導入頭文件
#import "UIImageView+CornerRadius.h"
創建圓角半徑為6的UIImageView(三種方式):
//1 UIImageView *imageView = [UIImageView zy_cornerRadiusAdvance:6.0f rectCornerType:UIRectCornerAllCorners]; imageView.image = [UIImage imageNamed:@"mac_dog"]; //2 UIImageView *imageView = [[UIImageView alloc] initWithCornerRadiusAdvance:6.0f rectCornerType:UIRectCornerAllCorners]; imageView.image = [UIImage imageNamed:@"mac_dog"]; //3 UIImageView *imageView = [[UIImageView alloc] init]; [imageView zy_cornerRadiusAdvance:6.0f rectCornerType:UIRectCornerAllCorners]; imageView.image = [UIImage imageNamed:@"mac_dog"];
創建圓形的UIImageView(三種方式):
//1 UIImageView *imageView = [UIImageView zy_roundingRectImageView]; imageView.image = [UIImage imageNamed:@"mac_dog"]; //2 UIImageView *imageView = [[UIImageView alloc] initWithRoundingRectImageView]; imageView.image = [UIImage imageNamed:@"mac_dog"]; //3 UIImageView *imageView = [[UIImageView alloc] init]; [imageView zy_cornerRadiusRoundingRect]; imageView.image = [UIImage imageNamed:@"mac_dog"];
子類ZYImageView方式同理:
導入頭文件
#import "ZYImageView.h"
使用方式同理
以下列出ZYCornerRadius所開放的主要的func:
配置一個圓角UIImageView,傳入圓角半徑和圓角類型
+ (UIImageView *)zy_cornerRadiusAdvance:(CGFloat)cornerRadius rectCornerType:(UIRectCorner)rectCornerType; - (instancetype)initWithCornerRadiusAdvance:(CGFloat)cornerRadius rectCornerType:(UIRectCorner)rectCornerType;
配置一個圓形的UIImageView
+ (UIImageView *)zy_roundingRectImageView; - (instancetype)initWithRoundingRectImageView;
直接為UIImageView設置圓角圖片,傳入UIImage,圓角半徑和圓角類型,當次有效!
- (void)zy_cornerRadiusWithImage:(UIImage *)image cornerRadius:(CGFloat)cornerRadius rectCornerType:(UIRectCorner)rectCornerType;
以下記錄失敗過程...
嘗試在-drawRect中做切角操作
1.內存使用過大,造成更多的性能損耗
嘗試從init出發
1.需要事先傳入Image,而且當Image改變後無效,不適合實際生產
嘗試從-layoutSubviews下手
1.在Category中重寫該方法會造成不可挽回的結果
在setImage中設置好標識符開關,在layoutSubviews中判斷開關狀態再執行操作
1.雖然解決了對其他UIImageView的影響,可實現方式過於投機取巧過於費力。
嘗試直接從重寫-setImage下手
1.直接重寫會導致無限遞歸
2.自己重寫為UIImageView顯示圖片的機制,不熟悉源碼實現,擔心造成什麼遺漏。
最壞的打算,大膽使用swizzleMethod。
Relation:@liuzhiyi1992 on Github