CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Flutter 开发入门/课时/综合练习
RawBlame
课时目录12 节

Flutter 开发入门

  1. 01Flutter 环境
  2. 02Dart 语言
  3. 03Widget 体系
  4. 04架构原则
  5. 05状态管理
  6. 06动画
  7. 07网络与本地存储
  8. 08Firebase 集成
  9. 09测试
  10. 10CI/CD 与发布
  11. 11分析与监控
  12. 12综合练习

综合练习

Todo + Firebase 小应用。

30 分钟

综合练习:做一个完整的天气 App

这一课你将能做到

  1. 把前 11 课的知识串起来:环境 → Dart → Widget → 状态 → 动画 → 网络 → 存储 → 测试 → 发布
  2. 从零搭建一个真实可发布的天气 App,包含城市搜索、当前天气、未来预报、收藏城市
  3. 走完「开发 → 测试 → 打包 → 上传 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();
  }
}

注册 <https://openweathermap.org> 拿免费 API Key,每分钟 60 次调用够学习用。

步骤 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:入口

lib/main.dart:

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:写测试

test/weather_test.dart:

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);
  });
}

跑:flutter test

步骤 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

讲清楚要点

  1. 分层清晰:UI(pages)→ 状态(state)→ 数据(data)→ 模型(models)。每层职责单一。
  2. 依赖注入靠 Provider:repositoryProvider、storageProvider 在测试时可 override。
  3. 状态提升 + Riverpod:currentCityProvider 是跨页面共享;favoritesProvider 是 StateNotifier 处理业务逻辑。
  4. 错误处理用 AsyncValue:weatherAsync.when(loading/error/data) 三态分明。
  5. 测试隔离: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

卡点急救

  1. API 返回 401:API Key 没填或没激活(OpenWeather 注册后等 1-2 小时)。
  2. iOS 网络请求失败:Info.plist 加 NSAppTransportSecurity 允许 HTTPS。
  3. Riverpod ref.watch 报错:确认在 ConsumerWidget / ConsumerStatefulWidget 里。

面试常问

  • 怎么设计一个 Flutter 项目的目录结构? 按层:pages / state / data / models / utils;或按 feature:features/home / features/search,每个 feature 内部再分层。

  • Riverpod 怎么做依赖注入? 用 Provider 暴露服务;测试时 ProviderScope(overrides: [repositoryProvider.overrideWithValue(mock)])。

自测三问

  1. 这个项目的「状态提升」体现在哪里?
  2. 如果要加「未来 5 天预报」,应该改哪几个文件?
  3. 怎么给 HomePage 写 Widget 测试?

小结一句话

把前 11 课串起来:Dart 写逻辑、Widget 画 UI、Riverpod 管状态、Dio 发请求、SharedPreferences 存本地、测试保质量、CI 做发布 —— 一个真实 App 就成型了。

延伸

  • OpenWeather API:<https://openweathermap.org/api>
  • Riverpod 实战:<https://riverpod.dev/docs/cookbooks/>
  • 你已经完成全部 12 课!去接 Firebase(第 8 课)把这个 App 加上登录和同步收藏,或者直接上架。
加载本节练习…
← 上一课分析与监控