在 HarmonyOS 中平滑切换“点状粒子”与“图片粒子”(含可运行 ArkTS 示例)


网罗开发 (小红书、快手、视频号同名)

大家好,我是 展菲,目前在上市企业从事人工智能项目研发管理工作,平时热衷于分享各种编程领域的软硬技能知识以及前沿技术,包括iOS、前端、Harmony OS、Java、Python等方向。在移动端开发、鸿蒙开发、物联网、嵌入式、云原生、开源等领域有深厚造诣。

图书作者:《ESP32-C3 物联网工程开发实战》
图书作者:《SwiftUI 入门,进阶与实战》
超级个体:COC上海社区主理人
特约讲师:大学讲师,谷歌亚马逊分享嘉宾
科技博主:华为HDE/HDG

我的博客内容涵盖广泛,主要分享技术教程、Bug解决方案、开发工具使用、前沿科技资讯、产品评测与使用体验 。我特别关注云服务产品评测、AI 产品对比、开发板性能测试以及技术报告,同时也会提供产品优缺点分析、横向对比,并分享技术沙龙与行业大会的参会体验。我的目标是为读者提供有深度、有实用价值的技术洞察与分析。

展菲:您的前沿技术领航员

👋 大家好,我是展菲!

📱 全网搜索"展菲",即可纵览我在各大平台的知识足迹。

📣 公众号"Swift社区",每周定时推送干货满满的技术长文,从新兴框架的剖析到运维实战的复盘,助您技术进阶之路畅通无阻。

💬 微信端添加好友"fzhanfei",与我直接交流,不管是项目瓶颈的求助,还是行业趋势的探讨,随时畅所欲言。

📅 最新动态:2025 年 3 月 17 日

快来加入技术社区,一起挖掘技术的无限潜能,携手迈向数字化新征程!

文章目录

摘要

本文针对在 HarmonyOS(ArkUI / ArkTS)中,如何在运行时在 点状粒子(POINT)图片粒子(IMAGE) 两种渲染模式间切换并实现平滑过渡的问题,给出一种工程化的实现思路、可运行的 ArkTS 示例代码以及调优建议。目标:不重建页面、不重新创建大量对象、避免渲染突兀感,兼顾性能与视觉效果。

本文面向的读者:有 HarmonyOS/DevEco Studio 开发经验,熟悉 ArkTS/ArkUI 基本组件(Canvas、生命周期、事件)的前端/移动开发者。

背景与问题描述

在一些 UI 效果(比如启动页、交互反馈、背景装饰)中,粒子系统既可能用**圆点(点)去表现简洁的粒子,也可能用图片(雪花、星星、Logo)**来表现更精致的粒子。

需求:在同一页面运行时,支持从点状粒子直接切换到图片粒子(或反向),切换期间不能重建页面 ,切换要平滑、不突兀、对性能影响可控。

常见坑:直接把渲染模式一换(比如马上改为 drawImage),会让帧中粒子形态突然变化,造成视觉断层;频繁创建/释放图片资源或粒子对象会导致卡顿或内存波动。

关键设计思路

统一粒子数据结构 + 抽象渲染器 + 以插值(插帧)方式在渲染层平滑过渡,同时复用粒子实例与图片资源。

关键点:

  • 统一粒子模型(位置、速度、大小、透明度、种子/形态因子等),渲染层只关心如何把这个模型绘制为点或图片。这样切换只是改变渲染方式,而非重建粒子数据。
  • 切换时通过参数插值(比如从 radius -> spriteScale,从 color -> 图片 alpha)逐帧过渡,或做 cross-fade(交叉淡入淡出)。
  • 图片资源(sprite)预加载并缓存,避免切换瞬间加载卡帧。
  • 渲染使用单一 Canvas 渲染循环(requestAnimationFrame / 系统绘制回调),避免重建组件。

整体架构

  1. ParticleModel(统一数据):保持粒子位置、速度、baseSize、currentSize、baseAlpha、currentAlpha、life 等。
  2. Renderer (渲染器抽象):支持 renderPoint(ctx, particle)renderImage(ctx, particle, image) 两种渲染方法。渲染器会读取粒子当前插值属性。
  3. TransitionController :当用户触发模式切换(POINT <-> IMAGE),由它驱动一个时间窗口(例如 300ms~800ms)逐帧更新粒子 currentSize/currentAlpha/shapeMix 等插值值。
  4. ResourceManager:负责图像预加载、缓存和销毁;确保在切换到 IMAGE 时纹理已经就绪。
  5. 主渲染循环:框架 Canvas 回调或 requestAnimationFrame,单循环更新物理(位置/碰撞/生命)并绘制当前帧。

