[转]Swift自动生成UML类图

1. 方案调研

swift-syntax

github - https://github.com/apple/swift-syntax

这是苹果官方的工具,Xcode工具包里有,可以扫描Swift源文件,生成AST树(抽象语法树)
用法也很简单,命令行输入

xcrun swiftc -frontend -emit-syntax ./a.swift | python3 -m json.tool

a.swift是创建的测试文件

class Demo {

    var a;

    func foo() {

    }
}

生成如下

{
    "kind": "SourceFile",
    "layout": [
        {
            "kind": "CodeBlockItemList",
            "layout": [
                {
                    "kind": "CodeBlockItem",
                    "layout": [
                        {
                            "kind": "ClassDecl",
                            "layout": [
                                null,
                                null,
                                {
                                    "tokenKind": {
                                        "kind": "kw_class"
                                    },
                                    "leadingTrivia": "",
                                    "trailingTrivia": " ",
                                    "presence": "Present"
                                },
                                {
                                    "tokenKind": {
                                        "kind": "identifier",
                                        "text": "Demo"
                                    },
                                    "leadingTrivia": "",
                                    "trailingTrivia": " ",
                                    "presence": "Present"
                                }, // 下面省略几百行

可以看到,AST树的嵌套层级非常深,即使代码非常简单,结果也很复杂。因为它是官方工具,要非常完备地分析文件,但是对于UML类图来说并不需要。

swift-ast-explorer

github - https://github.com/SwiftFiddle/swift-ast-explorer
测试地址 - https://swift-ast-explorer.com/

这是把swift代码生成AST树的网页,也是用swift-syntax生成的

image.png

swift-auto-diagram

github - https://github.com/yoshimkd/swift-auto-diagram

它可以把目录下的swift文件生成UML类图,自动生成一个网页,可以缩放拖动。只需要输入一句命令行(YourSwiftDir代表包含swift文件的目录)

ruby generateEntityDiagram.rb ~/YourSwiftDir                     

image.png

它没有用AST,而是直接用正则表达式扫描分析文件中的类、对象和方法,优点是速度快,缺点是生成结果摆放非常乱,类型关系基本找不到。但是它使用的vis-network网页绘图方案很不错,支持任意摆放、拖动、缩放和添加箭头。

SourceKitten

github - https://github.com/jpsim/SourceKitten

Sourcekitten 是基于 Apple 的 SourceKit 封装的命令行工具,SourceKitten 链接并与 sourcekitd.framework 通信以解析 Swift AST 树,最终提取 Swift 或 ObjC 文件的类结构和方法等。

🔥Drafter

Drafter是一个命令行工具,用于分析iOS工程的代码,支持Objective-C和Swift。支持自动解析代码并生成方法调用关系图,自动解析代码并生成类继承关系图。
这个工具对实现的完备性是比较不错的,另外开原代码的解析部分对我很有帮助。Github - Drafter

2. 实现方案

根据前面的研究,我采用Sourcekitten解析Swift文件,vis-network生成类图的方案。效果如下:

image.png
  • 右边是类图主体,包括类名和类里面所有的属性和方法。类之间的关系:继承、实现、关联和依赖分别用不用的线段和箭头表示。
  • 左边是文件夹的树状结构,可以点击隐藏和显示文件夹里的全部或单个文件,这样就不会因为类太多显示太乱。
  • 左上角是继承、实现、关联和依赖四种关系线段的开关,也可以点击隐藏或显示某种关系。

2.1 解析Swift文件

主要是用python遍历文件夹并调用'sourcekitten structure --file '解析文件,然后生成json文件,供网页读取。

遍历文件中的类,取出类名、父类名、属性类名和方法的参数返回值类名


def visitFile(path):
    structure = os.popen('sourcekitten structure --file ' + path).read()
    if printStructrue:
        print(structure)
    try:
        dict = json.loads(structure)
    except Exception as e:
        print('Exception in file ' + path)
        print(e)
        return

    for sub in dict['key.substructure']:
        kind = sub['key.kind']
        validKinds = ['source.lang.swift.decl.class', 'source.lang.swift.decl.struct', 'source.lang.swift.decl.protocol', 'source.lang.swift.decl.enum']
        if kind not in validKinds:
            continue

        varDetails = []
        funcDetails = []

        print('-visit: ' + name)
        if 'key.inheritedtypes' in sub: # ParentClass and protocols
            i = 0
            for s in sub['key.inheritedtypes']:
                i += 1
                name = s['key.name'] # ParentClass
                if i == 1:
                    j = name.find('<') # ParentClass<T1, T2>
                    if j != -1:
                        arr = name[j+1:-1].split(', ')
                        for a in arr:
                            variables.append(a)
                        name = name[:j]
                    parents.append(name)
                else:
                    protocols.append(s['key.name'])

        if 'key.substructure' in sub: # class members
            for s in sub['key.substructure']:
                if s['key.kind'].startswith('source.lang.swift.decl.var'): # .instance/.static/.class
                    type = s['key.kind'].split('.')[-1]
                    type = type if type != 'instance' else ''
                    typename = '' if 'key.typename' not in s else s['key.typename']
                    if typename != '':
                        if type == '':
                            variables.append(typename)
                        else:
                            temporaries.append(typename)
                    s1 = '' if typename == '' else ': ' + typename
                    s2 = '' if type == '' else ' (' + type + ')'
                    varDetails.append('- ' + s['key.name'] + s1 + s2)
                if s['key.kind'] == 'source.lang.swift.expr.call':
                    name = s['key.name']
                    i = name.find('.')
                    if i != -1: # MyClass.staticFunc
                        name = name[:i]
                    variables.append(name)
                if s['key.kind'].startswith('source.lang.swift.decl.function.method'): # .instance/.static/.class
                    type = s['key.kind'].split('.')[-1]
                    type = type if type != 'instance' else ''
                    typename = '' if 'key.typename' not in s else s['key.typename']
                    if typename != '':
                        temporaries.append(typename)

                    s1 = '' if typename == '' else ': ' + typename
                    s2 = '' if type == '' else ' (' + type + ')'
                    funcDetails.append('+ ' + s['key.name'] + s1 + s2)
                    visitMethod(s, temporaries)
                if s['key.kind'] == 'source.lang.swift.decl.enumcase':
                    varDetails.append('.' + s['key.substructure'][0]['key.name'])

        s1 = '\n'.join(varDetails)
        s2 = '\n'.join(funcDetails)
        data['detail'] = '\n-------------------------\n'.join([data['name'], s1, s2])

递归遍历方法,取出方法里使用到的局部变量的类名

def visitMethod(sub, temporaries):
    if 'key.substructure' in sub:
        for s in sub['key.substructure']:
            if s['key.kind'] == 'source.lang.swift.decl.var.parameter':
                if 'key.typename' in s:
                    temporaries.append(s['key.typename'])
            if s['key.kind'] == 'source.lang.swift.decl.var.local':
                if 'key.typename' in s:
                    temporaries.append(s['key.typename'])
            if s['key.kind'] == 'source.lang.swift.expr.call':
                    name = s['key.name']
                    i = name.find('.')
                    if i != -1: # MyClass.staticFunc
                        name = name[:i]
                    temporaries.append(name)
            visitMethod(s, temporaries)

打开浏览器,启动一个简单的服务器(因为要访问json文件,所以要访问服务器的网页。我开发用的是VSCode和Live Preview插件,非常方便)。

        webbrowser.open('http://localhost:8080/diagram.html')
        os.system('python3 -m http.server 8080')

2.2 网页展示

用JavaScript读取json文件,生成vis-network需要的点node和线段edge就可以展示了。

读取json文件

function readTextFile(path) {
    var rawFile = new XMLHttpRequest();
    rawFile.open("GET", path, false);
    rawFile.onreadystatechange = function ()
    {
        if(rawFile.readyState === 4)
        {
            if(rawFile.status === 200 || rawFile.status == 0)
            {
                var allText = rawFile.responseText;
                handleJsonStr(path, allText);
            }
        }
    }
    rawFile.send(null);
}

生成目录树

function generateTree(obj) {
    if (typeof obj == 'string') {
        return `<div id='file'>${obj}</div>`
    } else {
        var str = `<div id='dir'>${obj['name']}</div><ul>`
        for (var o of obj['list']) {
            str += '<li>' + generateTree(o) + '</li>'
        }
        return str + '</ul>'
    }
}

生成类图


function handleDataJson(dataArr) {
    var nodeArr = []
    var edgeArr = []

    var nodeTypes = ['class','struct','protocol','enum']
    var edgeTypes = ['parents','protocols','variables','temporaries']

    generateCheck(edgeTypes)

    var nameIdDict = {}
    for (var data of dataArr) {
        nameIdDict[data['name']] = data['id']
    }

    for (var data of dataArr) {
        var node = createNode(data['id'], data['detail'], data['kind'], data['file'])
        nodeArr.push(node)

        var from = data['id']
        for (var type of edgeTypes) {
            for (var to of data[type]) {
                to = nameIdDict[to]
                if (to != undefined) {
                    var edge = createEdge(from, to, type)
                    edgeArr.push(edge)
                }
            }
        }
    }

    let nodes = new vis.DataSet(nodeArr)
    let edges = new vis.DataSet(edgeArr)

    const nodesFilter = (node) => {
        if (hideFiles[node.file] == true) {
            return false
        }
        return true;
    };

    const edgesFilter = (edge) => {
        if (hideEdges[edge.type] == true) {
            return false
        }
        return true;
    };

    nodesView = new vis.DataView(nodes, { filter: nodesFilter });
    edgesView = new vis.DataView(edges, { filter: edgesFilter });

    // create a network
    var container = document.getElementById("mynetwork");
    var data = {
    nodes: nodesView,
    edges: edgesView,
    };
    var options = {
        physics: createPhysicsConfig(),

        // layout: {
        //     hierarchical: {
        //       direction: 'Up-Down',
        //     },
        // },
    };
    var network = new vis.Network(container, data, options);
}

3 代码和运行

完整代码都在github仓库

运行方法:下载或克隆仓库,进入主目录,运行命令即可
python3 runSwift.py /YourSwiftProjectDir

调式方法:用VSCode(安装Python插件和Live Preview插件)打开项目,运行runSwift.pypython文件即可。

注意:运行需要Xcode环境,并且用brew安装好sourcekitten

引用

Swift自动生成UML类图

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 196,099评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,473评论 2 373
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 143,229评论 0 325
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,570评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,427评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,335评论 1 273
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,737评论 3 386
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,392评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,693评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,730评论 2 312
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,512评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,349评论 3 314
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,750评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,017评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,290评论 1 251
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,706评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,904评论 2 335

推荐阅读更多精彩内容