查看原文
其他

把倒计时做到极致,又准、又稳!

小政 承香墨影 2022-09-09

快手电商无线团队-小政 | 作者

承香墨影 | 编辑

https://juejin.cn/post/6984725689257689101 | 原文

Hi,大家好,这里是承香墨影!

能上架的 App,都逃不过一个倒计时的功能,手机验证码登录总归是要实现的。那这个功能中,实现的倒计时就包括每秒去修改 UI 倒计时计数,让时间的流逝做到用户可感知。

那么对于倒计时的功能,有那些要求呢?我认为有 2 点: &

是说,一个 2 分钟的倒计时,就应该严格执行 2 分钟,不会多 1 秒也不会少 1 秒;的意思就是说,每次执行同步 UI 的更新,间隔都是固定的,例如需要每秒 -1 的倒计时,那每次更新 UI 的时间,应该都差不多是间隔 1秒。

而在客户端的环境下,有太多影响因素。比如 App 退出后台并且锁屏,导致 CPU 休眠了;又比如定时器每次唤醒的时间差了几毫秒甚至几十毫秒,倒计时结束时,总间隔时长大于预设的时长,这都是需要解决的问题。

今天给大家推荐一篇来自快手电商无线团队-小政的文章,看快手是如何处理倒计时问题的,让时间倒数的又准又稳。

一、背景

我们在项目中经常有倒计时的场景,比如活动倒计时、抢红包倒计时等等。通常情况下,我们实现倒计时的方案有 Android 中的 CountDownTimer、Java 中自带的 TimerScheduleExcutorService、RxJava 中的 interval 操作符。

在实际项目中存在 2 个典型的问题。

  1. 倒计时的实现形式不统一,不统一的原因分为认知不一致、每种倒计时方案各有优势;
  2. 存在大量倒计时同时执行。

二、对比分析

关于几种方案的用法不是本文要讨论的重点,在此我们通过表格的方式列出来各自的特性,表格底部的 CountDownTimerManager 就是本文要为大家介绍的新鲜出炉的中心化倒计时组件。

2.1 是否是倒计时

Rx 中的 interval 操作符,是每隔一段时间会发送一个事件,可以说是一个计数器,而不是倒计时,在实际项目中会发现很多同学,都把它当做倒计时在使用。下图是 RxJava 官方对 interval 的图解:

