
1. 项目概述为什么要可视化Module依赖1.1 核心需求解析做Android开发的朋友尤其是维护中大型项目的一定遇到过这种场景项目里的Module越来越多UI层、业务层、数据层、基础库层层叠叠每个人心里都有一张大概的依赖图但谁也没见过真正的全局关系图。等到某个Module要重构、要拆分或者要排查循环依赖才发现自己根本不了解整个依赖网络的全貌。这个项目要解决的就是把Gradle构建过程中Module之间的依赖关系用可视化的方式呈现出来。具体来说就是通过分析Gradle的配置阶段数据拿到每个Module的project依赖和外部依赖清单然后生成一份能直接看懂的图形化结果。它解决了几个很痛的痛点新同学入职拿着一堆Module名字完全不知道谁依赖谁业务边界在哪里。排查循环依赖时只能靠人肉看build.gradle效率极低还容易漏。做模块化改造时想评估某个Module被多少人引用缺少一个全局视角的工具。代码评审时dependency关系说不清楚评审效率上不去。这个项目适合谁看我觉得有三类人一是Android工程师尤其是项目规模到了一定程度、Module数量超过10个的团队二是做构建工具链、想着手优化工程效率的基建同学三是刚接触Android模块化、想搞懂依赖关系本质的初学者——虽然你可能还没维护过大项目但搞懂这套分析思路后面处理巨型工程会有底不少。1.2 方案选型Gradle API Graphviz依赖关系可视化的实现路径其实有挺多种我做过对比。最直接的方式是在settings.gradle里注册一个Gradle监听器在配置阶段结束后把Project的依赖数据捞出来然后选一个图形渲染方案输出成图片或交互页面。最终我选了两件套Gradle API负责数据采集Graphviz负责图形渲染。Gradle本身的API就内置了Project.getDependencies()和Configuration.getDependencies()可以拿到完整的依赖集合并不需要去解析build.gradle里的文本内容——解析文本又慢又脆因为Groovy DSL的写法太灵活了正则很容易翻车。用API直接拿结构化数据这才是正统做法。图形渲染选了Graphviz一个老牌的开源图形可视化工具。理由有三点首先是它成熟稳定从2000年左右发展到现在什么复杂依赖图都见过处理几百个节点的图完全不在话下其次是它可以用文字描述图的结构也就是DOT语言生成PNG、SVG等格式非常方便适合命令行调用最后是它对中文支持相对友好调一下字体配置就能把Module名正常显示出来。当然如果你想在浏览器里交互式地查看依赖关系也可以选ECharts的Graph图或者D3.js。不过我觉得对于依赖分析这种场景静态图加上搜索定位功能就能覆盖绝大多数需求Graphviz胜在简单、快、依赖少。提示如果项目只需要知道A依赖了B这种静态关系Graphviz完全够用。如果需要点击节点查看该Module的详细依赖链这类交互操作再考虑上ECharts方案成本会高一些。2. 依赖数据采集拿到原始关系图2.1 Gradle配置阶段钩子要实现依赖采集第一步是找到合适的切入时机。Gradle构建过程分为三个阶段初始化、配置、执行。依赖关系在配置阶段结束的时候就是确定的——因为build.gradle里的dependencies配置块全部是在配置阶段被执行的。所以我选择在settings.gradle里通过gradle.beforeProject {}或者gradle.afterProject {}来挂钩子。更稳妥的方式是注册GradleBuildListener监听projectsEvaluated事件——这个事件意味着所有工程都配置完毕依赖数据已经完整。核心代码如下// 在settings.gradle中注册 gradle.beforeProject { project - project.afterEvaluate { p - // 到这里p的dependencies已经配置完毕 collectDependencies(p) } }需要注意不要直接在beforeProject里就读取依赖因为那个时候当前Project的build.gradle还没执行拿走的是空数据。一定得等到afterEvaluate。另外这里有个小坑afterEvaluate是个延迟回调如果你只是注册而不去调用某个会触发配置完成的API回调可能不执行。所以我用了一个技巧显式调用project.getTasks()来触发配置评估完成gradle.beforeProject { project - // 触发afterEvaluate回调 project.afterEvaluate { p - // 读取依赖 def deps p.configurations .findAll { it.name in [implementation, api, compileOnly, runtimeOnly] } .collectMany { it.dependencies } .findAll { it instanceof ProjectDependency } } // 这一行很关键触发配置评估完成 project.getTasks() }这个getTasks()的踩坑记忆还是挺深刻的不加这一行我在某个自定义Gradle版本下就会遇到回调不触发的问题。2.2 过滤ProjectDependency与外部依赖拿到Dependency集合之后得区分项目依赖与外部依赖。ProjectDependency代表的是Module之间的依赖DefaultExternalModuleDependency代表的是第三方库依赖。我做了两层采集第一层收集Module间依赖也就是ProjectDependency通过dependency.getDependencyProject().getPath()拿到目标工程路径形如:feature:login。这一层是可视化的核心因为它反应的是工程内部架构。第二层收集外部依赖也就是group:name:version三元组比如com.squareup.okhttp3:okhttp:4.11.0可用于标注这个Module引入了哪些著名第三方库。这个信息虽然不展示在最终的主图上但可以用来做高危依赖预警——例如某些Module直接依赖了网络库意味着它可能抢占了数据层的职责边界。def collectDependencies(Project project) { project.configurations .findAll { it.name in [implementation, api, compileOnly, runtimeOnly] } .each { config - config.dependencies.each { dep - if (dep instanceof ProjectDependency) { def targetPath dep.dependencyProject.path def sourcePath project.path projectDependencyList [from: sourcePath, to: targetPath] } else { def externalKey ${dep.group}:${dep.name}:${dep.version} externalDependencyMap[project.path] externalKey } } } }注意这里只捞了最常见的四个配置testImplementation和androidTestImplementation没有包含进来。因为测试代码的依赖关系不影响生产环境的架构边界不该混在一起不然图会变得很脏。2.3 数据序列化方案Gradle配置阶段运行时数据还在内存里需要在构建结束后写到文件里。我用JSON格式做中转结构大概是这样{ projectDependencies: [ {from: :app, to: :feature:login}, {from: :feature:login, to: :core:network}, {from: :app, to: :core:common} ], externalDependencies: { :feature:login: [ com.squareup.okhttp3:okhttp:4.11.0, org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3 ] } }JSON格式的好处是后续接什么渲染方案都方便Python脚本能读、Node脚本能读、写个Java命令行工具也能读。整个可视化流程就是Gradle把JSON吐出来渲染脚本消费JSON出图。这个JSON文件建议放在build/reports/dependency-visualizer/deps.json路径构建产物归构建产物不进版本库。3. 图结构设计与层级布局3.1 依赖图建模思路拿到数据之后就要把数据变成一张图。这里的建模思路很重要因为它直接决定了图形最终的布局和可读性。我采用分层图的建模方式按照Module的职责把节点分成几个层级从上到下依次是接入层app、业务功能层feature:*、中间业务层business:*、核心能力层core:*、基础组件层base:*。每个节点归入对应层级层级之间规定只能从上往下依赖如果出现了反向依赖在图上用红色标记出来提示这里存在架构倒置。LAYER_MAP { :app: 0, :feature:login: 1, :feature:home: 1, :feature:profile: 1, :business:xxx: 2, :core:network: 3, :core:storage: 3, :base:ui: 4, :base:utils: 4, }在Graphviz的DOT语言里可以通过rank约束来把同层级节点放在同一行。3.2 依赖方向与循环检测依赖图构建之后要做的第一件事就是检查有没有环。架构上循环依赖是最要命的A依赖B、B依赖C、C又依赖A一旦出现轻则编译不稳重则根本没法单独编译某个模块。Gradle虽然能容忍部分循环依赖但它会让构建变得极度脆弱。我用了经典的三色标记法来判断环是否存在并给出环上所有节点路径。这个知识点做算法的人都很熟简单说就是深度优先搜索用三个颜色标记节点状态白色为未访问灰色为访问中黑色为完成。如果DFS过程中遇到一个灰色节点就说明有环。def find_cycle(adj_list): WHITE, GRAY, BLACK 0, 1, 2 color {node: WHITE for node in adj_list} parent {} cycle_path [] def dfs(node): color[node] GRAY for neighbor in adj_list.get(node, []): if color.get(neighbor, WHITE) WHITE: parent[neighbor] node if dfs(neighbor): return True elif color.get(neighbor) GRAY: # 找到环回溯路径 cycle_path.append(neighbor) cur node while cur ! neighbor: cycle_path.append(cur) cur parent.get(cur) if cur is None: break cycle_path.append(neighbor) cycle_path.reverse() return True color[node] BLACK return False for node in adj_list: if color[node] WHITE and dfs(node): break return cycle_path这个是实现中很核心的一段发现环之后会输出一条从环起点回到环起点的完整路径。例如:app - :feature:login - :core:network - :feature:login这样排查问题的时候就非常直观了。3.3 自定义布局策略Graphviz自带了好几种布局引擎比如dot、neato、fdp、sfdp。我测试下来dot引擎在有层级关系的场景下效果最好因为它的核心算法就是处理有向分层图。不过工程实践中光靠自动布局还不够节点名字一长或者数量一多还是容易乱。所以我做了一张可选的CSS式配置为不同层级定义不同的颜色和形状。层级颜色形状说明接入层#FADB14houseapp主工程业务功能层#36CFC9ellipse功能模块中间业务层#69C0FFellipse业务模块核心能力层#B37FEBbox网络/存储等核心能力基础组件层#FF9C6Ecomponent基础库颜色区分在这个场景下非常关键比如你想知道core:network被多少个业务模块依赖目光扫一下紫色节点有几条上游连接就行不需要一个一个去抠名字。4. 可视化渲染从DOT语言到图片4.1 DOT文件生成细节把依赖数据转成DOT语言是整个流程中看起来很简单、其实细节最多的一步。首先节点命名必须转义Module名里包含冒号和部门符号直接放在DOT里会引发语法错误。我统一做了处理把冒号替换成下划线同时设置label为显示名称。def escape_node_name(path): return path.replace(:, _).replace(-, _) def generate_dot(project_deps, layer_map): lines [digraph G {] lines.append( rankdirTB;) lines.append( node [fontnamePingFang SC, fontsize12];) for node, layer in layer_map.items(): color LAYER_COLORS[layer] shape LAYER_SHAPES[layer] lines.append( f {escape_node_name(node)} [label{node}, fcolor{color}, shape{shape}]; ) for src, dst in project_deps: lines.append( f {escape_node_name(src)} - {escape_node_name(dst)}; ) lines.append(}) return \n.join(lines)这个fontname的配置得啰嗦一句Graphviz默认字体对中文支持很差不设置中文字体的话生成图片里中文会变成一个个方块。Mac上可以用PingFang SCLinux上可以试试Noto Sans CJK SCWindows上则是Microsoft YaHei。4.2 渲染命令与批量处理DOT文件生成之后调用Graphviz命令行工具出图。命令行很简单dot -Tsvg deps.dot -o deps.svg dot -Tpng deps.dot -o deps.png -Gdpi150这里我推荐输出SVG因为SVG是矢量图节点再多也不会糊而且可以用浏览器直接打开CtrlF搜索节点名非常方便。PNG则是方便直接在IM软件里发给别人传阅两格式都生成就行成本也不算高。如果Module数量特别多、图特别大可以加参数调整节点间距和边重叠度-Goverlapfalse -Gsplinestrue -Gsep1。dot -Tsvg deps.dot -o deps.svg -Goverlapfalse -Gsplinestrue -Gsep14.3 在图里标注架构问题只画一张关系图还不够更有价值的是把架构问题也标注出来。我在渲染脚本里额外做了三步增强第一步反向依赖高亮。按照预定的分层规则上层模块不应该依赖下层的模块如果出现:core:x依赖:feature:y这种反向就在边上加红色加粗样式用虚线表示一眼扫过去就知道哪里的架构关系是异常的。第二步重复依赖聚合。比如feature:login和feature:home都依赖了:core:network渲染脚本会把边合并用带宽表示依赖方数量带宽越粗说明这个核心模块被越多上层模块依赖。这样就能快速识别出高耦合核心模块为后续的拆分或者薄化提供数据支撑。第三步外部依赖标签。每个节点下方用一个灰色小字列表展示它引入的几大外部依赖起到这个模块使用了哪些知名库的参考作用。信息多了会占用空间所以只展示前3个并且忽略了空版本号的依赖。提示图上信息贵精不贵多。我一开始也是把所有外部依赖全堆上去结果整个图变成一团乱麻根本没法看。后来改成只展示核心风险依赖可读性立刻上来了。5. 工具链整合一条命令出图5.1 一键式运行方案以上各步骤是分散的日常使用不能每次手动跑三步那样没人愿意用。我做了一条命令打通从Gradle到图片的流程用一个Python脚本壳住全过程。python3 visualize_deps.py --project-dir /path/to/android/project --output-dir ./graph脚本内部干的事情是调用./gradlew执行一个自定义任务把JSON数据落盘然后读JSON、生成DOT、调用dot渲染、输出最终图片。异常时打印错误并给出建议比如未读取到依赖数据是否执行过Gradle同步。5.2 Gradle自定义Task注册自定义Task在Gradle里的注册方法很直接。在根工程的build.gradle里加一段tasks.register(collectDependencyGraph) { doLast { println collecting dependency graph... // 实际数据采集逻辑可以放在这里 } }不过数据采集本身是在配置阶段完成的Task的执行阶段更多是确认数据已经写过文件。因此我在doLast里去校验文件是否存在、是否为空如果无效就抛出带提示的异常免得用户拿着一个空白图在那怀疑人生。5.3 输出结果展示最终产物是两个文件deps.svg和deps.png。SVG可以在浏览器打开直接用支持缩放和搜索PNG方便发到群里让人快速围观。另外还会顺带输出一个cycle_report.json如果有循环依赖就把环路径写进去没有就是空数组。我在模拟项目X一个Android工程约25个Module上做了验证。不加任何样式处理Graphviz自动布局生成的图已经能看清整体分层加上分层配色之后架构边界更明晰一眼能看出哪个模块越界依赖。整个流程从运行到出图用时不到3秒其中Graphviz渲染只占了0.2秒左右效率完全够用。以下是我模拟出图后的部分节点连接示意用来展示关系结构不是完整图只是示意:app - :feature:login :app - :feature:home :feature:login - :core:network :feature:home - :core:network :core:network - :base:utils6. 常见问题与排查技巧实录6.1 数据采集中遇到的问题问题1afterEvaluate回调不触发这个问题我前面提到过很典型。在settings.gradle中注册时直接用了延迟API但不触发配置完成回调就静默地不执行。排查技巧在回调里加打印日志发现无输出时手动调用project.getTasks()强制评估。问题2依赖重复记录一个Module如果同时用api和implementation依赖了另一个Module同一对边可能被捞两次。解决方法是最终输出前做一次去重以(from, to)作为key。问题3编译型依赖与运行型依赖的区分有些Module之间只有compileOnly依赖这种依赖编译期有效、运行期不传递如果一律当成普通依赖画进去会高估耦合度。我这里做了取舍把这三种配置分开统计compileOnly会在线条上加虚线样式让人知道编译期相关但运行时独立。6.2 渲染环节遇到的问题问题1中文乱码前面说了是字体问题。一定要在DOT文件里指定系统中文字体名称或者用Graphviz指定fontpath。实在不行退路是在出图前把全部中文转拼音我们项目没有转因为转了就失去可读性了。问题2图太大导致节点重叠一个超过50个节点的大工程图Graphviz默认布局下节点标签容易互相遮挡。我测试下来的有效参数组合是-Goverlapfalse -Gsplinestrue -Gsep1.5必要时增大画布尺寸-Gsize30,30。问题3SVG搜索不精准浏览器CtrlF搜的是标签文本Graphviz生成的SVG里每个label是一个独立text元素搜索可以命中。但是如果用-Tsvg默认模式某些标签可能被拆分成多个tspan子元素搜索时按整句搜会失效。我后来改用-Tsvg:svg模式可以保证label的完整性。6.3 使用建议与扩展思路这个工具链当前形态是命令行工具。如果想接入CI可以在每次代码合并后自动跑一遍发现新增循环依赖就中断构建这是非常有效的架构约束手段。进一步扩展的话可以考虑按Git提交历史生成依赖图变化记录辅助评估架构演进方向。将依赖数据上传到内部可视化看板支持历史版本对比。增加Gradle Configuration Cache适配避免重复计算影响构建时间。我在实际使用中最大的感悟是依赖可视化的价值不只是看一眼而是让架构约束变得可自动化、可度量。以前靠人肉审查架构倒置现在一张红点图摆在面前谁违反边界一目了然。再配合CI阻断机制模块化工程才能真正做到有序演化。最后再分享一个小技巧把这份工具集成到项目的README里新同学入职第一件事就是跑一下命令、看一遍依赖图比给他讲两个小时PPT有用得多。这个项目虽然技术栈不复杂但它把看不见的工程结构变成了看得见的治理抓手在维护中大型Android工程时绝对是一项值得投入的基建工作。