首页 文章详情

白嫖一个Android项目的类图生成工具!(建议收藏)

刘望舒 | 662 2022-03-18 20:16 0 0 0
UniSMS (合一短信)
 BATcoder技术群,让一部分人先进大厂

大家好,我是刘望舒,腾讯最具价值专家,著有三本业内知名畅销书,连续五年蝉联电子工业出版社年度优秀作者,百度百科收录的资深技术专家。

前华为面试官、独角兽公司技术总监。


想要加入 BATcoder技术群,公号回复BAT 即可。

作者:leobert-lan 

https://juejin.cn/user/2066737589654327/posts

前言

首先必须声明,这不是一篇广告或者标题党。而是我开源了一个工具,可以优雅的为Java or Kotlin项目生成Class Diagram。

我推测读者会进来阅读,原因无非以下两点:

  • 获得一个生成类图的工具,并通过文章快速了解是否方便且好用
  • 了解一下我是如何折腾的

仅关心如何使用的,可以移步使用示例章节

我们将按照下面的脑图顺时针展开,揭开这一工具的诞生过程。

主要问题与方案

背景

背景:笔者今年换了份工作,所在的公司属于医疗器械下的细分领域,而相比于纯互联网行业领域,医疗器械领域所属的配套软件, 都有明确的文档要求,并非可有可无 ,而且公司管理层比较重视细节(核心产品为颅内、体内植入的医疗器械,确实需要非常认真仔细)。

毋庸置疑,准确的 、关键的算法流程图,时序图,组件图,状态图,类图等, 对于产品本身的维护及发展具有很大帮助!

对于研发工作者而言,高度概括流程、设计、算法等的专业工具图对工作有极大帮助。既然需要审核的文档中也需要这些内容,又对工作有帮助,何不做的好一些呢

上文提到的各类UML图中, 类图 Class-Diagram 是非常特殊的, 它表述的是 类之间的关系 ,基于源码文件分析可以得出准确的结果。而流程图 、时序图 、状态图 、 组件图等则不行。

问题

随着行业的发展,软件开发也演变为以迭代的方式,依次实现最重要的功能,持续性交付,顺其自然的,我们已经不再像几十年前的前辈们那样:代码未动,文档与UML图先行。一般概要设计后,方案可行便进行编码了。

根据我的实际情况,复杂的功能一般在草稿纸上画画草图,简单的就脑子里想想,难以留下存档

在这一工作模式下,笔者也遇到了一些问题:

  • 业务迭代后或者代码改进后,文档(uml图)未及时更新
  • 手动维护耗时耗力

如果这件事情可以交给机器来做,那显然是极好的!而让机器维护类图是最容易实现的!

综上所述:我们需要一款工具或者插件,可以直接基于源码生成类图 (或者中间产物,例如:plant-uml文件) ,能够配合其他工具链,直接进行归档。

当然,最重要是免费, 这省去了说服公司进行购买。

留有类图的好处:

  • 方便向他人介绍业务和代码
  • 项目庞大或者复杂时,更容易找到需求对应的关注点,重新维护时日久远的业务时,状态来的快
  • 图比代码亲切而且保护隐私🤣

解决方案

众所周知,Intellij-Idea的官方插件可以分析出类图,但是Idea是收费软件,付费支持官方插件是一个省时省力的方案,这是个兜底方案。最终没辙时,我们再考虑它。

  • 编码时分析


仿照官方插件的思路,基于源码文档树进行分析,在Intellij的支持下,基于 PSI和uast即可分析出类之间的关系。这需要一定的PSI、uast知识基础。

  • 编译时分析


在整个编译环节中,有一些切面应对特定问题,例如:"注解处理" 、 "Gradle Transformer" ,在此切面处,我们可以基于编译中间产物,间接分析出类之间的关系。

最简单的是注解处理阶段介入,这只需要对Element和TypeMirror有一定的知识基础即可

  • 运行时反射分析


显然这不是一个太好的切入点,直接pass。

考虑到PSI方面的知识体系掌握地不太完善,Intellij跨越大版本时,会有较大变更, 而注解处理方面的知识还过的了关,搞个类图生成问题不大。

