「 Flutter 项目实战 」设计企业级项目入口 main.dart 设计与实现 ( GSYGithubApp 源码解读·二 )

2021-12-30 16:33:35 浏览数 (1)

提示:温馨提示一下哈,这篇文章主要是针对 GitHub 上 12 k 顶级项目「 CarGuo/gsy_github_app_flutter 」 的源码解读,因为这是我目前见过最棒、最具有企业级水平的 Flutter 开源项目,整个项目的设计令我倾佩,所以我希望与大家一起分享它 注意:我并非什么大神,只是一个热爱分享,并希望带大家一起进步的码者,所以我也无法保证本文的方案就一定是最好的,如果有更好的方案,也希望大家在评论区分享。那么与君共勉,我们开始吧 ~

一、前言

  • 初始化 Flutter project 时,系统会给我们一个默认的 main.dart 文件,但在世纪开发中我不建议直接使用,因为它的功能过于简单(只是加载了界面),并不能满足实际复杂的开发需求
  • 我将给大家呈现的 main.dart 设计方案讲具有:失败页、错误日志获取、数据共享和网络监听等功能,下面我们正式进入

二、main.dart

  • 由于相比默认 main.dart 文件,新方案功能要多很多,所以我们需要拆分为:main.dart 和 app.dart 两个文件来实现
  • 在 main.dart 中需要实现三个功能:异常捕获、错误页展示、主页面加载

2.1 异常捕获 - runZoned

  • 在 Flutter 中,还无法捕获的异常,如调用空对象方法异常、Futurer 中的异常等
  • 同样,对于在 Dart 中的同步异常和异步异常,同步异常可以通过 try/catch 捕获,但异步异常则比较麻烦
  • 举个异步异常的栗子:
代码语言:javascript复制
try{
    Future.delayed(Duration(seconds: 1)).then((e) => Future.error("asynchronous error"));
}catch (e){
    // TODO Report
}
  • Dart 中有一个 runZoned(…) 方法( Zone 表示一个代码执行的环境范围)
  • 在 Zone 中可以捕获日志输出、Timer 创建、微任务调度的行为,同时 Zone 也可以捕获所有未处理的异常
  • 将上面代码结合 runZoned 实现就是:
代码语言:javascript复制
  runZoned(() {
    Future.delayed(Duration(seconds: 1)).then((e) => Future.error("asynchronous error"));
  }, onError: (Object obj, StackTrace stack) {
    // crash 日志打印与上报
  })
  • 所以 main() 方法就可以相应的写作:
代码语言:javascript复制
void main() {
  runZoned(() {
  	// TODO some init
    runApp(FlutterReduxApp());
  }, onError: (Object obj, StackTrace stack) {
    // crash 日志打印与上报
    print(obj);
    print(stack);
  });
}

2.2 错误页展示 - ErrorWidget

  • Flutter 在很多关键的方法进行了异常捕获
  • 举个例子,当布局发生越界或不和规范时,会自动弹出一个错误界面:
  • 现网环境中,我们不能直接给用户展示这个页面,这时就需要 ErrorWidget。让它处于最底层来覆盖这个这样的页面
  • 添加上 ErrorWidget 后如下所示:
代码语言:javascript复制
void main() {
  runZoned(() {
    ErrorWidget.builder = (FlutterErrorDetails details) {
      Zone.current.handleUncaughtError(details.exception, details.stack);
      return ErrorPage(
          details.exception.toString()   "n "   details.stack.toString(), details);
    };
    runApp(FlutterReduxApp());
  }, onError: (Object obj, StackTrace stack) {
  	// crash 日志打印与上报
    print(obj);
    print(stack);
  });
}
  • 其中 ErrorWidget 所创建的错误页:ErrorPage 是我们自定义的
  • 其主要功能应包括:错误日志上传、返回上一界面
  • 具体逻辑需根据实际环境设计,由于异常上报跟本文主题关系无关,大家可以参照 error_page 源码 进行设计

