iOS 基於socket的高效白板工具--HYWhiteboard

HaydenYe發表於2018-03-15

導語

根據線上教學的需求,除了同步畫線的資料,還需要保證畫線的流暢和即時性,因為底層的背景是圖片以及視訊流,所以對白板的效能提出了更高的要求。在許多開源專案中都不支援橡皮擦這個功能(有支援的也只是簡單的把“橡皮”的顏色設定成背景色),因為橡皮這個功能會使流暢度優化的策略完全失效。(主要參考:網易白板Demo、BHBDrawBoarderDemo、若干github開源專案)

HYWhiteboard Demo地址:https://github.com/HaydenYe/HYWhiteboard.git


一、白板優化

1.記憶體優化

BHBDrawBoarderDemo主要解決了使用CALayer作為畫線的圖層導致的記憶體暴漲的問題,主要的原理就是:

CALayer在呼叫drawRect:方法重繪的時候,cpu會為其分配一個上下文ctx,ctx所佔記憶體為rect的長*寬*4,如果我們的layer大小是螢幕大小(事實上圖片需要縮放,所以會更大),那麼就會有幾十兆甚至幾百兆的記憶體佔用。

而使用CAShapeLayer則剛好解決這個問題,因為CAShapeLayer使用硬體加速,不會產生中間點陣圖(當然也不會呼叫drawRect:)所以不會有記憶體暴漲的現象。減少記憶體開支的同時,加快了繪製速度,並且降低了CPU使用率(Core Graphics繪製會使用CPU)。

原文連結:mp.weixin.qq.com/s?__biz=MjM…


2.新增橡皮擦功能

根據專案需求,背景色不是單一不變的顏色,那麼我們不能使用與背景色相同顏色的筆去覆蓋其他畫線。

普通畫線:

// 初始化貝塞爾曲線
UIBezierPath *path = [UIBezierPath new];
path.lineJoinStyle = kCGLineJoinRound;
path.lineWidth = 1.f;
path.lineCapStyle = kCGLineCapRound;
UIColor *lineColor = [UIColor redColor];
[lineColor setStroke];
// 正常覆蓋模式
[path strokeWithBlendMode:kCGBlendModeNormal alpha:1.0];
複製程式碼

橡皮擦:一定注意兩種模式對於顏色的計算,需找到適合自己背景顏色的模式

// 初始化貝塞爾曲線
UIBezierPath *path = [UIBezierPath new];
path.lineJoinStyle = kCGLineJoinRound;
path.lineWidth = 1.f;
path.lineCapStyle = kCGLineCapSquare;
// 清除模式,適用於背景色為透明的layer
[path strokeWithBlendMode:kCGBlendModeClear alpha:1.0];
複製程式碼

或者:

// 複製模式
UIColor *lineColor = [UIColor clearColor];
[lineColor setStroke];
[path strokeWithBlendMode:kCGBlendModeCopy alpha:1.0];
複製程式碼


3.卡頓優化

造成卡頓的原因在於,我們每新增一個點,就要在圖層上重新繪製之前所有的點,而當繪製的速度大於螢幕重新整理頻率的時候會丟幀。如果用網路同步資料,還會造成很長時間的延遲。

優化方案步驟:

1. 控制圖層重新整理頻率。通過CADisplayLink來控制檢視繪製重新整理的頻率,其中frameInterval為1時,重新整理頻率最快,每秒60次;frameInterval越大則重新整理頻率越慢,例frameInterval為2時,重新整理頻率為每秒30次,以此類推。

