📖 前言

在移动互联网时代,天气应用几乎是人手必备的工具之一。今天,我将带大家使用鸿蒙ArkUI从零开发一款精美的天气应用。这个项目不仅涵盖了基础的UI布局和状态管理,还涉及到了渐变背景、条件渲染、自定义组件构建器、模拟数据设计等进阶内容。

先看最终效果:

在这里插入图片描述
在这里插入图片描述


一、项目概述

1.1 功能需求

这款天气App具备以下核心功能:

功能模块详细说明
实时天气展示温度、天气状况、最高/最低温度
空气质量指数AQI数值及等级描述
生活指数紫外线强度、湿度、风速
逐小时预报未来8小时天气趋势
7天预报一周天气概况及温度曲线
城市切换支持多个城市快速切换
动态主题根据天气状况自动切换背景颜色

1.2 技术亮点

  • 🎨 渐变背景 - 根据天气状况动态切换主题色
  • 📊 温度条可视化 - 直观展示温度范围
  • 🏗️ @Builder装饰器 - 封装可复用UI组件
  • 📱 模态弹窗 - 优雅的城市选择交互
  • 🔄 数据驱动UI - 状态管理自动更新视图

1.3 技术栈

技术说明
开发框架ArkUI(声明式UI)
开发语言ArkTS(TypeScript扩展)
API版本23
开发工具DevEco Studio
项目模型Stage模型

二、项目创建与环境配置

2.1 创建新项目

  1. 打开 DevEco Studio,选择 Create Project
  2. 选择 Empty Ability 模板
  3. 填写项目信息:
    • Project name: WeatherApp
    • Bundle name: com.example.weatherapp
    • Save location: E:\HMproject\Project\WeatherApp
    • Compile SDK: 选择最新的 HarmonyOS Next SDK
    • Model: Stage

2.2 项目结构

WeatherApp/
├── AppScope/
│   ├── app.json5                    # 应用全局配置
│   └── resources/                   # 全局资源
├── entry/
│   ├── src/main/
│   │   ├── ets/
│   │   │   ├── entryability/
│   │   │   │   └── EntryAbility.ets    # 应用入口
│   │   │   └── pages/
│   │   │       └── Index.ets            # 主页面(核心)
│   │   └── resources/
│   │       ├── base/element/            # 字符串、颜色
│   │       ├── base/media/              # 图片资源
│   │       └── base/profile/            # 页面路由
│   ├── module.json5                  # 模块配置
│   └── build-profile.json5           # 构建配置
└── oh_modules/                       # 依赖模块

三、数据模型设计

3.1 天气信息数据结构

首先定义天气信息的数据接口:

interface WeatherInfo {
  city: string      // 城市名称
  temp: number      // 当前温度
  cond: string      // 天气状况(晴/多云/雨等)
  humid: number     // 湿度百分比
  wind: string      // 风速等级
  uv: string        // 紫外线强度
  high: number      // 当日最高温度
  low: number       // 当日最低温度
  aqi: number       // 空气质量指数
  aqiDesc: string   // AQI等级描述
}

3.2 小时预报数据结构

interface HourlyItem {
  time: string    // 时间点
  temp: number    // 温度
  icon: string    // 天气图标(emoji)
}

3.3 日预报数据结构

interface DailyItem {
  day: string     // 星期几
  date: string    // 日期
  icon: string    // 天气图标
  high: number    // 最高温度
  low: number     // 最低温度
  desc: string    // 天气描述
}

3.4 模拟天气数据

由于本示例不涉及真实API调用,我们准备8个城市的模拟天气数据:

private readonly CITIES: string[] = [
  '北京市', '上海市', '广州市', '深圳市', 
  '杭州市', '成都市', '武汉市', '南京市'
]

