Flutter实战总结之脚手架篇
使用Flutter开发项目快两年时间了,成功支撑了两个App项目上线。经历过Flutter1.1.0版本到2.10.0版本的更迭,期间遇到了很多困难,踩了很多坑,但最终成功将Flutter技术栈应用到前端App跨平台开发中来。为了聚焦业务、快速进行功能迭代,我尝试搭建Flutter脚手架项目(适用移动端),旨在简化通用模板代码、封装冗杂的细节处理,提供灵活的页面状态管理且高度可定制。
Flutter环境搭建
Flutter环境搭建在官网有详细的说明,请查阅文档。此处简单说明:
- 移动端:
①:iOS,需要一台苹果电脑,安装xcode,vscode
②: Android,需要配置Java环境,安装Android Studio,vscode
通常情况下,环境搭建完毕后,使用flutter doctor -v
命令检测一下环境集成结果。
flutter doctor -v
Flutter 脚手架概要说明及使用到的三方插件
Flutter 脚手架(下称Flutter Scaffold)使用
mobx
管理数据状态,使用retrofit
进行接口数据请求,json_annotation
用来进行接口数据序列化和反序列化。其中页面渲染和数据逻辑(pageStore接管)处理分离。下表是Flutter Scaffold项目使用到的部分三方插件。(所有插件均可到Flutter插件市场
查看)
编号 | 插件名称及版本号 | 说明 |
---|---|---|
1 | flutter: sdk: flutter | 所有flutter项目在创建后,均默认配置flutter sdk依赖。通过environment属性标记版本范围。environment:sdk: ">=2.7.0 <3.0.0" |
2 | retrofit: 1.3.4+1 | 网络组件 |
3 | mobx: ^1.2.1+4 flutter_mobx: ^1.1.0+2 | 页面状态管理组件 |
4 | fluro: ^1.7.8 | 导航管理组件 |
5 | permission_handler:5.0.1+1 | 权限管理插件 |
6 | flutter_screenutil | 屏幕适配工具 |
dev_dependencies
编号 | 插件名称及版本号 | 说明(该表格为Flutter代码生成或编辑阶段使用到的依赖) |
---|---|---|
1 | retrofit_generator: 1.4.0+2 | 用于生成retrofit实现类 |
2 | build_runner: 1.10.2 | 提供了用于生成文件的通用命令,这些命令中有的可以用于测试生成的文件,有的可以用于对外提供这些生成的文件以及它们的源代码。能够扫描出每个dart文件中类成员变量、构造函数、所有父类、注释等 |
3 | json_serializable: ^3.2.0 | The builders generate code when they find members annotated with classes defined in package:json_annotation |
4 | flutter_native_splash: ^0.1.9 | 用于生成原生的闪屏代码 |
5 | mobx_codegen: ^1.1.2 | mobx状态管理相关代码生成工具 |
脚手架工程目录结构
Flutter脚手架(下称Flutter Scaffod)的主体目录结构与使用
flutter create aplication_name
创建的项目一致。
.
├── README.md
├── analysis_options.yaml
├── android
│ ├── app
│ ├── build.gradle
│ ├── debug.jks
│ ├── debug.properties
│ ├── flutter_scaffold_android.iml
│ ├── gradle
│ │ └── wrapper
│ │ └── gradle-wrapper.properties
│ ├── gradle.properties
│ ├── local.properties
│ ├── release.jks
│ ├── release.properties
│ ├── settings.gradle
│ ├── start-build.sh
│ ├── start-clean.sh
│ └── version.properties
├── assets
│ ├── audios
│ │ └── bee.wav
│ └── images
│ ├── address
│ ├── balance
│ ├── cart
│ ├── common
│ └── update
├── cmd.sh
├── flutter_scaffold.iml
├── ios
│ ├── Flutter
│ │ ├── AppFrameworkInfo.plist
│ │ ├── Debug.xcconfig
│ │ ├── Flutter.podspec
│ │ ├── Generated.xcconfig
│ │ ├── Release.xcconfig
│ │ └── flutter_export_environment.sh
│ ├── Podfile
│ ├── Podfile.lock
│ ├── Pods
│ ├── Runner
│ │ ├── Base.lproj
│ │ ├── GeneratedPluginRegistrant.h
│ │ ├── GeneratedPluginRegistrant.m
│ │ ├── Info.plist
│ │ ├── Runner-Bridging-Header.h
│ │ └── Runner.entitlements
│ ├── Runner.xcodeproj
│ │ ├── project.pbxproj
│ │ ├── project.xcworkspace
│ │ │ ├── contents.xcworkspacedata
│ │ │ └── xcshareddata
│ │ │ ├── IDEWorkspaceChecks.plist
│ │ │ └── WorkspaceSettings.xcsettings
│ │ └── xcshareddata
│ │ └── xcschemes
│ │ └── Runner.xcscheme
│ └── Runner.xcworkspace
│ ├── contents.xcworkspacedata
│ ├── xcshareddata
│ │ ├── IDEWorkspaceChecks.plist
│ │ └── WorkspaceSettings.xcsettings
│ └── xcuserdata
│ └── ddg-dany.xcuserdatad
│ └── UserInterfaceState.xcuserstate
├── lib
│ ├── application.dart
│ ├── components
│ │ ├── common
│ │ └── dialog
│ ├── config
│ │ ├── api.dart
│ │ ├── color_set.dart
│ │ ├── image_set.dart
│ │ ├── keys.dart
│ ├── constants
│ │ └── constants.dart
│ ├── generated_plugin_registrant.dart
│ ├── lib
│ ├── main.dart
│ ├── model
│ │ ├── address
│ │ │ ├── address_manage
│ │ │ │ ├── address_manage.dart
│ │ │ │ └── address_manage.g.dart
│ │ └── version
│ │ ├── version.dart
│ │ └── version.g.dart
│ ├── page
│ │ ├── 404
│ │ │ ├── page_404.dart
│ │ │ ├── page_404_store.dart
│ │ │ └── page_404_store.g.dart
│ ├── routes
│ │ ├── routes.dart
│ │ └── routes_handlers.dart
│ ├── service
│ │ ├── api_error_handler.dart
│ │ ├── app
│ │ │ ├── app_service.dart
│ │ │ └── app_service.g.dart
│ │ ├── dio_factory.dart
│ │ └── retrofit_client
│ ├── store
│ │ └── base_store.dart
│ └── utils
├── pubspec.lock
├── pubspec.yaml
├── test
└── web
详细说明
Flutter Scaffod主要代码位于项目
/lib
目录下,该部分对项目页面状态、路由、数据加载等逻辑及关键代码进行说明。
application.dart 全局状态管理
application.dart 文件位于/lib目录下,通常用于一些三方插件(如友盟,TPNS等)的初始化及全局状态的持有。
① 初始化缓存管理工具 SpUtil
② 持有全局路由管理
③ 持有用户状态
④ 持有首页store
/*
* 全局application
* @Author: otto.wong
* @Date: 2020-11-28 11:19:44
* @Last Modified by: otto.wong
* @Last Modified time: 2020-12-15 20:00:19
*/
import 'dart:io';
import 'package:InternetHospital/store/userStore.dart';
import 'package:InternetHospital/utils/loggerAgent.dart';
import 'package:device_info/device_info.dart';
import 'package:fluro/fluro.dart';
import 'package:package_info/package_info.dart';
import 'package:sp_util/sp_util.dart';
import 'config/routes.dart';
import 'store/indexStore.dart';
class Application {
static FluroRouter fluroRouter;
static UserStore userStore;
static IndexStore indexStore;
static init() async {
await SpUtil.getInstance();
Application.userStore = UserStore();
}
static setup() {
final router = FluroRouter();
Routes.configureRoutes(router);
Application.fluroRouter = router;
Application.indexStore = IndexStore();
}
}
main.dart 应用入口
/lib/main.dart
为Flutter项目的入口文件,从main
函数开始执行。通常在runApp(App(key: UniqueKey()))
方法执行之前,进行资源加载(包括本地资源和网络数据)操作,在加载过程中,用户看到的是App的开屏页面,注意资源加载时间不能过长,所以此处应仅加载必要资源。在AppWidget的build方法中,进行三方插件的初始化和应用主题、国际化等配置。在Flutter Scaffold中,配置了EasyLoading
,RefreshConfiguration
,ScreenUtilInit
。
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/services.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_scaffold/application.dart';
import 'package:flutter_scaffold/config/api.dart';
import 'package:flutter_scaffold/config/color_set.dart';
import 'package:flutter_scaffold/config/custom_localizations_delegates.dart';
import 'package:flutter_scaffold/constants/constants.dart';
import 'package:flutter_scaffold/model/env_type.dart';
import 'package:flutter_scaffold/page/splash/splash.dart';
import 'package:flutter_scaffold/routes/routes.dart';
import 'package:flutter_scaffold/utils/logger_agent.dart';
import 'package:flutter_scaffold/utils/umeng.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'package:sp_util/sp_util.dart';
var hasAcceptAgreement = false;
void main() async {
// WidgetsFlutterBinding的ensureInitialized()其实就是一个获取WidgetsFlutterBinding单例的过程
WidgetsFlutterBinding.ensureInitialized();
FlutterError.onError = (FlutterErrorDetails details) {
// 只有在生产环境才上报异常到友盟
if (ApiConfig.envType == EnvType.prod) {
UMengUtils.reportError(details.exceptionAsString());
}
};
// 提前进行数据初始化
await Application.setup();
await Application.init();
// 强制竖屏
SystemChrome.setPreferredOrientations(
[DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]).then(
(value) =>
runZonedGuarded(() => runApp(App(key: UniqueKey())), (obj, error) {
LoggerAgent.e('runZonedGuarded error: $error');
// if (ApiConfig.envType == EnvType.prod) {
UMengUtils.reportError(error.toString());
// }
}),
);
}
class App extends StatelessWidget {
const App({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
// 设置状态栏透明样式
SystemUiOverlayStyle _style =
const SystemUiOverlayStyle(statusBarColor: Colors.transparent);
SystemChrome.setSystemUIOverlayStyle(_style);
var rootWidget = initRefreshConfiguration();
return rootWidget;
}
/// 初始化一些插件
initPlugins() {
// var builder = EasyLoading.init();
EasyLoading.instance
..displayDuration = const Duration(milliseconds: 2000)
..indicatorType = EasyLoadingIndicatorType.fadingCircle
..contentPadding = const EdgeInsets.all(20)
..loadingStyle = EasyLoadingStyle.dark
..indicatorSize = 45.0
..radius = 10.0
..progressColor = Colors.white
..backgroundColor = Colors.black
..indicatorColor = Colors.white
..textColor = Colors.white
..maskColor = Colors.blue.withOpacity(0.5)
..userInteractions = false
..dismissOnTap = false;
// return builder;
return (context, child) {
EasyLoading.init();
EasyLoading.instance
..displayDuration = const Duration(milliseconds: 2000)
..indicatorType = EasyLoadingIndicatorType.fadingCircle
..contentPadding = const EdgeInsets.all(20)
..loadingStyle = EasyLoadingStyle.dark
..indicatorSize = 45.0
..radius = 10.0
..progressColor = Colors.white
..backgroundColor = Colors.black
..indicatorColor = Colors.white
..textColor = Colors.white
..maskColor = Colors.blue.withOpacity(0.5)
..userInteractions = false
..dismissOnTap = false;
return FlutterEasyLoading(
child: MediaQuery(
data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0),
child: child,
),
);
};
}
/// 初始化全局下拉刷新配置
Widget initRefreshConfiguration() {
// 全局下拉刷新配置
return RefreshConfiguration(
headerBuilder: () => const MaterialClassicHeader(),
footerBuilder: () => const ClassicFooter(),
headerTriggerDistance: 80.0,
springDescription:
const SpringDescription(stiffness: 170, damping: 16, mass: 1.9),
maxOverScrollExtent: 100,
maxUnderScrollExtent: 0,
enableScrollWhenRefreshCompleted: true,
enableLoadingWhenFailed: false,
hideFooterWhenNotFull: true,
enableBallisticLoad: true,
child: buildMaterialApp(),
);
}
Widget buildMaterialApp() {
final initRoute = hasAcceptAgreement ? Routes.root : null;
final initPage = hasAcceptAgreement ? null : SplashPage(key: UniqueKey());
return ScreenUtilInit(
designSize: const Size(375, 667),
splitScreenMode: false,
builder: () => MaterialApp(
key: const Key('flutterScaffold'),
locale: const Locale('zh', 'CN'),
debugShowCheckedModeBanner: ApiConfig.envType != EnvType.prod ||
logEnabled ||
apiLogEnabled, // 非生产环境或者开启日志,显示debug角标
title: 'Flutter Scaffold',
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
CommonLocalizationsDelegate()
],
supportedLocales: const [
Locale('zh', 'CN'),
Locale('en', 'US'),
],
theme: ColorSet.defaultTheme,
navigatorObservers: [Application.lifeObserver],
navigatorKey: Application.navigatorKey,
onGenerateRoute: Application.fluroRouter.generator,
builder: initPlugins(),
initialRoute: initRoute,
home: initPage,
),
);
}
}
routes.dart 路由管理
路由管理包括路由定义,路由处理器声明,页面跳转方法封装。具体可以查看
fluro
文档。
/*
* 路由处理
* @Author: otto.wong
* @Date: 2020-12-03 13:37:49
* @Last Modified by: otto.wong
* @Last Modified time: 2020-12-10 09:57:05
*
*/
import 'package:fluro/fluro.dart';
import 'package:flutter/material.dart';
import 'package:flutter_scaffold/page/404/page_404.dart';
import 'package:flutter_scaffold/page/auth/login/login.dart';
typedef OnRouteGenerate = T Function(
BuildContext? context, Map params);
/// 构建路由页面参数
Map buildPageParams(BuildContext? context) {
return (context?.settings?.arguments ?? {'empty': 'noParams'})
as Map;
}
/// 生成路由页面handler
Handler generatePageHandler(OnRouteGenerate callback) {
return Handler(handlerFunc: (context, params) {
var pageParams = buildPageParams(context);
var pageInstance = callback(context, pageParams);
return pageInstance;
});
}
/// 404路由
var page404Handler = generatePageHandler(
(context, params) => Page404(key: UniqueKey(), pageParams: params));
/// 根路由
var rootHandler = generatePageHandler(
(context, params) => IndexPage(key: UniqueKey(), pageParams: params));
/// 扫码
var qrHandler = generatePageHandler(
(context, params) => QRPage(key: UniqueKey(), pageParams: params));
/// 中转页
var hubHandler = generatePageHandler(
(context, params) => HubPage(key: UniqueKey(), pageParams: params));
/// 通用H5页面
var h5Handler = generatePageHandler(
(context, params) => WebViewPage(key: UniqueKey(), pageParams: params));
...
/// 页面跳转
import 'dart:io';
import 'package:fluro/fluro.dart';
import 'package:flutter/material.dart';
import 'package:flutter_scaffold/application.dart';
class Routes {
static StackDynamic routePathStack = StackDynamic();
static const String root = "/"; // 根路由
static const String splash = '/splash'; // 闪屏页面路由
static const String qr = '/qr'; // 扫码
static const String hub = '/hub'; // 中转页
static const String h5 = '/h5'; // h5路由
}
/// 展示通用弹框
static Future showMagicModal(
BuildContext context,
dynamic content, {
String? title,
String? conformText,
String? cancelText,
bool? showCancel,
bool? showConform,
bool? showCountDown,
bool? silentCountDown,
int? countDown,
bool? showMagicTopic,
String? topicIcon,
String? topicContent,
}) {
return Navigator.of(context).push(TransparentRoute(
builder: (BuildContext context) => MagicDialog(
pageParams: {
'title': title,
'content': content,
'conformText': conformText,
'cancelText': cancelText,
'showCancel': showCancel,
'showConform': showConform,
'showCountDown': showCountDown,
'silentCountDown': silentCountDown,
'countDown': countDown,
'showMagicTopic': showMagicTopic,
'topicIcon': topicIcon,
'topicContent': topicContent,
},
)));
}
static canPop(BuildContext context) {
return Navigator.of(context).canPop();
}
static void pop(BuildContext context, [dynamic result]) {
if (Navigator.of(Application.navigatorKey.currentContext!).canPop()) {
Application.fluroRouter.pop(context, result);
}
}
/// pop直到目标路由
static void popUntil(BuildContext context, String routePath) {
Navigator.of(context).popUntil(ModalRoute.withName(routePath));
}
/// 根据路由地址展示页面
/// context 上下文
/// path 路由
/// replace 是否替换当前路由页面
/// clearStack 是否清空当前路由栈
/// pageParams Map? 页面参数
static dynamic showPageByPath(BuildContext context, String path,
{bool replace = false,
bool clearStack = false,
Map? pageParams}) {
// 取消焦点
// FocusScope.of(context).requestFocus(FocusNode());
var pageUri = '${path}_${pageParams?.toString() ?? ''}';
if (pageUri == currentRoutePath) {
LoggerAgent.d('路由重复了: $pageUri');
// 防止进入重复的路由
return Future.value(0);
}
if (replace) {
// 替换当前路由
routePathStack.pop();
}
if (clearStack) {
// 清空路由栈
routePathStack.clear();
}
// 记录当前路由地址
routePathStack.push(pageUri);
LoggerAgent.d('currentRoutePath: $currentRoutePath');
return Application.fluroRouter.navigateTo(
context,
path,
replace: replace,
clearStack: clearStack,
routeSettings: RouteSettings(arguments: pageParams),
);
}
/// 显示扫码二维码
static Future showQRPage(
BuildContext context, {
bool clearStack = false,
bool replace = false,
Map? pageParams,
}) =>
showPageByPath(context, qr,
clearStack: clearStack, replace: replace, pageParams: pageParams);
/// 显示h5内容
static Future showH5Page(
BuildContext context, {
required String title,
required String url,
bool replace = false,
}) {
return showPageByPath(context, h5,
replace: replace, pageParams: {'title': title, 'url': url});
}
/// 展示文章详情
static void showArticleDetails(BuildContext context,
{String id, String title}) {
Application.fluroRouter.navigateTo(context, h5,
routeSettings: RouteSettings(arguments: {
'title': title,
'url':
'${ApiConfig.getArticleBaseUrl()}/h5-customize/pages/article/articleDetails.html?articleId=${id}'
}));
}
数据状态管理
Flutter 应用是 声明式 的,这也就意味着 Flutter 构建的用户界面就是应用的当前状态。开发者将主要精力聚焦在对数据的管理上即可。
base_state.dart
baseState.dart 位于 /lib/pages目录下,是动态页面的一个基类。封装了页面参数初始化、页面数据自动加载及页面UI根据状态自动渲染。
新增业务时只需关注UI渲染部分即可。BaseState接收两个泛型参数,泛型参数S为 BasePageWidget 的子类,一般为业务页面,T为页面状态管理类,是 BaseStore 的子类。页面上的数据加载、状态管理均由 BaseStore 的子类实现控制。在BaseState中,页面store的初始化、入参注入及页面数据加载,均在initState 生命周期中实现。
注意onPaused
和onResume
方法,结合WidgetsBindingObserver
和RouteAware
实现对Flutter页面生命周期的监听,并在合适的时候被调用。其中,onPaused
方法在当前路由出栈或者应用被切回后台(智能手机设备按下home键或者用户将其他应用切到前台)时调用,通常可用于数据持久化场景;onResume
在当前路由入栈或者应用从后台切到前台时调用。通常可用于数据刷新场景,如从B路由回到A路由并携带了从B路由选择的数据场景。结合onlyLoadDataAfterResume
方法可实现回到当前路由时刷新页面数据的需求。
import 'package:flutter/material.dart';
abstract class BasePageWidget extends StatefulWidget {
final String pageName;
final Map? pageParams;
const BasePageWidget({Key? key, required this.pageName, this.pageParams})
: super(key: key);
}
import 'dart:io';
import 'package:flutter/material.dart';
const List _kDefaultRainbowColors = [
Color(0xff8CEEC7),
Color(0xff01C591),
];
abstract class BaseState
extends State with WidgetsBindingObserver, RouteAware {
late final T pageStore;
AppBar? appBar;
void initPageStore();
// 页面销毁后是否执行store的dispose方法,用于多页面共用pageStore场景
bool autoDisposePageStore() {
return true;
}
// 键盘弹出后页面是否需要resize
bool needResize() {
return true;
}
/// 标记页面安全区组件是否显示顶部安全区域,默认显示。
/// 若页面顶部有appbar,则该配置失效;
/// 若页面顶部需自定义样式,如”个人中心“页,则重写该方法,返回false
bool showSafeAreaPageTop() {
return true;
}
bool showSafeAreaPageBottom() {
return true;
}
/// 仅在页面resume之后,加载数据。默认false
bool onlyLoadDataAfterResume() {
return false;
}
/// 在网络恢复后刷新页面
bool refreshAfterNetworkAvailable() {
return false;
}
/// 网络可用后回调
void loadPageDataAfterNetworkAvailable(dynamic args) {
pageStore.loadData();
}
double get appBarHeight => appBar == null ? 0 : appBar!.preferredSize.height;
@override
void initState() {
super.initState();
WidgetsBinding.instance!.addObserver(this);
initPageStore();
pageStore.initState(pageParams: widget.pageParams);
WidgetsBinding.instance!.addPostFrameCallback((_) {
if (!onlyLoadDataAfterResume()) {
pageStore.loadData();
}
});
UMengUtils.onPageStart(widget.pageName);
// 监听网络状态可用
if (refreshAfterNetworkAvailable()) {
EventBus().on(
EventBusEvent.networkAvailable, loadPageDataAfterNetworkAvailable);
}
}
@override
void didChangeMetrics() {
super.didChangeMetrics();
var viewBottom = WidgetsBinding.instance!.window.viewInsets.bottom;
Application.globalConfigStore.updateKeyboardVisibility(viewBottom > 0.0);
Application.updateKeyboardHeight();
}
@override
void didChangeDependencies() {
dynamic route = ModalRoute.of(context);
if (route != null) {
Application.lifeObserver.subscribe(this, route);
}
super.didChangeDependencies();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
switch (state) {
case AppLifecycleState.inactive:
// 应用处于未激活状态
break;
case AppLifecycleState.resumed:
// 应用回到前台
onResume();
break;
case AppLifecycleState.paused:
// 应用处于后台
onPaused();
break;
case AppLifecycleState.detached:
// 挂载
break;
}
}
@override
void didPop() {
onPaused();
}
@override
void didPopNext() {
onResume();
}
@override
void didPushNext() {
onPaused();
}
@override
void didPush() {
onResume();
}
void onPaused() {
// LoggerAgent.d('${widget.pageName} on paused');
}
void onResume() {
// LoggerAgent.d('${widget.pageName} on resume');
if (onlyLoadDataAfterResume()) {
WidgetsBinding.instance!.addPostFrameCallback((_) {
pageStore.loadData();
});
}
}
Future onPageWillPop() async {
LoggerAgent.d('page: ${widget.pageName} will pop');
// if (Navigator.of(context).userGestureInProgress) {
// // 禁止在iOS和Android平台滑动返回
// // return false;
// }
return true;
}
Color? getPageContentBgColor() {
return null;
}
Widget renderBackIcon(BuildContext context, bool isWhiteStyle) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => Routes.pop(context),
child: Center(
child: Image.asset(
isWhiteStyle
? ImageSet.navigator_back_black
: ImageSet.navigator_back_white,
width: SizeWidthUtils.w_10,
height: SizeWidthUtils.w_20,
fit: BoxFit.scaleDown,
),
),
);
}
/// 构建顶部栏
/// 默认显示左侧返回按钮,中间title,右侧无操作按钮
/// 若页面无标题栏,则重写该方法并返回null即可
AppBar? buildPageNavigation(BuildContext context) {
return AppBar(
leading: renderBackIcon(context, false),
title: Text(
widget.pageName,
style:
const TextStyle(fontSize: FontSizeUtils.f_18, color: Colors.white),
),
centerTitle: true,
);
}
AppBar buildWhiteAppBar() {
return AppBar(
backgroundColor: Colors.white,
leading: renderBackIcon(context, true),
title: Text(
widget.pageName,
style: const TextStyle(
fontSize: FontSizeUtils.f_18, color: ColorSet.pageTitleColor),
));
}
Widget pageRenderDelegate() {
var appBar = buildPageNavigation(context);
if (appBar == null) {
return Observer(
builder: (_) => pageStore.pageStatus == PageStatus.success
? SafeArea(
top: showSafeAreaPageTop(),
bottom: showSafeAreaPageBottom(),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [buildPageContent(context)],
))
: buildPageState(),
);
}
return Scaffold(
appBar: appBar,
resizeToAvoidBottomInset: needResize(),
backgroundColor: getPageContentBgColor(),
body: Observer(
builder: (_) => pageStore.currentPageStatus == PageStatus.success
? SafeArea(
bottom: showSafeAreaPageBottom(),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [buildPageContent(context)],
))
: buildPageState(),
),
);
}
Widget buildPageContent(BuildContext context);
Widget buildPageState() {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: pageStore.loadData,
child: Container(
decoration: const BoxDecoration(color: Colors.white),
padding: EdgeInsets.only(bottom: ScreenUtil().screenHeight * 0.2),
child: Center(
child: pageStore.pageStatus == PageStatus.loading
? const SizedBox(
width: 40,
height: 40,
child: LoadingIndicator(
colors: _kDefaultRainbowColors,
indicatorType: Indicator.ballSpinFadeLoader,
strokeWidth: 1,
),
)
: StatusLayout(
message: pageStore.pageMessage ?? 'ff',
icon: pageStore.pageIcon ?? ''),
),
),
);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => FocusScope.of(context).requestFocus(FocusNode()),
child: WillPopScope(
child: pageRenderDelegate(),
onWillPop: Platform.isIOS ? null : onPageWillPop,
// onWillPop: onPageWillPop,
),
);
}
@override
void dispose() {
Routes.routePathStack.pop();
super.dispose();
WidgetsBinding.instance!.removeObserver(this);
Application.lifeObserver.unsubscribe(this);
if (refreshAfterNetworkAvailable()) {
EventBus().off(
EventBusEvent.networkAvailable, loadPageDataAfterNetworkAvailable);
}
if (autoDisposePageStore()) {
pageStore.dispose();
}
EasyLoading.dismiss();
UMengUtils.onPageEnd(widget.pageName);
}
}
base_store.dart
baseStore.dart位于/lib/store目录下,是页面数据状态管理的基类,提供了统一的初始化、数据加载、页面状态更新等接口。所有业务页面的数据状态管理应继承该类。
import 'package:flutter/material.dart';
import 'package:flutter_scaffold/application.dart';
import 'package:flutter_scaffold/config/image_set.dart';
import 'package:flutter_scaffold/model/page/page_status.dart';
import 'package:flutter_scaffold/utils/logger_agent.dart';
import 'package:mobx/mobx.dart';
part 'base_store.g.dart';
const String pageIconEmptyDefault = ImageSet.pic_empty;
const String pageIconErrorDefault = ImageSet.pic_error;
abstract class BaseStore = BaseStoreBase with _$BaseStore;
abstract class BaseStoreBase with Store {
@observable
PageStatus pageStatus = PageStatus.loading;
@observable
String? pageMessage;
@observable
String? pageIcon;
Map? pageParams;
ReactionDisposer? disposer;
void initState({Map? pageParams}) {
if (pageParams != null) {
this.pageParams = pageParams;
}
}
void loadData();
void dispose() {
if (disposer != null) {
disposer!();
}
}
BuildContext get context => Application.navigatorKey.currentContext!;
@computed
bool get isLoading => pageStatus == PageStatus.loading;
@computed
bool get isSuccess => pageStatus == PageStatus.success;
@computed
bool get isError => pageStatus == PageStatus.error;
@computed
PageStatus get currentPageStatus => pageStatus;
@action
void showPageLoading() {
LoggerAgent.i('showPageLoading');
pageStatus = PageStatus.loading;
pageMessage = '';
pageIcon = '';
}
@action
void showPageSuccess() {
// LoggerAgent.i('showPageSuccess');
pageStatus = PageStatus.success;
}
@action
void showPageEmpty({String? pageMessage, String? pageIcon}) {
LoggerAgent.i('showPageEmpty pageMessage:$pageMessage pageIcon:$pageIcon');
pageStatus = PageStatus.empty;
this.pageMessage = pageMessage ?? '无数据~';
this.pageIcon = pageIcon ?? pageIconEmptyDefault;
}
@action
void showPageError({String? pageMessage, String? pageIcon}) {
LoggerAgent.i('showPageError');
pageStatus = PageStatus.error;
this.pageMessage = pageMessage ?? '出错了~';
this.pageIcon = pageIcon ?? pageIconErrorDefault;
}
}
文章列表示例
最后,我们看文章列表数据加载及页面渲染示例
/// 文章列表渲染
class _HomePageState extends BaseState {
@override
void initPageStore() {
this.pageStore = HomeStore();
}
_renderRow(BuildContext context, int index) {
var article = this.pageStore.articles[index];
return ListTile(
onTap: () => Routes.showArticleDetails(context,
id: article.id, title: article.title),
title: Container(
padding: EdgeInsets.only(bottom: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: Image.network(
article.imageUrl,
width: 120,
height: 80,
fit: BoxFit.cover,
),
),
Expanded(
child: Container(
padding: EdgeInsets.only(left: 10, top: 0),
height: 80,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${this.pageStore.articles[index].title}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left,
style: TextStyle(
fontSize: 17, fontWeight: FontWeight.bold),
),
Container(
padding: EdgeInsets.only(top: 5),
child: Text(
'${pageStore.articles[index].title}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left,
style: TextStyle(fontSize: 14),
),
)
],
)))
],
),
),
subtitle: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: (ScreenUtil().screenWidth - 20) / 4,
child: IconText(
'${article.author}',
icon: Icon(Icons.people),
iconSize: 18,
style: TextStyle(
color: Colors.black45,
fontSize: 15,
),
),
),
Container(
width: (ScreenUtil().screenWidth - 20) / 4,
child: IconText(
'${article.lookTimes}',
icon: Icon(Icons.remove_red_eye),
iconSize: 18,
style: TextStyle(
color: Colors.black45,
fontSize: 15,
),
),
),
Container(
width: (ScreenUtil().screenWidth - 20) / 3,
child: IconText(
'${article.keyword == null ? '无' : article.keyword.split(',')[0]}',
icon: Icon(Icons.label),
iconSize: 18,
style: TextStyle(
color: Colors.black45,
fontSize: 15,
),
),
),
]),
);
}
@override
Widget buildPageContent(BuildContext context) {
return Observer(
builder: (_) => SmartRefresher(
enablePullDown: true,
enablePullUp: true,
header: WaterDropHeader(),
footer: CustomFooter(
builder: (BuildContext context, LoadStatus mode) {
Widget body;
if (mode == LoadStatus.idle) {
body = Text("pull up load");
} else if (mode == LoadStatus.loading) {
body = CupertinoActivityIndicator();
} else if (mode == LoadStatus.failed) {
body = Text("Load Failed!Click retry!");
} else if (mode == LoadStatus.canLoading) {
body = Text("release to load more");
} else {
body = Text("No more Data");
}
return Container(
height: 55.0,
child: Center(child: body),
);
},
),
controller: this.pageStore.refreshController,
onRefresh: () => this.pageStore.refresh(this),
onLoading: () => this.pageStore.loadMore(this),
child: ListView.separated(
physics: ClampingScrollPhysics(),
padding: EdgeInsets.all(8),
itemCount: this.pageStore.articles.length,
separatorBuilder: (_, i) => Divider(),
itemBuilder: (_, i) {
return _renderRow(_, i);
}),
),
);
}
}
/// 文章列表数据加载
part 'homeStore.g.dart';
class HomeStore = HomeStoreBase with _$HomeStore;
abstract class HomeStoreBase extends BaseStore with Store {
@observable
RefreshController refreshController;
@observable
ObservableList _articles = ObservableList();
ReactionDisposer disposer;
int pageNo = 1;
int pageSize = 40;
@override
void initState({BaseState statusHolder, Map pageParams}) {
super.initState(pageParams: pageParams);
this.refreshController = RefreshController(initialRefresh: false);
this.disposer = autorun((reaction) {
LoggerAgent.i('articles: ${this.articles.length}');
});
}
void refresh(BaseState holder) {
_fetchArticles(true);
}
void loadMore(BaseState holder) {
_fetchArticles(false);
}
@override
void loadData() {
_fetchArticles(true);
}
void _fetchArticles(bool refresh) async {
if (refresh) {
this.pageNo = 1;
} else {
this.pageNo += 1;
}
try {
ArticleResp articleResp = await RetrofitClientAgent
.instance.doctorRestClient
.getArticles({"pageNo": this.pageNo, "pageSize": pageSize});
LoggerAgent.i('articleResp: ${articleResp.data.length}');
if (articleResp != null && articleResp.data != null) {
this._updateArticles(refresh, articleResp.data);
}
} catch (e) {
this.showPageError();
} finally {
if (refresh) {
this.refreshController.refreshCompleted();
} else {
this.refreshController.loadComplete();
}
}
}
@action
void _updateArticles(bool refresh, List data) {
if (refresh) {
_articles.clear();
if (data.length == 0) {
this.showPageEmpty();
return;
}
}
_articles.addAll(data);
this.showPageSuccess();
}
@override
void dispose() {
if (this.disposer != null) {
this.disposer();
}
}
@computed
List get articles => this._articles.toList();
}
文章详情
文章详情复用
webview_page
页面呈现。
而脚手架中webview_page
是一个通用的H5内容展示组件,使用到flutter_inappwebview插件(点击查看)
。除了基础的内容展示,webview_page
还有以下特性:
① 导航栏底部展示页面加载进度;
② 导航栏支持H5页面返回(若H5内容路由栈长度大于1)和直接关闭;
③ 导航栏标题跟随H5标题;
④ 支持H5与App侧通信;
遗留问题:页面手势不支持H5内容返回。
H5与App通信关键代码
需要H5侧配合
// App内代码
onWebViewCreated: (controller) {
webViewController = controller;
webViewController!.addJavaScriptHandler(
handlerName: 'YFHealthToaster',
callback: (args) => WebviewUtil.messageCallback(context, args));
}
// H5侧代码。使用flutter_inappwebview打开H5页面,会自动在window对象上挂载`flutter_inappwebview`,H5侧可使用`appMessagePoster`与App侧通信。代码如下:
// 判断在App环境中
const { flutter_inappwebview: appMessagePoster } = window;
if (appMessagePoster) {
appMessagePoster.callHandler('YFHealthToaster', content);
}
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:flutter_scaffold/application.dart';
import 'package:flutter_scaffold/config/image_set.dart';
import 'package:flutter_scaffold/page/common/base_page_widget.dart';
import 'package:flutter_scaffold/routes/routes.dart';
import 'package:flutter_scaffold/utils/logger_agent.dart';
import 'package:flutter_scaffold/utils/webview_util.dart';
import 'package:url_launcher/url_launcher.dart';
class WebViewPage extends BasePageWidget {
final Map _pageParams;
const WebViewPage({Key? key, required Map pageParams})
: _pageParams = pageParams,
super(key: key, pageName: '', pageParams: pageParams);
@override
_WebViewPageState createState() {
return _WebViewPageState();
}
}
class _WebViewPageState extends State {
final GlobalKey webViewKey = GlobalKey();
late String title;
late String url;
late PullToRefreshController pullToRefreshController;
InAppWebViewController? webViewController;
InAppWebViewGroupOptions options = InAppWebViewGroupOptions(
crossPlatform: InAppWebViewOptions(
useShouldOverrideUrlLoading: true,
mediaPlaybackRequiresUserGesture: false,
),
android: AndroidInAppWebViewOptions(
useHybridComposition: true,
),
ios: IOSInAppWebViewOptions(
allowsInlineMediaPlayback: true,
));
double progress = 0;
@override
void initState() {
super.initState();
title = widget._pageParams['title'] ?? 'title';
final location = Application.globalConfigStore.aMapLocation;
final currChannelCode = Application.globalConfigStore.currentChannelCode;
final customerId = Application.userStore.customerId;
final code = Application.userStore.customerInfo.code;
final name = Application.userStore.customerInfo.name;
final mobile = Application.userStore.customerInfo.mobile;
// url 拦截处理
url = WebviewUtil.transformUrl(
url: widget._pageParams['url'] ?? 'url',
latitude: location.isValid() ? location.latitude : null,
longitude: location.isValid() ? location.longitude : null,
channelCode: currChannelCode,
customerId: customerId,
code: code,
name: name,
mobile: mobile,
);
pullToRefreshController = PullToRefreshController(
options: PullToRefreshOptions(
enabled: false,
color: Colors.blue,
),
onRefresh: () async {
if (Platform.isAndroid) {
webViewController?.reload();
} else if (Platform.isIOS) {
webViewController?.loadUrl(
urlRequest: URLRequest(url: await webViewController?.getUrl()));
}
},
);
}
@override
void dispose() {
super.dispose();
webViewController?.stopLoading();
Routes.routePathStack.pop();
}
AppBar buildAppBar() {
return AppBar(
title: Text(title),
leading: Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: Image.asset(
ImageSet.icon_back_white,
width: 24,
height: 24,
),
onPressed: () async {
if (webViewController != null &&
await webViewController!.canGoBack()) {
webViewController!.goBack();
} else {
Navigator.pop(context);
}
},
),
IconButton(
icon: const Icon(Icons.close_sharp),
onPressed: () {
Navigator.pop(context);
},
),
],
),
leadingWidth: 100,
);
}
Widget buildContent() {
return SafeArea(
child: Column(
children: [
Expanded(
child: Stack(
children: [
InAppWebView(
key: webViewKey,
initialUrlRequest: URLRequest(url: Uri.parse(url)),
initialOptions: options,
pullToRefreshController: pullToRefreshController,
onWebViewCreated: (controller) {
webViewController = controller;
webViewController!.addJavaScriptHandler(
handlerName: 'YFHealthToaster',
callback: (args) =>
WebviewUtil.messageCallback(context, args));
},
onLoadStart: (controller, url) {
LoggerAgent.i('onLoadStart');
},
androidOnPermissionRequest:
(controller, origin, resources) async {
return PermissionRequestResponse(
resources: resources,
action: PermissionRequestResponseAction.GRANT);
},
shouldOverrideUrlLoading: (controller, navigationAction) async {
var uri = navigationAction.request.url!;
if (![
"http",
"https",
"file",
"chrome",
"data",
"javascript",
"about"
].contains(uri.scheme)) {
if (await canLaunch(url)) {
// Launch the App
await launch(
url,
);
// and cancel the request
return NavigationActionPolicy.CANCEL;
}
}
return NavigationActionPolicy.ALLOW;
},
onReceivedServerTrustAuthRequest:
(controller, challenge) async {
return ServerTrustAuthResponse(
action: ServerTrustAuthResponseAction.PROCEED);
},
// onReceivedClientCertRequest: (controller, challenge) {
// return ClientCertChallenge(protectionSpace: URLProtectionSpace());
// },
// onReceivedHttpAuthRequest
onLoadStop: (controller, url) async {
pullToRefreshController.endRefreshing();
LoggerAgent.i('onLoadStop -> url : $url');
},
onLoadError: (controller, url, code, message) {
pullToRefreshController.endRefreshing();
LoggerAgent.i('onLoadError: $message');
},
onProgressChanged: (controller, progress) {
if (progress == 100) {
pullToRefreshController.endRefreshing();
}
LoggerAgent.i('onProgressChanged: $progress');
setState(() {
this.progress = progress / 100;
});
},
onConsoleMessage: (controller, consoleMessage) {
LoggerAgent.i(consoleMessage);
},
onTitleChanged: (controller, html5Title) {
// webViewController?.getTitle();
setState(() {
title = html5Title ?? '';
});
},
),
progress < 1.0
? LinearProgressIndicator(value: progress, minHeight: 1)
: Container(),
],
),
),
],
));
}
@override
Widget build(BuildContext context) {
return WillPopScope(
child: Scaffold(
appBar: buildAppBar(),
body: buildContent(),
backgroundColor: Colors.white,
),
onWillPop: Platform.isIOS
? null
: () {
Future canGoBack = webViewController!.canGoBack();
canGoBack.then((value) {
if (value) {
webViewController!.goBack();
} else {
Navigator.of(context).pop();
}
});
return Future.value(false);
},
);
}
}
网络处理
在Flutter项目中,推荐使用retrofit插件。依赖dio,采用注解方式。具体的使用方式可在上文文章列表数据加载示例中查看。
XXX_service_rest_client.dart
import 'package:dio/dio.dart';
import 'package:retrofit/http.dart';
import '../models/articleResp.dart';
part 'doctorRestClient.g.dart';
@RestApi()
abstract class DoctorRestClient {
factory DoctorRestClient(Dio dio, {String baseUrl}) = _DoctorRestClient;
@POST("/openx/drug/articleService/whiteListArticleForEHP")
Future getArticles(@Body() Map map);
}
DioFactory
最开始的retrofit版本,在baseUrl不同的情况下,需要提供baseUrl对应的多个dio实例。最新的版本可以一个dio实体使用不同的baseUr。dioFactory主要用户初始化dio,设置网络代理,拦截器等。
网络代理设置通常只在测试环境下开启,配合测试人员抓包及数据验证工作。网络代理需要动态设置测试人员电脑IP及端口,通过读取用户粘贴板实现。具体操作如下:测试人员将自己电脑IP及端口在测试手机上复制到粘贴板,再打开测试App,即动态设置代理完毕。若需要移除掉代理配置,只需清除粘贴板再次打开测试App即可。
而在拦截器中,做了以下三件事情:
① 统一处理token失效场景;
② 统一处理异常并转化为友好提示;
③ 统计接口耗时并上报;
import 'dart:developer';
import 'dart:io';
import 'package:dio/adapter.dart';
import 'package:dio/dio.dart';
class DioFactory {
static const maxTimeCostLimit = 1; // 最大接口耗时阀值 1秒
static final Dio _globalDioInstance = Dio();
static Map timeCostRecord = {}; // 接口耗时记录
static Dio getGlobalDioInstance() {
setupDioOption(_globalDioInstance,
baseUrl: null, connectionTimeout: 15000, receiveTimeout: 10000);
return _globalDioInstance;
}
static setupDioOption(Dio dio,
{String? baseUrl,
int connectionTimeout = 15000,
int receiveTimeout = 10000}) {
if (baseUrl != null) {
dio.options.baseUrl = baseUrl;
}
dio.options.connectTimeout = connectionTimeout;
dio.options.receiveTimeout = receiveTimeout;
var defaultHttpClientAdapter =
dio.httpClientAdapter as DefaultHttpClientAdapter;
defaultHttpClientAdapter.onHttpClientCreate = (client) {
client.badCertificateCallback =
(X509Certificate cert, String host, int port) {
return ApiConfig.envType == EnvType.test;
};
// 测试环境支持代理
if (ApiConfig.envType == EnvType.test) {
if (!ConditionUtils.isEmptyOrNull(Application.clipboardData) &&
RegUtil.checkIpPort(Application.clipboardData)) {
client.findProxy = (uri) {
return 'PROXY ${Application.clipboardData};';
};
}
} else {
// 生产环境
}
};
var basicHeaders = {
'Connection': 'Keep-Alive',
'deviceId': Application.deviceIdentifier, // 设备标记
'User-Agent': Platform.isAndroid ? 'android' : 'iOS',
'version': Platform.isAndroid
? AppInfo.versionCodeAndroid
: AppInfo.versionCodeiOS,
};
// 增加拦截器
dio.interceptors.add(InterceptorsWrapper(
onRequest: (RequestOptions options, RequestInterceptorHandler handler) {
options.headers.addAll(basicHeaders);
// 动态添加token
if (!ConditionUtils.isEmptyOrNull(Application.userStore.token)) {
options.headers.addAll({
'Token': Application.userStore.token,
});
}
recordRequestStart(options);
handler.next(options);
},
onResponse: (Response response, ResponseInterceptorHandler handler) {
printApiResponse(response);
if (response.data == 'null') {
// print('response data is: ${response.data}');
response.data = null;
handler.next(response);
return;
}
// 处理 [null] -> []
if (isArrNullRes(response)) {
response.data['data'] = [];
handler.next(response);
return;
}
handler.resolve(response);
},
onError: handlerApiError,
));
}
/// 记录接口请求开始
static void recordRequestStart(RequestOptions options) {
var requestUri =
options.uri.toString() + formateRequestData(options.data).toString();
timeCostRecord.addAll({requestUri: Timeline.now});
}
/// 记录接口响应
static double calculAPITimeCost(Response response) {
var requestUri = response.requestOptions.uri.toString() +
formateRequestData(response.requestOptions.data).toString();
var timeNow = Timeline.now;
var timeRecord = timeCostRecord[requestUri] ?? timeNow;
var timeCost = (timeNow - timeRecord) / 1000000.0;
// timeCostRecord.remove(requestUri);
// 若响应时间超过1秒,通过友盟记录
if (timeCost > maxTimeCostLimit) {
var requestUrl =
'${response.requestOptions.baseUrl}${response.requestOptions.path}';
UMengUtils.reportAPITimeEvent(
{'requestUrl': requestUrl, 'timeCostValue': 'timeCost: $timeCost'});
}
return timeCost;
}
// 是否返回结果为 [null]
static bool isArrNullRes(Response response) {
try {
final data = response.data?['data'];
if (data is! List) {
return false;
}
return data.length == 1 && data[0] == null;
} catch (e) {
return false;
}
}
/// 处理接口异常
static void handlerApiError(DioError e, ErrorInterceptorHandler handler) {
var response = e.response;
print('handlerApiError: $e');
if (response != null) {
printApiResponse(response);
}
UMengUtils.reportAPIError(e);
try {
ApiErrorHandler.getMessage(e);
} catch (e) {
print('handlerApiError: $e');
} finally {
handler.reject(e);
}
}
static dynamic formateRequestData(dynamic requestData) {
if (requestData is FormData) {
FormData fd = requestData;
return {
'fields': fd.fields,
'files': fd.files,
};
} else {
return requestData;
}
}
static void printApiResponse(Response response) async {
var apiTimeCost = calculAPITimeCost(response);
if (!apiLogEnabled) {
return;
}
LoggerAgent.d(
'API response info: \nurl:${response.requestOptions.baseUrl}${response.requestOptions.path}\nstatus:${response.statusCode}\nmethod:${response.requestOptions.method}\ntimeCost: $apiTimeCost');
LoggerAgent.d(response.requestOptions.headers, '接口请求头');
LoggerAgent.d(
formateRequestData(response.requestOptions.data), 'FormData或JSON入参');
LoggerAgent.d(response.requestOptions.queryParameters, 'Query参数');
if (apiLogPretty) {
LoggerAgent.d(response.data, '接口响应数据');
} else {
LoggerAgent.d('接口响应数据: ${response.data}');
}
}
}
JSON数据序列化和反序列化
目前脚手架仅支持JSON格式接口响应数据。结合 json_annotation ,retrofit 可将接口响应自动转换为对应类型的实例。
import 'package:json_annotation/json_annotation.dart';
import 'article.dart';
part 'articleResp.g.dart';
@JsonSerializable()
class ArticleResp {
int totalCount;
List data;
ArticleResp(this.totalCount, this.data);
factory ArticleResp.fromJson(Map json) =>
_$ArticleRespFromJson(json);
Map toJson() => _$ArticleRespToJson(this);
}
import 'package:json_annotation/json_annotation.dart';
part 'article.g.dart';
@JsonSerializable()
class Article {
String id;
String title;
String articleType;
String author;
int collectTimes;
int commentTimes;
String content;
String contentType;
bool copyright;
String fileUrl;
String imageUrl;
String videoUrl;
String keyword;
int lookTimes;
String outline;
String pharmacistId;
bool recommended;
int releaseTime;
int time;
String viewTime;
Article(
this.id,
this.title,
this.articleType,
this.author,
this.collectTimes,
this.commentTimes,
this.content,
this.contentType,
this.copyright,
this.fileUrl,
this.imageUrl,
this.videoUrl,
this.keyword,
this.lookTimes,
this.outline,
this.pharmacistId,
this.recommended,
this.releaseTime,
this.time,
this.viewTime);
factory Article.fromJson(Map json) =>
_$ArticleFromJson(json);
Map toJson() => _$ArticleToJson(this);
}
编译运行
项目中mobx、json序列化部分代码需要编译之后才能正常运行。查看
build_runner
了解细节。
# 获取依赖
flutter pub get # 项目在新创建时一般会自动运行该脚本
# 编译
flutter pub run build_runner watch --delete-conflicting-outputs # 其中,watch表示监听资源文件变动,--delete-conflicting-outputs表示输出冲突时覆盖
# 运行
flutter run -v # Run your Flutter app on an attached device. v表示在控制台打印细节,若有多个可用设备连接了电脑,则会出现选“择执行设备”的交互
结语
脚手架项目还存在不完善需要改善的地方,如主题动态设置、内存管理等,在改造升级之后,会开源到github,欢迎大家批评指正。敬请期待,谢谢。