HarmonyOS 6商城开发学习:抢票倒计时与系统日历提醒——票务类场景的完整落地思路

在购物比价/票务类应用里,抢票 是最能暴露"前端时间管理基本功"的场景------不是因为你算不准 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
}

改完后要做的连锁动作:

  1. 停旧计时器

  2. 更新 saleTs(服务器同步一份,本地用服务器为准)

  3. 重算状态 & 重启倒计时

  4. 如果日历提醒已写入 → 删旧事件 + 重新写一个(时间变了,旧那条就过期了)


六、边界情况清单(你上线前最好逐条过一遍)

边界 你会观察到的症状 对策
用户关日历权限又进来 写事件静默失败 每次写前用 hasPermission或 catch 权限码,引导去设置
用户手动删了日历里的那条事件 App 里还显示"已添加提醒" 退出时存 eventId,下次进来用 getEvent(id)探活;探不到就回退
锁屏很久回来 计时器停了 onPageShow → cd.resync()
服务器时间跟本地差了几秒 显示跳一下 用服务端下发的 serverTimeDiff校正:effectiveNow = Date.now() + serverTimeDiff
倒计时到 0 但后端库存接口还没开(延迟) 用户狂点"立即抢票" 前端状态只能进 OPEN;真正提交前还要调后端 getSaleStatus()做二次守门

七、总结

抢票倒计时和日历提醒在票务/购物比价类App里看起来是"一个计时器 + 一个按钮",但它真正考的是三件事:

  1. 时间要锚定服务器时间戳 ,本地 setInterval只能当重绘器,不能当真理源

  2. 倒计时是App的临时状态机,日历提醒是委托给系统的持久事件------两者职责分清楚,逻辑就不缠

  3. 用户随时会切走、锁屏、改权限、删事件 ,你的代码要能在 onPageShow / catch里自我修复,而不是假设"路径永远是 happy case"

TicketCountdown写成纯状态机、把日历操作封装成带权限守卫的服务函数,这个场景就不会再是"上线后天天救火"的模块,而是真正省心的体验加分项。

相关推荐
05大叔1 小时前
对话系统学习,问答型数据库,闲聊型对话数据库
学习
●VON2 小时前
AtomGit Flutter鸿蒙客户端:主题系统
javascript·flutter·华为·跨平台·harmonyos·鸿蒙
yuegu7772 小时前
HarmonyOS应用<节气通>开发第17篇:意见反馈页面
华为·harmonyos
yuegu7772 小时前
HarmonyOS应用<节气通>开发第19篇:空态页面设计
harmonyos
伶俜662 小时前
零基础学 ArkUI 传感器(专题二):从加速度计到指南针,玩转硬件能力
学习·华为·harmonyos
G_dou_2 小时前
Flutter三方库适配OpenHarmony【expense_tracker】消费记录器项目完整实战
flutter·harmonyos
进击的小头3 小时前
第8篇:IGBT 从零到精通:核心原理、关键参数、选型指南与工业级应用要点
经验分享·嵌入式硬件·学习
小陈phd3 小时前
Text2SQL智能体学习笔记(一)——NL2SQL及执行流程介绍
笔记·学习