The Interval operator returns an Observable that emits an infinite sequence of ascending integers, with a constant interval of time of your choosing between emissions.(简单理解就是固定间隔时间进行回调

通过源码,我们也可以看出在 ObservableInterval 中实际也是进行了周期性调度。

public final class ObservableInterval extends Observable<Long> {

  @Override
  public void subscribeActual(Observer<? super Long> observer) {
    IntervalObserver is = new IntervalObserver(observer);
    observer.onSubscribe(is);

    Scheduler sch = scheduler;

    if (sch instanceof TrampolineScheduler) {
      Worker worker = sch.createWorker();
      is.setResource(worker);

      worker.schedulePeriodically(is, initialDelay, period, unit);
    } else {

      Disposable d = sch.schedulePeriodicallyDirect(is, initialDelay, period, unit);
      is.setResource(d);
    }
  }
}

那么作为倒计时使用会有什么问题呢?

  • 问题一:回调可能不准确,假设倒计时 9.5 秒,每 1 秒刷新一次 View,该怎么设置回调间隔时间呢?

  • 问题二:在手机长时间息屏后,某些厂商会将 CPU 休眠,RxJava 的 interval 操作符此时将被按下暂停键,当 APP 再次回到前台,interval 会继续执行,假设暂停时倒计时剩余 100 秒,回到前台后实际只有 10 秒了,但是 interval 还是从 100 继续执行。

2.2 支持多任务

Timer 是单线程串行执行多任务,假设 taskA 设定 1 秒后执行,taskB 设定 2 秒后执行,实际上 taskB 是在 taskA 执行结束后才执行 taskB,所以 taskB 的执行时间是在第 3 秒,所以 Timer 只算是伪支持多任务。ScheduledExecutorService 是利用线程池支持了多任务调度的。

2.3 支持时间校准

CountDownTimer 中每次 onTick() 方法回调,都会重新计算下一次 onTick 的时间。其中主要优化有 2 点,一是减去 onTick() 执行耗时;二是针对特殊情况(如 2.1 中提到的手机息屏后 CPU 休眠场景),对比 delay 是否小于 0,如果小于 0 则需要累加 mCountdownInterval

long lastTickStart = SystemClock.elapsedRealtime();
onTick(millisLeft);
long lastTickDuration = SystemClock.elapsedRealtime() - lastTickStart;
long delay;
if (millisLeft < mCountdownInterval) {

  delay = millisLeft - lastTickDuration;
  if (delay < 0) {
    delay = 0;
  } else {
    delay = mCountdownInterval - lastTickDuration;

    while (delay < 0) {
     delay += mCountdownInterval;
    }
  }
  sendMessageDelayed(obtainMessage(MSG), delay);
}

2.4 支持同帧刷新

我们项目中有很多场景是这样的:

倒计时 A 先执行,倒计时 B 后执行,A 和 B 的倒计时结束时间是一致的,那么我们假设倒计时时间为 10 秒,每 1 秒刷新一次,A 在剩余 10 秒时执行,B 在剩余 9.5 秒执行,当二者在同一页面显示时,就会刷新不一致,这个问题在我们新的倒计时组件中将得到解决,文章后面将会详细说明。

2.5 支持延迟执行

延迟 1 分钟再执行 10 秒的倒计时?Android 中提供的 CountDownTimer 是做不到的,只能额外写一个 1 分钟的定时器,到时间后再启动倒计时。

2.6 支持 CPU 休眠

我们这里提到的支持 CPU 休眠,并不是指 CPU 休眠期间倒计时仍能得到执行,而是在 CPU 休眠后能够恢复正常执行。和 1.2.3 中提到的时间校准类似,解决了时间校准的问题也就支持了 CPU 休眠的特性。

三、需求目标

  1. 设计一个中心化的倒计时组件,同时支持上述提到的一系列特性;
  2. 接口易于调用,使用者只需关注计时回调的逻辑;

四、设计类结构

CountDownTimer 采用静态内部类形式实现单例,暴露 countdown()timer() 方法供业务方 ClientA/ClientB/ClientC 等调用,Task 是抽象任务,每次调用 countdown()timer() 后都生成一个 task,交给优先级队列管理,内部通过 handler 不断从队列中取 task 执行。

五、具体实现

5.1 收口

收口可以理解为进行统一管理,这里我们通过一个优先级队列管理所有倒计时、定时器,优先级队列可以直接采用 Java 中已有的数据结构 PriorityQueue,设置队列大小默认为 5,根据 task 中的 mExecuteTimeInNext 进行正序排序。

这里有一个特别需要注意的点,PriorityQueue 需要传入实现 Comparator 接口的对象,在实现 Comparator 时,因为 mExecuteTimeInNext 的数据类型是 long 类型,而 compare() 方法返回的是 int 类型,如果直接使用二者相减再强制转换为 int,会有溢出的风险,所以可以使用 Long.compare() 来实现大小比较。

private final Queue<Task> mTaskQueue = new PriorityQueue<>(DEFAULT_INITIAL_CAPACITY,
  new Comparator<Task>() {
    @Override
    public int compare(Task task1, Task task2) {
      return Long.compare(task1.mExecuteTimeInNext, task2.mExecuteTimeInNext);
    }
  });

5.2 支持与 RxJava 协同

提供倒计时countdown、定时器 timer 操作符,直接返回 Observable,方便与 RxJava 框架协同。

  public synchronized Observable<Long> countdown(long millisInFuture, long countDownInterval, long delayMillis) {
  AtomicReference<Task> taskAtomicReference = new AtomicReference<>();
  return Observable.create((ObservableOnSubscribe<Long>) emitter -> {
    Task newTask = new Task(millisInFuture, countDownInterval, delayMillis, emitter);
    taskAtomicReference.set(newTask);
    synchronized (CountDownTimerManager.this) {
      Task topTask = mTaskQueue.peek();
      if (topTask == null || newTask.mExecuteTimeInNext < topTask.mExecuteTimeInNext) {
        cancel();
      }
      mTaskQueue.offer(newTask);
      if (mCancelled) {
        start();
      }
    }
  }).doOnDispose(() -> {
    if (taskAtomicReference.get() != null) {
      taskAtomicReference.get().dispose();
    }
  });
}
public synchronized Observable<Long> timer(long millisInFuture) {
  return countdown(00, millisInFuture);
}

private synchronized void remove(Task task) {
  mTaskQueue.remove(task);
  if (mTaskQueue.size() == 0) {
    cancel();
  }
}

5.3 支持时间校准

不推荐使用 RxJava 中的 interval,因为 RxJava 中的实现无法保障倒计时的准确执行,如在手机 CPU 进入休眠之后再恢复到前台。那么如何实现呢?这里借鉴了 Android 中 CountDownTimer 的设计思路,在每次 onTick 后重新计算了下一次 onTick 的时间,比如前文提到的 “CPU 进入休眠” 的情况,我们通过一个 while 循环,计算出下一次 onTick 的时间(其条件是大于当前时间)。

mTaskQueue.poll();
if (!task.isDisposed()) {
  if (stopMillisLeft <= 0 || task.mCountdownInterval == 0) {
    task.mDisposed = true;
    task.mEmitter.onNext(0L);
    task.mEmitter.onComplete();
  } else {
    task.mEmitter.onNext(stopMillisLeft % task.mCountdownInterval == 0 ? stopMillisLeft
        : (stopMillisLeft / task.mCountdownInterval + 1) * task.mCountdownInterval);

    do {
      task.mExecuteTimeInNext += task.mCountdownInterval;
    } while (task.mExecuteTimeInNext < SystemClock.elapsedRealtime());
    mTaskQueue.offer(task);
  }
}

5.4 支持同步刷新

针对多个倒计时在同一时刻结束的情况,优化了刷新不同步的问题。mExecuteTimeInNext 是下一次任务执行时间,假设倒计时剩余时间为 9.5 秒,每 1 秒刷新,那么下一次的执行时间则是在 0.5 秒之后。

private Task(long millisInFuture, long countDownInterval, long delayMillis,
    @NonNull ObservableEmitter<Long> emitter) 
{
    
  mCountdownInterval = countDownInterval;
  mExecuteTimeInNext = SystemClock.elapsedRealtime() + (mCountdownInterval == 0 ? 0
      : millisInFuture % mCountdownInterval) + delayMillis;
  mStopTimeInFuture = SystemClock.elapsedRealtime() + millisInFuture + delayMillis;
  mEmitter = emitter;
}

5.5 支持延迟执行

在计算下次执行的时间时,加上了 delayMillis,这样就支持了延迟执行。

private Task(long millisInFuture, long countDownInterval, long delayMillis,
    @NonNull ObservableEmitter<Long> emitter) 
{
  mCountdownInterval = countDownInterval;

  mExecuteTimeInNext = SystemClock.elapsedRealtime() + (mCountdownInterval == 0 ? 0
      : millisInFuture % mCountdownInterval) + delayMillis;
  mStopTimeInFuture = SystemClock.elapsedRealtime() + millisInFuture + delayMillis;
  mEmitter = emitter;
}

六、小结

文内逻辑清晰,核心代码都在文中,有需要自己复制出来改改就能用。

另外我看到有人比较关心如何测试 CPU 休眠这个场景,CPU 是否进入休眠,完全取决于 OS 的策略。

不过有个步骤可以试试,将 App 置为后台,然后锁屏,在现在续航优化的大环境下,多数手机厂商对电池的优化手段,都会在这个场景,进入 CPU 休眠状态,如果还不行,可以将手机设为「省电模式」。

-- End --

本文对你有帮助吗?留言、转发、点好看是最大的支持,谢谢!

推荐阅读:

还在用 Glide?看看 Google 官推的图片库 Coil 有何不同!

效果炸了,Drawable 实现红鲤鱼动画,点哪儿游哪儿(下)

微信Matrix不好用?主要原因是监控日志解析方面 - 那手写一个!

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存