【Flutter】组件化除了使用melos还有其他方案吗?像Android一样自行实现组件化

1,909 阅读17分钟

让 Flutter 像 Android 一样的的组件化

前言

组件化一直是移动端比较流行的开发方式,有着编译运行快,业务逻辑分明,任务划分清晰等优点。虽然 Flutter 可以在开发中实时预览页面不需要经常的重新运行,相对而言看起来收益没有Android组件化那么高。

与 Flutter 的热重载相比,Android 项目的编译时间较长,尤其是在项目规模较大时,这可能会影响开发效率,但是组件化的的确确在其他方面也是可以提高代码的可维护性、复用性和团队的协作效率。特别是独立运行单独组件,也是可以提升一些效率的。

我之前讲过 Android 组件化【传送门】。我们 Android 的组件化往往基于模块化(Module)来实现。每个模块可以是一个功能独立的组件,比如用户模块、支付模块等,这些模块在项目中以依赖库的形式存在。而通过各种方式让此模块能独立运行从而达到组件化的效果。

相对应的 Flutter 项目的组件化也是一样的道理,我们也能把各个功能独立的组件比如用户模块、支付模块等,这些模块在项目以单独项目的方式存在,通过本地 path 依赖或者 melos 的方案去组合起来,由于 Flutter 项目的特殊性,我们还能在单独模块项目中可以创建各自的 sample 模块去独立运行这个模块从而实现组件化。

本文我们就以 Android 组件化为模板,对于如何魔改出一套类似的 Flutter 的组件化项目,供大家参考。

一、组件化方案选型

在以往的 Android 组件化实现中,我们直接可以在 gradle 里进行自定义配置相关参数,执行 application 和 library ,以及其他的参数信息,来实现一个可运行模块依赖模块的动态转换,这是非常方便的。

但是,在 Flutter 的 yaml 配置文件中,除了使用特定的系统配置的之外,是不支持自定义的,也就是说,我们无法通过在构建里通过表达式或者变量判断等方式来实现组件化运行的切换。

这就在一定程度上阻碍了组件化的配置,虽然我们可以按照不同 AAR 的方式,拆分出独立的业务模块,形成模块化开发。但是这种为了模块化而模块化的方式在业务逻辑复杂的项目,很多的模块需要编译,无疑来说是更加耗时的,完全是负优化不如不用。

我们要探讨是在一个项目中如何用代码的方式来拆分组件的模块。

模块化:

Flutter_-_01.png

如何拆分模块我们从上图中看出,分为基础层,此层的作用,主要集成了常用的第三方插件,封装了基础能力,比如网络,数据操作,日志工具类,列表加载等等,目的是显而易见的,就是为了便于拓展和后续的复用,因为这些能力是统一的,是每个项目都或多或少都是需要的,前期封装好之后是不需要变动的,无论后续多少个项目,我们都可以直接拿来用。

公共服务类,我们是基于当前项目的服务类,比如 CongifService 或者业务相关的 UserService 等等,并且提供了全局的常量,API,路由,拦截器等等与当前项目相关的公共配置。

其次就是我们的各子模块了,比如我们的动态模块,新闻模块,商城模块等等。他们需要依赖公共服务模块和基础模块。

最终我们在我们的宿主 App 中依赖个动态模块就可以像搭建积木的方式组成完整的应用。

开发阶段我们就可以不同的小组开发不同的模块,在可维护性、复用性和团队的协作效率上还是很有优势的。

但是此时有一个问题,我们还是需要运行整个App才能展示到我们自己的小组开发的模块,如果其他小组并没有完成对应的功能,就会导致整个应用无法运行。吗,那么我们就需要实现真正的组件化方式,也就是让我们小组负责的模块可以单独运行。

组件化:

在上述的模块化中,我们知道组件化和模块化是类似的,只不过多了一个独立运行的功能,别小看这个独立运行的能力,在实际的开发中,能大大减少我们的编译时间,提高我们的开发效率,毕竟全部编译和局部编译,还是有着很大的差距。

并且在各小组同时开发不同模块的时候很方便,不受其他小组的进度影响。

Flutter_-_02.png

其中很大的一个问题是资源组件的定义,不使用资源组件行不行?行!但是很麻烦。因为 Flutter 的项目因素,其实它没有严格的区分父子关系,也就是说我们在 News 组件是可以访问到 app 组件中的类和资源的。

但是如果独立运行怎么办?此时没有 app 组件了,那么资源就会报错,如果我们在各自组件中各自实现自己需要的资源行不行?行!但是很麻烦。以图片资源为例我们需要指定加载哪一个组件的资源,除非我们把所有的资源每一个组件都复制一份那也没问题,但是这也太傻了,如果跨组件拿资源是可以拿到的,但是如果这个组件你单独运行的时候没有加载呢?也会报错。

