☰
日程拖到明天以后,腕上轨道怎样保留原来的提醒
2026/10/21 2:37:31 网站建设 项目流程

上周把一个下午的评审拖到明天,同事回到表盘上看了一眼,提醒没了。

他不是没操作成功。日程卡片确实挪到了明天那一格,时间也对。但原来挂在 14:20 的那条提醒不见了,第二天到点也没有任何动静。在他看来这就是"日程改期把提醒弄丢了"。

排查下来原因很朴素:日程和提醒在代码里被当成同一个对象,改期时整体替换,提醒的触发状态跟着一起被清掉了。改动本身没错,错在它们本来就不该是同一条记录。

这篇记录一下腕上日程轨道改期时,提醒是怎么被独立出来、又怎么跟着日程一起搬走的。

先把提醒从日程里拆出来

日程描述"这件事什么时候开始",提醒描述"系统什么时候该震动一下"。前者是用户编辑的对象,后者是调度器要执行的任务。两者的生命周期完全不同:日程可以反复改期,提醒一旦触发就该进历史。

importpreferencesfrom'@ohos.data.preferences'exporttypeAgendaKind='review'|'focus'|'workout'|'personal'// 提醒自身的状态,跟日程的编辑状态没有关系exporttypeTicketState='pending'|'fired'|'moved'|'cancelled'exportinterfaceAgendaItem{id:stringtitle:stringkind:AgendaKind startAt:number// 本地时区的毫秒时间戳durationMin:numberremindBeforeMin:number}exportinterfaceReminderTicket{itemId:stringfireAt:number// 真正该震动的时刻state:TicketState movedFrom:number|null// 上一次的触发时刻,用于回溯revision:number// 每次重排自增,用来丢过期回调}

拆开之后有个额外好处:一条日程可以挂多个提醒(提前 15 分钟、提前 5 分钟),也能把某个提醒单独关掉而保留日程。

多个提醒在腕上比手机实用。手机可以解锁看详细通知,腕上就一两秒的抬腕时间,提前 15 分钟那张票震动时用户大概还在走路,提前 5 分钟那张才是“该起身了”。两张票各自带 revision,关掉一张只是把它的 state 置成 cancelled,另一张和日程都不受影响。rebuildTicket里对durationMin <= 0的判断是给拖动过程的中间态兜底的——用户拖日程时时长可能被压成 0,这种占位日程不该产生震动,不然会冒出一次没有着落的震动,用户抬手却发现什么都没有。

改期时只挪日期,不动墙上时间

第一版的实现是给时间戳直接加 24 小时:

constDAY_MS:number=24*60*60*1000exportfunctionshiftByOneDay(item:AgendaItem):AgendaItem{return{...item,startAt:item.startAt+DAY_MS}}

平时看不出问题,到了跨月、跨年边界才会露馅。8 月 31 日 14:30 加一天,得到的是 9 月 1 日 14:30,没问题;但如果日程本来定在 23:40,加满 24 小时后在夏令时切换的那一天会偏整一小时,第二天变成 22:40 或者 00:40。

国内不用夏令时,这个偏差平时碰不到,但闰秒和时区数据库更新是真实存在的。稳妥的写法是按日历字段走:

