问题

最近跟着慕课网上的课程在做一个网易云音乐小程序,遇到了一个进度条回跳的 bug,这里记录一下踩坑和解决的过程。

具体情况见下图:

预期行为:在拖拽进度条之后,直接到达拖拽之后的位置

实际行为:在拖拽进度条之后,会首先回跳到拖拽之前的位置,然后再跳到拖拽之后的位置。

模拟调试的 bug

代码逻辑

无论如何,先来看一下代码的逻辑:

页面结构如下,左右两个 text 显示时间就不说了,主要是中间的进度条。这个进度条没有使用小程序原生提供的 slider 来做,而是采用 movable-area 和 movable-view 相结合的方式,movable-area 划出了一块可供滑动的区域,而 movable-view 则是中间可以拖拽的滑块。拖拽滑块的时候会有个 x 来记录拖拽距离,同时绑定 onXChange 事件监听 x 的变化,绑定 onTouchEnd 事件监听拖拽松手的动作。另外,下面还有一个 progress 组件,这个是用来显示进度的,已经播放的进度给个白色样式。

<view class="container">
  <text class="time">{{showtime.currentTime}}</text>
  <view class="control">
    <movable-area class="movable-area">
      <movable-view class="movable-view" direction="horizontal" 
      damping="1000" x="{{movableDist}}"
      bindchange="onXchange" bindtouchend="onTouchEnd">  
      </movable-view>  
    </movable-area>
    <progress percent="{{progress}}" stroke-width="4" backgroundColor="#969696" activeColor="#fff"></progress>
  </view>
  <text class="time">{{showtime.totalTime}}</text>  
</view>

一旦确定 x 的变化来源于用户的拖拽,就在onXChange 里根据比例关系设置好进度。这里要注意的是,在用户拖拽没松手的时候先不进行 setData 渲染视图层的操作 —— 因为用户可能会频繁进行拖拽,我们要避免频繁的 setData 带来的性能损耗。所以,这里只是把数据保存下来,等待渲染。

onXchange(event){
    if(event.detail.source == "touch"){  
        ratio = event.detail.x / (movableAreaWidth - movableViewWidth) 
        this.data.progress = ratio * 100    
        this.data.movableDist = event.detail.x
    }
},

用户一旦松手,基本就可以确定他已经把滑块拖拽到了目标位置,这时候就进行正式的 setData 操作,同时调用 seek 方法让歌曲跳转到对应的位置去播放

onTouchEnd(){      
    let toSec = totalSec * ratio
    this.setData({
        progress:this.data.progress,
        movableDist: this.data.movableDist,
        ['showtime.currentTime']: this.timeFormat(toSec)
    })   
    backgroundAudioManager.seek(toSec)   
},

目前来看,好像并没有什么问题。不过别忘了,我们还有一个 onTimeUpdate 在监听歌曲的播放:

backgroundAudioManager.onTimeUpdate(() => {
    let currentTime = backgroundAudioManager.currentTime          
    // 获取当前激活时刻
    let sec = currentTime.toString().split('.')[0]
    // 设置movableview进度
    let movableDist = (movableAreaWidth - movableViewWidth) * currentTime / totalSec
    // 设置progress-bar进度
    let progress = 100 * currentTime / totalSec
    // 赋值
    if(compareSec != sec){
        this.setData({
            movableDist,
            progress,
            ['showtime.currentTime']: this.timeFormat(currentTime)
        })
        compareSec = sec
    }
})

歌曲播放 --> 时间改变 --> 带动进度条跟着走,这个函数就是用来实现该功能的。

解决方案

问题就在于:拖拽和歌曲播放是同时进行的,拖拽本身会修改进度条,歌曲播放所带来的时间改变也会修改进度条,如果拖拽松开的那一刻是歌曲播放带来的时间改变在修改进度条,那么进度条确实是会弹回到原先位置的。

解决的方案很简单,这里参考视频的做法。其实很像 OS 中的进程互斥(这么说不准确,但可以近似理解)问题,进度条就相当于是互斥资源,我们只要保证一个时间段内只有一个操作可以修改进度条就好了。具体做法是声明一个变量 isMoving 作为“锁”,在拖拽的时候置为 true,并限制此时 onTimeUpdate 无法修改数据;而在松手后置为 false,并调用 seek 跳转到音乐的某个播放位置 A。由于对 onTimeUpdate 来说,他获取的 currentTime 也是 A 位置对应的时间,这样就不会发生冲突了。

修改代码后再来看一下拖拽效果,发现确实没有回跳的 bug 了:

你以为事情就这么结束了吗?No~~

真机调试的 bug