PS: AndroidStudio 基于Intellij核心二次开发,PSI插件跟随Idea大版本进行适配;

所以最终方案为:从注解处理阶段入手,分析编译中间产物,最终生成类图

问题分治与解决

分治1-简化输出产物


确定了大方向之后,我们需要再思考下整个问题的方方面面。生成类图有两大问题需要解决:

  • 从源码、或者编译的中间产物中分析出类关系;ps:我们已经确定了要从编译中间产物出发
  • 将类关系转变为图

显然,"开发一个用来生成图的引擎",这件事情成本过大且没有必要。所幸的是,UML不是一个新生物,业内也有大名鼎鼎的PlantUml。

PlantUml (https://plantuml.com/zh/)基于 Graphviz(http://www.graphviz.org/) , Graphviz 本身使用Dot语法描述元素与元素关系, 直接使用 Graphviz 比较朴素,PlantUml通过自定义语法,使得内容可阅读性提升,且无须关注转换图片时进行各类装饰问题

于是,我们可以将问题转化为:从编译的中间产物中分析出类关系,将关系按照PlantUml语法生成puml文件,它的内容是纯文本。

分治2-确定分析的起始点


如果从最终结果看,我们得到的是一个有方向的图,那么按照图本身的起始点出发比较符合习惯。

也就是说,我们将在起始点所对应的类上添加注解,作为注解处理的目标起始点。例如:


Cat 和 Dog 将作为起始点。

因为只需要标记类,我们约定注解:

@Target(AnnotationTarget.CLASS)
annotation class GenerateClassDiagram {}

在代码上,将表现为:

class Animal

@GenerateClassDiagram
class Dog : Animal()

@GenerateClassDiagram
class Cat : Animal()

在示例中,当我们处理GenerateClassDiagram时,可以扫描获得Cat以及Dog类对应的javax.lang.model.element.Element示例,下文简称 Element。

几点可能存在的疑惑:

  • 为何不 "双向" 分析:继承和实现关系,双向分析会带来额外的复杂度,且在使用上规则不清晰,依赖关系难以双向分析。但是,如果使用规则上可以做到清晰明了,这一点值得实现
  • 为何不标注在Animal上,进行反向分析:如果高层级的类在库包中,则需要修改库包,这不利于日常管理与维护
  • 如果只标注了Cat而没有标注Dog,Dog将不会体现在图中?:是的
  • 如果全部标注了,是否产生不良影响 :不会,但是没有必要

分治3-确定关系的分析方法


  • 继承&实现


因为注解的标记对象是类 或者接口,我们理应得到TypeElement,基于Element的访问者模式实现,这一点并不难。

public interface TypeElement extends ElementParameterizableQualifiedNameable {

    TypeMirror getSuperclass();

    List<? extends TypeMirror> getInterfaces();

    //其他无关代码略去
}

不言自明,我们可以通过 TypeMirror getSuperclass(); 得到继承关系,通过 List<? extends TypeMirror> getInterfaces(); 得到实现关系

注意,此处可以细分,接口和枚举仅需要分析实现关系即可,通过 Element#getKind():ElementKind 可以判断类型

  • 依赖&关联&聚合&组合


这四个关系非常的类似但又不同,先降低复杂度,均认为是依赖关系,在后续迭代中,可以进一步增加功能,将关系细化

进一步降低复杂度,我们仅从类的属性出发,分析依赖关系,忽略掉方法声明 (可分析)、方法体 (无法分析) 、静态块 (无法分析)中所包含的关系。

public interface TypeElement extends ElementParameterizableQualifiedNameable {

    List<? extends Element> getEnclosedElements();
    //无关代码略去
}

不言自明,通过这一API,配合 ElementFilter#fieldsIn(Iterable<? extends Element>):List<VariableElement> 可以得到声明的 fields;

通过Element的API可以很轻易的得到命名以及修饰;

通过 Element#asType():TypeMirror API 将其转换为TypeMirror后, 基于其Visitor模式设计可以得到field的类型 DeclaredType 并通过DeclaredType#asElement():Element API重新得到Element

分治4-确定分析的终点


在分治2中,我们已经确定了分析的起点 (可能有多个) , 在分治3中,我们已经确定了关系的分析方式。为了方便表述,我们以:

Relation(From,End) 表述 从From 到 End 的关系

执行一轮 分治2&分治3,我们将得到一系列的Relation(From,End),此时我们将所有的End作为新的From,不断迭代这一过程,即可完成图的遍历!

那么何时结束这一过程呢?

我们只需要维护一个集合Sfrom,存储迭代过程中的From ,每次得到的End 只有满足 "不存在于Sfrom中" 这一条件时,才是新的From,当无法获得新的From时,迭代结束。

分治5-分治3的补充,处理集合、数组、泛型


按照分治3中的约定,我们将集合、数组涉及的类型,以及泛型去泛化时的类型,都认为和当前类型是 依赖关系;虽然这并不严谨

得益于TypeMirror的Visitor模式实现,我们很容易写出以下代码,获取我们关心的内容!

private abstract class CastingTypeVisitor<Tconstructor(private val label: String) :
    SimpleTypeVisitor6<T, Void?>() {
    override fun defaultAction(e: TypeMirror, v: Void?): T {
        throw IllegalArgumentException("$e does not represent a $label")
    }
}

private class FetchClassTypeVisitor : CastingTypeVisitor<List<DeclaredType>>(label = "") {
    override fun defaultAction(e: TypeMirror, v: Void?): List<DeclaredType> {
        //ignore it
        return emptyList()
    }

    override fun visitArray(t: ArrayType, p: Void?): List<DeclaredType> {
        return t.componentType.accept(this, p)
    }

    override fun visitWildcard(t: WildcardType, p: Void?): List<DeclaredType> {
        val ret = arrayListOf<DeclaredType>()

        t.superBound?.let {
            ret.addAll(it.accept(this, p))
        }
        t.extendsBound?.let {
            ret.addAll(it.accept(this, p))
        }
        return ret
    }

    override fun visitDeclared(t: DeclaredType, p: Void?): List<DeclaredType> {
        val ret = arrayListOf(t)
        t.typeArguments?.forEach {
            ret.addAll(it.accept(this, p))
        }
        return ret.toSet().toList()
    }

    override fun visitError(t: ErrorType, p: Void?): List<DeclaredType> {
        return visitDeclared(t, p)
    }

    override fun visitTypeVariable(t: TypeVariable, p: Void?): List<DeclaredType> {
        val ret = arrayListOf<DeclaredType>()

        t.lowerBound?.let {
            ret.addAll(it.accept(this, p))
        }
        t.upperBound?.let {
            ret.addAll(it.accept(this, p))
        }
        return ret
    }
}

fun TypeMirror.fetchDeclaredType(): List<DeclaredType> {
    return this.accept(FetchClassTypeVisitor(), null)
}

分治6-关系的存储


显然,我们需要一个合适的数据结构用以存储图,得益于我去年在组件化:组件的按序初始化(https://juejin.cn/post/6884492604370026503/)方面的一些探索,当时我开发了Maat(https://github.com/leobert-lan/Maat) 其中包含组件依赖关系的有向无环图分析,其中包含DAG的实现。

很显然,我们将“无环检测”禁用,就可以直接将数据结构拿来使用了,不需要再制造轮子!

显而易见,Relation的各种情况可以和度建立映射关系,人为维护一个虚拟顶点作为遍历的起始点可以减少很多麻烦。

分治7-类型的细节处理


在分治3中,我们已经已经对类型 (enum、class、interface) 进行了很充分的分析,但还遗漏了一些细节,例如方法、修饰符等;

在分治6中,我们确定了关系存储方案,我们还需要描述图的顶点。

我们定义UmlElement类来进行描述。

abstract class UmlElement(val diagram: ClassDiagram?, val element: Element?) {
    /**
     * return: plant-uml 中相应的文本
     * */

    abstract fun umlElement(context: MutableSet<UmlElement>): String

    abstract fun parseFieldAndMethod(diagram: ClassDiagram,
                                     graph: DAG<UmlElement>,
                                     cache: MutableSet<UmlElement>)


    abstract fun drawField(fieldDrawer: FieldDrawer
                           builder: StringBuilder,
                           context: MutableSet<UmlElement>)


    abstract fun drawMethod(methodDrawer: MethodDrawer,
                            builder: StringBuilder,
                            context: MutableSet<UmlElement>)

}

并实现:

  • UmlInterface:接口
  • UmlEnum:枚举
  • UmlClass:类
  • UmlStub:分治6中提到的虚拟顶点

定义:IElementDrawer接口与IJavaxElementDrawer接口。

interface IElementDrawer {
    fun drawAspect(builder: StringBuilder, element: UmlElement, context: MutableSet<UmlElement>)
}

interface IJavaxElementDrawer {
    fun drawAspect(builder: StringBuilder, element: Element, context: MutableSet<UmlElement>)
}

并参考Plant-Uml的语法规则,实现了一系列的切面处理,例如:修饰符解析与输出,类型解析与输出,名称解析与输出,方法解析与输出等

此处使用了责任链,将Element转化plantUml语法进行了链式切分,并定义了一系列的切面处理。限于篇幅不做展开,有兴趣的读者可以在文末获取源码了解更多。

例如:

abstract class SuperClz : SealedI {
    var superI: Int = 0
}

其Element被处理后,将转变为如下的文本内容:

abstract class "SuperClz"{
  .. fields ..
  {field}-superI : int
  .. methods ..
  {method}+ getSuperI(): int
  {method}+ setSuperI(int): void
}

由PlantUml处理后形如:


分治8-输出为PlantUml文件


得益于我先前的一些探索,我曾开发过一款用于生成文档的注解处理器,通过(
https://blog.csdn.net/a774057695/article/details/106603455)简单了解 。

设计中采用了SPI机制,我们可以很轻松地实现一个扩展,实现上面提到的所有内容,并且轻松地输出文本文档。

虽然在APT中输出一些文本文档是一件很简单的事情,但我决定使用以前造的轮子,毕竟它本身就是为了生成文档而开发的

至此,我们已经对整个问题的主要流程进行了推演,可以得出结论:这件事情可以成!

感谢我老婆早早就买到了中秋节回家的高铁票,在 回家的途中 便完成了方案的推演,并进行了框架编写与冒烟。 而现在整理博客的时间远远超过了编码🤣

保持优雅

显而易见,上述的内容仅止于“解决问题”, 还不能“出色地解决问题”。例如:

  • 绘制多张ClassDiagram
  • 增加配置,屏蔽一些输出,例如:不想看见private修饰的fields
  • 包名是在是太长了,存在阅读干扰
  • 等等

下面继续雕琢

维持简单


在继续雕琢功能的同时,我们必须兼顾简单性,这一点非常重要!

一方面,我们不要过早的考虑用不着的功能,维持功能体系的简单。另一方面,功能使用要简单,方法或规则要明确。

例如在实现“绘制多张ClassDiagram”功能时:

我最先想到的是在GenerateClassDiagram中添加qualifier:List<String>, 可以将被标识的类分配到不同的组别。但是它看起来并不太友好。

于是我产生了:将配置与标识分离的想法。定义一个注解,可以进行配置,它仅可被标识于注解。

@Target(AnnotationTarget.ANNOTATION_CLASS)
annotation class ClassDiagram(
    val qualifier: String = "",
    val fieldVisible: Array<Visible> = [Visible.Private, Visible.Protected, Visible.Package, Visible.Public],
    val methodVisible: Array<Visible> = [Visible.Private, Visible.Protected, Visible.Package, Visible.Public],
)

这样使用者可以自由的定义注解,例如:

@ClassDiagram("Demo")
annotation class DemoDiagram

这样,注解处理器需要关心的注解将变为两个:

  • ClassDiagram : 标识注解表达分组,并且包含配置
  • GenerateClassDiagram :标识类图中的分析起始点

如此,我们使用时的规则更加清晰!注意,被GenerateClassDiagram 注解的类,必须添加分组注解,即被DemoDiagram注解的注解,否则将被忽略。例如:

@GenerateClassDiagram
@DemoDiagram
class Clz : SuperClz(), SealedI {
    val int: Int? = null
}

目前增加的功能,仅仅是在前文大流程上的细节优化,实现不再展开。

结合我目前的工作使用了一段时候后,插件的功能还够用,就先不做超前的功能实现了。

减少侵入


我们从上文获知,使用这一插件,需要在代码中进行侵入修改,增加注解标注。理论上而言,侵入应该最少越好!在后续功能迭代设计中,同样需要考虑这一点。

在分治3中,我们将组合、聚合等关系暂时全部认为是依赖关系。

在最初的设计中,需要使用者通过注解标识其关系,但侵入性大大提升。

过多的注解会影响源码可读性以及增大侵入

在我的构想中,将通过ClassDiagram对依赖 、关联 、组合等进行一些约定,以解决这一问题并尽可能地减少侵入。

扩展能力


目前还有一些功能尚未有优雅的解决方案,在前文已经提及。 我预留了足够多的扩展性用于装饰Plant-Uml语法文档,如果读者有处理方案,可以直接扩展后提PR。

因本文并非为介绍编程技巧或者剖析如何提升项目的扩展能力 ,故不再展开。

使用示例

添加依赖


implementation "io.github.leobert-lan:class-diagram-reporter:1.0.0"
annotationProcessor "io.github.leobert-lan:report-anno-compiler:1.1.4"
annotationProcessor "io.github.leobert-lan:class-diagram-reporter:1.0.0"

均已发布到MavenCentral,最新版本号可参考以下:


配置信息:

kapt {
    arguments {
        arg("module""ktsample"//模块名称
        arg("mode""mode_file"
        arg("active_reporter""on")
    }
}

自行定义注解


该注解需被ClassDiagram注解,其他自行配置,例如:

@ClassDiagram(qualifier = "BridgePattern")
annotation class BridgePatternDiagram

//or

@ClassDiagram(
    qualifier = "AAAB",
    fieldVisible = {Visible.Package, Visible.Public}
)

public @interface AAAB {
}

配合GenerateClassDiagram注解使用。

此处以桥接模式示例,一个满足桥接模式的代码实现如下:

class BridgePattern {

    @ClassDiagram(qualifier = "BridgePattern")
    annotation class BridgePatternDiagram

    interface MessageImplementor {
        fun send(message: String, toUser: String)
    }

    abstract class AbstractMessage(private val impl: MessageImplementor) {
        open fun sendMessage(message: String, toUser: String) {
            impl.send(message, toUser)
        }
    }

    @BridgePatternDiagram
    @GenerateClassDiagram
    class CommonMessage(impl: MessageImplementor) : AbstractMessage(impl)

    @BridgePatternDiagram
    @GenerateClassDiagram
    class UrgencyMessage(impl: MessageImplementor) : AbstractMessage(impl) {
        override fun sendMessage(message: String, toUser: String) {
            super.sendMessage("加急:$message", toUser)
        }
    }

    @BridgePatternDiagram
    @GenerateClassDiagram
    class MessageSMS : MessageImplementor {
        override fun send(message: String, toUser: String) {
            println("使用系统内短消息的方法,发送消息'$message'给$toUser")
        }
    }

    @BridgePatternDiagram
    @GenerateClassDiagram
    class MessageEmail : MessageImplementor {
        override fun send(message: String, toUser: String) {
            println("使用邮件短消息的方法,发送消息'$message'给$toUser")
        }
    }
}

编译后,我们得到的puml文件,渲染后得到:


😂恰好满足了plant-uml中package的语法,正常情况不会有package。

我们可以注意到,关系上没有太大问题,AbstractMessage与MessageImplementor之间表现为关联更加恰当。

除此之外,从阅读习惯上而言,图中的一些位置关系,还需要再调整,我们可以在后续的版本加,添加相应的配置方式。

项目地址:
https://github.com/leobert-lan/ReportPrinter/



耗时2年,Android进阶三部曲第三部《Android进阶指北》出版!

『BATcoder』做了多年安卓还没编译过源码?一个视频带你玩转!

『BATcoder』我去!安装Ubuntu还有坑?

重生!进阶三部曲第一部《Android进阶之光》第2版 出版!

为了防止失联,欢迎关注我的小号

  微信改了推送机制,真爱请星标本公号👇
good-icon 0
favorite-icon 0
收藏
回复数量: 0
    暂无评论~~
    Ctrl+Enter