美文网首页flutter
Flutter实战总结之脚手架篇

Flutter实战总结之脚手架篇

作者: 大胆哥丹尼 | 来源:发表于2022-02-27 21:30 被阅读0次

    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 extends BasePageWidget> = T Function(
        BuildContext? context, Map<String, dynamic> params);
    
    /// 构建路由页面参数
    Map<String, dynamic> buildPageParams(BuildContext? context) {
      return (context?.settings?.arguments ?? {'empty': 'noParams'})
          as Map<String, dynamic>;
    }
    
    /// 生成路由页面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<String> 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<dynamic> 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<String, dynamic>? 页面参数
      static dynamic showPageByPath(BuildContext context, String path,
          {bool replace = false,
          bool clearStack = false,
          Map<String, dynamic>? 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<dynamic> showQRPage(
        BuildContext context, {
        bool clearStack = false,
        bool replace = false,
        Map<String, dynamic>? pageParams,
      }) =>
          showPageByPath(context, qr,
              clearStack: clearStack, replace: replace, pageParams: pageParams);
    
      /// 显示h5内容
      static Future<dynamic> 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<String, dynamic>? pageParams;
    
      const BasePageWidget({Key? key, required this.pageName, this.pageParams})
          : super(key: key);
    }
    
    
    import 'dart:io';
    import 'package:flutter/material.dart';
    
    const List<Color> _kDefaultRainbowColors = [
      Color(0xff8CEEC7),
      Color(0xff01C591),
    ];
    
    abstract class BaseState<S extends BasePageWidget, T extends BaseStore>
        extends State<S> 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<bool> 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<String, dynamic>? pageParams;
      ReactionDisposer? disposer;
    
      void initState({Map<String, dynamic>? 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<HomePage, HomeStore> {
      @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<Article> _articles = ObservableList<Article>();
      ReactionDisposer disposer;
      int pageNo = 1;
      int pageSize = 40;
    
      @override
      void initState({BaseState statusHolder, Map<String, dynamic> 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<Article> 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<Article> 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<String, dynamic> _pageParams;
      const WebViewPage({Key? key, required Map<String, dynamic> pageParams})
          : _pageParams = pageParams,
            super(key: key, pageName: '', pageParams: pageParams);
    
      @override
      _WebViewPageState createState() {
        return _WebViewPageState();
      }
    }
    
    class _WebViewPageState extends State<WebViewPage> {
      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<bool> 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<ArticleResp> getArticles(@Body() Map<String, dynamic> 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<String, int> 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<Article> data;
    
      ArticleResp(this.totalCount, this.data);
    
      factory ArticleResp.fromJson(Map<String, dynamic> json) =>
          _$ArticleRespFromJson(json);
    
      Map<String, dynamic> 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<String, dynamic> json) =>
          _$ArticleFromJson(json);
    
      Map<String, dynamic> 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实战总结之脚手架篇

        本文链接:https://www.haomeiwen.com/subject/tqwhrrtx.html