如果你想做一个倒计时挑战App,需要用到高精度计时和动画

想体验一个完整的倒计时挑战应用吗?打开鸿蒙应用市场,搜索「限时赛」下载体验,看看倒计时圆环动画、分组卡片、历史最佳这些功能实际用起来是什么感觉。体验完了再回来,咱们一起拆解它是怎么做出来的。


写在前面

倒计时类的App看起来简单 – 一个数字从大到小变化,一个圆环从满到空。但真要做精确的倒计时,里面有不少坑。setInterval不精确怎么办?圆环动画怎么和倒计时同步?历史最佳成绩怎么存?这些问题我们一个个来聊。

这篇文章我们以「限时赛」这个App为例,从React的实现开始,然后搬到鸿蒙ArkTS上。重点聊高精度计时和圆环进度动画。

这篇文章聊什么

  1. 高精度计时 – 怎么用Date.now()做精确的倒计时,而不是依赖不精确的setInterval
  2. 圆环进度动画 – 怎么用@ohos.animator画一个平滑的倒计时圆环
  3. 历史最佳存储 – 怎么用Preferences保存和比较历史成绩

第一步:高精度倒计时

很多人做倒计时第一反应是用setInterval(fn, 1000),每秒减1。但setInterval是不精确的,它受事件循环影响,实际间隔可能大于1000ms,长时间运行会累积误差。正确的做法是用Date.now()记录开始时间,每次计算剩余时间。

React版本

function useCountdown(totalSeconds) {
  const [remaining, setRemaining] = useState(totalSeconds);
  const [isRunning, setIsRunning] = useState(false);
  const startTimeRef = useRef(null);
  const animFrameRef = useRef(null);

  const start = useCallback(() => {
    startTimeRef.current = Date.now();
    setIsRunning(true);

    function tick() {
      const elapsed = (Date.now() - startTimeRef.current) / 1000;
      const left = Math.max(0, totalSeconds - elapsed);
      setRemaining(left);

      if (left > 0) {
        animFrameRef.current = requestAnimationFrame(tick);
      } else {
        setIsRunning(false);
      }
    }

    animFrameRef.current = requestAnimationFrame(tick);
  }, [totalSeconds]);

  const stop = useCallback(() => {
    if (animFrameRef.current) {
      cancelAnimationFrame(animFrameRef.current);
    }
    setIsRunning(false);
  }, []);

  // 组件卸载时清理
  useEffect(() => {
    return () => {
      if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
    };
  }, []);

  return { remaining, isRunning, start, stop };
}

这个自定义Hook的思路是:记录开始时间startTimeRef.current,每次tick时用Date.now()减去开始时间得到已经过去的时间,再用总时间减去已过时间得到剩余时间。用requestAnimationFrame而不是setInterval,因为requestAnimationFrame和屏幕刷新同步,更新频率更稳定,而且页面不可见时会自动暂停,省电。

Math.max(0, totalSeconds - elapsed)确保剩余时间不会变成负数。当剩余时间为0时,停止动画循环。

ArkTS版本

import { animator } from '@kit.ArkUI';

@Entry
@Component
struct CountdownPage {
  @State remaining: number = 60;       // 剩余秒数
  @State isRunning: boolean = false;   // 是否在运行
  @State progress: number = 1;         // 圆环进度,1=满,0=空
  private totalSeconds: number = 60;   // 总秒数
  private startTime: number = 0;       // 开始时间戳
  private timerAnimator: animator.AnimatorResult | null = null;

  // 开始倒计时
  startCountdown() {
    this.startTime = Date.now();
    this.isRunning = true;

    const options: animator.AnimatorOptions = {
      duration: this.totalSeconds * 1000,  // 动画时长 = 倒计时总时长
      easing: 'linear',  // 线性缓动,匀速减少
      delay: 0,
      iterations: 1,
      fill: 'forwards',
      onUpdate: (value: AnimatorResult) => {
        // value从0到1,表示动画进度
        // 用Date.now()计算精确的剩余时间
        const elapsed: number = (Date.now() - this.startTime) / 1000;
        this.remaining = Math.max(0, this.totalSeconds - elapsed);
        // 圆环进度 = 剩余时间 / 总时间
        this.progress = this.remaining / this.totalSeconds;
      },
      onComplete: () => {
        this.remaining = 0;
        this.progress = 0;
        this.isRunning = false;
        // 倒计时结束,保存成绩
        this.saveResult();
      }
    };

    this.timerAnimator = animator.createAnimator(options);
    this.timerAnimator.start();
  }

