一. Quartz2D
Quartz 2D 是一个二维绘图
引擎,它能够支持:
1、绘制图形: 线条、三角形、矩形、圆和弧等;
2、绘制文字;
3、绘制,生成图片(图像);
4、读取,生成PDF;
5、截图,裁剪图片;
6、自定义 UI 控件(普通的 UI 控件无法使用 UIkit 框架实现,可以使用 Quartz 2D 技术将控件内部的结构画出来,自定义控件的样子,例如五角星⭐️)。
几个概念:
1、图像上下文(Graphics Context) - 相当于一个画笔
- Graphics Context是一个数据类型(CGContextRef),封装了Quartz绘制图像到输出设备的信息,输出设备可以是PDF文件、Bitmap(位图文件,一种图形文件)或者显示器的窗口上
2)Quartz中所有的对象都是绘制到一个Graphics Context中
3)当用Quartz绘图时,所有设备相关的特性都包含在Graphics Context中,换句话说,我们可以简单地给Quartz绘图序列指定不同的Graphics Context,就可将相同的图像绘制在不同的设备上,而不需要任何设备相关的计算,这些都有Quartz替我们完成
2、Quartz2D坐标系
1)Quartz中默认的坐标系统是:原定(0,0)在左下角,沿着X轴从左到右坐标值逐渐增大,沿着Y轴从下到上左键增加
2)有些技术在设置他们的graphics Context时使用了不同于Quartz的默认坐标系统,最常见的是系统原点修改为左上角
3)坐标系的转换
//相对原点旋转上下文坐标系
CGContextRotateCTM(CGContextRef c, CGFloat angle)
//相对原点平移上下文坐标系
CGContextTranslateCTM(CGContextRef c, CGFloat tx, CGFloat ty)
//缩放上下文坐标系
CGContextScaleCTM(CGContextRef c, CGFloat sx, CGFloat sy)
3、Quartz2D的绘图顺序
1)谁后绘制谁显示在顶部,即叠加到最上面
2)利用Quartz2D绘制UIView
当在UIView子类中重写drawRect方法时,iOS会自动准备好一个图像上下文,可以通过调用UIGraphicsGetCurrentContext()来获取
3)只要一个UIView需要被刷新或者重绘,drawRect方法就会被调用,需要注意的是:重绘时应该调用setneedsDisplay,而不能直接调用drawRect,setNeedsDisplay会自动调用drawRect:
- drawRect注意事项
drawRect:是在UIViewController的loadView和viewDidLoad两方法之后调用的
drawRect:如果试图没有设置frame,将导致该方法不能执行
如果设置UIView的contentMode属性值为UIViewContentModeRedraw,那么将在每次更改frame时自动调用drawRect:
如果使用UIView绘图,只能在drawRect:方法中获取相应的CGContextRef并绘图,而在其他方法中获取的CGContextRef不能用于绘图
4、Quartz内存管理
1)使用含有"Create"或"Copy"的函数创建的对象,使用完后必须释放,否则将导致内存泄露,使用不含有"Create"或"Copy"的函数获取的对象,则不需要释放
2)如果retain了一个对象,不再使用时需要将其release掉,可以使用Quratz2D的函数来指定retain和release一个对象,例如创建了一个CGColorSpace对象,则使用函数CGColorSpaceRetain和CGColorSpaceRelease来retain和release对象,也可以使用CoreFoundation的CFRetain和CGRelease。注意不能传递NULL值给这些函数
5、Quartz2D绘图的基础元素-路径
路径定义了一条或者多条形状或子路径
子路径可以包含一条或者多条直线或曲线
子路径也可以是一些简单的形状,例如线、圆形、矩形或者星型等
子路径还可以包含复杂的形状,例如地图轮廓或者涂鸦等
路径是可以是开放的,也可以是封闭的,对于封闭路径可以是空心的也可以是实心的
二. drawRect
有时候我们需要自己实现图像绘制,需要重写drawRect方法。
首先我们需要了解一下layoutSubviews。layoutSubviews在以下情况下会被调用:
1、init初始化不会触发layoutSubviews。
2、addSubview会触发layoutSubviews。
3、设置view的Frame会触发layoutSubviews,当然前提是frame的值设置前后发生了变化。
4、滚动一个UIScrollView会触发layoutSubviews。
5、旋转Screen会触发父UIView上的layoutSubviews事件。
6、改变一个UIView大小的时候也会触发父UIView上的layoutSubviews事件。
7、直接调用setLayoutSubviews。
drawRect在以下情况下会被调用:
1、如果在UIView初始化时没有设置rect大小,将直接导致drawRect不被自动调用。drawRect 掉用是在Controller->loadView, Controller->viewDidLoad 两方法之后掉用的.所以不用担心在 控制器中,这些View的drawRect就开始画了.这样可以在控制器中设置一些值给View(如果这些View draw的时候需要用到某些变量 值)。
2、该方法在调用sizeToFit后被调用,所以可以先调用sizeToFit计算出size。然后系统自动调用drawRect:方法。
3、通过设置contentMode属性值为UIViewContentModeRedraw。那么将在每次设置或更改frame的时候自动调用drawRect:。
4、直接调用setNeedsDisplay,或者setNeedsDisplayInRect:触发drawRect:,但是有个前提条件是rect不能为0。
以上1,2推荐;而3,4不提倡
setNeedsDisplay和setNeedsLayout两个方法都是异步的,setNeedsDisplay会自动调用drawRect,而setNeedsLayout会自动调用layoutSubviews。
可以看出layoutSubviews方便数据计算,drawRect方便视图重绘。
另外:
1、若使用UIView绘图,只能在drawRect:方法中获取相应的contextRef并绘图。如果在其他方法中获取将获取到一个invalidate的ref并且不能用于画图。drawRect:方法不能手动显示调用,必须通过调用setNeedsDisplay 或者 setNeedsDisplayInRect ,让系统自动调该方法。
2、若使用CALayer绘图,只能在drawInContext: 中(类似于drawRect)绘制,或者在delegate中的相应方法绘制。同样也是调用setNeedDisplay等间接调用以上方法。
3、若要实时画图,不能使用gestureRecognizer,只能使用touchbegan等方法来掉用setNeedsDisplay实时刷新屏幕
在iOS
系统中所有显示的视图都是从基类UIView
继承而来的,同时UIView
负责接收用户交互。但是实际上你所看到的视图内容,包括图形等,都是由UIView的一个实例图层属性来绘制和渲染的,那就是CALayer。
CALayer
类的概念与UIView
非常类似,它也具有树形的层级关系,并且可以包含图片文本、背景色等。它与UIView
最大的不同在于它不能响应用户交互,可以说它根本就不知道响应链的存在,它的API虽然提供了“某点是否在图层范围内的方法”,但是它并不具有响应的能力。
在每一个UIView
实例当中,都有一个默认的支持图层,UIView
负责创建并且管理这个图层。实际上这个CALayer图层才是真正用来在屏幕上显示的,UIView
仅仅是对它的一层封装,实现了CALayer
的delegate
,提供了处理事件交互的具体功能,还有动画底层方法的高级API。
可以说CALayer
是UIView
的内部实现细节。
屏幕上你所看到的东西,其实都是一张张图片。而为什么我们能看到CALayer
的内容呢,是因为CALayer
内部有一个contents
属性。contents
默认可以传一个id
类型的对象,但是只有你传CGImage
的时候,它才能够正常显示在屏幕上。
contents
也被称为寄宿图,除了给它赋值CGImage
之外,我们也可以直接对它进行绘制,绘制的方法正是这次问题的关键,通过继承UIView
并实现-drawRect:
方法即可自定义绘制。-drawRect:
方法没有默认的实现,因为对UIView
来说,寄宿图并不是必须的,UIView
不关心绘制的内容。如果UIView
检测到-drawRect:
方法被调用了,它就会为视图分配一个寄宿图,这个寄宿图的像素尺寸等于视图大小乘以contentsScale
的值(这个属性与屏幕分辨率有关,在不同模拟器下呈现的内存用量不同也是因为它,用于关联逻辑坐标和物理坐标。因为iOS中的绘图系统使用的尺寸单位为Point,而屏幕显示的单位为Pixel,为什么要这样做呢?其实就是为了隔离变化:对于绘图而言,并不关心如何在屏幕上显示,这些属于硬件细节,也不应该关心,因此框架使用了万金油方法——抽象,绘图使用与硬件无关的Point,系统根据当前屏幕的情况自动将Point转成Pixel,所以不论以后硬件屏幕如何变化,使用Point的绘图系统以不变应万变)。
This value defines the mapping between the logical coordinate space of the layer (measured in points) and the physical coordinate space (measured in pixels). Higher scale factors indicate that each point in the layer is represented by more than one pixel at render time. For example, if the scale factor is
2.0
and the layer’s bounds are 50 x 50 points, the size of the bitmap used to present the layer’s content is 100 x 100 pixels.
-drawRect:
方法的背后实际上都是底层的CALayer
进行了重绘和保存中间产生的图片,CALayer
的delegate
属性默认实现了CALayerDelegate
协议,当它需要内容信息的时候会调用协议中的方法来拿。当视图重绘时,因为它的支持图层CALayer
的代理就是视图本身,所以支持图层会请求视图给它一个寄宿图来显示,它此刻会调用:
- (void)displayLayer:(CALayer *)layer;
如果视图实现了这个方法,就可以拿到layer
来直接设置contents
寄宿图,如果这个方法没有实现,支持图层CALayer
会尝试调用:
- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx;
这个方法调用之前,CALayer
创建了一个合适尺寸的空寄宿图(尺寸由bounds
和contentsScale
决定)和一个Core Graphics
的绘制上下文环境,为绘制寄宿图做准备,它作为ctx
参数传入。在这一步生成的空寄宿图内存是相当巨大的,它就是内存问题的关键,一旦你实现了CALayerDelegate
协议中的-drawLayer:inContext:
方法或者UIView
中的-drawRect:
方法(其实就是前者的包装方法),图层就创建了一个绘制上下文,这个上下文需要的内存可从这个公式得出:图层宽
x图层高
x4字节
,宽高的单位均为像素。比如屏幕大小为:
_myDrawer = [[BHBMyDrawer alloc] initWithFrame:CGRectMake(0, 0, SCREEN_SIZE.width, SCREEN_SIZE.height)];
它在iPhone6s plus
机器上的上下文内存量就是 1920*1080*4字节
,相当于8.29MB内存,图层每次重绘的时候都需要重新抹掉内存然后重新分配。
所以如果需要大量的画线,绘制操作我们可以使用CAShapeLayer
。
CAShapeLayer继承自CALayer,可使用CALayer的所有属性
CAShapeLayer需要和贝塞尔曲线配合使用才有意义。贝塞尔曲线可以为其提供形状,而单独使用CAShapeLayer是没有任何意义的。
-
使用CAShapeLayer与贝塞尔曲线可以实现不在view的DrawRect方法中画出一些想要的图形。
我们可以对比一下CAShapeLayer和drawRect:
DrawRect:DrawRect属于CoreGraphic
框架,占用CPU,消耗性能大。
CAShapeLayer:CAShapeLayer属于CoreAnimation
框架,通过GPU来渲染图形,节省性能。动画渲染直接提交给手机GPU,不消耗内存。
CAShapeLayer
是一个通过矢量图形而不是bitmap
来绘制的图层子类。用CGPath
来定义想要绘制的图形,CAShapeLayer
会自动渲染。它可以完美替代我们的直接使用Core Graphics
绘制layer
,对比之下使用CAShapeLayer
有以下优点:
- 渲染快速。CAShapeLayer使用了硬件加速,绘制同一图形会比用Core Graphics快很多。
- 高效使用内存。一个CAShapeLayer不需要像普通CALayer一样创建一个寄宿图形,所以无论有多大,都不会占用太多的内存。
- 不会被图层边界剪裁掉。
- 不会出现像素化。
1. UIBezierPath
- (void)moveToPoint:(CGPoint)point; //设置初始线段的起点
+ (instancetype)bezierPathWithRect:(CGRect)rect; //根据一个矩形画贝塞尔曲线
+ (instancetype)bezierPathWithOvalInRect:(CGRect)rect; //根据一个矩形画内切曲线。通常用它来画圆或者椭圆。
+ (instancetype)bezierPathWithRoundedRect:(CGRect)rect cornerRadius:(CGFloat)cornerRadius; //该方法是画矩形,但是这个矩形是可以画圆角的。第一个参数是矩形,第二个参数是圆角大小 + (instancetype)bezierPathWithRoundedRect:(CGRect)rect byRoundingCorners:(UIRectCorner)corners cornerRadii:(CGSize)cornerRadii; //该方法功能和上一个是一样的,但是可以指定某一个角画成圆角。像这种我们就可以很容易地给UIView扩展添加圆角的方法
+ (instancetype)bezierPathWithArcCenter:(CGPoint)center radius:(CGFloat)radius startAngle:(CGFloat)startAngle endAngle:(CGFloat)endAngle clockwise:(BOOL)clockwise; /** 这个工厂方法用于画弧,参数说明如下: center: 弧线中心点的坐标 radius: 弧线所在圆的半径 startAngle: 弧线开始的角度值 endAngle: 弧线结束的角度值 clockwise: 是否顺时针画弧线 **/
lineCapStyle
属性是用来设置线条拐角帽的样式的,其中有三个选择:
/* Line cap styles. */
typedef CF_ENUM(int32_t, CGLineCap) {
kCGLineCapButt, 默认的
kCGLineCapRound, 轻微圆角
kCGLineCapSquare 第三个正方形
};
lineJoinStyle
属性是用来设置两条线连结点的样式,其中也有三个选择:
/* Line join styles. */
typedef CF_ENUM(int32_t, CGLineJoin) {
kCGLineJoinMiter, 默认的表示斜接
kCGLineJoinRound, 圆滑衔接
kCGLineJoinBevel 斜角连接
};
2. 绘制
通常我们需要进行以下的步骤进行绘制。
- 1 获取当前的上下文(这里只能获取一次,并且只能在drawRect方法中获取)
- 2 描述路径、形状(就是处理想要显示的样子)
- 3 把描述好的路径、形状添加早上下文中
- 4 显示上下文内容
//1.画线条
- (void)drawRect:(CGRect)rect{
//1.获取上下文
CGContextRef contextRef = UIGraphicsGetCurrentContext();
//2.描述路径
UIBezierPath *path = [UIBezierPath bezierPath];
//起点
[path moveToPoint:CGPointMake(0, 0)];
//终点
[path addLineToPoint:CGPointMake(100, 100)];
//设置颜色
[[UIColor whiteColor] setStroke];
//设置线宽
CGContextSetLineWidth(contextRef, 5);
//3.添加路径
CGContextAddPath(contextRef, path.CGPath);
//显示路径
CGContextStrokePath(contextRef);
}
//2.画矩形
- (void)drawRect:(CGRect)rect{
//1.获取上下文
CGContextRef contextRef = UIGraphicsGetCurrentContext();
//2.描述路径
UIBezierPath *path = [UIBezierPath bezierPathWithRect:CGRectMake(10, 10, 100, 100)];
//设置颜色
[[UIColor greenColor] set];
//3.添加路径
CGContextAddPath(contextRef, path.CGPath);
//显示填充路径
CGContextFillPath(contextRef);
// 方法2:
// //1.获取上下文
// CGContextRef contextRef = UIGraphicsGetCurrentContext();
// //2.描述路径
// UIBezierPath *path = [UIBezierPath bezierPath];
// //起点
// [path moveToPoint:CGPointMake(10, 10)];
// //第二个点
// [path addLineToPoint:CGPointMake(100, 10)];
// //第三个点
// [path addLineToPoint:CGPointMake(100, 100)];
// //第四个点
// [path addLineToPoint:CGPointMake(10, 100)];
// //闭合路径 也等于 [path addLineToPoint:CGPointMake(10, 10)];
// [path closePath];
// //设置颜色
// [[UIColor greenColor] setStroke];
//// [[UIColor greenColor] setFill];
// //3.添加路径
// CGContextAddPath(contextRef, path.CGPath);
// //显示描边路径
// CGContextStrokePath(contextRef);
//// CGContextFillPath(contextRef);
}
//3.画圆(要确定圆心、半径,以及旋转的角度)
- (void)drawRect:(CGRect)rect{
//1、获取当前上下文
CGContextRef contextRef = UIGraphicsGetCurrentContext();
//2.描述路径
//ArcCenter:中心点
//radius:半径
//startAngle:起始角度
//endAngle:结束角度
//clockwise:是否逆时针
UIBezierPath *path = [UIBezierPath bezierPathWithArcCenter:CGPointMake(self.bounds.size.width*0.5, self.bounds.size.height*0.5) radius:self.bounds.size.width*0.4 startAngle:0 endAngle:M_PI*2 clockwise:NO];
//3.添加路径到上下文
CGContextAddPath(contextRef, path.CGPath);
//4.设置颜色
[[UIColor greenColor] setFill];
CGContextFillPath(contextRef);
// //2.也可以画椭圆
// //1、获取当前上下文
// CGContextRef contextRef = UIGraphicsGetCurrentContext();
// //2.描述路径 这是画椭圆的方法
// UIBezierPath * path = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(10, 10, 100, 100)];
// //3.添加路径到上下文
// CGContextAddPath(contextRef, path.CGPath);
// //4.设置颜色
// [[UIColor redColor]setFill];
// //4.显示上下文
// CGContextFillPath(contextRef);
}
//4.画文字
- (void)drawRect:(CGRect)rect{
//1.获取当前上下文
CGContextRef contextRef = UIGraphicsGetCurrentContext();
//2.创建文字
NSString *str = @"人生也像坐火车一样,过去的景色那样美,让你流连不舍,但是你总是需要前进,会离开,然后你告诉自我,没关联,我以后必须还会再来看,可其实,往往你再也不会回去。流逝的时刻,退后的风景,邂逅的人,终究是渐行渐远。";
//上下文
NSMutableDictionary * dic = [NSMutableDictionary dictionary];
dic[NSForegroundColorAttributeName] = [UIColor whiteColor];
[str drawInRect:rect withAttributes:dic];
CGContextFillPath(contextRef);
}
//画图片
- (void)drawRect:(CGRect)rect{
//1.获取当前的上下文
CGContextRef contextRef = UIGraphicsGetCurrentContext();
//2.加载图片
//这里顺便咯嗦一句:使用imageNamed加载图片是会有缓存的
//我们这里只需要加载一次就够了,不需要多次加载,所以不应该保存这个缓存,使用imageWithContentsOfFile
UIImage *image = [UIImage imageWithContentsOfFile:[[NSBundle mainBundle] pathForResource:@"Icon-152.png" ofType:nil]];
//绘制的大小位置
// [image drawInRect:rect];
//从某个点开始绘制
// [image drawAtPoint:CGPointMake(10, 10)];
//绘制一个多大的图片,并且设置他的混合模式以及透明度
//Rect:大小位置
//blendModel:混合模式
//alpha:透明度
[image drawInRect:rect blendMode:kCGBlendModeNormal alpha:1];
//从某一点开始绘制图片,并设置混合模式以及透明度
//point:开始位置
//blendModel:混合模式
//alpha:透明度
// [image drawAtPoint:CGPointMake(0, 0) blendMode:kCGBlendModeNormal alpha:1];
//添加到上下文
CGContextFillPath(contextRef);
}
3. 图形上下文
- 1 开启一个图形上下文
- 2 绘制图片
- 3 从当前上下文获取新的图片
- 4 关闭上下文
/**
根据传入imageName获取图形上下文
@param imageName imageName
@return image
*/
+ (nullable UIImage *)tt_drawImageWithImageName:(nullable NSString *)imageName{
//1.获取图片
UIImage *image = [UIImage imageWithContentsOfFile:[[NSBundle mainBundle] pathForResource:imageName ofType:nil]];
//2.开启图形上下文
UIGraphicsBeginImageContext(image.size);
//3.绘制到图形上下文中
[image drawInRect:CGRectMake(0, 0, image.size.width, image.size.height)];
//4.从上下文中获取图片
UIImage * newImage = UIGraphicsGetImageFromCurrentImageContext();
//5.关闭图形上下文
UIGraphicsEndImageContext();
//返回图片
return newImage;
}
/**
自定义裁剪
@param view 裁剪的view
@param frame 裁剪区域
@param block 回调
*/
+ (void)tt_clipView:(nullable UIView *)view cutFrame:(CGRect)frame block:(void(^_Nullable)(UIImage * _Nullable image,NSData * _Nullable imageData))block{
//1.开启上下文
UIGraphicsBeginImageContextWithOptions(view.frame.size, NO, 0);
//2、获取当前的上下文
CGContextRef contextRef = UIGraphicsGetCurrentContext();
//3、添加裁剪区域
UIBezierPath * path = [UIBezierPath bezierPathWithRect:frame];
[path addClip];
//4、渲染
[view.layer renderInContext:contextRef];
//5、从上下文中获取
UIImage * newImage = UIGraphicsGetImageFromCurrentImageContext();
//7、关闭上下文
UIGraphicsEndImageContext();
NSData * data = UIImageJPEGRepresentation(newImage, 1);
block(newImage,data);
}
三. 如何高效添加圆角
我们经常会给视图添加圆角。
label.layer.cornerRadius = 5
label.layer.masksToBounds = true
你肯定知道,这会产生离屏渲染,离屏渲染,指的是GPU在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作。
是的,但是实际上cornerRadius是不会离屏渲染的,只有设置了masksToBounds才会。我们通常会这两句代码搭配使用,因为对于内部含有子视图的控件,确实是需要裁剪。
另外,上面我们也说到了drawRect方法对内存的影响,所以我们应该尽量避免重写 drawRect
方法。不恰当的使用这个方法会导致内存暴增。举个例子,iPhone6 上与屏幕等大的 UIView
,即使重写一个空的 drawRect
方法,它也至少占用 750 * 1134 * 4 字节 ≈ 3.4 Mb
的内存;而7p会达到8.29Mb。
我们可以使用之前的2个方法去给uiimage添加圆角。
+ (instancetype)bezierPathWithRoundedRect:(CGRect)rect cornerRadius:(CGFloat)cornerRadius; // rounds all corners with the same horizontal and vertical radius
+ (instancetype)bezierPathWithRoundedRect:(CGRect)rect byRoundingCorners:(UIRectCorner)corners cornerRadii:(CGSize)cornerRadii;
具体做法,可以先给UIImage添加一个分类:
- (UIImage *_Nullable)tt_drawRectWithRoundedCorner:(CGFloat)radius andSize:(CGSize)size{
//1.设置rect
CGRect rect = CGRectMake(0, 0, size.width, size.height);
//2.开启上下文
UIGraphicsBeginImageContextWithOptions(size, NO, [UIScreen mainScreen].scale);
//3.获取当前的上下文
CGContextRef ctx = UIGraphicsGetCurrentContext();
//4.设置裁剪区域 UIRectCornerAllCorners 为上下左右 根据radius初始化一个圆角矩形路径
UIBezierPath * path = [UIBezierPath bezierPathWithRoundedRect:rect byRoundingCorners:UIRectCornerAllCorners cornerRadii:CGSizeMake(radius, radius)];
CGContextAddPath(ctx,path.CGPath);
//5.修改当前剪切路径
CGContextClip(ctx);
//5.绘制到图形上下文中
[self drawInRect:rect];
CGContextDrawPath(ctx, kCGPathFillStroke);
UIImage * newImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return newImage;
}
//调用
imageV.image = [imageV.image tt_drawRectWithRoundedCorner:5 andSize:image6.bounds.size];
如果还是觉得麻烦,还可以给UIImageView添加一个分类,添加上一个方法,然后可以直接调用设置圆角。
- (void)tt_addCorner:(CGFloat)raduis{
self.image = [self.image tt_drawRectWithRoundedCorner:raduis andSize:self.bounds.size];
}
//调用
[imageV tt_addCorner:25];
结论:
其实,如果能够只用 cornerRadius
解决问题,就不用优化。
如果必须设置 masksToBounds
,可以参考圆角视图的数量,如果数量较少(一页只有几个)也可以考虑不用优化。
UIImageView
的圆角通过直接截取图片实现,其它视图的圆角可以通过 Core Graphics 画出圆角矩形实现。