likes
comments
collection
share

跟🤡杰哥一起学Flutter (十七、实战进阶-用 ViewModel 来分离 UI & 逻辑)

作者站长头像
站长
· 阅读数 34

1. 引言

😆上节杰哥手把手带着大伙基于 dio + riverpod 封装了一波网络请求,一手无脑定义 Provider,发起请求 refresh() ,监听值变化 watch() ,异步任务执行完,无需手动 setState() 更新UI,与状态关联的 Widget 会自动更新。

🤡 这套玩法也被我搬运到公司项目上了,正当我以为会收获一堆 "大佬🐂🍺" 的 虚假吹捧,结果同事看了我的代码,反而提出了 问题

跟🤡杰哥一起学Flutter (十七、实战进阶-用 ViewModel 来分离 UI & 逻辑)

我一看,立马和同事 讨(zheng)论 起来了:

  • 🐔:封装的结果只是不用写setState(),你这样写UI和逻辑还是混到一起了啊?
  • 🦆:em... 我感觉还好,就一些简单的逻辑处理,弹Toast、存数据、关页面,都是和Widget无关的操作。
  • 🐔:不对,这些应该分离出来,不该出现在UI层的,就是 Riverpod 做不了这个,我才想试试 Bloc 的。

😳 啊?Riverpod 做不了这个?看到他还在用 Riverpod 老版定义 Provider 的写法,而不是通过 @riverpod 注解生成,我感觉他大概率还没玩透 Riverpod,不过也合理,毕竟 官方文档 确实写得那么一言难尽🤣。

跟🤡杰哥一起学Flutter (十七、实战进阶-用 ViewModel 来分离 UI & 逻辑)

那本节就引入 ViewModel概念,用 Riverpod 中特殊的 ProviderNotifier 来实现 UI与逻辑 的分离。

2. 概念相关

😄 所谓的 ViewModel 就是在 View(视图) 和 Model (数据) 中间添加一个 桥梁,包含View层所需的数据和逻辑,但不包含 View(Widget) 相关的代码。通常会暴露出数据和命令,如用户操作的响应函数(回调),并且会监听Model的变化,以便更新自己的 状态MVVM (Model-View-ViewModel) 模式,可以帮我们构建一个结构清晰、易于维护和测试的应用程序。😏接着手撕一个例子帮助大家理解这种模式~

2.1. Model

代表应用程序的 数据模型,负责存储数据、定义数据结构 和 处理数据相关的逻辑。

class UserModel {
  final String id;
  final String name;
  final String email;

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

  factory UserModel.fromJson(Map<String, dynamic> json) {
    return UserModel(
      id: json['id'] as String,
      name: json['name'] as String,
      email: json['email'] as String,
    );
  }
}

2.2. ViewModel

class UserViewModel extends ChangeNotifier {
  UserModel? _user;

  // 暴露数据
  UserModel? get user => _user;

  // 暴露命令
  void fetchUserData() async {
    var response = await Dio().get("https://mock.apifox.com/m1/4081539-3719383-default/flutter_article/testUser");
    Map<String, dynamic>? responseObject = response.data;
    _user = UserModel.fromJson(responseObject?['data']);
    // 通知View层更新
    notifyListeners();
  }
}

2.3. View

代表 用户界面 的部分,通过监听 ViewModel 的状态变化来更新自己,这里使用 ChangeNotifierProvider 来连接ViewModel和View,实现数据的 双向绑定 (ViewModel的状态变化可以自动反映到View上,View上的UI操作可以通过调用 ViewModel 的方法来影响应用的状态或数据):

void main() {
  runApp(const MvvmApp());
}

class MvvmApp extends StatelessWidget {
  const MvvmApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(create: (context) => UserViewModel(), child: const MaterialApp(home: UserView()));
  }
}