  // 停止倒计时
  stopCountdown() {
    if (this.timerAnimator) {
      this.timerAnimator.cancel();
      this.timerAnimator = null;
    }
    this.isRunning = false;
  }
}

这里我们用@ohos.animator来驱动倒计时更新。虽然animatoronUpdate回调本身就能提供进度值,但我们还是用Date.now()来计算精确的剩余时间,而不是直接用value。这是因为animator的回调时间也可能有微小偏差,用绝对时间戳计算更可靠。

duration设为totalSeconds * 1000,这样动画的时长和倒计时的总时长一致。easing: 'linear'表示匀速,因为倒计时应该是匀速减少的,不需要加速减速效果。

第二步:倒计时圆环动画

倒计时旁边通常有一个圆环,随着时间推移逐渐变空。这个效果可以用Canvas或者用鸿蒙的Circle组件配合stroke来实现。

React版本 – 用SVG画圆环

function CountdownRing({ progress, remaining }) {
  const radius = 80;
  const circumference = 2 * Math.PI * radius;
  const offset = circumference * (1 - progress);

  return (
    <div style={{ position: 'relative', width: 200, height: 200 }}>
      <svg width="200" height="200">
        {/* 背景圆环 */}
        <circle
          cx="100" cy="100" r={radius}
          fill="none" stroke="#E0E0E0" strokeWidth="8"
        />
        {/* 进度圆环 */}
        <circle
          cx="100" cy="100" r={radius}
          fill="none" stroke="#FF6B6B" strokeWidth="8"
          strokeDasharray={circumference}
          strokeDashoffset={offset}
          strokeLinecap="round"
          transform="rotate(-90 100 100)"
          style={{ transition: 'stroke-dashoffset 0.1s' }}
        />
      </svg>
      {/* 中间的剩余时间文字 */}
      <div style={{
        position: 'absolute', top: '50%', left: '50%',
        transform: 'translate(-50%, -50%)',
        fontSize: 36, fontWeight: 'bold'
      }}>
        {Math.ceil(remaining)}
      </div>
    </div>
  );
}

SVG圆环的原理是:strokeDasharray设为圆的周长,strokeDashoffset设为周长乘以(1 - progress)。当progress为1时,offset为0,整个圆环都显示;当progress为0时,offset等于周长,整个圆环都隐藏。rotate(-90)让圆环从顶部开始。

ArkTS版本 – 用Canvas画圆环

// 画倒计时圆环
private drawCountdownRing(ctx: CanvasRenderingContext2D, progress: number,
  width: number, height: number) {
  const centerX: number = width / 2;
  const centerY: number = height / 2;
  const radius: number = 80;
  const lineWidth: number = 8;

  ctx.clearRect(0, 0, width, height);

  // 画背景圆环(灰色)
  ctx.beginPath();
  ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI);
  ctx.strokeStyle = '#E0E0E0';
  ctx.lineWidth = lineWidth;
  ctx.lineCap = 'round';
  ctx.stroke();

  // 画进度圆环(红色)
  // 从顶部开始(-90度),顺时针画
  const startAngle: number = -Math.PI / 2;
  const endAngle: number = startAngle + 2 * Math.PI * progress;

  ctx.beginPath();
  ctx.arc(centerX, centerY, radius, startAngle, endAngle);
  ctx.strokeStyle = '#FF6B6B';
  ctx.lineWidth = lineWidth;
  ctx.lineCap = 'round';
  ctx.stroke();

  // 画中间的剩余时间文字
  const seconds: number = Math.ceil(this.remaining);
  ctx.fillStyle = '#333333';
  ctx.font = 'bold 36px sans-serif';
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillText(seconds.toString(), centerX, centerY);
}

鸿蒙没有SVG组件,所以用Canvas来画圆环。原理和SVG版本一样:先画一个完整的灰色背景圆,再画一个部分弧度的红色进度圆。startAngle设为-Math.PI / 2(也就是-90度),这样圆环从顶部开始。endAngle根据progress计算,progress越大,画的弧度越长。

lineCap: 'round'让圆环端点是圆角,看起来更柔和。文字用fillText画在圆心位置。

第三步:分组卡片和挑战列表

倒计时App通常有多个挑战项目,每个项目有不同的时长。我们用卡片列表来展示。

React版本

const challenges = [
  { id: '1', name: '速算挑战', group: '数学', duration: 30, bestTime: null },
  { id: '2', name: '单词速记', group: '语言', duration: 60, bestTime: null },
  { id: '3', name: '深呼吸练习', group: '健康', duration: 120, bestTime: null },
];

