Flutter工程体系化建设与实践:flutter-zycli-app脚手架(一)

avatar
@智云健康

Flutter

本文作者:cherry689,未经授权禁止转载。

前言

本期内容分为3个部分:

  • 背景
  • 工程提效实践
  • 总结

一、背景

2019 年无疑是 Flutter 技术如火如荼发展的一年。采用 Flutter 使业务在需求节奏不变的情况下人力投入减少一半,对缓解业务研发压力起到了明显的作用;应用的整体性能和稳定性也与 Native 基本持平;同时其优秀的跨多端多平台能力,使得 Flutter 技术已经成为越来越多行业伙伴重点投入的技术建设方向 。flutter-zycli-app脚手架从工程体系的角度,提供一套标准化的 API 能力,以规范并抽象移动端的端基础能力,使业务尽量少甚至不关心平台差异性,专注于业务;同时借助标准化 API 的能力,实现跨多端多平台部署,使技术真正赋能公司业务的快速发展诉求。

附上横向对比行业开源方案:

横向对比行业开源方案

二、工程提效实践

解决的痛点

  • 多个重点项目的百花齐放,同个功能技术的多次实现
  • 标准不统一,后期维护成本高,研发人员陷入固定项目
  • 需要制定统一项目开发标准,提供基础能力,提高开发效率

我理解的“脚手架”

  • 能够快速帮我生成新项目的目录模板
  • 能够提升我的开发效率和开发的舒适性

内置集成功能

  • 移动端通用UI组件库
  • 移动端基础库
  • 路由
  • 国际化
  • 主题切换
  • 事件总线
  • 存储管理
  • 状态管理
  • 网络
  • 用户中心
  • 配置中心
  • 屏幕适配
  • 广告页面
  • 引导页面

目录结构

android/ 		# 安卓工程
ios/     		# ios工程
lib/
  |- components/ 	# 共用widget组件封装
  |- config/ 		# 全局的配置参数
  |- constants/ 	# 常量文件夹
  |- event_bus/ 	# 事件总线
  |- provider/ 		# 全局状态管理
  |- pages/ 		# 页面ui层,每个独立完整的页面,每个页面可独立放自己的provider状态管理
      |- AppHomePage/ 	# APP主体页面
      |- SplashPage/ 	# APP闪屏页
  |- service/ 		# 请求接口抽离层
  |- routes/ 		# 定义路由相关文件夹
  |- utils/ 		# 公共方法抽离
    |- dio/ 		# dio底层请求封装
  |- main.dart 		# 入口文件
pubspec.yaml 		# 配置文件

移动端通用UI组件库

Flutter 具有强大的 UI 表现力,可以帮助开发者快速高效低成本的开发出极为炫酷的 UI ,帮助业务构建出富有表现力的页面。公司想要通过产品快速占领市场,同时有更多的辨识度、风格,我们需要有一套自己的移动端通用的UI标准,实现了UI标准化,统一三端,提升交付效率。

支持的所有组件

序号组件名描述截图
1Buttons按钮可以显示文本、图像。扁平按钮和浮动按钮是最常用的两种按钮类型。Buttons
2Badges消息红点。IMG_5989.PNG
3Appbar一个Material Design应用程序栏,由工具栏和其他可能的widget(如TabBar和FlexibleSpaceBar)组成。IMG_5990.PNG
4BottomNavigationBar底部导航标签 选项卡可以方便地在不同视图间浏览和切换。IMG_5991.PNG
5TabBar顶部标签栏。IMG_5992.PNG
6Pickers底部滚轮选择器。IMG_5993.PNG
7Dialogs对话框用于提示用户做一些决定,或者提供完成某个任务时需要的一些其他额外信息。IMG_5994.PNG
8Toast主要用于消息提示。IMG_5995.PNG
9SwitchiOS风格的开关. 用于单一状态的开/关。IMG_5996.PNG
10PopupMenu底部弹窗。IMG_5997.PNG
11SearchBar搜索栏。IMG_5998.PNG
12ListTile表单展示。IMG_5999.PNG
13Notification通知栏。IMG_6001.PNG
14StateWidget缺省页示例。IMG_6002.PNG
15SelectListTile配合Picker等信息交互的单元格。IMG_6003.PNG
16InputListTitle表单输入。IMG_6004.PNG
17Refresh下拉刷新、上拉加载。IMG_6005.PNG
18ShareWidget分享面板。IMG_6006.PNG
19ActionSheet底部弹窗,固定行数,不可滑动。IMG_6007.PNG

