找回密码
 会员注册
查看: 24|回复: 0

化繁为简:Flutter组件依赖可视化

[复制链接]

2万

主题

0

回帖

6万

积分

超级版主

积分
64077
发表于 2024-10-13 00:47:58 | 显示全部楼层 |阅读模式
化繁为简:Flutter组件依赖可视化 402 1 前言正在使用 Flutter 开发的你是否也有这样的困扰:组件繁多,依赖关系错综复杂,理不清头绪,看不清耦合。那么有没有一种工具或者方法让我们的依赖关系变得清晰明了,让人秒懂呢?我们给出答案就是:依赖关系可视化。那么我们如何才能得到一张结构清晰、效果酷炫的依赖关系图呢?跟随我的脚步,我们一起剖析如何实现 Flutter 的依赖可视化。2 行业技术调研当开始对 Flutter 工程做组件化拆分的时候,我们会自然而然地想到:各个业务模块之间的依赖关系是怎样的?如何能让依赖关系可视化?原生有没有这种通用的技术方案?答案是显而易见的。2.1 Android通过 Gradle 编写任务,遍历 Project,分析组件依赖情况,收集 Project 的 ProjectName 和 Dependencies,整理出依赖关系,输出 .dot 文件通过 Graphviz 图形可视化。具体参考 JakeWharton 的 projectDependencyGraph.gradle。2.2 iOS通过 CocoaPods 实现,解析项目的 Podfile,构建依赖关系图,进行依赖决策,生成 Podfile.lock 文件,整理出依赖关系,通过 Graphviz 图形可视化。具体参考 cocoapods-dependencies。2.3 Flutter通过资料检索发现做 Flutter 依赖可视化的方案很少,在调研过程中发现了一个宝藏库 gviz, 其原理和 Android、iOS 类似,也是通过 Graphviz 图形可视化。本文将基于 gviz 库深入源码进行剖析,一起来学习作者的思路吧。可以看出,基本上所有可视化方案都会使用一个叫做 Graphviz 的图形可视化工具。那么什么是 Graphviz 呢?它是用来干嘛的呢?我们接着往下看。3 什么是Graphviz?Graphviz(Graph Visualization Software)是一个开源的图形可视化软件,它能够从简单的文本文件描述中生成复杂的图形和网络。它使用一种名为 DOT 的描述语言来定义图形,使得用户可以专注于内容而非布局和设计。 Graphviz 的主要特点和用途包括: 1.灵活的渲染功能:Graphviz 可以生成多种格式的图形文件,包括 raster 和 vector 格式,如 PNG、PDF、SVG 等。 2.自动布局:Graphviz 的一个主要特点是其自动布局能力。用户只需定义图的元素和它们之间的关系,Graphviz 就能够自动计算出合适的布局。 3.扩展性:Graphviz 提供了多种工具和库,可以用于各种应用,如 Web 服务、生成报告,或与其他软件的集成。 4.广泛的应用:Graphviz 被广泛用于各种领域,包括软件工程(如代码依赖关系图)、网络设计和分析、生物信息学(如基因表达网络)等。更过关于 Graphviz 相关的内容可以查看 https://graphviz.org/。介绍到这里,大家对 Graphviz 已经有了基本的概念,也知道了可视化是要通过 Graphviz 来实现。那么接下来我们就从一个小 Demo 开始,跟着我来了解一个简单的 Flutter 依赖可视化小工具是如何实现的。4 从一个Demo开始先来看一个简单的 Demo。我们知道 Dart/Flutter 项目基于 pubspec.yaml 文件管理组件之间的依赖关系,比如组件 A 的依赖关系如下:name: moudule_aversion: 1.0.0environment:  sdk: '>=2.17.0 =2.17.0  commandArgs) {  final proc = _isFlutterPkg ? 'flutter' : 'dart';  final args = [    ...['pub'],    ...commandArgs  ];  final result = rocess.runSync(    proc,    args,    runInShell: true,    workingDirectory: rootPackageDir,  );  return result.stdout as String;}解析出来的结果如下所示:Dart SDK 2.17.0Flutter SDK 3.0.0module_a 1.0.0dependencies: path 1.8.3 module_b 0.0.1  meta 1.7.0transitive dependencies: meta 1.7.05.2 依赖格式转换有了前面的依赖关系清单,接下来主要做的就是要把工程的依赖关系转化为 Graphviz 可以直接使用的 DOT 描述语言。但是,细心的读者可能已经发现了问题。最后需要在一张图里面完全展示所有的依赖关系,但是现在两部分的依赖关系是分开存储的,并且数据结构还不太一样,gviz 作者也对这两部分的依赖关系做了一个合并。5.2.1 依赖格式统一要相对依赖做合并,就需要一个更大的数据结构来承载主工程和所有组件的依赖。这里作者引入一个新的自定义类 VizPackage。VizPackage 类来描述一个三方 SDK 信息,它包括名称、版本号及依赖其他 SDK 的集合。VizPackage 类图如下所示:用 Dependency 类来描述一个依赖关系,它包括名称、版本号。Dependency 类图如下所示:有了这样一个大的数据结构来承载所有的依赖信息,接下来只需要将两部分依赖信息分别转换成 VizPackage 就可以了。5.2.1.1 主工程依赖数据结构转换对 5.1.1 章节中的输出结果进行依赖解析,将依赖关系转换为 VizPackage:/// pubspec为5.1.1中获取主工程依赖final pubspec = rootPubspec();/// 主工程的依赖信息 转 VizPackageVizPackage rootPackage = VizPackage(  pubspec.name,  null,  Dependency.getDependencies(    pubspec,    includeDevDependencies: !productionDependenciesOnly,  ),  null,);/// ubspec 转 Dependencystatic Set getDependencies(  parse.Pubspec pubspec, {  bool includeDevDependencies = true,}) {  // 依赖关系结果集  final deps = {};  // 正式依赖:对应 pubspec.yaml 中的 dependencies  _populateFromSection(pubspec.dependencies, deps, false);  if (includeDevDependencies) {    // 开发依赖:对应 pubspec.yaml 中的 dev_dependencies    _populateFromSection(pubspec.devDependencies, deps, true);  }  return deps;}5.2.1.2 组件依赖数据结构转换对 5.1.2 章节中的输出结果进行依赖解析,将依赖关系转换为 VizPackage。由于直接获取到的组件依赖的数据结构是一个字符串类型,要先对字符串做解析,才能获取到其中的有用信息,所以这里需要再引入一个自定义数据结构 DepsList。主要包括 SDK 行匹配、包依赖匹配、空行匹配、依赖的 SDK 集合和 parse 转换方法。如下是 DepsList 的类图:其中 sections 是一个依赖关系集合,它以 Key-Value 的形式存储了所有组件的依赖信息。如下代码展示如何将一个字符串依赖关系转换成 sections 来进行管理:// 匹配一个包的名字的正则表达式const _identifierRegExp = r'[a-zA-Z_]\w*';// 匹配允许的软件包名称的正则表达式const _pkgName = '$_identifierRegExp(?:\\.$_identifierRegExp)*';/// Section头匹配正则,例如:dependencies:final _sectionHeaderLine = RegExp(r'([a-zA-Z ]+):\n');/// 一级包依赖匹配正则,例如:http 0.13.4final _usageLine = RegExp('- ($_pkgName) (.+)\n');/// 二级包依赖匹配正则,例如:async ^2.5.0final _depLine = RegExp('  - ($_pkgName) (.+)\n');/// scanner就是4.1.2中获取到的所有组件依赖关系的字符串MapEntry>>    _scanSection(StringScanner scanner) {  /// 开始匹配Section头  scanner.expect(_sectionHeaderLine, name: 'section header');  final header = scanner.lastMatch![1]!;  /// 依赖关系结果集,key: 一级包信息,value: 二级包依赖信息  final entries = >{};  void scanUsage() {    /// 开始匹配一级包依赖    scanner.expect(_usageLine, name: 'dependency');    final entry = VersionedEntry.fromMatch(scanner.lastMatch!);    assert(!entries.containsKey(entry.name));    final deps = entries[entry] = {};    /// 开始匹配二级包依赖    while (scanner.scan(_depLine)) {      deps[scanner.lastMatch![1]!] =          VersionConstraint.parse(scanner.lastMatch![2]!);    }  }  do {    scanUsage();  } while (scanner.matches(_usageLine));  return MapEntry(header, entries);}至此,组件间依赖的数据结构被转换成了 DepsList 类型。但是主工程的依赖是 VizPackage 类型。二者数据结构不同,仍然不能直接合并。接下来需要对就需要再将 DepsList 转换为 VizPackage 类型。由于 DepsList 中的 sections 存储了所有组件的依赖关系,下面展示一下如何将单个 section 转换为 VizPackage。全部转换只需遍历调用即可。/// 将DepsList中,sections中单个元素的格式转换为 VizPackageVizPackage addPkg(VersionedEntry key, Map value) {  final pkg = VizPackage(    key.name,    key.version,    SplayTreeSet.of(      value.entries          .where((element) => !_ignoredPackages.contains(element.key))          .map(            (entry) => Dependency(entry.key, entry.value.toString(), false),          ),    ),    flagOutdated ? _latest(key.name) : null,  );  return pkg;}5.2.2 依赖合并经过上面两个步骤,主工程的的依赖和组件间的依赖都已经被转换成了 VizPackage 类型,那么怎么对他们进行合并呢?作者创建了一个 Map,其中 key 是各个组件的名称,value(VizPackage) 是该组件的依赖关系,主工程也当做一个组件来处理。最终,所有的依赖关系都会被存储到这个 Map 中去。核心代码如下所示:/// 获取主工程依赖关系Pubspec rootPubspec() {  // ...参考 5.1.1 章节代码}/// 获取所有组件的依赖关系/// ...参考 5.1.2 章节代码DepsList rootDeps() {  final commandOutput = _pubCommand(['deps', '-s', 'list']);  return DepsList.parse(commandOutput);}/// 所有依赖关系合并Future> getReferencedPackages(  bool flagOutdated,  bool directDependenciesOnly,  bool productionDependenciesOnly,) async {  /// 1. 创建最后的依赖关系结果集  final map = SplayTreeMap();  /// 2.1 获取主工程依赖关系  final deps = rootPubspec();    /// 2.2 主工程的依赖关系转换  rootPackge = VizPackage(    pubspec.name,    null,    Dependency.getDependencies(      pubspec,      includeDevDependencies: !productionDependenciesOnly,    ),    null,  );    /// 2.3 主工程的依赖关系保存  map[rootPackge.name] = rootPackge;    /// 3.1 获取组件间依赖关系  final deps = rootDeps();    /// 3.2 组件间依赖关系转换并保存  addSectionValues(deps.sections['dependencies'] ?? const {})  /// 3.2.1 遍历组件间依赖,转换依赖关系  void addSectionValues(    Map> section,   ) {    for (var entry in section.entries) {      addPkg(entry.key, entry.value);    }  }    /// 3.2.2 将依赖关系转换为 VizPackage,并保存  void addPkg(VersionedEntry key, Map value) {    final pkg = VizPackage(      key.name,      key.version,      SplayTreeSet.of(        value.entries            .where((element) => !_ignoredPackages.contains(element.key))            .map(              (entry) => Dependency(entry.key, entry.value.toString(), false),            ),      ),      flagOutdated ? _latest(key.name) : null,    );    map[pkg.name] = pkg;  }   return map;}至此,两部分依赖关系被转换成为了同一个数据结构,并合并到了一起。接下来只需要将最终结果转换成 DOT 就可以愉快地拿去可视化了。5.2.3 将依赖树Map转换为DOT格式这里作者采用了 Graphviz 库来实现。依赖方式如下:dependencies:  gviz: ^0.4.0具体的转换逻辑封装到了toDot方法中:import 'package:gviz/gviz.dart';/// 将依赖关系结果集转换成 dot 文本/// [packages] 项目依赖关系结果集/// [ignorePackages] 需要忽略的 package 名称String toDot(  Map packages, {  bool escapeLabels = false,  Iterable ignorePackages = const [],}) {  // 初始化 Gviz,设置绘制属性  final gviz = Gviz(    name: 'demo',    graphProperties: {'nodesep': '0.2'},    edgeProperties: {'fontcolor': 'gray'},  );  for (var pack      in packages.values.where((v) => !ignorePackages.contains(v.name))) {    gviz.addBlankLine();    _writeDot(pack, gviz, 'demo', escapeLabels, ignorePackages);  }  return gviz.toString();}// 绘制点和连线void _writeDot(  VizPackage pkg,  Gviz gviz,  String rootName,  bool escapeLabels,  Iterable ignorePackages,) {  final isRoot = rootName == pkg.name;  final newLine = escapeLabels ? r'\n' : '\n';  // 模块展示内容:名称+版本号  var label = pkg.name;  if (pkg.version != null) {    label = '$label$newLine${pkg.version}';  }  final props = {'label': label};   // ...设置字体,间距等样式,此部分代码省略    // 追加节点  gviz.addNode(pkg.name, properties: props);  final orderedDeps = pkg.dependencies.toList(growable: false)..sort();  for (var dep in orderedDeps.where((d) => !ignorePackages.contains(d.name))) {    if (!dep.isDevDependency || isRoot) {      final edgeProps = {};      // 连线展示内容      if (!dep.versionConstraint.isAny) {        edgeProps['label'] = '${dep.versionConstraint}';      }      // ...设置字体,间距等样式,此部分代码省略      if (dep.name == rootName) {        // 如果一个包依赖于根节点,它不应该影响布局        edgeProps['constraint'] = 'false';      }      // 绘制连线      gviz.addEdge(pkg.name, dep.name, properties: edgeProps);    }  }}以前面的 Demo 工程为例,输出的 .dot 文件内容如下:digraph demo {  graph [nodesep="0.2"];  edge [fontcolor=gray];  meta [label="meta 1.7.0", shape=box, margin="0.25,0.15"];  module_a [label=module_a, fontsize="18", style=bold, shape=box, margin="0.25,0.15"];  module_a -> module_b [label="", penwidth="2"];  module_a -> path [label="^1.8.0", penwidth="2"];  module_b [label="module_b 0.0.1", shape=box, margin="0.25,0.15", style=bold];  module_b -> meta [label="1.7.0"];  path [label="path 1.8.3", shape=box, margin="0.25,0.15", style=bold];}5.3 绘制可视化关系图有了前面的一系列铺垫,要生成依赖关系图,只需通过一行简单的 dot 命令:安装 graphviz:brew install graphviz执行 dot 命令输出依赖关系图:dot x.dot -T png -o x.png至此我们已经可以从一个工程中,分析依赖,并得到了一张清晰明了的依赖关系图。 例如,Demo工程的依赖关系图如下:5.4 小结整个绘制流程分为3大步,完整流程图如下所示:通过前面的分析可知,gviz 分别用了 2 种不同的方式来解析主工程和子组件工程的依赖清单,并且解析结果的数据结构也不一致,需要额外进行合并操作,经实测,统一采用同一种方式解析就可以实现,个人更推荐第二种 (命令行方式) 。 作者做 yaml 文件解析目的主要是为了获取主工程 pubsepc.yaml 文件配置信息,便于后续绘制依赖关系图能区分出主工程做一些特殊处理。6 化繁为简按照上面的流程,我们可以获取到Flutter工程的完整依赖关系图。但实际工作中,工程中的组件比较多,也会使用到大量的三方库,这会产生大量的“噪音”,所以,实际工程输出的依赖关系图可能是这样的:虽然上图展示了所有组件库的依赖关系,除了业务组件外,还有依赖的三方库,甚至是三方库依赖的三方库,一直套娃,这些依赖关系全被绘制出来,对于我们分析业务组件的依赖关系反而带来了不必要的麻烦,那么如何才能聚焦我们关注的业务组件依赖关系呢?其实解决方案很简单:在解析依赖树的时候进行一次过滤即可。方法一、 白名单在主工程的 pubspec.yaml 文件中新增一种如下所示的配置规则:#自定义的yaml文件节点dependency_rules:   # 需要统计的组件清单  include:    - search    - chat    - ...在解析依赖树时,用此清单(白名单)进行过滤,这样,用于生成dot文件的组件清单全部在白名单内。方法二、 黑名单与方法一类似,创建一个黑名单,在解析依赖树时,过滤组件清单中所有黑名单内的库即可。方法三、 指定白名单目录新增配置规则如下:#自定义的yaml文件节点dependency_rules:  # 需要统计的路径  include:    - plugins/**    - packages/common/**在 include 指定的路径下扫描出所有工程的 pubspec.yaml 文件,并解析出所有的组件名(库名)列表,也就是自动生成白名单,剩下的跟方法一相同。方法四、 指定黑名单目录与方法三类似,只不过将白名单改成了黑名单。如果需要,方法四还可以跟方法三结合起来使用,在白名单目录中过滤黑名单。#自定义的yaml文件节点dependency_rules:  # 需要统计的路径  include:    - plugins/**    - packages/common/**  # 不需要统计的路径  exclude:    - example/**    - plugins/**/example/知其然,知其所以然,才能做到化繁为简,更好的结合和服务自身的业务。经过过滤之后,我们得到的依赖关系图就可以是下面这样的了:7 总结Flutter 组件之间可以相互依赖,编译不会报错,但随着项目规模越来越大,组件越来越多,如果不注重组件解耦,组件之间的依赖关系就会越来越乱,这会给项目的重构和平时的开发带来极大的困扰。通过依赖关系可视化,项目中各个组件的依赖关系我们可以做到一目了然。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2024-12-26 11:20 , Processed in 0.343857 second(s), 25 queries .

Powered by Discuz! X3.5

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表