2.3 数据共享 - InheritedWidget

  • 由于Flutter采用节点树的方式组织页面,以致于一个普通页面的节点层级会很深。
  • 此时,我们如果还是一层层传递数据,当需要修改数据时,就会比较麻烦。
  • 《Flutter 实战》中讲到:InheritedWidget 是 Flutter 中非常重要的一个功能型组件,它提供了一种数据在 widget 树中从上到下传递、共享的方式
  • 比如我们在应用的根 widget 中通过 InheritedWidget共享了一个数据,那么我们便可以在任意子 widget 中来获取该共享的数据!
  • 这个特性在一些需要在 widget 树中共享数据的场景中非常方便!如Flutter SDK 中正是通过 InheritedWidget 来共享应用主题(Theme)和 Locale (当前语言环境)信息的。

InheritedWidget 基本使用: 还没有学会 使用的同学可以先查看这篇文章进行学习 「flutter 必知必会」详细解析数据共享 InheritedWidget 完整使用

2.3.1 共享的数据
  • 根据 OOP 原则,我们将需共享的数据独立出一个类 EnvConfig
  • 新建 env_config.dart 文件内容如下
代码语言:javascript复制
///环境配置
@JsonSerializable(createToJson: false)
class EnvConfig {
  final String env;
  final bool debug;

  EnvConfig({
    this.env,
    this.debug,
  });

  factory EnvConfig.fromJson(Map<String, dynamic> json) => _$EnvConfigFromJson(json);
}
  • 由于这些配置一般是通过本地存储,或者联网时拉取
  • 所以其实例化采用 fromJson 方法,同时用户更新后也可以在转为 json 串存储到本地进行覆盖
2.3.2 封装与管理 ConfigWrapper

数据绑定的作用分两种:跟 UI 结合的内容刷新(如页面文字内容),全局共享的配置数据(如用户登录状态,系统颜色等) 由于本文是对 main.dart 的解析,所以我们针对第二种情况进行分析即可 对第一种情况感兴趣的同学可以点击上面链接查看

  • 我们知道 runApp 的参数是 WIdget 类型,同时我们需要将界面 MaterialApp 和数据进行绑定
  • 所以为了方便管理,我们新建一个 config_wrapper 文件,同时在这个文件里实现类 ConfigWrapper
代码语言:javascript复制
///往下共享环境配置
class ConfigWrapper extends StatelessWidget {
  ConfigWrapper({Key key, this.config, this.child});

  @override
  Widget build(BuildContext context) {
    ///设置 Config.DEBUG 的静态变量
    Config.DEBUG = this.config.debug;
    print("ConfigWrapper build ${Config.DEBUG}");
    return new _InheritedConfig(config: this.config, child: this.child);
  }

  static EnvConfig of(BuildContext context) {
    final _InheritedConfig inheritedConfig =
    context.dependOnInheritedWidgetOfExactType<_InheritedConfig>();
    return inheritedConfig.config;
  }

  final EnvConfig config;

  final Widget child;
}
  • 这个类的作用主要是对功能的封住,比如获取/更新数据,就可以通过 ConfigWrapper.of(…).methed(),来进行操作
2.3.3 绑定数据与视图 _InheritedConfig
  • 其中,将数据与视图(MaterialApp)绑定需要使用到 InheritedWidget
  • 同样在 config_wrapper.dart 文件中,我们新建一个类 _InheritedConfig
代码语言:javascript复制
class _InheritedConfig extends InheritedWidget {
  const _InheritedConfig(
      {Key key, @required this.config, @required Widget child})
      : assert(child != null),
        super(key: key, child: child);

  final EnvConfig config;

  @override
  bool updateShouldNotify(_InheritedConfig oldWidget) =>
      config != oldWidget.config;
}
  • 每一次调用/修改绑定的数据,都会调用 updateShouldNotify 这个方法
  • 这方法是用来判断是否需要通知视图,可更具具体场景进行设定
  • 比如数字数据变化时修改,或者数据变不变都修改

updateShouldNotify : Whether the framework should notify widgets that inherit from this widget.

2.3.4 获取数据与更新
  • 就如前面的流程图所示,我们通过 ConfigWrapper.of(…) 方法就能获得配置的数据对象
  • 之后其中数据,进行对应的修改、赋值操作即可,修改后会调用到 updateShouldNotify 来确认是否通知 UI 更新

