Dart 脚本:组件依赖关系可视化

引言 - 组件依赖关系

写过 flutter 的同学可能都用过命令行查看项目中各组件与主工程间的依赖关系,例如这样一个项目:

  • 红色框住的为主工程
  • 绿色框住的为组件模块(各模块内部还依赖了一些三方库)

执行命令行可查看组件依赖树:

dart 复制代码
// 遍历组件依赖节点
pub deps --json

结果如下:

dart 复制代码
{
  "root": "flutter_dependency_draw",
  "packages": [
    {
      "name": "flutter_dependency_draw",
      "version": "1.0.0+1",
      "kind": "root",
      "source": "root",
      "dependencies": [
        "flutter",
        "cupertino_icons",
        "common",
        "menu",
        "order",
        "trade",
        "cart",
        "splash",
        "upgrade",
        "flutter_test",
        "flutter_lints",
        "yaml",
        "gviz"
      ]
    },
    {
      "name": "gviz",
      "version": "0.4.0",
      "kind": "dev",
      "source": "hosted",
      "dependencies": []
    },
    {
      "name": "yaml",
      "version": "3.1.1",
      "kind": "dev",
      "source": "hosted",
      "dependencies": [
        "collection",
        "source_span",
        "string_scanner"
      ]
    },
    {
      "name": "string_scanner",
      "version": "1.1.1",
      "kind": "transitive",
      "source": "hosted",
      "dependencies": [
        "source_span"
      ]
    },
    {
      "name": "source_span",
      "version": "1.9.0",
      "kind": "transitive",
      "source": "hosted",
      "dependencies": [
        "collection",
        "path",
        "term_glyph"
      ]
    },
    {
      "name": "term_glyph",
      "version": "1.2.1",
      "kind": "transitive",
      "source": "hosted",
      "dependencies": []
    },
    {
      "name": "path",
      "version": "1.8.2",
      "kind": "transitive",
      "source": "hosted",
      "dependencies": []
    },
    {
      "name": "collection",
      "version": "1.16.0",
      "kind": "transitive",
      "source": "hosted",
      "dependencies": []
    },
    {
      "name": "flutter_lints",
      "version": "2.0.2",
      "kind": "dev",
      "source": "hosted",
      "dependencies": [
        "lints"
      ]
    },
    {
      "name": "lints",
      "version": "2.0.1",
      "kind": "transitive",
      "source": "hosted",
      "dependencies": []
    },
    {
      "name": "flutter_test",
      "version": "0.0.0",
      "kind": "dev",
      "source": "sdk",
      "dependencies": [
        "flutter",
        "test_api",
        "path",
        "fake_async",
        "clock",
        "stack_trace",
        "vector_math",
        "async",
        "boolean_selector",
        "characters",
        "collection",
        "matcher",
        "material_color_utilities",
        "meta",
        "source_span",
        "stream_channel",
        "string_scanner",
        "term_glyph"
      ]
    },
    {
      "name": "stream_channel",
      "version": "2.1.0",
      "kind": "transitive",
      "source": "hosted",
      "dependencies": [
        "async"
      ]
    },
    {
      "name": "async",
      "version": "2.9.0",
      "kind": "transitive",
      "source": "hosted",
      "dependencies": [
        "collection",
        "meta"
      ]
    },
    {
      "name": "meta",
      "version": "1.8.0",
      "kind": "transitive",
      "source": "hosted",
      "dependencies": []
    },
    {
      "name": "material_color_utilities",
      "version": "0.1.5",
      "kind": "transitive",
      "source": "hosted",
      "dependencies": []
    },
    {
      "name": "matcher",
      "version": "0.12.12",
      "kind": "transitive",
      "source": "hosted",
      "dependencies": [
        "stack_trace"
      ]
    },
    {
      "name": "stack_trace",
      "version": "1.10.0",
      "kind": "transitive",
      "source": "hosted",
      "dependencies": [
        "path"
      ]
    },
    {
      "name": "characters",
      "version": "1.2.1",
      "kind": "transitive",
      "source": "hosted",
      "dependencies": []
    },
    {
      "name": "boolean_selector",
      "version": "2.1.0",
      "kind": "transitive",
      "source": "hosted",
      "dependencies": [
        "source_span",
        "string_scanner"
      ]
    },
    {
      "name": "vector_math",
      "version": "2.1.2",
      "kind": "transitive",
      "source": "hosted",
      "dependencies": []
    },
    {
      "name": "clock",
      "version": "1.1.1",
      "kind": "transitive",
      "source": "hosted",
      "dependencies": []
    },
    {
      "name": "fake_async",
      "version": "1.3.1",
      "kind": "transitive",
      "source": "hosted",
      "dependencies": [
        "clock",
        "collection"
      ]
    },
    {
      "name": "test_api",
      "version": "0.4.12",
      "kind": "transitive",
      "source": "hosted",
      "dependencies": [
        "async",
        "boolean_selector",
        "collection",
        "meta",
        "source_span",
        "stack_trace",
        "stream_channel",
        "string_scanner",
        "term_glyph",
        "matcher"
      ]
    },
    {
      "name": "flutter",
      "version": "0.0.0",
      "kind": "direct",
      "source": "sdk",
      "dependencies": [
        "characters",
        "collection",
        "material_color_utilities",
        "meta",
        "vector_math",
        "sky_engine"
      ]
    },
    {
      "name": "sky_engine",
      "version": "0.0.99",
      "kind": "transitive",
      "source": "sdk",
      "dependencies": []
    },
    {
      "name": "upgrade",
      "version": "0.0.1",
      "kind": "direct",
      "source": "path",
      "dependencies": [
        "flutter",
        "common",
        "net"
      ]
    },
    {
      "name": "net",
      "version": "0.0.1",
      "kind": "transitive",
      "source": "path",
      "dependencies": [
        "flutter",
        "dio"
      ]
    },
    {
      "name": "dio",
      "version": "4.0.6",
      "kind": "transitive",
      "source": "hosted",
      "dependencies": [
        "http_parser",
        "path"
      ]
    },
    {
      "name": "http_parser",
      "version": "4.0.2",
      "kind": "transitive",
      "source": "hosted",
      "dependencies": [
        "collection",
        "source_span",
        "string_scanner",
        "typed_data"
      ]
    },
    {
      "name": "typed_data",
      "version": "1.3.2",
      "kind": "transitive",
      "source": "hosted",
      "dependencies": [
        "collection"
      ]
    },
    {
      "name": "common",
      "version": "0.0.1",
      "kind": "direct",
      "source": "path",
      "dependencies": [
        "flutter"
      ]
    },
    {
      "name": "splash",
      "version": "0.0.1",
      "kind": "direct",
      "source": "path",
      "dependencies": [
        "flutter",
        "login",
        "common"
      ]
    },
    {
      "name": "login",
      "version": "0.0.1",
      "kind": "transitive",
      "source": "path",
      "dependencies": [
        "flutter",
        "common_ui",
        "net"
      ]
    },
    {
      "name": "common_ui",
      "version": "0.0.1",
      "kind": "transitive",
      "source": "path",
      "dependencies": [
        "flutter",
        "flutter_screenutil"
      ]
    },
    {
      "name": "flutter_screenutil",
      "version": "5.7.0",
      "kind": "transitive",
      "source": "hosted",
      "dependencies": [
        "flutter"
      ]
    },
    {
      "name": "cart",
      "version": "0.0.1",
      "kind": "direct",
      "source": "path",
      "dependencies": [
        "flutter",
        "common_ui"
      ]
    },
    {
      "name": "trade",
      "version": "0.0.1",
      "kind": "direct",
      "source": "path",
      "dependencies": [
        "flutter",
        "common_ui",
        "net"
      ]
    },
    {
      "name": "order",
      "version": "0.0.1",
      "kind": "direct",
      "source": "path",
      "dependencies": [
        "flutter",
        "common_ui",
        "net"
      ]
    },
    {
      "name": "menu",
      "version": "0.0.1",
      "kind": "direct",
      "source": "path",
      "dependencies": [
        "flutter",
        "common_ui",
        "net"
      ]
    },
    {
      "name": "cupertino_icons",
      "version": "1.0.5",
      "kind": "direct",
      "source": "hosted",
      "dependencies": []
    }
  ],
  "sdks": [
    {
      "name": "Dart",
      "version": "2.18.6"
    },
    {
      "name": "Flutter",
      "version": "3.3.10"
    }
  ],
  "executables": []
}

