iOS马赛克图片画笔一种实现思路

iOS马赛克图片画笔一种实现思路

先看下效果:

GIF

功能:

  • 能实现马赛克画笔功能,并提供多种马赛克图案层叠测试
  • 实现了重做,撤销功能
  • 实现了对原图做处理功能,并不是失真保存图案
  • 优化了处理时 CPU 占用太高问题

从DrawRect说起

在我们平时开发中,难免会遇到画笔,画线问题。第一个想到的是使用 drawRect 方法实现功能。 但是drawRect方法会存在较大的内存问题。

举个栗子:
我们需要实现一个画板功能,我们会这样做:

  1. 新建一个画布
  2. touchBegan方法 或者 添加手势方法中 , 保存获得的路径点 , 生成路径
  3. 调用 [setNeedDisplay] , 在 -(void)drawRect 方法里面, 绘制出来.

在大量操作之后,内存、CPU 就会出现明显问题。

那么为什么会有这个问题呢?

DrawRect调用场景

drawRect方法在UIView的使用上起着十分关键的作用。视图第一次显示的时候会调用。这个是由系统自动调用的,主要是在 UIViewControllerloadViewviewDidLoad方法调用之后;

如果在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仅仅是对它的一层封装,实现了CALayerdelegate,提供了处理事件交互的具体功能,还有动画底层方法的高级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进行了重绘和保存中间产生的图片CALayerdelegate属性默认实现了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
2
3
CALayer * layer = [CALayer layer];
CALayer * maskLayer = [CALayer layer];
layer.mask = maskLayer;

这样的话,maskLayer就成为了layer的蒙版,maskLayer类似于一个子图层,相对于父图层(即拥有该属性的图层,在这里就是layer)布局,但是它却不是一个普通的子图层。maskLayer并不会直接绘制在父图层之上,它只是定义了父图层的“可视部分”。

想象maskLayer是一张纸,盖在了layer上,那么layer能显示出来的内容,就是maskLayer“不是透明的部分”的内容。mask属性就像是一个饼干切割机,mask图层实心的部分会被保留下来,其他的(透明的部分)则会被抛弃。

马赛克画笔思路

总结了上面 -drawRect 的不足与 CAShapeLayer 的优点,马赛克画笔思路也有了,选在再实时显示时候,使用 CAShapeLayermask 蒙版的结合,达到马赛克画笔功能,再每一笔画完时候,使用 -drawRect 生成一张原图片大小的画布,在上面抠出马赛克画笔的路径,然后将马赛克图案与原图融合,达到马赛克效果。

Q:为什么要生成原图?

  1. 因为当前实时显示的马赛克效果,是原图按比例缩小到屏幕尺寸显示出来的,实际上,如果需要对原图处理,需要将移动路径点重新乘上缩小的比例,那么实时显示的点才是对应原图上的点
  2. 生成的图,并不是通过layer渲染的失真缩小图

重点: 当选择 A 马赛克图案作为画笔纹理时候,其实就是将马赛克图案作为一个 layer 寄宿图加载出来,通过mask蒙版遮住路径以外的位置,那么看到的是,路径所显示马赛克底图的路径了。每次画笔画完,都会保存一张每一笔处理完马赛克与原图的融合图,下次替换马赛克图案时候,如上面初始化方法,将上一笔生层融合图作为原图,新马赛克图案再作为layer,绘制新的马赛克。

Reference:

  1. 谈谈对drawRect的理解
  2. 内存恶鬼drawRect
  3. Custom Drawing
  4. iOS CoreAnimation专题——技巧篇(三)Layer Masking - 图层蒙版

iOS马赛克图片画笔一种实现思路

https://swlfigo.github.io/posts/3f5f/

Author

Sylar

Posted on

2019-04-28

Updated on

2020-09-21

Licensed under

Comments