在wy的时候有推广过一个工具,是使用节点编辑器工具来完成一些程序化布景,称之为程序化工作流,不过那个工具主要是针对场编和美术策划相关的场景,而本人的工作是工具和自动化测试。当时就在想自动化测试应该是符合流水线一样执行的,能不能把两样东西结合起来,通过节点编辑器来完成自动化测试相关工作流,于是现在离职后就尝试实现一下
前言
节点编辑器已经是老东西了,网上各种实现五花八门,之前只接触过web前后端,本着学习和练手的目的,选择了客户端+qt(pyside,c++写不来)。由于本人没接触过qt,也是踩了很多坑。基于blenderfreak大佬的实现(https://gitlab.com/pavel.krupala/pyqt-node-editor)基础上进行开发
开贴记录一下,帖子里的内容并不能面面俱到,如果有不清楚的可以clone下面的仓库代码对着看。
github仓库地址
https://github.com/wjwABCDEFG/LineIt
画布/图元
这种可以自由绘制画板的方式,写起来和以往的一般的控件不同,需要补充一点前置知识,基于图元绘制主要是三个类:
QGraphicsScene:背景画布格子线条
QGraphicsView:背景画布绘制模式(抗锯齿,全量更新)、鼠标键盘事件处理(滚轮放大缩小,中键画布拖拽)
QGraphicsItem:绘制正方形/圆形/曲线等基本元素,用来完成node/edge/socket等设计
- 格子绘制
QGraphicsScene的drawBackground函数每次背景变动都会出发重新绘制,把当前的rect画满
1 | def drawBackground(self, painter, rect): |
- 节点图形
元素比较简单一个矩形,左右两边的插槽(socket)构成的node
QGraphicsItem的paint函数中绘制节点,分为上面的title和content两个矩形,并加上了圆角,这部分都是固定写法,需要注意的就是content的height实际上是从[title_height, height-title_height]
吐槽,虽然是固定写法,但是qt这个写起来也太麻烦了
ui和逻辑的互操架构
blenderfreak大佬的实现中给了一个设计图,这也是我看了几个节点编辑器后觉得这个最好的原因,总体就是这几个部分:
Scene的QWidget类保存了scene的QGraphicsView对象
Node的QWidget类保存了node和content的QGraphicsItem对象
Edge的QWidget类保存了edge的QGraphicsItem对象
Socket的QWidget类保存了socket的QGraphicsItem对象
同时,又在Graph的构造中传入了Widget对象,互相保存,在下面的节点设计中展示了具体的伪代码,总之有点类似安卓的context
ui部分就到这里,qt的很多代码看起来很繁杂,但都是固定写法,比如图元绘制的addRoundedRect/addRect/setPen/setBrush/drawPath,但归根到底都是些api的调用
对于节点编辑器,后续的socket逻辑才是大头
节点的设计
基本结构
ui和逻辑分离,ui部分使用qt的QGraphicsItem绘制,逻辑在Node类中
为了方便互操,ui对象作为Node的成员,同时构造的时候会将本对象self传入ui对象中作为成员(这个操作类似android经常会将activity的this传入fragment中, 不过安卓中形参称之为context上下文,这里没有过多专业的名词,不过本质上是一样的)
1 | class QDMGraphicsNode(QGraphicsItem): |
和socket/edge关联
socket就是node左右两边的连接点,node里面保存了inputs和outputs节点,保存了所有的socket,这是整个编辑器最复杂的地方,因为socket实际上是连接node和edge的媒介,设计时要考虑的东西很多,但我并不想在这里单独开一章,原来大佬的设计很精彩,但我这里的重点是整体的功能
socket的属性包括:
- node:所在node的引用,和之前说的一样,类似context互操
- edges: 连接的所有edge,注意,input socket只能连接一根线,但是output socket可以连接多根线
- index:第几个socket,同一侧可能有多个,用于绘制时计算位置和序列化时使用
- position:即使在同一侧,也可以选择左上中下,右上中下几种,也是用于绘制
- socket_type:这部分我认为原本的设计存在不足,代码里写的Constant defining type(color) of this socket,也就是说只是一个color,我估计原本作者的想法应该是像unity shader或者ue蓝图一样,根据输出的值类型变化颜色,让用户知道什么output能连接什么input,不允许乱连接,但由于作者最后是做了一个计算器,所以都是int没有细化这部分
- is_input:通过这个来区分是输入还是输出
edge的属性包括:
- start_socket和end_socket
- edge_type:贝塞尔曲线/直线
当一个节点要找前面/后面节点的时候,实际上的链路为:node -> (for)output_socket -> (for)edge -> end_socket -> next_node
动态注册
为了方便用户添加自定义节点,需要动态注册的方式。后续如使用pyinstaller打包时,也可以通过配置.spec文件跳过对nodes文件夹下的打包,以方便用户自定义和参考原有node
- 通过@register_node(“DIV”)装饰器把这个class添加到字典CALC_NODES = {}中保存动态,所有需要添加的节点都可以通过这个装饰器添加进节点编辑器中,方便用户自定义和扩展节点
- 使用
__all__
注册整个包下的py文件,后续打包时可以屏蔽这个文件夹,用户便可以在这个文件夹下自由的编写自定义节点
计算节点
- 如四则运算节点,父节点CalcNode抽象出一个evalImplementation/evalOperation方法,子节点类都需要实现
数据存储:每个节点有一个value字段用于存储,当想要计算时,通过getInput(idx)方法从指定的输入端口(inputs[idx])找到对应的上游节点拿其value,完成计算后也要将结果放置在当前节点的value中
向后计算:向后计算时,对着当前节点点击eval,会调用eval后,调用evalChildren遍历当前节点所有output,找到其下一层子节点并调用其eval方法,需要注意的是整个计算顺序应该是dfs
1 | def evalChildren(self): |
- 向前计算:向前计算时,比如output,对着output点击eval,往前找所有的input,并调用其eval函数,可以自动向前完成计算,其过程也是dfs
1 | def evalImplementation(self): |
序列化/反序列化
要实现graph的持久化保存,或数据传输,甚至是redo/undo功能,都必须完成序列化和反序列化工作,通过字符串完整表达graph中的数据
serialize
需要序列化的东西包括如下几个部分:
场景:id、画布的大小
节点(列表):节点id、标题、位置、inputs输入点列表(id、idx、位置、类型)、outputs输出点列表(id、idx、位置、类型)、内容
边(列表):边的id、起点和终点的节点id
因此整个序列化和反序列化过程是一个自顶向下的结构,完成如上所述的整个场景的序列化,实际上是分别对node和edge进行序列化,最终返回一个dict方便后续的json直接保存成字符串:
1 | # 场景序列化伪代码 |
node和edge的serialize实现也是按上面我们所说的保存相应的信息到dict
当然,上述伪代码写的是dict,最终的实现是OrderedDict,保证了每次保存数据key的顺序是一样的
deserialize
反序列化即是根据dict信息构造出整个画布,deserialize和serialize结构类似,也是分别对node和edge进行反序列化,反序列化的过程实际上就是根据信息创建node和edge对象的过程。但是需要注意以下关键点
- id的存储,按理来说,即使没有id也可以完全复刻一个长得一模一样的场景,为什么还要记录id呢?因为创建出来的node只是位置和内容相同并非原来的node(内存地址不同),而边的序列化信息又需要记录起点和终点是哪个node的哪个socket,我们没有办法在同样的内存地址中创建对象,因此这个id只能当成成员变量记录起来
1 | # deserialize伪代码 |
这样一来,虽然新旧节点内存地址不一样,但self.id是一样的,在edge反序列化的时候就可以通过起点终点id来找到是哪个node的哪个socket了
- 注意到形参这里有个hashmap,算是一个优化点,node和socket反序列化时就加入{id: obj}进hashmap,这样edge序列化的时候就可以直接通过id寻找socket了不需要遍历查找
undo/redo功能
这里的一个误区在于:我们首先会想到一个操作栈history_stack,把所有的操作都入栈,看起来undo就是弹出过程,redo就是栈恢复的过程。但这样做从实现的角度是非常复杂的
实际上,设计应该如下:
- 我们并不会用到栈stack这种存储结构,因为在多次undo后每次弹出的内容还不能抛弃,因为可能又多次redo,因此如果使用stack实现,需要用另一个栈来存储,因此我们history_stack实际上采用的是数组队列+下标指针
- 存储的不应该是操作,因为操作需要正向和反向,比如undo删除对应redo创建,由于操作非常多,这是不合理的。因此实际上history_stack是存储整个面板的serialize数据,整个过程为:“变动” -> store(serialized_data) -> undo/redo -> restore(serialized_data),这样undo和redo实际上都是重绘整个画布,只是在移动cur_idx指针,逻辑统一实现简单
- “变动”包括:节点(新建/移动/删除),边(新建/删除),选中和取消选中
- 刚开始创建/打开节点文件的时候要记录一次
- 当undo几次后,如果不是redo而是storeHistory插入,那么就会丢弃原本可以redo的部分,就像丫字形,视为开始新的一条岔路
剪贴板功能
利用QApplication.instance().clip实现,在复制(ctrl+c)和剪贴(ctrl+x)的时候序列化并存入剪贴板,粘贴(ctrl+v)的时候反序列化
注意:
粘贴的时候,需要给node、socket和edge添加一个新的id,否则id重复。由于序列化/反序列化所有节点都存储于一个hashmap中,会导致key重复
剪切(删除)节点后,如果剪贴板的边有未连接的节点,需要剔除,如果view中剩余的边有未连接的节点,也需要剔除
MDI窗口
Multiple Document Interface多文档界面,即一个窗口下通过tab分割多个子窗口,文档型工具通常不会只打开一个文件,如下所示
可以极大的提高生产力和便利性,据说是由微软提出的用于excel多表编辑,但不知道为什么后来的excel弃用了,多表采用多窗口
mdi窗口现在有大量通用的参考,QT也有预制的实现QMdiArea,关键点:
- 原来QMainWindow.CentralWidget = QWidget对象,现在中间添加了一层QMdiArea,变为QMainWindow.CentralWidget = QMdiArea对象,然后通过mdiArea.addSubWindow(QWidget)添加每个tab项里的QWidget对象
- 几个api并不复杂,关键点在于每次操作里面的widget的时候,记得先通过mdiArea.activeSubWindow().widget()来获取当前的widget
- 这也侧面说明了为什么history_stack要放在Widget而不是window中的原因,因为每个graph的history_stack是独立的,同理保存等操作也是独立的,记得先获取当前激活的窗口
Details面板
某一个节点可能有需要配置的东西,不好直接在一个节点的content里展示,显得非常凌乱,所以在右侧加入了details面板,这样方便用户配置自己的属性
考虑到用户可能会自定义节点,因此在node_base中也暴露了detailsInfo:属性, 类型为List[QWidget],自定义节点时,可以通过往该属性中不断append widget,即可在Details面板中看到需要的属性
需要注意的是,这个面板里的参数如果需要保存,应该加入到序列化的value中
开关
在自动化测试中,根据以往的经验,有几次可能要执行某一段代码,而有几次又要不执行某一段代码。因此为每个节点设计了开关,如果关闭,则此次执行跳过该节点
eval执行流程设计
考虑自动化需求,重新实现了计算流程,a -> b -> c -> d,有可能需要先执行到c出错了,此时希望不重复执行ab,而是从c开始继续执行,由于前面已经实现了前向计算和后向计算的基本操作,这里考虑如下设计方案
方案一(向后执行):
将c mark dirty,会把后续的子节点都mark dirty,对着c或者b进行eval向后执行
方案二(向前执行):
将c mark dirty,会把后续的子节点都mark dirty,对着d进行eval向前执行,此方案会导致一个问题,如果c出现了分叉c->d同时还有c->e,没法执行到c->e
方案三(向前执行修正):
将c mark dirty,会把后续的子节点都mark dirty,对着d进行eval,不要向前执行而是改为往前找到已经ok的节点b,再从该节点开始向后执行
暂时是方案二啊,我意识到有问题,后面再改
方案一和方案三相比,方案三更加符合正常人的操作习惯,因为连接完节点后习惯于从最后的节点开始点击执行
Store节点
在之前使用节点编辑器的时候就发现了一个问题,对于正常编写程序,逻辑本就不是像流水线一样线性的,难免存在如下代码:
1 | a = 1 |
如果a b c分别在不同的节点,a -> b ->c
那么c想使用a的时候,就得把a先传给b,再把b传给c
在以前工作时使用的节点编辑器中,爷爷节点的数据需要一路传下来,链路上保存的数据就越来越多,而且每次都得往下传!!(即使这个节点编辑器的数据是不在链路上而在节点中,但依然存在这个问题),虽然自动化测试更加接近流水线一点,但使用历史数据问题肯定是在所难免的,因此要解决这个设计缺陷
Store节点,本质上是往一个空的global_data存东西,这里用的是模块,有点数据总线的味道~
由于output socket本身就可以连接多根线,所以不用担心从哪个socket出来的问题,在LineIt中,本身output大概率就只有一个socket
接口类型颜色
也许在上一个节点value的类型转换成颜色值,下一个的getInput里面验证,不一样类型的就不允许连接
TODO,暂时搁置,很多连接点其实是没有类型传输给下个节点的,不确定这种功能是否会导致操作不便,需要再仔细考量
动态增加ouput数量
TODO,在LineIt中,本身output大概率就只有一个socket,所以可能没多大用处,暂时搁置
节点列表
既然是做自动化了,那就尝试性的把一些功能封装成节点吧
移动设备操作类
这里要注意尽量兼容多平台,主流就是android/iOS,android使用adb,iOS使用tidevice/wda,相当于做了之前airtest的一些工作,但是这样更加原生一点,也避免引入了很多不必要的东西
Nodes | 作用 | 设计 |
---|---|---|
设备列表 | 显示通过usb连接的android和iOS设备 | 开两个线程,分别通过adb和tidevice检测设备连接情况,然后emit到该node更新信息即可 |
打开应用 | 打开app | / |
截屏 | 截屏 | / |
性能数据 | 监测性能数据 | 单独开个widget对每台已连接设备展示性能数据,2s一次即可 |
安装应用 | / | / |
TODO模拟操作 | 单机/双击/滑动? | 暂时不做,如果做了感觉有点像appium/airtest了?或者后面直接集成呢? |
https://blog.csdn.net/qq_27672101/article/details/143996095
https://zhuanlan.zhihu.com/p/3422441811
文件类
Nodes | 作用 | 设计 |
---|---|---|
文件列表 | 用于配合下面的节点批量操作 | |
重命名 | 搭配文件列表进行批量重命名 | windows的会变成a_1.png,a_1_1.png,不用我多说了吧 |
TODO复制 | 暂时不做,意义不大,如果后续有生成文件时再补充 | |
TODO移动 | 暂时不做,意义不大,如果后续有生成文件时再补充 | |
图像类
Nodes | 作用 | 设计 |
---|---|---|
TODO,后续搞点超分辨率或者stable diffusion相关的? | ||
TODO,也许针对图片后期处理这部分有得做,滤镜/对比度/高光/曝光色调这些 | ||
工具类
Nodes | 作用 | 设计 |
---|---|---|
等待 | sleep(s) | 有些时候打开应用这种操作,没有回调,就没办法知道打开没有的,sleep个几秒看看 |
store | 全局存储节点 | 用于非流水线式的变量取值 |
python | 有些人就说:哎呀我就不想打开破编辑器写自定义节点,我就想简单写两行验证一下 | 行,双击node还可以打开vscode |
可能存在的问题以及后续的规划
节点通用性问题
要思考封装的节点是否具有通用性,否则容易变成这一套节点只能用于这一套graph。为了避免出现这种问题,封装节点可以思考如下几点:
- 该节点是否能想到第二种连接方式搭配使用,如果是,则存在合理性
- 是否以“功能”/“操作”为单位,如果是,则存在合理性
- 本次编写的所有节点链路是否需要合并成一个节点,如果是,则不合理
避免在eval相关的函数中操作界面
并行化改造后,eval可能由于父节点不在同一线程,此时操作界面可能会导致界面无法更新或者无法显示的问题,并且这本来也是不好的习惯。如果要在eval中修改界面,请通过信号槽机制来修改。这点非常重要
并行化改造存在的问题
关于并行化改造,目前还存在问题,本来应该使用多进程multiprocess操作安卓设备的,在python中,创建多进程在linux下是使用fork,而在windows中使用的是pickle进行序列化+反序列化,但是pyqt/pyside中QObject实际上是c++对象,于是无法pickle会报错
这算是pyside的坑,暂时没有解决
pyside的坑(知乎),答主介绍了一种重写__repr__魔法方法使pickle序列化的解决方案,太深
for循环问题
这种情况在纯coding中肯定是存在的,而在节点编辑器中处理起来又很麻烦不优雅,目前市面上的节点编辑器也没有找到合适的方案,比如comfyui是单张图片的工作流而不是面向“批量化”处理,ShaderGraph是面对gpu的并行化操作本身没有for逻辑,UE蓝图倒是有WhileLoop/ForLoop节点,可以参考,但ue蓝图是面向程序的,参数给到了节点上,而LineIt是面向工作流的,需要重新设计
假设有如下操作
1 | for item in list: |
在没有for循环节点的情况下,变成了
1 | //node1 |
复杂度也会由O(n)变成O(3n)
参数节点化问题
后续可以做一个功能,允许把detailsInfo的参数变成输入节点,这样可能更加灵活一些
暂定的设计:右键节点->暴露参数->选择detailsInfo中的参数列表->扩展input节点
如果做这个的话,每个socket可能还要带一个label标签注明这个socket的作用