对于这种文本树状结构,不够直观且存在重复的连线关系,远远达不到初衷预期。

依赖关系可视化 - 转树状 PNG

为此,小编使用脚本的方式,将上述树状结构,转成更为直观的树状连线图。

1. 实现方案

使用gviz,通过 DOT (一种描述语言来定义图形)Graphviz 实现图形可视化。

最终产物可生成 PNG、PDF、SVG 等格式。

2. 前置工作

  • 以 Mac 为例,需要在电脑使用命令行工具安装 graphviz
dart 复制代码
// 我的电脑是 m1,命令行如下:
arch -arm64 brew install graphviz
  • 在需要生成依赖关系图形 的项目根目录下,找到 pubspec.yaml 文件,添加如下依赖
dart 复制代码
dev_dependencies:
yaml: ^3.1.1
gviz: ^0.4.0

3. (直接copy可用)执行脚本生成依赖关系图

dart 复制代码
import 'dart:convert';
import 'dart:io';
import 'package:gviz/gviz.dart';
import 'package:yaml/yaml.dart' as yaml;

void main() async {
  final projectPath = await _getProjectPath();
  final file = File('$projectPath/pubspec.yaml');
  final fileContent = file.readAsStringSync();
  final yamlMap = yaml.loadYaml(fileContent) as yaml.YamlMap;
  final appName = yamlMap['name'].toString();

  print('开始 ...');
  final dependencyContent = await _getComponentDependencyTree(
    projectPath: projectPath,
  );
  // 获取所有的组件依赖
  print('... 开始遍历组件依赖节点');
  print(dependencyContent);
  final dependencyNodes = _traversalComponentDependencyTree(dependencyContent);
  print('... 完成遍历组件依赖节点');
  final graph = Gviz(
    name: appName,
    graphProperties: {
      'pad': '0.5',
      'nodesep': '1',
      'ranksep': '2',
    },
    edgeProperties: {
      'fontcolor': 'gray',
    },
  );
  print('... 开始转换 dot 节点');
  _generateDotByNodes(
    dependencyNodes,
    graph: graph,
    edgeCache: <String>[],
  );
  print('... 完成转换 dot 节点');
  final dotDirectoryPath = '$projectPath/dotGenerateDir';
  final dotDirectory = Directory(dotDirectoryPath);
  if (!dotDirectory.existsSync()) {
    await dotDirectory.create();
    print('... 创建 dotGenerate 文件夹');
  }
  final dotFileName = '$appName.dot';
  final dotPngName = '$appName.png';
  final dotFile = File('$dotDirectoryPath/$dotFileName');
  final dotPngFile = File('$dotDirectoryPath/$dotPngName');
  if (dotFile.existsSync()) {
    await dotFile.delete();
    print('... 删除原有 dot 生成文件');
  }
  if (dotPngFile.existsSync()) {
    await dotPngFile.delete();
    print('... 删除原有 dot 依赖关系图');
  }
  await dotFile.create();
  final dotResult = await dotFile.writeAsString(graph.toString());
  print('dot 文件生成成功: ${dotResult.path}');
  print('... 开始生成 dot png');
  await _runCommand(
    executable: 'dot',
    projectPath: projectPath,
    commandArgs: [
      '$dotDirectoryPath/$dotFileName',
      '-T',
      'png',
      '-o',
      '$dotDirectoryPath/$dotPngName'
    ],
  );
  print('png 文件生成成功:$dotDirectoryPath/$dotPngName');
  await Process.run(
    'open',
    [dotDirectoryPath],
  );
}