2.4 页面编写 MaterialApp

  • 页面的编写主要注意两个
  • 一方面是页面的更新(flutter_redux / InheritedWidget)
  • 另一方面是诸如网络异常、登录成功之类,各种提示的显示(eventBus)
2.4.1 页面独立
  • 首先根据 oop 六大原则,我们需要将 app 的页面独立出一个类
  • 这里建议将其命名为 app.dart 更为合理,因为有入口的意思
代码语言:javascript复制
class FlutterReduxApp extends StatefulWidget {
  @override
  _FlutterReduxAppState createState() => _FlutterReduxAppState();
}

class _FlutterReduxAppState extends State<FlutterReduxApp> {
  // ...
}

2.5 页面更新 flutter_redux

  • 关于数据与页面的绑定/更新,前面已经介绍了 InheritedWidget
  • flutter_redux 是在 InheritedWidget 的基础上封装的,对于 UI 上数据的更新与管理更加方便高效,但是如果数据很简单,或者不涉及 UI 那么使用 InheritedWidget 更简单一些也就比较适合

这里如果是还不会使用 flutter_redux 的同学可以先看这篇文章 「 flutter 必知必会 」最强数据管理方案 flutter_redux 使用解析

  • OK,那么一个企业级项目的 main.dart 木块中该如何使用 flutter_redux 呢?
  • 下面我们就以 GSYGitHubApp 为例,看看优秀的 app 是怎么实现的
2.4.1 创建 store
  • 要使用 flutter_redux 来对页面进行管理,就系要实例化 store
代码语言:javascript复制
  /// 创建Store,引用 GSYState 中的 appReducer 实现 Reducer 方法
  /// initialState 初始化 State
  final store = new Store<GSYState>(
    appReducer,

    ///拦截器
    middleware: middleware,

    ///初始化数据
    initialState: new GSYState(
        userInfo: User.empty(),
        login: false,
        themeData: CommonUtils.getThemeData(GSYColors.primarySwatch),
        locale: Locale('zh', 'CH')),
  );
  • 但是使用 store 需要准备 appReducer、middleware、GSYState 四个模块,下面我们逐个来看看
2.4.2 创建 Reducer
  • 源码中 Reducer 是一个方法 typedef State Reducer(State state, dynamic action);
  • 我们自定义了 appReducer 用于创建 store
代码语言:javascript复制
GSYState appReducer(GSYState state, action) {
  return GSYState(
    ///通过 UserReducer 将 GSYState 内的 userInfo 和 action 关联在一起
    userInfo: UserReducer(state.userInfo, action),

    ///通过 ThemeDataReducer 将 GSYState 内的 themeData 和 action 关联在一起
    themeData: ThemeDataReducer(state.themeData, action),

    ///通过 LocaleReducer 将 GSYState 内的 locale 和 action 关联在一起
    locale: LocaleReducer(state.locale, action),
    login: LoginReducer(state.login, action),
  );
}
  • 那么 GSYState 需要如何设计呢?
2.4.3 创建 State
  • 全局Redux store 的对象,保存State数据
代码语言:javascript复制
class GSYState {
  ///用户信息
  User userInfo;

  ///主题数据
  ThemeData themeData;

  ///语言
  Locale locale;

  ///当前手机平台默认语言
  Locale platformLocale;

  ///是否登录
  bool login;

  ///构造方法
  GSYState({this.userInfo, this.themeData, this.locale, this.login});
}
2.4.4 拦截器 middleware
  • 拦截器顾名思义就是拦截消息是否再往下传递
代码语言:javascript复制
final List<Middleware<GSYState>> middleware = [
  EpicMiddleware<GSYState>(loginEpic),
  EpicMiddleware<GSYState>(userInfoEpic),
  EpicMiddleware<GSYState>(oauthEpic),
  UserInfoMiddleware(),
  LoginMiddleware(),
];
  • 可以看到 GSYGitHubApp 中设置了 5 个拦截器, 如果均满足其中的筛选条件,就可以进行后续的 UI 刷新操作
  • 就比如第一个‘登录’,如果用户没登录,自然不用再往后了,按照 app 设计的逻辑,这时需要先跳转登录才行