private readonly WEATHER_DATA: WeatherInfo[] = [
  { city: '北京市', temp: 26, cond: '晴', humid: 45, wind: '3级', uv: '中等', high: 30, low: 18, aqi: 72, aqiDesc: '良' },
  { city: '上海市', temp: 24, cond: '多云', humid: 62, wind: '4级', uv: '中等', high: 27, low: 20, aqi: 55, aqiDesc: '良' },
  { city: '广州市', temp: 31, cond: '雷阵雨', humid: 78, wind: '3级', uv: '强', high: 33, low: 25, aqi: 38, aqiDesc: '优' },
  { city: '深圳市', temp: 29, cond: '阵雨', humid: 72, wind: '3级', uv: '强', high: 31, low: 24, aqi: 42, aqiDesc: '优' },
  { city: '杭州市', temp: 27, cond: '晴', humid: 50, wind: '2级', uv: '中等', high: 29, low: 19, aqi: 68, aqiDesc: '良' },
  { city: '成都市', temp: 22, cond: '阴', humid: 70, wind: '2级', uv: '弱', high: 24, low: 17, aqi: 85, aqiDesc: '良' },
  { city: '武汉市', temp: 28, cond: '晴', humid: 55, wind: '3级', uv: '强', high: 31, low: 21, aqi: 78, aqiDesc: '良' },
  { city: '南京市', temp: 25, cond: '多云', humid: 58, wind: '3级', uv: '中等', high: 28, low: 19, aqi: 65, aqiDesc: '良' }
]

四、状态管理设计

4.1 状态变量定义

天气应用需要管理大量的状态数据:

@Entry
@Component
struct Index {
  // 城市与基础天气信息
  @State location: string = '北京市'
  @State currentTemp: number = 26
  @State currentCondition: string = '晴'
  
  // 详细天气指标
  @State currentHumidity: number = 45
  @State currentWind: string = '3级'
  @State currentUV: string = '中等'
  @State currentHigh: number = 30
  @State currentLow: number = 18
  @State currentAQI: number = 72
  @State currentAQIDesc: string = '良'
  
  // UI交互状态
  @State showCityPicker: boolean = false
  
  // 预报数据
  @State hourlyData: HourlyItem[] = this.generateHourlyData('晴', 26)
  @State dailyData: DailyItem[] = this.generateDailyData('晴', 30, 18)
}

4.2 状态变量说明

状态变量类型作用
locationstring当前选中的城市
currentTempnumber当前温度
currentConditionstring天气状况
showCityPickerboolean控制城市选择弹窗显示
hourlyDataHourlyItem[]逐小时预报数据
dailyDataDailyItem[]7天预报数据

五、核心功能实现

5.1 动态渐变背景

根据天气状况动态切换背景颜色是本App的一大亮点:

private getBgGradient(cond: string): string {
  if (cond === '晴') return '#FF9F0A'        // 橙黄色
  if (cond === '多云' || cond === '阴') return '#8E8E93'  // 灰色
  if (cond === '小雨' || cond === '阵雨' || cond === '雷阵雨') return '#5AC8FA'  // 蓝色
  return '#4A90D9'
}

private getBgEnd(cond: string): string {
  if (cond === '晴') return '#FFD60A'        // 明黄色
  if (cond === '多云' || cond === '阴') return '#636366'  // 深灰
  if (cond === '小雨' || cond === '阵雨' || cond === '雷阵雨') return '#007AFF'  // 深蓝
  return '#87CEEB'
}

使用方式:

Column() {
  // 头部天气展示区域
  // ...
}
.linearGradient({
  direction: GradientDirection.Bottom,
  colors: [
    [this.getBgGradient(this.currentCondition), 0],
    [this.getBgEnd(this.currentCondition), 1]
  ]
})

效果说明:

  • 晴天:橙黄渐变,温暖明亮
  • 多云/阴天:灰色渐变,沉稳内敛
  • 雨天:蓝色渐变,清新凉爽

5.2 天气图标映射

使用emoji作为天气图标,简单直观:

private getWeatherEmoji(cond: string): string {
  if (cond === '晴') return '☀️'
  if (cond === '多云') return '⛅'
  if (cond === '阴') return '☁️'
  if (cond === '小雨') return '🌦️'
  if (cond === '阵雨') return '🌧️'
  if (cond === '雷阵雨') return '⛈️'
  return '🌤️'
}

5.3 逐小时预报数据生成

private generateHourlyData(cond: string, baseTemp: number): HourlyItem[] {
  const times: string[] = ['现在', '15:00', '18:00', '21:00', '00:00', '03:00', '06:00', '09:00']
  const icons: string[] = ['🌙', '🌙', '🌤️', '☀️', '☀️', '☀️', '🌤️', '🌙']
  const result: HourlyItem[] = []
  
  for (let i = 0; i < 8; i++) {
    result.push({
      time: times[i],
      temp: baseTemp + Math.floor(Math.random() * 6) - 3,  // 随机波动±3度
      icon: icons[i]
    })
  }
  result[0].temp = baseTemp  // 当前时间保持实际温度
  return result
}

