前言

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

这个想法搁在心里很久了。上周晚上闲着,我打开 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
}

每个粒子知道“我在哪”和“我要去哪”。动画的每一帧,我们遍历所有粒子,把 xtx 靠近一点,yty 靠近一点。这个“靠近一点”不是瞬间跳到目标,而是用一个平滑的缓动公式。最简单的就是线性插值:

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],对应小时十位、小时个位、分钟十位、分钟个位。每次时间变化时,逐个比较新旧数字,只有数字变了的那一组才重新生成目标坐标,并把该组所有粒子的 txty 更新到新坐标上。其余组保持不变。

用一个 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 的屏幕上,这些飞舞的光点把冰冷的数字变成了有温度的动画——这大概就是写代码时,偶尔会冒出来的那种小小的浪漫吧。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