HarmonyOS APP开发:天气特效与动态背景渲染

举报
Jack20 发表于 2026/06/22 21:17:19 2026/06/22
【摘要】 HarmonyOS APP开发:天气特效与动态背景渲染📌 核心要点:构建完整天气特效系统,实现雨雪粒子、云层渲染、闪电效果、天气状态切换动画,打造沉浸式天气App动态背景。 一、背景与动机打开手机上的天气App,如果只是干巴巴地显示"晴 26°C",你会觉得这App有灵魂吗?大概率不会。但如果背景是缓缓飘动的白云、偶尔掠过的飞鸟、阳光透过云层洒下的光斑——哪怕温度数字一模一样,你的感受也...

HarmonyOS APP开发:天气特效与动态背景渲染

📌 核心要点:构建完整天气特效系统,实现雨雪粒子、云层渲染、闪电效果、天气状态切换动画,打造沉浸式天气App动态背景。


一、背景与动机

打开手机上的天气App,如果只是干巴巴地显示"晴 26°C",你会觉得这App有灵魂吗?大概率不会。但如果背景是缓缓飘动的白云、偶尔掠过的飞鸟、阳光透过云层洒下的光斑——哪怕温度数字一模一样,你的感受也完全不同。

这就是天气特效的魅力:它不是功能,但它是体验

一个好的天气App,动态背景至少要覆盖以下场景:

  • ☀️ 晴天:蓝天白云,阳光光斑,偶尔飞过的鸟
  • 🌧️ 雨天:雨滴粒子,水面涟漪,灰暗天空
  • ❄️ 雪天:雪花飘落,地面积雪,朦胧雾气
  • ⛈️ 雷暴:闪电划过,雷声震动,暴雨倾盆
  • 🌫️ 雾天:能见度降低,朦胧效果,远处模糊

这些效果单独实现都不难,但要把它们整合成一个统一的天气特效系统,支持平滑切换、状态管理、性能可控——这就需要系统化的设计了。

今天咱们就从系统架构开始,逐个实现雨、雪、云、闪电四大核心特效,最后组装成一个完整的天气动态背景。


二、核心原理

2.1 天气特效系统架构

天气特效系统的核心是分层渲染——不同天气元素在不同的层上绘制,互不干扰,又相互叠加。

graph TD
    A[天气特效系统]:::primary --> B[天空层 Sky Layer]:::info
    A --> C[云层 Cloud Layer]:::info
    A --> D[降水层 Precipitation Layer]:::info
    A --> E[闪电层 Lightning Layer]:::warning
    A --> F[雾气层 Fog Layer]:::info
    A --> G[前景层 Foreground Layer]:::info
    
    B --> B1[天空渐变色]:::success
    B --> B2[太阳/月亮]:::success
    
    C --> C1[云朵粒子]:::success
    C --> C2[云层密度]:::success
    
    D --> D1[雨滴粒子]:::success
    D --> D2[雪花粒子]:::success
    
    E --> E1[闪电路径]:::error
    E --> E2[光照闪烁]:::error
    
    F --> F1[雾气浓度]:::success
    F --> F2[能见度]:::success
    
    G --> G1[地面涟漪]:::success
    G --> G2[积雪效果]:::success
    
    classDef primary fill:#4CAF50,stroke:#388E3C,color:#fff
    classDef warning fill:#FF9800,stroke:#F57C00,color:#fff
    classDef info fill:#2196F3,stroke:#1976D2,color:#fff
    classDef success fill:#9C27B0,stroke:#7B1FA2,color:#fff
    classDef error fill:#F44336,stroke:#D32F2F,color:#fff

2.2 天气状态管理

天气不是静态的,它会变化。从晴天到雨天,从雨天到雪天——切换过程需要平滑过渡,不能"啪"一下就变了。

graph LR
    A[晴天 Sunny]:::primary -->|降温+增湿| B[多云 Cloudy]:::info
    B -->|继续增湿| C[雨天 Rainy]:::info
    C -->|降温| D[雪天 Snowy]:::info
    C -->|气压骤变| E[雷暴 Storm]:::error
    B -->|能见度降低| F[雾天 Foggy]:::warning
    D --> B
    E --> C
    F --> B
    
    classDef primary fill:#4CAF50,stroke:#388E3C,color:#fff
    classDef warning fill:#FF9800,stroke:#F57C00,color:#fff
    classDef info fill:#2196F3,stroke:#1976D2,color:#fff
    classDef success fill:#9C27B0,stroke:#7B1FA2,color:#fff
    classDef error fill:#F44336,stroke:#D32F2F,color:#fff

