让时间在屏幕上“流动”起来——我在 HarmonyOS 里写了一个粒子时钟
前言
数字时钟看久了总觉得冷冰冰的。固定的七段数码管,黑底白字,一秒跳一下,像在催促什么。我一直想做一个“活的”时钟——那些构成数字的笔画不是静止的,而是由一群小光点组成的,它们从四面八方飞来,落到正确的位置上,拼出当前的时间。每当秒钟跳动,那些光点就重新飞起来,找到新的位置安顿下来。整个过程像一群萤火虫在夜空中排队,既有秩序感,又带着一点点随机的美感。

这个想法搁在心里很久了。上周晚上闲着,我打开 DevEco Studio 6.1.1 Beta1,在 Pura X Max 模拟器上把这个粒子时钟写了出来。用 Canvas 画几百个小圆点,给每个点一个目标坐标,每帧让它们向目标移动一小段距离;当时间变化时,更新目标坐标,粒子们就嗡嗡地飞向新的数字形状。这篇文章就是这次折腾的完整记录,里面有粒子系统的设计思路、数字点阵的生成方法、缓动运动的数学原理,以及怎么在 HarmonyOS 上用定时器驱动一个流畅的动画。代码照例给全,你拷进模拟器就能看到一个会呼吸的时钟。
一、粒子系统的骨架——每个光点都是一只“飞蛾”
粒子系统这个词听起来很高深,但拆开来看就是三件事:每个粒子有一个当前位置和一个目标位置;每一帧,所有粒子都向自己的目标移动一小步;所有粒子画出来,形成某种形状。应用到时钟上,就是把当前时间(比如“12:34”)的每个数字转换成一组目标坐标点,然后让粒子们飞过去。
核心数据结构很简单:

interface Particle {
x: number; // 当前位置 x
y: number; // 当前位置 y
tx: number; // 目标位置 x
ty: number; // 目标位置 y
}
每个粒子知道“我在哪”和“我要去哪”。动画的每一帧,我们遍历所有粒子,把 x 向 tx 靠近一点,y 向 ty 靠近一点。这个“靠近一点”不是瞬间跳到目标,而是用一个平滑的缓动公式。最简单的就是线性插值:
x += (tx - x) * speed
y += (ty - y) * speed
speed 取 0.08 左右,在每秒 30 帧的情况下,粒子会有一个快速的启动、然后慢慢减速逼近目标的过程,看起来自然不生硬。如果把 speed 设成 1,粒子会“啪”地一下瞬移过去,就没意思了;设得太小,粒子又像在爬。0.05 到 0.15 之间是比较舒服的范围。
粒子初始化时,可以把它们随机撒在画布各处,这样一启动,所有光点就从四周飞向第一个时间,有种“聚拢”的仪式感。每次目标更新时,粒子已经从上一个形状出发,飞向下一个形状,形成连续的流动效果。
二、数字怎么变成一组坐标——点阵字模的妙用
粒子知道目标坐标的前提是,我们得先告诉它数字长什么样。早期的点阵打印机、LED 广告屏,都是把每个字符定义成一个 5×7 或 7×9 的网格,亮灯为 1,灭灯为 0。我们也可以用同样的方法,把 0 到 9 每个数字定义为一个 5×7 的二维数组。
例如数字 0:

[
[1,1,1,1,1],
[1,0,0,0,1],
[1,0,0,0,1],
[1,0,0,0,1],
[1,0,0,0,1],
[1,0,0,0,1],
[1,1,1,1,1]
]
数字 1 就是中间一竖列,数字 8 几乎全亮。把这些数组里值为 1 的坐标提取出来,就得到了一组相对坐标。然后根据这个数字在时钟字符串里的位置(比如第几位),乘以间距和缩放比例,映射到画布的绝对坐标,就得到了该数字对应的所有粒子的目标坐标。
为了避免粒子太密或太稀,每个 5×7 的点阵大约有 15~25 个粒子,四个数字加上两个冒号,总共大约 80~120 个粒子。这个数量在模拟器上绘制毫无压力,动画流畅。
提取坐标的函数大致如下:

