CodeRoad Map 程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。
由 程序员柏风 独立维护 · 关于作者
学习 更多
综合练习:做一个完整的天气 App
这一课你将能做到
把前 11 课的知识串起来:环境 → Dart → Widget → 状态 → 动画 → 网络 → 存储 → 测试 → 发布
从零搭建一个真实可发布 的天气 App,包含城市搜索、当前天气、未来预报、收藏城市
走完「开发 → 测试 → 打包 → 上传 Firebase App Distribution」的完整流程
为什么要学
知识不串起来就是散沙。本课用一个真实复杂度 的项目,把所有单点技能连成线。
人话:前 11 课教你做菜的各种刀工火候,这一课让你做一桌完整的宴席。
先建立图像
┌────────────────────────────────────────┐
│ WeatherApp │
├────────────────────────────────────────┤
│ UI 层 (Widget) │
│ ├── HomePage (当前天气 + 预报) │
│ ├── SearchPage (搜索城市) │
│ └── FavoritePage (收藏) │
├────────────────────────────────────────┤
│ 状态层 (Riverpod) │
│ ├── weatherProvider │
│ ├── favoritesProvider │
│ └── searchProvider │
├────────────────────────────────────────┤
│ 数据层 │
│ ├── WeatherRepository (API) │
│ └── LocalStorage (SharedPreferences)│
└────────────────────────────────────────┘
跟着做
步骤 1:初始化项目
flutter create weather_app
cd weather_app
pubspec.yaml:
name: weather_app
description: A weather app.
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ^3.5.0
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.5.0
dio: ^5.4.0
shared_preferences: ^2.2.0
intl: ^0.19.0
json_annotation: ^4.9.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^4.0.0
mocktail: ^1.0.0
flutter:
uses-material-design: true
步骤 2:定义数据模型
lib/models/weather.dart:
class Weather {
final String city;
final double temperature;
final String description;
final int humidity;
final double windSpeed;
final List<Forecast> forecast;
Weather({
required this.city,
required this.temperature,
required this.description,
required this.humidity,
required this.windSpeed,
required this.forecast,
});
factory Weather.fromJson(Map<String, dynamic> json) => Weather(
city: json['name'] as String,
temperature: (json['main']['temp'] as num).toDouble(),
description: json['weather'][0]['description'] as String,
humidity: json['main']['humidity'] as int,
windSpeed: (json['wind']['speed'] as num).toDouble(),
forecast: [],
);
}
class Forecast {
final DateTime date;
final double high;
final double low;
final String description;
Forecast({required this.date, required this.high, required this.low, required this.description});
}
步骤 3:写 Repository(网络层) lib/data/weather_repository.dart:
import 'package:dio/dio.dart';
import '../models/weather.dart';
class WeatherRepository {
final Dio _dio;
static const _apiKey = 'YOUR_OPENWEATHER_API_KEY';
static const _baseUrl = 'https://api.openweathermap.org/data/2.5';
WeatherRepository({Dio? dio})
: _dio = dio ?? Dio(BaseOptions(baseUrl: _baseUrl, connectTimeout: const Duration(seconds: 10)));
Future<Weather> getCurrentWeather(String city) async {
final resp = await _dio.get('/weather', queryParameters: {
'q': city,
'appid': _apiKey,
'units': 'metric',
'lang': 'zh_cn',
});
return Weather.fromJson(resp.data as Map<String, dynamic>);
}
Future<List<String>> searchCities(String query) async {
final resp = await _dio.get(
'http://api.openweathermap.org/geo/1.0/direct',
queryParameters: {'q': query, 'limit': 5, 'appid': _apiKey},
);
return (resp.data as List).map((e) => e['name'] as String).toList();
}
}
步骤 4:本地收藏存储 lib/data/favorites_storage.dart:
import 'package:shared_preferences/shared_preferences.dart';
class FavoritesStorage {
static const _key = 'favorite_cities';
Future<List<String>> load() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getStringList(_key) ?? [];
}
Future<void> save(List<String> cities) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(_key, cities);
}
}
步骤 5:Riverpod 状态层 lib/state/providers.dart:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/weather_repository.dart';
import '../data/favorites_storage.dart';
import '../models/weather.dart';
final repositoryProvider = Provider((ref) => WeatherRepository());
final storageProvider = Provider((ref) => FavoritesStorage());
final currentCityProvider = StateProvider<String>((ref) => '北京');
final weatherProvider = FutureProvider<Weather>((ref) async {
final city = ref.watch(currentCityProvider);
final repo = ref.watch(repositoryProvider);
return repo.getCurrentWeather(city);
});
class FavoritesNotifier extends StateNotifier<List<String>> {
final FavoritesStorage _storage;
FavoritesNotifier(this._storage) : super([]) {
_load();
}
Future<void> _load() async => state = await _storage.load();
Future<void> toggle(String city) async {
state = state.contains(city)
? state.where((c) => c != city).toList()
: [...state, city];
await _storage.save(state);
}
}
final favoritesProvider = StateNotifierProvider<FavoritesNotifier, List<String>>(
(ref) => FavoritesNotifier(ref.watch(storageProvider)),
);
步骤 6:写主页面 UI lib/pages/home_page.dart:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../state/providers.dart';
import 'search_page.dart';
import 'favorite_page.dart';
class HomePage extends ConsumerWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final weatherAsync = ref.watch(weatherProvider);
final city = ref.watch(currentCityProvider);
final favorites = ref.watch(favoritesProvider);
final isFav = favorites.contains(city);
return Scaffold(
appBar: AppBar(
title: Text(city),
actions: [
IconButton(
icon: Icon(isFav ? Icons.favorite : Icons.favorite_border,
color: isFav ? Colors.red : null),
onPressed: () => ref.read(favoritesProvider.notifier).toggle(city),
),
IconButton(
icon: const Icon(Icons.search),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SearchPage()),
),
),
IconButton(
icon: const Icon(Icons.list),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const FavoritePage()),
),
),
],
),
body: weatherAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('加载失败: $e')),
data: (w) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('${w.temperature.toStringAsFixed(0)}°',
style: const TextStyle(fontSize: 96, fontWeight: FontWeight.w200)),
Text(w.description, style: const TextStyle(fontSize: 24)),
const SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_infoTile('湿度', '${w.humidity}%'),
_infoTile('风速', '${w.windSpeed} m/s'),
],
),
],
),
),
),
);
}
Widget _infoTile(String label, String value) => Column(children: [
Text(label, style: const TextStyle(color: Colors.grey)),
const SizedBox(height: 4),
Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
]);
}
步骤 7:搜索和收藏页 lib/pages/search_page.dart:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../state/providers.dart';
class SearchPage extends ConsumerStatefulWidget {
const SearchPage({super.key});
@override
ConsumerState<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends ConsumerState<SearchPage> {
final _ctrl = TextEditingController();
List<String> _results = [];
Future<void> _search(String q) async {
if (q.isEmpty) return setState(() => _results = []);
try {
final r = await ref.read(repositoryProvider).searchCities(q);
setState(() => _results = r);
} catch (_) {}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: TextField(
controller: _ctrl,
autofocus: true,
decoration: const InputDecoration(hintText: '搜索城市'),
onChanged: _search,
),
),
body: ListView.builder(
itemCount: _results.length,
itemBuilder: (ctx, i) => ListTile(
title: Text(_results[i]),
onTap: () {
ref.read(currentCityProvider.notifier).state = _results[i];
Navigator.pop(context);
},
),
),
);
}
}
lib/pages/favorite_page.dart:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../state/providers.dart';
class FavoritePage extends ConsumerWidget {
const FavoritePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final favorites = ref.watch(favoritesProvider);
return Scaffold(
appBar: AppBar(title: const Text('收藏')),
body: favorites.isEmpty
? const Center(child: Text('暂无收藏'))
: ListView.builder(
itemCount: favorites.length,
itemBuilder: (ctx, i) {
final city = favorites[i];
return ListTile(
key: ValueKey(city),
title: Text(city),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () =>
ref.read(favoritesProvider.notifier).toggle(city),
),
onTap: () {
ref.read(currentCityProvider.notifier).state = city;
Navigator.pop(context);
},
);
},
),
);
}
}
步骤 8:入口 import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'pages/home_page.dart';
void main() => runApp(const ProviderScope(child: MyApp()));
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '天气',
theme: ThemeData(colorSchemeSeed: Colors.blue, useMaterial3: true),
home: const HomePage(),
);
}
}
步骤 9:写测试 import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:weather_app/data/weather_repository.dart';
class MockDio extends Mock implements Dio {}
class MockResponse extends Mock implements Response<dynamic> {}
void main() {
test('解析天气 JSON', () async {
final dio = MockDio();
final resp = MockResponse();
when(() => resp.data).thenReturn({
'name': '北京',
'main': {'temp': 22.5, 'humidity': 40},
'weather': [{'description': '晴'}],
'wind': {'speed': 3.2},
});
when(() => dio.get(any(), queryParameters: any(named: 'queryParameters')))
.thenAnswer((_) async => resp);
final repo = WeatherRepository(dio: dio);
final w = await repo.getCurrentWeather('北京');
expect(w.city, '北京');
expect(w.temperature, 22.5);
});
}
步骤 10:发布 # Android release
flutter build apk --release
# 上传到 Firebase App Distribution(需先 firebase login + 配置)
firebase appdistribution:distribute build/app/outputs/flutter-apk/app-release.apk \
--app YOUR_FIREBASE_APP_ID \
--groups testers
讲清楚要点
分层清晰 :UI(pages)→ 状态(state)→ 数据(data)→ 模型(models)。每层职责单一。
依赖注入靠 Provider :repositoryProvider、storageProvider 在测试时可 override。
状态提升 + Riverpod :currentCityProvider 是跨页面共享;favoritesProvider 是 StateNotifier 处理业务逻辑。
错误处理用 AsyncValue :weatherAsync.when(loading/error/data) 三态分明。
测试隔离 :Mock Dio 测 Repository;override Provider 测 Widget。
你可能会懵的地方
为什么用 FutureProvider 不用 FutureBuilder?
FutureProvider 把数据提升到顶层,多个 Widget 共享同一份;FutureBuilder 只能在当前 Widget 用,且 future 容易在 rebuild 时被重建。
StateNotifier 什么时候用?
状态有「加载/修改/持久化」三步,且对外暴露动作(toggle)而不是只读字段。
API Key 不能提交怎么办?
用 --dart-define 注入:flutter run --dart-define=API_KEY=xxx,代码里 String.fromEnvironment('API_KEY')。
错法对照 错法 问题 正解 在 build 里直接 ref.read(weatherProvider.future) 不响应数据变化 用 ref.watch(weatherProvider) 拿 AsyncValue 把 API Key 写在源码并推到 GitHub 被爬走滥用 dart-define 或 secrets 所有逻辑写在 Widget 里 难测试 拆 Repository + Provider 测试直接发真实请求 慢且不稳 Mock Dio
卡点急救
API 返回 401 :API Key 没填或没激活(OpenWeather 注册后等 1-2 小时)。
iOS 网络请求失败 :Info.plist 加 NSAppTransportSecurity 允许 HTTPS。
Riverpod ref.watch 报错 :确认在 ConsumerWidget / ConsumerStatefulWidget 里。
面试常问
怎么设计一个 Flutter 项目的目录结构?
按层:pages / state / data / models / utils;或按 feature:features/home / features/search,每个 feature 内部再分层。
Riverpod 怎么做依赖注入?
用 Provider 暴露服务;测试时 ProviderScope(overrides: [repositoryProvider.overrideWithValue(mock)])。
自测三问
这个项目的「状态提升」体现在哪里?
如果要加「未来 5 天预报」,应该改哪几个文件?
怎么给 HomePage 写 Widget 测试?
小结一句话 把前 11 课串起来:Dart 写逻辑、Widget 画 UI、Riverpod 管状态、Dio 发请求、SharedPreferences 存本地、测试保质量、CI 做发布 —— 一个真实 App 就成型了。
延伸