可运行 ArkTS Demo

说明:下面给出在 DevEco Studio/ArkTS 环境下的一个完整 Canvas 粒子组件示例。把下面代码放到一个页面组件中(例如 pages/index 下的 ParticleCanvas.tsx),并在 index.json / index.hml 中引用该组件(示例末尾有运行提示)。代码已尽量保持自包含并注释详尽,便于直接粘贴运行和调试。

ts 复制代码
// 文件:ParticleCanvas.tsx
// ArkTS + ArkUI 风格示例(TypeScript)
// 注意:实际工程中根据你的项目结构把组件注册/引用到页面中。

import { Component, Property, State } from '@ohos:staging'; // 伪装导入,按你的 ArkTS 模板调整

type Mode = 'POINT' | 'IMAGE';

interface Particle {
  x: number;
  y: number;
  vx: number;
  vy: number;
  baseSize: number;       // 基础尺寸
  currentSize: number;    // 用于插值
  baseAlpha: number;      // 基础透明度
  currentAlpha: number;   // 用于插值
  life: number;           // 剩余生命
  maxLife: number;        // 初始生命
  angle: number;          // 旋转角
  shapeMix: number;       // 0 = fully point, 1 = fully image
}

@Component
export default class ParticleCanvas {
  @State() mode: Mode = 'POINT';
  private canvasWidth = 720;
  private canvasHeight = 1280;
  private particles: Particle[] = [];
  private rafId: number | null = null;
  private lastTime = 0;
  private transitionStart = 0;
  private transitionDuration = 500; // ms
  private transitioning = false;
  private targetMode: Mode = 'POINT';
  private sprite: any = null; // image resource
  private spriteLoaded = false;

  build() {
    // 在 ArkUI 中,Canvas 组件通常通过 onDraw 回调绘制
    return (
      <div>
        <canvas id="particleCanvas" width={this.canvasWidth} height={this.canvasHeight}
          onDraw={this.onDraw.bind(this)} />
        <div className="controls">
          <button onClick={() => this.switchMode('POINT')}>点状</button>
          <button onClick={() => this.switchMode('IMAGE')}>图片</button>
        </div>
      </div>
    );
  }

  onInit() {
    this.initParticles(200);
    this.preloadSprite('/resources/snowflake.png'); // 放在工程资源中
    this.startLoop();
  }

  onDestroy() {
    this.stopLoop();
  }

  initParticles(count: number) {
    this.particles = [];
    for (let i = 0; i < count; i++) {
      const p: Particle = {
        x: Math.random() * this.canvasWidth,
        y: Math.random() * this.canvasHeight,
        vx: (Math.random() - 0.5) * 0.6,
        vy: (Math.random() - 0.2) * 0.6,
        baseSize: 1 + Math.random() * 4,
        currentSize: 1 + Math.random() * 4,
        baseAlpha: 0.4 + Math.random() * 0.6,
        currentAlpha: 0.4 + Math.random() * 0.6,
        life: 3 + Math.random() * 5,
        maxLife: 3 + Math.random() * 5,
        angle: Math.random() * Math.PI * 2,
        shapeMix: 0 // start as point
      };
      this.particles.push(p);
    }
  }

  preloadSprite(src: string) {
    // 构造 Image 并缓存(示意)
    const img = new Image();
    img.src = src;
    img.onload = () => {
      this.sprite = img;
      this.spriteLoaded = true;
      console.log('sprite loaded');
    };
    img.onerror = () => {
      console.warn('sprite load failed', src);
      this.spriteLoaded = false;
    };
  }

  switchMode(mode: Mode) {
    if (mode === this.mode) return;
    this.targetMode = mode;
    this.transitioning = true;
    this.transitionStart = Date.now();
    // 如果切换到 IMAGE,确保资源已加载
    if (mode === 'IMAGE' && !this.spriteLoaded) {
      // 如果还没加载,你可以先启动淡出 -> 等待图片加载完后淡入,或者直接载入并在加载完成后触发淡入
      this.preloadSprite('/resources/snowflake.png');
    }
  }

  // 渲染循环(用框架的Canvas onDraw触发时可能会不同,示例用 requestAnimationFrame 驱动)
  startLoop() {
    this.lastTime = Date.now();
    const loop = () => {
      const now = Date.now();
      const dt = (now - this.lastTime) / 1000; // s
      this.lastTime = now;
      this.update(dt);
      // 触发 Canvas 重绘:在 ArkUI 中你可能需要调用 invalidate 或者让框架重新触发 onDraw
      const canvas = document.getElementById('particleCanvas') as HTMLCanvasElement | null;
      if (canvas) {
        const ctx = (canvas.getContext('2d') as CanvasRenderingContext2D);
        if (ctx) this.draw(ctx);
      }
      this.rafId = requestAnimationFrame(loop);
    };
    this.rafId = requestAnimationFrame(loop);
  }

