🌤️ 22 - 天气App

阶段五:实战项目 · 第22课
✅ 验证通过

📖 核心概念

天气App是学习网络请求和JSON解析的绝佳项目。它需要从API获取数据、解析复杂的JSON结构、根据数据动态渲染UI,还涉及定位服务获取用户位置。本课将使用OpenWeatherMap API+geolocator定位+flutter_dotenv安全管理密钥。

🌤️ 天气数据模型

1. 响应式数据建模

天气API返回的JSON通常嵌套很深。我们需要设计清晰的Dart模型来映射这些数据。

// lib/models/weather.dart
class WeatherData {
  final String cityName;
  final String country;
  final double temp;        // 当前温度
  final double feelsLike;   // 体感温度
  final double tempMin;
  final double tempMax;
  final int humidity;        // 湿度
  final int pressure;        // 气压
  final double windSpeed;
  final int windDeg;
  final int visibility;
  final int clouds;
  final String description;
  final String icon;
  final DateTime sunrise;
  final DateTime sunset;
  final DateTime dt;         // 数据时间

  const WeatherData({
    required this.cityName,
    required this.country,
    required this.temp,
    required this.feelsLike,
    required this.tempMin,
    required this.tempMax,
    required this.humidity,
    required this.pressure,
    required this.windSpeed,
    required this.windDeg,
    required this.visibility,
    required this.clouds,
    required this.description,
    required this.icon,
    required this.sunrise,
    required this.sunset,
    required this.dt,
  });

  factory WeatherData.fromJson(Map<String, dynamic> json) => WeatherData(
    cityName: json['name'] as String,
    country: (json['sys'] as Map)['country'] as String,
    temp: (json['main'] as Map)['temp'] as double,
    feelsLike: (json['main'] as Map)['feels_like'] as double,
    tempMin: (json['main'] as Map)['temp_min'] as double,
    tempMax: (json['main'] as Map)['temp_max'] as double,
    humidity: (json['main'] as Map)['humidity'] as int,
    pressure: (json['main'] as Map)['pressure'] as int,
    windSpeed: (json['wind'] as Map)['speed'] as double,
    windDeg: (json['wind'] as Map)['deg'] as int? ?? 0,
    visibility: json['visibility'] as int? ?? 10000,
    clouds: (json['clouds'] as Map)['all'] as int,
    description: ((json['weather'] as List)[0] as Map)['description'] as String,
    icon: ((json['weather'] as List)[0] as Map)['icon'] as String,
    sunrise: DateTime.fromMillisecondsSinceEpoch(
      (json['sys'] as Map)['sunrise'] as int * 1000),
    sunset: DateTime.fromMillisecondsSinceEpoch(
      (json['sys'] as Map)['sunset'] as int * 1000),
    dt: DateTime.fromMillisecondsSinceEpoch(json['dt'] as int * 1000),
  );

  // 天气图标映射
  IconData get weatherIcon => {
    '01d': Icons.wb_sunny, '01n': Icons.nightlight,
    '02d': Icons.wb_cloudy, '02n': Icons.cloud,
    '03d': Icons.cloud, '03n': Icons.cloud,
    '04d': Icons.cloud_queue, '04n': Icons.cloud_queue,
    '09d': Icons.grain, '09n': Icons.grain,
    '10d': Icons.water_drop, '10n': Icons.water_drop,
    '11d': Icons.flash_on, '11n': Icons.flash_on,
    '13d': Icons.ac_unit, '13n': Icons.ac_unit,
    '50d': Icons.blur_on, '50n': Icons.blur_on,
  }[icon] ?? Icons.cloud;

  // 温度显示(保留1位小数)
  String get tempStr => '${temp.toStringAsFixed(1)}°C';
  String get feelsLikeStr => '${feelsLike.toStringAsFixed(1)}°C';
}

// 预报数据
class ForecastData {
  final DateTime dt;
  final double temp;
  final String description;
  final String icon;

  const ForecastData({
    required this.dt, required this.temp,
    required this.description, required this.icon,
  });

  factory ForecastData.fromJson(Map<String, dynamic> json) => ForecastData(
    dt: DateTime.fromMillisecondsSinceEpoch(json['dt'] as int * 1000),
    temp: (json['main'] as Map)['temp'] as double,
    description: ((json['weather'] as List)[0] as Map)['description'] as String,
    icon: ((json['weather'] as List)[0] as Map)['icon'] as String,
  );
}

🌐 HTTP请求与API交互

2. 天气API服务封装

// lib/services/weather_service.dart
class WeatherService {
  static const _baseUrl = 'https://api.openweathermap.org/data/2.5';
  final http.Client _client;
  final String _apiKey;

