从"服务不可用"到"一键分享":一次完整的天气应用开发实战

在HarmonyOS 6应用开发中,我最近接到了一个天气应用开发需求:用户想要一个能够显示实时天气、空气质量、未来预报,并且能一键分享天气信息给朋友的应用。听起来很简单,对吧?但实际开发中,我遇到了两个让人头疼的问题。

第一个问题来自华为官方文档的"温馨提示":Weather Service Kit当前仅面向系统应用开放。这意味着普通开发者根本无法开通天气服务!有开发者反馈:"在AppGallery Connect里翻了个底朝天,就是找不到开通天气服务的开关,文档看了三遍还是不知道怎么用。"

第二个问题更隐蔽:当我好不容易通过其他方式获取到天气数据后,发现HourlyWeather里面的空气质量aqi字段居然为空!文档明明写着有这个字段,但实际返回就是null。用户抱怨:"说好的空气质量指数呢?怎么显示'暂无数据'?"

更麻烦的是,用户还要求分享功能——不是简单的文字分享,而是要把精美的天气卡片生成图片分享出去。这让我想起了之前做AI旅行助手时遇到的长截图问题,但天气数据动态生成海报又太耗资源。

今天,我就把这次完整的天气应用开发经历记录下来,从服务集成到数据展示,再到智能分享,帮你避开所有我踩过的坑。

问题一:Weather Service Kit的"准入壁垒"

问题现象:消失的服务开关

按照华为官方文档的指引,我兴冲冲地打开AppGallery Connect,准备开通天气服务:

  1. 第一步:登录AppGallery Connect → 我的项目 → 选择应用

  2. 第二步:左侧菜单找到"增长" → 服务管理

  3. 第三步:搜索"Weather" → 结果:无

  4. 第四步:仔细核对文档 → 确认操作步骤无误

  5. 第五步:联系技术支持 → 得到回复:"天气服务当前仅面向系统应用开放"

官方文档说明

华为官方文档明确指出:"天气服务当前仅面向系统应用开放。"这意味着普通第三方应用开发者暂时无法直接使用Weather Service Kit。

解决方案:第三方天气API的集成方案

既然官方天气服务走不通,那就换条路。经过调研,我找到了几种可行的替代方案:

方案一:国内主流天气API

  1. 和风天气:提供免费版,支持实时天气、3天预报、空气质量等

  2. 心知天气:免费额度足够个人开发者使用

  3. 阿里云天气:企业级服务,功能全面但需要付费

方案二:开源天气数据

  1. OpenWeatherMap:国际服务,有免费套餐

  2. WeatherAPI:简单易用,文档清晰

方案三:聚合数据平台

  1. 聚合数据:国内数据聚合平台,提供天气接口

  2. APISpace:多种API集合,包括天气数据

我最终选择了和风天气,原因有三:

  1. 免费额度足够(1000次/天)

  2. 数据全面(实时天气、预报、空气质量、生活指数)

  3. 文档清晰,SDK完善

问题二:神秘消失的aqi数据

问题现象:文档有字段,实际返回空

集成和风天气API后,我顺利拿到了大部分数据,但发现了一个奇怪的问题:

// 小时天气预报数据
interface HourlyWeather {
  time: string          // 时间 ✓ 有数据
  temp: number         // 温度 ✓ 有数据
  text: string         // 天气状况 ✓ 有数据
  windSpeed: number    // 风速 ✓ 有数据
  humidity: number     // 湿度 ✓ 有数据
  aqi?: number         // 空气质量指数 ✗ 经常为空
  aqiLevel?: string    // 空气质量等级 ✗ 经常为空
}

用户反馈:"为什么有时候显示空气质量,有时候又不显示?是不是bug?"

问题根因:数据源的局限性

经过深入排查,我发现问题的根本原因:

  1. 数据更新频率不同:温度和湿度数据更新频繁(每小时),但空气质量数据更新较慢(每3-6小时)

  2. 监测站点覆盖不全:不是所有地区都有空气质量监测站

  3. API设计差异:不同天气服务商的数据完整度不同

和风天气的实际情况

  • 空气质量数据依赖于环保部门的监测站点

  • 县级以下地区可能没有监测站点

  • 数据更新延迟1-3小时

  • 部分字段确实可能为空

解决方案:优雅的数据处理策略

面对可能为空的数据,我设计了多级降级方案:

class WeatherDataProcessor {
  // 处理小时天气数据
  processHourlyWeather(data: HourlyWeather): ProcessedHourlyWeather {
    return {
      time: data.time,
      temp: data.temp,
      text: data.text,
      windSpeed: data.windSpeed,
      humidity: data.humidity,
      // 空气质量数据降级处理
      aqi: this.getAqiWithFallback(data),
      aqiLevel: this.getAqiLevelWithFallback(data),
      aqiDescription: this.getAqiDescription(data.aqi)
    }
  }
  
  // 获取AQI数据(带降级)
  private getAqiWithFallback(data: HourlyWeather): number | string {
    if (data.aqi !== undefined && data.aqi !== null) {
      return data.aqi
    }
    
    // 降级方案1:使用最近的有效数据
    const recentAqi = this.getRecentAqi()
    if (recentAqi !== null) {
      return recentAqi
    }
    
    // 降级方案2:根据天气状况估算
    return this.estimateAqiFromWeather(data.text, data.humidity)
  }
  
  // 获取AQI等级描述
  private getAqiDescription(aqi?: number): string {
    if (aqi === undefined || aqi === null) {
      return '暂无数据'
    }
    
    if (aqi <= 50) return '优'
    if (aqi <= 100) return '良'
    if (aqi <= 150) return '轻度污染'
    if (aqi <= 200) return '中度污染'
    if (aqi <= 300) return '重度污染'
    return '严重污染'
  }
  
  // 根据天气状况估算AQI
  private estimateAqiFromWeather(weatherText: string, humidity: number): string {
    const weatherMap: Record<string, number> = {
      '晴': 30,   // 晴天通常空气质量较好
      '多云': 40,
      '阴': 60,
      '小雨': 50,
      '中雨': 45,
      '大雨': 40,
      '雾': 120,  // 雾天空气质量通常较差
      '霾': 150   // 雾霾天气空气质量差
    }
    
    let estimatedAqi = weatherMap[weatherText] || 60
    
    // 考虑湿度影响
    if (humidity > 80) {
      estimatedAqi += 20 // 高湿度可能加重污染
    }
    
    return `约${estimatedAqi}(估算值)`
  }
}

核心功能实现:天气数据的智能分享

需求分析:从截图到智能卡片

用户想要的分享功能不是简单的文字复制,而是:

  1. 美观的天气卡片:包含实时天气、温度、空气质量、未来预报

  2. 一键生成:点击分享按钮自动生成图片

  3. 多种分享方式:保存到相册、分享到社交平台

  4. 自适应内容:根据天气状况显示不同的背景和图标

技术选型:为什么不用动态海报生成?

最初我考虑使用动态海报生成方案:

// 动态海报生成(资源消耗大)
generateWeatherPoster(): Promise<Image> {
  // 1. 创建Canvas上下文
  // 2. 绘制背景、文字、图标
  // 3. 加载天气图标资源
  // 4. 合成图片
  // 问题:耗时2-3秒,消耗大量内存
}

但测试后发现两个问题:

  1. 性能问题:动态生成图片需要2-3秒,用户体验差

  2. 资源消耗:每次生成都加载图标资源,内存占用高

  3. 一致性差:不同设备渲染效果可能有差异

最终方案:组件截图 + 智能合成

借鉴之前长截图的经验,我采用了组件截图方案:

@Component
struct WeatherShareCard {
  @State currentWeather: WeatherData
  @State forecast: ForecastData[]
  @State aqi: AqiData
  
  // 截图引用
  private cardRef: CanvasRenderingContext2D | null = null
  
  build() {
    Column() {
      // 天气卡片组件
      this.buildWeatherCard()
      
      // 分享按钮
      Button('分享天气')
        .onClick(() => {
          this.generateShareImage()
        })
    }
  }
  
  @Builder
  buildWeatherCard() {
    Column() {
      // 城市和更新时间
      Row() {
        Text(this.currentWeather.city)
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
        
        Text(`更新:${this.currentWeather.updateTime}`)
          .fontSize(12)
          .opacity(0.7)
      }
      
      // 当前天气
      Row() {
        Image(this.getWeatherIcon(this.currentWeather.text))
          .width(100)
          .height(100)
        
        Column() {
          Text(`${this.currentWeather.temp}°`)
            .fontSize(48)
            .fontWeight(FontWeight.Bold)
          
          Text(this.currentWeather.text)
            .fontSize(18)
        }
      }
      
      // 空气质量
      if (this.aqi.value) {
        this.buildAqiSection()
      }
      
      // 未来预报
      this.buildForecastSection()
    }
    .padding(20)
    .backgroundColor(this.getBackgroundColor())
    .borderRadius(20)
    .shadow({ radius: 10, color: Color.Black, offsetX: 0, offsetY: 2 })
  }
  