class UserView extends StatelessWidget {
  const UserView({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final viewModel = Provider.of<UserViewModel>(context);
    return Scaffold(
      appBar: AppBar(title: const Text('User')),
      body: Center(
          child: viewModel.user == null
              ? ElevatedButton(
                  onPressed: () {
                    // 用户交互触发数据加载
                    viewModel.fetchUserData();
                  },
                  child: const Text('Load User'),
                )
              : Text("Hello, ${viewModel.user!.name}, your email is ${viewModel.user!.email}")),
    );
  }
}

运行输出结果如下

跟🤡杰哥一起学Flutter (十七、实战进阶-用 ViewModel 来分离 UI & 逻辑)

😄 还是非常好理解的,然后 状态 又可以细分为两类:

  • 数据/应用状态:如用户登录信息、应用的配置信息等。
  • 页面/UI状态:如Widget的当前选中状态、用户在表单中输入的数据、滚动位置、动画状态等。

然后需要把这些状态及与状态有关的逻辑都在到 ViewModel 中,在Flutter中的表现就是维护一个大的Notifier

3. 用 Riverpod 实现 ViewModel 层

这里使用 Riverpod 2.0 新增的 NotifierProvider 来实现~

跟🤡杰哥一起学Flutter (十七、实战进阶-用 ViewModel 来分离 UI & 逻辑)

先不用 @riverpod 注解自动生成Provider的写法~

跟🤡杰哥一起学Flutter (十七、实战进阶-用 ViewModel 来分离 UI & 逻辑)

调用处:

跟🤡杰哥一起学Flutter (十七、实战进阶-用 ViewModel 来分离 UI & 逻辑)

如果没有 Flutter Riverpod SnippetsGithub Copilot 插件补全,定义Provider还是挺麻烦的,😆 用 @riverpod注解 解君愁~

跟🤡杰哥一起学Flutter (十七、实战进阶-用 ViewModel 来分离 UI & 逻辑)

调用处无需另外定义Provider变量,直接调:

跟🤡杰哥一起学Flutter (十七、实战进阶-用 ViewModel 来分离 UI & 逻辑)

懵逼的话,点开下生成的源码就知道了~

跟🤡杰哥一起学Flutter (十七、实战进阶-用 ViewModel 来分离 UI & 逻辑)

😋 不得不说 注解生成Provider真香 ❗️

4. 实战示例:引入 ViewModel 改造 登录页

🤔 公司项目代码不太好展示,随手写个Demo演示下,大概流程:

  • 主页面:显示去登录按钮,点击后跳转登录页。
  • 登录页:输入账号密码点击登录按钮,触发登录,登录成功,弹提示,回传登录信息,关闭页面。
  • 主界面:判断收到登录信息,刷新页面,显示登录信息。

😄 先粗暴地实现一波,然后再改造~

4.1. 改造前

创建下登录信息的Model类 → LoginInfo,就一个 用户名登录时间 的字段,定义下 fromJson()

跟🤡杰哥一起学Flutter (十七、实战进阶-用 ViewModel 来分离 UI & 逻辑)

主页面 (main.dart):

跟🤡杰哥一起学Flutter (十七、实战进阶-用 ViewModel 来分离 UI & 逻辑)

登录页 (login_page.dart):

跟🤡杰哥一起学Flutter (十七、实战进阶-用 ViewModel 来分离 UI & 逻辑)

跟🤡杰哥一起学Flutter (十七、实战进阶-用 ViewModel 来分离 UI & 逻辑)

运行效果如下

跟🤡杰哥一起学Flutter (十七、实战进阶-用 ViewModel 来分离 UI & 逻辑)

4.2. 改造后

😆 确实粗暴,接着开始我们的改造,先定义一个大的 State类 来存 数据 & UI 相关的状态,然后定义一个 命名构造函数-initial() 来创建一个初始状态,接着定义一个 实例方法-copyWith() 用于基于当前状态创建一个新的状态:

跟🤡杰哥一起学Flutter (十七、实战进阶-用 ViewModel 来分离 UI & 逻辑)

接着,把 逻辑 相关都丢到 ViewModel(Notifier) 中,这里的难点估计是 弹窗页面跳转,拿不到当前的 context。如果是 异步操作 中使用传入 BuildContext,会显示 "Don't use 'BuildContext's across async gaps. " 的 警告

跟🤡杰哥一起学Flutter (十七、实战进阶-用 ViewModel 来分离 UI & 逻辑)

上面的例子,如果在5s内,用户跳转到别的页面,原先的 BuildContext(本质是Element引用) 所对应的Widget可能已经不在Widget树中了,此时,尝试使用这个 BuildContext 将会引发运行时错误。一种常见的解决方式:

定义一个 GlobalKey 类型的 顶层变量,在创建 MaterialApp 时,通过 navigatorKey 属性传入,然后就可以在应用的 任何地方 使用 navigatorKey.currentContext 来获取 BuildContext。然后需要注意下,它可能会返回 null 值,你能确保它不会空的话就用 ! ,否则还是老老实实判空~

😄 定义一个全局的 showSnackBar()pop() 代码方便代���复用:

跟🤡杰哥一起学Flutter (十七、实战进阶-用 ViewModel 来分离 UI & 逻辑)

通过 @rivperod 注解定义一个Notifier,重写 build() 返回 LoginPageState.initial() 初始化的状态对象,登录方法完善下逻辑,请求响应成功,设置 state 值为 state.copyWith(loginInfo: loginInfo) ,具体代码:

跟🤡杰哥一起学Flutter (十七、实战进阶-用 ViewModel 来分离 UI & 逻辑)

😄 然后是登录页:

跟🤡杰哥一起学Flutter (十七、实战进阶-用 ViewModel 来分离 UI & 逻辑)

🤣 现在是真的一点 逻辑 都没有了,最后的 主页面,直接 watch()loginPageVMProvider,loginInfo没值显示去登录按钮,有值显示登录信息:

跟🤡杰哥一起学Flutter (十七、实战进阶-用 ViewModel 来分离 UI & 逻辑)

😄 ref.watch(loginPageVMProvider).loginInfo 这样的写法会在 loginPageVMProvider任何状态变化时都触发 Widget 重建,而无论 loginInfo 是否发生变化。比如,调用的另外一个获取banner的方法,更新了另一个 子状态,也会触发:

跟🤡杰哥一起学Flutter (十七、实战进阶-用 ViewModel 来分离 UI & 逻辑)

😏 如果只关心某个 子状态,可以用 select() 来指定一个函数,从Provider的状态中选择一个子状态,只有当这个子状态发生变化时,才会触发依赖于它的 Widget 的重建,更细粒度的监听,可以减少不必要的Widget重建,提高性能~

跟🤡杰哥一起学Flutter (十七、实战进阶-用 ViewModel 来分离 UI & 逻辑)

5. 小结

💁‍♂️ 本节在上节封装的基础上,引入了ViewModel的概念,利用 Riverpod 实现了 UI与逻辑的完全分离,使得代码编写起来更清爽。另外,关于Riverpod的,有些同学可能担心定义了那么多 Provider的全局变量,会不会有什么性能影响?其实问题不太大,因为默认是 懒加载 的,只有在 首次调用时才初始化,而且默认使用的 AutoDisposeNotifier,当没有任何监听器监听它时(ref.watch/ref.listen),它会 自动被清理

:完整的代码 (只是方便演示才写在一起,实际开发可按需放到对应的文件或包中~)

/// login_model.dart
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:hello_flutter/main.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'login_model.g.dart';

class LoginInfo {
  final String userName;
  final String loginTime;

  LoginInfo({
    required this.userName,
    required this.loginTime,
  });

  factory LoginInfo.fromJson(Map<String, dynamic> json) {
    return LoginInfo(
      userName: json['userName'],
      loginTime: json['loginTime'],
    );
  }
}

class LoginPageState {
  final TextEditingController userNameController;
  final TextEditingController passwordController;
  final LoginInfo? loginInfo;

  LoginPageState({this.loginInfo, required this.userNameController, required this.passwordController});

  LoginPageState.initial()
      : userNameController = TextEditingController(),
        passwordController = TextEditingController(),
        loginInfo = null;

