iOS - 图形绘制iOS开发-绘制iOS开发笔记

iOS高性能涂鸦画板实现(Quartz2D & Open

2017-05-18  本文已影响2320人  啦啦啦属蛇

最近要做一个在图片上面涂鸦的功能,本来以为很简单,但是自己实现的过程中发现有很多的坑。首先感谢这篇文章的作者:http://www.jianshu.com/p/8c145884cf2c
虽然我最终没有采用里面的方案,但是他里面介绍的方法给了我很大的启发,本文会在上面这篇文章的基础上进行方案的对比和分析,下面进入正题。

一、分析3种实现方式

其实涂鸦功能的本质实际上就是跟踪用户的手势绘制轨迹。这里面有3个问题。1:如何跟踪用户的手势。2、根据手势生成要绘制的轨迹。3、如何绘制这些轨迹。对于问题1,我查了很多的资料和demo,都是通过touchBegin、touchMoved、touchEnd来跟踪,或者创建手势对这三个事件进行响应,实现的方式没有过多选择。那么我们把目光就集中在后面两个问题上。
开篇提到的文章中给出了3种方案:UIBezierPath、Quartz2D 、openGLES。其实前两种都是通过Quartz2D 实现的,只不过第一种使用了贝塞尔的封装。先看一下这三个方案各自的问题。

1、UIBezierPath:实现的思路是使用UIBezierPath来记录轨迹,每次轨迹更新后调用setNeedsDisplay来触发界面重绘,然后在drawRect里对生成的path统一进行绘制。文章中对轨迹的路径进行了优化,对于轨迹的生成并不是直线的连接,而是用曲线,使得轨迹看起来更加圆滑。这种方法实现起来比较简单,绘制样式也符合预期,橡皮擦功能也很好实现,但是存在严重的性能问题。如果用户连续绘制(触摸事件不结束),那么UIBezierPath中就会记录一条很长的路径,重绘的工作就会越来越多,界面就越来越卡顿。

2、Quartz2D :Quartz2D 是苹果提供的一套二维绘图引擎,其实UIBezierPath也是Quartz2D 的一部分,所以我认为第二种方案实际上是第一种方案的优化。前者每次都会重新绘制整个路径,而后者每次在drawRect的时候仅仅绘制一条线段,而不是所有轨迹。最重要的是用setNeedsDisplayInRect替代了setNeedsDisplay,使得drawRect在一个特定区域里面进行绘制,大大减少了重绘的面积。所以这种方案在性能上有很大的提升,但是仍然存它的问题。作者在实现线段绘制的过程中采用如下的代码:

- (void)drawRect:(CGRect)rect
{
  [self.curImage drawAtPoint:CGPointMake(0, 0)]; 
  CGPoint mid1 = midPoint1(self.previousPoint1, self.previousPoint2);
  CGPoint mid2 = midPoint1(self.currentPoint, self.previousPoint1);

  self.context = UIGraphicsGetCurrentContext();
  [self.layer renderInContext:self.context];
  CGContextMoveToPoint(self.context, mid1.x, mid1.y);
  // 添加画点
  CGContextAddQuadCurveToPoint(self.context, self.previousPoint1.x,   self.previousPoint1.y, mid2.x, mid2.y);
  // 设置圆角
  CGContextSetLineCap(self.context, kCGLineCapRound);
  // 设置线宽
  CGContextSetLineWidth(self.context, self.isErase? kEraseLineWidth:kLineWidth);
  // 设置画笔颜色
  CGContextSetStrokeColorWithColor(self.context, self.isErase?[UIColor   clearColor].CGColor:self.lineColor.CGColor);
  CGContextSetLineJoin(self.context, kCGLineJoinRound);
  // 根据是否是橡皮擦设置设置画笔模式
  CGContextSetBlendMode(self.context, self.isErase ?   kCGBlendModeDestinationIn:kCGBlendModeNormal);
  CGContextStrokePath(self.context);
  [super drawRect:rect];
}

可以看到作者并没有直接将上一个点与当前的触摸点进行简单的连接。而是计算了两个差值点:mid1和mid2,生成的是从mid1到previousPoint1的一个曲线,用mid2作为控制点。这么做的目的是希望绘制的线更加圆滑。但这里面就存在两个问题:1、我们并没有从上一个点绘制到当前点,所以当快速滑动的时候(previousPoint2、previousPoint1、currentPoint相差较多),可能会出现断点。因为绘制的曲线起点并不是上一个曲线的终点。2、为了让每次不用重绘之前的轨迹,在绘制之前都会将Layer原有的图像覆盖在当前的上下文中,就是说我们的新增轨迹都是会“覆盖”在原Layer的视图层级上的。所以如果我们的绘制颜色是透明的,那么不会得到方案1的效果,因为方案1是将整个轨迹作为一个整体进行绘制的。效果就是绘制的轨迹会有不同的深浅,在关键点处尤为明显,因为实际上,端点至少绘制了两次,是两次绘制的颜色叠加。当然如果你画笔的颜色没有透明效果,这个问题就可以忽略。

方案2效果示意图

3、openGLES:上面两种渲染方式还都是基于软件层,而openGLES则是充分利用了硬件渲染的能力,所以在渲染效率上要比上面两种高得多。苹果提供了一个利用openGLES实现画板的Demo:https://developer.apple.com/library/content/samplecode/GLPaint/Introduction/Intro.html
在openGLES的demo中,可以定义笔刷样式、自己通过关键点定制曲线轨迹等等,能力要比前两者强大很多。但画线的实现方式是通过绘制点集合实现的,这就直接导致了与方案2一样的问题,即曲线里面存在重复绘制的部分,如下图。如果不需要绘制透明颜色,那么就不存在这个问题。除此之外,demo里面的openGL的实现没有提供橡皮擦功能的实现,我相信openGL是能够实现这个功能的,但是需要较大的学习成本,毕竟在Xcode里面根本看不到openGL库的函数注释。灵活的代价就是需要自己封装一些功能,利用点集合的方式绘制的是线段,而不是“曲线”,所以在我们的示例里面可以看到严重的拐点,当然可以自己实现曲线的设计,但肯定没有使用Quartz2D提供的函数来的方便。
PS:openGLES里想实现曲线透明色,关设置笔刷颜色是不行的,需要设置笔刷的模板为透明。

openGLES实现效果

总结一下:方案1的展现是符合需求的,但是性能存在瓶颈。方案2在1的基础上进行渲染性能的优化,但是展现又存在缺陷。方案3利用底层硬件加速来绘制,最为流畅,但是由于过于底层,如果想实现我们想要的样式,需要较大的学习和开发成本的。

二、我的方案

方案1和2的根本区别在于是否对有修改的区域进行重绘,所以我们仍然可以使用UIBezierPath曲线的方式来记录和管理轨迹,因为它提供了很好的路径管理能力,在此基础上进行变更区域的重绘来提升渲染性能。那么对于区域绘制的一个重要问题就是如何记录原图像,在进行部分区域的绘制时,方案2中的[Layer renderInContext]的方法并不好,因为它在drawRect和touchMoved、toucnEnd里面都进行了多次调用。因此这里采用图像保存的方式。

- (void)drawRect:(CGRect)rect
{
    CGContextRef context = UIGraphicsGetCurrentContext();
    if (_tmpImage) {
        [_tmpImage drawAtPoint:CGPointZero];
    }
    
    [_content.color setStroke];
    if (_content.color == [UIColor clearColor]) {
        CGContextSetBlendMode(context, kCGBlendModeClear);
    }
    else {
        CGContextSetBlendMode(context, kCGBlendModeNormal);
    }
    
    [_content.path stroke];
    [super drawRect:rect];
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    CGPoint point = [self touchPoint:touches];
    _content = [PainterContent new];
    _content.color = _paintColor;
    _content.path.lineWidth = _paintWidth;
    [_content.path moveToPoint:point];
}

- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    CGPoint previousPoint2 = _content.path.currentPoint;
    CGPoint previousPoint1 = [self touchPrePoint:touches];
    CGPoint currentPoint = [self touchPoint:touches];
    CGPoint mid1 = midPoint(previousPoint1, currentPoint);
    [_content.path addQuadCurveToPoint:mid1 controlPoint:previousPoint1];
    
    CGFloat minX = MIN(MIN(previousPoint2.x, previousPoint1.x), currentPoint.x);
    CGFloat minY = MIN(MIN(previousPoint2.y, previousPoint1.y), currentPoint.y);
    CGFloat maxX = MAX(MAX(previousPoint2.x, previousPoint1.x), currentPoint.x);
    CGFloat maxY = MAX(MAX(previousPoint2.y, previousPoint1.y), currentPoint.y);
    CGFloat space = _paintWidth * 0.5 + 1;
    CGRect drawRect = CGRectMake(minX-space, minY-space, maxX-minX+_paintWidth, maxY-minY+_paintWidth);
    
    [self setNeedsDisplayInRect:drawRect];
}

- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    CGPoint previousPoint2 = _content.path.currentPoint;
    CGPoint previousPoint1 = [self touchPrePoint:touches];
    CGPoint currentPoint = [self touchPoint:touches];
    [_content.path addQuadCurveToPoint:currentPoint controlPoint:previousPoint1];
    
    CGFloat minX = MIN(MIN(previousPoint2.x, previousPoint1.x), currentPoint.x);
    CGFloat minY = MIN(MIN(previousPoint2.y, previousPoint1.y), currentPoint.y);
    CGFloat maxX = MAX(MAX(previousPoint2.x, previousPoint1.x), currentPoint.x);
    CGFloat maxY = MAX(MAX(previousPoint2.y, previousPoint1.y), currentPoint.y);
    CGFloat space = _paintWidth * 0.5 + 1;
    CGRect drawRect = CGRectMake(minX-space, minY-space, maxX-minX+_paintWidth+2, maxY-minY+_paintWidth+2);
    
    [self setNeedsDisplayInRect:drawRect];
    UIGraphicsBeginImageContextWithOptions(self.bounds.size, NO, [UIScreen mainScreen].scale);
    [self.layer renderInContext:UIGraphicsGetCurrentContext()];
    _tmpImage = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
}

每次只对变更区域进行重绘。当“连续轨迹”绘制结束时,将当前画面进行保存成图片。之后的新轨迹均在这个画面基础上进行绘制,这个时候新轨迹叠加在原图像之上是符合预期的。所以UIBezierPath属性只是记录了当前绘制的连续轨迹,之前已经绘制结束的都不再保留了,是以图片的形式保存,对于绘制复杂的场景,这种方式可以大大减少内存占用。
这个方案不仅可以满足透明颜色轨迹的绘制,先后轨迹的覆盖,而且操作流畅。通过UIBezierPath对轨迹进行曲线优化之后,几乎看不到任何拐点,过渡很圆滑。如下图:

秀一下我的效果图

欢迎大家对文章的内容批评指正!
这里献上Demo GitHub : https://github.com/BaiKunlun/efficient-painter-in-iOS

上一篇下一篇

猜你喜欢

热点阅读