关于drawRect

0.05字数 1788阅读 2825

由于�一直没有好好学习UIView的绘制流程,关于UIView的drawRect一直以来都有两个疑问:
1 为什么只在drawRect方法里才能获取当前图层的上下文
2 drawRect不是号称自定义实现UIView吗,为什么我重写了drawRect原先设置的背景颜色和frame等等都没变,不是应该是我在drawRect写了什么就只显示什么吗?如:
代码:

// ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    self.view.backgroundColor = [UIColor whiteColor];
    self.roosterView = [[RoosterView alloc] initWithFrame:self.view.bounds];
    self.roosterView.backgroundColor = [UIColor whiteColor];
    [self.view addSubview:self.roosterView];
}

// RoosterView
- (void)drawRect:(CGRect)rect {
    CGContextRef context = UIGraphicsGetCurrentContext();
    UIImage *myImage = [UIImage imageNamed:@"rooster"];
    CGRect myRect = CGRectMake(0, 0, myImage.size.width, myImage.size.height);
    CGContextScaleCTM(context, 1.0, -1.0);
    CGContextTranslateCTM(context, 0, -(myRect.size.height-(myRect.size.height-2*myRect.origin.y-myRect.size.height)));//向上平移
    CGContextTranslateCTM (context, myRect.size.width/4, 0);
    CGContextScaleCTM (context, .25,  .5);
    CGContextRotateCTM (context, radians ( 22.));
    CGContextDrawImage(context, myRect, myImage.CGImage);
}
5F466471-C727-4C59-9DD5-18937678E2F7.png

不是只应该显示形变之后的图片吗?!为什么还是占满整个屏幕白色背景还在?不是说重写drawRect对UIView进行自定义嘛!!!

这里需要了解:真正被显示的是layer,每一个在 UIKit 中的 view 都有它自己的 CALayer。每一个layer都有个content,这个content指向的是一块缓存,叫做backing store(后备存储),backing store有点像一个图像。这个后备存储正是被渲染到显示器上的。
绘图流程大概是:

  • 每一个UIView都有一个layer,每一个layer都有个content,这个content指向的是一块缓存,叫做backing store。
  • UIView的绘制和渲染是两个过程,当UIView被绘制时,CPU执行drawRect,通过context将数据写入backing store。
  • 当backing store写完后,通过render server交给GPU去渲染,将backing store中的bitmap数据显示在屏幕上

CALayer被绘制时方法调用栈:


drawRect的调用栈.png

首先:Core Animation 在 RunLoop 中注册了一个 Observer 监听 BeforeWaiting(即将进入休眠) 和 Exit (即将退出Loop) 事件 。当在操作 UI 时,比如改变了 Frame、更新了 UIView/CALayer 的层次时,或者手动调用了 UIView/CALayer 的 setNeedsLayout/setNeedsDisplay方法后,这个 UIView/CALayer 就被标记为待处理,并被提交到一个全局的容器去。当Oberver监听的事件到来时,回调执行函数中会遍历所有待处理的UIView/CAlayer 以执行实际的绘制和调整,并更新 UI 界面。从图中可以看到监听回调。

接着:当渲染系统准备好,它会调用视图图层的-display方法.此时,图层会装配它的后备存储。然后建立一个 Core Graphics 上下文(CGContextRef),将后备存储对应内存中的数据恢复出来,绘图会进入对应的内存区域,并使用 CGContextRef 绘制。
上图监听事件到来后出发一系列事件一直到-[CALayer display],根据微软开源WinObjc,display的主要工作有:

- (void)display {
        .......
       //  判断contents是否有值
      if (priv->contents == NULL || priv->ownsContents || [self isKindOfClass:[CAShapeLayer class]]) {
     
        .......

        // 创建当前图层上下文
        CGContextRef drawContext = CreateLayerContentsBitmapContext32(width, height);

        priv->ownsContents = TRUE;
        CGImageRef target = CGBitmapContextGetImage(drawContext);

        CGContextRetain(drawContext);
        CGImageRetain(target);
        priv->savedContext = drawContext;

        ......
        // 设备坐标和UIKit坐标之间的转换
        CGContextScaleCTM(drawContext, 1.0f, -1.0f);
        CGContextTranslateCTM(drawContext, -priv->bounds.origin.x, -priv->bounds.origin.y);
       
        CGContextSetDirty(drawContext, false);
        [self drawInContext:drawContext];

        if (priv->delegate != 0) {
            if ([priv->delegate respondsToSelector:@selector(displayLayer:)]) {
                [priv->delegate displayLayer:self];
            } else {
                [priv->delegate drawLayer:self inContext:drawContext];
            }
        }

        CGContextReleaseLock(drawContext);
        CGContextRelease(drawContext);

        // If we've drawn anything, set it as our contents
        if (!CGContextIsDirty(drawContext)) {
            CGImageRelease(target);
            CGContextRelease(drawContext);
            priv->savedContext = NULL;
            priv->contents = NULL;
        } else {
            priv->contents = target;
        }
    } else if (priv->contents) {
        priv->contentsSize.width = float(priv->contents->Backing()->Width());
        priv->contentsSize.height = float(priv->contents->Backing()->Height());
    }      
}

