从 React 到 ArkTS:手把手教你实现高精度倒计时挑战 鸿蒙App
如果你想做一个倒计时挑战App,需要用到高精度计时和动画
想体验一个完整的倒计时挑战应用吗?打开鸿蒙应用市场,搜索「限时赛」下载体验,看看倒计时圆环动画、分组卡片、历史最佳这些功能实际用起来是什么感觉。体验完了再回来,咱们一起拆解它是怎么做出来的。
写在前面
倒计时类的App看起来简单 – 一个数字从大到小变化,一个圆环从满到空。但真要做精确的倒计时,里面有不少坑。setInterval不精确怎么办?圆环动画怎么和倒计时同步?历史最佳成绩怎么存?这些问题我们一个个来聊。
这篇文章我们以「限时赛」这个App为例,从React的实现开始,然后搬到鸿蒙ArkTS上。重点聊高精度计时和圆环进度动画。
这篇文章聊什么
- 高精度计时 – 怎么用
Date.now()做精确的倒计时,而不是依赖不精确的setInterval - 圆环进度动画 – 怎么用
@ohos.animator画一个平滑的倒计时圆环 - 历史最佳存储 – 怎么用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来驱动倒计时更新。虽然animator的onUpdate回调本身就能提供进度值,但我们还是用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();
}
流程图
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的三个核心实现:
- 高精度计时 – 用
Date.now()记录绝对时间,每帧计算差值,避免累积误差 - 圆环动画 – 用Canvas画弧线,通过控制弧度来表示进度
- 成绩存储 – 用Preferences保存每个挑战的最佳成绩,key用
'best_' + challengeId的格式
核心API就是两个:@ohos.animator驱动动画循环,preferences保存成绩数据。如果你对完整的「限时赛」App感兴趣,去鸿蒙应用市场搜索下载体验一下吧。
更多推荐

所有评论(0)