在确定模拟调试没问题的情况下,我打开手机进行真机调试,诡异的是,这个 bug 再次出现了,并且机率几乎是 100%,这怎么能忍呢?于是继续想方法解决。

在前面说过,“调用 seek 跳转到音乐的某个播放位置 A,对于 onTimeUpdate 来说,他获取的 currentTime 也是 A 位置对应的时间。” 在真机调试的场景下并不是这样。

延迟更新的问题

我们假设一下,调用 seek 进行跳转后,onTimeUpdate 内部获取的 currentTime 不是当前时间,而仍然是跳转前的时间,也就是说它的时间没有更新过来,那么按照这个时间计算的数据最后渲染到进度条上,我们看到的就还会是拖拽之前的进度条,而在稍后,时间更新过来了,进度条再次跳回到拖拽之后的位置。如果真的是这样,或许就可以解释回跳的原因了。那么怎么验证呢?

我们可以在 onTimeUpdate 函数内部打印格式化的 currentTimeprogress 的值,如果这两者保持在差不多的水平,那么可以认为它们是同步的,如果某个时刻出现了很大的差距,那么就说明 currentTime 没有及时进行更新(progress 是通过 onXchange 修改的,不会有问题)。

console.log('currentTime:' + this.timeFormat(backgroundAudioManager.currentTime))
console.log('progress:' + this.data.progress)

打印结果见下图:

一开始没有拖拽,所以理所当然, currentTimeprogress 保持在差不多的水平。然后,注意看红圈部分,红圈的时刻我往后拖拽了进度条,所以可以看到 progress 突然变大了,但是这时候的 currentTime 竟然没有跟着改变(仍然是一个很小的数)!这就验证了上面的假设了,因为 currentTime 没有及时更新,而它又影响着其它数据,所以导致进度条又跳回到之前的位置,而稍后 currentTime 更新了,所以时间又从 00:07 骤增到 02:11,此后才恢复正常。

不过,为什么在真机调试下就会有这个“延迟更新”的问题呢?一开始我还猜想这是因为 seek 是异步的,onTimeUpdate 抢先它执行了,但经过测试发现它其实是同步的。所以,或许是因为真机调试下有延迟?这个先不管了,现在我们先看一下怎么解决这个 bug。

解决方案

问题的根源在于,我们在 onTimeUpdate 中是拿 currentTime 作为标准去进行数据修改的,并且认定 currentTime 是正确的数据,但其实,由于延迟更新的问题,这个数据有时候是错误的。所以我们可以做一个判断,一旦发现数据是错误的(没更新过来),我们就改用 progress 作为标准去进行数据修改(progress 不会出错)

PS:为什么不统一以 progress 作为标准呢?因为在不拖拽的情况下,progress 是基于 currentTime 进行计算的,所以正常情况还是得用 currentTime

如何判断数据是错误的呢?这里用了一个比较笨+不优雅的方法:在调用 onTimeUpdate 的时候,拿到实际的当前秒数以及基于 progress 计算的理想的当前秒数。经过测试发现,正常情况下这两者的偏差不会大于2,而在不正常的情况下(比如截图红圈部分),这两者相差会很大,彼此的差距大概就是我们拖动进度条前后的差距。

这样,我们就可以把代码改成:

backgroundAudioManager.onTimeUpdate(() => {
    // 不拖拽的时候才setData
    if(!isMoving){         
        let currentTime = 0
        if(Math.abs(backgroundAudioManager.currentTime - totalSec * this.data.progress/100) < 2){
            console.log('同步')
            currentTime = backgroundAudioManager.currentTime
        } else {
            console.log('不同步')
            currentTime = totalSec * this.data.progress/100     
        }
        // 获取当前激活时刻
        let sec = currentTime.toString().split('.')[0]
        // 设置movableview进度
        let movableDist = (movableAreaWidth - movableViewWidth) * currentTime / totalSec
        // 设置progress-bar进度
        let progress = 100 * currentTime / totalSec
        // 赋值
        if(compareSec != sec){
            this.setData({
                movableDist,
                progress,
                ['showtime.currentTime']: this.timeFormat(currentTime)
            })
            compareSec = sec
        }
    }
})

理论上好像说得过去,实际效果如何呢?真机调试看一下:

因为我是录屏然后转成 GIF 的,帧数比较低,但是经过反复测试,确实没有进度条回跳的 bug 了。

到这里,bug 就算解决了。当然,可能还会有其它更好的解决方式,后续我会找个时间再看下能不能进行优化和改进,有思路的大佬也欢迎留言指点。小程序的坑着实不少,但是我觉得应该享受这种踩坑后又从坑里爬出来的感觉。最后要特别感谢群里的 @疯子大佬,多亏他的提醒,让我定位到问题的关键部位。