鸿蒙生态里做小工具的人不少,但真正愿意把一个看起来没什么技术含量的东西打磨到能天天用的,不多。小羊时钟就是这么个存在——一个免费、没有会员体系、没有开屏广告的时钟工具,装上之后能把时间以最舒服的方式摆在桌面上、锁屏前、车里或者老人机的替代场景里。我做桌面小工具做了几年,第一次看到这个项目的时候是有点意外的:时钟这件事在功能机时代就解决了,为什么到了智能手机时代反而成了需要重新解决的问题?用了一段时间之后,我发现它的价值不在“看时间”,而在于把时间这个信息用最轻的方式放进视线半径,同时不占据注意力。这篇东西我准备拆两件事:一是这个免费时钟工具到底解决了什么真实问题,值不值得装;二是如果你自己想在鸿蒙上做一个类似的东西,从表盘绘制、时间同步、卡片刷新到省电策略,有哪些坑是必须提前知道的。前一部分偏使用侧,后一部分偏实现侧,做纯用户的可以只看前半,想动手的可以直接抄后半的代码思路。
1. 为什么一个时钟还需要单独做:整体设计与思路拆解
1.1 手机自带时钟的三个真实痛点
先说清楚需求,不然讨论技术没有意义。手机自带的时钟应用承担的是“闹钟 + 计时器 + 世界时钟”这套重功能,它在功能上是完整的,但在“看时间”这件事上是笨重的。第一个痛点是入口层级:解锁、找图标、点开,三到四步才看到一个表盘,而实际上你只需要知道“现在几点”。手表抬腕就能看,手机却要三步,这个体验落差在做饭、带娃、开车、健身这些双手占用或者注意力稀缺的场景里特别明显。
第二个痛点是常驻性差。你想在书桌上把旧手机当座钟用,自带的时钟应用息屏就没了,除非你手动去改系统设置里的锁屏时间,而那是全局设置,改完之后看视频也会一直亮着,很别扭。第三方时钟工具的价值就在这儿——它可以自己申请前台常亮,退出就恢复,不污染系统设置。这个能力在鸿蒙上是有对应窗口接口的,后面会讲。
第三个痛点是个性化表达几乎为零。表盘样式就那几套,字号、配色、是否显示秒针、是否显示农历,基本都是写死的。但对于一个要摆在桌面上长期看的东西来说,视觉舒适度直接决定了你会不会用它。小羊时钟在这块做得比较克制,它没有做几十套花里胡哨的皮肤,而是给几个关键参数开放了调节:表盘尺寸、指针样式、是否显示数字刻度、配色方案、是否跟随系统深色模式。这种“少而准”的开放方式其实更难做,因为每一个可调项背后都要考虑边界情况。
我在实际使用里最看重的反而是最不起眼的一条:它没有后台常驻进程,也没有推送权限,装上之后不会在通知栏里晃。对于一个免费工具来说,这几乎是奢侈品级别的克制。
1.2 方案选型:做完整应用还是做元服务卡片
如果让我从零设计这个东西,第一个要拍板的问题是形态。鸿蒙上可选的路有三条,每条路的成本和使用体验差别很大,我列个表对比一下,这也是我在别的项目里踩过坑之后总结的。
| 形态 | 优点 | 限制 | 适合场景 |
|---|---|---|---|
| 完整应用(UIAbility + 页面) | 能力最全,可做全屏常亮、复杂交互、动画 | 需要安装、需要拉起、占桌面图标 | 座钟模式、长期展示 |
| 桌面卡片(Form) | 免打开,桌面直接可见 | 刷新受系统调度约束,做不了秒级动画 | 快速看时间、抬屏可见 |
| 元服务(免安装) | 分发门槛低,即点即用 | 包体和能力有约束,复杂绘制受限 | 临时使用、分享给他人 |
我的结论是不要二选一,而是“卡片负责高频轻量,应用负责深度使用”。卡片放在桌面第一屏,用系统允许的刷新节奏更新到分钟级就够;用户真要看秒针、要当座钟,点进去进全屏页面。这个组合的好处是,日常 90% 的看时间行为在桌面一步完成,剩下 10% 的重需求才付出打开的代价。
这里有个选型上的取舍值得说透:很多人第一反应是让卡片每秒刷新,做出“活的”表盘。技术上不是不能做,但代价是电量、发热和被系统限流的风险。卡片的刷新是走系统调度通道的,频率有硬性约束,你硬要绕过去,最后的结果往往是刷新被降级,卡片时间反而停住不动,比分钟级刷新更糟。所以我倾向于让卡片做“静态美学 + 分钟级准确”,秒针留给应用内。这是工程上的诚实,不是能力不足。
1.3 技术栈与工程结构的取舍
鸿蒙的应用开发现在主推 ArkTS + ArkUI 声明式范式,时钟这个场景其实非常适合声明式:状态变了 UI 自动更新,你只要维护好“当前时间”这一个状态源就行。但表盘绘制是个例外,指针的逐帧旋转用声明式去做会产生大量节点更新,性能不如直接上 Canvas。所以小羊时钟这类工具通常采用混合结构:外层的布局、设置项、深色模式切换走 ArkUI 组件,中间的表盘走 Canvas 自绘。
工程目录上我建议按职责切分,不要把所有东西塞进一个页面文件。一个可维护的结构大致是这样:
entry/src/main/ets/ ├── entryability/ │ └── EntryAbility.ets // 生命周期、窗口常亮控制 ├── pages/ │ └── ClockPage.ets // 主页面,承载 Canvas 与设置面板 ├── view/ │ ├── ClockCanvas.ets // 表盘绘制组件(纯绘制,无业务) │ └── SettingPanel.ets // 设置面板 ├── model/ │ ├── TimeTicker.ets // 时间源与自校正定时器 │ └── ClockConfig.ets // 配置项数据结构与持久化 └── widget/ └── ClockCard.ets // 桌面卡片这么切的好处是,ClockCanvas 只接收一个时间对象和一份配置,它不知道数据从哪来,因此可以被页面复用、被预览复用、也能单独写测试。TimeTicker 只负责“稳定地吐出当前时间”,不关心 UI。这两层解耦之后,你在排查“指针抖”还是“时间不准”这类问题时会轻松非常多——指针抖是绘制层的事,时间不准是时间源的事,边界清晰。
提示:表盘组件一定要做成“纯函数式”的,输入时间 + 配置,输出画面。一旦你在绘制代码里直接读 Date.now(),后面想加“时间旅行调试”或者做静态截图预览就会非常痛苦。
2. 核心细节解析:角度、时间源与刷新节奏
2.1 指针角度的数学模型与常见计算错误
表盘的本质是一个极坐标到直角坐标的映射。指针从 12 点方向开始顺时针旋转,角度和时间单位是线性关系:秒针走一圈 360 度用 60 秒,所以每秒 6 度;分针每分钟 6 度;时针每小时 30 度。这个换算看着简单,但我见过太多实现栽在三个细节上。
第一个细节是分针和时针必须带小数。分针在 30 秒时要指向 3 分的位置而不是停在整分刻度上,时针在 3 点 30 分时要指向 3 和 4 之间而不是钉死在 3。正确写法是把秒和分折算进去:
// 角度单位为度,0 度为 12 点方向,顺时针为正 const secAngle = (second + ms / 1000) * 6; const minAngle = (minute + second / 60) * 6; const hourAngle = ((hour % 12) + minute / 60 + second / 3600) * 30;其中秒针加上毫秒是为了让动画更顺滑,如果你用 60fps 的定时器驱动,秒针会像扫秒一样连续走动;如果你只想让它一秒一跳,把 ms 项去掉即可。这是个体验取舍,两种都有人喜欢,我个人的偏好是扫秒,因为静止的机械表盘上突然跳一下,视觉上比连续移动更吸引注意力,而扫秒更像“背景”。
第二个细节是坐标转换的符号。Canvas 的坐标系原点在左上角,y 轴向下,所以从角度算终点坐标时纵向要用减号:
function polarToXY(cx: number, cy: number, radius: number, deg: number) { const rad = (deg - 90) * Math.PI / 180; // 12 点方向对齐到 -90 度 return { x: cx + radius * Math.cos(rad), y: cy + radius * Math.sin(rad) }; }这个写法比用 sin/cos 硬套更不容易错,因为“减去 90 度”这一步把“12 点在上”的直觉直接翻译成了数学。我见过有人写成x = cx + r * sin(θ)、y = cy - r * cos(θ),也能跑,但每次加新元素(比如刻度、数字)都要重新推一遍方向,很容易在某次改动里把刻度画反。
第三个细节是角度归一化。时钟跨过 12 点时,如果小时数没做取模,指针会突然从 360 度弹回 0 度,视觉上就是一次诡异的倒转。hour % 12这一句不能省,而且要注意负数时间戳的情况——虽然在时钟场景里不会出现负值,但如果你以后做“倒计时”复用这段代码,就会踩到。
2.2 定时器怎么选:setInterval 的漂移与自校正
绘制层解决了,接下来是驱动。新手最常见的主张是“用 1000 毫秒的 setInterval 每秒重绘一次”,这个方案能跑,但长期跑会出两个问题。
问题是累积漂移。setInterval 只保证“至少间隔这么久”,不保证精确。在系统繁忙、GC 触发、页面有复杂动画的时候,回调会被延迟。延迟一次两次看不出来,但跑两小时之后,你可能发现时钟比别人慢了十几秒。对于一块挂钟来说,十几秒的误差是不能接受的,因为它会误导人。
我的做法是不信任定时器的累积次数,每一帧都从系统时间重算。定时器只负责“叫醒我”,不负责“告诉我现在几点”。这样即使某几次回调延迟了,下一帧一读系统时间就自动追回来了,误差不会累积:
export class TimeTicker { private timerId: number = -1; private onTick: (d: Date) => void; constructor(onTick: (d: Date) => void) { this.onTick = onTick; } start(intervalMs: number = 1000) { this.stop(); const loop = () => { const now = new Date(); // 关键:读系统时间,而不是用计数器累加 this.onTick(now); // 对齐到下一个整秒边界,减少视觉上的抖动 const drift = now.getMilliseconds() % intervalMs; const next = intervalMs - drift; this.timerId = setTimeout(loop, next); }; loop(); } stop() { if (this.timerId !== -1) { clearTimeout(this.timerId); this.timerId = -1; } } }这里用 setTimeout 递归而不是 setInterval,核心目的是每一轮都能重新对齐整秒边界。如果按 1000 毫秒固定间隔,而第一次触发发生在 0.37 秒的位置,那么之后每次都是在 0.37 秒处触发,秒针的跳变时刻就会和真实秒的切换错开,看起来像“秒针比手机系统时钟慢半拍”。对齐之后,秒针跳动的时刻和系统时间切秒几乎重合,观感会干净很多。
第二个问题是前后台切换。应用切到后台之后,定时器会被系统挂起,回来时你如果只依赖定时器,画面会停在离开时的那一刻,直到下一帧才恢复,中间可能出现指针“停顿然后猛跳”。解决方案是在页面生命周期回到前台时主动触发一次重算,不要等定时器:
onPageShow() { this.ticker.stop(); this.ticker.start(1000); // 重新起循环,首帧立即执行 } onPageHide() { this.ticker.stop(); // 后台不空转,省电 }主动 stop 是省电的关键。很多人只在 onPageShow 里 start,不在 onPageHide 里 stop,结果页面被覆盖之后定时器还在跑,白白耗电。对于一块要长期亮着的表盘来说,这部分浪费很可观。
2.3 Canvas 矢量绘制与高分屏适配
表盘清晰度这件事,本质上是“你给的坐标单位”和“设备的物理像素密度”之间的换算问题。ArkUI 的 Canvas 组件在鸿蒙上的绘制坐标默认使用 vp(虚拟像素,会随屏幕密度缩放),这意味着你用 vp 描述一个半径 120 的圆,在不同设备上的物理尺寸是一致的、也是清晰的。但如果你混用了 px,或者手动去乘了屏幕密度,反而会得到模糊的边缘。
我踩过一次坑:早期为了“保险”,把所有绘制坐标都乘以了px2vp的倒数,结果在高密度屏上表盘边缘出现了明显的锯齿和虚化。原因很简单——乘过头了,实际绘制尺寸被放大到了两倍物理像素,Canvas 内部的抗锯齿处理就变得很吃力。删除那一行之后,问题瞬间消失。
刻度绘制是另一个容易被低估的地方。60 根刻度如果每根都开一次新的 path,性能会肉眼可见地掉。正确做法是把分钟刻度攒在一条 path 里,小时刻度用另一条,最后统一 stroke:
drawTicks(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) { ctx.lineCap = 'round'; // 分钟刻度:细、短、低对比 ctx.beginPath(); ctx.strokeStyle = '#9AA0A6'; ctx.lineWidth = 1; for (let i = 0; i < 60; i++) { if (i % 5 === 0) continue; // 小时刻度单独画 const deg = i * 6; const outer = polarToXY(cx, cy, r * 0.94, deg); const inner = polarToXY(cx, cy, r * 0.89, deg); ctx.moveTo(inner.x, inner.y); ctx.lineTo(outer.x, outer.y); } ctx.stroke(); // 小时刻度:粗、长、高对比 ctx.beginPath(); ctx.strokeStyle = '#3C4043'; ctx.lineWidth = 3; for (let i = 0; i < 12; i++) { const deg = i * 30; const outer = polarToXY(cx, cy, r * 0.94, deg); const inner = polarToXY(cx, cy, r * 0.84, deg); ctx.moveTo(inner.x, inner.y); ctx.lineTo(outer.x, outer.y); } ctx.stroke(); }注意这里用外层半径的百分比(0.94、0.89)而不是绝对数值。这个习惯是从多设备适配的教训里来的:手机是圆的、平板是方的、折叠屏展开后比例又不一样,一旦你写死r * 0.94变成r - 12,在小屏上刻度会挤到中心,在大屏上又显得稀疏。用比例,一次写对,处处能用。
注意:Canvas 的绘制必须在
onReady回调之后进行。在组件构建完成前调用绘制 API,得到的是一片空白,而且不会有任何报错提示。这个问题我在第一次用 ArkUI 的 Canvas 时足足找了半小时。
3. 实操过程:从零把一块表盘跑起来
3.1 工程初始化与关键配置
从空工程开始,先确认几个基础配置,这几项如果漏了,后面会以很隐蔽的方式出问题。
第一项是模块的module.json5,需要声明页面路由和窗口行为。如果你打算做前台常亮(座钟模式),需要申请对应的权限,具体权限名以你所用平台版本的文档为准,不同版本之间有过调整,照抄旧文档大概率编译不过。
第二项是卡片配置。卡片的尺寸档位、刷新方式、是否支持深色模式,都在卡片的配置里声明。这里有个容易忽略的点:卡片声明的尺寸档位和你在预览里看到的尺寸不是一回事,实际桌面上的卡片大小还会受用户拖拽影响,所以卡片的布局必须用弹性方案,不能假设固定宽高。
第三项是资源目录。表盘的配色、字体大小、不同深色模式下的色值,全部放进resources下的限定词目录里,跟着系统深浅色自动切换。不要试图在代码里判断当前是否深色再手动改颜色——这样不仅代码丑,而且用户在系统设置里切换时,你需要监听配置变化,主动重绘,多了一堆状态同步的麻烦。
// module.json5 片段(示意,字段以平台文档为准) { "module": { "name": "entry", "type": "entry", "abilities": [ { "name": "EntryAbility", "srcEntry": "./ets/entryability/EntryAbility.ets", "exported": true, "skills": [{ "actions": ["action.system.home"] }] } ] } }3.2 表盘绘制组件的完整实现
下面这块是核心,我把它写成一个自包含的组件,外部只传时间和配置。为了让指针转动更顺滑,我用一个@State的 Date 对象驱动重绘,ArkUI 会在状态变化后触发 Canvas 的重新绘制。
@Component export struct ClockCanvas { @Prop config: ClockConfig; private settings: RenderingContextSettings = new RenderingContextSettings(true); private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings); private ticker: TimeTicker | null = null; private cw: number = 0; // canvas 宽(vp) private ch: number = 0; aboutToAppear() { this.ticker = new TimeTicker((d: Date) => this.render(d)); } aboutToDisappear() { this.ticker?.stop(); this.ticker = null; } build() { Canvas(this.ctx) .width('100%') .height('100%') .onReady(() => { this.cw = this.ctx.width; this.ch = this.ctx.height; this.ticker?.start(this.config.sweepSeconds ? 50 : 1000); }) } private render(now: Date) { const ctx = this.ctx; const cx = this.cw / 2; const cy = this.ch / 2; const r = Math.min(cx, cy) * 0.92; // 以短边为基准,保证圆形不被裁切 ctx.clearRect(0, 0, this.cw, this.ch); // 1. 底盘 ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fillStyle = this.config.faceColor; ctx.fill(); // 2. 刻度 this.drawTicks(ctx, cx, cy, r); // 3. 指针:从粗到细,后画的压在上面 const h = now.getHours(), m = now.getMinutes(); const s = now.getSeconds() + now.getMilliseconds() / 1000; this.drawHand(ctx, cx, cy, ((h % 12) + m / 60 + s / 3600) * 30, r * 0.50, 7, this.config.hourColor); this.drawHand(ctx, cx, cy, (m + s / 60) * 6, r * 0.72, 5, this.config.minuteColor); if (this.config.showSecond) { this.drawHand(ctx, cx, cy, s * 6, r * 0.80, 2, this.config.secondColor); } // 4. 轴心盖帽,遮住三根指针的交点 ctx.beginPath(); ctx.arc(cx, cy, 5, 0, Math.PI * 2); ctx.fillStyle = this.config.secondColor; ctx.fill(); } private drawHand( ctx: CanvasRenderingContext2D, cx: number, cy: number, deg: number, len: number, width: number, color: string ) { const end = polarToXY(cx, cy, len, deg); const tail = polarToXY(cx, cy, -len * 0.12, deg); // 指针尾巴,让旋转中心更自然 ctx.beginPath(); ctx.lineCap = 'round'; ctx.lineWidth = width; ctx.strokeStyle = color; ctx.moveTo(tail.x, tail.y); ctx.lineTo(end.x, end.y); ctx.stroke(); } }这段代码里有几个决定成败的小设计,值得单独说。
一是r = Math.min(cx, cy) * 0.92。用短边而不是宽高分别算,是为了让表盘在非正方形区域里永远是正圆。0.92 这个系数留出了边距,避免圆弧紧贴容器边缘被裁掉一点点,那一点点在高分屏上非常显眼。
二是指针尾巴-len * 0.12。真实的手表指针在轴心另一侧是有一小段的,这个细节几乎没人注意,但加上之后,指针旋转时轴心区域不会出现“一根线凭空从中间长出来”的违和感,视觉重心更稳。
三是绘制顺序必须是底盘、刻度、指针、盖帽。我见过把盖帽放在指针之前画的,结果秒针从盖帽下面穿出来,像针扎在圆盘边缘,观感很怪。
四是aboutToDisappear里一定要 stop 定时器。这个组件如果在列表里被复用,或者页面被销毁,定时器没清掉就是内存泄漏 + 后台空耗,两个问题一起出现。
3.3 桌面卡片:刷新策略与降级方案
卡片是这类工具的灵魂,但也是限制最多的地方。核心认知是:卡片的刷新走系统调度,不是你想刷就刷。所以设计卡片时,一定要按“分钟级准确”来做,而不是“秒级”。
一个稳妥的卡片实现思路是记录一个“下次刷新时间”,在渲染时把当前时间画上去,然后请求系统在下一个整分钟时再唤醒一次。这样卡片上显示的时间最多在整分钟内是准确的,跨分钟时会有短暂的旧值,但用户几乎感知不到,因为看卡片的人通常只关心“几点几分”。
// 卡片侧:计算下一次刷新的目标时间 function nextMinuteBoundary(): number { const now = Date.now(); return now - (now % 60000) + 60000; } // 在卡片的 onAddForm / 更新时间回调里使用 // 具体 API 名称以平台文档为准,此处仅表达调度逻辑这里必须配一个降级方案。如果系统因为耗电管理、用户清理、或者长时间未使用而延迟了卡片刷新,卡片上就会显示一个过期时间。应对办法是给卡片加一个视觉提示:当日志检测到距上次刷新超过某个阈值时,让时间文字变成一个较淡的颜色,或者在旁边显示一个小圆点。这不是为了炫技,而是让用户知道“这不是实时值”,避免误解。
我个人对卡片的一个重要建议是:不要试图在卡片里画完整表盘。卡片的渲染能力和刷新预算都比应用内页面紧张,复杂的矢量绘制会拖慢卡片的首帧展示,用户会觉得“点开桌面卡了一下”。更实用的做法是卡片只显示数字时间 + 日期 + 一行极简装饰线,把表盘留给应用。把美术预算花在刀刃上,这是我在多个小工具项目里验证过的取舍。
3.4 全屏座钟模式与屏幕常亮
座钟模式是这个工具最容易被低估的价值点。想象一下:一台闲置的旧手机,插上充电器,立在书桌角落,全屏显示一块大表盘,亮度自动调到最低,晚上不刺眼,白天看得清。这套体验在鸿蒙上是可以实现的,但需要处理三件事。
第一件是隐藏系统状态栏,进入真正的沉浸式。这通过窗口能力设置全屏布局实现,具体接口在不同版本里叫法略有差异,效果是让内容延伸到状态栏和导航栏区域。
第二件是让屏幕不自动熄灭。这个能力不是普通权限,而是窗口级的设置,属于“应用在前台时保持屏幕常亮”的范畴:
// EntryAbility 或页面中调用,接口以平台文档为准 import { window } from '@kit.ArkUI'; async function keepScreenOn(context: Context, on: boolean) { const win = await window.getLastWindow(context); await win.setWindowKeepScreenOn(on); }关键在于成对调用。进入座钟模式时打开,退出页面时必须关掉,否则用户回到桌面之后屏幕一直亮着,电量和体验双输。我在测试里就遇到过忘记关掉的情况,手机放了一晚上,第二天电量掉了一大截,而且用户完全不知道是哪个应用干的——这种问题一旦被投诉,基本就是口碑事故。
第三件是亮度控制。座钟模式下应该允许用户手动调低亮度,甚至做一个“夜间模式”自动降亮。这个不需要系统权限,应用内的遮罩层就能实现:在表盘上叠一层半透明黑色,透明度随时间变化。这招比改系统亮度友好得多,因为改系统亮度会影响全局,退出应用后用户还得手动调回来。
提示:屏幕常亮 + 全屏 + 低亮度,这三件事组合起来才是座钟模式。只做前两件,晚上摆在床头会亮得像个手电筒,这个体验缺陷比没有座钟模式更劝退。
4. 省电这件事,免费工具反而更要算清楚
4.1 秒针是最大的耗电源头
很多人以为时钟工具不耗电,因为“它只是在画个圈”。实际测下来,一块常亮的表盘,耗电大头排在屏幕亮度之后,就是刷新频率。60fps 的扫秒动画和 1fps 的跳秒,在功耗上可以差出好几倍,因为前者意味着每秒 60 次完整的 Canvas 重绘和屏幕合成。
我的优化路径是按“用户是否真的需要”来分层。默认用 1fps 的跳秒,省电且够用;用户主动开启扫秒动画时,才切到高帧率;当系统进入低电量模式时,强制回落到跳秒并且隐藏秒针。这个逻辑写在配置层,不写在绘制层,这样以后加别的省电策略时不用动渲染代码。
| 模式 | 刷新频率 | 相对功耗 | 适用场景 |
|---|---|---|---|
| 跳秒(默认) | 1 次/秒 | 基准 1.0 | 日常桌面摆放 |
| 扫秒 | 约 20 次/秒 | 约 2.5 到 3.0 | 短时观赏、展示 |
| 分钟级 | 1 次/分钟 | 约 0.1 | 低电量、夜间 |
| 隐藏秒针 | 1 次/分钟 | 约 0.1 | 极简表盘、省电优先 |
这张表的数据来自我在几台不同设备上的粗略对比,绝对值会随屏幕材质和亮度变化,但量级关系是稳定的:把刷新从秒级降到分钟级,收益是十倍级的。所以如果只能做一项优化,就做这个。
4.2 局部重绘:一个被忽略的性能技巧
Canvas 每次重绘都要清空整块画布,这是标准行为。但对于时钟来说,底盘和刻度是静态的,只有指针在动,每次都把 60 根刻度重画一遍纯属浪费。
优化方案是双层 Canvas:底层放静态表盘(画一次就不动了),顶层放指针(每次只清空顶层并重绘指针)。两层用绝对定位叠在一起,顶层背景透明。这个技巧在 Web 上很常见,在 ArkUI 里同样适用,实现成本大概十几行代码,收益是每次刷新少画几百条路径。
Stack() { StaticDial({ config: this.config }) // 底层:底盘 + 刻度,不刷新 HandLayer({ config: this.config }) // 顶层:只有指针,透明背景 } .width('100%') .height('100%')需要提醒的是,两层 Canvas 的尺寸必须严格一致,且都用同一套半径计算逻辑。我一开始把两层各自的半径都独立算了一遍,结果因为边距系数写得不一样,指针和刻度差了 3 个 vp,看起来就像指针没对准中心。后来把半径计算抽成一个共用函数,问题解决。这类“看起来是渲染 bug,实际是参数不一致”的问题,在多图层结构里特别常见。
4.3 后台与提醒:该申请的能力要克制
如果这个工具带闹钟或者整点提醒,就需要考虑后台能力。这里的经验是:能不申请就不申请。一个免费时钟工具如果索要一堆后台权限,用户的第一反应是警惕,而不是感谢。
真正需要后台的场景只有一种——用户设置了闹钟或计时器并且退出了应用。这种情况下用系统的提醒代理能力来触发,而不是自己起一个常驻服务去轮询时间。自己轮询既费电又容易被系统清理,最后闹钟不响,用户会认为这是应用的质量问题,而不会理解背后的技术原因。
权限申请的时机也很重要。不要在首次启动时就弹一堆授权,而是在用户第一次点击“开启整点提醒”时,才弹出对应的说明和申请。这叫“场景化授权”,转化率和用户接受度都明显更高。
5. 常见问题排查实录
5.1 问题速查表
下面这些是我在实际开发和长期使用里遇到过的典型问题,按现象、原因、解法整理成表,遇到类似情况可以直接对照。
| 现象 | 最可能的原因 | 排查与解决 |
|---|---|---|
| 指针抖动、跳动不均匀 | 用 setInterval 固定间隔且未对齐整秒 | 改为 setTimeout 递归并每轮对齐到下一个整秒边界 |
| 应用切回前台后时间停在旧值 | 定时器被挂起,未在生命周期回调里重启 | 在页面显示回调里 stop 再 start,首帧立即重算 |
| 表盘边缘发虚、有锯齿 | 绘制坐标混用 px 与 vp,或重复乘了密度系数 | 统一使用 vp,删掉手动乘密度的代码 |
| 表盘被裁掉一点边 | 半径按长边计算,非正方形区域溢出 | 半径用Math.min(cx, cy)乘以小于 1 的系数 |
| 桌面卡片时间长期不更新 | 系统调度延迟、刷新预算被限制 | 按分钟级刷新设计,加“过期提示”降级,不要在卡片做秒级 |
| 秒针从中心盖帽下穿出 | 绘制顺序错误,盖帽画在指针之前 | 顺序固定为底盘、刻度、指针、盖帽 |
| 深色模式下表盘还是白的 | 颜色写死在代码里,未走资源限定词目录 | 颜色放进资源文件,由系统深浅色自动切换 |
| 座钟模式退出后屏幕一直亮 | 常亮开关没有成对关闭 | 在页面销毁或隐藏时强制关闭常亮 |
| 指针和刻度中心对不齐 | 多层 Canvas 各自算半径,参数不一致 | 半径和圆心抽成共用函数,两层共用同一份 |
5.2 三个值得单独说的排查故事
第一个是“时间越走越慢”。有个同行把他的表盘挂在副屏上跑了一整天,晚上对比系统时间,慢了将近 20 秒。他一开始怀疑是设备时钟不准,后来把定时器换成读系统时间的方案,问题依旧。真正的原因是他用了setInterval(fn, 1000)并且在 fn 里做了一次count++然后拿 count 去算角度。系统繁忙时回调被延迟,但 count 还是按次累加,于是显示的秒数和真实秒数脱节。改法很简单:删掉 count,每次读new Date()。这个问题的教训是,任何依赖“次数”的时间计算都是不安全的。
第二个是“卡片在特定机型上变成空白”。日志里没有任何错误,卡片就是不显示内容。排查下来发现,卡片布局里用了一个在大尺寸下才渲染的组件,而那个机型默认给卡片分配的是小尺寸档位,组件被判定为不满足约束,整个布局塌了。解决办法是把卡片布局按尺寸档位分支处理,小尺寸只显示时间,中尺寸加日期,大尺寸才上装饰元素。卡片的尺寸适配必须显式做,不能指望弹性布局自动兜住。
第三个是“晚上特别刺眼”。用户反馈座钟模式在卧室里亮度太高。但直接把遮罩透明度调高,白天又看不清。最后的方案是做一个随时间变化的遮罩曲线:晚上 10 点到早上 6 点之间,遮罩透明度线性上升到较高值;其余时间保持较低值。这个改动只有十几行代码,但它是那种“做了用户不会夸,不做用户会卸载”的细节。小工具的口碑往往就靠这类细节堆出来。
注意:排查刷新类问题时,先分清是“数据没来”还是“画面没画”。最快的方法是临时在界面角落加一个数字,直接显示内部状态里保存的时间值。如果数字是准的而指针不对,问题在绘制;如果数字本身就不动,问题在时间源或生命周期。这个二分法能省掉大量猜测。
6. 免费这件事,以及后续还能怎么长
6.1 免费工具的分发与生存逻辑
一个免费的时钟工具,靠什么活下来?答案是不需要活得多好,只需要成本足够低。时钟这类工具没有服务端、没有内容审核、没有用户数据存储,运维成本接近于零,这是它能长期免费的前提。一旦它开始做云同步表盘、在线皮肤商店,成本结构就变了,免费也就难以为继。
所以我在看这类工具时,会特别关注它有没有做“不必要的联网”。小羊时钟这类定位清爽的工具,理想状态是完全离线可用:不请求网络权限,不采集使用数据,不弹更新提示。这不只是隐私问题,也是稳定性问题——一个需要联网才能显示时间的时钟,在信号差的地方就是个废物,而时钟恰恰是那种“必须永远可靠”的工具。
分发上,鸿蒙生态对轻量工具的承载方式是应用市场和元服务两条路。元服务的好处是免安装、即点即用,非常适合分享给别人临时用;完整应用的好处是能进桌面、能做常驻。这个项目如果两条路都走,最合理的组合是“元服务做引流,应用做留存”,两者共用同一套绘制代码,只在入口和权限上做区分。
6.2 值得做的扩展方向
如果后续要继续迭代,我认为优先级应该是这样的。
排第一的是“多设备同步显示”。家里有平板、有旧手机、有智慧屏,能不能让它们同时显示同一个表盘,做一个多屏时钟墙?技术上可行,难点在于时钟同步的精度和时间源的一致性,需要在局域网内做一次时间对齐,误差控制在几十毫秒内,人眼就看不出来。这个功能对喜欢折腾桌面布置的人来说是刚需。
排第二的是“表盘的可编辑性”。不是给几十套预设皮肤,而是开放几个真正影响观感的参数:指针粗细、刻度密度、是否显示数字、字体。这些参数可以做成一组滑杆,实时预览。这个方向的价值在于让用户自己调出“顺眼”的那一块,而不是在预设里挑一个“最不丑”的。
排第三的是“专注计时”。时钟和番茄钟在形态上高度重合,共用一套表盘渲染逻辑,加一个倒计时环的成本很低。但我建议做的时候要克制——不要做成一个功能大杂烩,专注模式应该是独立的一屏,跟时钟模式并列,而不是塞进时钟的设置里。工具一旦开始堆功能,离难用就不远了。
我个人在实际折腾这些桌面小工具的过程中,最大的体会是:克制比功能更重要。一块表盘上能加的东西太多了,农历、天气、日程、步数,每一个都“看起来有用”,但每加一个,表盘就离“一眼看清时间”远一步。真正好用的工具,是那种你用了一年之后说不出它有什么功能,但每天都会看一眼的东西。时钟尤其如此,它的最高境界是存在感足够低,而准确度足够高。