2.3 粒子系统基础参数

不同天气效果的粒子参数差异很大:

参数 雨滴 雪花 云朵
发射方向 斜向下 飘落(带水平摆动) 水平移动
速度 8-15 px/帧 1-3 px/帧 0.3-0.8 px/帧
大小 1-2 px(细长) 3-8 px(圆形) 50-150 px(椭圆)
生命 1-2秒 3-6秒 10-30秒
数量 100-200 50-100 5-15
透明度 0.3-0.6 0.5-0.9 0.4-0.8

三、代码实战

3.1 基础用法——雨滴粒子效果

先从最常见的雨天效果开始。

// 雨滴粒子效果
interface RainDrop {
  x: number
  y: number
  speed: number
  length: number     // 雨滴长度
  opacity: number
  windOffset: number // 风偏移
}

@Component
struct RainEffectDemo {
  private settings: RenderingContextSettings = new RenderingContextSettings(true)
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
  private drops: RainDrop[] = []
  private animationId: number = -1
  private canvasWidth: number = 400
  private canvasHeight: number = 700

  // 初始化雨滴
  private initRainDrops(): void {
    this.drops = []
    for (let i = 0; i < 150; i++) {
      this.drops.push(this.createRainDrop())
    }
  }

  // 创建单个雨滴
  private createRainDrop(): RainDrop {
    return {
      x: Math.random() * this.canvasWidth,
      y: Math.random() * this.canvasHeight,
      speed: Math.random() * 8 + 8,          // 8-16的速度
      length: Math.random() * 15 + 10,        // 10-25的长度
      opacity: Math.random() * 0.3 + 0.3,     // 0.3-0.6的透明度
      windOffset: Math.random() * 2 + 1       // 风向偏移
    }
  }

  // 更新雨滴
  private updateRainDrops(): void {
    for (const drop of this.drops) {
      drop.y += drop.speed
      drop.x += drop.windOffset  // 风吹雨滴偏移

      // 超出屏幕底部则重置到顶部
      if (drop.y > this.canvasHeight) {
        drop.y = -drop.length
        drop.x = Math.random() * this.canvasWidth
      }
      // 超出屏幕右侧则重置到左侧
      if (drop.x > this.canvasWidth) {
        drop.x = 0
      }
    }
  }

  // 渲染雨滴
  private renderRainDrops(): void {
    for (const drop of this.drops) {
      this.context.beginPath()
      this.context.moveTo(drop.x, drop.y)
      // 雨滴是斜线,受风影响
      this.context.lineTo(
        drop.x + drop.windOffset * (drop.length / drop.speed),
        drop.y + drop.length
      )
      this.context.strokeStyle = `rgba(174,194,224,${drop.opacity})`
      this.context.lineWidth = 1.5
      this.context.stroke()
    }
  }

  // 绘制雨天背景
  private renderRainBackground(): void {
    // 灰暗天空渐变
    const gradient = this.context.createLinearGradient(0, 0, 0, this.canvasHeight)
    gradient.addColorStop(0, '#2C3E50')
    gradient.addColorStop(0.4, '#34495E')
    gradient.addColorStop(1, '#1a252f')
    this.context.fillStyle = gradient
    this.context.fillRect(0, 0, this.canvasWidth, this.canvasHeight)
  }

  // 动画循环
  private startAnimation(): void {
    this.initRainDrops()
    const animate = () => {
      this.renderRainBackground()
      this.updateRainDrops()
      this.renderRainDrops()
      this.animationId = requestAnimationFrame(animate)
    }
    animate()
  }

  build() {
    Column() {
      Canvas(this.context)
        .width(400)
        .height(700)
        .borderRadius(16)
        .onReady(() => {
          this.startAnimation()
        })

      Text('🌧️ 雨天效果')
        .fontSize(16)
        .fontColor('#999999')
        .margin({ top: 8 })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#1a1a2e')
  }

  aboutToDisappear() {
    if (this.animationId !== -1) {
      cancelAnimationFrame(this.animationId)
    }
  }
}

3.2 进阶用法——雪花飘落与云层渲染

雪花和雨滴最大的区别在于:雪花会"飘",不是直线下落,而是左右摆动。云层则是大块的半透明椭圆,缓慢移动。

// 雪花粒子 + 云层渲染
interface SnowFlake {
  x: number
  y: number
  radius: number
  speed: number
  drift: number       // 水平漂移幅度
  driftSpeed: number  // 漂移速度
  phase: number       // 漂移相位
  opacity: number
}

interface Cloud {
  x: number
  y: number
  width: number
  height: number
  speed: number
  opacity: number
}

@Component
struct SnowCloudDemo {
  private settings: RenderingContextSettings = new RenderingContextSettings(true)
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
  private snowflakes: SnowFlake[] = []
  private clouds: Cloud[] = []
  private animationId: number = -1
  private time: number = 0
  private canvasWidth: number = 400
  private canvasHeight: number = 700