  stopLoop() {
    if (this.rafId) cancelAnimationFrame(this.rafId);
  }

  update(dt: number) {
    // update physics
    for (const p of this.particles) {
      p.x += p.vx * dt * 60; // scale
      p.y += p.vy * dt * 60;
      p.life -= dt;
      if (p.y > this.canvasHeight + 20) {
        p.y = -10;
      }
      if (p.x < -20) p.x = this.canvasWidth + 20;
      if (p.x > this.canvasWidth + 20) p.x = -20;
      if (p.life <= 0) {
        // respawn
        p.x = Math.random() * this.canvasWidth;
        p.y = -10;
        p.vx = (Math.random() - 0.5) * 0.6;
        p.vy = 0.1 + Math.random() * 0.5;
        p.life = p.maxLife = 3 + Math.random() * 5;
      }
    }

    // manage transitions
    if (this.transitioning) {
      const t = (Date.now() - this.transitionStart) / this.transitionDuration;
      const tt = Math.min(1, Math.max(0, t));
      const targetMix = this.targetMode === 'IMAGE' ? 1 : 0;
      // 对所有粒子逐帧更新 shapeMix / size / alpha
      for (const p of this.particles) {
        // ease (smoothstep)
        const eased = tt * tt * (3 - 2 * tt);
        p.shapeMix = p.shapeMix + (targetMix - p.shapeMix) * 0.2 + (eased - p.shapeMix) * 0; // 简单插值
        // size 从 baseSize 缩放到 imageSize(比如 *3)
        p.currentSize = p.baseSize * (1 + p.shapeMix * 3);
        // alpha 调整,点 -> 更亮或更暗取决于需求
        p.currentAlpha = p.baseAlpha * (1 + p.shapeMix * 0.2);
      }
      if (tt >= 1) {
        this.transitioning = false;
        this.mode = this.targetMode;
      }
    }
  }

  draw(ctx: CanvasRenderingContext2D) {
    // 清屏
    ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);

    for (const p of this.particles) {
      if ((this.mode === 'POINT' && !this.transitioning) || p.shapeMix < 0.5) {
        // 更偏向点状时,混合 draw
        const mix = 1 - p.shapeMix; // 1: point, 0: image
        ctx.globalAlpha = p.currentAlpha * mix;
        ctx.beginPath();
        ctx.arc(p.x, p.y, p.currentSize, 0, Math.PI * 2);
        ctx.fillStyle = 'white';
        ctx.fill();
      }

      if ((this.mode === 'IMAGE' && !this.transitioning) || p.shapeMix > 0.01) {
        const mix = p.shapeMix; // 0: point, 1: image
        if (this.spriteLoaded && this.sprite) {
          // draw image with alpha
          ctx.save();
          ctx.translate(p.x, p.y);
          ctx.rotate(p.angle);
          const drawSize = p.currentSize * 4; // 图片尺寸依赖于粒子大小映射
          ctx.globalAlpha = p.currentAlpha * mix;
          ctx.drawImage(this.sprite, -drawSize/2, -drawSize/2, drawSize, drawSize);
          ctx.restore();
        } else {
          // 如果图片未加载,可用点逐渐替代(fade-in fallback)
          ctx.globalAlpha = p.currentAlpha * mix * 0.6;
          ctx.beginPath();
          ctx.arc(p.x, p.y, p.currentSize * (1 + mix * 2), 0, Math.PI * 2);
          ctx.fillStyle = 'rgba(255,255,255,0.8)';
          ctx.fill();
        }
      }

      ctx.globalAlpha = 1.0; // reset
    }
  }
}

代码解析(逐段说明)

  • Particle 数据结构:只包含物理与可插值的渲染属性,切换时只更新 shapeMix/currentSize/currentAlpha,这样不会频繁创建新对象。
  • preloadSprite:提前加载图片到内存并缓存;切换前应确保 spriteLoaded === true 或者在加载完成后再做淡入。
  • switchMode:仅触发一次过渡并记录目标模式 targetMode。过渡通过 transitioningtransitionStart 驱动,每帧在 update 中计算插值。
  • update:包含粒子物理与过渡插值。物理与渲染分离,便于控制。插值使用了简单平滑函数(smoothstep)来避免线性突兀。
  • draw:渲染时根据 shapeMix 在点与图片之间混合渲染。在图片不可用时提供点状降级,避免空帧或闪烁。注意 globalAlpha 的设置遵循混合策略。