5.4 7天预报数据生成

private generateDailyData(cond: string, high: number, low: number): DailyItem[] {
  const days: string[] = ['今天', '明天', '周三', '周四', '周五', '周六', '周日']
  const icons: string[] = ['☀️', '⛅', '🌧️', '☁️', '☀️', '☀️', '⛅']
  const descs: string[] = ['晴', '多云', '小雨', '阴', '晴', '晴', '多云']
  const result: DailyItem[] = []
  
  for (let i = 0; i < 7; i++) {
    result.push({
      day: days[i],
      date: '6月' + (i + 1) + '日',
      icon: icons[i],
      high: high + Math.floor(Math.random() * 6) - 3,
      low: low + Math.floor(Math.random() * 4) - 2,
      desc: descs[i]
    })
  }
  return result
}

5.5 城市切换逻辑

private getWeatherByCity(city: string): WeatherInfo {
  for (let i = 0; i < this.WEATHER_DATA.length; i++) {
    if (this.WEATHER_DATA[i].city === city) {
      return this.WEATHER_DATA[i]
    }
  }
  return this.WEATHER_DATA[0]  // 默认返回北京
}

private switchCity(city: string): void {
  const data = this.getWeatherByCity(city)
  
  // 更新所有状态
  this.location = city
  this.currentTemp = data.temp
  this.currentCondition = data.cond
  this.currentHumidity = data.humid
  this.currentWind = data.wind
  this.currentUV = data.uv
  this.currentHigh = data.high
  this.currentLow = data.low
  this.currentAQI = data.aqi
  this.currentAQIDesc = data.aqiDesc
  
  // 重新生成预报数据
  this.hourlyData = this.generateHourlyData(data.cond, data.temp)
  this.dailyData = this.generateDailyData(data.cond, data.high, data.low)
  
  // 关闭弹窗
  this.showCityPicker = false
}

六、UI组件封装

6.1 @Builder装饰器详解

ArkUI提供了 @Builder 装饰器,用于封装可复用的UI组件。相比普通方法,@Builder 方法可以返回UI描述,实现组件级别的复用。

6.2 紧凑信息卡片

用于展示空气质量、紫外线、湿度、风速等指标:

@Builder compactCard(icon: string, value: string, desc: string, color: string) {
  Column() {
    Text(icon)
      .fontSize(20)
    Text(value)
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor(Color.White)
      .margin({ top: 6 })
    if (desc.length > 0) {
      Text(desc)
        .fontSize(12)
        .fontColor(color)
        .margin({ top: 2 })
    }
  }
  .layoutWeight(1)
  .padding({ top: 10, bottom: 10 })
  .alignItems(HorizontalAlign.Center)
}

使用示例:

Row() {
  this.compactCard('💨 空气质量', String(this.currentAQI), this.currentAQIDesc, 
    this.currentAQI <= 50 ? '#34C759' : '#FF9F0A')
  this.compactCard('☀️ 紫外线', this.currentUV, '', '#FF9F0A')
  this.compactCard('💧 湿度', String(this.currentHumidity) + '%', '', '#5AC8FA')
  this.compactCard('🌬️ 风速', this.currentWind, '', '#8E8E93')
}

6.3 逐小时预报项

@Builder hourlyItem(item: HourlyItem) {
  Column() {
    Text(item.time)
      .fontSize(12)
      .fontColor('#8E8E93')
    Text(item.icon)
      .fontSize(22)
      .margin({ top: 6 })
    Text(String(item.temp) + '°')
      .fontSize(15)
      .fontWeight(FontWeight.Medium)
      .fontColor(Color.White)
      .margin({ top: 6 })
  }
  .padding({ left: 10, right: 10 })
  .alignItems(HorizontalAlign.Center)
}

6.4 7天预报行

这是一个较为复杂的组件,包含温度条可视化:

