我来提供一个完整的、可直接运行的 Flutter MVVM 实战项目,包含网络请求、错误处理、加载状态、列表展示等真实场景。
完整项目代码
- 项目依赖 (pubspec.yaml)
yaml
name: flutter_mvvm_demo
description: Flutter MVVM实战项目
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
riverpod: ^2.4.0
flutter_riverpod: ^2.4.0
dio: ^5.3.0
json_annotation: ^4.8.1
intl: ^0.18.1
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^2.4.6
json_serializable: ^6.7.1
flutter:
uses-material-design: true
-
项目结构
lib/
├── main.dart
├── models/
│ ├── post_model.dart
│ └── user_model.dart
├── viewmodels/
│ ├── post_viewmodel.dart
│ └── auth_viewmodel.dart
├── views/
│ ├── post_screen.dart
│ ├── login_screen.dart
│ └── widgets/
│ ├── loading_widget.dart
│ └── error_widget.dart
├── services/
│ ├── api_service.dart
│ └── http_client.dart
└── constants/
└── app_constants.dart -
数据模型 (Models)
lib/models/post_model.dart
dart
import 'package:json_annotation/json_annotation.dart';
part 'post_model.g.dart';
@JsonSerializable()
class PostModel {
final int id;
final int userId;
final String title;
final String body;
@JsonKey(name: 'created_at')
final DateTime? createdAt;
PostModel({
required this.id,
required this.userId,
required this.title,
required this.body,
this.createdAt,
});
factory PostModel.fromJson(Map<String, dynamic> json) => _$PostModelFromJson(json);
Map<String, dynamic> toJson() => _$PostModelToJson(this);
@override
String toString() => 'PostModel(id: $id, title: $title)';
}
lib/models/user_model.dart
dart
import 'package:json_annotation/json_annotation.dart';
part 'user_model.g.dart';
@JsonSerializable()
class UserModel {
final int id;
final String name;
final String email;
final String? avatar;
@JsonKey(name: 'phone')
final String? phoneNumber;
UserModel({
required this.id,
required this.name,
required this.email,
this.avatar,
this.phoneNumber,
});
factory UserModel.fromJson(Map<String, dynamic> json) => _$UserModelFromJson(json);
Map<String, dynamic> toJson() => _$UserModelToJson(this);
}
- 网络服务 (Services)
lib/services/http_client.dart - 封装Dio
dart
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
class HttpClient {
static final HttpClient _instance = HttpClient._internal();
factory HttpClient() => _instance;
HttpClient._internal();
late Dio _dio;
void init() {
_dio = Dio(BaseOptions(
baseUrl: 'https://jsonplaceholder.typicode.com',
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
headers: {
'Content-Type': 'application/json',
},
));
// 添加拦截器
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) {
if (kDebugMode) {
print('🚀 Request: ${options.method} ${options.path}');
print('📦 Params: ${options.queryParameters}');
}
return handler.next(options);
},
onResponse: (response, handler) {
if (kDebugMode) {
print('✅ Response: ${response.statusCode} ${response.requestOptions.path}');
}
return handler.next(response);
},
onError: (error, handler) {
if (kDebugMode) {
print('❌ Error: ${error.message}');
}
return handler.next(error);
},
));
}
Future<dynamic> get(
String path, {
Map<String, dynamic>? queryParameters,
Options? options,
}) async {
try {
final response = await _dio.get(
path,
queryParameters: queryParameters,
options: options,
);
return response.data;
} on DioException catch (e) {
throw _handleError(e);
}
}
Future<dynamic> post(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
}) async {
try {
final response = await _dio.post(
path,
data: data,
queryParameters: queryParameters,
options: options,
);
return response.data;
} on DioException catch (e) {
throw _handleError(e);
}
}
Exception _handleError(DioException error) {
if (error.type == DioExceptionType.connectionTimeout) {
return Exception('连接超时,请检查网络');
} else if (error.type == DioExceptionType.receiveTimeout) {
return Exception('接收超时,请稍后重试');
} else if (error.type == DioExceptionType.cancel) {
return Exception('请求已取消');
} else if (error.response != null) {
final statusCode = error.response!.statusCode;
if (statusCode == 401) {
return Exception('未授权,请重新登录');
} else if (statusCode == 404) {
return Exception('请求的资源不存在');
} else if (statusCode == 500) {
return Exception('服务器错误,请稍后重试');
} else {
return Exception('网络错误: $statusCode');
}
} else {
return Exception('网络连接失败,请检查网络设置');
}
}
}
lib/services/api_service.dart
dart
import 'package:flutter_mvvm_demo/models/post_model.dart';
import 'package:flutter_mvvm_demo/models/user_model.dart';
import 'package:flutter_mvvm_demo/services/http_client.dart';
class ApiService {
final HttpClient _httpClient = HttpClient();
// 获取帖子列表
Future<List<PostModel>> fetchPosts({int page = 1, int limit = 10}) async {
try {
final data = await _httpClient.get(
'/posts',
queryParameters: {
'_page': page,
'_limit': limit,
},
);
return List<PostModel>.from(
(data as List).map((json) => PostModel.fromJson(json))
);
} catch (e) {
rethrow;
}
}
// 获取单个帖子详情
Future<PostModel> fetchPostDetail(int postId) async {
try {
final data = await _httpClient.get('/posts/$postId');
return PostModel.fromJson(data);
} catch (e) {
rethrow;
}
}
// 获取用户信息
Future<UserModel> fetchUser(int userId) async {
try {
final data = await _httpClient.get('/users/$userId');
return UserModel.fromJson(data);
} catch (e) {
rethrow;
}
}
// 创建帖子
Future<PostModel> createPost({
required int userId,
required String title,
required String body,
}) async {
try {
final data = await _httpClient.post(
'/posts',
data: {
'userId': userId,
'title': title,
'body': body,
},
);
return PostModel.fromJson(data);
} catch (e) {
rethrow;
}
}
}
- ViewModels (核心业务逻辑)
lib/viewmodels/post_viewmodel.dart
dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_mvvm_demo/models/post_model.dart';
import 'package:flutter_mvvm_demo/services/api_service.dart';
// 定义状态
class PostState {
final List<PostModel> posts;
final bool isLoading;
final bool isLoadingMore;
final String? error;
final int currentPage;
final bool hasMore;
const PostState({
this.posts = const [],
this.isLoading = false,
this.isLoadingMore = false,
this.error,
this.currentPage = 1,
this.hasMore = true,
});
PostState copyWith({
List<PostModel>? posts,
bool? isLoading,
bool? isLoadingMore,
String? error,
int? currentPage,
bool? hasMore,
}) {
return PostState(
posts: posts ?? this.posts,
isLoading: isLoading ?? this.isLoading,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
error: error ?? this.error,
currentPage: currentPage ?? this.currentPage,
hasMore: hasMore ?? this.hasMore,
);
}
}
// ViewModel
class PostViewModel extends StateNotifier<PostState> {
final ApiService _apiService;
PostViewModel(this._apiService) : super(const PostState());
// 获取帖子列表
Future<void> fetchPosts({bool refresh = false}) async {
try {
// 如果是刷新,重置状态
if (refresh) {
state = state.copyWith(
isLoading: true,
posts: [],
currentPage: 1,
error: null,
);
} else if (state.posts.isEmpty) {
state = state.copyWith(isLoading: true);
}
final newPosts = await _apiService.fetchPosts(
page: state.currentPage,
limit: 10,
);
final hasMore = newPosts.length == 10;
final updatedPosts = refresh ? newPosts : [...state.posts, ...newPosts];
state = state.copyWith(
posts: updatedPosts,
isLoading: false,
hasMore: hasMore,
error: null,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
}
}
// 加载更多
Future<void> loadMore() async {
if (state.isLoadingMore || !state.hasMore) return;
state = state.copyWith(isLoadingMore: true);
try {
final nextPage = state.currentPage + 1;
final newPosts = await _apiService.fetchPosts(
page: nextPage,
limit: 10,
);
final hasMore = newPosts.length == 10;
state = state.copyWith(
posts: [...state.posts, ...newPosts],
currentPage: nextPage,
hasMore: hasMore,
isLoadingMore: false,
);
} catch (e) {
state = state.copyWith(
isLoadingMore: false,
error: e.toString(),
);
}
}
// 刷新
Future<void> refresh() async {
await fetchPosts(refresh: true);
}
// 创建新帖子
Future<bool> createPost({
required int userId,
required String title,
required String body,
}) async {
state = state.copyWith(isLoading: true);
try {
final newPost = await _apiService.createPost(
userId: userId,
title: title,
body: body,
);
// 将新帖子添加到列表开头
state = state.copyWith(
posts: [newPost, ...state.posts],
isLoading: false,
);
return true;
} catch (e) {
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
return false;
}
}
// 清除错误
void clearError() {
state = state.copyWith(error: null);
}
}
// Provider 定义
final postViewModelProvider = StateNotifierProvider<PostViewModel, PostState>((ref) {
return PostViewModel(ApiService());
});
lib/viewmodels/auth_viewmodel.dart - 展示登录场景
dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_mvvm_demo/models/user_model.dart';
import 'package:flutter_mvvm_demo/services/api_service.dart';
class AuthState {
final UserModel? user;
final bool isLoading;
final String? error;
final bool isAuthenticated;
const AuthState({
this.user,
this.isLoading = false,
this.error,
this.isAuthenticated = false,
});
AuthState copyWith({
UserModel? user,
bool? isLoading,
String? error,
bool? isAuthenticated,
}) {
return AuthState(
user: user ?? this.user,
isLoading: isLoading ?? this.isLoading,
error: error ?? this.error,
isAuthenticated: isAuthenticated ?? this.isAuthenticated,
);
}
}
class AuthViewModel extends StateNotifier<AuthState> {
final ApiService _apiService;
AuthViewModel(this._apiService) : super(const AuthState());
Future<bool> login(String email, String password) async {
state = state.copyWith(isLoading: true, error: null);
try {
// 模拟登录验证(实际项目应该有登录接口)
await Future.delayed(const Duration(seconds: 1));
// 获取用户信息(这里用id=1作为示例)
final user = await _apiService.fetchUser(1);
// 验证邮箱(简单示例)
if (email != user.email) {
throw Exception('邮箱或密码错误');
}
state = state.copyWith(
user: user,
isLoading: false,
isAuthenticated: true,
);
return true;
} catch (e) {
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
return false;
}
}
void logout() {
state = const AuthState();
}
void clearError() {
state = state.copyWith(error: null);
}
}
final authViewModelProvider = StateNotifierProvider<AuthViewModel, AuthState>((ref) {
return AuthViewModel(ApiService());
});
- UI组件 (Views)
lib/views/widgets/loading_widget.dart
dart
import 'package:flutter/material.dart';
class LoadingWidget extends StatelessWidget {
final String? message;
const LoadingWidget({Key? key, this.message}) : super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
if (message != null) ...[
const SizedBox(height: 16),
Text(
message!,
style: Theme.of(context).textTheme.bodyMedium,
),
],
],
),
);
}
}
lib/views/widgets/error_widget.dart
dart
import 'package:flutter/material.dart';
class ErrorDisplayWidget extends StatelessWidget {
final String error;
final VoidCallback onRetry;
const ErrorDisplayWidget({
Key? key,
required this.error,
required this.onRetry,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
'加载失败',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
error,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('重试'),
),
],
),
);
}
}
lib/views/post_screen.dart - 主界面
dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_mvvm_demo/viewmodels/post_viewmodel.dart';
import 'package:flutter_mvvm_demo/views/widgets/loading_widget.dart';
import 'package:flutter_mvvm_demo/views/widgets/error_widget.dart';
class PostScreen extends ConsumerStatefulWidget {
const PostScreen({Key? key}) : super(key: key);
@override
ConsumerState<PostScreen> createState() => _PostScreenState();
}
class _PostScreenState extends ConsumerState<PostScreen> {
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_loadInitialData();
_setupScrollListener();
}
void _loadInitialData() {
Future.microtask(() {
ref.read(postViewModelProvider.notifier).fetchPosts();
});
}
void _setupScrollListener() {
_scrollController.addListener(() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
ref.read(postViewModelProvider.notifier).loadMore();
}
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(postViewModelProvider);
final viewModel = ref.read(postViewModelProvider.notifier);
return Scaffold(
appBar: AppBar(
title: const Text('帖子列表'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => viewModel.refresh(),
),
IconButton(
icon: const Icon(Icons.add),
onPressed: () => _showCreatePostDialog(context, viewModel),
),
],
),
body: _buildBody(state, viewModel),
);
}
Widget _buildBody(PostState state, PostViewModel viewModel) {
if (state.isLoading && state.posts.isEmpty) {
return const LoadingWidget(message: '加载中...');
}
if (state.error != null && state.posts.isEmpty) {
return ErrorDisplayWidget(
error: state.error!,
onRetry: () => viewModel.refresh(),
);
}
return RefreshIndicator(
onRefresh: () => viewModel.refresh(),
child: ListView.builder(
controller: _scrollController,
itemCount: state.posts.length + (state.hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == state.posts.length) {
return _buildLoadingMore(state);
}
return _buildPostItem(state.posts[index]);
},
),
);
}
Widget _buildPostItem(PostModel post) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: ListTile(
leading: CircleAvatar(
child: Text('${post.id}'),
),
title: Text(
post.title,
style: const TextStyle(fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
post.body,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
isThreeLine: false,
trailing: const Icon(Icons.chevron_right),
onTap: () {
_showPostDetail(context, post);
},
),
);
}
Widget _buildLoadingMore(PostState state) {
if (state.isLoadingMore) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Center(child: CircularProgressIndicator()),
);
}
if (!state.hasMore && state.posts.isNotEmpty) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Center(
child: Text(
'没有更多数据了',
style: Theme.of(context).textTheme.bodyMedium,
),
),
);
}
return const SizedBox.shrink();
}
void _showPostDetail(BuildContext context, PostModel post) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(post.title),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('ID: ${post.id}'),
const SizedBox(height: 8),
Text('用户ID: ${post.userId}'),
const SizedBox(height: 16),
Text(post.body),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('关闭'),
),
],
),
);
}
void _showCreatePostDialog(BuildContext context, PostViewModel viewModel) {
final titleController = TextEditingController();
final bodyController = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('创建新帖子'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: titleController,
decoration: const InputDecoration(
labelText: '标题',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: bodyController,
decoration: const InputDecoration(
labelText: '内容',
border: OutlineInputBorder(),
),
maxLines: 3,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
ElevatedButton(
onPressed: () async {
if (titleController.text.isNotEmpty &&
bodyController.text.isNotEmpty) {
final success = await viewModel.createPost(
userId: 1,
title: titleController.text,
body: bodyController.text,
);
if (success && context.mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('创建成功')),
);
}
}
},
child: const Text('发布'),
),
],
),
);
}
}
lib/views/login_screen.dart - 登录界面
dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_mvvm_demo/viewmodels/auth_viewmodel.dart';
import 'package:flutter_mvvm_demo/views/post_screen.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({Key? key}) : super(key: key);
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _formKey = GlobalKey<FormState>();
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authViewModelProvider);
final authViewModel = ref.read(authViewModelProvider.notifier);
ref.listen(authViewModelProvider, (previous, next) {
if (next.isAuthenticated && mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const PostScreen()),
);
}
});
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const FlutterLogo(size: 100),
const SizedBox(height: 48),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: '邮箱',
prefixIcon: Icon(Icons.email),
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty) {
return '请输入邮箱';
}
if (!value.contains('@')) {
return '请输入有效的邮箱地址';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: '密码',
prefixIcon: Icon(Icons.lock),
border: OutlineInputBorder(),
),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty) {
return '请输入密码';
}
if (value.length < 6) {
return '密码至少6位';
}
return null;
},
),
const SizedBox(height: 24),
if (authState.error != null)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
authState.error!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
),
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: authState.isLoading
? null
: () async {
if (_formKey.currentState!.validate()) {
await authViewModel.login(
_emailController.text,
_passwordController.text,
);
}
},
child: authState.isLoading
? const CircularProgressIndicator()
: const Text('登录'),
),
),
const SizedBox(height: 16),
TextButton(
onPressed: () {
// 测试账号提示
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('测试账号'),
content: const Text('邮箱: Sincere@april.biz\n密码: 123456'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('知道了'),
),
],
),
);
},
child: const Text('测试账号?'),
),
],
),
),
),
),
);
}
}
- 主入口 (lib/main.dart)
dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_mvvm_demo/services/http_client.dart';
import 'package:flutter_mvvm_demo/views/login_screen.dart';
void main() {
// 初始化网络客户端
HttpClient().init();
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter MVVM Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primarySwatch: Colors.blue,
useMaterial3: true,
),
home: const LoginScreen(),
);
}
}
- 生成JSON序列化代码
运行以下命令生成 .g.dart 文件:
bash
flutter pub run build_runner build --delete-conflicting-outputs
关键特性说明
- 状态管理
· 使用 StateNotifier + Riverpod 实现不可变状态
· 每个状态变化都创建新的State对象,确保可预测性
- 网络层
· 封装 HttpClient 统一处理Dio配置和错误
· ApiService 提供业务接口
- ViewModel设计
· 不持有 BuildContext
· 暴露方法供UI调用
· 管理加载、成功、失败状态
- UI响应
· 使用 ConsumerWidget 监听状态
· 根据状态显示不同UI(加载/错误/内容)
· 支持下拉刷新和上拉加载更多
- 错误处理
· 统一的错误捕获和展示
· 提供重试机制
这个完整示例展示了:
· ✅ 网络请求与数据解析
· ✅ 分页加载
· ✅ 表单验证
· ✅ 路由跳转
· ✅ 状态管理最佳实践
你可以直接复制代码运行,体验完整的MVVM架构。如果需要特定场景的实现,我可以进一步细化。