  WeatherService({required String apiKey, http.Client? client})
    : _apiKey = apiKey, _client = client ?? http.Client();

  // 按城市名获取天气
  Future<WeatherData> getWeatherByCity(String city) async {
    final response = await _client.get(Uri.parse(
      '$_baseUrl/weather?q=$city&appid=$_apiKey&units=metric&lang=zh_cn'
    ));
    _checkResponse(response);
    return WeatherData.fromJson(jsonDecode(response.body));
  }

  // 按坐标获取天气
  Future<WeatherData> getWeatherByLocation(double lat, double lon) async {
    final response = await _client.get(Uri.parse(
      '$_baseUrl/weather?lat=$lat&lon=$lon&appid=$_apiKey&units=metric&lang=zh_cn'
    ));
    _checkResponse(response);
    return WeatherData.fromJson(jsonDecode(response.body));
  }

  // 获取5天预报
  Future<List<ForecastData>> getForecast(double lat, double lon) async {
    final response = await _client.get(Uri.parse(
      '$_baseUrl/forecast?lat=$lat&lon=$lon&appid=$_apiKey&units=metric&lang=zh_cn'
    ));
    _checkResponse(response);
    final list = (jsonDecode(response.body)['list'] as List);
    // 每隔8个取一个(每天一个数据点,3小时一个共8个)
    return list
      .where((i) => DateTime.fromMillisecondsSinceEpoch(i['dt'] * 1000).hour == 12)
      .map((i) => ForecastData.fromJson(i))
      .toList();
  }

  void _checkResponse(http.Response response) {
    if (response.statusCode != 200) {
      final msg = jsonDecode(response.body)['message'] ?? 'Unknown error';
      throw WeatherException('API错误 (${response.statusCode}): $msg');
    }
  }
}

class WeatherException implements Exception {
  final String message;
  WeatherException(this.message);
  @override
  String toString() => message;
}

📍 定位服务集成

3. Geolocator获取用户位置

// lib/services/location_service.dart
class LocationService {
  Future<({double lat, double lon})> getCurrentLocation() async {
    // 检查定位权限
    bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
    if (!serviceEnabled) {
      throw LocationException('请开启定位服务');
    }

    LocationPermission permission = await Geolocator.checkPermission();
    if (permission == LocationPermission.denied) {
      permission = await Geolocator.requestPermission();
      if (permission == LocationPermission.denied) {
        throw LocationException('定位权限被拒绝');
      }
    }
    if (permission == LocationPermission.deniedForever) {
      throw LocationException('定位权限被永久拒绝,请在设置中开启');
    }

    // 获取当前位置
    final position = await Geolocator.getCurrentPosition(
      locationSettings: const LocationSettings(
        accuracy: LocationAccuracy.medium,
        timeLimit: Duration(seconds: 10),
      ),
    );

    return (lat: position.latitude, lon: position.longitude);
  }
}

📱 动态UI渲染

4. 天气主页面

// lib/pages/weather_page.dart
class WeatherPage extends StatefulWidget {
  @override
  State<WeatherPage> createState() => _WeatherPageState();
}

class _WeatherPageState extends State<WeatherPage> {
  WeatherData? _weather;
  List<ForecastData>? _forecast;
  bool _isLoading = false;
  String? _error;

  @override
  void initState() {
    super.initState();
    _loadWeatherByLocation();
  }

  Future<void> _loadWeatherByLocation() async {
    setState(() { _isLoading = true; _error = null; });
    try {
      final loc = await LocationService().getCurrentLocation();
      final service = WeatherService(apiKey: dotenv.env['OPENWEATHER_KEY']!);
      final results = await Future.wait([
        service.getWeatherByLocation(loc.lat, loc.lon),
        service.getForecast(loc.lat, loc.lon),
      ]);
      setState(() {
        _weather = results[0] as WeatherData;
        _forecast = results[1] as List<ForecastData>;
        _isLoading = false;
      });
    } catch (e) {
      setState(() {
        _error = e.toString();
        _isLoading = false;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: RefreshIndicator(
        onRefresh: _loadWeatherByLocation,
        child: CustomScrollView(
          slivers: [
            SliverAppBar(
              expandedHeight: 300,
              pinned: true,
              flexibleSpace: _buildWeatherHeader(),
            ),
            SliverToBoxAdapter(child: _buildWeatherDetails()),
            SliverToBoxAdapter(child: _buildForecast()),
          ],
        ),
      ),
    );
  }

  Widget _buildWeatherHeader() {
    if (_isLoading) return const Center(child: CircularProgressIndicator());
    if (_error != null) return Center(child: Text(_error!));
    if (_weather == null) return const SizedBox();

    return FlexibleSpaceBar(
      title: Text(_weather!.cityName,
        style: const TextStyle(fontSize: 20)),
      background: Container(
        decoration: BoxDecoration(
          gradient: _buildGradient(_weather!),
        ),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(_weather!.weatherIcon, size: 80, color: Colors.white),
            const SizedBox(height: 8),
            Text(_weather!.tempStr,
              style: const TextStyle(fontSize: 64, fontWeight: FontWeight.w300,
                color: Colors.white)),
            Text(_weather!.description,
              style: const TextStyle(fontSize: 18, color: Colors.white70)),
          ],
        ),
      ),
    );
  }

