作为一名程序员,我们自然是希望自己写出的代码是可以让人觉得赏心悦目的,然而现实却是残酷的,老板只在乎我们出活没,产品经理也只关心需求实现没,然后不停的告诉你,这里需要改一下,那里需要改。。。OMG,项目完工了。接着你会发现你项目的
第一个版本往往是丑陋不堪,甚至伴随着极多的Bug,就如我曾经听说过的那句话——程序员每天70%的工作时间是在敲代码,剩下的30%是在给自己挖坑,写Bug。
所以,个人觉得,一个项目的好坏往往在于后期的维护,那么项目的优化就显得更为重要了。稍微对之前工作中的遇到的情况以及个人日常学习总结的知识点,加以整合,写出这篇文章。
一、复用
1.使用复用机制:UITableViewCell、UICollectionView、sectionHeader/Footer
目前,市面上所有商用App中,使用最多的控件莫过于UITableView了,可以随意定制样式的Cell给我们展示信息带来了极大的便利,然而频繁的创建Cell会导致内存暴增,幸好苹果在tableView中为我们提供了Cell的复用机制(总感觉这点是在凑字数,现在还会有不复用Cell的程序员嘛。。。)
复用机制是一个很优秀的机制,但是不正确的使用却会给我们的程序带来很多问题。下面用TableView的Cell来举例:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:cellId forIndexPath:indexPath];
[cell.textLabel setText:@"even"];
//奇数行的Cell背景色为红色
if (indexPath.row % 2) {
[cell.textLabel setText:@"odd number"];
cell.backgroundColor = [UIColor redColor];
}
return cell;
}
我们写了一段代码,需求是创建一个tableView,奇数行Cell背景色为,并且显示文字为odd number,然后运行效果如下图,刚运行出的效果是符合我们的需求,但是一旦我们开始滑动TableView,就会和出现Bug——偶数行也变成了红色。
所以这个需求的正确代码应该是这样的
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:cellId forIndexPath:indexPath];
//奇数行的Cell背景色为红色
if (indexPath.row % 2) {
[cell.textLabel setText:@"odd number"];
cell.backgroundColor = [UIColor redColor];
}else{
[cell.textLabel setText:@"even"];
cell.backgroundColor = [UIColor whiteColor];
}
return cell;
}
2.复用高开销对象:NSDateFormater,NSCalendar,NSRegularExpression
关于NSDateFormatter、NSCalendar是高开销对象这件事,一直听大家这样说,有这么个概念,但从没想过为什么。最近我翻阅了苹果官方文档,并没有相关描述,Google了一下,说是NSDateFormatter之类的对象,在实例化的时候,速度非常慢,所以我写了如下代码来验证:
- (void)viewDidLoad {
[super viewDidLoad];
NSDate *date1 = [NSDate date];
for (int i = 0; i < 10000; i++) {
NSString *str = [NSString new];
}
NSLog(@"createNSString = %f",[date1 timeIntervalSinceNow]);
NSDate *date2 = [NSDate date];
for (int i = 0; i < 10000; i++) {
NSCalendar *calendar = [NSCalendar new];
}
NSLog(@"createNSCalendar = %f",[date2 timeIntervalSinceNow]);
NSDate *date3 = [NSDate date];
for (int i = 0; i < 10000; i++) {
NSDateFormatter *dateFormatter = [NSDateFormatter new];
}
NSLog(@"createNSDateFormatter = %f",[date3 timeIntervalSinceNow]);
}
为避免试验的效果不明显以及偶然性,我将创建过程重复了一万次,并将上述代码运行了三次,输出结果如下图:
可以很明显的看出,相较于创建NSString来说,NSDateFormatter的创建所花费的时间相当的恐怖,而NSCalendar的消耗时间波动较大,原因不得而知。
那么如何服用NSDateFormatter这类高开销的类呢,通常情况下我会创建一个一个分类,然后在分类中实现单例的方法,这样在整个项目中都可以调用,如果大家有更好的处理方法,欢迎交流。
需要注意的是:设置 NSDateFormatter 的 date format 跟创建一个新的 NSDateFormatter 对象一样慢,因此当你的程序中要用到多种格式的 date format,而每种又会用到多次的时候,你可以尝试为每种 date format 创建一个可复用的 NSDateFormatter 对象来提供程序的性能。
二、减少计算量
1.通过frame布局
说到了布局,那我们必然需要谈到AutoLayout,那么AutoLayout与frame布局的区别在哪?
首先我们必须知道,AutoLayout 用来取代Frame布局在遇见屏幕尺寸多种多样的不足。
关键概念:AutoLayout核心的参照,就是任何一个View都可以参照另一个View。无论添加多少约束,都需要定位好X Y W H,少了不行,多了更不行,少了不行,多了更不行,少了不行,多了更不行。重要的事情说三遍。
然而,AutoLayout是怎样通过一系列约束去进行布局的呢?
实际上,AutoLayout是在layoutSubview的时候,通过设置好的布局约束,去计算frame,最终按照计算好的frame进行布局。所以AutoLayout布局是需要实现大量计算的,大量的计算必然会影响性能,而frame布局可以很好的解决这个问题,但是通过frame布局,又没办法很好的适配不同的版本,如何取舍,还是看你自身的需求吧。
2.控件frame缓存
缓存控件的frame也是一种很好的优化系统性能方案,同样是为了减少计算量,结合TableView等需要大量复用的控件,可以产生很好的效果。
3.cell行高缓存
tableViewCell的自定义十分便捷,我们经常会根据内容不同去设置不同样式的Cell,这就导致Cell的行高会有巨大的差异,这时我们就需要根据Cell去动态的计算行高,但这样实际上带来一个很不好的结果。在tableView的使用过程中,会一直对Cell的高度进行计算,性能好的机器或许还行,性能差些的机器运行起来就不那么流畅了。
那么我们要怎么解决这样的问题呢?通常我们会将数据传入一个模型中,将cell的行高提前计算好并存储起来,最终通过TableView的代理方法(CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath,将模型中存储的行高取出并返回。
注意:(CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath这个方法会调用很多次,千万不要在里面做计算,卡顿现象会十分明显。
4.按需加载
按需加载,即根据需求,加载数据,可能这样还是不好理解,举个例子吧,当我们翻微信聊天记录或者是刷微博的时候,快速滑动,中间快速划过的位置就可以不去处理,只加载将会显示在屏幕上的数据,因此我们需要事先计算出停止滑动后将会现在屏幕中的Cell的IndexPath。
@implementation ViewController{
UITableView *_table;
NSMutableArray *_ArrM;//用以存储将会显示在屏幕上的Cell的indexPath
}
//计算停止拖动后将会显示的Cell
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset{
//通过偏移量拿到indexPath
NSIndexPath *index = [_table indexPathForRowAtPoint:CGPointMake(0, targetContentOffset->y)];
//拿到当前屏幕上的第一个cell的indexPath
NSIndexPath *currentIndex = [[_table indexPathsForVisibleRows] firstObject];
//设置按需处理的条件值
int skipCount = 14;
if (labs(currentIndex.row - index.row) > skipCount) {
NSArray<NSIndexPath *> *array = [_table indexPathsForRowsInRect:CGRectMake(0, targetContentOffset->y, self.view.bounds.size.width, self.view.bounds.size.height)];
_ArrM = [NSMutableArray arrayWithArray:array];
}
}
//注意要在下面两个方法中将用以储存cell的indexPath的数组清空,否则会出现Bug
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView{
[_ArrM removeAllObjects];
}
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView{
[_ArrM removeAllObjects];
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:cellId forIndexPath:indexPath];
if (_ArrM.count > 0) {
//判断数组中是否存在当前cell,没有直接return
BOOL result = [_ArrM containsObject:[NSIndexPath indexPathForRow:indexPath.row inSection:indexPath.section]];
if (!result) {
NSLog(@"打酱油");
return cell;
}
}
[cell.textLabel setText:[NSString stringWithFormat:@"%ld",(long)indexPath.row]];
//奇数行的Cell背景色为红色
if (indexPath.row % 2) {
cell.backgroundColor = [UIColor lightGrayColor];
}else{
cell.backgroundColor = [UIColor whiteColor];
}
return cell;
}
最终程序运行起来,效果如下图所示
一开始我快速滑动,可以看出中间滚过屏幕的部分cell完全重用了其它cell,而我需要显示的cell并没有出现重用问题。
5.图片尺寸匹配UIImageView
当我们为UIImageView设置图片的时候,不论图片原先的大小如何,我们设置的图片总是能完整的显示在UIImageView中,当然前提是我们没有去手动设置ImageView的ContentMode。这是因为我们在为ImageView设置图片的时候,系统会对图片进行压缩或者是拉伸,以保证图片可以完整的显示在视图中。
为此我们可以为ImageView设置一个大小合适的图片,我们有两种方法可以去实现。第一,找美工帮你做一张你想要的图片,但是不同设配的适配依然会导致问题出现,或许有人会说,让美工做一套不就好了?拜托,你确定别人会搭理你?再说,拿到一堆图片,难道设置图片前还要进行一堆判断?你确定这样能提升性能?所以我们只能选择第二种方法,自己写代码。代码其实很简单
@implementation UIImage (scaleImage)
- (instancetype)wa_scaleImageWith:(CGSize)size{
UIGraphicsBeginImageContextWithOptions(size, YES, [UIScreen mainScreen].scale);
CGRect rect = CGRectMake(0, 0, size.width, size.height);
[self drawInRect:rect];
UIImage *scaleImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return scaleImage;
}
是不是很简单,当然如果只是这样还是存在问题的,你们可以想一下,具体问题我们后面再说。
三、异步
1.请求网络数据
网络请求是个耗时操作,如果将网络请求放在主线程之上,那么主线程的卡顿现象必然会十分严重。所以一切涉及到网络请求的操作,我们都需要尽量将它放到子线程去进行,同时,为保证线程安全,对UI控件进行赋值的操作应当回到主线程之后再执行。
2.绘制
drawRect:
首先我们需要明白一件事情,我们这里所指的绘制并不是UIView中的DrawRect方法,因为此方法是十分消耗内存的,光说不练假把式,为此我写了下面一段代码:
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
drawView *draw = [[drawView alloc]initWithFrame:self.view.bounds];
[self.view addSubview:draw];
}
@implementation drawView
- (void)drawRect:(CGRect)rect {
UIBezierPath *path = [UIBezierPath bezierPathWithRect:rect];
[path fill];
}
在代码中我只做了一件事,用drawRect方法绘制了一个矩形,为了避免其他操作带来的误差,我连颜色都没有填充。矩形的大小和视图的大小是一样的,运行代码时,我使用的是6s的模拟器。
上面的图片是一个什么都没做的干净程序所占用的内存,下面的图是我通过DrawRect方法绘制视图并添加到控制器中所占用的内存。差了将近4MB的内存,这还仅仅只是一个控件而已。想要了解更详尽的,请参照这篇文章内存恶鬼drawRect,作者在这篇文章及其后续文章中,作者详细介绍了其原理。
图形上下文绘制
自己挖的坑,自己要把填上,现在说下上文留下的问题,通过图形上下文绘制合适大小的图片,会有什么问题。
其实代码本身已经能完成我们的需求,但是通过图形上下文去绘制,其实是一项十分耗时的操作。我在上文的代码中又增加了几句代码,打印出绘制所消耗的时间。
绘制一张图片我们花费了将近0.03秒的时间,或许你会觉得这样的时间已经很少了,但是你要知道这是计算机花费了0.03秒,再对比一下我们上文中实例化一个对象所消耗的时间,0.03秒是不是已经很多了?所以上面那段代码实际上我们是要做一下修改的。
#import "UIImage+scaleImage.h"
@implementation UIImage (scaleImage)
- (void)wa_scaleImageWith:(CGSize)size completion:(void (^)(UIImage *))completion{
dispatch_async(dispatch_get_global_queue(0, 0), ^{
UIGraphicsBeginImageContextWithOptions(size, YES, [UIScreen mainScreen].scale);
CGRect rect = CGRectMake(0, 0, size.width, size.height);
[self drawInRect:rect];
UIImage *scaleImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
dispatch_async(dispatch_get_main_queue(), ^{
if (completion != nil) {
completion(scaleImage);
}
});
});
}
最终,完整版的代码大概就是这个样子。
四、图层混合
谈到图层混合,首先我们应该了解什么是图层混合。通俗的来说,图层就是文字或者图形等元素的集合体,每一个图层都是由许多像素组成的,而图层又通过上下叠加的方式来组成整个图像,
我们平常工作中使用的UI控件大多都是由许多图层组合而成。如果某一块区域上覆盖了多个layer,最后的显示效果受到这些layer的共同影响,在计算机中,内容用颜色来表示,形状用透明度来表示,而颜色由R、G、B三种元素构成。
这样说,或许不太容易理解,那让我们换个说法,以前上学的时候,大家肯定或多或少都用到过颜料,调色这个词想必也不陌生,我们往调色盘中所添加的各色颜料比重,决定了我们的RGB值,向调色盘中加多少清水,决定了Alpha值。在我的理解当中,系统在发生图层混合时,也会进行类似于调色的工作。举个例子:我们创建两个视图,一个绿色,一个红色,绿色视图在红色之上,绿色视图透明度的改变会影响整个视图颜色的变化。代码如下
@interface ViewController ()
@property (weak, nonatomic) IBOutlet UISlider *alpha;
@end
@implementation ViewController{
UIView *_green;
UIView *_red;
}
- (void)viewDidLoad {
[super viewDidLoad];
_green = [[UIView alloc]initWithFrame:self.view.bounds];
_green.backgroundColor = [UIColor greenColor];
[self.view insertSubview:_green belowSubview:_alpha];
_red = [[UIView alloc]initWithFrame:self.view.bounds];
_red.backgroundColor = [UIColor redColor];
[self.view insertSubview:_red belowSubview:_green];
[_alpha addTarget:self action:@selector(alphaBeChanged) forControlEvents:UIControlEventValueChanged];
}
- (void)alphaBeChanged{
_green.alpha = 1 - _alpha.value;
}
图层混合的后果是会消耗一定量的GPU资源,当上层视图的透明度为1(即不透明)时,GPU会忽略下层所有Layer,减少很多不必要的计算,节约GPU资源。那么我们要怎么解决这种问题呢?首先我们要明白哪些情况下,会发生图层混合,总结了一下大概有这么几种情况。
可调整大小的图片易导致图层混合
任何 opaque = NO 或者是背景色的 alpha 值小于 1 的图层
任何 alpha 值小于 1 的图层
任何图层的 layer.content 或任何 UIImageView 的 UIImage 有一个 alpha 通道
很多文章中都有提到把控件的 opaque = YES ,但这种方法一般情况下用处不大,因为不论是我们在IB中创建的控件还是用代码创建的控件,其
opaque 默认就是 YES ,也就是说不需要去手动设置,也不会出现图层混合的情况。但是需要注意的一点是,如果你在创建一个控件的时候并没有给它设置背景色,那么他其实是透明的。下图我创建了一个控制器,采用了modal的方式展示,控制器中只有一个黄色文字的label。
所以,给创建的控件设置背景色可以有效的解决图层混合的问题,而对于UIImageView来说,不仅要做到控件自身不透明,还要做到内容也是不透明的。
五、离屏渲染
提到优化问题,必然离不开离屏渲染,这个词我们听了很多很多遍,随便问一个工作过的程序员,相比都能说出几个会导致离屏渲染的原因吧,那么离屏渲染又是什么?
离屏渲染,指的是GPU在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作。离屏渲染耗时是发生在离屏这个动作上面,而不是渲染。为什么离屏这么耗时?原因主要有创建缓冲区和上下文切换。创建新的缓冲区代价都不算大,付出最大代价的是上下文切换
。
上下文切换,不管是在GPU渲染过程中,还是一直所熟悉的进程切换,上下文切换在哪里都是一个相当耗时的操作。让我们再看看上文所说的通过图形上下文绘制合适大小图片的那段代码具体干了些什么吧。
UIGraphicsBeginImageContextWithOptions(size, YES, [UIScreen mainScreen].scale);//开启图片上下文
CGRect rect = CGRectMake(0, 0, size.width, size.height);
[self drawInRect:rect];//开始绘制
UIImage *scaleImage = UIGraphicsGetImageFromCurrentImageContext();//拿到当前图形上下文中的图片
UIGraphicsEndImageContext();//关闭图形上下文
return scaleImage;
让我们再看看通常情况下我们处理圆角的方式吧
UIImageView *img = [UIImageView new];
img.layer.cornerRadius = 10;
img.layer.masksToBounds = YES;
StackOverFlow中,这个问题的回答中详细描述了一些可能导致离屏渲染的原因:What triggers offscreen rendering, blending and layoutSubviews in iOS?但是其中的一些描述似乎有些问题。大家普遍认为,给layer设置圆角就会导致离屏渲染的情况发生,但是实际并不是这样的,只有当 cornerRadius 和 maskToBounds 同时被设置,才会产生离屏渲染的情况,并且,有一个前提是控件的背景色被设置,上述条件无法同时完成,都不会产生离屏渲染的问题。对此,我没有找到很好的解释,个人猜测,复杂的渲染过程中发生了些不为我所知的事,如果有知道原因的小伙伴,请联系我。
至于如何避免离屏渲染的情况发生,我们只要针对上文给出链接中的回答去做处理就好了。为避免各位小伙伴们跳转的麻烦,我将会导致离屏渲染问题发生的原因翻译并整理了一下。
1.任何有mask或者是阴影的图层,模糊效果也属于mask
2.重写drawRect:方法,甚至只是空实现
3.任何类型的文本(包括 UILabel,CATextLayer,Core Text 等)
4.同时设置控件的 CornerRadius ,backgroundColor 以及 maskToBounds = YES;
5.layer.shouldRasterize = YES;
layer.shouldRasterize = YES 这句代码的意思是开启光栅化,那么光栅化又是什么意思呢?实际上光栅化就是把矢量图形转化成像素点儿的过程。我们屏幕上显示的画面都是由像素组成,而三维物体都是点线面构成的。要让点线面,变成能在屏幕上显示的像素,就需要Rasterize这个过程。就是从矢量的点线面的描述,变成像素的描述。iOS中,这句代码的实际意义是将layer渲染成一个位图( bitmap ),并缓存起来,等下次使用时不会再重新去渲染了。在处理某些消耗比较大的layer时,shouldRasterize = YES,下次就可以直接从缓存中调用,从而达到减少消耗的目的。
写在最后:
关于项目优化,暂时只能写这么多了,感谢各位前辈的分享。需要再提一下的是,项目的优化在工作中还是要看需求的,完全不必做到吹毛求疵的地步,现在很多设备实际上都是性能过甚的,当然如果你的项目需要适配低系统版本的设备上,那么加油吧。