比较推荐的做法就是单独分类出资源组件,把字体,数据,音频,图片等资源单独管理,可以用不同的文件夹来标识不同的组件,方便管理,这样在单独运行或整体运行的时候都没有问题。

同时我们可以在对应业务模块中可以创建对应的 flutter 项目可以单独运行这些单独的模块从而实现组件化。

以 Demo 中的文件格式为例,我演示一个简单的组件化项目。

image.png

app 组件只管理了全局的推送,Splash页面和首页。其他的各模块的页面由各个模块自己管理。

以用户模块为例,内部创建了 runalone 的单独 flutter 项目,用于单独运行用户模块。

image.png

其中 runalone 的 yaml 配置我们只需要依赖当前的组件即可。

name: auth_runalone
description: Auth模块独立运行

version: 1.0.0

environment:
  sdk: '>=3.0.2 <4.0.0'

dependencies:
  flutter:
    sdk: flutter

  cpt_auth:
    path: ../


flutter:
  uses-material-design: true

当然此时如果你了解过 melos 的方式的话,也可以使用的,我这里是用 path 的方式直接引用的,总体思路是一致的。

下面我们就一起看看如何一步一步的实现吧。

二、通用基类与项目服务类的抽离

其实通用基类库和公共服务库,他们不算是一个单独的组件,他们是无法单独运行的,他们都算是组件的服务库。

在通用基类库中我们需要在 yaml 文件中加入常用的第三方插件,例如 状态管理,依赖注入,路由管理,Dio , permission_handler , url_launcher ,软键盘处理,图片选择,图片裁剪,图片加载,图片压缩,WebView,device_info_plus,package_info_plus 等等。

这些每个项目都会用到的,我们都可以依赖进去,并且封装成对应的引擎,供给上层组件调用,并且封装之后我们后期想要替换相关的插件也更方便快捷。由于每个人使用的第三方插件并不相同,所以这里我没有给出具体的插件,按照你自己的使用习惯就好。

image.png

同时我们在通用基类中定义了页面的基类,也是我自己的使用习惯,如果你不需要也是可以去掉的。基本上你可以只用常规的 Widget 的一些扩展与封装,对应第三方插件的封装,常用的工具类之类的就行了。

一般我们的通用基类完善之后是不需要变动的,每个项目都能直接用,至于公共服务类,就是和相关关联的服务类,需要根据不同的项目来改动。比如在其中定义一些公共的服务类,国际化处理,多样式处理。项目的路由配置和路由表,项目需要用到的 Channel 通道,项目的通用实体等等。

它的 yeml 配置如下:

name: cs_service
description: 组件化服务类,给各组件提供公共服务,一般由各项目自行修改

version: 1.0.0

environment:
  sdk: '>=3.0.2 <4.0.0'

dependencies:

  flutter_localizations:
    sdk: flutter

  flutter:
    sdk: flutter

  cs_baselib:
    path: ../cs_baselib

  #手写签名 https://pub.dev/packages/hand_signature
  hand_signature: ^3.0.2

  #视频播放 https://pub.dev/packages/video_player
  chewie: 1.7.5

  ...

flutter:
  uses-material-design: true

它依赖于通用基类,但是加入了一些当前项目需要用到的插件依赖,当然你在各需要的模块单独去依赖也是可以的。

在我们定义好通用基类和项目服务类之后我们就可以分离对应的组件了。

三、各模块与组件的定义

组件化之前我们都是把所有的逻辑,封装,工具类,相关页面、控制器都在 app 模块中,我们是以不同的文件夹来区分的,现在我们要拆分组件我们只需要把对应的文件夹转移到单独的组件中。

同时,我们还需要创建对应的独立运行模块的 flutter 项目,大致的结构如图:

image.png

上面的 lib 是我们组件的真正页面与逻辑,而下面的 lib 则是 独立运行模块的入口与单独运行的入口页面,它只是在独立运行的时候才会生效,当我们运行宿主 app 整体运行的时候是不参与编译的。

那么我们只需要在 AS 中配置对应的入口即可:

image.png

如果你是 VSCode 进行的开发,那么也可以执行对应的命令启动对应的模块,在这方面 melos 更加专业。

那么这个 Mall 组件的 yaml 只需要依赖公共服务模块即可:

name: cpt_mall
description: MallComponent 商城组件

version: 1.0.0

environment:
  sdk: '>=3.0.2 <4.0.0'

dependencies:

  flutter_localizations:
    sdk: flutter

  flutter:
    sdk: flutter

  cs_service:
    path: ../cs_service