// 忽略这些组件库,不需要显示出来
const List<String> ignoreDependency = <String>[
  'flutter',
  'flutter_test',
  'flutter_lints',
  'cupertino_icons',
  'gviz',
  'yaml',
];

/// 获取组件依赖树
Future<String> _getComponentDependencyTree({
  required String projectPath,
}) {
  return _runCommand(
    projectPath: projectPath,
    commandArgs: ['pub', 'deps', '--json'],
  ).then(
    (value) {
      if (value.contains('dependencies:') &&
          value.contains('dev dependencies:')) {
        final start = value.indexOf('dependencies:');
        final end = value.indexOf('dev dependencies:');
        return value.substring(start, end);
      } else {
        return value;
      }
    },
  );
}

/// 遍历组件节点
List<DependencyNode> _traversalComponentDependencyTree(
  String dependencyContent,
) {
  final dependencyJson = jsonDecode(dependencyContent) as Map<String, dynamic>;
  final packages = dependencyJson['packages'] as List<dynamic>;
  final dependencyNodeList =
      packages.map((e) => DependencyNode.fromMap(e)).toList();
  final rootNode =
      dependencyNodeList.firstWhere((element) => element.isRootNode);

  DependencyNode? matchNode(String nodeName) {
    DependencyNode? target;
    try {
      target =
          dependencyNodeList.firstWhere((element) => element.name == nodeName);
    } catch (_) {
      print(_);
    }
    return target;
  }

  void mapDependencies(DependencyNode node) {
    final dependencies = node.dependencies;
    for (int index = 0; index < dependencies.length; index++) {
      final itemName = dependencies[index];
      if (!ignoreDependency.contains(itemName)) {
        final itemNode = matchNode(itemName);
        if (itemNode != null) {
          mapDependencies(itemNode);
          node.children.add(itemNode);
        }
      }
    }
  }

  mapDependencies(rootNode);

  // 获取子集中所有的依赖
  void fetchChildrenDependency(
    DependencyNode node, {
    required List<String> dependencyContainer,
    bool containSelf = false,
  }) {
    if (node.children.isEmpty) {
      return;
    } else {
      for (int index = 0; index < node.children.length; index++) {
        final itemNode = node.children[index];
        if (containSelf && !dependencyContainer.contains(itemNode.name)) {
          dependencyContainer.add(itemNode.name);
        }
        for (var element in itemNode.children) {
          fetchChildrenDependency(
            element,
            dependencyContainer: dependencyContainer,
            containSelf: true,
          );
        }
      }
    }
  }

  // 去掉重复的连线关系
  void filterRepeatDependency(DependencyNode node) {
    final childrenDependencyContainer = <String>[];
    fetchChildrenDependency(node,
        dependencyContainer: childrenDependencyContainer);
    if (childrenDependencyContainer.isNotEmpty) {
      node.children.removeWhere(
          (element) => childrenDependencyContainer.contains(element.name));
    }
    for (var childNode in node.children) {
      filterRepeatDependency(childNode);
    }
  }

  filterRepeatDependency(rootNode);
  return [rootNode];
}

