📖 前言

在移动互联网时代,天气应用几乎是人手必备的工具之一。今天,我将带大家使用鸿蒙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 状态变量说明

状态变量 类型 作用
location string 当前选中的城市
currentTemp number 当前温度
currentCondition string 天气状况
showCityPicker boolean 控制城市选择弹窗显示
hourlyData HourlyItem[] 逐小时预报数据
dailyData DailyItem[] 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、测试、元服务和应用上架分发等。

更多推荐