从调用栈截图看出layer是在drawInContext:方法里调用了layer代理实现的
drawLayer:inContext:方法和以上代码关于drawInContext:和代理函数[priv->delegate displayLayer:self];和[priv->delegate drawLayer:self inContext:drawContext];的调用时机有出入(待详查)不过整体流程操作还是可以明白的。

代码先判断contents属性是否有值,如果没有就开始创建自己的图层关联上下文,从上下文创建CGImageRef,最后赋值给contents属性,这与文档关于contents属性的描述一致。
文档:

If you are using the layer to display a static image, you can set this property to the CGImageRef containing the image you want to display. (In macOS 10.6 and later, you can also set the property to an NSImage object.) Assigning a value to this property causes the layer to use your image rather than create a separate backing store.
If the layer object is tied to a view object, you should avoid setting the contents of this property directly. The interplay between views and layers usually results in the view replacing the contents of this property during a subsequent update.

那么这些和只在drawRect方法里才能获取当前图层的上下文有什么关系呢,依然看源码:

- (void)drawLayer:(CALayer*)layer inContext:(CGContextRef)context {
    UIGraphicsPushContext(context);

    CGRect bounds;
    bounds = CGContextGetClipBoundingBox(context);
    [self drawRect:bounds];

    UIGraphicsPopContext();
}

drawRect方法在drawLayer:inContext:里被调用,并且被调用前有个UIGraphicsPushContext(context);方法将视图图层对应上下文压入栈顶,然后drawRect执行完后,将视图图层对应上下文执行出栈操作。
系统会维护一个CGContextRef的栈,而UIGraphicsGetCurrentContext()会取栈顶的CGContextRef,当前视图图层的上下文的入栈和出栈操作恰好将drawRect的执行包裹在其中,所以说只在drawRect方法里才能获取当前图层的上下文。

第一个问题知道了答案,那么是时候总结下第二道个问题的答案了:对于view的frame,backgroundColor各种设置是通过view间接操作了layer,继而存储到backing store,view给暴露出drawRect接口只是一个询问补充的目的,layer自己会装配它的后备存储,生成了上下文,已经玩的红红火火了,为了表示对你的尊重,再问你一句:大爷还有要补充的吗?你重写了drawRect说有。大家都说drawRect自定义view说白了其实只是一个补充的作用。

把drawRect说的这么不堪,其实不是没有凭据的,因为苹果说:

这听起来貌似有点低俗,但是最快的绘制就是你不要做任何绘制。
大多数时间,你可以不要合成你在其他视图(图层)上定制的视图(图层),这正是我们推荐的,因为 UIKit 的视图类是非常优化的 (就是让我们不要闲着没事做,自己去合并视图或图层) 。

最后:图层的后备存储将会被不断的渲染到屏幕上。直到下次再次调用视图的 -setNeedsDisplay ,将会依次将图层的后备存储更新到视图上。

在调用中drawRect之前的都在cpu中执行,然后GPU将bitmap从RAM移动到VRAM将按像素计算将一层层图层合成成一张图然后显示:

屏幕快照 2016-11-28 下午7.46.21.png

// 以下待进一步验证:
drawRect调是在Controller->loadView, Controller->viewDidLoad 两方法之后掉用的.所以不用担心在控制器中,这些View的drawRect就开始画了.这样可以在控制器中设置一些值给View(如果这些View draw的时候需要用到某些变量值).
1.如果在UIView初始化时没有设置rect大小,将直接导致drawRect不被自动调用。
2.该方法在调用sizeThatFits后被调用,所以可以先调用sizeToFit计算出size。然后系统自动调用drawRect:方法。
3.通过设置contentMode属性值为UIViewContentModeRedraw。那么将在每次设置或更改frame的时候自动调用drawRect:。
4.直接调用setNeedsDisplay,或者setNeedsDisplayInRect:触发drawRect:,但是有个前提条件是rect不能为0.
以上1,2推荐;而3,4不提倡

参考:
ObjC中国
iOS开发之图形渲染分析、离屏渲染、当前屏幕渲染、On-Screen Rendering、Off-Screen Rendering
iOS开发笔记--iOS 事件处理机制与图像渲染过程

推荐阅读更多精彩内容