摘要:本文通过完整的实战项目,综合运用Flutter核心知识,从零到一构建一个新闻阅读App。涵盖项目架构设计、网络请求、状态管理、本地存储、主题切换等核心功能,帮助你将理论知识转化为实际开发能力。
关键词:Flutter、实战项目、新闻App、Provider、网络请求、本地缓存、主题切换
适合人群:已掌握Flutter基础、状态管理、网络存储的开发者
阅读时间:约 60 分钟
版本信息:Flutter 3.x | Dart 3.x | http 1.x | provider 6.x
文章目录
- [1. 项目概述](#1. 项目概述)
-
- [1.1 功能需求](#1.1 功能需求)
- [1.2 技术栈](#1.2 技术栈)
- [1.3 项目结构](#1.3 项目结构)
- [2. 项目初始化](#2. 项目初始化)
-
- [2.1 创建项目](#2.1 创建项目)
- [2.2 添加依赖](#2.2 添加依赖)
- [3. 数据层](#3. 数据层)
-
- [3.1 数据模型](#3.1 数据模型)
- [3.2 网络服务](#3.2 网络服务)
- [3.3 本地缓存](#3.3 本地缓存)
- [4. 状态管理](#4. 状态管理)
-
- [4.1 新闻状态](#4.1 新闻状态)
- [4.2 主题状态](#4.2 主题状态)
- [5. UI 层](#5. UI 层)
-
- [5.1 首页](#5.1 首页)
- [5.2 新闻详情](#5.2 新闻详情)
- [5.3 设置页](#5.3 设置页)
- [6. 路由配置](#6. 路由配置)
- [7. 完整代码](#7. 完整代码)
- [❓ 常见问题 FAQ](#❓ 常见问题 FAQ)
-
- [1. 如何添加更多页面?](#1. 如何添加更多页面?)
- [2. 如何实现分页加载?](#2. 如何实现分页加载?)
- [3. 如何优化图片加载?](#3. 如何优化图片加载?)
- [4. 如何添加搜索功能?](#4. 如何添加搜索功能?)
- [📝 总结](#📝 总结)
- [📚 参考资料](#📚 参考资料)
1. 项目概述
1.1 功能需求
构建一个新闻阅读App,包含以下功能:
- 新闻列表展示(支持分页)
- 新闻详情查看
- 收藏功能
- 主题切换(亮色/暗色)
- 离线缓存
1.2 技术栈
| 技术 | 用途 |
|---|---|
| Provider | 状态管理 |
| http | 网络请求 |
| SharedPreferences | 本地存储 |
| cached_network_image | 图片缓存 |
1.3 项目结构
lib/
├── main.dart
├── models/
│ └── news.dart
├── services/
│ ├── api_service.dart
│ └── storage_service.dart
├── providers/
│ ├── news_provider.dart
│ └── theme_provider.dart
└── screens/
├── home_screen.dart
├── detail_screen.dart
└── settings_screen.dart
2. 项目初始化
2.1 创建项目
bash
flutter create news_app
cd news_app
2.2 添加依赖
编辑 pubspec.yaml:
yaml
dependencies:
flutter:
sdk: flutter
http: ^1.1.0
provider: ^6.0.0
shared_preferences: ^2.2.0
cached_network_image: ^3.3.0
intl: ^0.18.0
3. 数据层
3.1 数据模型
models/news.dart:
dart
class NewsModel {
final String id;
final String title;
final String summary;
final String imageUrl;
final String content;
final String author;
final DateTime publishDate;
final bool isFavorite;
NewsModel({
required this.id,
required this.title,
required this.summary,
required this.imageUrl,
required this.content,
required this.author,
required this.publishDate,
this.isFavorite = false,
});
factory NewsModel.fromJson(Map<String, dynamic> json) {
return NewsModel(
id: json['id']?.toString() ?? '',
title: json['title'] ?? '',
summary: json['summary'] ?? '',
imageUrl: json['image_url'] ?? '',
content: json['content'] ?? '',
author: json['author'] ?? '',
publishDate: DateTime.parse(json['publish_date']),
isFavorite: json['is_favorite'] ?? false,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'summary': summary,
'image_url': imageUrl,
'content': content,
'author': author,
'publish_date': publishDate.toIso8601String(),
'is_favorite': isFavorite,
};
}
}
3.2 网络服务
services/api_service.dart:
dart
import 'package:http/http.dart' as http;
import 'dart:convert';
import '../models/news.dart';
class ApiService {
static const String baseUrl = 'https://newsapi.example.com';
// 获取新闻列表
static Future<List<NewsModel>> getNewsList({int page = 1, int pageSize = 20}) async {
final response = await http.get(
Uri.parse('$baseUrl/news?page=$page&pageSize=$pageSize'),
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
return (data['news'] as List)
.map((item) => NewsModel.fromJson(item))
.toList();
} else {
throw Exception('获取新闻失败');
}
}
// 获取新闻详情
static Future<NewsModel> getNewsDetail(String id) async {
final response = await http.get(
Uri.parse('$baseUrl/news/$id'),
);
if (response.statusCode == 200) {
return NewsModel.fromJson(json.decode(response.body));
} else {
throw Exception('获取详情失败');
}
}
}
3.3 本地缓存
services/storage_service.dart:
dart
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';
import '../models/news.dart';
class StorageService {
static const String _favoritesKey = 'favorites';
static const String _themeKey = 'isDarkTheme';
// 获取收藏列表
static Future<List<NewsModel>> getFavorites() async {
final prefs = await SharedPreferences.getInstance();
final data = prefs.getStringList(_favoritesKey) ?? [];
return data.map((item) => NewsModel.fromJson(json.decode(item))).toList();
}
// 保存收藏列表
static Future<void> saveFavorites(List<NewsModel> favorites) async {
final prefs = await SharedPreferences.getInstance();
final data = favorites.map((item) => json.encode(item.toJson())).toList();
await prefs.setStringList(_favoritesKey, data);
}
// 获取主题设置
static Future<bool> getTheme() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_themeKey) ?? false;
}
// 保存主题设置
static Future<void> saveTheme(bool isDark) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_themeKey, isDark);
}
}
4. 状态管理
4.1 新闻状态
providers/news_provider.dart:
dart
import 'package:flutter/material.dart';
import '../models/news.dart';
import '../services/api_service.dart';
import '../services/storage_service.dart';
class NewsProvider with ChangeNotifier {
List<NewsModel> _newsList = [];
List<NewsModel> _favorites = [];
bool _isLoading = false;
String? _error;
List<NewsModel> get newsList => _newsList;
List<NewsModel> get favorites => _favorites;
bool get isLoading => _isLoading;
String? get error => _error;
// 加载新闻
Future<void> loadNews() async {
_isLoading = true;
_error = null;
notifyListeners();
try {
_newsList = await ApiService.getNewsList();
_favorites = await StorageService.getFavorites();
_updateFavorites();
} catch (e) {
_error = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
// 收藏/取消收藏
Future<void> toggleFavorite(NewsModel news) async {
final index = _newsList.indexWhere((n) => n.id == news.id);
if (index != -1) {
_newsList[index] = NewsModel(
id: _newsList[index].id,
title: _newsList[index].title,
summary: _newsList[index].summary,
imageUrl: _newsList[index].imageUrl,
content: _newsList[index].content,
author: _newsList[index].author,
publishDate: _newsList[index].publishDate,
isFavorite: !_newsList[index].isFavorite,
);
if (_newsList[index].isFavorite) {
_favorites.add(_newsList[index]);
} else {
_favorites.removeWhere((n) => n.id == news.id);
}
await StorageService.saveFavorites(_favorites);
notifyListeners();
}
}
void _updateFavorites() {
_newsList = _newsList.map((news) {
final isFav = _favorites.any((f) => f.id == news.id);
return NewsModel(
id: news.id,
title: news.title,
summary: news.summary,
imageUrl: news.imageUrl,
content: news.content,
author: news.author,
publishDate: news.publishDate,
isFavorite: isFav,
);
}).toList();
}
}
4.2 主题状态
providers/theme_provider.dart:
dart
import 'package:flutter/material.dart';
import '../services/storage_service.dart';
class ThemeProvider with ChangeNotifier {
bool _isDark = false;
bool get isDark => _isDark;
ThemeMode get themeMode => _isDark ? ThemeMode.dark : ThemeMode.light;
Future<void> init() async {
_isDark = await StorageService.getTheme();
notifyListeners();
}
Future<void> toggleTheme() async {
_isDark = !_isDark;
await StorageService.saveTheme(_isDark);
notifyListeners();
}
}
5. UI 层
5.1 首页
screens/home_screen.dart:
dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../providers/news_provider.dart';
import '../providers/theme_provider.dart';
import 'detail_screen.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
@override
void initState() {
super.initState();
context.read<NewsProvider>().loadNews();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('新闻阅读'),
actions: [
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
Navigator.pushNamed(context, '/settings');
},
),
],
),
body: Consumer<NewsProvider>(
builder: (context, provider, child) {
if (provider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (provider.error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('加载失败: ${provider.error}'),
ElevatedButton(
onPressed: () => provider.loadNews(),
child: const Text('重试'),
),
],
),
);
}
return ListView.builder(
itemCount: provider.newsList.length,
itemBuilder: (context, index) {
final news = provider.newsList[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: ListTile(
leading: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: CachedNetworkImage(
imageUrl: news.imageUrl,
width: 80,
height: 80,
fit: BoxFit.cover,
placeholder: (context, url) => const CircularProgressIndicator(),
errorWidget: (context, url, error) => const Icon(Icons.error),
),
),
title: Text(news.title, maxLines: 2, overflow: TextOverflow.ellipsis),
subtitle: Text('${news.author} · ${news.publishDate.toString().substring(0, 10)}'),
trailing: IconButton(
icon: Icon(
news.isFavorite ? Icons.favorite : Icons.favorite_border,
color: news.isFavorite ? Colors.red : null,
),
onPressed: () => provider.toggleFavorite(news),
),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DetailScreen(news: news),
),
);
},
),
);
},
);
},
),
);
}
}
5.2 新闻详情
screens/detail_screen.dart:
dart
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/news.dart';
class DetailScreen extends StatelessWidget {
final NewsModel news;
const DetailScreen({super.key, required this.news});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(news.title)),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CachedNetworkImage(
imageUrl: news.imageUrl,
width: double.infinity,
height: 200,
fit: BoxFit.cover,
),
const SizedBox(height: 16),
Text(
news.title,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'${news.author} · ${news.publishDate.toString().substring(0, 10)}',
style: const TextStyle(color: Colors.grey),
),
const SizedBox(height: 16),
Text(
news.content,
style: const TextStyle(fontSize: 16, height: 1.6),
),
],
),
),
);
}
}
5.3 设置页
screens/settings_screen.dart:
dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/theme_provider.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('设置')),
body: Consumer<ThemeProvider>(
builder: (context, themeProvider, child) {
return ListView(
children: [
SwitchListTile(
title: const Text('暗色主题'),
value: themeProvider.isDark,
onChanged: (value) => themeProvider.toggleTheme(),
),
const Divider(),
ListTile(
title: const Text('清除缓存'),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('缓存已清除')),
);
},
),
ListTile(
title: const Text('关于我们'),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: () {},
),
],
);
},
),
);
}
}
6. 路由配置
main.dart:
dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'providers/news_provider.dart';
import 'providers/theme_provider.dart';
import 'screens/home_screen.dart';
import 'screens/settings_screen.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final themeProvider = ThemeProvider();
await themeProvider.init();
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => NewsProvider()),
ChangeNotifierProvider.value(value: themeProvider),
],
child: const NewsApp(),
),
);
}
class NewsApp extends StatelessWidget {
const NewsApp({super.key});
@override
Widget build(BuildContext context) {
return Consumer<ThemeProvider>(
builder: (context, themeProvider, child) {
return MaterialApp(
title: '新闻阅读',
themeMode: themeProvider.themeMode,
theme: ThemeData(
primarySwatch: Colors.blue,
brightness: Brightness.light,
),
darkTheme: ThemeData(
primarySwatch: Colors.blue,
brightness: Brightness.dark,
),
initialRoute: '/',
routes: {
'/': (context) => const HomeScreen(),
'/settings': (context) => const SettingsScreen(),
},
);
},
);
}
}
7. 完整代码
将以上所有文件整合后,即可运行项目:
bash
flutter run
运行效果:
- 首页展示新闻列表,支持加载状态和错误处理
- 点击新闻卡片进入详情页
- 点击收藏图标切换收藏状态
- 设置页支持切换暗色主题
- 所有数据持久化到本地
❓ 常见问题 FAQ
1. 如何添加更多页面?
解答 :
在 main.dart 的 routes 中添加路由映射,然后使用 Navigator.pushNamed(context, '/route') 跳转。
2. 如何实现分页加载?
解答 :
在 NewsProvider 中添加 currentPage 变量,在 ListView 底部添加 LoadMore 组件触发下一页加载。
3. 如何优化图片加载?
解答 :
使用 cached_network_image 包自动缓存图片,避免重复下载。
4. 如何添加搜索功能?
解答 :
在 NewsProvider 中添加 searchKeyword 变量,过滤 _newsList 并调用 notifyListeners()。
📝 总结
项目亮点
- 分层架构:数据层、状态层、UI层分离
- 状态管理:使用 Provider 管理全局状态
- 本地缓存:SharedPreferences 持久化数据
- 主题切换:支持亮色/暗色主题
- 错误处理:完善的加载状态和错误提示
后续扩展
- 添加 SQLite 存储更多数据
- 实现 WebSocket 实时推送
- 添加动画效果
- 支持多语言
📚 参考资料
- 📘 Flutter 官方文档
- 📗 Provider 文档
- 📙 新闻 API 示例
- 📕 Flutter 实战教程