  // 初始化雪花
  private initSnowflakes(): void {
    this.snowflakes = []
    for (let i = 0; i < 80; i++) {
      this.snowflakes.push({
        x: Math.random() * this.canvasWidth,
        y: Math.random() * this.canvasHeight,
        radius: Math.random() * 4 + 2,
        speed: Math.random() * 1.5 + 0.5,
        drift: Math.random() * 30 + 10,
        driftSpeed: Math.random() * 0.02 + 0.01,
        phase: Math.random() * Math.PI * 2,
        opacity: Math.random() * 0.4 + 0.5
      })
    }
  }

  // 初始化云层
  private initClouds(): void {
    this.clouds = []
    for (let i = 0; i < 6; i++) {
      this.clouds.push({
        x: Math.random() * this.canvasWidth * 1.5 - this.canvasWidth * 0.25,
        y: Math.random() * 200 + 30,
        width: Math.random() * 120 + 80,
        height: Math.random() * 40 + 25,
        speed: Math.random() * 0.3 + 0.1,
        opacity: Math.random() * 0.4 + 0.3
      })
    }
  }

  // 更新雪花
  private updateSnowflakes(): void {
    for (const flake of this.snowflakes) {
      flake.y += flake.speed
      // 正弦漂移:左右摆动
      flake.x += Math.sin(this.time * flake.driftSpeed + flake.phase) * 0.5

      if (flake.y > this.canvasHeight + flake.radius) {
        flake.y = -flake.radius
        flake.x = Math.random() * this.canvasWidth
      }
    }
  }

  // 更新云层
  private updateClouds(): void {
    for (const cloud of this.clouds) {
      cloud.x += cloud.speed
      // 超出右侧则从左侧重新进入
      if (cloud.x > this.canvasWidth + cloud.width) {
        cloud.x = -cloud.width
        cloud.y = Math.random() * 200 + 30
      }
    }
  }

  // 渲染云层
  private renderClouds(): void {
    for (const cloud of this.clouds) {
      // 用多个椭圆组合成云朵形状
      this.context.save()
      this.context.globalAlpha = cloud.opacity
      this.context.fillStyle = '#E8E8E8'

      // 主体
      this.context.beginPath()
      this.context.ellipse(cloud.x, cloud.y, cloud.width / 2, cloud.height / 2, 0, 0, Math.PI * 2)
      this.context.fill()

      // 上部凸起
      this.context.beginPath()
      this.context.ellipse(cloud.x - cloud.width * 0.2, cloud.y - cloud.height * 0.3,
        cloud.width * 0.3, cloud.height * 0.35, 0, 0, Math.PI * 2)
      this.context.fill()

      this.context.beginPath()
      this.context.ellipse(cloud.x + cloud.width * 0.15, cloud.y - cloud.height * 0.25,
        cloud.width * 0.25, cloud.height * 0.3, 0, 0, Math.PI * 2)
      this.context.fill()

      this.context.restore()
    }
  }

  // 渲染雪花
  private renderSnowflakes(): void {
    for (const flake of this.snowflakes) {
      this.context.beginPath()
      this.context.arc(flake.x, flake.y, flake.radius, 0, Math.PI * 2)
      this.context.fillStyle = `rgba(255,255,255,${flake.opacity})`
      this.context.fill()
    }
  }

  // 渲染雪天背景
  private renderSnowBackground(): void {
    const gradient = this.context.createLinearGradient(0, 0, 0, this.canvasHeight)
    gradient.addColorStop(0, '#B0BEC5')
    gradient.addColorStop(0.3, '#CFD8DC')
    gradient.addColorStop(1, '#ECEFF1')
    this.context.fillStyle = gradient
    this.context.fillRect(0, 0, this.canvasWidth, this.canvasHeight)

    // 地面积雪
    this.context.fillStyle = '#F5F5F5'
    this.context.beginPath()
    this.context.moveTo(0, this.canvasHeight)
    // 波浪形积雪
    for (let x = 0; x <= this.canvasWidth; x += 20) {
      const snowHeight = this.canvasHeight - 30 + Math.sin(x * 0.03 + this.time * 0.001) * 8
      this.context.lineTo(x, snowHeight)
    }
    this.context.lineTo(this.canvasWidth, this.canvasHeight)
    this.context.closePath()
    this.context.fill()
  }

