资讯动态

日程拖到明天以后,腕上轨道怎样保留原来的提醒

发布时间:2026/10/21 2:37:33 来源:尧图企业网站定制
上周把一个下午的评审拖到明天同事回到表盘上看了一眼提醒没了。他不是没操作成功。日程卡片确实挪到了明天那一格时间也对。但原来挂在 14:20 的那条提醒不见了第二天到点也没有任何动静。在他看来这就是日程改期把提醒弄丢了。排查下来原因很朴素日程和提醒在代码里被当成同一个对象改期时整体替换提醒的触发状态跟着一起被清掉了。改动本身没错错在它们本来就不该是同一条记录。这篇记录一下腕上日程轨道改期时提醒是怎么被独立出来、又怎么跟着日程一起搬走的。先把提醒从日程里拆出来日程描述这件事什么时候开始提醒描述系统什么时候该震动一下。前者是用户编辑的对象后者是调度器要执行的任务。两者的生命周期完全不同日程可以反复改期提醒一旦触发就该进历史。importpreferencesfromohos.data.preferencesexporttypeAgendaKindreview|focus|workout|personal// 提醒自身的状态跟日程的编辑状态没有关系exporttypeTicketStatepending|fired|moved|cancelledexportinterfaceAgendaItem{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:number24*60*60*1000exportfunctionshiftByOneDay(item:AgendaItem):AgendaItem{return{...item,startAt:item.startAtDAY_MS}}平时看不出问题到了跨月、跨年边界才会露馅。8 月 31 日 14:30 加一天得到的是 9 月 1 日 14:30没问题但如果日程本来定在 23:40加满 24 小时后在夏令时切换的那一天会偏整一小时第二天变成 22:40 或者 00:40。国内不用夏令时这个偏差平时碰不到但闰秒和时区数据库更新是真实存在的。稳妥的写法是按日历字段走exportfunctionshiftDateKeepingClock(item:AgendaItem,plusDays:number):AgendaItem{constoriginnewDate(item.startAt)consttargetnewDate(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{returnvalue10?0${value}:${value}}functionymdOf(ts:number):string{constd:DatenewDate(ts)return${d.getFullYear()}-${pad2(d.getMonth()1)}-${pad2(d.getDate())}}functionhmOf(ts:number):string{constd:DatenewDate(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:AgendaItemshiftDateKeepingClock(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.durationMin0||item.remindBeforeMin0){returnnull}constfireAt:numberitem.startAt-item.remindBeforeMin*60*1000constrevision:numberpreviousnull?1:previous.revision1return{itemId:item.id,fireAt,// 已经响过的提醒改期后要重新激活所以这里统一回到 pendingstate:pending,movedFrom:previousnull?null:previous.fireAt,revision}}exportfunctionshouldFire(ticket:ReminderTicket,incomingRevision:number,now:number):boolean{if(ticket.revision!incomingRevision){returnfalse}if(ticket.state!pending){returnfalse}returnnowticket.fireAt}movedFrom这个字段是给用户看的。表盘上长按提醒能看到“由 14:20 移至次日 14:20”改期记录有了着落用户才不会觉得提醒是自己消失的。规则写完了用几个边界用例把它们钉住。断言函数就十几行不引测试框架挂在 EntryAbility 的 onCreate 里跑一次看日志输出就行。letpassed:number0letfailed:number0functionexpectEqual(actual:unknown,expected:unknown,caseName:string):void{if(actualexpected){passed1return}failed1console.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:ReminderTicketrebuildTicket(baseItem,null)asReminderTicketexpectEqual(first.fireAt,baseItem.startAt-15*60*1000,fireAt offset)// 边界 2revision 从 1 开始每次重排自增expectEqual(first.revision,1,first revision)constsecond:ReminderTicketrebuildTicket(baseItem,first)asReminderTicketexpectEqual(second.revision,2,second revision)// 边界 3旧 revision 的回调不触发哪怕时间早就过了expectEqual(shouldFire(first,second.revision,first.fireAt60_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}fromkit.AbilityKitconstSTORE_NAME:stringagenda_storeconstTICKET_KEY:stringticketsexportasyncfunctionpersistTickets(context:common.UIAbilityContext,tickets:ReminderTicket[]):Promisevoid{conststoreawaitpreferences.getPreferences(context,STORE_NAME)awaitstore.put(TICKET_KEY,JSON.stringify(tickets))awaitstore.flush()}exportfunctionparseTickets(raw:string):ReminderTicket[]{if(raw.length0){return[]}try{constlistJSON.parse(raw)asReminderTicket[]if(!Array.isArray(list)){return[]}// 触发时刻非法或者已经过期的票直接丢弃避免带着脏数据进调度器returnlist.filter((item:ReminderTicket){returntypeofitem.fireAtnumberitem.fireAt0item.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.startAtitem.durationMin*60*1000now).sort((a:AgendaItem,b:AgendaItem)a.startAt-b.startAt)returnsorted.map((item:AgendaItem,index:number){constclocknewDate(item.startAt)consthh:stringclock.getHours()10?0${clock.getHours()}:${clock.getHours()}constmm:stringclock.getMinutes()10?0${clock.getMinutes()}:${clock.getMinutes()}return{id:item.id,label:item.title,clock:${hh}:${mm},meta:item.durationMin0?${item.durationMin}分钟:无固定时长,isNext:index0}})}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.json5ReminderScheduler只管提醒的注册和版本比对不碰任何界面状态AgendaStore只管持久化解析失败也返回空数组而不上抛。视图层拿到的永远是一份已经排好序、过滤过的日程不需要自己判断哪条该显示。如果后面要接真实的日程账号同步加一个 adapter 把远端数据转成AgendaItem就行调度逻辑和界面都不用动。本文的实现思路只在本地完成提醒的准确送达仍受系统调度策略影响。涉及闹钟、用药等强时间要求的场景还需要按平台的精确闹钟规范单独适配不能依赖应用层的普通定时。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