exportfunctionshiftDateKeepingClock(item:AgendaItem,plusDays:number):AgendaItem{constorigin=newDate(item.startAt)consttarget=newDate(item.startAt)target.setDate(origin.getDate()+plusDays)// 保留原来的时分秒,只换日期target.setHours(origin.getHours(),origin.getMinutes(),origin.getSeconds(),0)return{...item,startAt:target.getTime()}}

这里setDate会自动处理月份溢出。31 号往后挪一天,落进只有 30 天的月份时,JavaScript 的日期对象会顺延到下个月 1 号,正好符合"拖到明天"的直觉。

有个边界要挡住:如果原定时间是 2 月 29 日,往后挪一年会落到 3 月 1 日。产品上不太能接受,所以轨道视图只允许拖动,不允许直接改年份,把这条路径绕开了。

改期函数的边界拿具体日期跑一遍更放心。8 月 31 日拖一天进 9 月 1 日、23:40 的日程跨过去还是 23:40——这两种各验证一次,时分秒没错位,就说明setHours的调用顺序摆对了。

functionpad2(value:number):string{returnvalue<10?`0${value}`:`${value}`}functionymdOf(ts:number):string{constd:Date=newDate(ts)return`${d.getFullYear()}-${pad2(d.getMonth()+1)}-${pad2(d.getDate())}`}functionhmOf(ts:number):string{constd:Date=newDate(ts)return`${pad2(d.getHours())}:${pad2(d.getMinutes())}`}constendOfAug:AgendaItem={id:'evt-2',title:'版本封测',kind:'workout',startAt:newDate(2026,7,31,23,40).getTime(),durationMin:30,remindBeforeMin:10}constshifted:AgendaItem=shiftDateKeepingClock(endOfAug,1)console.info(`[agenda-test] aug31 ->${ymdOf(shifted.startAt)}${hmOf(shifted.startAt)}`)// 期望输出:2026-09-01 23:40

重排提醒要带上版本号

改期之后,旧提醒必须作废,新的提醒带着新的触发时刻注册进去。麻烦的地方在于设备上的震动回调是异步的,改期和回调可能同时发生。

试过用"先取消再注册"的两步操作,结果偶发出现旧回调在新提醒注册之后才回来,把刚建好的提醒又置成了已触发。后来给每个提醒带上自增的revision,回调里比对版本,对不上就直接丢掉:

exportfunctionrebuildTicket(item:AgendaItem,previous:ReminderTicket|null):ReminderTicket|null{if(item.durationMin<=0||item.remindBeforeMin<0){returnnull}constfireAt:number=item.startAt-item.remindBeforeMin*60*1000constrevision:number=previous===null?1:previous.revision+1return{itemId:item.id,fireAt,// 已经响过的提醒改期后要重新激活,所以这里统一回到 pendingstate:'pending',movedFrom:previous===null?null:previous.fireAt,revision}}exportfunctionshouldFire(ticket:ReminderTicket,incomingRevision:number,now:number):boolean{if(ticket.revision!==incomingRevision){returnfalse}if(ticket.state!=='pending'){returnfalse}returnnow>=ticket.fireAt}

movedFrom这个字段是给用户看的。表盘上长按提醒能看到“由 14:20 移至次日 14:20”,改期记录有了着落,用户才不会觉得提醒是自己消失的。

规则写完了,用几个边界用例把它们钉住。断言函数就十几行,不引测试框架,挂在 EntryAbility 的 onCreate 里跑一次,看日志输出就行。

letpassed:number=0letfailed:number=0functionexpectEqual(actual:unknown,expected:unknown,caseName:string):void{if(actual===expected){passed+=1return}failed+=1console.error(`[agenda-test]${caseName}: expect${expected}, got${actual}`)}constbaseItem:AgendaItem={id:'evt-1',title:'交互评审',kind:'review',startAt:newDate(2026,9,16,14,20).getTime(),durationMin:60,remindBeforeMin:15}// 边界 1:提醒时刻 = 开始时刻减提前量,跨分钟要精确constfirst:ReminderTicket=rebuildTicket(baseItem,null)asReminderTicketexpectEqual(first.fireAt,baseItem.startAt-15*60*1000,'fireAt offset')// 边界 2:revision 从 1 开始,每次重排自增expectEqual(first.revision,1,'first revision')constsecond:ReminderTicket=rebuildTicket(baseItem,first)asReminderTicketexpectEqual(second.revision,2,'second revision')// 边界 3:旧 revision 的回调不触发,哪怕时间早就过了expectEqual(shouldFire(first,second.revision,first.fireAt+60_000),false,'stale revision dropped')// 边界 4:时长为 0 的日程不建提醒expectEqual(rebuildTicket({...baseItem,durationMin:0},null),null,'zero duration')console.info(`[agenda-test] passed=${passed}failed=${failed}`)

四条用例对应四次返工:提醒时刻的分钟级偏移、revision 自增、旧版本回调丢弃、零时长日程不建提醒。真机上验证过——把shouldFire里的 revision 比对注释掉,第三个用例立刻报错,failed变成 1。这种手写断言的好处是改期逻辑每次动完,按一次电源键就能全量回归,不用连电脑。

回归节奏上,这几个用例每次碰调度逻辑都跑一遍。真正麻烦的是震动回调本身,它跟系统调度绑在一起,应用层只能等到点看现象;能固化的只有判定函数,所以边界全部围着rebuildTicket和shouldFire写。哪天台面上再加"提前 5 分钟"的第二张票,用例里补一条双票互不影响就行。

落盘要能扛住解析失败

提醒列表要跨进程存活,交给preferences存成 JSON 字符串。写进去简单,读回来才是容易出事的地方——升级版本、手动改过数据、磁盘写了一半,都可能让 JSON 解析失败。

import{common}from'@kit.AbilityKit'constSTORE_NAME:string='agenda_store'constTICKET_KEY:string='tickets'exportasyncfunctionpersistTickets(context:common.UIAbilityContext,tickets:ReminderTicket[]):Promise<void>{conststore=awaitpreferences.getPreferences(context,STORE_NAME)awaitstore.put(TICKET_KEY,JSON.stringify(tickets))awaitstore.flush()}exportfunctionparseTickets(raw:string):ReminderTicket[]{if(raw.length===0){return[]}try{constlist=JSON.parse(raw)asReminderTicket[]if(!Array.isArray(list)){return[]}// 触发时刻非法或者已经过期的票直接丢弃,避免带着脏数据进调度器returnlist.filter((item:ReminderTicket)=>{returntypeofitem.fireAt==='number'&&item.fireAt>0&&item.state!=='cancelled'})}catch(error){console.error(`[agenda] tickets parse failed:${(errorasError).message}`)return[]}}

解析失败时返回空数组而不是抛出异常,这是个有意为之的选择。提醒丢失比应用起不来要轻,日程本体还在,用户可以重新设提醒;反过来如果启动阶段被一个异常打断,整个轨道视图都打不开了。

容错能力的测法是手工往 store 里塞脏数据。把tickets的值改成三种样子:字段类型是字符串的合法 JSON、写到一半的残串、不是数组的对象。第一种被 filter 挡掉,第二种进 catch 返回空数组,第三种被Array.isArray拦下。三种都跑通之后,调用方才敢直接拿parseTickets的返回值,不用再包一层 try-catch。

flush()也值得说一下。只调put不调flush,数据是先落内存缓存的,进程被杀就丢。腕上设备被系统回收的频率比手机高得多,这一步不能省。

轨道视图只负责呈现

界面这一侧的工作比想象中少。既然提醒已经独立调度,视图只需要把日程按时间排序后摊开,再标出哪个是"下一项"。

interfaceOrbitSlot{id:stringlabel:stringclock:stringmeta:stringisNext:boolean}exportfunctionbuildSlots(items:AgendaItem[],now:number):OrbitSlot[]{constsorted:AgendaItem[]=items.filter((item:AgendaItem)=>item.startAt+item.durationMin*60*1000>now).sort((a:AgendaItem,b:AgendaItem)=>a.startAt-b.startAt)returnsorted.map((item:AgendaItem,index:number)=>{constclock=newDate(item.startAt)consthh:string=clock.getHours()<10?`0${clock.getHours()}`:`${clock.getHours()}`constmm:string=clock.getMinutes()<10?`0${clock.getMinutes()}`:`${clock.getMinutes()}`return{id:item.id,label:item.title,clock:`${hh}:${mm}`,meta:item.durationMin>0?`${item.durationMin}分钟`:'无固定时长',isNext:index===0}})}

filter那一步过滤掉的是"已经开始且持续时间已过"的项。当天 7 条日程里只要有一条超时未完成,不移除的话它会一直占着"下一项"的位置,后面的安排全被挤下去。

排序用startAt而不是插入顺序。用户拖动之后数组里还是原来的次序,不重排的话顶部显示的还是旧的那一条。

视图层的验证用构造数据比点界面快。拿 7 条日程,故意插一条“已经开始 40 分钟、时长 30 分钟”的过期项,再把数组顺序打乱,跑buildSlots看输出:过期项不该出现,顶部isNext必须是开始时刻最早的那条。这两条各出过一次事故——第一次是超时项一直顶着“下一项”不放,后面全被挤下去;第二次是拖动后忘了重排,用户看到顶部还是旧安排,以为拖动失败了。isNext取的是排序后的下标,不是数据里预存的标记,预存标记在拖动之后就会说谎。

目录结构

OrbitAgenda/ ├─ entry/src/main/ets/ │ ├─ pages/Index.ets # 轨道视图与今日安排 │ ├─ features/ │ │ ├─ AgendaModel.ets # 日程与提醒的数据结构 │ │ ├─ ReminderScheduler.ets # 注册、重排与版本校验 │ │ └─ AgendaStore.ets # preferences 读写与容错 │ ├─ entryability/EntryAbility.ets # 生命周期入口 │ └─ adapters/ │ └─ WearVibratorAdapter.ets # 震动能力封装 ├─ entry/src/main/resources/ # 颜色、文案与图标 └─ build-profile.json5

ReminderScheduler只管提醒的注册和版本比对,不碰任何界面状态;AgendaStore只管持久化,解析失败也返回空数组而不上抛。视图层拿到的永远是一份已经排好序、过滤过的日程,不需要自己判断哪条该显示。

如果后面要接真实的日程账号同步,加一个 adapter 把远端数据转成AgendaItem就行,调度逻辑和界面都不用动。

本文的实现思路只在本地完成,提醒的准确送达仍受系统调度策略影响。涉及闹钟、用药等强时间要求的场景,还需要按平台的精确闹钟规范单独适配,不能依赖应用层的普通定时。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询