  // 动画循环
  private startAnimation(): void {
    this.initSnowflakes()
    this.initClouds()
    const animate = () => {
      this.time++
      this.renderSnowBackground()
      this.updateClouds()
      this.renderClouds()
      this.updateSnowflakes()
      this.renderSnowflakes()
      this.animationId = requestAnimationFrame(animate)
    }
    animate()
  }

  build() {
    Column() {
      Canvas(this.context)
        .width(400)
        .height(700)
        .borderRadius(16)
        .onReady(() => {
          this.startAnimation()
        })

      Text('❄️ 雪天效果')
        .fontSize(16)
        .fontColor('#999999')
        .margin({ top: 8 })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#1a1a2e')
  }

  aboutToDisappear() {
    if (this.animationId !== -1) {
      cancelAnimationFrame(this.animationId)
    }
  }
}

3.3 完整示例——天气App动态背景

整合所有特效,加上闪电效果和天气切换动画,打造完整的天气动态背景。

// 完整天气特效系统
type WeatherType = 'sunny' | 'rainy' | 'snowy' | 'storm'

interface WeatherParticle {
  x: number
  y: number
  vx: number
  vy: number
  size: number
  opacity: number
  life: number
  type: 'rain' | 'snow'
}

interface LightningBolt {
  segments: Array<{ x1: number; y1: number; x2: number; y2: number }>
  opacity: number
  life: number
}

@Component
struct WeatherEffectDemo {
  private settings: RenderingContextSettings = new RenderingContextSettings(true)
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
  private particles: WeatherParticle[] = []
  private clouds: Array<{ x: number; y: number; w: number; h: number; speed: number; opacity: number }> = []
  private lightnings: LightningBolt[] = []
  private animationId: number = -1
  private time: number = 0
  private canvasWidth: number = 400
  private canvasHeight: number = 700

  @State currentWeather: WeatherType = 'sunny'
  @State targetWeather: WeatherType = 'sunny'
  @State transitionProgress: number = 1  // 0=正在切换, 1=切换完成
  @State flashOpacity: number = 0  // 闪电闪光

  // 天气背景色配置
  private weatherColors: Record<WeatherType, { top: string; mid: string; bottom: string }> = {
    sunny: { top: '#4FC3F7', mid: '#81D4FA', bottom: '#B3E5FC' },
    rainy: { top: '#2C3E50', mid: '#34495E', bottom: '#1a252f' },
    snowy: { top: '#B0BEC5', mid: '#CFD8DC', bottom: '#ECEFF1' },
    storm: { top: '#1a1a2e', mid: '#16213e', bottom: '#0f3460' }
  }

  // 切换天气
  private switchWeather(weather: WeatherType): void {
    if (weather === this.currentWeather && this.transitionProgress >= 1) return
    this.targetWeather = weather
    this.transitionProgress = 0
    // 清空粒子,重新生成
    this.particles = []
    this.initParticles(weather)
  }

  // 初始化粒子
  private initParticles(weather: WeatherType): void {
    if (weather === 'rainy' || weather === 'storm') {
      const count = weather === 'storm' ? 200 : 120
      for (let i = 0; i < count; i++) {
        this.particles.push({
          x: Math.random() * this.canvasWidth,
          y: Math.random() * this.canvasHeight,
          vx: weather === 'storm' ? Math.random() * 4 + 2 : Math.random() * 2 + 1,
          vy: Math.random() * 10 + 8,
          size: Math.random() * 1.5 + 0.5,
          opacity: Math.random() * 0.3 + 0.3,
          life: 1,
          type: 'rain'
        })
      }
    } else if (weather === 'snowy') {
      for (let i = 0; i < 80; i++) {
        this.particles.push({
          x: Math.random() * this.canvasWidth,
          y: Math.random() * this.canvasHeight,
          vx: Math.sin(Math.random() * Math.PI * 2) * 0.5,
          vy: Math.random() * 1.5 + 0.5,
          size: Math.random() * 4 + 2,
          opacity: Math.random() * 0.4 + 0.5,
          life: 1,
          type: 'snow'
        })
      }
    }
  }

  // 初始化云层
  private initClouds(): void {
    this.clouds = []
    for (let i = 0; i < 8; i++) {
      this.clouds.push({
        x: Math.random() * this.canvasWidth * 1.5 - this.canvasWidth * 0.25,
        y: Math.random() * 180 + 20,
        w: Math.random() * 120 + 60,
        h: Math.random() * 35 + 20,
        speed: Math.random() * 0.3 + 0.1,
        opacity: Math.random() * 0.4 + 0.2
      })
    }
  }

