H5你画我猜实现与优化

273 阅读9分钟

前言

《你画我猜》这款游戏想必大家都比较熟悉,不管是在线上还是线下都有玩过,正好接到一个需求,需要用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即可,mousemovemouseup事件要监听document,而非canvas元素,否则鼠标移出canvas后移入,事件监听会出现问题,具体原理这里就不赘述了。

画笔锯齿毛边

基础功能已落地,但是笔画有锯齿和毛边,如何优化呢?

  1. lineJoin和lineCap属性平滑过渡
  • lineJoin

属性设置或返回所创建边角的类型,当两条线交汇时。

说人话就是:控制两条直线相交时的展现形式,提供三种:round |bevel | miter ,也就是圆角|平角|尖角,默认miter

  • lineCap

属性设置或返回线条末端线帽的样式。

说人话就是:控制直线的首尾展现形式,值同上。

我们将两个都设置为round。

// 初始化是设置属性
this.ctx.lineJoin = 'round'
this.ctx.lineCap = 'round'
  1. 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渲染一次就行了。

具体思路:

  1. 数据分小段

为了及时到达,上面已经做过分段处理,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)
        }
      }
    },

2. 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()
    },
}

逻辑梳理图

另附笔画处理的逻辑梳理图,帮助理解。

  • 绘画者
https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/JZWGl0jNNyeBq34Y/img/680984bc-3bc4-4da7-9e51-7db922ef49d9.png
  • 猜词者
https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/JZWGl0jNNyeBq34Y/img/144cf072-e0d1-4542-afd6-ef5c45352faa.png

低端机型画笔不流畅

上线了一段时间,偶尔打开app,看着用户开心的玩着《你画我猜》,满满的成就感。

天有不测,报回来一个线上问题,用户反馈:为什么我只能画直线?

https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/JZWGl0jNNyeBq34Y/img/c28739b5-f517-4c4b-80d0-6263eccaebdd.png

???(大写的)

什么情况,难道我代码出问题了?查来查去,代码没有修改记录,线上运行正常,其他小伙伴玩的很开心, 一看用户机型,安卓5,找来我们组祖传的、唯一的宝贝机子(vivo xxx)。一运行,正常呀;过了一会儿,开始发烫了,发现玩游戏出现问题了,慢点画倒是问题不大,但是只要手速快一点就会出现用户看到的那样,一看日志,这是点没有上传完整呀,本来该传50个点的,结果它性能不行,只能传2个,导致连成一条直线了。

这。。。性能问题呀,能解决吗?能!

解决思路:

  • quadraticCurveTo

通过使用表示二次贝塞尔曲线的指定控制点,向当前路径添加一个点。使用控制点可以控制线条的弧度

原来的绘画原理:每一个点连接起来,点足够密就是曲线

使用二次贝塞尔曲线原理:先有一个初始点A,收到第二个点B时,先不绘制,当收到第三个点C时,取B、C的中心点,做为终点,B点本身作为控制点,连接A点和B、C中心点,依次类推,最后连接好最后一个点,实现所有线都是有弧度的,即使你画直线,它也是直线。

代码如下:

// 绘画者
canvasTouchmove(e){
    // ... other code
  // 如果是第一个点,先不做绘制
  if (this.prePoint.x !== undefined) {
    const cx = (this.prePoint.x + x) / 2
    const cy = (this.prePoint.y + y) / 2
    this.ctx.quadraticCurveTo(this.prePoint.x, this.prePoint.y, cx, cy)
    this.ctx.stroke()
  }
  this.prePoint = { x, y }
}
canvasTouchend(e){
    // ... other code
  // 绘制最后一个点
  if (this.prePoint.x !== undefined) {
  this.ctx.lineTo(this.prePoint.x, this.prePoint.y)
  this.prePoint = {}
  this.ctx.stroke()
  }
}

// 猜词者
draw (v) {
  this.ctx.lineWidth = v.size
  this.ctx.strokeStyle = v.color
  switch (v.cmdType) {
    // 开始
    case 1:
      this.ctx.beginPath()
      this.ctx.moveTo(v.point[0].x, v.point[0].y)
      break
    case 2:
      // 中间
      v.point.forEach(p => {
      if (this.prePoint.x !== undefined) {
      const cx = (this.prePoint.x + p.x) / 2
      const cy = (this.prePoint.y + p.y) / 2
      this.ctx.quadraticCurveTo(this.prePoint.x, this.prePoint.y, cx, cy)
      this.ctx.stroke()
      }
      this.prePoint = { x: p.x, y: p.y }
      })
      break
    // 结束
    case 3:
      if (this.prePoint.x !== undefined) {
        this.ctx.lineTo(this.prePoint.x, this.prePoint.y)
        this.prePoint = {}
        this.ctx.stroke()
      }
      this.ctx.closePath()
      break
    // 清除
    case 9:
      this.ctx.clearRect(0, 0, this.drawSize.w * this.drawSize.mult, this.drawSize.h * this.drawSize.mult)
      break
    }
},

完美解决了低端机型的体验问题。

结尾

好了,分享到这就结束了,感谢到这里,您的观看就是对我的认可。