/// 获取项目根路径
Future<String> _getProjectPath() async {
  final originProjectPath = await Process.run(
    'pwd',
    [],
  );
  final projectPath = (originProjectPath.stdout as String).replaceAll(
    '\n',
    '',
  );
  return projectPath;
}

/// 转换生成 dot 绘制节点
void _generateDotByNodes(
  List<DependencyNode> nodes, {
  required Gviz graph,
  required List<String> edgeCache,
}) {
  if (nodes.isEmpty) {
    return;
  }
  for (int index = 0; index < nodes.length; index++) {
    final itemNode = nodes[index];
    final from = '${itemNode.name}\n${itemNode.version}';
    if (!graph.nodeExists(from)) {
      // 绘制节点
      graph.addNode(
        from,
        properties: {
          'color': 'black',
          'shape': 'rectangle',
          'margin': '1,0.8',
          'penwidth': '7',
          'style': 'filled',
          'fillcolor': 'gray',
          'fontsize': itemNode.isLevel1Node ? '60' : '55',
        },
      );
    }
    final toArr = itemNode.children.map((e) => '${e.name}\n${e.version}').toList();
    for (var element in toArr) {
      // 绘制连线
      final edgeKey = '$from-$element';
      if (!edgeCache.contains(edgeKey)) {
        graph.addEdge(
          from,
          element,
          properties: {
            'penwidth': '2',
            'style': 'dashed',
            'arrowed': 'vee',
            // 'weight': '2',
          },
        );
        edgeCache.add(edgeKey);
      }
    }
    _generateDotByNodes(
      itemNode.children,
      graph: graph,
      edgeCache: edgeCache,
    );
  }
}