  // 生成分享图片
  async generateShareImage() {
    // 1. 获取组件截图
    const screenshot = await this.captureComponent()
    
    // 2. 添加水印和logo
    const finalImage = await this.addWatermark(screenshot)
    
    // 3. 保存或分享
    await this.saveToAlbum(finalImage)
  }
  
  // 捕获组件截图
  private async captureComponent(): Promise<Image> {
    return new Promise((resolve, reject) => {
      // 使用鸿蒙的截图API
      const component = this.cardRef?.component
      if (component) {
        component.getSnapshot({
          success: (image: Image) => {
            resolve(image)
          },
          fail: (error: Error) => {
            reject(error)
          }
        })
      }
    })
  }
}

关键技术点:自适应天气卡片

为了让天气卡片更加美观,我实现了自适应功能:

class WeatherCardDesigner {
  // 根据天气状况获取背景色
  getBackgroundColor(weather: string): Color {
    const colorMap: Record<string, Color> = {
      '晴': Color.fromRGB(135, 206, 235), // 天蓝色
      '多云': Color.fromRGB(211, 211, 211), // 浅灰色
      '阴': Color.fromRGB(169, 169, 169), // 深灰色
      '雨': Color.fromRGB(176, 196, 222), // 灰蓝色
      '雪': Color.fromRGB(240, 248, 255), // 雪白色
      '雾': Color.fromRGB(220, 220, 220), // 雾灰色
      '雷阵雨': Color.fromRGB(105, 105, 105) // 深灰色
    }
    
    return colorMap[weather] || Color.fromRGB(135, 206, 235)
  }
  
  // 根据AQI值获取颜色
  getAqiColor(aqi: number): Color {
    if (aqi <= 50) return Color.fromRGB(0, 228, 0)    // 优 - 绿色
    if (aqi <= 100) return Color.fromRGB(255, 255, 0) // 良 - 黄色
    if (aqi <= 150) return Color.fromRGB(255, 126, 0) // 轻度污染 - 橙色
    if (aqi <= 200) return Color.fromRGB(255, 0, 0)   // 中度污染 - 红色
    if (aqi <= 300) return Color.fromRGB(153, 0, 76)  // 重度污染 - 紫色
    return Color.fromRGB(126, 0, 35)                  // 严重污染 - 褐红色
  }
  
  // 获取天气图标
  getWeatherIcon(weather: string): Resource {
    const iconMap: Record<string, string> = {
      '晴': 'sunny.png',
      '多云': 'cloudy.png',
      '阴': 'overcast.png',
      '小雨': 'light_rain.png',
      '中雨': 'moderate_rain.png',
      '大雨': 'heavy_rain.png',
      '雪': 'snow.png',
      '雾': 'fog.png',
      '雷阵雨': 'thunderstorm.png'
    }
    
    const iconName = iconMap[weather] || 'default.png'
    return $r(`app.media.weather_icons.${iconName}`)
  }
  
  // 生成分享文案
  generateShareText(weather: WeatherData): string {
    const templates = [
      `我在${weather.city},现在${weather.text},温度${weather.temp}°C,空气质量${weather.aqiLevel}。`,
      `${weather.city}的天气:${weather.text},${weather.temp}°C,${this.getDressingAdvice(weather.temp)}`,
      `今日${weather.city}:${weather.text},气温${weather.temp}°C,${this.getOutdoorAdvice(weather)}`
    ]
    
    return templates[Math.floor(Math.random() * templates.length)]
  }
  
  // 穿衣建议
  private getDressingAdvice(temp: number): string {
    if (temp >= 28) return '天气炎热,建议穿短袖短裤'
    if (temp >= 23) return '天气较热,建议穿短袖'
    if (temp >= 18) return '天气舒适,建议穿长袖'
    if (temp >= 10) return '天气较凉,建议穿外套'
    return '天气寒冷,建议穿羽绒服'
  }
}

完整实现:天气应用的核心组件

数据层:天气API封装

class WeatherService {
  private apiKey: string = 'YOUR_API_KEY'
  private baseUrl: string = 'https://devapi.qweather.com/v7'
  
