iOS马赛克图片画笔一种实现思路
iOS马赛克图片画笔一种实现思路
先看下效果:
功能:
- 能实现马赛克画笔功能,并提供多种马赛克图案层叠测试
- 实现了重做,撤销功能
- 实现了对原图做处理功能,并不是失真保存图案
- 优化了处理时 CPU 占用太高问题
从DrawRect说起
在我们平时开发中,难免会遇到画笔,画线问题。第一个想到的是使用 drawRect
方法实现功能。 但是drawRect
方法会存在较大的内存问题。
举个栗子:
我们需要实现一个画板功能,我们会这样做:
- 新建一个画布
- 在
touchBegan
方法 或者 添加手势方法中 , 保存获得的路径点 , 生成路径 - 调用
[setNeedDisplay]
, 在-(void)drawRect
方法里面, 绘制出来.
在大量操作之后,内存、CPU 就会出现明显问题。
那么为什么会有这个问题呢?
DrawRect调用场景
drawRect
方法在UIView
的使用上起着十分关键的作用。视图第一次显示的时候会调用。这个是由系统自动调用的,主要是在 UIViewController
中loadView
和viewDidLoad
方法调用之后;
如果在UIView
初始化时没有设置rect
大小,将直接导致drawRect
不被自动调用;
该方法在调用sizeThatFits
后被调用,所以可以先调用sizeToFit
计算出size
,然后系统自动调用drawRect:
方法;
通过设置contentMode
属性值为UIViewContentModeRedraw
,那么将在每次设置或更改frame
的时候自动调用drawRect:
;
直接调用setNeedsDisplay
,或者setNeedsDisplayInRect:
触发drawRect:
,但是有个前提条件是rect
不能为0;
DrawRect 内存问题
在iOS系统中所有显示的视图都是从基类UIView
继承而来的,同时UIView
负责接收用户交互。但是实际上你所看到的视图内容,包括图形等,都是由UIView的一个实例图层属性来绘制和渲染的,那就是CALayer
。
CALayer
类的概念与UIView
非常类似,它也具有树形的层级关系,并且可以包含图片文本、背景色等。它与UIView
最大的不同在于它不能响应用户交互,可以说它根本就不知道响应链的存在,它的API虽然提供了“某点是否在图层范围内的方法”,但是它并不具有响应的能力。
在每一个UIView
实例当中,都有一个默认的支持图层,UIView
负责创建并且管理这个图层。实际上这个CALayer
图层才是真正用来在屏幕上显示的,UIView
仅仅是对它的一层封装,实现了CALayer
的delegate
,提供了处理事件交互的具体功能,还有动画底层方法的高级API。
可以说CALayer
是UIView的内部实现细节。
CALayer
其实也只是iOS当中一个普通的类,它也并不能直接渲染到屏幕上,因为屏幕上你所看到的东西,其实都是一张张图片。而为什么我们能看到CALayer的内容呢,是因为CALayer内部有一个contents属性。contents默认可以传一个id类型的对象,但是只有你传CGImage的时候,它才能够正常显示在屏幕上。所以最终我们的图形渲染落点落在contents身上如图。
-drawRect: 方法没有默认的实现,因为对UIView来说,寄宿图并不是必须的,它不在意那到底是单调的颜色还是有一个图片的实例。如果UIView检测到-drawRect: 方法被调用了,它就会为视图分配一个寄宿图,这个寄宿图的像素尺寸等于视图大小乘以 contentsScale的值。
如果你不需要寄宿图,那就不要创建这个方法了,这会造成CPU资源和内存的浪费,这也是为什么苹果建议:如果没有自定义绘制的任务就不要在子类中写一个空的-drawRect:方法。
contents
也被称为寄宿图,除了给它赋值CGImage
之外,我们也可以直接对它进行绘制,绘制的方法正是这次问题的关键,通过继承UIView
并实现-drawRect:
方法即可自定义绘制。-drawRect:
方法没有默认的实现,因为对UIView
来说,寄宿图并不是必须的,UIView
不关心绘制的内容。如果UIView
检测到-drawRect:
方法被调用了,它就会为视图分配一个寄宿图,这个寄宿图的像素尺寸等于视图大小乘以contentsScale(这个属性与屏幕分辨率有关,
回到上面 画板
栗子中,因为重写了-drawRect:
方法,-drawRect :
方法就会自动调用。生成一张寄宿图后,方法里面的代码利用Core Graphics
去绘制n条黑色的线,然后内容就会缓存起来,等待下次你调用-setNeedsDisplay
时再进行更新。
画板视图的-drawRect:
方法的背后实际上都是底层的CALayer
进行了重绘和保存中间产生的图片,CALayer
的delegate
属性默认实现了CALayerDelegate
协议,当它需要内容信息的时候会调用协议中的方法来拿。当画板视图重绘时,因为它的支持图层CALayer
的代理就是画板视图本身,所以支持图层会请求画板视图给它一个寄宿图来显示,它此刻会调用:
1 | - (void)displayLayer:(CALayer *)layer; |
如果画板视图实现了这个方法,就可以拿到layer
来直接设置contents
寄宿图,如果这个方法没有实现,支持图层CALayer会尝试调用:
1 | - (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx; |
这个方法调用之前,CALayer
创建了一个合适尺寸的空寄宿图(尺寸由bounds和contentsScale决定)和一个Core Graphics
的绘制上下文环境,为绘制寄宿图做准备,它作为ctx
参数传入。在这一步生成的空寄宿图内存是相当巨大的,它就是本次内存问题的关键,一旦你实现了CALayerDelegate
协议中的-drawLayer:inContext:
方法或者UIView
中的-drawRect:
方法(其实就是前者的包装方法),图层就创建了一个绘制上下文,这个上下文需要的内存可从这个公式得出:图层宽图层高4字节,宽高的单位均为像素。
图层每次重绘的时候都需要重新抹掉内存然后重新分配。它就是我们画板程序内存暴增的真正原因。
最合理的办法处理类似于画板这样画线条的需求直接用专有图层CAShapeLayer
CAShapeLayer
drawRect
:属于CoreGraphics
框架,占用CPU,性能消耗大,不建议重写CAShapeLayer
:属于CoreAnimation
框架,通过GPU来渲染图形,节省性能。动画渲染直接提交给手机GPU,不消耗内存
这两者各有各的用途,而不是说有了CAShapeLayer
就不需要drawRect
。
drawRect只是一个方法而已,是UIView的方法,重写此方法可以完成我们的绘制图形功能。
CAShapeLayer
是一个通过矢量图形而不是bitmap
来绘制的图层子类。用CGPath
来定义想要绘制的图形,CAShapeLayer
会自动渲染。它可以完美替代我们的直接使用Core Graphics
绘制layer
,对比之下使用CAShapeLayer有以下优点:
- 渲染快速。CAShapeLayer使用了硬件加速,绘制同一图形会比用Core Graphics快很多。
- 高效使用内存。一个CAShapeLayer不需要像普通CALayer一样创建一个寄宿图形,所以无论有多大,都不会占用太多的内存。
- 不会被图层边界剪裁掉。
- 不会出现像素化。
CAShapeLayer 与 Mask
我们知道,如果你想要显示一个图层的内容,需要将其加到图层的层级上
1 | - [CALayer addSublayer:] |
当你需要将一个CALayer的内容变成圆角的时候,你可以通过设置cornerRadius来很方便的实现,但是如果你想要一个CALayer的内容被剪裁成任意形状应该如何是好呢?
如果你使用过Photoshop,这个问题你肯定知道可以创建一个图层蒙版来实现。而在CoreAnimation中,框架同样为我们提供了这样的功能,CALayer拥有一个属性叫做mask,作为这个CALayer对象的蒙版,mask本身也是一个CALayer,比如:
1 | CALayer * layer = [CALayer layer]; |
这样的话,maskLayer就成为了layer的蒙版,maskLayer类似于一个子图层,相对于父图层(即拥有该属性的图层,在这里就是layer)布局,但是它却不是一个普通的子图层。maskLayer并不会直接绘制在父图层之上,它只是定义了父图层的“可视部分”。
想象maskLayer是一张纸,盖在了layer上,那么layer能显示出来的内容,就是maskLayer“不是透明的部分”的内容。mask属性就像是一个饼干切割机,mask图层实心的部分会被保留下来,其他的(透明的部分)则会被抛弃。
马赛克画笔思路
总结了上面 -drawRect
的不足与 CAShapeLayer
的优点,马赛克画笔思路也有了,选在再实时显示时候,使用 CAShapeLayer
与 mask
蒙版的结合,达到马赛克画笔功能,再每一笔画完时候,使用 -drawRect
生成一张原图片大小的画布,在上面抠出马赛克画笔的路径,然后将马赛克图案与原图融合,达到马赛克效果。
Q:为什么要生成原图?
- 因为当前实时显示的马赛克效果,是原图按比例缩小到屏幕尺寸显示出来的,实际上,如果需要对原图处理,需要将移动路径点重新乘上缩小的比例,那么实时显示的点才是对应原图上的点
- 生成的图,并不是通过layer渲染的失真缩小图
重点: 当选择 A 马赛克图案作为画笔纹理时候,其实就是将马赛克图案作为一个 layer
寄宿图加载出来,通过mask
蒙版遮住路径以外的位置,那么看到的是,路径所显示马赛克底图的路径了。每次画笔画完,都会保存一张每一笔处理完马赛克与原图的融合图,下次替换马赛克图案时候,如上面初始化方法,将上一笔生层融合图作为原图,新马赛克图案再作为layer
,绘制新的马赛克。
Reference:
iOS马赛克图片画笔一种实现思路