移动端基础库

我们会将基础的能力下沉到zy_base仓库,以供所有的项目依赖使用。

比如:

  • 事件总线
  • 状态管理
  • 路由
  • 屏幕适配
  • 主题切换
  • 工具包
  • 公共页面
  • ...

zy_base.png

路由

路由简单的来说就是一个中转站,通过URL映射到相应的类,然后就能进行跳转并携带页面所需要的参数,路由承担的功能又不仅仅是做页面跳转,更重要的是可以解耦页面跳转的文件引入和跳转逻辑代码。

建议将App上的多个一级页面放到一个zy-main-page.dart类中,统一配置多个一级页面,使main.dart中的代码更加清晰明了。

如下:

import 'package:flutter/material.dart';
import 'package:flutter_zycli_app/constants/zy_r.dart';
import 'package:flutter_zycli_app/pages/home/zy_home_page.dart';
import 'package:flutter_zycli_app/pages/mine/zy_mine_page.dart';
import 'package:flutter_zycli_app/pages/social/zy_social_page.dart';

class ZyMainPage extends StatefulWidget {
  @override
  _ZyMainPageState createState() => _ZyMainPageState();
}

class _ZyMainPageState extends State<ZyMainPage> {
  var _pageController = PageController();

  /// 选中页面的索引值,默认为0
  int _selectedIndex = 0;

  /// 记录最后一次点击返回键的时间
  DateTime _lastPressed;

  /// 页面数组
  final List<Widget> pages = <Widget>[
    /// 首页
    ZyHomePage(),

    /// 社区
    ZySocialPage(),

    /// 我的
    ZyMinePage(),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: WillPopScope(
        /// 对点击pop时进行细节处理,快速点击则只相应一次返回
        onWillPop: () async {
          if (_lastPressed == null ||
              DateTime.now().difference(_lastPressed) > Duration(seconds: 1)) {
            /// 两次点击间隔超过1秒则重新计时
            _lastPressed = DateTime.now();
            return false;
          }
          return true;
        },
        child: PageView.builder(
          itemBuilder: (ctx, index) => pages[index],
          itemCount: pages.length,
          controller: _pageController,
          physics: NeverScrollableScrollPhysics(),
          onPageChanged: (index) {
            setState(() {
              _selectedIndex = index;
            });
          },
        ),
      ),
      bottomNavigationBar: BottomNavigationBar(
        type: BottomNavigationBarType.fixed,
        items: _buildBottomBar(context),
        currentIndex: _selectedIndex,
        onTap: (index) {
          debugPrint('ZyMainPage BottomNavigationBar selected index:$index');
          if (_selectedIndex == 0 && _selectedIndex == index) {
            _pageController?.notifyListeners();
          }
          _pageController.jumpToPage(index);
        },
      ),
    );
  }

  List<BottomNavigationBarItem> _buildBottomBar(BuildContext context) {
    return <BottomNavigationBarItem>[
      BottomNavigationBarItem(
        icon: Image.asset(R.TAB_HOME),
        activeIcon: Image.asset(R.TAB_HOME_HIGHLIGHTED),
        label: '首页',
      ),
      BottomNavigationBarItem(
        icon: Image.asset(R.TAB_SOCIAL),
        activeIcon: Image.asset(R.TAB_SOCIAL_HIGHLIGHTED),
        label: '社区',
      ),
      BottomNavigationBarItem(
        icon: Image.asset(R.TAB_MY),
        activeIcon: Image.asset(R.TAB_MY_HIGHLIGHTED),
        label: '我的',
      ),
    ];
  }
}

页面路由配置

class RoutePath {
  /// 配置主页-包含(首页/社区/我的),用于动态切换主页
  static const zyMainPage = '/zyMainPage';

  /// 详情
  static const zyHomeDetailPage = '/zyHomeDetailPage';
}

main.dart中配置路由

void main() {
  /// 普通页面路由注册
  ZyAppRouter.register();

  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',

      /// 初始化路由页面,是一个单独的页面,好处是可以动态切换主页
      initialRoute: RoutePath.zyMainPage,

      /// 路由钩子
      onGenerateRoute: ZyRoute.generator(),

      home: Container(
        color: Colors.white,
      ),
    );
  }
}

