提示
:温馨提示一下哈,这篇文章主要是针对 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 捕获,但异步异常则比较麻烦
- 举个异步异常的栗子:
try{
Future.delayed(Duration(seconds: 1)).then((e) => Future.error("asynchronous error"));
}catch (e){
// TODO Report
}
- Dart 中有一个 runZoned(…) 方法( Zone 表示一个代码执行的环境范围)
- 在 Zone 中可以捕获日志输出、Timer 创建、微任务调度的行为,同时 Zone 也可以捕获所有未处理的异常
- 将上面代码结合 runZoned 实现就是:
runZoned(() {
Future.delayed(Duration(seconds: 1)).then((e) => Future.error("asynchronous error"));
}, onError: (Object obj, StackTrace stack) {
// crash 日志打印与上报
})
- 所以 main() 方法就可以相应的写作:
void main() {
runZoned(() {
// TODO some init
runApp(FlutterReduxApp());
}, onError: (Object obj, StackTrace stack) {
// crash 日志打印与上报
print(obj);
print(stack);
});
}
2.2 错误页展示 - ErrorWidget
- Flutter 在很多关键的方法进行了异常捕获
- 举个例子,当布局发生越界或不和规范时,会自动弹出一个错误界面:
- 现网环境中,我们不能直接给用户展示这个页面,这时就需要 ErrorWidget。让它处于最底层来覆盖这个这样的页面
- 添加上 ErrorWidget 后如下所示:
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 文件内容如下
///环境配置
@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
///往下共享环境配置
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
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 更为合理,因为有入口的意思
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
/// 创建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
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数据
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
- 拦截器顾名思义就是拦截消息是否再往下传递
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 方便后续使用
@override
Widget build(BuildContext context) {
return StoreBuilder<GSYState>(
builder: (context, store) {
double size = 200;
return Material(
...
);
},
);
}
2.4.6 注册更新监听
- 在需要 Store 中数据的地方/页面,通过 StoreConnector 进行注册
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
///初始化用户信息
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 对象
- 具体实例化过程如下
import 'package:event_bus/event_bus.dart';
EventBus eventBus = new EventBus();
2.5.2 定义消息 event 对象
- 在传递网络请求结果的事件时,我们将其内容封装在一个对象中传递
- 通常情况下我们只需要在请求错误时,向用户反馈结果
- 所以这里我们只需封装一个 HttpErrorEvent 对象(当然如果需要,我们也可以添加更多的类型对象)
- 这里我们新建一个类:http_error_event.dart 来专门管理相关对象
class HttpErrorEvent {
final int code;
final String message;
HttpErrorEvent(this.code, this.message);
}
2.5.3 创建监听器
- 我们需要为 HomePage 建立一个监听器,来监听传来的各种事件
- 这里一般采用混合注入的方式
- 首先我们采用 mixin 方式建立,同时让他 on State
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
- 带这个项目中,使用的部分诸如
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 等等)
@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@黎明韭菜
- 设计一个完美的程序入口不是件容易的事情,也希望大家有更完美的方案能在评论区分享
- 最后感谢大家的三连或者关注支持,我们下期博客再见