@Builder dailyRow(item: DailyItem) {
  Row() {
    Text(item.day)
      .fontSize(15)
      .fontColor(Color.White)
      .width(48)
    Text(item.icon)
      .fontSize(18)
      .width(30)
    Text(item.desc)
      .fontSize(14)
      .fontColor('#8E8E93')
      .width(36)
    Blank()
    Text(String(item.low) + '°')
      .fontSize(14)
      .fontColor('#8E8E93')
      .width(32)
      .textAlign(TextAlign.End)
    
    // 温度条
    Column() {
      Column()
        .width(this.tempBarWidth(item.low, item.high))
        .height(6)
        .borderRadius(3)
        .backgroundColor(this.tempBarColor(item.low, item.high))
    }
    .width(60)
    .height(6)
    .backgroundColor('#333333')
    .borderRadius(3)
    .margin({ left: 4, right: 4 })
    
    Text(String(item.high) + '°')
      .fontSize(14)
      .fontColor(Color.White)
      .width(32)
      .textAlign(TextAlign.End)
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 6, bottom: 6 })
}

6.5 温度条辅助方法

private tempBarWidth(low: number, high: number): string {
  // 根据温差计算条宽度
  return Math.floor(((high - low) / 20) * 100 + 20) + '%'
}

private tempBarColor(low: number, high: number): string {
  const avg = (low + high) / 2
  if (avg >= 28) return '#FF3B30'  // 红色 - 热
  if (avg >= 20) return '#FF9F0A'  // 橙色 - 温暖
  return '#34C759'                  // 绿色 - 凉爽
}

6.6 城市选择按钮

@Builder cityButton(city: string) {
  Button(city)
    .fontSize(14)
    .fontWeight(FontWeight.Medium)
    .height(44)
    .borderRadius(22)
    .backgroundColor(this.location === city ? '#FF9F0A' : '#2C2C2E')
    .fontColor(this.location === city ? Color.White : '#8E8E93')
    .layoutWeight(1)
    .margin({ left: 4, right: 4 })
    .onClick(() => { this.switchCity(city) })
}

七、页面布局实现

7.1 整体结构

build() {
  Stack() {
    // 主内容区
    Scroll() {
      Column() {
        // 1. 顶部天气展示区(带渐变背景)
        Column() { /* ... */ }
          .linearGradient({ /* ... */ })
        
        // 2. 信息卡片区域
        Row() { /* compactCard */ }
        
        // 3. 逐小时预报
        Column() { /* ... */ }
        
        // 4. 7天预报
        Column() { /* ... */ })
        
        // 5. 更新时间
        Text('数据更新时间: 2026年6月1日 14:00')
      }
    }
    
    // 城市选择弹窗(条件渲染)
    if (this.showCityPicker) {
      Column() { /* 弹窗内容 */ }
    }
  }
}

7.2 头部天气展示区

Column() {
  // 城市选择按钮
  Row() {
    Blank()
    Button(this.location + '  ▾')
      .fontSize(18)
      .fontWeight(FontWeight.Medium)
      .fontColor(Color.White)
      .backgroundColor(Color.Transparent)
      .onClick(() => { this.showCityPicker = true })
    Blank()
  }
  .width('100%')
  .padding({ top: 30 })

  // 天气图标
  Text(this.getWeatherEmoji(this.currentCondition))
    .fontSize(64)
    .margin({ top: 8 })

  // 当前温度
  Text(String(this.currentTemp) + '°')
    .fontSize(80)
    .fontWeight(FontWeight.Bold)
    .fontColor(Color.White)
    .margin({ top: 4 })

  // 天气状况
  Text(this.currentCondition)
    .fontSize(20)
    .fontColor('#FFFFFF')
    .opacity(0.9)

  // 最高/最低温度
  Text('最高 ' + this.currentHigh + '°  最低 ' + this.currentLow + '°')
    .fontSize(15)
    .fontColor('#FFFFFF')
    .opacity(0.7)
    .margin({ top: 6 })
}
.width('100%')
.padding({ bottom: 28 })
.linearGradient({
  direction: GradientDirection.Bottom,
  colors: [
    [this.getBgGradient(this.currentCondition), 0],
    [this.getBgEnd(this.currentCondition), 1]
  ]
})

7.3 城市选择弹窗

使用 Stack 叠加布局实现模态弹窗效果:

if (this.showCityPicker) {
  Column() {
    Column() {
      Text('选择城市')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor(Color.White)
        .margin({ top: 20, bottom: 16 })
      
      Row() {
        this.cityButton(this.CITIES[0])
        this.cityButton(this.CITIES[1])
        this.cityButton(this.CITIES[2])
        this.cityButton(this.CITIES[3])
      }
      .width('100%')
      .padding({ left: 12, right: 12 })
      .margin({ top: 4 })
      
      Row() {
        this.cityButton(this.CITIES[4])
        this.cityButton(this.CITIES[5])
        this.cityButton(this.CITIES[6])
        this.cityButton(this.CITIES[7])
      }
      .width('100%')
      .padding({ left: 12, right: 12 })
      .margin({ top: 8 })
      
      Button('取消')
        .fontSize(16)
        .fontColor('#FF9F0A')
        .backgroundColor('#2C2C2E')
        .width('90%')
        .height(44)
        .borderRadius(22)
        .margin({ top: 16, bottom: 20 })
        .onClick(() => { this.showCityPicker = false })
    }
    .width('85%')
    .backgroundColor('#1C1C1E')
    .borderRadius(20)
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#80000000')  // 半透明遮罩
  .justifyContent(FlexAlign.Center)
  .onClick(() => { this.showCityPicker = false })
}

八、完整代码实现

8.1 主页面完整代码(Index.ets)

// 数据接口定义
interface WeatherInfo {
  city: string
  temp: number
  cond: string
  humid: number
  wind: string
  uv: string
  high: number
  low: number
  aqi: number
  aqiDesc: string
}

interface HourlyItem {
  time: string
  temp: number
  icon: string
}

interface DailyItem {
  day: string
  date: string
  icon: string
  high: number
  low: number
  desc: string
}

@Entry
@Component
struct Index {
  // 城市列表
  private readonly CITIES: string[] = [
    '北京市', '上海市', '广州市', '深圳市',
    '杭州市', '成都市', '武汉市', '南京市'
  ]

  // 模拟天气数据
  private readonly WEATHER_DATA: WeatherInfo[] = [
    { city: '北京市', temp: 26, cond: '晴', humid: 45, wind: '3级', uv: '中等', high: 30, low: 18, aqi: 72, aqiDesc: '良' },
    { city: '上海市', temp: 24, cond: '多云', humid: 62, wind: '4级', uv: '中等', high: 27, low: 20, aqi: 55, aqiDesc: '良' },
    { city: '广州市', temp: 31, cond: '雷阵雨', humid: 78, wind: '3级', uv: '强', high: 33, low: 25, aqi: 38, aqiDesc: '优' },
    { city: '深圳市', temp: 29, cond: '阵雨', humid: 72, wind: '3级', uv: '强', high: 31, low: 24, aqi: 42, aqiDesc: '优' },
    { city: '杭州市', temp: 27, cond: '晴', humid: 50, wind: '2级', uv: '中等', high: 29, low: 19, aqi: 68, aqiDesc: '良' },
    { city: '成都市', temp: 22, cond: '阴', humid: 70, wind: '2级', uv: '弱', high: 24, low: 17, aqi: 85, aqiDesc: '良' },
    { city: '武汉市', temp: 28, cond: '晴', humid: 55, wind: '3级', uv: '强', high: 31, low: 21, aqi: 78, aqiDesc: '良' },
    { city: '南京市', temp: 25, cond: '多云', humid: 58, wind: '3级', uv: '中等', high: 28, low: 19, aqi: 65, aqiDesc: '良' }
  ]

  // 状态变量
  @State location: string = '北京市'
  @State currentTemp: number = 26
  @State currentCondition: string = '晴'
  @State currentHumidity: number = 45
  @State currentWind: string = '3级'
  @State currentUV: string = '中等'
  @State currentHigh: number = 30
  @State currentLow: number = 18
  @State currentAQI: number = 72
  @State currentAQIDesc: string = '良'
  @State showCityPicker: boolean = false
  @State hourlyData: HourlyItem[] = this.generateHourlyData('晴', 26)
  @State dailyData: DailyItem[] = this.generateDailyData('晴', 30, 18)

  // 辅助方法...
  private getBgGradient(cond: string): string {
    if (cond === '晴') return '#FF9F0A'
    if (cond === '多云' || cond === '阴') return '#8E8E93'
    if (cond === '小雨' || cond === '阵雨' || cond === '雷阵雨') return '#5AC8FA'
    return '#4A90D9'
  }

  private getBgEnd(cond: string): string {
    if (cond === '晴') return '#FFD60A'
    if (cond === '多云' || cond === '阴') return '#636366'
    if (cond === '小雨' || cond === '阵雨' || cond === '雷阵雨') return '#007AFF'
    return '#87CEEB'
  }

  private getWeatherEmoji(cond: string): string {
    if (cond === '晴') return '☀️'
    if (cond === '多云') return '⛅'
    if (cond === '阴') return '☁️'
    if (cond === '小雨') return '🌦️'
    if (cond === '阵雨') return '🌧️'
    if (cond === '雷阵雨') return '⛈️'
    return '🌤️'
  }

  private generateHourlyData(cond: string, baseTemp: number): HourlyItem[] {
    const times: string[] = ['现在', '15:00', '18:00', '21:00', '00:00', '03:00', '06:00', '09:00']
    const icons: string[] = ['🌙', '🌙', '🌤️', '☀️', '☀️', '☀️', '🌤️', '🌙']
    const result: HourlyItem[] = []
    for (let i = 0; i < 8; i++) {
      result.push({ time: times[i], temp: baseTemp + Math.floor(Math.random() * 6) - 3, icon: icons[i] })
    }
    result[0].temp = baseTemp
    return result
  }

  private generateDailyData(cond: string, high: number, low: number): DailyItem[] {
    const days: string[] = ['今天', '明天', '周三', '周四', '周五', '周六', '周日']
    const icons: string[] = ['☀️', '⛅', '🌧️', '☁️', '☀️', '☀️', '⛅']
    const descs: string[] = ['晴', '多云', '小雨', '阴', '晴', '晴', '多云']
    const result: DailyItem[] = []
    for (let i = 0; i < 7; i++) {
      result.push({ day: days[i], date: '6月' + (i + 1) + '日', icon: icons[i], high: high + Math.floor(Math.random() * 6) - 3, low: low + Math.floor(Math.random() * 4) - 2, desc: descs[i] })
    }
    return result
  }

  private getWeatherByCity(city: string): WeatherInfo {
    for (let i = 0; i < this.WEATHER_DATA.length; i++) {
      if (this.WEATHER_DATA[i].city === city) return this.WEATHER_DATA[i]
    }
    return this.WEATHER_DATA[0]
  }

  private switchCity(city: string): void {
    const data = this.getWeatherByCity(city)
    this.location = city
    this.currentTemp = data.temp
    this.currentCondition = data.cond
    this.currentHumidity = data.humid
    this.currentWind = data.wind
    this.currentUV = data.uv
    this.currentHigh = data.high
    this.currentLow = data.low
    this.currentAQI = data.aqi
    this.currentAQIDesc = data.aqiDesc
    this.hourlyData = this.generateHourlyData(data.cond, data.temp)
    this.dailyData = this.generateDailyData(data.cond, data.high, data.low)
    this.showCityPicker = false
  }

  private tempBarWidth(low: number, high: number): string {
    return Math.floor(((high - low) / 20) * 100 + 20) + '%'
  }

  private tempBarColor(low: number, high: number): string {
    const avg = (low + high) / 2
    if (avg >= 28) return '#FF3B30'
    if (avg >= 20) return '#FF9F0A'
    return '#34C759'
  }

  // @Builder组件
  @Builder compactCard(icon: string, value: string, desc: string, color: string) {
    Column() {
      Text(icon).fontSize(20)
      Text(value).fontSize(16).fontWeight(FontWeight.Bold).fontColor(Color.White).margin({ top: 6 })
      if (desc.length > 0) { Text(desc).fontSize(12).fontColor(color).margin({ top: 2 }) }
    }.layoutWeight(1).padding({ top: 10, bottom: 10 }).alignItems(HorizontalAlign.Center)
  }

  @Builder hourlyItem(item: HourlyItem) {
    Column() {
      Text(item.time).fontSize(12).fontColor('#8E8E93')
      Text(item.icon).fontSize(22).margin({ top: 6 })
      Text(String(item.temp) + '°').fontSize(15).fontWeight(FontWeight.Medium).fontColor(Color.White).margin({ top: 6 })
    }.padding({ left: 10, right: 10 }).alignItems(HorizontalAlign.Center)
  }

  @Builder dailyRow(item: DailyItem) {
    Row() {
      Text(item.day).fontSize(15).fontColor(Color.White).width(48)
      Text(item.icon).fontSize(18).width(30)
      Text(item.desc).fontSize(14).fontColor('#8E8E93').width(36)
      Blank()
      Text(String(item.low) + '°').fontSize(14).fontColor('#8E8E93').width(32).textAlign(TextAlign.End)
      Column() {
        Column().width(this.tempBarWidth(item.low, item.high)).height(6).borderRadius(3).backgroundColor(this.tempBarColor(item.low, item.high))
      }.width(60).height(6).backgroundColor('#333333').borderRadius(3).margin({ left: 4, right: 4 })
      Text(String(item.high) + '°').fontSize(14).fontColor(Color.White).width(32).textAlign(TextAlign.End)
    }.width('100%').padding({ left: 16, right: 16, top: 6, bottom: 6 })
  }

  @Builder cityButton(city: string) {
    Button(city).fontSize(14).fontWeight(FontWeight.Medium).height(44).borderRadius(22)
      .backgroundColor(this.location === city ? '#FF9F0A' : '#2C2C2E')
      .fontColor(this.location === city ? Color.White : '#8E8E93')
      .layoutWeight(1).margin({ left: 4, right: 4 })
      .onClick(() => { this.switchCity(city) })
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          Column() {
            Row() {
              Blank()
              Button(this.location + '  ▾').fontSize(18).fontWeight(FontWeight.Medium)
                .fontColor(Color.White).backgroundColor(Color.Transparent)
                .onClick(() => { this.showCityPicker = true })
              Blank()
            }.width('100%').padding({ top: 30 })
            Text(this.getWeatherEmoji(this.currentCondition)).fontSize(64).margin({ top: 8 })
            Text(String(this.currentTemp) + '°').fontSize(80).fontWeight(FontWeight.Bold).fontColor(Color.White).margin({ top: 4 })
            Text(this.currentCondition).fontSize(20).fontColor('#FFFFFF').opacity(0.9)
            Text('最高 ' + this.currentHigh + '°  最低 ' + this.currentLow + '°').fontSize(15).fontColor('#FFFFFF').opacity(0.7).margin({ top: 6 })
          }.width('100%').padding({ bottom: 28 })
          .linearGradient({
            direction: GradientDirection.Bottom,
            colors: [[this.getBgGradient(this.currentCondition), 0], [this.getBgEnd(this.currentCondition), 1]]
          })

          Row() {
            this.compactCard('💨 空气质量', String(this.currentAQI), this.currentAQIDesc, this.currentAQI <= 50 ? '#34C759' : '#FF9F0A')
            this.compactCard('☀️ 紫外线', this.currentUV, '', '#FF9F0A')
            this.compactCard('💧 湿度', String(this.currentHumidity) + '%', '', '#5AC8FA')
            this.compactCard('🌬️ 风速', this.currentWind, '', '#8E8E93')
          }.width('100%').padding({ left: 12, right: 12 }).margin({ top: -20 })

          Column() {
            Row() { Text('⏰ 逐小时预报').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Color.White); Blank() }
            .width('100%').padding({ left: 16, bottom: 10, top: 16 })
            Row() {
              this.hourlyItem(this.hourlyData[0]); this.hourlyItem(this.hourlyData[1])
              this.hourlyItem(this.hourlyData[2]); this.hourlyItem(this.hourlyData[3])
              this.hourlyItem(this.hourlyData[4]); this.hourlyItem(this.hourlyData[5])
              this.hourlyItem(this.hourlyData[6]); this.hourlyItem(this.hourlyData[7])
            }.width('100%').padding({ left: 8, right: 8 })
          }.width('100%').margin({ top: 16 }).backgroundColor('#1C1C1E').borderRadius(16).padding({ bottom: 12 })

          Column() {
            Row() { Text('📅 7 天预报').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Color.White); Blank() }
            .width('100%').padding({ left: 16, bottom: 8, top: 16 })
            this.dailyRow(this.dailyData[0]); this.dailyRow(this.dailyData[1])
            this.dailyRow(this.dailyData[2]); this.dailyRow(this.dailyData[3])
            this.dailyRow(this.dailyData[4]); this.dailyRow(this.dailyData[5])
            this.dailyRow(this.dailyData[6])
          }.width('100%').margin({ top: 12 }).backgroundColor('#1C1C1E').borderRadius(16).padding({ bottom: 8 })

          Text('数据更新时间: 2026年6月1日 14:00').fontSize(12).fontColor('#555555').margin({ top: 16, bottom: 24 })
        }.width('100%').backgroundColor('#000000')
      }.width('100%').height('100%')

      if (this.showCityPicker) {
        Column() {
          Column() {
            Text('选择城市').fontSize(20).fontWeight(FontWeight.Bold).fontColor(Color.White).margin({ top: 20, bottom: 16 })
            Row() {
              this.cityButton(this.CITIES[0]); this.cityButton(this.CITIES[1]); this.cityButton(this.CITIES[2]); this.cityButton(this.CITIES[3])
            }.width('100%').padding({ left: 12, right: 12 }).margin({ top: 4 })
            Row() {
              this.cityButton(this.CITIES[4]); this.cityButton(this.CITIES[5]); this.cityButton(this.CITIES[6]); this.cityButton(this.CITIES[7])
            }.width('100%').padding({ left: 12, right: 12 }).margin({ top: 8 })
            Button('取消').fontSize(16).fontColor('#FF9F0A').backgroundColor('#2C2C2E')
              .width('90%').height(44).borderRadius(22).margin({ top: 16, bottom: 20 })
              .onClick(() => { this.showCityPicker = false })
          }.width('85%').backgroundColor('#1C1C1E').borderRadius(20)
        }.width('100%').height('100%').backgroundColor('#80000000').justifyContent(FlexAlign.Center)
        .onClick(() => { this.showCityPicker = false })
      }
    }.width('100%').height('100%').backgroundColor('#000000')
  }
}

