HarmonyOS ArkUI翻页时钟与计时器组件开发实战

发布时间:2026/10/14 18:10:53
HarmonyOS ArkUI翻页时钟与计时器组件开发实战 从报名参加那个HarmonyOS组件开发征集活动开始我就一直在想一个问题为什么手机上那么多时钟应用翻页时钟却始终有不可替代的存在感后来做产品调研的时候我明白了用户要的并不是一个显示时间的控件而是那种机械翻页时咔哒一下的确定性反馈——它把时间的流逝变成了可以看见、可以触摸的动作。计时器也是一样光显示数字不够得有进度环、得有开始暂停的节奏感。所以这篇博文不写Hello World直接记录我从需求拆解到最终封装成可发布组件库的完整过程一个翻页时钟组件一个计时器组件两者共享同一套视觉基础。如果你是刚接触ArkUI的开发者或者正打算参加类似的组件征集活动这篇文章的代码片段和踩坑记录可以直接拿去做参考。我不太喜欢只贴代码不解释的设计所以文章里会有大量的为什么这么做。1. 从组件征集说起这个组件到底要解决什么问题1.1 需求拆解时钟与计时器并不是两个简单控件但凡做过组件的人都知道一个组件在需求阶段就定错了边界后面写再多代码都是白费力气。拿到翻页时钟和计时器组件这个题目时我第一件事不是打开DevEco Studio写代码而是拿纸笔把这玩意儿到底给谁用画清楚。翻页时钟的典型使用场景我当时梳理出三个桌面端的展示小组件、会议室的空闲时间显示屏、还有直播或录屏软件里的画面点缀。这些场景有一个共同特点——用户不操作它只是看它。所以翻页时钟的视觉质感、翻页动作的流畅程度比功能的丰富度重要得多。计时器则反过来它是被反复操作的设置时长、开始、暂停、继续、复位几乎没有用户会盯着一个计时器看十分钟他们只会每隔几秒瞄一眼剩余时间。基于这个差异我把组件库拆成了两个独立组件FlipClock负责时间展示CountdownTimer负责时段计时。两者外观上保持统一的配色和圆角风格但内部逻辑完全隔离。这么设计的好处是接入方可以只用在会议大屏上放一个时钟而不必被迫加载一路计时器的代码反过来健身应用只需要计时器组件时也不会因为翻页时钟的定时器逻辑污染应用主线程。1.2 组件边界只做时间相关的事其他都交给宿主组件开发和页面开发最大的区别就是组件必须克制。我当时给这个组件库定了几条铁律不主动获取地理位置不做时区自动推断时区偏移由宿主传入。不负责提醒的声音、振动等系统级动作只通过回调通知宿主倒计时结束了。不内置业务皮肤包只暴露主题色、尺寸比例、字体粗细这几个关键参数。我说句实在话很多组件征集活动里的作品问题不在功能不够而在功能太多。一个翻页时钟组件何必自己内置天气、日历、农历把这些塞进去组件就变成了外包页面后面的维护成本指数级上升。倒计时结束之后该怎么办是播放声音还是震动那是宿主的业务逻辑组件管到回调触发这一步就够了。经过这轮需求梳理我得到了一张可以动手开发的功能清单组件核心功能可选能力非目标翻页时钟时/分/秒翻页显示整点联动12/24小时切换、秒显隐、主题色、日期显示闹钟、时区切换计时器设置时长、开始/暂停/复位、到点回调环形进度、预设快捷时长、结束提醒钩子多任务计时、正计时/倒计时双向切换有了这张表后面的技术选型就有了依据翻页动画是核心中的核心计时器的时间精度是底线。2. 翻页动画到底怎么实现我对比过的三种方案如果你去搜翻页时钟开源组件能搜到一大堆但大部分是Web端的。HarmonyOS上的实现资料相对少所以我当时花了两个晚上把能想到的方案全撸了一遍。2.1 方案A整卡翻转代码最少但质感最差这个思路最直白每一格数字就是一张卡片数字变化时把整张卡片绕X轴翻转180度翻过去之后背面就是新数字。代码写出来很短核心就是一次rotateX的属性动画配合animateTo就能跑起来。问题也很明显真实的机械翻页钟翻转动作是分两段完成的——上一半先翻下去下一半再跟着翻上去不是整张纸翻面。整卡翻转的效果看起来更像翻书或者像那种老式的翻页广告牌和机械翻页钟的精致感差得很远。如果你只是要一个能动就行的Demo这个方案够用要是想做出让人愿意一直盯着看的质感它不合格。2.2 方案B上下半叶独立翻转我最终采用的方案真实翻页钟的内部结构其实可以拆成上叶和下叶两套卡片系统。以秒针从12变成13为例当前显示的是1212的上半部分先绕中轴往下翻翻到几乎水平的位置让藏在后面的13的上半部分露出来。12的下半部分再绕中轴往上翻翻到水平甚至稍微超过一点的位置13的下半部分顺势露出。两片旧卡完全让位之后整个数字位就彻底变成13。在ArkUI里实现这个流程需要把每一格数字拆成独立控制的上半块和下半块分别做旋转动画并且严格控制动画的先后时序。这比方案A复杂但做出来的效果是机械感的不是网页感的。我最后采用的也是这个方案下一章会讲具体的代码落地。2.3 方案CCanvas全自绘控制力最强但得不偿失还有一个思路是用Canvas把时钟整体画出来翻转动画也全部在Canvas内部实现。这样做的控制力最强甚至可以模拟真实的景深和光影但代价也很明显整个时间显示区域都变成了一个画布组件的结构语义完全丢失字体渲染、多分辨率适配、无障碍支持统统要自己处理。而且说实话在ArkUI里声明式的属性动画是交给系统渲染流水线去调度的每帧的插值计算走的是原生链路用Canvas自绘则要你在JavaScript线程里每帧重算坐标和透明度性能开销完全不是一个量级。所以我的结论很直接能交给声明式动画做的事就不要自己一帧一帧去算。Canvas保留给计时器的环形进度就够了——那个东西不用翻页只是画一个会变化的弧线。我用一个表格总结一下当时的决策依据方案代码量真实机械感运行性能维护成本结论整卡翻转很低差好低拒绝上下半叶翻转中等好好中采用Canvas全自绘很高最好受JS帧消耗影响高局部使用3. 翻页时钟实现拆解叶片结构、动画时序与防漂移3.1 时间数据模型与变化检测翻页时钟的数据模型我建议不要按照小时、分钟、秒各自一个变量来设计而是直接用一个时间字符串数组。假设当前时间是12:34:56内部就维护一个长度为6的数组每一位对应一个数字格// 每一个FlipUnit代表一位数字从小时十位到秒个位 const timeDigits: number[] [1, 2, 3, 4, 5, 6];每次定时器触发就重新解析系统时间得到新的timeDigits。然后逐位对比旧数组和新数组只有发现某一位数字真的变了才触发那一位的翻转动画。这点很重要因为翻页时钟的翻页动效是不均匀的秒的个位每秒翻一次秒的十位每十秒翻一次而小时的十位可能一整天只翻两三次。如果你偷懒让所有数字格每秒都重新执行一次动画用户会看到满屏卡片不停翻动又乱又费电。正确做法是按位触发谁变谁翻。我的经验是在组件内部维护一个previousDigits数组每次tick之后把新数组拷贝进去供下一次比较。3.2 半叶裁剪与翻转动画的ArkUI结构单个FlipUnit的UI结构我用三层Stack叠加来实现。先解释一下三层分别是什么最底层放新数字的完整视图也就是翻转完成之后要显示的那个数字。中间层放旧数字的上半叶它只露出数字的上半部分负责往下翻转。最上层放旧数字的下半叶它只露出数字的下半部分负责往上翻转。听起来有点绕但你记住一句话就明白了旧数字两片叶子盖在上面新数字垫在底下旧叶子翻走新数字就露出来了。要看懂代码先得解决怎么让一个Text只显示上半部分的问题。ArkUI提供clip属性配合clipShape可以裁剪但最简洁的做法是给外层容器设置固定高度再用align把文字顶到你想显示的区域。比如上半叶容器高度只有数字卡片高度的一半文字align(Alignment.Top)并设置overflow(Clip)自然就只显示上半部分下半叶同理align(Alignment.Bottom)。下面是核心的FlipUnit结构我省略了样式细节保留关键逻辑Component export struct FlipUnit { Prop currentValue: number 0; Prop nextValue: number 0; State topRotate: number 0; // 旧数字上半叶的旋转角 State bottomRotate: number 0; // 旧数字下半叶的旋转角 State topVisible: boolean true; State bottomVisible: boolean true; private leafHeight: number 60; build() { Stack({ alignContent: Alignment.Center }) { // 1. 底层新数字完整显示 Text(${this.nextValue}) .fontSize(48) .width(80) .height(this.leafHeight * 2) // 2. 旧数字上半叶 Text(${this.currentValue}) .fontSize(48) .width(80) .height(this.leafHeight) .backgroundColor(#1A1A2E) .textAlign(TextAlign.Center) .align(Alignment.Top) .clip(true) .rotate({ x: 1, angle: this.topRotate, centerY: this.leafHeight // 旋转轴在卡片水平中间线 }) .visibility(this.topVisible ? Visibility.Visible : Visibility.Hidden) // 3. 旧数字下半叶 Text(${this.currentValue}) .fontSize(48) .width(80) .height(this.leafHeight) .backgroundColor(#16213E) .textAlign(TextAlign.Center) .align(Alignment.Bottom) .clip(true) .rotate({ x: 1, angle: this.bottomRotate, centerY: 0 // 旋转轴在卡片水平中间线 }) .visibility(this.bottomVisible ? Visibility.Visible : Visibility.Hidden) } } }这里面有两个非常容易踩的坑我单独拿出来说。第一个是旋转轴的位置。rotate里的centerY不是你想当然的中心点它表示旋转轴在组件坐标系里的Y坐标。上半叶的高度是leafHeight翻转轴要放在它的底部也就是centerY要设成leafHeight下半叶的翻转轴要放在它的顶部所以centerY是0。我当时第一次调的时候两个都用的默认中心结果旧数字上半叶绕着它自己的中心翻像扑克牌一样整个飞出去根本不可能出现下半部分贴底翻折的效果。第二个是clip和align配合。align(Alignment.Top)只决定文字在父容器内的对齐位置真正把多余部分切掉的是.clip(true)。如果不设clip上半叶的Text虽然只有半个卡片高文字还是会超出容器的可视边界漏出来动画瞬间穿帮。你能想象旧数字的下半部分还悬在半空、却被上半叶翻下来的画面吗那基本就是灾难。3.3 翻转时序ledger两级动画动画时序我用了两段式animateTo先翻上半叶再翻下半叶两段之间通过onFinish衔接startFlip() { // 第一步旧数字上半叶往下翻 animateTo({ duration: 350, curve: Curve.EaseIn, onFinish: () { // 上半叶已经翻走先把它隐藏防止遮挡底层新数字 this.topVisible false; // 第二步旧数字下半叶往上翻 animateTo({ duration: 350, curve: Curve.EaseOut, onFinish: () { this.bottomVisible false; // 动画完成整个FlipUnit切换到新数字状态 this.completeFlip(); } }, () { this.bottomRotate 90; }) } }, () { this.topRotate -90; }) }为什么上半叶翻到-90、下半叶翻到90这里需要一点三维想象。rotateX正负角度的区别只看你的坐标系怎么摆。-90表示卡片的上边缘向屏幕内倒下去看起来就是上半叶翻到后面90表示下半叶的下边缘向屏幕内翘起来看起来是被新数字顶开。两个方向一配合机械感就出来了。但这里还有个细节ArkUI的动画插值默认是同步到渲染帧的两段动画用onFinish串行中间会有大约一帧的间隙如果速度太快可能看到轻微的闪烁。我当时解决的办法是把第一段的duration设为360第二段设为380并且第二段的起始帧稍微提前一点。后来实测发现在60Hz屏幕上这个微小的重叠正好可以掩盖两个半叶的接缝观感更顺滑。这个参数属于经验值你拿到代码后可以自己在真机上微调。3.4 为什么刷新要用系统时间而不是计数累加我见过不少新手写的时钟组件先setInterval(() { seconds }, 1000)然后拿这个seconds变量去换算时分秒。这种做法在页面保持前台活跃、且系统负载很低的时候勉强能用但一旦出现卡顿、掉帧、任务被系统调度延迟误差就会累积。鸿蒙应用在后台被挂起是常态setInterval并不保证按真实时间间隔触发。假如你的应用被挂起了30秒回来之后定时器会一口气触发很多次用累加计数的逻辑就会快速跳秒视觉上像抽风。而正确做法是每次定时器触发时直接用Date.now()取系统当前时间戳再换算成时分秒。这样不管定时器被延迟多久计算出来的永远是真实时间组件只会跳到正确的下一秒不会走偏。换算代码很简单private tick() { const now new Date(); const newDigits [ Math.floor(now.getHours() / 10), now.getHours() % 10, Math.floor(now.getMinutes() / 10), now.getMinutes() % 10, Math.floor(now.getSeconds() / 10), now.getSeconds() % 10 ]; // 逐位与previousDigits比较触发变化位动画 }可能有人会问既然Date.now()已经够准了那定时器间隔设多少合适我的建议是设成500毫秒而不是1000毫秒。原因很简单setInterval本身并不精确如果设成1000毫秒可能在59.9秒的时候触发也可能在60.1秒的时候触发。设成500毫秒之后即使某次触发有几百毫秒的偏差也能保证在下一秒变化后最短时间内刷新界面。多出来的那一点点性能开销对于翻页时钟这种轻量组件完全可以忽略。3.5 动画结束后的状态复位这个坑我调试了整整一个晚上必须写出来。第一次写完startFlip之后出现了这样一个诡异现象数字从12变成13翻页动画正常但从13变回12的时候动画方向完全反了叶片从奇怪的角度转回来。原因出在动画结束后的状态没有复位。第一次动画结束时topRotate停留在-90bottomRotate停留在90topVisible和bottomVisible都是false。第二次翻转时代码虽然把topVisible设回了true但topRotate还是-90它就得从一个歪着的角度再转回0视觉上自然不对。正确的复位逻辑是每次动画完成后把currentValue替换成nextValue同时重置topRotate 0、bottomRotate 0、topVisible true、bottomVisible true。而且这个复位不能带动画必须瞬间完成否则就会出现翻回去的残影。后来我把复位和值切换封装成一个completeFlip()在第二段动画的onFinish里调用。这样每一个FlipUnit永远在静态——上半翻——下半翻——瞬间复位——静态的循环里无论连续触发多少次都不会乱。4. 计时器组件状态机设计、环显与后台补偿4.1 四态状态机空闲、运行、暂停、结束计时器的状态流转比翻页时钟的动画时序好理解得多但越是简单的东西越容易写乱。我见过很多人把暂停写成停止然后发现点击开始键永远无法回到原来的剩余时间代码里全是if...else。我用四态状态机来定义它状态可执行动作动作结果idle空闲开始进入running记录startTimestamprunning运行暂停 / 复位进入paused / 回到idlepaused暂停继续 / 复位回到running / 回到idlefinished结束复位回到idle组件对外只需要暴露三个方法start()、pause()、reset()内部根据当前状态决定这些方法是否有效。从架构上避免非法状态切换比在调用方写一堆防御判断靠谱得多。4.2 用时间戳差计算剩余时间而不是倒减计时器的核心逻辑和翻页时钟一样永远不要用一个每秒减1的计数器来代表剩余时间。正确模型是保存两个值totalMs本次计时的总时长。startTimestamp本次开始计时或继续计时时的时间戳。那么任意时刻的剩余毫秒就是remainingMs totalMs - (Date.now() - startTimestamp);暂停的时候先把remainingMs算出来存到内部属性里然后把状态切到paused。继续的时候重新取一次Date.now()赋给startTimestamp。这样无论中间发生了多少次卡顿、掉帧、后台休眠剩余时间永远和真实时间对齐不会出现我锁屏五分钟回来发现计时器只走了三分钟这种匪夷所思的事。这里还有一个产品细节值得提UI上每秒钟只刷新一次数字但真正到点的判定不能等每秒刷新。我内部维护了一个高频的短定时器比如500ms每次触发都重新算remainingMs只要它小于等于0立即触发onFinish回调并切换到finished状态。高频计算撑死了也就是每秒两次减法性能上没有负担。4.3 圆环进度显示与按钮交互计时器的视觉组件我用的是ArkUI自带的圆形Progress参数很简单Progress({ value: this.elapsedMs, total: this.totalMs, type: ProgressType.Circle }) .width(220) .color(this.themeColor)你可能会想这个进度值怎么算我用的是已消耗时间而不是剩余时间作为value。因为Progress的value是从0开始往上涨的把它当成已消耗时间圆环从空到满直观地表示时间被你用掉了多少。如果你习惯剩余时间越少圆环越短也可以把value传成remainingMs但那样进度环就在倒缩视觉上会显得很焦虑。我测试了几个场景之后发现增长型圆环 剩余数字的组合最舒服。数字显示和开始/暂停按钮的布局我就不贴完整代码了只说一个值得注意的交互细节暂停状态下按钮文案要变成继续而不是开始。别小看这个文案很多人会在finished之后点一下继续结果计时器又从0开始跑给人感觉彻底坏了。还是那句话状态机管住按钮文案从状态推导不要单独维护另一个变量。4.4 后台切回的时间补偿策略计时器组件在应用退到后台之后鸿蒙系统可能会暂停JS定时器。对用户来说最直观的槽点是我切到微信聊了五分钟回来发现计时器还在等我。刚才说过用时间戳差值计算剩余时间已经能保证回来之后时间数字是对的。但UI层面还有一个优化回到前台时检测到剩余时间跳变超过一定阈值就播放一次兜底的收尾动画而不是没有任何过渡地直接跳到新数字。我当时的做法是监听页面的onPageHide和onPageShow生命周期在组件里可以通过aboutToDisappear和aboutToAppear配合记录离开前台的时间戳回到前台时计算差值如果超过总共时长的5%就把圆环的进度更新和数字刷新合并成一次短暂渐变。这个优化在真机上很管用它让用户感觉到时间确实在走只是刚才没看到。5. 封装成可复用的组件接口、打包与性能调优5.1 对外参数与回调的取舍组件写好了封装成什么接口风格直接决定别人愿不愿意用。我一贯的原则是参数宁少勿多回调宁清晰勿花哨。翻页时钟的对外参数我只留了四个Prop showSecond: boolean true; // 是否显示秒 Prop use24Hour: boolean true; // 24小时制 Prop themeColor: string #F39C12; // 主题色 Prop dateLabel: boolean false; // 是否显示日期小字计时器的对外参数和回调Prop totalSeconds: number 300; Prop autoStart: boolean false; onFinish: () void () {};我特意没有做从翻页时钟一键切换到计时器这种花哨模式。很多人会觉得既然两个组件在一个库里为什么不合并成一个带模式的复杂组件我的看法是模式切换的逻辑会污染两个组件的独立性时钟不需要知道计时器的状态机计时器也不需要关心翻页叶片的角度。保持阉割保持独立。5.2 通过Har包组织与本地依赖组件的目录结构我采用了标准的Har包结构flip-timer-component/ ├── src/main/ets/ │ ├── components/ │ │ ├── FlipClock/ │ │ │ ├── FlipClock.ets │ │ │ └── FlipUnit.ets │ │ ├── CountdownTimer/ │ │ │ ├── CountdownTimer.ets │ │ │ └── TimerCircle.ets │ │ └── common/ │ │ └── Theme.ets │ └── index.ets ├── oh-package.json └── README.mdindex.ets负责导出export { FlipClock } from ./components/FlipClock/FlipClock; export { CountdownTimer } from ./components/CountdownTimer/CountdownTimer;接入方在oh-package.json里依赖这个Har包然后import { FlipClock } from flip-timer-component就能直接用完全透明的使用体验。这里有个容易踩的坑如果index.ets漏导出了某个组件模块化打包不会报错但接入方会拿到一个undefined排查起来很费劲。我的习惯是每次改完导出都写一个最小的测试页先本地跑一遍再发布。5.3 实测中的性能与显示问题这个组件在参加征集活动的过程中来来回回优化了挺多轮这里记录三个印象最深的问题。第一个是文本颜色和背景对比度。翻页时钟上半叶和下半叶因为物理上是有阴影的真实机械结构上半叶往往比下半叶亮。我在最初版本上下半叶用了同一个背景色结果用户反馈下半叶像被一层雾罩住。后来我分别给上下半叶指定了略有明暗差异的背景色上半叶用#1A1A2E下半叶用#16213E再配合翻转过程中的阴影设置立体的感觉一下子就出来了。别小看这10%的色差机械感就靠这个细节。第二个是帧率。翻页时钟如果每秒都翻页在低端设备上长时间跑偶尔会出现动画掉帧。我的优化策略是监听动画的onFinish时间与预期时间的差值如果连续多次动画实际耗时超过配置耗时就认为当前设备性能吃紧自动把动画时长从350ms降到200ms甚至在极端情况下关掉动画直接切换数字。这个降级策略对组件类作品很重要——你要保证在任何设备上呈现的效果都不会拉胯而不是只在旗舰机上流畅。第三个是圆环进度条的线程时序。最初我用了每秒刷新一次Progress的value结果在慢速网络调试时发现进度环的走动有肉眼可见的台阶感一格一格跳。改成每500ms刷新一次之后台阶感明显改善。其实500ms在视觉上依然不是完全平滑但考虑到圆环本身不是那种需要60帧丝滑的精密仪表用户并不会盯着它看这个程度已经足够。最后再说一个开发工具层面的建议在模拟器上看翻页动画流畅度会比真机差不少因为模拟器的GPU转译会引入额外延迟。如果你评估动画参数一定拿到真机上测尤其是duration这个值模拟器上滑顺的参数在真机上可能会显得拖沓。我在活动中提交作品之前专门在低端真机上把整套动画从头到尾跑了几轮把翻转时长校准到一个既够看清叶片动作、又不拖泥带水的值。我的体会是一个动画组件的完成度从来不取决于代码逻辑有多炫而取决于这些细节参数被调得多顺。组件征集活动其实是一个很好的契机它逼着你去思考组件边界、思考状态机、思考性能降级而不是随手交一个能跑的控件了事。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询