NSInteger frameInterval = 1;
CADisplayLink *displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(onDisplayLink:)];
[displayLink setFrameInterval:frameInterval];
[displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
複製程式碼

2. 每條線都生成一個CAShapeLayer,每畫一個點,只更新所在layer的path。

UIBezierPath *path = [self _singleLine:currentLine needStroke:NO];
CAShapeLayer *realTimeLy = [CAShapeLayer layer];
realTimeLy.backgroundColor = [UIColor clearColor].CGColor;
realTimeLy.path = path.CGPath;
realTimeLy.strokeColor = [[UIColor redColoe] CGColor];
realTimeLy.fillColor = [UIColor clearColor].CGColor;
realTimeLy.lineWidth = path.lineWidth;
realTimeLy.lineCap = kCALineCapRound;
複製程式碼

3. 一共兩個CAShapeLayer,每畫一個點,只更新上層layer,當畫完一條線的時候(手指離開螢幕)則在下層layer上面重繪所有的線。(v1.0版本的程式碼)

@interface HYWhiteboardView ()
@property (nonatomic, strong)CAShapeLayer *realTimeLy;  // 實時顯示層
@end

@implementation HYWhiteboardView

// 設定view的layer為CAShapeLayer,這樣可以使CAShapeLayer呼叫drawRect:方法
+ (Class)layerClass {
    return [CAShapeLayer class];
}

// 渲染非橡皮的畫線(只渲染實時顯示層)
- (void)onDisplayLinkFire:(HYCADisplayLinkHolder *)holder duration:(NSTimeInterval)duration displayLink:(CADisplayLink *)displayLink {

    if (_dataSource && [_dataSource needUpdate]) {
        
        // 自己畫的線需要實時顯示層(優化畫線卡頓)
        NSArray *lines = [[_dataSource allLines] objectForKey:UserOfLinesMine];
        
        // 清除畫線的渲染
        if (lines.count <= 0) {
            [self.layer setNeedsDisplay];
            self.realTimeLy.hidden = YES;
            return;
        }
        
        // 橡皮的畫線需要直接渲染到檢視層,所以不再此渲染
        NSArray *currentLine = lines.lastObject;
        HYWbPoint *firstPoint = [currentLine objectAtIndex:0];
        if (_isEraserLine) {
            return;
        }
        
        // 將畫線渲染到實時顯示層
        UIBezierPath *path = [self _singleLine:currentLine needStroke:NO];
        self.realTimeLy.path = path.CGPath;
        _realTimeLy.strokeColor = [[_dataSource colorArr][firstPoint.colorIndex] CGColor];
        _realTimeLy.fillColor = [UIColor clearColor].CGColor;
        _realTimeLy.lineWidth = path.lineWidth;
        _realTimeLy.lineCap = firstPoint.isEraser ? kCALineCapSquare : kCALineCapRound;
        _realTimeLy.hidden = NO;
        
        // 如果是最後一個點,更新檢視層,將線畫到檢視層
        HYWbPoint *theLastPoint = [currentLine lastObject];
        if (theLastPoint.type == HYWbPointTypeEnd) {
            // 標記圖層需要重新繪製
            [self.layer setNeedsDisplay];
            _realTimeLy.hidden = YES;
        }
    }
}

// 重繪所有畫線在檢視層
- (void)drawRect:(CGRect)rect {
    [self _drawLines];
}

@end複製程式碼

此時畫線的卡頓已修復,但是發現kCGBlendModeClear或者kCGBlendModeCopy模式的線,必須與其他線在同一layer才有效果,所以要進一步優化橡皮畫線。

4. 橡皮的點直接繪製在下層layer中,但是使用的是setNeedsDisplayInRect:方法,區域性繪製解決重繪線條過多的問題,當畫完一條線的時候再重繪所有的線。

// 橡皮直接渲染到檢視
- (void)drawEraserLineByPoint:(HYWbPoint *)wbPoint {
    
    // 一條線已畫完,渲染到檢視層
    if (wbPoint.type == HYWbPointTypeEnd) {
        _isEraserLine = NO;
        [self.layer setNeedsDisplay];
        [self.layer display];
        return ;
    }
    
    _isEraserLine = YES;
    
    CGPoint point = CGPointMake(wbPoint.xScale * self.frame.size.width, wbPoint.yScale * self.frame.size.height);
    if (wbPoint.type == HYWbPointTypeStart) {
        _lastEraserPoint = point;
    }
    
    // 渲染橡皮畫線
    [self _drawEraserPoint:point lineWidth:wbPoint.lineWidth];
    
    _lastEraserPoint = point;
}

// 渲染橡皮畫線
- (void)_drawEraserPoint:(CGPoint)point lineWidth:(NSInteger)width {
    // 只重繪區域性,提高效率
    CGRect brushRect = CGRectMake(point.x - lineWidth /2.f, point.y - lineWidth/2.f, lineWidth, lineWidth);
    [self.layer setNeedsDisplayInRect:brushRect];
    
    // 十分關鍵,需要立即渲染
    [self.layer display];
}
複製程式碼

區域性繪製出的點(應該稱為區域)是不連貫的,這是因為UIPanGestureRecognizer獲取手指座標是根據固定的時間間隔(螢幕重新整理頻率),而不是監聽座標是否有變化。既然不能用貝塞爾繪製曲線,只能自己計算補出算缺的點(區域)。不足的地方是每個點之間會有1pt的偏移,也就是線條會有鋸齒狀,但是重繪(手指離開螢幕)之後不會有這樣的問題。

計算方法:

1. 分別計算兩個點之間x、y的偏移量offsetX、offsetY

2. 根據Max(fabs(offsetX), fabs(offsetY))計算出最多需要多少個點(間隔為1pt);根據點的個數計算出Min(fabs(offsetX), fabs(offsetY))的每個點之間的間隔。

3. 根據offsetX、offsetY確定區域性繪製的rect座標

static float const kMaxDif = 1.f; // 計算橡皮軌跡時候,兩個橡皮位置的最大偏移

// 計算橡皮的兩點之間的畫點
- (void)_addEraserPointFromPoint:(CGPoint)point lineWidth:(NSInteger)lineWidth {
    
    // 1.兩個點之間,x、y的偏移量
    CGFloat offsetX = point.x - self.lastEraserPoint.x;
    CGFloat offsetY = point.y - self.lastEraserPoint.y;
    
    // 每個點之間,x、y的間隔
    CGFloat difX = kMaxDif;
    CGFloat difY = kMaxDif;

    // 起始點x、y便宜量為零,直接繪製,防止Nan崩潰(也可以不繪製)
    if (offsetX == 0 && offsetY == 0) {
        [self _drawEraserpoint:point line lineWidth:lineWidth];
        return ;
    }
    
    // 2.計算需要補充的畫點的個數,以及間隔
    NSInteger temPCount = 0;
    if (fabs(offsetX) > fabs(offsetY)) {
        difY = fabs(offsetY) / fabs(offsetX);
        temPCount = fabs(offsetX);
    } else {
        difX = fabs(offsetX) / fabs(offsetY);
        temPCount = fabs(offsetY);
    }
    
    // 渲染補充的畫點
    // 3.確認x、y分量上面的點方向
    if (offsetX > kMaxDif) {
        for (int i = 0; i < temPCount ; i ++) {
            CGPoint addP = CGPointMake(_lastEraserPoint.x + difX * i, _lastEraserPoint.y);
            if (offsetY > kMaxDif) {
                addP.y = addP.y + difY * i;
            }
            else if (offsetY < - kMaxDif) {
                addP.y = addP.y - difY * i;
            }
            
            [self _drawEraserPoint:addP lineWidth:lineWidth];
        }
    }
    else if (offsetX < - kMaxDif) {
        for (int i = 0; i < temPCount ; i ++) {
            CGPoint addP = CGPointMake(_lastEraserPoint.x - difX * i, _lastEraserPoint.y);
            if (offsetY > kMaxDif) {
                addP.y = addP.y + difY * i;
            }
            else if (offsetY < - kMaxDif) {
                addP.y = addP.y - difY * i;
            }
            [self _drawEraserPoint:addP lineWidth:lineWidth];
        }
    }
    else if (offsetY > kMaxDif) {
        for (int i = 0; i < temPCount ; i ++) {
            CGPoint addP = CGPointMake(_lastEraserPoint.x, _lastEraserPoint.y + difY * i);
            if (offsetX > kMaxDif) {
                addP.x = addP.x + difX * i;
            }
            else if (offsetX < - kMaxDif) {
                addP.x = addP.x - difX * i;
            }
            
            [self _drawEraserPoint:addP lineWidth:lineWidth];
        }
    }
    else if (offsetY < - kMaxDif) {
        for (int i = 0; i < temPCount ; i ++) {
            CGPoint addP = CGPointMake(_lastEraserPoint.x, _lastEraserPoint.y - difY * i);
            if (offsetX > kMaxDif) {
                addP.x = addP.x + difX * i;
            }
            else if (offsetX < - kMaxDif) {
                addP.x = addP.x - difX * i;
            }
            
            [self _drawEraserPoint:addP lineWidth:lineWidth];
        }
    }
    // 不需要補充畫點
    else {
        [self _drawEraserPoint:point lineWidth:lineWidth];
    }
}
複製程式碼


4.線條圓滑度優化

1. 由於獲取點的座標是根據螢幕重新整理頻率獲得,即手指移動速度越快,且時間一定,所以可能兩個座標的距離就越遠,導致一階貝塞爾出現稜角,所以決定使用二階貝塞爾。二階貝塞爾的原理:(網路圖片)

iOS 基於socket的高效白板工具--HYWhiteboard

因為兩個點之間的間隔是不定的,那麼想要形成圓滑的曲線,控制點的選取很重要。我們取上一次的控制點和待新增點的中點作為新的控制點。

- (instancetype)init {
    if (self = [super init]) {
        // 初始化控制點
        _controlPoint = CGPointZero;
    }
    return self;
}

// 獲取一條貝塞爾曲線
- (UIBezierPath *)_singleLine:(NSArray<HYWbPoint *> *)line needStroke:(BOOL)needStroke {
    
    // 取線的起始點,獲取畫線的資訊
    HYWbPoint *firstPoint = line.firstObject;
    
    // 初始化貝塞爾曲線
    UIBezierPath *path = [UIBezierPath new];
    path.lineJoinStyle = kCGLineJoinRound;
    path.lineWidth = firstPoint.isEraser ? firstPoint.lineWidth * 2.f : firstPoint.lineWidth;
    path.lineCapStyle = firstPoint.isEraser ? kCGLineCapSquare : kCGLineCapRound;
    
    // 畫線顏色
    UIColor *lineColor = [_dataSource colorArr][firstPoint.colorIndex];
    
    // 生成貝塞爾曲線
    for (HYWbPoint *point in line) {
        CGPoint p = CGPointMake(point.xScale * self.frame.size.width, point.yScale * self.frame.size.height);
        
        if (point.type == HYWbPointTypeStart) {
            [path moveToPoint:p];
        }
        // 優化曲線的圓滑度,二階貝塞爾
        else {
            if (_controlPoint.x != p.x || _controlPoint.y != p.y) {
                [path addQuadCurveToPoint:CGPointMake((_controlPoint.x + p.x) / 2, (_controlPoint.y + p.y) / 2) controlPoint:_controlPoint];
            }
        }
        
        _controlPoint = p;
    }
    
    // 需要渲染
    if (needStroke) {
        if (firstPoint.isEraser) {
            [path strokeWithBlendMode:kCGBlendModeClear alpha:1.0];
        }
        else {
            [lineColor setStroke];
            [path strokeWithBlendMode:kCGBlendModeNormal alpha:1.0];
        }
    }
    
    return path;
}
複製程式碼

2. 使用二階貝塞爾之後,畫出的是曲線,所以會發現線條出現了鋸齒狀,這是由於蘋果的retina螢幕使用的畫素更高,這時只需要設定layer的contentsScale屬性即可。

layer.contentsScale = [UIScreen mainScreen].scale;
複製程式碼


5.可優化空間

在使用橡皮時,cpu的使用率偏高,還有優化空間。


二、資料同步

1.畫線位置精確度

由於專案中的終端包括安卓電視、安卓平板、安卓手機、蘋果手機、蘋果平板。其中電視是橫屏顯示而其他終端是豎屏顯示,所以會導致畫線位置不準確。

解決方案:

傳送座標點在繪製區域的比例,而不是絕對座標,且保證可繪製區域與圖片大小相同,則寬高比相同,那麼座標轉換後就是準確的。

// 傳送的點
HYWbPoint *point = [HYWbPoint new];
point.xScale = (p.x) / _wbView.frame.size.width;
point.yScale = (p.y) / _wbView.frame.size.height;

// 接收到點之後的轉換
CGPoint p = CGPointMake(point.xScale * self.frame.size.width ,  point.yScale * self.frame.size.height);
複製程式碼


2.畫線粗細精確度

1. 畫線:這裡在設計的時候有個誤區,並不是兩端都需要根據比例計算,而是要根據畫線所佔圖片的面積的比例,從而計算出畫線的粗細。例:規定畫線寬度lineWidth為圖片寬度picWidth的1%,那麼畫線粗細為:

CGFloat result =  lineWidth * picWidth / 100.f;
複製程式碼

2. 橡皮:橡皮的優化處理,在區域性繪製的時候,寬度的計算如果按照畫線的粗細設定rect的寬高,那麼畫斜線的時候誤差會很大,所以我們認為畫線的粗細其實是rect對角線的長度。

// 渲染橡皮畫線
- (void)_drawEraserPoint:(CGPoint)point lineWidth:(NSInteger)width {
    // 通過對角線寬度計算真正的長寬
    CGFloat lineWidth = width * 2.f / 1.414f;
    
    // 只重繪區域性,提高效率
    CGRect brushRect = CGRectMake(point.x - lineWidth /2.f, point.y - lineWidth/2.f, lineWidth, lineWidth);
    [self.layer setNeedsDisplayInRect:brushRect];
    
    // 十分關鍵,需要立即渲染
    [self.layer display];
}
複製程式碼


3.畫線資料的同步

畫線資料是把點的集合通過socket(內網)或者IM等傳輸手段傳送給對方來實現的。理想狀態下畫一個點同時就傳送一個點,但是socekt有緩衝池,所以頻繁的傳送會造成粘包,其效果達不到理想狀態(資料解析也是耗CPU的)。一般網路情況良好的時候,可以設定60ms傳送一個包。

// 開啟白板命令定時器
- (void)_startSendingCmd {
    if (_cmdTimer) {
        [_cmdTimer invalidate];
        _cmdTimer = nil;
    }
    _cmdTimer = [NSTimer timerWithTimeInterval:kTimeIntervalSendCmd target:self selector:@selector(_sendWhiteboardCommand) userInfo:nil repeats:YES];
    [[NSRunLoop currentRunLoop] addTimer:self.cmdTimer forMode:NSRunLoopCommonModes];
}

// 傳送白板命令
- (void)_sendWhiteboardCommand {
    if (_cmdBuff.count > 0) {
        // 傳送點的集合
        NSArray<NSString *> *cmds = [NSArray arrayWithArray:_cmdBuff];
        [self _sendWhiteboardMessage:cmds successed:nil failed:nil];
        [_cmdBuff removeAllObjects];
    }
}
複製程式碼

如果是使用IM傳送則最好根據網路波動以及CPU佔用率調整發包時間間隔,因為IM是通過伺服器轉發的方式,所以傳送訊息的時間過短會導致傳送順序和到達伺服器的順序不一致,接受訊息的順序就會不一致,所以還需要重新排序。


三、其他

在設計一個遠端教學場景的白板應用的時候,還有一些需要注意的事項:

1. 通訊協議的設計時候要考慮到流量的使用,協議版本前後相容,各終端的相容。

2. 移動端iOS和安卓裝置之間的實現上面的差異,例如圖片縮放,畫線優化等,需要及時溝通。

3. 一次性同步對方所有畫線資料,其資料量很大,需要分包處理。

4. 由於傳送頻率過高或者傳送資料量過大,socket緩衝池溢位等原因造成的資料傳送失敗,需要自行實現ARQ策略來保證關鍵性訊息的傳送。


四、補充&更新

2018.9.19 更新

iOS 12版本,由於蘋果的bug,使用setNeedsDisplayInRect:方法已經不是區域性繪製,所以導致橡皮擦功能優化失效。可以在drawRect:方法中驗證返回的rect與傳入的rect不一致。現已提交bug,等待回覆,近日會在專案中iOS12系統下關閉橡皮擦優化功能。

2018.10.15 更新版本為v1.2

  1.  修復接收畫線時CPU過高的BUG
  2. 修復未同步橡皮擦模式切換的BUG
  3.  優化畫線核心邏輯,支援檔案回看
  4.  優化畫線寬度的精確度
  5. 優化畫線資料量,效能小幅提升

在同步遠端使用者畫線的時候,我們考慮到有可能一個packet會包含多條畫線的點,所以只渲染最後一條畫線是不準確的。我們引入了dirtyCount屬性,記錄已經渲染過的畫線的個數,這樣可以準確的找到未渲染的畫線。

// 渲染非橡皮的畫線(只渲染實時顯示層)
- (void)onDisplayLinkFire:(HYCADisplayLinkHolder *)holder duration:(NSTimeInterval)duration displayLink:(CADisplayLink *)displayLink {
    if (_dataSource && [_dataSource needUpdate]) {
        
        HYWbAllLines *allLines = [_dataSource allLines];

        // 清除所有人畫線
        if (allLines.allLines.count == 0) {
            [self.layer setNeedsDisplay];
            _realTimeLy.hidden = YES;
            return ;
        }
        
        // 橡皮的畫線需要直接渲染到檢視層,所以不再此渲染
        if (_isEraserLine) {
            return;
        }
        
        // 此使用者的所有點已經渲染完,可能是撤銷或恢復操作
        if (allLines.dirtyCount >= allLines.allLines.count) {
            [self.layer setNeedsDisplay];
            return;
        }
        
        // 是否需要重繪所有畫線
        BOOL needUpdateLayer = NO;
        
        // 將未渲染的畫線先渲染到實時顯示層(優化畫線卡頓)
        if (allLines.allLines.count - allLines.dirtyCount == 1) {
            // 有一條未渲染的線
            HYWbLine *line = allLines.allLines.lastObject;
            
            // 將畫線渲染到實時顯示層
            [self _drawLineOnRealTimeLayer:line.points color:line.color.CGColor];
            
            // 是否畫完一條線
            HYWbPoint *lastPoint = [line.points lastObject];
            if (lastPoint.type == HYWbPointTypeEnd) {
                allLines.dirtyCount += 1;
                needUpdateLayer = YES;
            }
        }
        else {
            // 有多條線未渲染
            NSArray *points = [NSArray new];
            CGColorRef color = [UIColor clearColor].CGColor;
            for (int i = (int)allLines.dirtyCount;i<allLines.allLines.count;i++) {
                HYWbLine *line = allLines.allLines[i];
                color = line.color.CGColor;
                points = [points arrayByAddingObjectsFromArray:line.points];
            }
            
            // 將畫線渲染到實時顯示層
            if (points.count) {
                [self _drawLineOnRealTimeLayer:points color:color];
                
                // 最後一條線是否畫完
                HYWbPoint *lastPoint = [points lastObject];
                if (lastPoint.type == HYWbPointTypeEnd) {
                    allLines.dirtyCount = allLines.allLines.count;
                }
                else {
                    allLines.dirtyCount = allLines.allLines.count - 1;
                }
                
                needUpdateLayer = YES;
            }
        }
        
        // 標記圖層需要重新繪製
        if (needUpdateLayer) {
            [self.layer setNeedsDisplay];
            _realTimeLy.hidden = YES;
        }
    }
}

// 將畫線渲染到實時顯示層
- (void)_drawLineOnRealTimeLayer:(NSArray *)line color:(CGColorRef)color {
    UIBezierPath *path = [self _singleLine:line needStroke:NO];
    self.realTimeLy.path = path.CGPath;
    _realTimeLy.strokeColor = color;
    _realTimeLy.fillColor = [UIColor clearColor].CGColor;
    _realTimeLy.lineWidth = path.lineWidth;
    _realTimeLy.lineCap = kCALineCapRound;
    _realTimeLy.hidden = NO;
}
複製程式碼



相關文章