|
化繁为简: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 组件之间可以相互依赖,编译不会报错,但随着项目规模越来越大,组件越来越多,如果不注重组件解耦,组件之间的依赖关系就会越来越乱,这会给项目的重构和平时的开发带来极大的困扰。通过依赖关系可视化,项目中各个组件的依赖关系我们可以做到一目了然。
|
|