function ChallengeList({ challenges, onSelect }) {
  const groups = [...new Set(challenges.map(c => c.group))];

  return (
    <div>
      {groups.map(group => (
        <div key={group}>
          <h3>{group}</h3>
          {challenges
            .filter(c => c.group === group)
            .map(challenge => (
              <div key={challenge.id} onClick={() => onSelect(challenge)}>
                <span>{challenge.name}</span>
                <span>{challenge.duration}秒</span>
                {challenge.bestTime && <span>最佳: {challenge.bestTime}秒</span>}
              </div>
            ))}
        </div>
      ))}
    </div>
  );
}

ArkTS版本

interface ChallengeItem {
  id: string;
  name: string;
  group: string;
  duration: number;       // 时长(秒)
  bestTime: number | null; // 最佳完成时间
}

@Entry
@Component
struct ChallengeListPage {
  @State challenges: ChallengeItem[] = [
    { id: '1', name: '速算挑战', group: '数学', duration: 30, bestTime: null },
    { id: '2', name: '单词速记', group: '语言', duration: 60, bestTime: null },
    { id: '3', name: '深呼吸练习', group: '健康', duration: 120, bestTime: null },
  ];
  @State selectedChallenge: ChallengeItem | null = null;

  // 按分组筛选
  getGroups(): string[] {
    const groupSet: Set<string> = new Set();
    for (const c of this.challenges) {
      groupSet.add(c.group);
    }
    return Array.from(groupSet);
  }

  // 获取某个分组下的挑战
  getChallengesByGroup(group: string): ChallengeItem[] {
    return this.challenges.filter(c => c.group === group);
  }

  build() {
    Scroll() {
      Column({ space: 16 }) {
        Text('限时挑战')
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
          .margin({ top: 20 })

        ForEach(this.getGroups(), (group: string) => {
          Column({ space: 8 }) {
            // 分组标题
            Text(group)
              .fontSize(18)
              .fontWeight(FontWeight.Medium)
              .width('100%')
              .padding({ left: 16 })

            // 该分组下的挑战卡片
            ForEach(this.getChallengesByGroup(group), (item: ChallengeItem) => {
              Row() {
                Column({ space: 4 }) {
                  Text(item.name)
                    .fontSize(16)
                    .fontWeight(FontWeight.Medium)
                  Text(item.duration + '秒')
                    .fontSize(12)
                    .fontColor('#999999')
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)

                if (item.bestTime !== null) {
                  Text('最佳: ' + item.bestTime + '秒')
                    .fontSize(12)
                    .fontColor('#FF6B6B')
                }
              }
              .width('100%')
              .padding(16)
              .borderRadius(12)
              .backgroundColor('#FFFFFF')
              .shadow({ radius: 4, color: '#00000010', offsetY: 2 })
              .onClick(() => {
                this.selectedChallenge = item;
              })
            }, (item: ChallengeItem) => item.id)
          }
        }, (group: string) => group)
      }
      .padding(16)
    }
  }
}

这里用了两层ForEach:外层遍历分组,内层遍历每个分组下的挑战项。getGroups()Set去重得到所有分组名,getChallengesByGroup()filter筛选。

卡片用Row布局,左边是挑战名称和时长,右边是最佳成绩。点击卡片后设置selectedChallenge,可以跳转到倒计时页面。

第四步:保存和比较历史最佳

倒计时结束后,需要保存成绩并和之前的最佳成绩比较。

ArkTS版本

import { preferences } from '@kit.ArkData';

// 保存挑战结果
async function saveChallengeResult(context: Context, challengeId: string,
  timeUsed: number) {
  const store = await preferences.getPreferences(context, 'challenge_store');

  // 读取之前的最佳成绩
  const bestKey: string = 'best_' + challengeId;
  const prevBest: preferences.ValueType = await store.get(bestKey, 0);
  const prevBestNum: number = prevBest as number;

  // 如果是第一次或者比之前更好,就更新
  if (prevBestNum === 0 || timeUsed < prevBestNum) {
    await store.put(bestKey, timeUsed);
    await store.flush();
    return true;  // 新纪录
  }
  return false;  // 没有破纪录
}

// 读取所有最佳成绩
async function loadBestResults(context: Context): Promise<Record<string, number>> {
  const store = await preferences.getPreferences(context, 'challenge_store');
  const data = await store.get('all_bests', '{}');
  return JSON.parse(data as string) as Record<string, number>;
}