flutter:
  uses-material-design: true

独立运行模块的 yaml 也只需要依赖当前模块即可:

name: auth_runalone
description: Auth模块独立运行

version: 1.0.0

environment:
  sdk: '>=3.0.2 <4.0.0'


dependencies:
  flutter:
    sdk: flutter

  cpt_mall:
    path: ../


flutter:
  uses-material-design: true

当然如果你想多个子组件联调,也可以加入对应的依赖即可。

四、组件化中资源的处理

此时你运行独立模块你,会最直观的发现图片资源无法加载,但是当你运行宿主 app 模块的时候确是可以加载的。

这是因为我们的图片资源默认在根目录的 assets 文件夹下面,并且在根目录的 yaml 中配置了 assets ,那么它就是 app 模块里的资源,虽然在子组件中我们也能拿到 app 模块的 package 下面的包和图片路径,但是独立运行的时候我们并没有 app 模块所以是肯定拿不到 app 模块的资源,就算它就在你的 ../../ 指定的目录下,你也拿不到。

注: 在子组件能拿到宿主的类和资源?当然在Android项目中是肯定拿不到的,子组件不能拿到父组件的资源,但是 Flutter 比较特殊,可以这么拿,但是不推荐。当然如果你只是模块化,并不需要单独运行调试子功能模块那是可以的。

所以当我们子组件独立运行的时候想要拿到资源必要要下沉资源,这样就分两种思路,一种是在各自的模块中定义资源,一种是单独做为一个组件。

如果在各自的模块中定义资源就只能自己模块使用,如果有重复的资源就容易导致资源的冗余,并且资源分模块之后加载图片或资源的时候我们加载资源是需要指定 package 的,也会导致多 package 的资源管理混乱。

我个人比较推荐的方式是单独抽离一个资源组件,单独的 package 管理资源,内部使用文件夹的方式区分各组件的资源,也可以跨模块使用资源避免文件的冗余。

我们就可以封装资源的获取,例如 Image.asset()加载图片资源时指定 package 参数,在 Text 中指定字体的时候也可以指定 package 的参数,并且这个 package 参数是固定的资源组件。

flutter:
  fonts:
    - family: MyCustomFont
      fonts:
        - asset: fonts/MyCustomFont-Regular.ttf
        - asset: fonts/MyCustomFont-Italic.ttf
          style: italic


Text(
  'This is custom font text from a package',
  style: TextStyle(
    fontFamily: 'MyCustomFont',
    package: 'resources', // 指定固定的资源包名
  ),
)

那么不管是宿主运行还是各组件独立运行我们就可以不需要改动业务代码都是一套代码运行。并且宿主 app 和各组件独立运行模块也不需要重复定义 assets 的配置了。

image.png

只需要在资源组件定义 assets 的配置:

name: cs_resources
description: 整体项目的资源,图片,字体,数据等

version: 1.0.0

environment:
  sdk: '>=3.0.2 <4.0.0'

dependencies:

  flutter_localizations:
    sdk: flutter

  flutter:
    sdk: flutter

flutter:
  uses-material-design: true

  assets:
    - assets/
    - assets/base_lib/
    - assets/cpt_auth/
    - assets/cpt_profile/
    - assets/base_service/

有些人使用硬编码并用工具类封装的方式管理路径,有些使用第三方插件类似 Assets 的文件自动生成对应的路径,这些都是可以的,你看我的 Demo 项目就是基于插件自动生成的。

五、组件路由的定义

其实到处就已经结束了,已经可以用了,啊?那跨组件通信怎么办?比如用户组件中用户登录成功之后需要调用详情组件的API去刷新用户信息。怎么办呢?

其实在 Android 开发中我们是通过 ARouter 类似的路由方式来管理组件之间的通信的,毕竟 Android 开发中各子组件是完全割裂的,调用不到其他组件的方法,但是我们是 Flutter 项目啊,我可以跨组件依赖到其他组件的类,直接 import 对应的package 就能调用。

方便是方便,但是独立运行的时候就会有问题,如果我们的独立组件并没有依赖到对应的组件,你直接 import 一个不存在的类,就会报错。而一般我们做项目都是各个组件平行开发的,你也不知道对方有什么类,我们就可以使用组件路由的方式实现。

这里推荐一个比较简单的方式,使用依赖注入的方式实现,当我们需要使用路由的时候注入到依赖注入池,并且保持全局单例,我们在公共服务中定义接口类,然后在各子组件中实现,在不同的子组件我们取出对应的依赖注入就能调用对方组件的实现了。

