HarmonyOS 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懂我"而留下。
核心要点回顾:
- 分层渲染是天气系统的骨架——天空、云层、降水、闪电、雾气各司其职
- 雨滴是线段,雪花是圆点——不同的粒子形态和运动方式决定了不同的视觉效果
- 云层用椭圆组合——多个椭圆叠加出自然的云朵形状
- 闪电是分形线段——主干+分支,配合shadowBlur和全屏闪光
- 天气切换要平滑过渡——颜色插值、粒子自然消亡、背景渐变
- 性能优化的核心是减少绘制调用——合并path、预渲染、控制粒子数量
- HarmonyOS 6的ParticleEmitter让简单天气效果的实现门槛大幅降低
天气特效就像是一幅动态的画——天空是画布,粒子是颜料,Canvas是画笔。掌握了这些,你就能在用户的手机屏幕上,画出四季更迭、风霜雨雪。
- 点赞
- 收藏
- 关注作者
评论(0)