用 go_router 和 Riverpod 构建可维护的应用架构

前言

上一篇我们完成了技术选型和架构设计,这篇文章我们开始写代码。路由和状态管理是 Flutter 应用的骨架,搞不好后面会很痛苦。

我会结合 CleanMark AI 项目的实际代码,讲解如何用 go_router 管理复杂的页面跳转,以及如何用 Riverpod 管理全局状态。


一、go_router 路由配置

1.1 路由设计思路

CleanMark AI 有 12 个页面,路由关系比较复杂:

启动页 (/)
  ↓
引导页 (/onboarding) [首次启动]
  ↓
登录页 (/login)
  ↓
主界面 (底部 Tab)
  ├─ 首页 (/home)
  ├─ 历史 (/history-list)
  └─ 我的 (/profile)
      ↓
功能页面(无底部导航)
  ├─ 图片上传 (/image-upload)
  ├─ 图片对比 (/image-comparison)
  ├─ 视频上传 (/video-upload)
  ├─ 视频结果 (/video-result)
  ├─ 积分历史 (/points)
  ├─ 赚取积分 (/earn-points)
  └─ 历史详情 (/history-detail)

设计要点:

  1. 底部 Tab 页面:使用 StatefulShellRoute 保持状态
  2. 功能页面:使用普通 GoRoute,支持返回
  3. 初始路由:根据登录状态和引导页状态动态决定
    请添加图片描述

1.2 路由配置代码

// lib/app/router.dart
import 'package:go_router/go_router.dart';

/// 创建应用路由
GoRouter createRouter({
  required bool skipOnboarding,  // 是否跳过引导页
  required bool isLoggedIn,      // 是否已登录
}) {
  // 根据状态决定初始路由
  String initialLocation = '/';
  if (isLoggedIn) {
    initialLocation = '/home';      // 已登录 → 首页
  } else if (skipOnboarding) {
    initialLocation = '/login';     // 看过引导 → 登录页
  }

  return GoRouter(
    initialLocation: initialLocation,
    routes: [
      // ---- 引导 & 认证(无底部导航) ----
      GoRoute(
        path: '/',
        builder: (ctx, state) => const SplashScreen(),
      ),
      GoRoute(
        path: '/login',
        builder: (ctx, state) => const LoginScreen(),
      ),

      // ---- 功能页(无底部导航) ----
      GoRoute(
        path: '/image-upload',
        builder: (ctx, state) => const ImageUploadScreen(),
      ),
      GoRoute(
        path: '/image-comparison',
        builder: (ctx, state) {
          // 通过 extra 传递参数
          final extra = state.extra as Map<String, dynamic>?;
          return ImageComparisonScreen(
            originalPath: extra?['original'] as String?,
            resultUrl: extra?['resultUrl'] as String?,
          );
        },
      ),

      // ---- Tab Shell 路由(三个主 Tab,共享底部导航) ----
      StatefulShellRoute.indexedStack(
        builder: (ctx, state, shell) => MainShell(navigationShell: shell),
        branches: [
          // Tab 0: 首页
          StatefulShellBranch(
            routes: [
              GoRoute(
                path: '/home',
                builder: (ctx, state) => const HomeScreen(),
              ),
            ],
          ),
          // Tab 1: 历史
          StatefulShellBranch(
            routes: [
              GoRoute(
                path: '/history-list',
                builder: (ctx, state) => const HistoryListScreen(),
              ),
            ],
          ),
          // Tab 2: 我的
          StatefulShellBranch(
            routes: [
              GoRoute(
                path: '/profile',
                builder: (ctx, state) => const ProfileScreen(),
              ),
            ],
          ),
        ],
      ),
    ],
  );
}

1.3 StatefulShellRoute 详解

为什么用 StatefulShellRoute?

普通的 Tab 切换会导致页面重建,状态丢失。比如:

  • 首页滚动到一半,切换到历史页,再切回来,滚动位置丢失
  • 历史页的筛选条件,切换后重置

StatefulShellRoute.indexedStack 会保持每个 Tab 的状态:

StatefulShellRoute.indexedStack(
  // builder 返回包含底部导航的 Shell
  builder: (ctx, state, shell) => MainShell(navigationShell: shell),
  branches: [
    // 每个 branch 对应一个 Tab
    StatefulShellBranch(routes: [...]),
    StatefulShellBranch(routes: [...]),
    StatefulShellBranch(routes: [...]),
  ],
)

MainShell 实现:

// lib/features/shell/main_shell.dart
class MainShell extends StatelessWidget {
  final StatefulNavigationShell navigationShell;