九、运行效果展示

9.1 构建项目

在 DevEco Studio 中:

  1. 点击菜单 Build > Build Hap(s)/APP(s) > Build Hap(s)
  2. 等待构建完成

9.2 运行应用

点击运行按钮,选择模拟器或真机运行。

9.3 界面效果

晴天主题:

在这里插入图片描述

多云主题:

在这里插入图片描述

雨天主题:

在这里插入图片描述

城市选择弹窗:

在这里插入图片描述


十、核心知识点总结

10.1 ArkUI核心概念

概念说明
@Entry页面入口装饰器
@Component组件装饰器
@State状态变量装饰器,驱动UI更新
@Builder组件构建器,封装可复用UI

10.2 布局组件

组件用途
Stack叠加布局,实现弹窗效果
Column垂直线性布局
Row水平线性布局
Scroll可滚动容器
Blank占位空白,自动填充

10.3 样式系统

// 链式调用
Text('示例')
  .fontSize(16)
  .fontColor(Color.White)
  .fontWeight(FontWeight.Bold)

// 渐变背景
.linearGradient({
  direction: GradientDirection.Bottom,
  colors: [['#FF9F0A', 0], ['#FFD60A', 1]]
})

10.4 条件渲染

if (this.showCityPicker) {
  // 显示弹窗
}