  // 生成闪电
  private generateLightning(): void {
    const startX = Math.random() * this.canvasWidth * 0.6 + this.canvasWidth * 0.2
    const segments: LightningBolt['segments'] = []
    let x = startX
    let y = 0
    const endY = Math.random() * 200 + 200

    while (y < endY) {
      const nextX = x + (Math.random() - 0.5) * 60
      const nextY = y + Math.random() * 40 + 15
      segments.push({ x1: x, y1: y, x2: nextX, y2: nextY })

      // 分支闪电(30%概率)
      if (Math.random() < 0.3) {
        const branchX = nextX + (Math.random() - 0.5) * 80
        const branchY = nextY + Math.random() * 60 + 20
        segments.push({ x1: nextX, y1: nextY, x2: branchX, y2: branchY })
      }

      x = nextX
      y = nextY
    }

    this.lightnings.push({
      segments,
      opacity: 1,
      life: 1
    })

    // 触发全屏闪光
    this.flashOpacity = 0.8
  }

  // 更新粒子
  private updateParticles(): void {
    for (const p of this.particles) {
      p.x += p.vx
      p.y += p.vy

      if (p.type === 'snow') {
        // 雪花左右摆动
        p.vx = Math.sin(this.time * 0.02 + p.x * 0.01) * 0.5
      }

      // 超出屏幕重置
      if (p.y > this.canvasHeight) {
        p.y = -p.size
        p.x = Math.random() * this.canvasWidth
      }
      if (p.x > this.canvasWidth) p.x = 0
      if (p.x < 0) p.x = this.canvasWidth
    }
  }

  // 更新云层
  private updateClouds(): void {
    for (const cloud of this.clouds) {
      cloud.x += cloud.speed
      if (cloud.x > this.canvasWidth + cloud.w) {
        cloud.x = -cloud.w
      }
    }
  }

  // 更新闪电
  private updateLightnings(): void {
    for (let i = this.lightnings.length - 1; i >= 0; i--) {
      this.lightnings[i].life -= 0.05
      this.lightnings[i].opacity = this.lightnings[i].life
      if (this.lightnings[i].life <= 0) {
        this.lightnings.splice(i, 1)
      }
    }
    // 闪光衰减
    this.flashOpacity *= 0.85
    if (this.flashOpacity < 0.01) this.flashOpacity = 0
  }

  // 渲染背景
  private renderBackground(): void {
    const colors = this.weatherColors[this.currentWeather]
    const gradient = this.context.createLinearGradient(0, 0, 0, this.canvasHeight)
    gradient.addColorStop(0, colors.top)
    gradient.addColorStop(0.5, colors.mid)
    gradient.addColorStop(1, colors.bottom)
    this.context.fillStyle = gradient
    this.context.fillRect(0, 0, this.canvasWidth, this.canvasHeight)
  }

  // 渲染太阳(晴天)
  private renderSun(): void {
    if (this.currentWeather !== 'sunny') return
    const sunX = 320
    const sunY = 80
    // 太阳光晕
    const glowGradient = this.context.createRadialGradient(sunX, sunY, 20, sunX, sunY, 100)
    glowGradient.addColorStop(0, 'rgba(255,235,59,0.4)')
    glowGradient.addColorStop(0.5, 'rgba(255,193,7,0.1)')
    glowGradient.addColorStop(1, 'rgba(255,152,0,0)')
    this.context.fillStyle = glowGradient
    this.context.fillRect(sunX - 100, sunY - 100, 200, 200)

    // 太阳本体
    this.context.beginPath()
    this.context.arc(sunX, sunY, 30, 0, Math.PI * 2)
    this.context.fillStyle = '#FFF176'
    this.context.fill()
  }

  // 渲染云层
  private renderClouds(): void {
    const isDark = this.currentWeather === 'rainy' || this.currentWeather === 'storm'
    for (const cloud of this.clouds) {
      this.context.save()
      this.context.globalAlpha = cloud.opacity * (isDark ? 1.5 : 1)
      this.context.fillStyle = isDark ? '#4a5568' : '#E8E8E8'

      this.context.beginPath()
      this.context.ellipse(cloud.x, cloud.y, cloud.w / 2, cloud.h / 2, 0, 0, Math.PI * 2)
      this.context.fill()

      this.context.beginPath()
      this.context.ellipse(cloud.x - cloud.w * 0.2, cloud.y - cloud.h * 0.3,
        cloud.w * 0.3, cloud.h * 0.35, 0, 0, Math.PI * 2)
      this.context.fill()

      this.context.beginPath()
      this.context.ellipse(cloud.x + cloud.w * 0.15, cloud.y - cloud.h * 0.25,
        cloud.w * 0.25, cloud.h * 0.3, 0, 0, Math.PI * 2)
      this.context.fill()

      this.context.restore()
    }
  }