2.4.5 全局注册
  • 在 _HomePageState 的 build 方法中, 配置 store 方便后续使用
代码语言:javascript复制
  @override
  Widget build(BuildContext context) {
    return StoreBuilder<GSYState>(
      builder: (context, store) {
        double size = 200;
        return Material(
          ...
        );
      },
    );
  }
2.4.6 注册更新监听
  • 在需要 Store 中数据的地方/页面,通过 StoreConnector 进行注册
代码语言:javascript复制
class DemoUseStorePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    ///通过 StoreConnector 关联 GSYState 中的 User
    return new StoreConnector<GSYState, User>(
      ///通过 converter 将 GSYState 中的 userInfo返回
      converter: (store) => store.state.userInfo,
      ///在 userInfo 中返回实际渲染的控件
      builder: (context, userInfo) {
        return new Text(
          userInfo.name,
          style: Theme.of(context).textTheme.headline4,
        );
      },
    );
  }
}
2.4.7 触发
  • 通过 store.dispatch(…) 可以调用到 appReducer ,并对 store 中的数据进行刷新
  • 同时可以通知到注册了这些数据的地方,让他们自动取刷新对应的 ui
代码语言:javascript复制
  ///初始化用户信息
  static initUserInfo(Store store) async {
    var token = await LocalStorage.get(Config.TOKEN_KEY);
    var res = await getUserInfoLocal();
    if (res != null && res.result && token != null) {
      store.dispatch(UpdateUserAction(res.data));
    }

    ///读取主题
    String themeIndex = await LocalStorage.get(Config.THEME_COLOR);
    if (themeIndex != null && themeIndex.length != 0) {
      CommonUtils.pushTheme(store, int.parse(themeIndex));
    }

    ///切换语言
    String localeIndex = await LocalStorage.get(Config.LOCALE);
    if (localeIndex != null && localeIndex.length != 0) {
      CommonUtils.changeLocale(store, int.parse(localeIndex));
    } else {
      CommonUtils.curLocale = store.state.platformLocale;
      store.dispatch(RefreshLocaleAction(store.state.platformLocale));
    }

    return new DataResult(res.data, (res.result && (token != null)));
  }

2.5 引入 event_bus

  • 由于 HomePage 是最原始/基础的 page ,所以我们可以吧网络请求的 Toast 显示在这个页面上来
  • 这样无论是哪个模块发生问题,HomePage 监听到后都能统一的显示 Toast
  • 很明显这是一个多对一的情形(多个发送方对一个接收方 HomePage),而且发送事件的逻辑是分散在不同功能模块中的,所以我们不要采用 event_bus(事件总线来实现)

如果还不熟悉 event_bus 的同学,可以先看这篇博客 https://blog.csdn.net/qq_43377749/article/details/115050851?spm=1001.2014.3001.5501

2.5.1 实例化 eventbus
  • 使用 event_bus 进行事件的发送和接收都是通过eventBus 对象进行的
  • 所以我们需要先实例化一个 eventBus 对象
  • 为了方便管理,我们先新建一个文件 index.dart 来用于管理项目中的 eventBus 对象
  • 具体实例化过程如下
代码语言:javascript复制
import 'package:event_bus/event_bus.dart';

EventBus eventBus = new EventBus();
2.5.2 定义消息 event 对象
  • 在传递网络请求结果的事件时,我们将其内容封装在一个对象中传递
  • 通常情况下我们只需要在请求错误时,向用户反馈结果
  • 所以这里我们只需封装一个 HttpErrorEvent 对象(当然如果需要,我们也可以添加更多的类型对象)
  • 这里我们新建一个类:http_error_event.dart 来专门管理相关对象
代码语言:javascript复制
class HttpErrorEvent {
  final int code;

  final String message;

  HttpErrorEvent(this.code, this.message);
}
2.5.3 创建监听器
  • 我们需要为 HomePage 建立一个监听器,来监听传来的各种事件
  • 这里一般采用混合注入的方式
  • 首先我们采用 mixin 方式建立,同时让他 on State