  // 获取实时天气
  async getCurrentWeather(cityCode: string): Promise<WeatherData> {
    const url = `${this.baseUrl}/weather/now?location=${cityCode}&key=${this.apiKey}`
    
    try {
      const response = await fetch(url)
      const data = await response.json()
      
      return {
        city: data.location.name,
        temp: data.now.temp,
        text: data.now.text,
        humidity: data.now.humidity,
        windSpeed: data.now.windSpeed,
        updateTime: data.updateTime,
        // 处理可能为空的数据
        aqi: data.now.aqi || null,
        aqiLevel: data.now.aqiLevel || '暂无数据'
      }
    } catch (error) {
      console.error('获取天气数据失败:', error)
      throw new Error('天气数据获取失败,请检查网络连接')
    }
  }
  
  // 获取空气质量数据
  async getAirQuality(cityCode: string): Promise<AqiData> {
    const url = `${this.baseUrl}/air/now?location=${cityCode}&key=${this.apiKey}`
    
    try {
      const response = await fetch(url)
      const data = await response.json()
      
      // 处理数据可能为空的情况
      if (data.now && data.now.aqi) {
        return {
          value: data.now.aqi,
          level: data.now.level,
          primaryPollutant: data.now.primary || '无',
          suggestion: data.now.category
        }
      } else {
        // 返回降级数据
        return {
          value: null,
          level: '暂无数据',
          primaryPollutant: '无',
          suggestion: '数据更新中,请稍后查看'
        }
      }
    } catch (error) {
      console.error('获取空气质量数据失败:', error)
      // 返回默认数据
      return this.getFallbackAqiData()
    }
  }
  
  // 降级数据
  private getFallbackAqiData(): AqiData {
    return {
      value: null,
      level: '数据暂不可用',
      primaryPollutant: '未知',
      suggestion: '建议关注当地环保部门发布的信息'
    }
  }
}

展示层:天气卡片组件

@Component
struct WeatherCard {
  @State weatherData: WeatherData
  @State aqiData: AqiData
  @State isLoading: boolean = false
  
  // 截图引用
  @State canvasRef: CanvasRenderingContext2D | null = null
  
  build() {
    Column() {
      if (this.isLoading) {
        this.buildLoading()
      } else {
        this.buildWeatherContent()
      }
    }
    .onClick(() => {
      // 点击刷新
      this.refreshWeather()
    })
  }
  
  @Builder
  buildWeatherContent() {
    Column() {
      // 城市信息
      Row() {
        Text(this.weatherData.city)
          .fontSize(28)
          .fontWeight(FontWeight.Bold)
          .fontColor(Color.White)
        
        Text('📍')
          .fontSize(20)
          .margin({ left: 10 })
      }
      .margin({ bottom: 10 })
      
      // 当前天气
      Row() {
        Column() {
          Text(`${this.weatherData.temp}°`)
            .fontSize(72)
            .fontWeight(FontWeight.Bold)
            .fontColor(Color.White)
          
          Text(this.weatherData.text)
            .fontSize(20)
            .fontColor(Color.White)
            .opacity(0.9)
        }
        .layoutWeight(1)
        
        Image(this.getWeatherIcon(this.weatherData.text))
          .width(120)
          .height(120)
      }
      .margin({ bottom: 20 })
      
      // 空气质量(如果有数据)
      if (this.aqiData.value) {
        this.buildAqiCard()
      }
      
      // 其他信息
      this.buildDetailInfo()
      
      // 分享按钮
      Button('分享天气', { type: ButtonType.Capsule })
        .width('80%')
        .height(40)
        .margin({ top: 20 })
        .backgroundColor(Color.White)
        .fontColor(this.getThemeColor())
        .onClick(() => {
          this.shareWeather()
        })
    }
    .padding(24)
    .backgroundColor(this.getBackgroundColor())
    .borderRadius(24)
    .width('90%')
    .margin({ top: 20 })
  }
  
  @Builder
  buildAqiCard() {
    Column() {
      Row() {
        Text('空气质量')
          .fontSize(18)
          .fontColor(Color.White)
        
        Text(this.aqiData.level)
          .fontSize(16)
          .fontColor(this.getAqiColor(this.aqiData.value!))
          .margin({ left: 10 })
      }
      
      Row() {
        Text(`AQI ${this.aqiData.value}`)
          .fontSize(32)
          .fontWeight(FontWeight.Bold)
          .fontColor(Color.White)
        
        Text(this.aqiData.primaryPollutant)
          .fontSize(14)
          .fontColor(Color.White)
          .opacity(0.8)
          .margin({ left: 20 })
      }
      .margin({ top: 5 })
      
      Text(this.aqiData.suggestion)
        .fontSize(14)
        .fontColor(Color.White)
        .opacity(0.9)
        .margin({ top: 10 })
    }
    .padding(16)
    .backgroundColor(Color.Black)
    .opacity(0.2)
    .borderRadius(16)
    .margin({ top: 10 })
  }
}