当然依赖注入的框架有很多,各种状态管理的第三方包或多或少都有依赖注入的功能,包括 getit 这种专职依赖注入的库,这里我以 Getx 框架自带的依赖注入为示例。

我们在公共服务模块中定义对应的组件路由接口和获取组件路由的方式。

abstract class ProfileService {
  Future<bool> checkLocation();
}

全局获取组件路由:

class ComponentServices{

  static final ComponentServices _instance = ComponentServices._internal();

  factory ComponentServices() {
    return _instance;
  }

  ComponentServices._internal();

  static ProfileService get profileService => Get.find();

}

我们在对应的 Profile 组件中实现:

class ProfileServiceImpl extends GetxService implements ProfileService {
  @override
  Future<bool> checkLocation() async {
    // 实现检查用户地址的逻辑
    return true; // 假设返回true表示已完善地址
  }

  @override
  void onInit() {
    super.onInit();
    //初始化资源
    Log.d("ProfileServiceImpl 初始化资源");
  }

  @override
  void onClose() {
    super.onClose();
    //销毁资源
    Log.d("ProfileServiceImpl 销毁资源");
  }
}

还可以在注入的时候调用初始化方法,例如一些第三方插件可以很方便的懒加载实现。

还记得我们在app 宿主和 独立运行模块都留了依赖注入的口子吗?

class AppBinding extends Bindings {
  @override
  void dependencies() async {
    ...
    Get.lazyPut<ProfileService>(() => ProfileServiceImpl());
  }
}

如何使用?

    MyButton(
        onPressed: () async{
        var success =  await ComponentServices.profileService.checkLocation();
        SmartDialog.showToast("是否已经登录:$success");
        Log.d("是否已经登录:$success");
      },
        text: "路由获取",
    )

打印日志如下:

image.png

基本上和 Arouter 的 Service 很类似了。这是组件的路由,如果是页面的路由跳转还是需要靠原生的 Navigation 实现。

并且这只是以其中一种框架实现的,只是一种淹死,你可以自行用你当前使用的任何依赖注入框架实现同样的效果。

总结

根据本文一路走下来,我相信你知道了如何像 Android 一样的组件化拆分。

无非就是拆分基类和公共类,抽取组件,然后在组件中依赖对于的公共类,然后添加独立运行的模块,最后实现资源拆分和组件路由的定义。

只是本文的实例是基于本地 path 的依赖,很多项目的组件化是基于 melos 构建的,其实这都可以实现,主要是拆分的思想。

那我我们是不是一定就要组件化的方式来搭建项目呢?那也倒未必,其实我们在组件化的过程中按照复杂程度可以分为三类。

如果你是单人开发,不需要独立运行的组件化,那么只需要抽取出对应的基础组件和公共服务组件,其他的页面都写在 app 组件中即可,这样可以快速的用基础组件和公共服务组件移植到其他的项目中。

如果你是两三人的开发团队,不同的人负责不同的组件,但是也不需要独立运行,那么只需要在抽取基础组件的基础上使用模块化的方式开发,不同的人负责不同的模块,调试还是使用宿主 APP 来运行,得益于 Flutter 项目开发 UI 得到高效,我们也可以不用单独运行的组件,这样既保证了开发的效率又能最大限度的协同开发,保证一定的可用性和维护性。

如果你是五人以上的团队,项目的组件也比较多,还是比较推荐使用独立组件的运行的组件化开发方案,你可以使用 path 的方式进行管理,也可以用 melos 的方式进行管理,可以独立运行隔离其他组件的影响。

那本文的 Demo 是基于 AS Iguana 版本 ,Flutter 版本为 3.19.2 ,如果大家有兴趣可以自行尝试一下。不同的编辑器可能有不同的代码提示和包名提示,如果有什么问题欢迎大家来吐槽啊,一起学习交流进步嘛。

最重要的是源码奉上,请各位高工大佬指点 【传送门】

本文我愿称之为 Flutter 组件化前传,后期我有时间我会出 melos 的方式完善 Flutter 组件化的完全版。

理解本文之后再看后面的文章更容易理解哦,并且我们后期的大型项目会用 Flutter 组件化的方案进行重构,如果有什么问题我也会继续更新哦。

那么本期内容就到这里,本人其实开发Flutter项目的时间并不长,不算是资深Flutter开发者,文章难免有错误,思路难免会走弯,如果你有其他的更多的更好的实现方式,也希望大家能评论区指出和交流。

如有讲的不到位或错漏的地方我的文章有错别字,不通顺的,或者代码、注释、有错漏的地方,同学们都可以指出修正。

如果感觉本文对你有一点点的启发,还望你能点赞支持一下,你的支持是我最大的动力啦!

Ok,这一期就此完结。