带你实现小程序高性能动画——秒杀倒计时

1,044 阅读6分钟

在小程序开发中,优美流畅的动画可以显著提升用户体验,尤其是互动式动画。通过联动页面元素的变化,可以实现一种动态的、反馈式的视觉效果。为了满足这些需求,小程序提供了多种实现动画的方式。下面我们将逐一探讨,并重点讲解如何实现高性能倒计时功能。

四种实现动画的方式

  • CSS Animation(使用 Animation 对象实现)
    这是小程序最早支持的动画方式。它通过 Animation 对象创建 CSS 动画,但性能效率较低,在高频次渲染需求的场景下表现欠佳。

  • 关键帧动画(使用 animate 方法实现)
    关键帧动画是通过页面或组件对象的 animate 方法实现的。这种方式性能相对较好,且接口简单易用,目前是官方推荐的方式之一。然而,尽管它的性能有所提升,但并非最高效的选择。

  • 响应式动画(基于滚动事件驱动)
    这种动画通过滚动事件触发,能够根据页面滚动位置实时调整动画进度。它的用户体验非常好,且实现成本低,适合创建响应式的页面交互效果。

  • 高性能动画(通过 WXS 脚本实现)
    WXS 脚本方式是目前效率最高的动画实现手段,因为它能够在视图层直接操作组件样式,无需经过逻辑层的数据传递。其关键优势在于避免了逻辑层与视图层之间的通信延迟,实现了更流畅的动画效果。

高性能倒计时功能的实现

一个典型的动画性能问题是付款倒计时功能。在订单列表中,每个待付款订单需要显示倒计时。当订单数量较多时,性能问题尤为明显,特别是当逻辑层依赖 setTimeoutsetInterval 实现计时时,性能开销和时间不准确性问题会显著增加。

问题1:计时不准确性
传统的倒计时实现方式是通过逻辑层的 setTimeout 实现,由于 setData 处理的延迟以及两层通信的延迟,导致计时误差累积。随着倒计时时间的增加,误差会变得越来越大,甚至使倒计时功能失去实际意义。

优化思路:引入 WXS 脚本处理倒计时
为了改善这个问题,可以将倒计时的计算交由 WXS 脚本来处理。WXS 脚本运行在视图层内,能够减少逻辑层与视图层之间的通信延迟,确保计时的精确性。

f68ba05a25392a6d42540eff9ed126db.gif

使用 WXS 实现高性能倒计时

尽管 WXS 没有原生的定时器功能,但我们可以通过组件对象传递的 ownerInstance 访问定时器方法,如 setTimeoutsetInterval。然而,考虑到性能,requestAnimationFrame 是更为推荐的方式。这个方法由视图层的帧率驱动,能够确保动画与页面的渲染频率保持一致,从而避免传统定时器带来的不流畅问题。相比定时器,它更适合频繁更新界面内容的场景,如倒计时功能。

性能测试与优化结果

通过在 WXS 脚本中使用 requestAnimationFrame 实现倒计时功能,性能测试显示,单次视图更新的时间消耗在 20 毫秒左右,较传统的逻辑层倒计时实现方案有所提升。特别是在高并发场景中,这种优化能够显著减少性能开销,并且计时的精度也得到了有效的保证。

页面切换与后台处理

在页面切换到后台时,可以通过监听页面的 onHide 事件,停止 requestAnimationFrame 的调用,以减少不必要的性能消耗。在用户返回页面时,再次启动倒计时的更新。

逐行解释代码实现

组件的JS代码

Component({
  data: {
    timeRemaining: -1, // 剩余时间,初始值为-1
    countdownInfo: {
      expiredTime: 0, // 倒计时结束的时间戳
      status: false,  // 倒计时状态,是否在进行中
    },
  },
  properties: {
    expiredTime: {
      type: Number,
      value: -1, // 过期时间,默认值为-1
    },
    extText: {
      type: String,
      value: '', // 额外显示的文本
    },
  },
  lifetimes: {
    attached() {
      this.startCountDown(); // 组件挂载时启动倒计时
    },
    detached() {
      this.setData({
        countdownInfo: {
          expiredTime: 0,
          status: false,
          isHide: true // 组件卸载时停止倒计时
        },
      });
    },
  },
  pageLifetimes: {
    show() {
      this.setData({
        "countdownInfo.isHide": false, // 页面显示时继续倒计时
      });
    },
    hide() {
      this.setData({
        "countdownInfo.isHide": true, // 页面隐藏时暂停倒计时
      });
    }
  },
  methods: {
    startCountDown() {
      if (this.data.countdownInfo.status || this.data.expiredTime === -1) return;
      const timeRemaining = this.data.expiredTime - Math.floor(new Date().getTime() / 1000);
      if (timeRemaining <= 0) {
        this.stopCountDown(); // 剩余时间小于等于0时停止倒计时
        return;
      }
      this.setData({
        countdownInfo: {
          expiredTime: this.data.expiredTime,
          status: true,
        },
      });
    },
    stopCountDown() {
      this.setData({
        timeRemaining: 0, // 停止倒计时,剩余时间设为0
      });
      this.triggerEvent('refreshOrder'); // 触发刷新订单事件
    },
  },
});