  LoginPageState copyWith({
    TextEditingController? userNameController,
    TextEditingController? passwordController,
    LoginInfo? loginInfo,
  }) {
    return LoginPageState(
      userNameController: userNameController ?? this.userNameController,
      passwordController: passwordController ?? this.passwordController,
      loginInfo: loginInfo ?? this.loginInfo,
    );
  }
}

@riverpod
class LoginPageVM extends _$LoginPageVM {
  @override
  LoginPageState build() => LoginPageState.initial();

  Future<void> login() async {
    final userName = state.userNameController.text;
    final password = state.passwordController.text;
    if (userName.isEmpty || password.isEmpty) {
      showSnackBar("请输入帐号或密码");
    } else {
      var response = await Dio().post(
        "https://mock.apifox.com/m1/4081539-3719383-default/flutter_article/testLogin",
        data: {'username': userName, 'password': password},
      );
      var data = response.data['data'];
      if (response.data['errorCode'] == 200) {
        var loginInfo = LoginInfo.fromJson(data);
        state = state.copyWith(loginInfo: loginInfo);
        showSnackBar("登录成功");
        pop(result: loginInfo);
      } else {
        showSnackBar("登录失败");
      }
    }
  }
}

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'login_model.dart';

/// login_page.dart → 登录页
class LoginPage extends ConsumerStatefulWidget {
  const LoginPage({super.key});

  @override
  ConsumerState<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends ConsumerState<LoginPage> {

  @override
  Widget build(BuildContext context) {
    var loginState = ref.watch(loginPageVMProvider);
    var loginVM = ref.watch(loginPageVMProvider.notifier);

    return Scaffold(
      appBar: AppBar(
        title: const Text('登录页', style: TextStyle(color: Colors.white)),
        backgroundColor: Colors.red,
        iconTheme: const IconThemeData(color: Colors.white),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: <Widget>[
            TextField(
              controller: loginState.userNameController,
              decoration: const InputDecoration(
                labelText: '用户名',
                border: OutlineInputBorder(),
              ),
            ),
            const SizedBox(height: 20.0),
            TextField(
              controller: loginState.passwordController,
              decoration: const InputDecoration(
                labelText: '密码',
                border: OutlineInputBorder(),
              ),
              obscureText: true,
            ),
            const SizedBox(height: 20.0),
            MaterialButton(
              onPressed: () {
                loginVM.login();
              },
              color: Colors.red,
              padding: const EdgeInsets.symmetric(vertical: 16.0),
              child: const Text('登录', style: TextStyle(color: Colors.white)),
            ),
          ],
        ),
      ),
    );
  }
}

/// main.dart → 主页面
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hello_flutter/api_client.dart';

import 'login_model.dart';
import 'login_page.dart';

final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

void showSnackBar(String message) {
  if (navigatorKey.currentContext != null) {
    ScaffoldMessenger.of(navigatorKey.currentContext!).showSnackBar(SnackBar(content: Text(message)));
  }
}

void pop<T>({T? result}) {
  if (navigatorKey.currentContext != null) {
    if(result != null) {
      Navigator.pop(navigatorKey.currentContext!, result);
    } else {
      Navigator.pop(navigatorKey.currentContext!);
    }
  }
}


void main() {
  ApiClient.init("https://mock.apifox.com/m1/4081539-3719383-default/flutter_article/");
  runApp(const ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(home: const HomePage(), navigatorKey: navigatorKey);
  }
}

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: const Text('Home')),
        body: Center(child: Consumer(builder: (context, ref, child) {
          LoginInfo? loginInfo = ref.watch(loginPageVMProvider.select((value) => value.loginInfo));
          return loginInfo == null
              ? ElevatedButton(
                  onPressed: () {
                    Navigator.push(context, MaterialPageRoute(builder: (context) => const LoginPage()));
                  },
                  child: const Text('去登录'),
                )
              : Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: <Widget>[
                    Text('用户名:${loginInfo.userName}'),
                    Text('登录时间:${loginInfo.loginTime}'),
                  ],
                );
        })));
  }
}
转载自:https://juejin.cn/post/7367252049560797224
评论
请登录