const DIGIT_WIDTH = 5;
const DIGIT_HEIGHT = 7;
function getDigitCoords(digit: number): number[][] {
let pattern = DIGIT_PATTERNS[digit];
let coords: number[][] = [];
for (let r = 0; r < DIGIT_HEIGHT; r++) {
for (let c = 0; c < DIGIT_WIDTH; c++) {
if (pattern[r][c] === 1) {
coords.push([c, r]); // 相对坐标
}
}
}
return coords;
}
对于整个时间字符串(比如 "12:34"),我们需要为每一位数字调用这个函数,并根据位置偏移,再缩放到画布上合适的大小。冒号可以直接用两个固定的粒子堆叠成上下两点,不需要从点阵生成。
三、每秒一次“重组”——时间更新与目标刷新
时钟的核心是每秒检查一次当前时间,看有没有数字发生变化。例如 12:34 变成 12:35 时,只有最后一个数字从 4 变成了 5,那么只有最后一位数字对应的那组粒子需要更新目标。其余三位数字的粒子保持原目标不动,它们就不会乱飞,保证视觉稳定。
如果每次时间变化都让所有粒子重新飞到随机位置再聚拢,虽然也能看,但会显得很“碎”。局部更新既能体现粒子动画的美感,又不会让屏幕一直处于混乱状态。
实现局部更新的方法是:把粒子按数字位分组。比如分成四个数组:particleGroups[0] 到 particleGroups[3],对应小时十位、小时个位、分钟十位、分钟个位。每次时间变化时,逐个比较新旧数字,只有数字变了的那一组才重新生成目标坐标,并把该组所有粒子的 tx 和 ty 更新到新坐标上。其余组保持不变。
用一个 setInterval 每秒执行一次检查:

setInterval(() => {
let now = new Date();
let hh = String(now.getHours()).padStart(2, '0');
let mm = String(now.getMinutes()).padStart(2, '0');
let newTime = hh + mm; // 四位字符串
if (newTime !== this.currentTime) {
this.updateTargets(newTime);
this.currentTime = newTime;
}
}, 1000);
更新目标函数 updateTargets 中,遍历四位,如果某位数字改变,就重新计算该位数字的绝对坐标,并赋给对应组里的每个粒子。粒子的当前位置保持不变,所以它们会从旧形状平滑飞向新形状。
四、动画循环——让 Canvas 动起来的关键
前面有了粒子和目标,接下来需要一个高速运转的“发动机”来驱动粒子移动和绘制。HarmonyOS 里用 setInterval 最方便。设定每 33 毫秒执行一次(约 30fps),在回调里做三件事:清空画布、更新所有粒子的位置、绘制所有粒子。
绘制粒子时,每个粒子画一个很小的圆,颜色可以固定为亮白或淡黄,也可以根据速度或位置做一些微妙的颜色变化。为了让整体更柔和,我用了半透明的白色,背景用深蓝黑,模拟星空感。
粒子位置更新的缓动公式前面已经说了,加上一点细微的随机抖动会让画面更生动——比如每次更新时加一个很小的随机偏移,模拟粒子在目标位置附近的微颤。但这种抖动要非常克制,否则数字就模糊了。我只在粒子距离目标很近时才加入微小抖动,让它看起来像在“呼吸”。
动画循环代码结构:

this.animTimer = setInterval(() => {
if (!this.ctx) return;
let ctx = this.ctx;
ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
// 绘制背景
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight);
// 更新并绘制粒子
for (let p of this.particles) {
// 缓动移动
p.x += (p.tx - p.x) * 0.08;
p.y += (p.ty - p.y) * 0.08;
// 绘制
ctx.beginPath();
ctx.arc(p.x, p.y, 2, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,0.9)';
ctx.fill();
}
}, 33);
注意,为了避免定时器累积导致的资源浪费,aboutToDisappear 里要清除定时器。
五、完整代码——让粒子在模拟器里准时飞起来
以下代码适配 DevEco Studio 6.1.1 Beta1、SDK22 语法,Pura X Max 模拟器。新建 Empty Ability 项目,替换 entry/src/main/ets/pages/Index.ets。无需任何权限,纯本地动画。

