iOS 两种方式高效的绘制动态波形

96
alenpaulkevin
2018.03.13 19:01* 字数 813

需要实现的效果图


waves.gif

绘制动态波形的总体思路

在我们绘制波形的控件中,定义一个可变数组receiveDataArray,不断接收外面心电数据,同时定义一个定时器,以把数据不断的显示在网格中,绘制成心电波形;

    _currentLocationX = 0;  // 当前波形点的X位置,
    _receiveDataArray = [NSMutableArray array]; // 接收心电数据的数组
    _pointSpace = self.frame.size.height / 500.0;   // 两波形点之间的距离
    _totalWidth = self.frame.size.width;  // 要绘制的总宽度
    _pointCount = (NSInteger)_totalWidth / _pointSpace; // 一屏幕总的点数
    // 开启一个定时器在 drawWaves 方法 绘制波形
    CADisplayLink *displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(drawWaves)];
    if (@available(iOS 10.0, *)) {
        displayLink.preferredFramesPerSecond = 30;  // 每秒三十次
    } else {
        displayLink.frameInterval = 2; // 每秒三十次,iOS10以下的方法
    }
    // 添加到NSRunLoopCommonModes模式中,避免UI交互,影响绘制
    [displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];

普通方式——通过setNeedsDisplay来全屏绘制