if (desc.length > 0) {
  Text(desc)  // 条件显示
}

十一、扩展思路

这个天气App还可以继续完善:

11.1 功能扩展

扩展项实现方案
真实天气API接入和风天气、心知天气等API
定位服务使用 @ohos.geoLocationManager 获取当前位置
天气预警显示气象预警信息
生活指数穿衣、洗车、运动等指数
天气动画下雨、下雪等动态效果

11.2 技术优化

优化项说明
数据缓存使用 @ohos.data.preferences 缓存天气数据
下拉刷新添加刷新手势
骨架屏加载时显示占位UI
国际化多语言支持

十二、常见问题

Q1: 渐变背景不显示?

确保 linearGradient 设置在 Column 或其他容器组件上,且颜色格式正确。

Q2: @Builder方法无法访问this?

@Builder 方法默认是静态的,需要定义为实例方法才能访问 this

Q3: 模态弹窗如何阻止事件穿透?

在遮罩层添加 .onClick() 事件处理,但不在内部弹窗容器上添加。

Q4: 温度条如何实现动态宽度?

通过方法计算返回宽度字符串,在 @Builder 中动态绑定。


十三、总结

通过这个天气App的开发,我们学习了:

✅ ArkUI声明式UI开发模式
✅ 复杂数据结构设计
✅ 多状态变量管理
✅ @Builder组件封装与复用
✅ 渐变背景与动态主题
✅ Stack叠加布局与模态弹窗
✅ 条件渲染与动态UI
✅ 温度条可视化实现

虽然使用的是模拟数据,但整体架构已经具备了真实天气应用的核心要素。接入真实API后,这款App就可以投入实际使用了!


参考资料


如果这篇文章对你有帮助,欢迎点赞、收藏、评论!你的支持是我创作的动力! 🌤️

Logo

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

更多推荐