HarmonyOS ArkTS 倒计时器的时间计算与列表渲染
引子倒计时的核心是算时间差倒计时器看起来简单——显示一个倒数的数字。但做起来有三个问题要处理时间差的计算、进度环的百分比、列表的动态增删。今天就从代码出发拆解时间戳运算、剩余时间格式化、进度环计算、列表渲染四个核心知识点。完整效果数据模型两个 class 的设计classRemainInfo{d:number0;// 天h:number0;// 时m:number0;// 分s:number0;// 秒total:number0;// 总毫秒差正未到负已过elapsed:number0;// 进度百分比0-1}classCDEvent{name:string;// 事件名称target:number0;// 目标时间戳毫秒color:string;// 主题色}为什么分两个 classCDEvent是用户输入的数据名称、目标时间、颜色RemainInfo是计算后的结果剩余天/时/分/秒、进度。分开设计让数据和计算解耦——修改计算逻辑不影响输入数据。target 为什么用时间戳而不是 Date时间戳是数字毫秒可以直接做减法target - now。如果用Date对象需要先.getTime()转成时间戳代码更冗长。核心方法calcRemaincalcRemain(target:number):RemainInfo{consttotaltarget-this.now;constabsMsMath.max(0,total);constdMath.floor(absMs/86400000);consthMath.floor((absMs%86400000)/3600000);constmMath.floor((absMs%3600000)/60000);constsMath.floor((absMs%60000)/1000);constspanMath.min(30*86400000,Math.max(absMs,1));constelapsedMath.max(0,1-absMs/span);constrnewRemainInfo();r.dd;r.hh;r.mm;r.ss;r.totaltotal;r.elapsedelapsed;returnr;}时间差的计算consttotaltarget-this.now;target是目标时间戳this.now是当前时间戳。差值为正表示还没到为负表示已经过了。取绝对值的处理constabsMsMath.max(0,total);如果total是负数已经过了取0而不是负数。这样显示的剩余时间是 “0天0时0分0秒” 而不是负数。天/时/分/秒的拆分总毫秒数 ÷ 86400000 天数 剩余毫秒 ÷ 3600000 小时 剩余毫秒 ÷ 60000 分钟 剩余毫秒 ÷ 1000 秒用取余%获取每级的余数逐级拆分。比如 90000000 毫秒 1天1小时。进度环的计算constspanMath.min(30*86400000,Math.max(absMs,1));constelapsedMath.max(0,1-absMs/span);span假设事件在 30 天内最多显示 30 天的进度elapsed已过时间的百分比1 - absMs/span比如事件还有 15 天span30 天elapsed0.550%。进度环显示一半。为什么要 Math.max(absMs, 1)避免absMs0时除以 0 的错误。如果absMs0span1elapsed1100%进度环满。now 的更新机制Statenow:numberDate.now();now只在组件初始化时赋值一次。但倒计时需要每秒更新。这个实现没有用定时器刷新now所以倒计时不会实时变化——需要用户手动刷新或重新进入页面。为什么不用 setIntervalsetInterval每秒更新now会让所有倒计时卡片每秒重绘性能开销大。对于天级的倒计时秒级更新意义不大。如果需要实时更新可以用setIntervalprivatetimer:number0;aboutToAppear():void{this.timersetInterval((){this.nowDate.now();},1000);}aboutToDisappear():void{clearInterval(this.timer);}但当前实现选择了性能优先。新增事件输入面板TextInputTextInput({placeholder:事件名称如生日、期末考试…,text:this.newName}).onChange((v:string){this.newNamev;})接收事件名称onChange实时更新newName。DatePickerDatePicker({start:newDate(2025-01-01),end:newDate(2099-12-31),selected:this.newDate?newDate(this.newDate):newDate()}).onChange((v:DatePickerResult){this.newDate${v.year}-${(v.month!1).toString().padStart(2,0)}-${v.day!.toString().padStart(2,0)};})DatePicker的onChange回调返回DatePickerResult包含year、month0-11、day。需要把 month1 转成 1-12。颜色选择ForEach(CD_COLORS,(c:string){Column().width(30).height(30).borderRadius(15).backgroundColor(c).border({width:this.selectedColorc?2:0,color:#FFFFFF}).onClick(()this.selectedColorc)})6 种预设颜色点击选中。选中的用白色边框2px未选中的无边框。addEvent 方法addEvent():void{if(this.newName.trim())return;consttsthis.newDate?newDate(this.newDate).getTime():(Date.now()86400000);this.events[...this.events,{name:this.newName.trim(),target:ts,color:this.selectedColor}];this.newName;this.newDate;this.showAddfalse;}名称为空时直接返回没选日期时默认明天Date.now() 86400000用展开运算符[...this.events, newEvent]创建新数组不可变性清空输入关闭面板列表渲染ForEach 条件渲染ForEach(this.events,(evt:CDEvent,idx:number){Column(){// 标题行Row(){Text(evt.name).fontSize(16).fontWeight(FontWeight.Bold).fontColor(#FFFFFF).maxLines(1).textOverflow({overflow:TextOverflow.Ellipsis}).layoutWeight(1)Text(✕).fontSize(14).fontColor(#666).onClick(()this.removeEvent(idx))}// 内容区if(this.calcRemain(evt.target).total0){Text( 已到达!).fontSize(22).fontColor(evt.color)}else{Row(){// 天:时:分:秒Column(){Text(d.toString()).fontSize(30).fontColor(evt.color);Text(天).fontSize(10)}Text(:).fontSize(24).fontColor(#444)Column(){Text(h.padStart(2,0)).fontSize(30).fontColor(evt.color);Text(时).fontSize(10)}// ... 分、秒类似}Progress({value:elapsed*100,total:100,type:ProgressType.Ring}).width(60).height(60).color(evt.color).style({strokeWidth:6})}}.width(100%).padding(18).borderRadius(16).backgroundColor(#1E1E32)})条件渲染的两种状态已到达total 0显示 已到达!未到达total 0显示剩余时间和进度环padStart(2, ‘0’) 的作用小时、分钟、秒用padStart(2, 0)补零。比如5显示为05保持两位数对齐。Progress 组件Progress({value:Math.round(elapsed*100),total:100,type:ProgressType.Ring}).width(60).height(60).color(evt.color).style({strokeWidth:6})ProgressType.Ring显示环形进度条。value是当前值0-100total是最大值。strokeWidth:6控制环的粗细。删除事件removeEvent(idx:number):void{this.eventsthis.events.filter((_,i)i!idx);}用filter过滤掉指定索引的元素创建新数组。不直接修改原数组不可变性。为什么用展开运算符而不是 push// 正确创建新数组this.events[...this.events,newEvent];// 错误直接 pushthis.events.push(newEvent);// ArkTS 可能不会触发 UI 更新ArkUI 的State变化检测基于引用比较。push修改原数组引用不变UI 不更新。[...events, newEvent]创建新数组引用变了UI 更新。踩坑记录坑 1DatePicker 的 month 是 0-11DatePickerResult.month从 0 开始0一月11十二月。显示时要1否则月份会少一个月。坑 2时间戳的精度JavaScript 的Date.now()精度是毫秒。如果用秒级时间戳需要× 1000。代码中统一用毫秒避免混淆。坑 3进度环的范围进度环的value必须在 0-100 之间。elapsed是 0-1 的小数需要× 100再传入。坑 4removeEvent 的 filterfilter会遍历整个数组。如果事件很多几百个性能会有影响。但对于倒计时应用事件数量通常很少不是问题。坑 5calcRemain 被多次调用每个事件在渲染时都会调用calcRemain计算剩余时间。如果事件很多会有性能开销。可以考虑缓存计算结果。代码改进建议1. 实时更新用setInterval每秒更新now让倒计时实时变化。2. 本地存储用 Preferences API 保存事件列表重启应用后数据不丢失。3. 提醒功能在事件到达前比如提前 1 天发送通知提醒。4. 编辑事件当前只能新增和删除不能编辑。可以加编辑功能。5. 分类标签给事件加标签比如工作、“生活”、“纪念日”方便筛选。总结倒计时器的核心是时间戳运算——用target - now得到剩余毫秒数再拆分成天/时/分/秒。进度环基于剩余时间占总时间的比例计算。列表用ForEach渲染支持动态增删。适用边界这个部分适合用作 ArkUI 时间计算和列表渲染的学习案例涵盖了时间戳运算、DatePicker 组件、Progress 组件、ForEach 渲染、不可变性更新等核心知识点。但如果要上架应用商店还需要补充实时更新、本地存储、提醒功能、编辑事件、分类标签等内容。