通过路由传参数及回调参数

////////////////////页面跳转并带参数///////////////////////
final result = await ZyRoute.pushNamed(
  context,
  RoutePath.zyHomeDetailPage,
  arguments: {
    'content': '兄弟,你好啊',
  },
);
String resultStr = result as String;
if (resultStr != null && resultStr.length > 0) {
  setState(() {
    receiveResult = resultStr;
  });
}

////////////////////页面回调参数//////////////////////////
ZyRoute.pop(context, '你也好啊!!!');

国际化

在开发一个App时,如果需要支持多种语言,比如:中文、英文、繁体等,那么我们就需要支持国际化。

生成arb文件

现在我们可以通intl_translation包的工具来提取代码中的字符串到一个arb文件,运行如下命名:

flutter pub pub run intl_translation:extract_to_arb --output-dir=l10n-arb lib/l10n/localization_intl.dart

生成dart代码

根据arb生成dart文件:

flutter pub pub run intl_translation:generate_from_arb --output-dir=lib/l10n --no-use-deferred-loading lib/l10n/localization_intl.dart l10n-arb/intl_*.arb

我们可以将最后两步放在一个shell脚本里,当我们完成第三步或完成arb文件翻译后只需要分别执行该脚本即可。我们在根目录下创建一个intl.sh的脚本

flutter pub pub run intl_translation:extract_to_arb --output-dir=l10n-arb lib/l10n/localization_intl.dart
flutter pub pub run intl_translation:generate_from_arb --output-dir=lib/l10n --no-use-deferred-loading lib/l10n/localization_intl.dart l10n-arb/intl_*.arb

授予执行权限

chmod +x intl.sh

执行intl.sh

./intl.sh

参考资料:

主题切换

为了让你的 App 更美观,主题切换已经是一个必不可少的功能了,但如果想在传统的 Android 和 iOS 上分别适配不同的主题相当繁琐。但这一切,在 Flutter 中都非常容易实现。今天我们就来看看,如何在 Flutter 中给你的 App 添加换肤功能。

我们将使用到provider和SharedPreferencesUtils来完成主题切换功能。

  1. 使用 Provider 进行全局状态管理
class ThemeViewModel extends ChangeNotifier {
  static const xThemeColorIndex = 'xThemeColorIndex';
  static const xThemeUserDarkMode = 'xThemeUserDarkMode';
  static const xFontIndex = 'xFontIndex';

  static const fontValueList = ['system', 'kuaile'];

  /// 用户选择的明暗模式
  bool _userDarkMode;

  /// 当前主题颜色
  MaterialColor _themeColor;

  /// 当前字体索引
  int _fontIndex;

  ThemeViewModel() {
    /// 用户选择的明暗模式
    _userDarkMode = SharedPreferencesUtils.getBool(xThemeUserDarkMode) ?? false;

    /// 获取主题色
    _themeColor = Colors.primaries[
    SharedPreferencesUtils.getInt(xThemeColorIndex) ?? 5];

    /// 获取字体
    _fontIndex = SharedPreferencesUtils.getInt(xFontIndex) ?? 0;
  }

  int get fontIndex => _fontIndex;

  /// 切换指定色彩
  void switchTheme({bool userDarkMode, MaterialColor color}) {
    _userDarkMode = userDarkMode ?? _userDarkMode;
    _themeColor = color ?? _themeColor;
    notifyListeners();
    saveTheme2Storage(_userDarkMode, _themeColor);
  }

  /// 切换字体
  switchFont(int index) {
    _fontIndex = index;
    switchTheme();
    saveFontIndex(_fontIndex);
  }