WXML代码

<view>{{extText}} <text class="time_remaining_text" wx:if="{{timeRemaining!==0 && expiredTime!==-1}}" change:prop="{{countdown.propObserver}}" prop="{{countdownInfo}}">{{countdown.formatCountDownTime(timeRemaining,'second')}}</text></view>
<wxs module="countdown">
var isHide = false;
function startCountdown(page, instance, _expiredTime, _status, _lastTs) {
    var expiredTime = _expiredTime;
    var status = _status;
    var lastTs = _lastTs;
    function calculateTime(){
        var timeRemaining = expiredTime - Math.floor(Date.now() / 1000);
        if (lastTs > timeRemaining) {
            lastTs = timeRemaining;
            page.callMethod("setData", {
                timeRemaining: timeRemaining // 更新剩余时间
            });
        }
        console.log("isHide", isHide);
        if (isHide) {
          return;
        } else if (timeRemaining > 0) {
            page.requestAnimationFrame(calculateTime); // 使用requestAnimationFrame实现高性能倒计时
        } else {
            page.callMethod("stopCountDown"); // 剩余时间为0时停止倒计时
        }
    }
    calculateTime();
}

function propObserver(newVal, oldVal, ownerInstance, instance) {
    if (!oldVal) return;
    var countdownInfo = newVal;
    var expiredTime = countdownInfo.expiredTime || 0;
    var status = countdownInfo.status || false;
    isHide = countdownInfo.isHide || false;
    var lastTs = expiredTime;
    if (status) {
        startCountdown(ownerInstance, instance, expiredTime, status, lastTs);
    }
}

module.exports = {
    propObserver: propObserver,
    formatCountDownTime: function (seconds, accuracy) {
        var second = seconds;
        if (!second || second < 0) {
          return '';
        }
        var day = Math.floor(second / (60 * 60 * 24));
        second -= day * 60 * 60 * 24;
        var hour = Math.floor(second / 3600);
        second -= hour * 3600;
        var minute = Math.floor(second / 60);
        if (minute === 60) {
          hour++;
          minute = 0;
        }
        second -= Math.floor(second / 60) * 60;
        var ret = '';
        if (hour > 0) {
          ret += hour + ':';
        }
        if (minute >= 0) {
          ret += minute < 10 ? '0' + minute + ':' : minute + ':';
        }
        if (accuracy === 'second') {
          second = Math.floor(second);
          ret += second < 10 ? '0' + second : second;
        }
        return ret;
    }
};
</wxs>

WXSS代码

/* 可以使得字体等宽,倒计时不会出现抖动 */
.time_remaining_text {
  display: inline-block;
  vertical-align: baseline !important;
  font-variant-numeric: tabular-nums;
  font-feature-settings: "tnum";
  font-size: 28rpx;
  text-align: right;
}

代码解析

  • 组件的JS代码:定义了倒计时的逻辑,包括启动和停止倒计时的方法。startCountDown方法会根据传入的expiredTime计算剩余时间,并每秒更新一次。stopCountDown方法会在倒计时结束时触发一个事件通知外部。

  • WXML代码:定义了倒计时的视图结构,并引用了WXS脚本。倒计时的文字会根据timeRemaining的变化自动更新。

  • WXS脚本:实现了高性能的倒计时逻辑。通过requestAnimationFrame方法,确保倒计时的更新与视图层的渲染同步。propObserver方法会监听countdownInfo的变化,触发倒计时的启动。

  • WXSS代码:通过CSS设置确保倒计时文字的等宽显示,避免抖动。

总结

通过将倒计时的动画实现交由 WXS 脚本处理,并使用 requestAnimationFrame 来替代传统的定时器方法,能够有效改善动画的流畅性和计时精度。WXS 脚本与视图层的高效协同使其成为高性能场景下的理想选择。最终的优化结果表明,即使在大量订单倒计时的场景下,页面性能也能保持稳定。