代码语言:javascript复制
mixin HttpErrorListener on State<FlutterReduxApp> {
  StreamSubscription stream;

  ///这里为什么用 _context 你理解吗?
  ///因为此时 State 的 context 是 FlutterReduxApp 而不是 MaterialApp
  ///所以如果直接用 context 是会获取不到 MaterialApp 的 Localizations 哦。
  BuildContext _context;

  @override
  void initState() {
    super.initState();

    ///Stream演示event bus
    stream = eventBus.on<HttpErrorEvent>().listen((event) {
      errorHandleFunction(event.code, event.message);
    });
  }

  @override
  void dispose() {
    super.dispose();
    if (stream != null) {
      stream.cancel();
      stream = null;
    }
  }

  ///网络错误提醒
  errorHandleFunction(int code, message) {
    switch (code) {
      case Code.NETWORK_ERROR:
        showToast(GSYLocalizations.i18n(_context).network_error);
        break;
      case 401:
        showToast(GSYLocalizations.i18n(_context).network_error_401);
        break;
      case 403:
        showToast(GSYLocalizations.i18n(_context).network_error_403);
        break;
      case 404:
        showToast(GSYLocalizations.i18n(_context).network_error_404);
        break;
      case 422:
        showToast(GSYLocalizations.i18n(_context).network_error_422);
        break;
      case Code.NETWORK_TIMEOUT:
        //超时
        showToast(GSYLocalizations.i18n(_context).network_error_timeout);
        break;
      case Code.GITHUB_API_REFUSED:
        //Github API 异常
        showToast(GSYLocalizations.i18n(_context).github_refused);
        break;
      default:
        showToast(GSYLocalizations.i18n(_context).network_error_unknown  
            " "  
            message);
        break;
    }
  }

  showToast(String message) {
    Fluttertoast.showToast(
        msg: message,
        gravity: ToastGravity.CENTER,
        toastLength: Toast.LENGTH_LONG);
  }
}
2.5.4 发送事件
  • 发送消息时只要调用 eventBus.fire(…) 即可
  • 参数是需要传递的消息对象,这里也就是 HttpErrorEvent
  • 带这个项目中,使用的部分诸如
代码语言:javascript复制
  static errorHandleFunction(code, message, noTip) {
    if (noTip) {
      return message;
    }
    if(message != null && message is String && (message.contains("Connection refused") || message.contains("Connection reset"))) {
      code = GITHUB_API_REFUSED;
    }
    eventBus.fire(new HttpErrorEvent(code, message));
    return message;
  }
2.5.5 接受事件
  • 消息发送后,经过过滤器等步骤的传递
  • 最后会传递到上面‘监听器’的 listen 方法下
  • 再由 listen 的回调进行后续操作(比如这个项目中,监听器是捆绑在 _HomePage 上的,因此可以在页面上显示 Toast 等等)
代码语言:javascript复制
  @override
  void initState() {
    super.initState();

    ///Stream演示event bus
    stream = eventBus.on<HttpErrorEvent>().listen((event) {
      errorHandleFunction(event.code, event.message);
    });
  }

  ///网络错误提醒
  errorHandleFunction(int code, message) {
    switch (code) {
      case Code.NETWORK_ERROR:
        showToast(GSYLocalizations.i18n(_context).network_error);
        break;
      case 401:
        showToast(GSYLocalizations.i18n(_context).network_error_401);
        break;
        // ...
      default:
        showToast(GSYLocalizations.i18n(_context).network_error_unknown  
            " "  
            message);
        break;
    }
  }

  showToast(String message) {
    Fluttertoast.showToast(
        msg: message,
        gravity: ToastGravity.CENTER,
        toastLength: Toast.LENGTH_LONG);
  }
2.5.6 完整代码
  • 这个模块的详细内容地址:
  • gsy_github_app_flutter/lib/app.dart

三、总结

  • 限于篇幅原因,这里就不展开讲了,后续会出一个相关的视频进行更详细的解析 bilibili@黎明韭菜
  • 设计一个完美的程序入口不是件容易的事情,也希望大家有更完美的方案能在评论区分享
  • 最后感谢大家的三连或者关注支持,我们下期博客再见

0 人点赞