  // 渲染降水粒子
  private renderPrecipitation(): void {
    for (const p of this.particles) {
      if (p.type === 'rain') {
        this.context.beginPath()
        this.context.moveTo(p.x, p.y)
        this.context.lineTo(p.x + p.vx * 2, p.y + p.vy * 1.5)
        this.context.strokeStyle = `rgba(174,194,224,${p.opacity})`
        this.context.lineWidth = p.size
        this.context.stroke()
      } else {
        this.context.beginPath()
        this.context.arc(p.x, p.y, p.size, 0, Math.PI * 2)
        this.context.fillStyle = `rgba(255,255,255,${p.opacity})`
        this.context.fill()
      }
    }
  }

  // 渲染闪电
  private renderLightnings(): void {
    for (const bolt of this.lightnings) {
      this.context.save()
      this.context.globalAlpha = bolt.opacity
      this.context.strokeStyle = '#E1F5FE'
      this.context.lineWidth = 3
      this.context.shadowColor = '#42A5F5'
      this.context.shadowBlur = 20

      for (const seg of bolt.segments) {
        this.context.beginPath()
        this.context.moveTo(seg.x1, seg.y1)
        this.context.lineTo(seg.x2, seg.y2)
        this.context.stroke()
      }

      // 内层亮线
      this.context.strokeStyle = '#FFFFFF'
      this.context.lineWidth = 1
      this.context.shadowBlur = 10
      for (const seg of bolt.segments) {
        this.context.beginPath()
        this.context.moveTo(seg.x1, seg.y1)
        this.context.lineTo(seg.x2, seg.y2)
        this.context.stroke()
      }

      this.context.restore()
    }

    // 全屏闪光效果
    if (this.flashOpacity > 0.01) {
      this.context.fillStyle = `rgba(255,255,255,${this.flashOpacity * 0.3})`
      this.context.fillRect(0, 0, this.canvasWidth, this.canvasHeight)
    }
  }

  // 渲染天气信息叠加
  private renderWeatherInfo(): void {
    const weatherLabels: Record<WeatherType, string> = {
      sunny: '☀️ 晴天 26°C',
      rainy: '🌧️ 雨天 18°C',
      snowy: '❄️ 雪天 -2°C',
      storm: '⛈️ 雷暴 15°C'
    }
    this.context.fillStyle = 'rgba(255,255,255,0.9)'
    this.context.font = '28px sans-serif'
    this.context.fillText(weatherLabels[this.currentWeather], 30, this.canvasHeight - 60)

    this.context.fillStyle = 'rgba(255,255,255,0.6)'
    this.context.font = '16px sans-serif'
    this.context.fillText('滑动切换天气效果', 30, this.canvasHeight - 30)
  }

  // 动画循环
  private startAnimation(): void {
    this.initClouds()
    this.initParticles(this.currentWeather)
    let lightningTimer = 0

    const animate = () => {
      this.time++
      lightningTimer++

      // 雷暴时随机生成闪电
      if (this.currentWeather === 'storm' && lightningTimer > 60 && Math.random() < 0.02) {
        this.generateLightning()
        lightningTimer = 0
      }

      this.renderBackground()
      this.renderSun()
      this.updateClouds()
      this.renderClouds()
      this.updateParticles()
      this.renderPrecipitation()
      this.updateLightnings()
      this.renderLightnings()
      this.renderWeatherInfo()

      this.animationId = requestAnimationFrame(animate)
    }
    animate()
  }