  const MainShell({required this.navigationShell});

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: navigationShell,  // 显示当前 Tab 的内容
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: navigationShell.currentIndex,
        onTap: (index) {
          // 切换 Tab
          navigationShell.goBranch(
            index,
            initialLocation: index == navigationShell.currentIndex,
          );
        },
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: '首页'),
          BottomNavigationBarItem(icon: Icon(Icons.history), label: '历史'),
          BottomNavigationBarItem(icon: Icon(Icons.person), label: '我的'),
        ],
      ),
    );
  }
}

1.4 路由跳转与参数传递

基本跳转:

// 跳转到新页面(可返回)
context.push('/image-upload');

// 替换当前页面(不可返回)
context.go('/login');

// 返回上一页
context.pop();

// 返回并传递结果
context.pop({'success': true});

传递参数的三种方式:

方式1:路径参数(适合简单参数)

// 路由定义
GoRoute(
  path: '/user/:id',
  builder: (ctx, state) {
    final userId = state.pathParameters['id'];
    return UserDetailScreen(userId: userId);
  },
)

// 跳转
context.push('/user/123');

方式2:查询参数(适合可选参数)

// 路由定义
GoRoute(
  path: '/search',
  builder: (ctx, state) {
    final keyword = state.uri.queryParameters['q'];
    return SearchScreen(keyword: keyword);
  },
)

// 跳转
context.push('/search?q=flutter');

方式3:extra 参数(适合复杂对象)

// 路由定义
GoRoute(
  path: '/image-comparison',
  builder: (ctx, state) {
    final extra = state.extra as Map<String, dynamic>?;
    return ImageComparisonScreen(
      originalPath: extra?['original'] as String?,
      resultUrl: extra?['resultUrl'] as String?,
    );
  },
)

// 跳转
context.push('/image-comparison', extra: {
  'original': '/path/to/image.jpg',
  'resultUrl': 'https://api.com/result.jpg',
});

我的建议:

  • 简单参数用路径参数
  • 可选参数用查询参数
  • 复杂对象用 extra(但不要传太大的对象)

二、Riverpod 状态管理

2.1 为什么选择 Riverpod

上一篇提到了 Riverpod 的优势,这里再强调几点:

1. 编译时类型检查

// Provider 不存在会编译报错
final user = ref.watch(userProviderTypo);  // ❌ 编译错误

// Provider 类型不匹配会编译报错
final user = ref.watch(userProvider);  // user 类型是 AsyncValue<UserModel?>
final name = user.name;  // ❌ 编译错误,AsyncValue 没有 name 属性

2. 不依赖 BuildContext

// Provider 需要 context
final user = Provider.of<User>(context);  // ❌ 必须在 Widget 中

// Riverpod 不需要 context
class UserService {
  void updateUser(WidgetRef ref) {
    final user = ref.read(userProvider);  // ✅ 任何地方都能用
  }
}

3. 自动依赖管理

// userProvider 依赖 authProvider
final userProvider = FutureProvider((ref) async {
  final token = ref.watch(authProvider);  // 自动监听 authProvider
  return fetchUser(token);
});

// authProvider 变化时,userProvider 自动重新计算

2.2 用户状态管理实战

CleanMark AI 的用户状态包括:

  • 用户信息(ID、邮箱、昵称)
  • 积分余额
  • 登录状态

定义 UserModel:

// lib/features/auth/user_model.dart
class UserModel {
  final String id;
  final String email;
  final String? nickname;
  final int credits;  // 积分

  UserModel({
    required this.id,
    required this.email,
    this.nickname,
    required this.credits,
  });

  // JSON 序列化
  factory UserModel.fromJson(Map<String, dynamic> json) {
    return UserModel(
      id: json['id'] as String,
      email: json['email'] as String,
      nickname: json['nickname'] as String?,
      credits: json['credits'] as int,
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'email': email,
      'nickname': nickname,
      'credits': credits,
    };
  }

  // copyWith 方法(用于更新部分字段)
  UserModel copyWith({
    String? id,
    String? email,
    String? nickname,
    int? credits,
  }) {
    return UserModel(
      id: id ?? this.id,
      email: email ?? this.email,
      nickname: nickname ?? this.nickname,
      credits: credits ?? this.credits,
    );
  }
}

定义 UserProvider:

// lib/features/auth/user_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';

/// 全局用户状态 Provider
final userProvider = AsyncNotifierProvider<UserNotifier, UserModel?>(
  UserNotifier.new,
);

/// 用户信息状态管理
class UserNotifier extends AsyncNotifier<UserModel?> {
  /// 启动时从 SharedPreferences 还原缓存用户信息
  
  Future<UserModel?> build() async {
    return AppPrefs.loadUser();
  }

