导语
根据在线教学的需求,除了同步画线的数据,还需要保证画线的流畅和即时性,因为底层的背景是图片以及视频流,所以对白板的性能提出了更高的要求。在许多开源项目中都不支持橡皮擦这个功能(有支持的也只是简单的把“橡皮”的颜色设置成背景色),因为橡皮这个功能会使流畅度优化的策略完全失效。(主要参考:网易白板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. 由于获取点的坐标是根据屏幕刷新频率获得,即手指移动速度越快,且时间一定,所以可能两个坐标的距离就越远,导致一阶贝塞尔出现棱角,所以决定使用二阶贝塞尔。二阶贝塞尔的原理:(网络图片)
因为两个点之间的间隔是不定的,那么想要形成圆滑的曲线,控制点的选取很重要。我们取上一次的控制点和待添加点的中点作为新的控制点。
- (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
- 修复接收画线时CPU过高的BUG
- 修复未同步橡皮擦模式切换的BUG
- 优化画线核心逻辑,支持文件回看
- 优化画线宽度的精确度
- 优化画线数据量,性能小幅提升
在同步远程用户画线的时候,我们考虑到有可能一个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;
}
复制代码