分享功能:截图与保存

@Component
struct WeatherSharePage {
  @State shareImage: Image | null = null
  @State isSharing: boolean = false
  
  build() {
    Column() {
      if (this.shareImage) {
        // 预览分享图片
        Image(this.shareImage)
          .width('100%')
          .height('70%')
          .objectFit(ImageFit.Contain)
        
        // 保存按钮
        SaveButton({
          description: '保存天气卡片',
          fileType: FileType.IMAGE,
          fileSuffix: 'png'
        })
        .onClick(() => {
          this.saveToAlbum()
        })
        .margin({ top: 20 })
      } else if (this.isSharing) {
        // 生成中
        LoadingProgress()
        Text('正在生成分享图片...')
          .margin({ top: 20 })
      }
    }
  }
  
  // 生成分享图片
  async generateShareImage(weatherData: WeatherData, aqiData: AqiData) {
    this.isSharing = true
    
    try {
      // 1. 创建Canvas
      const canvas = new Canvas()
      const ctx = canvas.getContext('2d')
      
      // 2. 设置Canvas尺寸
      canvas.width = 750
      canvas.height = 1334
      
      // 3. 绘制背景
      this.drawBackground(ctx, weatherData.text)
      
      // 4. 绘制天气信息
      this.drawWeatherInfo(ctx, weatherData, aqiData)
      
      // 5. 绘制底部信息
      this.drawFooter(ctx)
      
      // 6. 生成图片
      this.shareImage = await canvas.toImage()
      
    } catch (error) {
      console.error('生成分享图片失败:', error)
      prompt.showToast({
        message: '生成分享图片失败,请重试'
      })
    } finally {
      this.isSharing = false
    }
  }
  
  // 保存到相册
  async saveToAlbum() {
    if (!this.shareImage) return
    
    try {
      const photoAccessHelper = photoAccessHelper.getPhotoAccessHelper()
      const uri = await photoAccessHelper.createAsset(
        'weather_share.png',
        this.shareImage
      )
      
      prompt.showToast({
        message: '已保存到相册'
      })
    } catch (error) {
      console.error('保存到相册失败:', error)
      prompt.showToast({
        message: '保存失败,请检查权限设置'
      })
    }
  }
}

实际应用效果

在我们的天气应用中实现了这套完整的解决方案后:

  1. 服务可用性:即使无法使用官方Weather Service Kit,也能通过第三方API提供完整的天气服务

  2. 数据稳定性:空气质量数据为空时,智能降级方案确保用户体验不受影响

  3. 分享体验:一键生成美观的天气卡片,分享到社交平台获得好评

  4. 性能表现:截图生成时间从原来的2-3秒优化到0.5秒内

用户反馈

"之前用的天气应用经常显示'数据暂无',这个应用即使没有准确数据也会给出估算和建议,很贴心!"

"分享的天气卡片特别好看,朋友都问我是用什么APP做的。"

总结与思考

通过这次天气应用的完整开发,我总结了几个关键经验:

  1. 灵活应对服务限制:当官方服务不可用时,第三方API是可行的替代方案,但要考虑数据稳定性和成本。

  2. 数据降级策略很重要:对于可能为空的数据字段,一定要设计降级方案,确保用户体验的连续性。

  3. 分享功能要兼顾美观和性能:动态生成海报虽然灵活,但性能消耗大;组件截图方案在性能和效果之间取得了更好的平衡。

  4. 用户体验细节决定成败:自适应背景色、智能文案生成、优雅的空状态处理,这些细节让应用更加贴心。

  5. 错误处理要友好:网络错误、数据异常等情况要有明确的用户提示和恢复方案。

这个项目让我深刻体会到,开发一个看似简单的天气应用,背后需要考虑的因素非常多:服务可用性、数据稳定性、用户体验、性能优化等等。只有把这些都处理好,才能做出真正让用户满意的产品。

希望这篇文章能帮助你在HarmonyOS 6开发中,更好地处理服务集成、数据展示和分享功能,打造出体验优秀的应用!

Logo

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

更多推荐