HarmonyOS7秒表计时面板实战:计时器控制与分段记录
文章目录前言秒表状态分成当前时间和计次基准startStop 同时负责启动和暂停reset 要把时间、状态和记录一起清掉lap 记录的是当前分段不是总时间时间格式化要分清总时间和分段时间离开页面时一定要清理定时器完整代码最后总结前言秒表页面看起来很小但它比普通列表页更容易踩坑。因为它有持续运行的副作用setInterval会一直改时间如果停止、重置、离开页面时没有清理干净就可能出现重复计时、后台还在跑、返回页面时间乱跳这些问题。这个示例实现的是一个基础秒表开始、停止、重置、计次、分段记录和时间格式化都在同一个组件里完成。它没有做后台计时补偿也没有处理系统休眠后的时间校准但用来理解 ArkUI 页面里的计时器状态已经够用了。秒表状态分成当前时间和计次基准这页的核心状态只有四个。isRunning表示秒表是否正在跑elapsed是总耗时lapRecords保存计次记录lapStart记录当前这一段从哪个总时间点开始。StateisRunning:booleanfalseStateelapsed:number0StatelapRecords:TimerRecord[][]StatelapStart:number0privateintervalId:number-1intervalId没有用State因为它不参与 UI 渲染只是用来保存定时器句柄方便后面clearInterval()。这是一个挺重要的边界会影响界面的数据放状态里只服务于副作用清理的变量不一定需要做成状态。TimerRecord保存每一次计次的标签、这一段耗时和当时的总时间。interfaceTimerRecord{label:stringduration:numbertimestamp:string}这里把timestamp存成格式化后的字符串而不是毫秒数。示例里这样简单如果以后要排序、导出、重新格式化语言环境我会更倾向于存原始毫秒值。startStop 同时负责启动和暂停开始按钮和停止按钮共用startStop()。如果正在运行就清掉定时器并把isRunning改成false如果没运行就从当前elapsed继续启动。startStop(){if(this.isRunning){clearInterval(this.intervalId)this.isRunningfalse}else{this.lapStartthis.elapsedthis.intervalIdsetInterval((){this.elapsed100},100)this.isRunningtrue}}这里每 100ms 给elapsed加 100UI 上再通过formatTime(this.elapsed)展示。这个写法简单但它不是高精度计时。setInterval本身会有漂移页面卡顿时也可能延迟。正式秒表通常会记录开始时间戳用当前时间减开始时间来算耗时而不是只靠每次累加 100。lapStart this.elapsed放在启动分支里意味着每次从暂停恢复时当前计次段会从恢复时刻重新计算。这个行为要看产品需求有些秒表希望暂停不打断当前计次段有些则希望恢复后算新段。当前源码选择的是后者。reset 要把时间、状态和记录一起清掉重置不是只把elapsed归零。源码里同时停止定时器、关闭运行状态、清掉总时间、计次基准和计次记录。reset(){clearInterval(this.intervalId)this.isRunningfalsethis.elapsed0this.lapStart0this.lapRecords[]}这个顺序比较稳先停副作用再改 UI 状态。否则定时器还在跑刚归零的elapsed下一次 tick 又会被加回去。左侧按钮的行为也不只是“重置”。它根据运行状态和记录数量显示“重置”或“计次”点击时再决定调用lap()还是reset()。if(this.lapRecords.length0!this.isRunning)this.reset()elseif(this.isRunning)this.lap()elsethis.reset()也就是说只有秒表正在运行时左侧按钮才真正记录计次停止后再点它会重置。lap 记录的是当前分段不是总时间计次逻辑里最关键的是elapsed - lapStart。elapsed是总时间lapStart是上一段开始时的总时间两者相减才是这一段的耗时。lap(){constlapTime:numberthis.elapsed-this.lapStartconstlabel:string计次${this.lapRecords.length1}consttimestamp:stringthis.formatTime(this.elapsed)constrecord:TimerRecord{label:label,duration:lapTime,timestamp:timestamp}this.lapRecords.unshift(record)this.lapStartthis.elapsed}unshift()会把最新记录放在列表最上面所以 UI 里第一条永远是最近一次计次。记录完成后lapStart更新成当前总时间为下一段计时做准备。这个设计适合运动分圈、训练分段这类场景。用户看到的不只是总耗时还能知道刚刚这一段用了多久。时间格式化要分清总时间和分段时间formatTime()用在主计时和记录里的总时间格式是mm:ss.cc。formatTime(ms:number):string{constminMath.floor(ms/60000)constsecMath.floor((ms%60000)/1000)constcentisecMath.floor((ms%1000)/10)return${min.toString().padStart(2,0)}:${sec.toString().padStart(2,0)}.${centisec.toString().padStart(2,0)}}formatDuration()用在单段耗时格式是s.ccs。formatDuration(ms:number):string{constsecMath.floor(ms/1000)constcentisecMath.floor((ms%1000)/10)return${sec}.${centisec.toString().padStart(2,0)}s}两个函数分开写让 UI 的语义更明确主表盘看总时间计次列表中间列看分段耗时右侧看计次发生时的总时间。离开页面时一定要清理定时器aboutToDisappear()里调用了clearInterval(this.intervalId)。aboutToDisappear(){clearInterval(this.intervalId)}这句很短但它是这类页面最不能漏的地方。用户返回上一页后如果定时器还在跑组件状态可能继续被修改轻则浪费资源重则引发难排查的页面异常。如果继续扩展我会先把计时方式改成基于时间戳减少setInterval漂移再把计次记录存原始毫秒值显示时再格式化。这样秒表从教学 demo 变成真实工具时数据会更可靠。完整代码下面保留案例完整代码方便你直接对照学习、复制和重构。import{router}fromkit.ArkUIinterfaceTimerRecord{label:stringduration:numbertimestamp:string}EntryComponentstruct StopwatchTimerBoard{StateisRunning:booleanfalseStateelapsed:number0StatelapRecords:TimerRecord[][]StatelapStart:number0privateintervalId:number-1startStop(){if(this.isRunning){clearInterval(this.intervalId)this.isRunningfalse}else{this.lapStartthis.elapsedthis.intervalIdsetInterval((){this.elapsed100},100)this.isRunningtrue}}reset(){clearInterval(this.intervalId)this.isRunningfalsethis.elapsed0this.lapStart0this.lapRecords[]}lap(){constlapTime:numberthis.elapsed-this.lapStartconstlabel:string\计次 \${this.lapRecords.length1}\consttimestamp:stringthis.formatTime(this.elapsed)constrecord:TimerRecord{label:label,duration:lapTime,timestamp:timestamp}this.lapRecords.unshift(record)this.lapStartthis.elapsed}formatTime(ms:number):string{constminMath.floor(ms/60000)constsecMath.floor((ms%60000)/1000)constcentisecMath.floor((ms%1000)/10)return\\${min.toString().padStart(2,0)}:\${sec.toString().padStart(2,0)}.\${centisec.toString().padStart(2,0)}\}formatDuration(ms:number):string{constsecMath.floor(ms/1000)constcentisecMath.floor((ms%1000)/10)return\\${sec}.\${centisec.toString().padStart(2,0)}s\}aboutToDisappear(){clearInterval(this.intervalId)}build(){Column(){Row(){Image($r(app.media.ic_back)).width(24).height(24).onClick(()router.back())Text(秒表计时).fontSize(18).fontWeight(FontWeight.Bold).layoutWeight(1).textAlign(TextAlign.Center)Text( ).width(24)}.width(100%).padding({left:16,right:16,top:12,bottom:16})// Timer displayColumn(){// Circular progress backgroundStack(){Circle().width(240).height(240).fill(none).stroke(#F0F0F0).strokeWidth(12)Column(){Text(this.formatTime(this.elapsed)).fontSize(40).fontWeight(FontWeight.Lighter).fontColor(#333).fontFamily(monospace)Text(当前计次).fontSize(13).fontColor(#888).margin({top:4})Text(\\${this.formatDuration(this.elapsed-this.lapStart)}\).fontSize(16).fontColor(#007AFF).margin({top:2})}.alignItems(HorizontalAlign.Center)}.margin({top:20,bottom:32})}.alignItems(HorizontalAlign.Center)// ControlsRow(){Column(){Button(this.lapRecords.length0!this.isRunning?重置:计次).width(72).height(72).borderRadius(36).backgroundColor(#F0F0F0).fontColor(#333).fontSize(16).onClick((){if(this.lapRecords.length0!this.isRunning)this.reset()elseif(this.isRunning)this.lap()elsethis.reset()})}Blank()Column(){Button(this.isRunning?停止:开始).width(80).height(80).borderRadius(40).backgroundColor(this.isRunning?#FF4757:#2ED573).fontColor(#FFF).fontSize(18).fontWeight(FontWeight.Bold).onClick(()this.startStop())}}.width(60%).alignItems(VerticalAlign.Center).margin({bottom:32})// Lap recordsif(this.lapRecords.length0){Row(){Text(计次).fontSize(13).fontColor(#888).layoutWeight(1)Text(计次用时).fontSize(13).fontColor(#888).width(100).textAlign(TextAlign.Center)Text(总时间).fontSize(13).fontColor(#888).width(90).textAlign(TextAlign.End)}.width(100%).padding({left:16,right:16,bottom:6})Divider().padding({left:16,right:16})List(){ForEach(this.lapRecords,(r:TimerRecord,i:number){ListItem(){Row(){Text(r.label).fontSize(14).fontColor(i0?#007AFF:#333).layoutWeight(1)Text(this.formatDuration(r.duration)).fontSize(14).fontFamily(monospace).fontColor(i0?#007AFF:#333).width(100).textAlign(TextAlign.Center)Text(r.timestamp).fontSize(13).fontFamily(monospace).fontColor(#888).width(90).textAlign(TextAlign.End)}.width(100%).padding({left:16,right:16,top:10,bottom:10}).border({width:{bottom:1},color:#F0F0F0})}})}.layoutWeight(1)}else{Column(){Text(点击「开始」开始计时).fontSize(14).fontColor(#BBBBBB)}.layoutWeight(1).justifyContent(FlexAlign.Center)}}.width(100%).height(100%).backgroundColor(#FAFAFA)}}最后总结秒表计时面板 这个案例并不靠复杂技巧取胜它更像一个结构扎实、逻辑完整的业务页面样板。把这篇文章里的状态设计、函数组织和页面分层吃透之后你再去写同类型功能速度会明显快很多。

相关新闻