在购物比价/票务类应用里,抢票 是最能暴露"前端时间管理基本功"的场景------不是因为你算不准 Date.now(),而是因为倒计时一旦跟系统日历、用户提醒、后台挂起、设备时间变更 搅到一起,纯粹靠 setInterval的那套就不够看了。
华为官方把这个场景归到购物比价类行业实践的关键示例里,核心诉求很明确:
用户预约了一场演唱会/音乐会的抢票时间 → 页面启动一个倒计时 → 到点前通过系统日历植入一条提醒 → 用户切走、锁屏、甚至短暂改了时间,回来还得是准的。
下面把这件事从"业务故事"拆到"工程边界",给你一条可落地的实现路线。
一、先把场景讲清楚:倒计时和日历提醒不是一回事
很多人把它们混成一个功能,但仔细看,它们解决的是两层不同问题:
| 层 | 做什么 | 用户感知 |
|---|---|---|
| **倒计时(UI层)** | 告诉用户"还有多久开抢",驱动页面状态:未开始 → 即将开抢 → 开抢中 → 已结束 |
首页/详情页的计时器动效 |
| **日历提醒(系统层)** | 在用户系统日历里写入一条事件(带闹钟),即使App不在前台,到点也能弹系统通知 | 系统日历里的蓝色事件块 + 提醒弹窗 |
这两句话的区别就是整篇文的灵魂:
倒计时是App自己的事,日历提醒是给系统的委托书。
二、倒计时的工程陷阱:为什么 setInterval不够
先承认:setInterval(fn, 1000)能跑,但票务场景下它有三个硬伤:
陷阱1:设备休眠/锁屏后,ArkTS 的回调节奏不可靠
HarmonyOS 虽然不像 Android 那样激进杀后台,但锁屏 + 省电策略下,你的 UI 线程并不一定每秒准时 tick 一次。结果就是:倒计时会出现肉眼可见的"跳秒"或"卡住"。
解法:不要拿 interval 当真理,要把 interval 当"重绘触发器",每次 tick 都用 目标时间 - Date.now()重新算剩余秒数。
陷阱2:用户改了系统时间
用户手动往前/往后调时钟,你的 Date.now()跟着变------倒计时可能突然显示负数或大跳跃。
解法:在时间敏感的票务页,至少做两件事:
-
用**目标时间戳(服务器下发)**而非本地计算的相对值
-
每次 tick 做 sanity check:
remaining < 0 → 直接进"已开抢"状态
陷阱3:页面切走再回来,interval 丢了
典型路径:用户看到倒计时 → 按 Home 键 → 回来发现计时器停了或状态不对。
解法:在 onPageShow / onAppear里做一次追赶校正(re-sync)------重新读目标时间、重算剩余、再决定是否重建计时器。
三、架构:把倒计时引擎做成"状态机",别写成一堆 callback
票务倒计时本质上只有四个稳定状态,把它变成显式状态,后面所有 UI 和行为都跟着状态走:
IDLE(未到预约时间)
→ COUNTING(倒计时中)
→ READY(开抢瞬间 / 即将开抢窗口)
→ OPEN(可以抢了)
→ ENDED(过期)
最小倒计时引擎(代码克制版)
// utils/TicketCountdown.ets
export type CountdownStatus = 'idle' | 'counting' | 'ready' | 'open' | 'ended'
export interface CountdownState {
status: CountdownStatus
totalSeconds: number // 距开抢还剩
hh: string
mm: string
ss: string
}
export class TicketCountdown {
// 开抢时间(服务器时间戳 ms)
private targetTs: number
private timer?: number
private cb: (s: CountdownState) => void
constructor(targetTs: number, cb: (s: CountdownState) => void) {
this.targetTs = targetTs
this.cb = cb
}
start() {
this.stop()
this.tick()
// 用 setInterval 做"刷新驱动",但每次都拿 Date.now() 重算
this.timer = setInterval(() => this.tick(), 1000) as unknown as number
}
stop() {
if (this.timer) {
clearInterval(this.timer)
this.timer = undefined
}
}
// 切回前台 / 页面重进时调用,纠正漂移
resync() {
this.tick()
}
private tick() {
const diff = Math.max(0, this.targetTs - Date.now())
const totalSec = Math.ceil(diff / 1000)
let status: CountdownStatus = 'counting'
if (totalSec <= 0) status = 'open'
else if (totalSec <= 60) status = 'ready' // 最后60秒可标"即将开抢"
const hh = String(Math.floor(totalSec / 3600)).padStart(2,'0')
const mm = String(Math.floor((totalSec%3600)/60)).padStart(2,'0')
const ss = String(totalSec % 60).padStart(2,'0')
this.cb({ status, totalSeconds: totalSec, hh, mm, ss })
if (status === 'open') {
this.stop()
}
}
}
页面侧只关心状态:
// pages/TicketDetail.ets(片段)
import { TicketCountdown } from '../../utils/TicketCountdown'
@Entry
@Component
struct TicketDetail {
// 假设服务端下发:开抢时间(Unix ms)
private saleTs: number = new Date('2026-08-15T20:00:00+08:00').getTime()
private cd!: TicketCountdown
@State hh: string = '--'
@State mm: string = '--'
@State ss: string = '--'
@State st: 'idle'|'counting'|'ready'|'open'|'ended' = 'counting'
aboutToAppear() {
this.cd = new TicketCountdown(this.saleTs, (s) => {
this.hh = s.hh
this.mm = s.mm
this.ss = s.ss
this.st = s.status
})
this.cd.start()
}
onPageShow() {
// 回来时校正漂移
this.cd?.resync()
}
aboutToDisappear() {
this.cd?.stop()
}
build() {
Column({ space: 12 }) {
// 修改开抢时间(需求:点击"开抢"旁可修改)
Row() {
Text(`开抢时间:${new Date(this.saleTs).toLocaleString()}`)
.fontSize(13)
.fontColor('#666')
Text('修改')
.fontSize(13)
.fontColor('#007DFF')
.margin({ left: 8 })
.onClick(() => this.showTimePicker())
}
// 倒计时 UI
if (this.st === 'open') {
Button('立即抢票', { type: ButtonType.Capsule })
.width(200).height(44).backgroundColor('#FF2D2D')
} else {
Row({ space: 4 }) {
Text('距开抢').fontSize(13).fontColor('#999')
Text(this.hh).fontSize(28).fontWeight(700)
Text(':').fontSize(28)
Text(this.mm).fontSize(28).fontWeight(700)
Text(':').fontSize(28)
Text(this.ss).fontSize(28).fontWeight(700)
}
}
}
.padding(16)
}
showTimePicker() { /* 弹时间选择器改 this.saleTs 后重启 cd */ }
}
到这里,倒计时本身已经对系统时间漂移免疫 (因为每次 tick 都重算)、对页面切走可恢复 (因为 onPageShow→resync)。
四、日历提醒:把"别忘了抢票"委托给系统
倒计时的作用是"看着等",但用户不可能一直盯屏------所以你要给用户一个系统级提醒:把抢票事件写进他的日历,并让他到点收到系统通知。
4.1 用哪个能力
文档点名的核心是 @ohos.calendarManager------即 Calendar Manager Kit,能力边界是:
| 你能做 | 做不到的 |
|---|---|
| 读取日历账户、创建日历事件、设置提醒偏移 | 强制保证提醒一定响(取决于用户系统通知权限/勿扰模式) |
用 ReminderType设定提醒时间(如开抢前 15min) |
不能绕过用户在设置里关掉的日历通知 |
关键点:日历提醒不是"保底必达通道",它是用户主动授权的约定渠道------价值在于"把你的票务事件混进他日常日程里",而不是替代你自己的 push/站内信。
4.2 权限:先问,别直接写
日历操作需要对应权限声明 + 运行时授权(否则 API 调了也白调)。
在 module.json5/ 模块声明里:
"requestPermissions": [
{ "name": "ohos.permission.READ_CALENDAR", "reason": "需要读取日历以检查是否已添加提醒" },
{ "name": "ohos.permission.WRITE_CALENDAR", "reason": "需要写入抢票提醒到系统日历" }
]
运行时授权走 Ability 上下文(ATManager/requestPermissionsFromUser),这块不贴大段代码,你只需要记住一个原则:
别在
aboutToAppear里直接弹授权 + 写日历。先让用户点"添加提醒",再发起授权,否则驳回体验很差。
4.3 写日历事件的"最小正确形态"
思路是:先拿默认日历 → 构造事件 → 写进去 → 记下 eventId 以便后续删除/更新。
// utils/CalendarReminder.ets(示意骨架,API名字以你SDK版本为准)
import calendar from '@ohos.calendarManager'
async function addSaleReminder(
title: string,
saleTs: number, // 开抢时间 ms
remindBeforeMs = 15 * 60 * 1000 // 提前15分钟提醒
): Promise<'ok'|'denied'|'err'> {
try {
const mgr = calendar.getCalendarManager()
// 1)取默认日历(通常是用户的主日历账户)
const calendars = await mgr.getAllCalendars()
const target = calendars[0]
if (!target) return 'err'
// 2)构造事件
const ev = {
title: title || '抢票开抢提醒',
startTime: saleTs,
endTime: saleTs + 5 * 60 * 1000, // 给个5分钟窗口即可
reminderTime: remindBeforeMs, // 提前提醒
// description / location 可按需写
} as calendar.CalendarEvent
// 3)写入
const id = await target.addEvent(ev)
console.info('calendar event added:', id)
return 'ok'
} catch (e: any) {
if (e?.code === '201' /* 权限拒示例值,实际以API为准 */ ) return 'denied'
return 'err'
}
}
UI 侧的交互逻辑应该是:
// 按钮文字:未添加 → "添加抢票提醒";已添加 → "已添加 ✓(可取消)"
Button(this.hasReminder ? '已添加提醒 ✓' : '添加抢票提醒')
.onClick(async () => {
if (this.hasReminder) {
// 取消 = 从日历删掉(按你存的 eventId 删)
await removeSaleReminder(this.reminderEventId)
this.hasReminder = false
return
}
const r = await addSaleReminder('🎫 演唱会抢票:VIP区', this.saleTs)
if (r === 'denied') prompt.showToast({ message: '请在设置中允许日历权限' })
if (r === 'ok') { this.hasReminder = true }
})
五、把"修改开抢时间"做安全:别让时间随便漂
文档提到一个细节:计时器上方支持点击"开抢"修改抢票时间------这个功能必须加护栏,否则业务方会骂娘:
三个必须校验
function validateNewSaleTime(ts: number): string | null {
const now = Date.now()
if (ts <= now) return '开抢时间不能是过去'
if (ts - now < 2 * 60_000) return '开抢时间至少预留2分钟'
// 可选:上限拦
if (ts - now > 365 * 86400_000) return '时间太远不支持'
return null
}
改完后要做的连锁动作:
-
停旧计时器
-
更新
saleTs(服务器同步一份,本地用服务器为准) -
重算状态 & 重启倒计时
-
如果日历提醒已写入 → 删旧事件 + 重新写一个(时间变了,旧那条就过期了)
六、边界情况清单(你上线前最好逐条过一遍)
| 边界 | 你会观察到的症状 | 对策 |
|---|---|---|
| 用户关日历权限又进来 | 写事件静默失败 | 每次写前用 hasPermission或 catch 权限码,引导去设置 |
| 用户手动删了日历里的那条事件 | App 里还显示"已添加提醒" | 退出时存 eventId,下次进来用 getEvent(id)探活;探不到就回退 |
| 锁屏很久回来 | 计时器停了 | onPageShow → cd.resync() |
| 服务器时间跟本地差了几秒 | 显示跳一下 | 用服务端下发的 serverTimeDiff校正:effectiveNow = Date.now() + serverTimeDiff |
| 倒计时到 0 但后端库存接口还没开(延迟) | 用户狂点"立即抢票" | 前端状态只能进 OPEN;真正提交前还要调后端 getSaleStatus()做二次守门 |
七、总结
抢票倒计时和日历提醒在票务/购物比价类App里看起来是"一个计时器 + 一个按钮",但它真正考的是三件事:
-
时间要锚定服务器时间戳 ,本地
setInterval只能当重绘器,不能当真理源 -
倒计时是App的临时状态机,日历提醒是委托给系统的持久事件------两者职责分清楚,逻辑就不缠
-
用户随时会切走、锁屏、改权限、删事件 ,你的代码要能在
onPageShow / catch里自我修复,而不是假设"路径永远是 happy case"
把 TicketCountdown写成纯状态机、把日历操作封装成带权限守卫的服务函数,这个场景就不会再是"上线后天天救火"的模块,而是真正省心的体验加分项。