Flutter实战总结之脚手架篇

Flutter实战总结之脚手架篇

使用Flutter开发项目快两年时间了,成功支撑了两个App项目上线。经历过Flutter1.1.0版本到2.10.0版本的更迭,期间遇到了很多困难,踩了很多坑,但最终成功将Flutter技术栈应用到前端App跨平台开发中来。为了聚焦业务、快速进行功能迭代,我尝试搭建Flutter脚手架项目(适用移动端),旨在简化通用模板代码、封装冗杂的细节处理,提供灵活的页面状态管理且高度可定制。

Flutter环境搭建

Flutter环境搭建在官网有详细的说明,请查阅文档。此处简单说明:

  1. 移动端:
    ①:iOS,需要一台苹果电脑,安装xcode,vscode
    ②: Android,需要配置Java环境,安装Android Studio,vscode
    通常情况下,环境搭建完毕后,使用flutter doctor -v 命令检测一下环境集成结果。
flutter doctor -v
image.png

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中,配置了EasyLoadingRefreshConfigurationScreenUtilInit

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 构建的用户界面就是应用的当前状态。开发者将主要精力聚焦在对数据的管理上即可。

flutter 状态管理

base_state.dart

baseState.dart 位于 /lib/pages目录下,是动态页面的一个基类。封装了页面参数初始化、页面数据自动加载及页面UI根据状态自动渲染。
新增业务时只需关注UI渲染部分即可。BaseState接收两个泛型参数,泛型参数S为 BasePageWidget 的子类,一般为业务页面,T为页面状态管理类,是 BaseStore 的子类。页面上的数据加载、状态管理均由 BaseStore 的子类实现控制。在BaseState中,页面store的初始化、入参注入及页面数据加载,均在initState 生命周期中实现。
注意onPausedonResume方法,结合WidgetsBindingObserverRouteAware实现对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,欢迎大家批评指正。敬请期待,谢谢。

你可能感兴趣的:(Flutter实战总结之脚手架篇)