  // 根据天气动态生成背景渐变
  LinearGradient _buildGradient(WeatherData w) {
    final isNight = w.icon.endsWith('n');
    if (isNight) return const LinearGradient(
      colors: [Color(0xFF0f0c29), Color(0xFF302b63)],
      begin: Alignment.topCenter, end: Alignment.bottomCenter,
    );
    if (w.clouds > 70) return const LinearGradient(
      colors: [Color(0xFF636e72), Color(0xFFb2bec3)],
    );
    return const LinearGradient(
      colors: [Color(0xFF56CCF2), Color(0xFF2F80ED)],
    );
  }
}
✓ 定位获取:自动获取用户位置并加载天气 ✓ 动态背景:晴天/阴天/夜晚不同渐变色 ✓ 天气详情:温度/湿度/气压/风速/能见度 ✓ 5天预报:横向滚动预报列表 ✓ 下拉刷新:RefreshIndicator更新数据 ✓ 搜索城市:输入城市名查看天气 ✓ 错误处理:网络错误/权限拒绝友好提示

🔒 安全与性能优化

5. API密钥安全管理

API密钥绝不能硬编码在代码中。使用flutter_dotenv.env文件加载,并在.gitignore中排除。

// 1. 安装 flutter_dotenv
// dart pub add flutter_dotenv

// 2. 创建 .env 文件(不要提交到Git!)
// OPENWEATHER_KEY=your_api_key_here
// .gitignore 添加:.env

// 3. 在main.dart中加载
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await dotenv.load(fileName: ".env");
  runApp(const WeatherApp());
}

// 4. 在代码中使用
class WeatherService {
  String get _apiKey => dotenv.env['OPENWEATHER_KEY'] ?? '';
  // ...
}

// 5. Android混淆配置 (proguard-rules.pro)
// -keep class io.flutter.app.** { *; }
// -keep class io.flutter.plugin.** { *; }

// 6. iOS检查Info.plist不包含敏感信息

6. 天气数据缓存策略

天气数据不需要实时请求——每15分钟更新一次已足够。实现智能缓存减少API调用。

// 天气缓存管理
class WeatherCache {
  final SharedPreferences _prefs;
  static const _cacheKey = 'weather_cache';
  static const _cacheTimeKey = 'weather_cache_time';
  static const _cacheDuration = Duration(minutes: 15);

  WeatherCache(this._prefs);

  // 读取缓存
  WeatherData? get cached {
    final time = _prefs.getInt(_cacheTimeKey);
    if (time == null) return null;

    final age = DateTime.now().millisecondsSinceEpoch - time;
    if (age > _cacheDuration.inMilliseconds) return null; // 过期

    final json = _prefs.getString(_cacheKey);
    if (json == null) return null;

    return WeatherData.fromJson(jsonDecode(json));
  }

  // 写入缓存
  Future<void> save(WeatherData weather) async {
    await _prefs.setString(_cacheKey, jsonEncode(weather.toJson()));
    await _prefs.setInt(_cacheTimeKey,
      DateTime.now().millisecondsSinceEpoch);
  }

  // 获取策略:缓存优先,过期则网络请求
  Future<WeatherData> getWeather(double lat, double lon) async {
    final cached = this.cached;
    if (cached != null) return cached;

    final fresh = await WeatherService().getWeatherByLocation(lat, lon);
    await save(fresh);
    return fresh;
  }
}

📝 练习

  1. 添加天气动画效果——下雨时显示雨滴粒子动画,下雪时显示雪花飘落
  2. 实现多城市管理——收藏多个城市,左右滑动切换
  3. 添加空气质量指数(AQI)展示——调用额外API获取AQI数据
  4. 实现离线缓存——最后一次成功获取的数据缓存到本地,无网络时显示缓存数据

🏆 成就解锁:气象达人

你已构建了一个天气App!掌握了:

下一课预告:聊天App——WebSocket实时通信、消息列表、在线状态,让你的App实时连接!