  build() {
    Column() {
      // 天气切换按钮
      Row({ space: 10 }) {
        Button('☀️ 晴天')
          .fontSize(13)
          .onClick(() => this.switchWeather('sunny'))
          .backgroundColor(this.currentWeather === 'sunny' ? '#4FC3F7' : '#555555')

        Button('🌧️ 雨天')
          .fontSize(13)
          .onClick(() => this.switchWeather('rainy'))
          .backgroundColor(this.currentWeather === 'rainy' ? '#34495E' : '#555555')

        Button('❄️ 雪天')
          .fontSize(13)
          .onClick(() => this.switchWeather('snowy'))
          .backgroundColor(this.currentWeather === 'snowy' ? '#90A4AE' : '#555555')

        Button('⛈️ 雷暴')
          .fontSize(13)
          .onClick(() => this.switchWeather('storm'))
          .backgroundColor(this.currentWeather === 'storm' ? '#1a1a2e' : '#555555')
      }
      .margin({ bottom: 16 })

      Canvas(this.context)
        .width(400)
        .height(650)
        .borderRadius(16)
        .onReady(() => {
          this.startAnimation()
        })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#0a0a1a')
  }

  aboutToDisappear() {
    if (this.animationId !== -1) {
      cancelAnimationFrame(this.animationId)
    }
  }
}

四、踩坑与注意事项

坑点1:Canvas的ellipse方法兼容性

CanvasRenderingContext2D.ellipse()在某些低版本HarmonyOS设备上不支持。如果你的目标设备包含老设备,需要用arc+scale模拟椭圆。

// 兼容性方案:用arc+scale模拟ellipse
this.context.save()
this.context.translate(cloud.x, cloud.y)
this.context.scale(cloud.w / cloud.h, 1)  // 水平拉伸
this.context.beginPath()
this.context.arc(0, 0, cloud.h / 2, 0, Math.PI * 2)
this.context.fill()
this.context.restore()

坑点2:雨滴数量过多导致帧率下降

200个雨滴 + 每帧stroke绘制,在低端设备上可能只有20-30fps。雨滴是线段绘制,比圆形粒子更耗性能(每条线段都是独立的path)。

优化方案:将所有雨滴合并到一个path中,只调用一次stroke。

// 优化:合并雨滴绘制
this.context.beginPath()
this.context.strokeStyle = 'rgba(174,194,224,0.4)'
this.context.lineWidth = 1.5
for (const drop of this.drops) {
  this.context.moveTo(drop.x, drop.y)
  this.context.lineTo(drop.x + drop.windOffset * 2, drop.y + drop.length)
}
this.context.stroke()  // 只调用一次stroke

坑点3:天气切换时粒子突然消失

切换天气时如果直接清空粒子数组,画面会"闪"一下——旧粒子突然消失,新粒子还没出现。

优化方案:不清空旧粒子,让旧粒子自然消亡(停止发射新的旧类型粒子),同时开始发射新类型粒子,形成平滑过渡。

坑点4:闪电的shadowBlur性能开销

shadowBlur是Canvas中最耗性能的属性之一。闪电效果中每条线段都设置了shadowBlur: 20,当闪电有20+条线段时,单帧的shadow计算可能超过30ms。

优化方案:只对闪电的主干使用shadowBlur,分支不使用;或者将闪电预渲染到离屏Canvas上,每帧只做一次drawImage。

坑点5:云层的globalAlpha影响后续绘制

this.context.globalAlpha = cloud.opacity设置后,如果忘记恢复,后续所有绘制都会受影响。

必须使用save/restore包裹云层绘制,确保globalAlpha不影响其他元素。

坑点6:雪天背景色与雪花颜色对比度不足

雪天背景是浅灰色(#ECEFF1),雪花是白色,对比度很低,雪花几乎看不清。

解决:给雪花添加轻微的阴影或使用半透明浅蓝色(rgba(200,220,255,0.7))代替纯白色。

坑点7:天气切换动画的背景色过渡

直接切换背景色会"跳变"。应该使用颜色插值实现平滑过渡。

// 颜色插值工具函数
private lerpColor(color1: string, color2: string, t: number): string {
  // 解析hex颜色
  const r1 = parseInt(color1.slice(1, 3), 16)
  const g1 = parseInt(color1.slice(3, 5), 16)
  const b1 = parseInt(color1.slice(5, 7), 16)
  const r2 = parseInt(color2.slice(1, 3), 16)
  const g2 = parseInt(color2.slice(3, 5), 16)
  const b2 = parseInt(color2.slice(5, 7), 16)
  // 线性插值
  const r = Math.round(r1 + (r2 - r1) * t)
  const g = Math.round(g1 + (g2 - g1) * t)
  const b = Math.round(b1 + (b2 - b1) * t)
  return `rgb(${r},${g},${b})`
}

五、HarmonyOS 6适配说明

API差异

API HarmonyOS 5.0 HarmonyOS 6.0 迁移建议
Canvas分辨率 物理分辨率 canvas.resolutionScale(scale) 可降低分辨率提升性能
ParticleEmitter 内置粒子发射器组件 简单天气效果可用内置组件
离屏Canvas OffscreenCanvas OffscreenCanvas + Worker 闪电等复杂效果可离屏预渲染
颜色插值 手动实现 Color.interpolate(color1, color2, t) 使用内置颜色插值API
Canvas帧率 固定60fps canvas.preferredFrameRateRange() 可根据场景动态调整帧率

行为变更

  • Canvas默认GPU加速:HarmonyOS 6中Canvas默认使用GPU渲染,shadowBlur等效果的性能显著提升
  • 粒子数量自适应:系统会根据设备性能自动调整粒子发射速率,低性能设备上粒子数量会减少
  • 天气传感器集成:新增@ohos.sensor天气相关传感器API,可实现根据真实天气自动切换特效
  • Canvas内存优化:Canvas的帧缓冲区采用双缓冲+延迟释放策略,减少内存峰值

适配代码

// HarmonyOS 6适配:使用内置ParticleEmitter和颜色插值
import { Color } from '@kit.ArkUI'

@Component
struct Hmos6WeatherDemo {
  @State currentWeather: WeatherType = 'rainy'

  build() {
    Stack() {
      // 天空背景:使用内置颜色插值
      Column()
        .width('100%')
        .height('100%')
        .linearGradient({
          direction: GradientDirection.Bottom,
          colors: this.getWeatherGradientColors()
        })

      // 使用内置ParticleEmitter实现雨雪效果
      if (this.currentWeather === 'rainy' || this.currentWeather === 'storm') {
        ParticleEmitter({
          emitter: {
            emitRate: this.currentWeather === 'storm' ? 80 : 40,
            lifetime: 2000,
            position: { x: 200, y: 0 },
            shape: 'rect',
            size: { width: 400, height: 10 }
          },
          particle: {
            color: {
              from: 'rgba(174,194,224,0.5)',
              to: 'rgba(174,194,224,0.1)'
            },
            opacity: {
              from: 0.5,
              to: 0.1
            },
            velocity: {
              x: { from: 20, to: 60 },
              y: { from: 300, to: 500 }
            },
            acceleration: {
              y: 200
            }
          }
        })
        .width('100%')
        .height('100%')
      }

      // 雪花效果
      if (this.currentWeather === 'snowy') {
        ParticleEmitter({
          emitter: {
            emitRate: 25,
            lifetime: 6000,
            position: { x: 200, y: 0 },
            shape: 'rect',
            size: { width: 400, height: 10 }
          },
          particle: {
            color: {
              from: 'rgba(255,255,255,0.8)',
              to: 'rgba(255,255,255,0.1)'
            },
            scale: {
              from: 1.0,
              to: 0.3
            },
            velocity: {
              x: { from: -30, to: 30 },
              y: { from: 20, to: 60 }
            }
          }
        })
        .width('100%')
        .height('100%')
      }
    }
    .width('100%')
    .height('100%')
  }

  // 获取天气渐变色
  private getWeatherGradientColors(): Array<[string, number]> {
    const colorSets: Record<WeatherType, Array<[string, number]>> = {
      sunny: [['#4FC3F7', 0], ['#81D4FA', 0.5], ['#B3E5FC', 1]],
      rainy: [['#2C3E50', 0], ['#34495E', 0.5], ['#1a252f', 1]],
      snowy: [['#B0BEC5', 0], ['#CFD8DC', 0.5], ['#ECEFF1', 1]],
      storm: [['#1a1a2e', 0], ['#16213e', 0.5], ['#0f3460', 1]]
    }
    return colorSets[this.currentWeather]
  }
}

六、总结

维度 评价
学习难度 ⭐⭐⭐⭐
使用频率 ⭐⭐⭐
重要程度 ⭐⭐⭐⭐

天气特效系统是"用技术创造体验"的典型代表。用户不会因为你的雨滴粒子数从120降到100而投诉,但一定会因为雨天背景让他在阴沉的天气里感到"这个App懂我"而留下。

核心要点回顾:

  1. 分层渲染是天气系统的骨架——天空、云层、降水、闪电、雾气各司其职
  2. 雨滴是线段,雪花是圆点——不同的粒子形态和运动方式决定了不同的视觉效果
  3. 云层用椭圆组合——多个椭圆叠加出自然的云朵形状
  4. 闪电是分形线段——主干+分支,配合shadowBlur和全屏闪光
  5. 天气切换要平滑过渡——颜色插值、粒子自然消亡、背景渐变
  6. 性能优化的核心是减少绘制调用——合并path、预渲染、控制粒子数量
  7. HarmonyOS 6的ParticleEmitter让简单天气效果的实现门槛大幅降低

天气特效就像是一幅动态的画——天空是画布,粒子是颜料,Canvas是画笔。掌握了这些,你就能在用户的手机屏幕上,画出四季更迭、风霜雨雪。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。