iOS 照片涂鸦功能的实现

上个版本项目图片编辑需求中有一项涂鸦涂鸦就是选取颜色后根据手指滑动路径在图片上生成对应路径,并最终生成一张新图片。接到项目,Google、Github 搜索一番,看到不少实现思路和 Demo,心想:so easy!然而,具体实现的时候却发现了这些 Demo 存在严重问题...

初版实现

gif_Memory03

实现了我们要的功能,是不是很完美?看效果是 OK,在讨论遇到的问题前,我们先来看看核心代码的实现。

初版核心代码

  1. 开始绘制的时候,获取初始坐标信息写入 CGPath 对象:
(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event 
{
    UITouch *touch = [touches anyObject];
    CGPoint p = [touch locationInView:self];

    _path = CGPathCreateMutable();
    CGPathMoveToPoint(_path, NULL, p.x, p.y);
}
  1. 随着手指的移动,触发 touchesMoved 方法,添加路径信息到 CGPath 对象,主动调用 setNeedsDisplay 使 view 得以执行 drawRect
(void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event 
{
    UITouch *touch = [touches anyObject];
    CGPoint p = [touch locationInView:self];

    //点加至线上
    CGPathAddLineToPoint(_path, NULL, p.x, p.y);
    //移动->重新绘图
    [self setNeedsDisplay];
}
  1. 按步撤销:
(void)revoke
{
    [_pathArray removeLastObject];
    [self setNeedsDisplay];
}
  1. 最后就是绘制核心方法:
(void)drawRect:(CGRect)rect 
{
    for (PathModal *modal in _pathArray)
    {
        CGContextRef context = UIGraphicsGetCurrentContext();
        [modal.color setStroke];
        CGContextSetLineWidth(context, modal.width);
        CGContextAddPath(context, modal.path);
        CGContextDrawPath(context, kCGPathStroke);
    }

    if (_path != nil)
    {
        CGContextRef context = UIGraphicsGetCurrentContext();
        CGContextAddPath(context, _path);
        [[XRPhotoEditTheme themeManager].color setStroke];
        CGContextSetLineWidth(context, _drawWidth);
        CGContextDrawPath(context, kCGPathStroke);  
    }    
}

性能问题?

上面的代码,虽然实现了需求,但是从代码里也能看到,不断的进行数组操作、调用 drawRect,连撤销操作都不是移除最后一条路径而是先清空页面绘制将余下的路径重新绘制。看到这里,就算对 drawRect 使用场景及注意事项不甚了解你也会疑问:这操作会有性能问题吧?

光靠代码逻辑的复杂程度毙掉此方案,略失严谨。但是通过真机体验可以感受到路径多了后,开始卡顿,然后收到 didReceiveMemoryWarning,之后大概率的会崩溃。通过 XCode 运行信息也可以看到内存的暴涨:

gif_Memory02

为了确认内存暴涨是因为不断的对象初始化操作还是 drawRect 绘制的,首先我们只保留了 setNeedsDisplay 调用和 drawRect 绘制所必需得代码,结果随着不断的绘制内存还是不断升高。

随后只保留手势、对象操作,注释掉绘制逻辑。在注释掉 drawRect 之后内存立刻恢复正常,我们终于抓到了消耗内存的元凶:不断的执行 drawRect 方法。

最终我们将内存暴增的原因找了出来,那为什么不断的调用 drawRect 绘制会收到内存警告而崩溃?有没有更好的解决方案呢?

重写

在 iOS 系统中所有显示的视图都是从基类 UIView 继承而来的,同时 UIView 负责接收用户交互。

但是实际上你所看到的视图内容,包括图形等,都是由 UIView 的一个实例图层属性来绘制和渲染的,那就是 CALayer,可以说
CALayer 是 UIView 的内部实现。而且基于 UIViewdrawRect 绘制方式存在内存暴涨的风险,我们综合研究后认为画板这种需要实时反馈在UI上的绘制需求应直接用专有图层 CAShapeLayer。至于为甚么是 CAShapeLayer,我们稍后讲。

  1. 初始化路径
(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event 
{
    UITouch *touch = [touches anyObject];
    CGPoint p = [touch locationInView:self];

    _path = CGPathCreateMutable();
    CGPathMoveToPoint(_path, NULL, p.x, p.y);

    CAShapeLayer * slayer = [CAShapeLayer layer];
    slayer.path = _path;
    // 略:设置 slyer 参数
    [self.layer addSublayer:slayer];
    _shapeLayer = slayer;

    [_pathArray addObject:slayer];
   }
  1. 路径绘制
(void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event 
{
    UITouch *touch = [touches anyObject];
    CGPoint p = [touch locationInView:self];
    //略:point有效值判断

    //点加至线上
    CGPathAddLineToPoint(_path, NULL, p.x, p.y);
    _shapeLayer.path = _path;
}
  1. 按步撤销
(void)revoke
{
    [_pathArray.lastObject removeFromSuperlayer];
    [_pathArray removeLastObject];
}
  1. 检验

gif_Memory01

以上代码用 CAShapeLayer 实现了路径的绘制,当然还有优化的空间:比如公用一个 CAShapeLayer 对象来实现;但不是本文的重点。
毕竟现在的代码运行起来发现内存和 CPU 的波动微乎其微,内存消耗也维持在较低的水平。

反思

重写 drawRect 方法为何会导致内存大量上涨?

要弄明白这个问题,我们先来回顾一下 iOS 中图像显示原理:

上文已经讲到 CALayerUIView 的内部实现,在每一个 UIView 实例当中,都有一个默认的图层 CALayerUIView 负责创建并且管理这个图层。而实际上这个 CALayer 图层并不是真正用来在屏幕上显示的。但为什么我们能看到 CALayer 的内容呢?是因为 CALayer 内部有一个 contents 属性。contents 默认可以传一个 id 类型的对象,但是只有你传 CGImage 对象的时候,它才能够正常显示在屏幕上。 所以最终图形渲染落脚点落在了 contents 上面。

contentsdrawRect 又有什么关系呢?

查阅文档发现 drawRect 方法没有默认的实现,因为对 UIView 来说,寄宿图并不是必须的,UIView 不关心绘制的内容。但如果 UIView 检测到 drawRect 方法被调用了,它就会为视图分配一个 contents (寄宿图)。这个寄宿图就是我们问题的关键:寄宿图的像素尺寸等于视图大小乘以 contentsScale (这个属性与屏幕分辨率有关,我们的画板程序在不同模拟器下呈现的内存用量不同也是因为它)的值。

回到我们的涂鸦程序,当画板从屏幕上出现的时候,因为重写了 drawRect 方法,drawRect 方法就会自动调用。 生成一张寄宿图 后,绘制方法里面的代码利用 Core Graphics 去绘制路径,然后内容会缓存起来,等待下次调用 setNeedsDisplay 时再次调用 drawRect 方法更新到寄宿图。

理解到这里,我们可以知道:调用一次 drawRect 方法,图层就创建了一个绘制寄宿图上下文,而这个上下文所需要的内存可从这个公式得出:图层宽*图层高*4 字节,宽高的单位均为像素。那么一张 4000*3001 的高清图创建一个上下文占用多少内存呢:4000*3001*4,相当于 46MB 内存。而涂鸦要求随着手指的移动立马在图层上展示出路径,使用 drawRect 方法重绘则必须不停的去 setNeedsDisplay 从而频繁触发 drawRect不断的创建寄宿图。

这就是我们画板程序内存暴增的真正原因

为什么是 CAShapeLayer

CAShapeLayer 是一个通过矢量图形而不是 bitmap 来绘制的图层子类。用 CGPath 来定义想要绘制的图形,CAShapeLayer 会自动渲染,而且它采用了硬件加速处理速度非常快而且内存占用更低。它可以完美替代我们的直接使用 Core Graphics 绘制 layer

总结

最终我们决定放弃 drawRect 而选用图层 CAShapeLayerCAShapeLayer 不仅在功能上满足了我们的需求,对比之下 CAShapeLayer 在性能方面表现也非常出色。

另外,因为模拟器内存是分配的 Mac 系统的内存可用内存远高于真机,这种性能问题很难在使用模拟器的开发阶段被关注到,所以开发过程中就尽量使用真机来测试吧。

参考

http://www.cnblogs.com/yaokang/archive/2012/06/02/2532257.html
https://www.cnblogs.com/chrisbin/p/6391933.html?utm_source=itdadao&utm_medium=referral

0

我们正在招聘Java工程师,欢迎有兴趣的同学投递简历到 rd-hr@xingren.com

发表评论

电子邮件地址不会被公开。 必填项已用*标注