每秒三十次,每次通过setNeedsDisplay方法把屏幕上的点重新绘制一遍,这里要注意给动的那个点,留出一部分空白区域,每次绘制的点应该要比实际数小20个左右,在定时器方法中,调用 setNeedsDisplay方法开始绘制

 - (void)drawWaves
 {
     // 只要多于10个就开始画
     if (self.receiveDataArray.count >= 10) {
     // 把接收到的数据赋值给另外一个数组
     self.midArray = [NSMutableArray arrayWithArray:self.receiveDataArray];
     // 清空数组
     [self.receiveDataArray removeAllObjects];
     [self setNeedsDisplay];  // 调用drawRect方法开始绘制
 }

在drawRect方法中,我们开始绘制波形

 - (void)drawRect:(CGRect)rect
 {
     // 控件高度
     CGFloat height = self.frame.size.height;
 
     // self.midArray  每次新增加的数据数组
     if (self.midArray.count == 0) {
     return;
     }
 
     // 把新增加的数据转为要新绘制的点
     for (int i = 0; i < self.midArray.count; i++) {
     // 如果当前位置大于要绘制的宽度,从开始位置继续画
     if (_currentLocationX > _totalWidth) {
     _currentLocationX = 0;
     }
 
     // 获取数组中的数据
     CGFloat tempY = [self.midArray[i] floatValue];
 
     // 把数据转化为波形的y值,不需要关心怎么转的,数值随意填,看这像就行了
     CGFloat y = height/2 + height *(tempY - 2040)/1784;
 
     // 波形点,添加到数组中
     CGPoint startPoint = CGPointMake(_currentLocationX,y);
     NSValue *value = [NSValue valueWithCGPoint:startPoint];
     [self.pointArray addObject:value];
     // 当前X位置增加单位距离
     _currentLocationX += _pointSpace;
     }
 
     // 留出二十个点的空隙,显示那个空白的移动点
     if (self.pointArray.count > _pointCount - 20) {
     [self.pointArray removeObjectsInRange:NSMakeRange(0, self.pointArray.count - _pointCount + 20)];
     }
 
     CGContextRef ctx = UIGraphicsGetCurrentContext();  // 获取上下文
     CGPoint point = [self.pointArray[0] CGPointValue];
     CGContextMoveToPoint(ctx, point.x, point.y);  // 移动到第一个点
     CGContextSetLineWidth(ctx, 1);  // 设置宽度
     for (NSInteger i = 1; i < [self.pointArray count];i++) {
     if (self.pointArray[i] == nil) {
     break;
     }
     CGPoint previousPoint = [self.pointArray[i - 1] CGPointValue];  // 前面的点
     CGPoint nextPoint = [self.pointArray[i] CGPointValue]; // 后面的点
     // 如果后面的点小于前面点的x值,表明已经回到开始值,不要增加线,把点移到原点;
     if (nextPoint.x  < previousPoint.x) {
     // 移动到起点
     CGContextMoveToPoint(ctx, nextPoint.x, nextPoint.y);
     } else {
     // 继续增加线
     CGContextAddLineToPoint(ctx, nextPoint.x, nextPoint.y);
     }
     }
     [[UIColor blackColor] setStroke]; // 设置颜色
     CGContextStrokePath(ctx);  // 画波形
 }

资源占用,见图


setNeedsDisplay.png

这种全屏幕绘制,占用的cpu很高,到达了18%,性能很差;

高效方式1——通过setNeedsDisplayInRect 方法绘制

通过setNeedsDisplay方法,全屏绘制效率很低,观察波形,我们可以看到除了新来的那一部分数据在变,其它部分是没有变的,有没有办法让这部分数据不重新绘制,只绘制新来的那一部分数据,查找API,发现有setNeedsDisplayInRect这个方法,跟setNeedsDisplay这个方法一样,调用它也会触发drawRect这个方法,但它只绘制我们指定的那一部分屏幕区域;

注意我们给出的数据一定要超过我们指定的区域,避免把相交区域的波形的裁剪细了,不对称

在定时器方法中

 - (void)drawWaves
 {
 // 只要多于10个就开始画
     if (self.receiveDataArray.count >= 10) {
         // 把接收到的数据赋值给另外一个数组
         self.midArray = [NSMutableArray arrayWithArray:self.receiveDataArray];
         // 清空数组
         [self.receiveDataArray removeAllObjects];
         [self shape];
         //        [self setNeedsDisplay];
         #if 0
         NSInteger midcount = [self.midArray count];
 
         // 每次多画十个,也就是10个的空隙,_currentLocationX 现在的位置,_pointSpace 两点之间的距离
         CGFloat totalNum = _currentLocationX + _pointSpace * (midcount + 10);
         if (totalNum > _totalWidth) {
             // 大于屏幕的宽度时,两部分要画, 最后面和最前面,往开始位置前面,不要纠结于5是什么,根据实际情况调节就是了
             // 后半部分
             [self setNeedsDisplayInRect:CGRectMake(_currentLocationX - _pointSpace * 5, 0, _totalWidth - _currentLocationX + _pointSpace * 5, self.frame.size.height)];
             // 前半部分
             [self setNeedsDisplayInRect:CGRectMake(0, 0, totalNum - _totalWidth, self.frame.size.height)];
         } else {
         // 小于屏幕宽度
            [self setNeedsDisplayInRect:CGRectMake(_currentLocationX - _pointSpace * 5 , 0, _pointSpace *(midcount + 15), self.frame.size.height)];
         }
     }
 }

drawRect方法中都只绘制的最新的四十个,因为我接收的数据每次大约是10个,我绘制的比它多个30个左右进行了,避免相交部分被裁切;

 - (void)drawRect {
      同上........
 // 这部分改为下面那部分
  if (self.pointArray.count > _pointCount - 10) {
        [self.pointArray removeObjectsInRange:NSMakeRange(0, self.pointArray.count - _pointCount + 10)];
     }
  
 // 每次最多画40个点,大于 setNeedsDisplayInRect 方法里 设置的范围就行了
     if (self.pointArray.count > 40) {
     [self.pointArray removeObjectsInRange:NSMakeRange(0, self.pointArray.count - 40)];
     }
 }

资源占用

setNeedsDisplayInRect.png

相比较于setNeedsDisplay CPU从18%降低到了2%,内存降低了4M左右,性能优化很明显

高效方式2——通过CAShapeLayer方式绘制

CAShapeLayer创建一个CAShapeLayer对象,每次设置它的path来绘制波形,在初始化方法中设置

 CAShapeLayer *shape = [[CAShapeLayer alloc] init];
 shape.lineCap = kCALineCapRound;
 [self.layer addSublayer:shape];
 shape.strokeColor = [UIColor blackColor].CGColor;
 shape.fillColor = [UIColor clearColor].CGColor;
 shape.lineWidth = 1.0f;
 self.shapLayer = shape;
 self.path = [UIBezierPath bezierPath]; // 创建

在定时器方法中,每次设置CAShapeLayer的路径就行了

 - (void)setupShapePath:(CGRect)rect
 {
     // 控件高度
     CGFloat height = self.frame.size.height;
 
     // self.midArray  每次新增加的数据数组
     if (self.midArray.count == 0) {
     return;
     }
 
     // 把新增加的数据转为要新绘制的点
     for (int i = 0; i < self.midArray.count; i++) {
     // 如果当前位置大于要绘制的宽度,从开始位置继续画
     if (_currentLocationX > _totalWidth) {
     _currentLocationX = 0;
     }
 
     // 获取数组中的数据
     CGFloat tempY = [self.midArray[i] floatValue];
 
     // 把数据转化为波形的y值,不需要关心怎么转的,数值随意填,看这像就行了
     CGFloat y = height/2 + height *(tempY - 2040)/1784;
 
     // 波形点,添加到数组中
     CGPoint startPoint = CGPointMake(_currentLocationX,y);
     NSValue *value = [NSValue valueWithCGPoint:startPoint];
     [self.pointArray addObject:value];
     // 当前X位置增加单位距离
     _currentLocationX += _pointSpace;
     }
 
     // 留出十个点的空隙,显示那个空白的移动点
     if (self.pointArray.count > _pointCount - 30) {
     [self.pointArray removeObjectsInRange:NSMakeRange(0, self.pointArray.count - _pointCount + 30)];
     }
 
     // 移除所有的点
     [self.path removeAllPoints];
     CGPoint point = [self.pointArray[0] CGPointValue];
     [self.path moveToPoint:point];
     for (NSInteger i = 1; i < [self.pointArray count];i++) {
     if (self.pointArray[i] == nil) {
     break;
     }
     CGPoint previousPoint = [self.pointArray[i - 1] CGPointValue];  // 前面的点
     CGPoint nextPoint = [self.pointArray[i] CGPointValue]; // 后面的点
     // 如果后面的点小于前面点的x值,表明已经回到开始值,不要增加线,把点移到原点;
     if (nextPoint.x  < previousPoint.x) {
     // 移动到起点
     [self.path moveToPoint:nextPoint];
     } else {
     // 继续增加线
     [self.path addLineToPoint:nextPoint];
     }
     }
     self.shapLayer.path = self.path.CGPath; // 设置shapeLayer的路径
 }

占用资源


CAShapeLayer.png

CPU跟setNeedsDisplayInRect差不多,多了2%左右,但相较于setNeedsDisplay好了很多,惊讶的是,占用内存居然比setNeedsDisplayInRect还少,要知到这里是把全屏幕的数据加进去的,而setNeedsDisplayInRect只算了部分数据;

多讲一下,关于CAShapeLayer,苹果弄出的一个很很很牛逼的控件,能实现很多神奇的效果,占用资源也非常少,如果你不是很懂,推荐去看一下;

总结:

setNeedsDisplay方法效率很差,占用CPU和内存都很高,不推荐使用,我们做其它方面的项目时,用这方法也应特别小心,至于setNeedsDisplayInRectCAShapeLayer两种方法,性能都不错,相对来说,setNeedsDisplayInRect性能稍微好点,但判断绘制的区域很麻烦,综合考虑,推荐使用CAShapeLayer方法;

iOS