LuckyHu Blog

Home MyGame MyApp About

iOS如何高效绘制一些又粗又长的东西

28 Feb 2015

好吧,这里的重点其实是高效绘制,又粗又长无非是一些文字或者曲线等矢量数据,对此比较失望的同学可以移步其(草)它(榴)地(社)方(区)。

这里的高效主要有两层含义,一是快,二是内存占用最小。

首先推荐一本书《iOS:Core Animation Advanced Techniques》,一本非常不错的对iOS的绘制原理讲解得比较清楚的书,虽然我有些部分还有些疑惑和怀疑,但足以解决工程中遇到的大部分问题了。

假定需求:

需要在一定大小的区域绘制一些曲线和图形,该区域比屏幕大,因此需要可以平滑地拖动,整个视图背景为白色,绘制区域的边界和屏幕边界有一定大小的空隙。(最终效果可能像下面这样)

–image–

background Layer 1

方案外的方案:如果对某些方面要求不是那么高,其实最简单的办法就是在线下用矢量数据生成一个图片,然后加载图片,这样本文就没有任何意义了。所以设定一些前提,后文介绍的方法都是已经把相应数据转化成了下面这种类型的数据来进行绘制,另外为了实现平滑的滚动,可能会设法把一些东西利用UIScrollview来做,否则可能很难达到如丝般顺滑的效果:

@interface Path : NSObject
@property (nonatomic,strong) UIBezierPath *path;
@property (nonatomic,strong) UIColor *color;
@end

尝试一:UIView + drawRect

这应该是通常多数人第一印象的方法,但基本上来说,如果你没有任何分析,直接就开始堆代码,出来的方案总是或多或少有些坑在里面。所以我们得先明确下面几个点:

1.当覆盖drawRect方法的时候,为了缓存你所绘制的内容,UIView会生成一个backing bitmap,这个bitmap的大小和你所绘制的东西有关。

2.由于绘制的曲线比较复杂,所以一次drawRect的成本是比较高的。

基于以上两点,为了使使用最流畅,占用内存最小,我们会尽量把所有的path画到一个UIView的drawRect方法中去,并且除了把UIView加到视图树中,使UIView显示出来,这会引起drawRect被调用一次之外,不在做其他操作引起UIView的重绘(也免得再有一次数据上屏)。

大体代码可能是这样的:

@interface CanvasView : UIView
@property (nonatomic,strong) NSArray *pathes;
@end
@implementation CanvasView
-(void)drawRect:(CGRect)rect{
    CGContextRef ctx = UIGraphicsGetCurrentContext();
    
    CGContextSetFillColorWithColor(ctx, [UIColor whiteColor].CGColor);
    CGContextFillRect(ctx, rect);
    
    for (Path *path in self.pathes) {
        CGContextBeginPath(ctx);
        CGContextSetLineWidth(ctx, path.path.lineWidth);
        CGContextSetStrokeColorWithColor(ctx, path.color.CGColor);
        CGContextAddPath(ctx, path.path.CGPath);
        CGContextStrokePath(ctx);
    }
}
@end

这里其实还有一个潜在的内存浪费,这也是我目前还理解得不是非常清楚的,我把它认为是UIView的光栅化。根据实践发现,UIView所创建的backing bitmap的大小并不是完全和你的frame的大小正相关的,而是和你所绘制内容所占内存块的大小正相关。

比如你的UIView的frame是(0,0,100,100),你在外截边界为(0,0,20,10)和(50,50,10,5)这块区域绘制了一些UIBezierPath,那么实际上你的backing bitmap的大小并不是100x100x4,而是(10+5)x100x4,这就是我所说的UIView的光栅化。所以如果你把整个UIView的背景色用backgroundColor的方式,或者上面的fillRect的方式把整个UIView填充为白色的时候,实际上就是把backing bitmap扩大了。我猜测这种实现是为了达到某种内存对齐的效果,否则其实可以光栅化得更彻底,只需要10x20x4+10x5x4的内存,岂不是更省。那如何实现白色背景和滑动呢,在这个UIView的外层套一个UIScorllView并把这个UIScrollView的背景色设置为白色。

尝试二:CALayer + drawInContext

利用CALayer来绘制的方法和上面的代码大同小异,区别只是你需要弄一个CanvasLayer来继承CALayer,然后在drawInContext方法中来绘制。

根据我目前的理解,采用这种方式并不会比第一种更快或者更省内存,实际上的最终的原理可能都是一样的,利用了CALayer的backing bitmap使滑动流畅,使用cpu将数据画到了那块缓存的bitmap上面。但使用calayer也有其他好处:

一.可以利用contentsScale来控制绘制内容的大小来调节清晰度,不用自己去做相应的换算。

二.可以在子线程中进行绘制,不会阻塞主线程,但由于主线程的cpu切片时间应该会更多,所以绘制不会更快,但用户体验会好一些。

有一点很奇怪的是CALayer设置了backgroundColor并不会影响CALayer所占内存大小,我觉得UIView的backgroundColor和CALayer的backgroundColor应该是不同的实现.

尝试三:CAShapeLayer

相比与其他两种方法,这种方法是内存占用最少的,几乎只会占用数据本身的内存大小。从绘制效率来说,既可以说它是最快的方式,也可以说它的是最慢的方式,这个得根据具体的使用场景来看。这个在我们的假定需求中说得并不明确。

首先看看CAShapeLayer的特点:利用GPU进行矢量绘制,单位条path的绘制效率很高,由于是矢量绘制,所以放大后也不会模糊,额外占用内存几乎为零。

但是在实际情况下,我发现这种方法有很多局限性:

1.因为计算二次曲线这些运算是非常耗时的,要一个点一个点地去算,由于没有进行缓存,每次滚动都会进行重绘,所以如果曲线比较复杂,在低端手机上的滚动体验非常差,我猜测如果曲线非常复杂,高端机的滚动体验也会很糟糕。

2.即便是高端机,在简单demo上的体验还行,但在实际项目中,一个进程中的各种任务很多,也会使滚动体验很差,我估计也许gpu也被分去做其他事情了。

所以综合看来,这种绘制方式的使用场景并不适合这种一次绘制的情况。我的理解是,CAShapeLayer是被设计用来展现一些快速变化的图形用的,因为不需要创建backing bitmap,只是数据上屏也比较快。

————分割线————–

综上,可以看出来,其实在iOS中,尤其是偏向应用层的地方,很多时候优化已经被系统层想得比较全了,应用层更多地是搞清楚系统的设计逻辑,并且去衡量,在自己的使用场景中,到底是用空间换时间还是时间换空间,这其实是没有完美的答案的。