  ThemeData themeData({bool platformDarkMode: false}) {
    var isDark = false/*platformDarkMode || _userDarkMode*/;
    Brightness brightness = Brightness.light/*isDark ? Brightness.dark : Brightness.light*/;

    var themeColor = _themeColor;
    var accentColor = isDark ? themeColor[700] : _themeColor;
    var themeData = ThemeData(
        brightness: brightness,
        primaryColorBrightness: Brightness.dark,
        accentColorBrightness: Brightness.dark,
        primarySwatch: themeColor,
        accentColor: accentColor,
        fontFamily: fontValueList[fontIndex]);

    themeData = themeData.copyWith(
      brightness: brightness,
      accentColor: accentColor,
      cupertinoOverrideTheme: CupertinoThemeData(
        primaryColor: themeColor,
        brightness: brightness,
      ),

      appBarTheme: themeData.appBarTheme.copyWith(color: Colors.blue, elevation: 0),
      splashColor: themeColor.withAlpha(50),
      hintColor: themeData.hintColor.withAlpha(90),
      errorColor: Colors.red,
      cursorColor: accentColor,
      textTheme: themeData.textTheme.copyWith(
        /// 解决中文hint不居中的问题 https://github.com/flutter/flutter/issues/40248
          subhead: themeData.textTheme.subhead
              .copyWith(textBaseline: TextBaseline.alphabetic)),
      textSelectionColor: accentColor.withAlpha(60),
      textSelectionHandleColor: accentColor.withAlpha(60),
      toggleableActiveColor: accentColor,
      chipTheme: themeData.chipTheme.copyWith(
        pressElevation: 0,
        padding: EdgeInsets.symmetric(horizontal: 10),
        labelStyle: themeData.textTheme.caption,
        backgroundColor: themeData.chipTheme.backgroundColor.withOpacity(0.1),
      ),
//          textTheme: CupertinoTextThemeData(brightness: Brightness.light)
      inputDecorationTheme: ThemeHelper.inputDecorationTheme(themeData),
    );
    return themeData;
  }

  /// 数据持久化到shared preferences
  saveTheme2Storage(bool userDarkMode, MaterialColor themeColor) async{
    var index = Colors.primaries.indexOf(themeColor);
    await Future.wait([
      SharedPreferencesUtils.setBool(userDarkMode, xThemeUserDarkMode),
      SharedPreferencesUtils.setInt(index, xThemeColorIndex)
    ]);
  }

  static String fontName(index, context) {
    switch(index) {
      case 0:
        return '0';
      case 1:
        return '1';
      default:
        return '';
    }
  }

  /// 字体选择持久化
  saveFontIndex(int index) async{
    await SharedPreferencesUtils.setInt(index, xFontIndex);
  }
}
  1. 因为是全局的状态管理,接下来我们需要在main.dart文件中配置一下刚才创建的 provider,有多个状态管理就使用 MultiProvider,单个的使用 Provider.value 就行了。(考虑到未来项目的扩展,这里我就直接使用 MultiProvider)了
class _MyAppState extends State<MyApp> {
  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider<ThemeViewModel>(
          create: (context) => ThemeViewModel(),
        )
      ],
      child: Consumer<ThemeViewModel>(
        builder: (_, themeViewModel, __) {
          ThemeData themeData = themeViewModel?.themeData();
          return MaterialApp(
            theme: themeData,
            builder: BotToastInit(),
            navigatorObservers: [BotToastNavigatorObserver()],
            darkTheme: themeViewModel.themeData(platformDarkMode: true),
            onGenerateRoute: BaseRouter.generateRoute,
            initialRoute: AppRouteName.mainPage,
          );
        },
      ),
    );
  }
}
  1. 切换主题颜色
var model = Provider.of<ThemeViewModel>(context,listen: false);
// var brightness = Theme.of(context).brightness;
model.switchTheme(color: color);

参考资料:

Flutter主题切换——让你的APP也能一键换肤

三、总结

虽然公司小伙伴在 Flutter 领域已经有几年的实战经验了,但 Flutter 体系化建设才刚刚起步,仍然有大量工作需要去做,我们正朝着把 Flutter 打造为统一移动应用基础研发框架的方向迈进,通过对已有的组件库进行梳理,技术整合,团队讨论,确定一套标准,熟练使用,使业务同学更关注业务的实现,从而提高了开发效率。

下期分享内容

  • 事件总线
  • 存储管理
  • 状态管理
  • 网络

参考文献

作者简介

佐助,Flutter工程师,来自智云健康移动端基建组团队

结尾

感谢你的阅读,日前智云健康大前端团队正在参加掘金人气团队评选活动。如果你觉得还不错的话,那就来 给我们投几票 吧!

今日总共可以投15票,网页5票,App5票,分享5票。感谢支持,2021我们还会创作更多的技术好文~~~

你的支持是是我们最大的动力~