/*
* 动态粒子时钟 — Canvas 粒子动画
* 环境:DevEco Studio 6.1.1 Beta1,Pura X Max 模拟器,SDK22
*/
import { CanvasRenderingContext2D } from '@ohos.graphics.canvas';
// 粒子数据结构
interface Particle {
x: number;
y: number;
tx: number;
ty: number;
}
// 数字点阵 0-9 (5x7)
const DIGIT_PATTERNS: number[][][] = [
// 0
[[1,1,1,1,1],[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1],[1,1,1,1,1]],
// 1
[[0,0,1,0,0],[0,1,1,0,0],[1,0,1,0,0],[0,0,1,0,0],[0,0,1,0,0],[0,0,1,0,0],[1,1,1,1,1]],
// 2
[[1,1,1,1,1],[0,0,0,0,1],[0,0,0,0,1],[1,1,1,1,1],[1,0,0,0,0],[1,0,0,0,0],[1,1,1,1,1]],
// 3
[[1,1,1,1,1],[0,0,0,0,1],[0,0,0,0,1],[1,1,1,1,1],[0,0,0,0,1],[0,0,0,0,1],[1,1,1,1,1]],
// 4
[[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1],[1,1,1,1,1],[0,0,0,0,1],[0,0,0,0,1],[0,0,0,0,1]],
// 5
[[1,1,1,1,1],[1,0,0,0,0],[1,0,0,0,0],[1,1,1,1,1],[0,0,0,0,1],[0,0,0,0,1],[1,1,1,1,1]],
// 6
[[1,1,1,1,1],[1,0,0,0,0],[1,0,0,0,0],[1,1,1,1,1],[1,0,0,0,1],[1,0,0,0,1],[1,1,1,1,1]],
// 7
[[1,1,1,1,1],[0,0,0,0,1],[0,0,0,1,0],[0,0,1,0,0],[0,1,0,0,0],[0,1,0,0,0],[0,1,0,0,0]],
// 8
[[1,1,1,1,1],[1,0,0,0,1],[1,0,0,0,1],[1,1,1,1,1],[1,0,0,0,1],[1,0,0,0,1],[1,1,1,1,1]],
// 9
[[1,1,1,1,1],[1,0,0,0,1],[1,0,0,0,1],[1,1,1,1,1],[0,0,0,0,1],[0,0,0,0,1],[1,1,1,1,1]]
];
const CELL_SIZE = 8; // 点阵单元大小
const DIGIT_SPACING = 6; // 数字间距
const COLON_WIDTH = 3; // 冒号宽度
@Entry
@Component
struct Index {
private ctx: CanvasRenderingContext2D | null = null;
private canvasWidth: number = 0;
private canvasHeight: number = 0;
private particles: Particle[] = []; // 所有粒子
private particleGroups: Particle[][] = []; // 按数字位分组
private currentTime: string = '';
private animTimer: number = -1;
private timeTimer: number = -1;
// Canvas 就绪
private onCanvasReady(ctx: CanvasRenderingContext2D): void {
this.ctx = ctx;
this.canvasWidth = ctx.canvas.width;
this.canvasHeight = ctx.canvas.height;
// 计算数字区域总尺寸,定位居中
this.initParticles();
this.startTimers();
}
// 获取数字的点阵坐标(相对)
private getDigitCoords(digit: number): number[][] {
let pattern = DIGIT_PATTERNS[digit];
let coords: number[][] = [];
for (let r = 0; r < 7; r++) {
for (let c = 0; c < 5; c++) {
if (pattern[r][c] === 1) {
coords.push([c, r]);
}
}
}
return coords;
}
// 初始化粒子与分组
private initParticles(): void {
this.particles = [];
this.particleGroups = [];
let timeStr = this.getTimeString();
this.currentTime = timeStr;
// 计算整体偏移,使数字居中
let totalWidth = 4 * 5 * CELL_SIZE + 3 * DIGIT_SPACING + COLON_WIDTH * CELL_SIZE;
let offsetX = (this.canvasWidth - totalWidth) / 2;
let offsetY = (this.canvasHeight - 7 * CELL_SIZE) / 2;
let positions = [0, 1, 2, 3]; // 小时十位、小时个位、分钟十位、分钟个位
for (let pos of positions) {
let digit = parseInt(timeStr[pos]);
let coords = this.getDigitCoords(digit);
let group: Particle[] = [];
let baseX = offsetX + pos * 5 * CELL_SIZE + pos * DIGIT_SPACING;
// 跳过冒号位置(pos=2 其实是分钟十位,冒号在小时和分钟之间,我们不在数字循环里处理)
// 实际上小时两位(pos0,1)后是冒号,再是分钟两位(pos2,3)
if (pos >= 2) {
baseX += COLON_WIDTH * CELL_SIZE + DIGIT_SPACING; // 冒号加间距
}
for (let [cx, cy] of coords) {
let tx = baseX + cx * CELL_SIZE + CELL_SIZE / 2;
let ty = offsetY + cy * CELL_SIZE + CELL_SIZE / 2;
// 粒子初始位置随机
let x = Math.random() * this.canvasWidth;
let y = Math.random() * this.canvasHeight;
let particle: Particle = { x, y, tx, ty };
group.push(particle);
this.particles.push(particle);
}
this.particleGroups.push(group);
}
}
// 更新指定数字位的目标坐标
private updateDigitTarget(pos: number, digit: number): void {
let coords = this.getDigitCoords(digit);
let totalWidth = 4 * 5 * CELL_SIZE + 3 * DIGIT_SPACING + COLON_WIDTH * CELL_SIZE;
let offsetX = (this.canvasWidth - totalWidth) / 2;
let offsetY = (this.canvasHeight - 7 * CELL_SIZE) / 2;
let baseX = offsetX + pos * 5 * CELL_SIZE + pos * DIGIT_SPACING;
if (pos >= 2) {
baseX += COLON_WIDTH * CELL_SIZE + DIGIT_SPACING;
}
let group = this.particleGroups[pos];
// 如果新点阵点数与当前粒子数不同,重建该组(本例子中每个数字点阵数固定)
for (let i = 0; i < group.length; i++) {
let [cx, cy] = coords[i];
group[i].tx = baseX + cx * CELL_SIZE + CELL_SIZE / 2;
group[i].ty = offsetY + cy * CELL_SIZE + CELL_SIZE / 2;
}
}
// 获取当前时间四位字符串
private getTimeString(): string {
let now = new Date();
let hh = String(now.getHours()).padStart(2, '0');
let mm = String(now.getMinutes()).padStart(2, '0');
return hh + mm;
}
// 启动定时器
private startTimers(): void {
// 动画循环 30fps
this.animTimer = setInterval(() => {
this.drawFrame();
}, 33);
// 时间检测每秒
this.timeTimer = setInterval(() => {
let newTime = this.getTimeString();
if (newTime !== this.currentTime) {
for (let i = 0; i < 4; i++) {
if (newTime[i] !== this.currentTime[i]) {
this.updateDigitTarget(i, parseInt(newTime[i]));
}
}
this.currentTime = newTime;
}
}, 1000);
}
// 清除定时器
private stopTimers(): void {
if (this.animTimer !== -1) {
clearInterval(this.animTimer);
this.animTimer = -1;
}
if (this.timeTimer !== -1) {
clearInterval(this.timeTimer);
this.timeTimer = -1;
}
}
aboutToDisappear(): void {
this.stopTimers();
}
// 绘制一帧
private drawFrame(): void {
if (!this.ctx) return;
let ctx = this.ctx;
let w = this.canvasWidth;
let h = this.canvasHeight;
ctx.clearRect(0, 0, w, h);
// 背景
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, w, h);
// 更新并绘制粒子
for (let p of this.particles) {
// 缓动移动
p.x += (p.tx - p.x) * 0.08;
p.y += (p.ty - p.y) * 0.08;
// 绘制小圆点
ctx.beginPath();
ctx.arc(p.x, p.y, 2, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(220, 240, 255, 0.9)';
ctx.fill();
}
// 绘制冒号(固定粒子)
this.drawColon(ctx);
}
// 绘制冒号两点
private drawColon(ctx: CanvasRenderingContext2D): void {
let totalWidth = 4 * 5 * CELL_SIZE + 3 * DIGIT_SPACING + COLON_WIDTH * CELL_SIZE;
let offsetX = (this.canvasWidth - totalWidth) / 2;
let offsetY = (this.canvasHeight - 7 * CELL_SIZE) / 2;
let colonX = offsetX + 2 * 5 * CELL_SIZE + 2 * DIGIT_SPACING + COLON_WIDTH * CELL_SIZE / 2;
let colonY1 = offsetY + 7 * CELL_SIZE * 0.3;
let colonY2 = offsetY + 7 * CELL_SIZE * 0.7;
ctx.fillStyle = 'rgba(220,240,255,0.9)';
ctx.beginPath();
ctx.arc(colonX, colonY1, 3, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(colonX, colonY2, 3, 0, Math.PI * 2);
ctx.fill();
}
build() {
Column() {
Text('粒子时钟')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.margin({ top: 20, bottom: 10 })
.fontColor('#CCCCCC')
Canvas()
.width('100%')
.height(400)
.backgroundColor('#0a0a1a')
.onReady((event) => {
let ctx = event.context as CanvasRenderingContext2D;
this.onCanvasReady(ctx);
})
Text('粒子从随机位置飞向数字点阵,每次时间变化重新组形')
.fontSize(13)
.fontColor('#888')
.width('90%')
.textAlign(TextAlign.Center)
.margin({ top: 10 })
}
.width('100%')
.height('100%')
.backgroundColor('#0a0a1a')
}
}
代码中的粒子初始位置是随机散布的,数字点阵通过预定义的 5×7 数组生成。每组粒子独立更新,冒号用两个固定圆点绘制。动画帧率 30fps,粒子缓动速度 0.08,流畅自然。时间检测每秒进行一次,只更新变化了的数字位。
运行效果
把代码粘贴进 DevEco Studio,Run 到 Pura X Max 模拟器。屏幕变成一片深邃的暗蓝背景,成百上千个白色小光点从四面八方缓缓飞来,逐渐汇聚成当前时间的四个数字,比如“14:25”。光点密集的地方形成了数字的笔画,边缘微微颤抖,像是在呼吸。等待一分钟后,最后一个数字从 5 变成 6 时,对应位置的光点突然“惊醒”,飞离原来的位置,快速移向新的笔画,和其余稳定的光点形成鲜明对比。整个过程没有闪烁,没有延迟,粒子移动轻盈流畅,像看一场微型的灯光秀。

总结
这个粒子时钟把 HarmonyOS Canvas 动画的几个核心技巧串联了起来:
- 粒子系统设计:用简单的对象数组管理大量运动点,每个点有位置和目标,通过缓动公式实现平滑移动。
- 点阵字模的应用:用二维数组存储数字形状,将抽象的时间字符串转化为物理坐标,是嵌入式显示和像素艺术的经典方法。
- 局部更新策略:只更新变化的数字位对应的粒子目标,避免全屏重置,保证了视觉的连贯性和性能。
- 定时器协调:高速动画循环(33ms)负责渲染,低速时间检测(1000ms)负责逻辑更新,两者分工明确,互不干扰。
如果说数字时钟是时间的精准刻度,那这个粒子时钟就是时间的“情绪表达”。它让每一秒的流逝都变得可见、可感,仿佛时间真的在流动,而不是机械地跳动。在 Pura X Max 的屏幕上,这些飞舞的光点把冰冷的数字变成了有温度的动画——这大概就是写代码时,偶尔会冒出来的那种小小的浪漫吧。
更多推荐

所有评论(0)