天气App是学习网络请求和JSON解析的绝佳项目。它需要从API获取数据、解析复杂的JSON结构、根据数据动态渲染UI,还涉及定位服务获取用户位置。本课将使用OpenWeatherMap API+geolocator定位+flutter_dotenv安全管理密钥。
天气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,
);
}
// 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;
}
// 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);
}
}
// 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)],
);
}
}
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不包含敏感信息
天气数据不需要实时请求——每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;
}
}
你已构建了一个天气App!掌握了:
下一课预告:聊天App——WebSocket实时通信、消息列表、在线状态,让你的App实时连接!