保存成绩的逻辑很简单:用'best_' + challengeId作为key,值是最佳用时。每次完成挑战后,如果当前用时比之前的小(或者第一次玩),就更新。timeUsed是实际用时,越小越好,所以用<比较。

这里有个设计选择:每个挑战的最佳成绩用单独的key存储,而不是把所有成绩放在一个大的JSON对象里。这样做的好处是更新单个成绩不需要读取和写入整个对象,操作更轻量。

第五步:完整的倒计时页面

把圆环、计时器、按钮组合在一起。

ArkTS UI代码

@Entry
@Component
struct CountdownPage {
  @State remaining: number = 60;
  @State isRunning: boolean = false;
  @State progress: number = 1;
  @State isNewRecord: boolean = false;
  private totalSeconds: number = 60;
  private startTime: number = 0;
  private timerAnimator: animator.AnimatorResult | null = null;
  private settings: RenderingContextSettings = new RenderingContextSettings(true);
  private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);

  build() {
    Column({ space: 24 }) {
      Text('限时挑战')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 20 })

      // 圆环倒计时
      Canvas(this.ctx)
        .width(200)
        .height(200)
        .onReady(() => {
          this.drawCountdownRing(this.ctx, this.progress, 200, 200);
        })

      // 控制按钮
      Row({ space: 16 }) {
        Button('开始')
          .width(120)
          .height(44)
          .type(ButtonType.Capsule)
          .backgroundColor('#4ECDC4')
          .enabled(!this.isRunning)
          .onClick(() => this.startCountdown())

        Button('停止')
          .width(120)
          .height(44)
          .type(ButtonType.Capsule)
          .backgroundColor('#FF6B6B')
          .enabled(this.isRunning)
          .onClick(() => this.stopCountdown())
      }

      // 新纪录提示
      if (this.isNewRecord) {
        Text('新纪录!')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FF6B6B')
      }
    }
    .width('100%')
    .alignItems(HorizontalAlign.Center)
  }
}

注意Canvas的onReady回调只在Canvas第一次准备好时触发一次。后续倒计时更新时,我们需要在onUpdate回调里手动调用drawCountdownRing来重绘。所以完整的startCountdown方法应该是这样的:

startCountdown() {
  this.startTime = Date.now();
  this.isRunning = true;
  this.isNewRecord = false;

  const options: animator.AnimatorOptions = {
    duration: this.totalSeconds * 1000,
    easing: 'linear',
    delay: 0,
    iterations: 1,
    fill: 'forwards',
    onUpdate: (value: AnimatorResult) => {
      const elapsed: number = (Date.now() - this.startTime) / 1000;
      this.remaining = Math.max(0, this.totalSeconds - elapsed);
      this.progress = this.remaining / this.totalSeconds;
      // 每一帧都重绘圆环
      this.drawCountdownRing(this.ctx, this.progress, 200, 200);
    },
    onComplete: () => {
      this.remaining = 0;
      this.progress = 0;
      this.isRunning = false;
      this.drawCountdownRing(this.ctx, 0, 200, 200);
      this.saveResult();
    }
  };

  this.timerAnimator = animator.createAnimator(options);
  this.timerAnimator.start();
}

流程图

用户选择挑战项目

进入倒计时页面

显示圆环和初始时间

用户点击开始

记录Date.now开始时间

创建animator动画

onUpdate每帧回调

计算精确剩余时间

更新圆环进度

重绘Canvas圆环

时间是否用完?

倒计时结束

保存成绩到Preferences

是否新纪录?

显示新纪录提示

显示完成提示

React vs ArkTS 对比表

功能点 React (Web) ArkTS (鸿蒙)
高精度计时 Date.now() + requestAnimationFrame Date.now() + @ohos.animator
圆环绘制 SVG circle + strokeDashoffset Canvas arc + stroke
动画驱动 requestAnimationFrame循环 animator.createAnimator
数据存储 localStorage Preferences
列表分组 Array.filter + map ForEach + filter
状态管理 useState + useRef @State + 普通属性

总结

这篇文章我们聊了倒计时挑战App的三个核心实现:

  1. 高精度计时 – 用Date.now()记录绝对时间,每帧计算差值,避免累积误差
  2. 圆环动画 – 用Canvas画弧线,通过控制弧度来表示进度
  3. 成绩存储 – 用Preferences保存每个挑战的最佳成绩,key用'best_' + challengeId的格式

核心API就是两个:@ohos.animator驱动动画循环,preferences保存成绩数据。如果你对完整的「限时赛」App感兴趣,去鸿蒙应用市场搜索下载体验一下吧。

更多推荐