/// 执行命令行
Future<String> _runCommand({
  String executable = 'flutter',
  required String projectPath,
  required List<String> commandArgs,
}) {
  return Process.run(
    executable,
    commandArgs,
    runInShell: true,
    workingDirectory: projectPath,
  ).then((result) => result.stdout as String);
}

/// 依赖的节点
class DependencyNode {
  final String name;
  final String version;
  final String kind;
  final String source;
  final List<String> dependencies;
  final children = <DependencyNode>[];
  bool isLevel1Node = true; //是否一级节点

  factory DependencyNode.fromMap(Map<String, dynamic> map) {
    return DependencyNode(
      name: map['name'] as String,
      version: map['version'] as String,
      kind: map['kind'] as String,
      source: map['source'] as String,
      dependencies: (map['dependencies'] as List<dynamic>)
          .map((e) => e as String)
          .toList(),
    );
  }

  bool get isRootNode => kind == 'root';

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is DependencyNode &&
          runtimeType == other.runtimeType &&
          name == other.name;

  @override
  int get hashCode => name.hashCode;

  DependencyNode({
    required this.name,
    required this.version,
    required this.kind,
    required this.source,
    required this.dependencies,
  });
}

4. 产物

最终会生成 依赖关系.png 文件, 位于当前项目的 dotGenerateDir 目录下。

  • 上述结构的demo(flutter_dependency_draw)生产的依赖关系图如下:

demo已上传: github.com/liyufengrex...

DOT 语言相关参考资料:

相关推荐
我命由我123459 分钟前
React - state、state 的简写方式、props、props 的简写方式、类式组件中的构造器与 props、函数式组件使用 props
前端·javascript·react.js·前端框架·html·html5·js
钰衡大师10 分钟前
Vue 3 源码学习教程
前端·vue.js·学习
C澒11 分钟前
React + TypeScript 编码规范|统一标准 & 高效维护
前端·react.js·typescript·团队开发·代码规范
时光少年28 分钟前
Android 视频分屏性能优化——GLContext共享
前端
IT_陈寒1 小时前
JavaScript开发者必知的5个性能杀手,你踩了几个坑?
前端·人工智能·后端
跟着珅聪学java1 小时前
Electron 精美菜单设计
运维·前端·数据库
日光倾1 小时前
【Vue.js 入门笔记】闭包和对象引用
前端·vue.js·笔记
一只程序熊1 小时前
UniappX 未找到 “video“ 组件,已自动当做 “view“ 组件处理。请确保代码正确,或重新生成自定义基座后再试。
前端
林小帅1 小时前
【笔记】xxx 技术分享文档模板
前端
雾岛心情1 小时前
【HTML&CSS】HTML为文字添加格式和内容
前端·css·html