前言
《你画我猜》这款游戏想必大家都比较熟悉,不管是在线上还是线下都有玩过,正好接到一个需求,需要用h5实现一个。下载了一些app,玩了一下,发现体验并不是很好,有的同步很慢,有的画笔不流畅。
花了些时间,这些问题一一做了优化,话不多说,和大家分享下技术方案和实现思路。
正文
文章导读
-
基础功能实现
-
画笔锯齿毛边
-
分段传输
-
防止画笔丢失
-
画笔流畅性
-
逻辑梳理图
基础功能实现
有前端基础的小伙伴们应该都知道,H5实现绘画功能,基本原理就是监听触摸事件:触摸开始touchstart
,触摸移动touchmove
,触摸结束touchend
,得到每个触摸点的xy坐标点,使用canvas
的API,将这些点连接起来,变成我们的画笔。其中要注意的是:监听事件得到的xy是基于屏幕左上角的坐标点,要想绘画点落在手指下,需要减去canvas
元素本身距离屏幕的xy,代码如下:
// html
<canvas ref="drawCanvas"></canvas>
// js
mounted(){
// 手机端监听canvas元素触摸事件touchstart,touchmove,touchend
this.$refs.drawCanvas.addEventListener('touchstart', this.canvasTouchstart)
this.$refs.drawCanvas.addEventListener('touchmove', this.canvasTouchmove)
this.$refs.drawCanvas.addEventListener('touchend', this.canvasTouchend)
// canvas宽高
const { left, top } = this.$refs.drawCanvas.getClientRects()[0]
this.cl = left
this.ct = top
// 初始化canvas
this.ctx = this.$refs.drawCanvas.getContext('2d')
}
methods:{
// 开始
canvasTouchstart(e){
// 兼容pc端事件属性
const touch = e.targetTouches ? e.targetTouches[0] : e
// touch.clientX,touch.clientY,触摸点距离屏幕左上角xy。
// this.cl,this.ct,canvas元素距离屏幕的left,top
const [x, y] = [touch.clientX - this.cl, touch.clientY - this.ct]
this.ctx.beginPath()
this.ctx.moveTo(x, y)
}
// 移动
canvasTouchmove(e){
const touch = e.targetTouches ? e.targetTouches[0] : e
const [x, y] = [touch.clientX - this.cl, touch.clientY - this.ct]
this.ctx.lineTo(x, y)
this.ctx.stroke()
}
// 结束
canvasTouchend(){
this.ctx.closePath()
}
}
没错,核心代码就这么点,如果还要适配pc端,那就不是触摸事件了,而是鼠标事件。
this.$refs.drawCanvas.addEventListener('mousedown', this.canvasTouchstart)
document.addEventListener('mousemove', this.canvasTouchmove)
document.addEventListener('mouseup', this.canvasTouchend)
注意:监听鼠标事件时,没有属性e.targetTouches,直接读取e即可,mousemove、mouseup事件要监听document,而非canvas元素,否则鼠标移出canvas后移入,事件监听会出现问题,具体原理这里就不赘述了。
画笔锯齿毛边
基础功能已落地,但是笔画有锯齿和毛边,如何优化呢?
- lineJoin和lineCap属性平滑过渡
// 属性定义
ctx.lineJoin 属性设置或返回所创建边角的类型,当两条线交汇时。
ctx.lineCap 属性设置或返回线条末端线帽的样式。
// 初始化是设置属性
this.ctx.lineJoin = 'round'
this.ctx.lineCap = 'round'
- canvas增加分辨率
商场的大屏幕我们能看到一个一个像素块,而手机看不到,因为手机分辨率很高,canvas也可以想象成一块屏幕,当分辨率过低时,你看到的就是像素块,也就是锯齿的原因之一,只要增加canvas的实际像素就可以了。
<canvas
:width="drawSize.w * 4"
:height="drawSize.h * 4"
style="`width:${drawSize.w};height:${drawSize.h};`"
ref="drawCanvas">
</canvas>
也就是说我们实际看到的cancas宽高是:w,h。而他实际的分辨率是:w4,h4
分段传输
- 数据传输
绘画数据是怎么传输的呢。我们需要在三个监听事件中,记录所有的xy点,打包成不同的绘画帧,发送即可。
canvasTouchstart(){
// ... other
this.point=[]
// 开始帧
sendPoint({
type:1,
point:[{x,y}]
})
}
canvasTouchmove(){
// ... other
// 保存数据
this.point.push({x,y})
}
canvasTouchend(){
// ... other
// 发送移动帧
sendPoint({
type:2,
point:this.point
})
// 发送结束帧
sendPoint({
type:3
})
}
猜词方只需要拿到数据后渲染就行了,看上去没什么问题,但是如果绘画方画一笔不松手,连笔画,那这一笔永远也发送不出去,同步不到别人,这也是很多app体验不好的原因之一,怎么解决呢?分段传输。
- 优化后的分段传输
具体思路:在移动过程中,不管是否画完,每隔一段时间传输一次,这里暂定800ms,(具体为什么是800ms,我是为了方便后续处理流畅性,可视情况而定)结束时再把剩下的传输过去。代码如下:
this.currentTime=new Date()
// 移动中
canvasTouchmove(){
// ... other
this.point.push({x,y})
if(new Date() - this.currentTime >= 800){
sendPoint({
type:2,
point:this.point
})
this.point=[]
}
}
这样传输,无论你一笔画多久,别人都可以尽快的收到画完的数据。
防止画笔丢失
网络是不稳定的,你并不能保证你每一笔一定到达服务器,也不能保证服务器一定能通知到所有人,那怎么给他们上保险呢?
具体思路:
每一笔都携带递增的唯一id。
绘画方:将所有发送帧先保留本地,定时循环发送本地数据,等待收到服务器发送”帧回复“,删除本地该帧。
猜词方:本地id递增,每次收到帧,检测是否是下一帧。如果是,则绘制,如果id小于下一帧,则不处理;如果大于下一帧,则暂存,主动向服务器请求丢失帧(如收到id=2,id=5,处理id=5数据时,先保存到本地,然后主动向服务器请求id=3,id=4)。每次收到帧,循环本地数组,看是否有下一帧可绘制。
代码如下:
// 绘画方
this.id=1
mounted(){
// 定时发送未反馈的绘画帧
setInterval(()=>{
this.drawData.forEach(e=>{
sendPoint(e)
})
},2000)
// 监听服务端会回复,删除帧
Ws.on(-321,e=>{
this.drawData = this.drawData.filter(v => v.id !== e.id)
})
}
methods:{
canvasTouchstart(){
// ... other
// 发送并保存开始帧
sendPoint(startdata)
this.drawData.push(startdata)
this.id++
}
canvasTouchmove(){
// ... other
// 发送并保存移动帧
sendPoint(movedata)
this.drawData.push(movedata)
this.id++
}
canvasTouchend(){
// ... other
// 发送并保存移动帧
sendPoint(movedata)
this.drawData.push(movedata)
// 发送并保存结束帧
sendPoint(enddata)
this.drawData.push(enddata)
this.id++
}
}
// 猜词方
this.id=1 // 本地id
this.localDrawData=[] // 本地未绘制数组
mounted(){
Ws.on(-320,e=>{
e.forEach(v=>{
if(this.id+1===v.id){
// 绘制
draw()
this.id=v.id
}
if(this.id+1<v.id){
// 保存本地,并请求
this.localDrawData.push(v)
// 请求下一个id,指定长度的数据
requestLostDraw({
lostId:this.id+1,
lostLen:v.id-(this.id+1)
})
}
})
// 每次收到帧后,循环本地数据,看是否可以绘制
this.localDrawData.forEach(v=>{
if(this.id+1===v.id){
// 绘制
draw()
this.id=v.id
}
})
})
}
画笔流畅性(重点)
目前为止,还存在一个体验上的优化点,就是猜词者看到的笔画是一段一段展示的。如图所示:
问题来了,为什么会这样呢?
我们的绘画数据是每800ms发送一段,这一段里面不管有多少个点,猜词方都会直接绘制,眼睛看到的就是直接生成了一条线。
那么如何让它变得丝滑呢?
众所周知,我们大部分手机刷新率是60hz(当然也有更高的90hz,120hz),屏幕每秒刷新60次图像。60hz已经足够让我们看到画面是顺滑的了,只要让笔画做到60hz刷新率,我们看到的就是顺滑的。1000ms/60=16.6ms,也就是说,只要将笔画16ms渲染一次就行了。
具体思路:
- 数据分小段
为了及时到达,上面已经做过分段处理,800ms一次,我们是不是只要将时间改成16ms就解决了?是,也不是。问题也会随之而来,传输数据过大,服务器顶不住。还有方法,就是收到数据后自己进行拆分,将800ms一段的数据打散成16ms一段,50份数据。代码如下:
// 打散返回的数据,为小集合
handlePartData (res) {
if (res.point) {
// 处理画笔移动点数据
if (res.cmdType === 2) {
const pointNum = res.point.length / this.drawIntervalTime
// 公共属性
const attr = {
cmdType: res.cmdType,
size: res.size,
color: res.color,
}
// 计算出的小集合大于1,一帧一个点的小集合
if (pointNum > 1) {
const num = Math.ceil(pointNum); const pointArr = []
for (let i = 0, len = res.point.length; i < len; i++) {
const v = res.point[i]
if (!(i % num) && i !== 0) {
this.miniDrawList.push({
...attr,
point: [...pointArr],
})
pointArr.length = 0
}
pointArr.push({ x: v.x, y: v.y })
}
if (pointArr.length) {
this.miniDrawList.push({
...attr,
point: [...pointArr],
})
}
} else {
// 点集合个数少于一,每一帧一个点
res.point.forEach(v => {
this.miniDrawList.push({
...attr,
point: [{ x: v.x, y: v.y }],
})
})
}
} else {
// 处理清空,开始,结束的数据
this.miniDrawList.push(res)
}
}
},
- 16ms一笔进行渲染
数据已经拆分好了,按照16ms渲染一次,将所有数据有序渲染就行,setInterval
?不,有更好的,requestAnimationFrame
,官方解释:告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。代码如下:
mounted(){
this.animationFrameFun()
}
methods:{
animationFrameFun () {
this.drawLocalData()
this.drawTimer = requestAnimationFrame(this.animationFrameFun)
},
drawLocalData () {
let drawStatus = false
while (!drawStatus) {
if (!this.miniDrawList.length) return
const data = this.miniDrawList[0]
// 只有移动数据需要有序渲染,其他数据直接执行
if (data.cmdType === 2) {
drawStatus = true
} else {
this.draw(this.miniDrawList[0])
this.miniDrawList.shift()
}
}
this.draw(this.miniDrawList[0])
this.miniDrawList.shift()
},
}
好了,问题解决完了,逻辑也讲完了,希望对大家有所帮助和启发。
逻辑梳理图
另附笔画处理的逻辑梳理图,帮助理解。
-
绘画者
-
猜词者
注意:代码片段均为伪代码,意在表达其思想,如果想要直接使用,还要考虑其复用性,封装性,以及内存泄漏等问题哦~
结尾
好了,分享到这就结束了,感谢到这里,您的观看就是对我的认可。
♥️比心♥️