  /// 使用 token 调用 API 拉取最新用户信息并持久化
  Future<void> loadFromApi(String token) async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(() async {
      final resp = await ApiClient.instance.get(
        ApiConstants.userMe,
        options: ApiClient.authOptions(token),
      );
      final user = UserModel.fromJson(resp.data);
      await AppPrefs.saveUser(user);
      return user;
    });
  }

  /// 登出时清除用户信息
  Future<void> clear() async {
    await AppPrefs.clearUser();
    state = const AsyncData(null);
  }

  /// 更新积分(本地立即更新,用于乐观更新)
  void updateCredits(int newCredits) {
    final currentUser = state.value;
    if (currentUser != null) {
      final updatedUser = currentUser.copyWith(credits: newCredits);
      state = AsyncData(updatedUser);
      AppPrefs.saveUser(updatedUser);
    }
  }
}

为什么用 AsyncNotifier?

用户信息需要从网络或本地存储加载,是异步的。AsyncNotifier 提供了三种状态:

  • AsyncLoading:加载中
  • AsyncData:加载成功
  • AsyncError:加载失败

2.3 在 Widget 中使用 Provider

ConsumerWidget 方式(推荐):

class HomeScreen extends ConsumerWidget {
  const HomeScreen({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    // 监听 userProvider,状态变化时自动重建
    final userAsync = ref.watch(userProvider);

    return userAsync.when(
      loading: () => const CircularProgressIndicator(),
      error: (err, stack) => Text('加载失败: $err'),
      data: (user) {
        if (user == null) {
          return const Text('未登录');
        }
        return Column(
          children: [
            Text('欢迎, ${user.nickname ?? user.email}'),
            Text('积分: ${user.credits}'),
          ],
        );
      },
    );
  }
}

Consumer 方式(局部监听):

class HomeScreen extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return Column(
      children: [
        const Text('首页'),
        // 只有这部分会在 userProvider 变化时重建
        Consumer(
          builder: (context, ref, child) {
            final userAsync = ref.watch(userProvider);
            return userAsync.when(
              loading: () => const CircularProgressIndicator(),
              error: (err, stack) => Text('加载失败'),
              data: (user) => Text('积分: ${user?.credits ?? 0}'),
            );
          },
        ),
      ],
    );
  }
}

ref.read vs ref.watch:

// ref.watch:监听状态变化,自动重建
final user = ref.watch(userProvider);

// ref.read:只读取一次,不监听变化(用于事件处理)
onPressed: () {
  final notifier = ref.read(userProvider.notifier);
  notifier.updateCredits(100);
}

三、积分系统状态管理

3.1 积分扣除流程

CleanMark AI 的积分系统比较复杂:

  1. 用户点击"开始去水印"
  2. 检查积分是否足够
  3. 调用 API 处理图片
  4. 扣除积分(乐观更新)
  5. 刷新用户信息

实现代码:

// lib/features/image/image_upload_screen.dart
class ImageUploadScreen extends ConsumerWidget {
  Future<void> _startRemove(BuildContext context, WidgetRef ref) async {
    // 1. 检查积分
    final userAsync = ref.read(userProvider);
    final user = userAsync.value;
    if (user == null || user.credits < 1) {
      _showInsufficientCreditsDialog(context);
      return;
    }

    // 2. 乐观更新积分(立即扣除,提升体验)
    ref.read(userProvider.notifier).updateCredits(user.credits - 1);

    // 3. 调用 API
    try {
      final result = await InpaintService.removeWatermark(imagePath);

      // 4. 跳转到结果页
      if (context.mounted) {
        context.push('/image-comparison', extra: {
          'original': imagePath,
          'resultUrl': result.url,
        });
      }
    } catch (e) {
      // 5. 失败时回滚积分
      ref.read(userProvider.notifier).updateCredits(user.credits);
      if (context.mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('处理失败: $e')),
        );
      }
    }
  }
}

乐观更新的好处:

  • 用户点击后立即看到积分减少,体验更流畅
  • 如果 API 失败,再回滚积分

本篇小结

这篇文章我们完成了:

  1. ✅ go_router 路由配置(包括 StatefulShellRoute)
  2. ✅ Riverpod 状态管理(AsyncNotifier)
  3. ✅ 用户状态管理实战
  4. ✅ 积分系统的乐观更新

下一篇我们会讲解 UI 设计规范和主题系统。


思考题

  1. 为什么 Tab 页面要用 StatefulShellRoute 而不是普通 GoRoute?
  2. 什么时候用 ref.watch,什么时候用 ref.read?
  3. 乐观更新有什么风险?如何处理失败情况?

下一篇预告:第03篇 - UI 设计规范与主题系统

更多推荐