可选的更高级平滑策略

  1. 双层交叉淡入(Cross-fade layers):保持两层渲染缓冲(点层与图像层),在过渡期间对两层分别调整 alpha(点层 alpha 从 1 -> 0,图像层 alpha 从 0 -> 1)。优点:更可控的视觉 blend;缺点:内存和渲染开销稍高。
  2. 粒子纹理逐步替换 :给每个粒子分配 age,按时间把粒子分批从点替换为图片(分批替换比整体同时替换看起来更自然)。
  3. 使用 GPU 加速(WebGL / OpenGL ES):如果对性能和大量粒子有要求,使用 GPU shader 做混合、插值和实例化渲染会更顺畅;在 HarmonyOS 中可以通过 Native 层或 OpenGL ES 渲染实现(复杂度更高)。

性能与工程建议

  • 资源预加载:图片必须预先加载并复用,不要在切换时同步加载大图。
  • 粒子数量控制:移动设备受限,尽量在 200~1000 粒子间调优,结合帧率目标(30/60 FPS)。
  • 对象复用:避免在渲染循环中频繁 new / delete,复用数组和对象。
  • 降级策略:当检测到低帧率时,可以降低粒子数或自动切换为点状粒子(点状更轻量)。
  • 过渡时长:体验上 300ms~700ms 是常见区间;更长会显得拖沓,更短则可能看起来突兀。

与实际场景结合的示例建议

  • 启动页:用点状粒子作为初始效果,logo 出场时切换为图片粒子(logo 形状的 sprite),通过 cross-fade 实现 logo 逐步形成的感觉。
  • 节日主题:白天为点状雪花(轻量),在用户切换到节日主题或开启"节日模式"时平滑替换为雪花图片粒子。
  • 互动反馈:点击区域触发波次切换,让粒子从点到图片逐步波及全屏,配合声效更有沉浸感。

常见问题 Q&A

Q:切换时会不会出现图片没加载完成导致闪烁?

A:会的,所以必须预加载并检测 spriteLoaded。可以在图片未加载时做淡出占位(点状)以避免闪烁。

Q:为什么不直接把粒子重建为图片粒子?

A:重建会导致短时间内大量对象创建以及视觉突变(从小点突然变大图片),并且在页面不重建的要求下更难实现流畅切换。

Q:如何平衡性能与视觉?

A:使用分层替换、分批过渡以及 particle count 动态调节。必要时考虑使用 GPU 加速渲染。

结论

要在 HarmonyOS 中实现不重建页面、平滑地在点状粒子和图片粒子间切换,关键是在渲染层做插值 / 混合复用粒子数据结构做好资源预加载 。工程上可以从简单的 shapeMix 插值实现起步,视需求再扩展到双层 cross-fade 或 GPU 实例化方法。

如果你想,我可以:

  • 把上面的 ArkTS 示例改成更适配你工程的完整页面文件(包含 index.hmlindex.json、资源路径与启动说明)。
  • 将示例改写为使用 OpenGL ES / Native 渲染以支持上千粒子的高性能方案。

告诉我你想要哪种后续产物,我会把相应代码一次性生成到项目级别的文件结构中供你直接粘贴使用。

相关推荐
猫林老师6 小时前
HarmonyOS多媒体开发:自定义相机与音频播放器实战
数码相机·音视频·harmonyos
逻极6 小时前
HarmonyOS 5 鸿蒙应用签名机制与安全开发实战指南
harmonyos
zhuweisky12 小时前
实现一个纯血鸿蒙版(HarmonyOS)的聊天Demo,并可与其它PC、手机端互通!
harmonyos·im·聊天软件
多测师_王sir12 小时前
鸿蒙hdc命令【杭州多测师】
华为·harmonyos
一点七加一13 小时前
Harmony鸿蒙开发0基础入门到精通Day01--JavaScript篇
开发语言·javascript·华为·typescript·ecmascript·harmonyos
那年窗外下的雪.14 小时前
鸿蒙ArkUI布局与样式进阶(十二)——自定义TabBar + class类机制全解析(含手机商城底部导航案例)
开发语言·前端·javascript·华为·智能手机·harmonyos·arkui
赵健zj17 小时前
鸿蒙 emitter 和 eventHub 的区别
华为·harmonyos
yenggd17 小时前
华为多级m-lag简单配置案例
网络·华为
qq_3863226918 小时前
华为网路设备学习-34(BGP协议 九)BGP路由 选路规则二
服务器·学习·华为