STNodeEditor - 将所有功能节点化
你有没有想过你的流程图是可执行的?
- 从GitHub下载代码: https://github.com/DebugST/STNodeEditor
引言
那是一个冬天,作者在研究无线安全,使用了 GNURadio。那是作者第一次使用节点编辑器。
-> 什么?你说什么...这是什么东西?...这是搞什么鬼...
那是一个春天,作者也不知道为什么,过完春节,整个世界都变了。大家都居家隔离。无聊到极致的作者,学习了 Blender。那是作者第二次使用节点编辑器。
-> 哇...原来这个东西这么方便使用。
于是乎,作者的脑海里渐渐出现了一些想法,让作者想自己做一个。
那是一个夏天,作者也不知道为什么,又开始学习 Davinci。那是作者第三次使用节点编辑器。这次的使用,让作者对节点编辑器的喜爱翻倍。作者瞬间感觉,只要是一个可以模块化,流程化的程序,都可以节点化。
让你的函数像流程图一样
很多时候我们在做开发,会使用流程图,并通过代码去实现流程图上的函数和执行过程。
但是这会产生一个问题,我们的函数执行过程会硬编码到程序中,执行过程对用户来说是不可见的。而当我们需要修改执行过程的时候,不得不去重新修改代码。
STNodeEditor
就是来解决这些问题的。
此项目主页: https://debugst.github.io/STNodeEditor/index_en.html
可以看到,上面的图片包含了一个NodeEditor
,一个TreeView
,还有一个PropertyGrid
。它们组合成了一个完整的框架。
树视图
:- 你可以将你的函数写成一个节点,然后将节点添加到
TreeView
中。TreeView
中的节点可以直接拖拽到NodeEditor
中。
- 你可以将你的函数写成一个节点,然后将节点添加到
PropertyGrid
:- 也许你的节点需要一些属性,
PropertyGrid
可以像WinForm设计器一样,提供对节点属性的访问操作。
- 也许你的节点需要一些属性,
NodeEditor
:- Node Editor 通过连线将节点的功能组合起来。让你的函数执行过程可视化。
如何使用?
STNodeEditor
使用起来非常简单,你几乎不需要任何学习成本。首先,你需要知道如何创建一个节点。
你可以像使用WinForm一样,非常简单地创建一个节点
public class MyNode : STNode
{
public MyNode() { //same as [override void Oncreate(){}]
this.Title = "MyNode";
this.TitleColor = Color.FromArgb(200, Color.Goldenrod);
this.AutoSize = false;
this.Size = new Size(100, 100);
var ctrl = new STNodeControl();
ctrl.Text = "Button";
ctrl.Location = new Point(10, 10);
this.Controls.Add(ctrl);
ctrl.MouseClick += new MouseEventHandler(ctrl_MouseClick);
}
void ctrl_MouseClick(object sender, MouseEventArgs e) {
MessageBox.Show("MouseClick");
}
}
//Add it to NodeEditor
stNodeEditor.Nodes.Add(new MyNode());
你可以看到,它和开发一个WinForm程序几乎一样。唯一的区别是,STNode
目前不提供所见即所得的UI设计器。当然,STNode
需要的控件类型是STNodeControl
。
作为STNode
控件的基类,STNodeControl
有很多和System.Windows.Forms.Control
同名的属性和事件,让开发者可以像开发WinForm程序一样开发节点。
注意:在当前这个版本(2.0)中,没有提供可用的控件,只有STNodeControl
基类,需要开发者自行扩展。后续如果有可用的,作者会进行完善。
上面的例子只是为了让大家熟悉一下,因为他非常像WinForm。
一个节点最重要的功能就是数据的输入和输出。对于一个节点,有两个重要的属性,InputOptions
和OutOptions
,数据类型是STNodeOption
。
public class MyNode : STNode
{
protected override void OnCreate() {//same as [public MyNode(){}]
base.OnCreate();
this.Title = "TestNode";
//Get the index that added
int nIndex = this.InputOptions.Add(new STNodeOption("IN_1", typeof(string), false));
//Get the STNodeOption that added
STNodeOption op = this.InputOptions.Add("IN_2", typeof(int), true);
this.OutputOptions.Add("OUT", typeof(string), false);
}
//Occurs when the owner changes, submit the color
//Color is used to distinguish between different data types.
//The different data types cannot be connected.
protected override void OnOwnerChanged() {
base.OnOwnerChanged();
if (this.Owner == null) return;
this.Owner.SetTypeColor(typeof(string), Color.Yellow);
//will replace old color
this.Owner.SetTypeColor(typeof(int), Color.DodgerBlue, true);
//The highest priority, the color information in the container will be ignored
//this.SetOptionDotColor(op, Color.Red); //Not need set it on OnOwnerChanged()
}
}
现在可以看到,节点拥有一个可以用来连接的点。但是目前,连接点还没有任何功能。
从上面的案例可以看出,STNodeOption
是STNode
的连接选项。连接选项可以有多连接和单连接模式。
public class MyNode : STNode {
protected override void OnCreate() {
base.OnCreate();
this.Title = "MyNode";
this.TitleColor = Color.FromArgb(200, Color.Goldenrod);
//multi-connection
this.InputOptions.Add("Single", typeof(string), true);
//single-connection
this.OutputOptions.Add("Multi", typeof(string), false);
}
}
- 多连接模式
- 一个选项可以连接多个相同数据类型的选项(矩形)
- 单连接模式
- 一个选项只能连接一个相同数据类型的选项(圆形)
如何与数据交互?
STNodeOption
通过绑定DataTransfer
事件,可以获取该选项的所有输入数据。
STNodeOption.TransferData(object)
函数可以给该选项上的所有连接传递数据。
我们来创建两个节点演示一下
创建一个节点,该节点的功能是每秒钟输出当前系统时间。
public class ClockNode : STNode
{
private Thread m_thread;
private STNodeOption m_op_out_time;
protected override void OnCreate() {
base.OnCreate();
this.Title = "ClockNode";
m_op_out_time = this.OutputOptions.Add("Time", typeof(DateTime), false);
}
//when the owner changed
protected override void OnOwnerChanged() {
base.OnOwnerChanged();
if (this.Owner == null) { //when the owner is null abort thread
if (m_thread != null) m_thread.Abort();
return;
}
this.Owner.SetTypeColor(typeof(DateTime), Color.DarkCyan);
m_thread = new Thread(() => {
while (true) {
Thread.Sleep(1000);
//STNodeOption.TransferData(object) will set STNodeOption.Data automatically
//and automatically post data to all connections on the option
m_op_out_time.TransferData(DateTime.Now);
//if you need to operate across UI threads in a thread,
// the node provides Begin/Invoke() to complete the operation.
//this.BeginInvoke(new MethodInvoker(() => {
// m_op_out_time.TransferData(DateTime.Now);
//}));
}
}) { IsBackground = true };
m_thread.Start();
}
}
当然,上面这个节点的时间我们可以直接显示,但是为了演示数据传递,我们还需要一个节点来接收数据。
public class ShowClockNode : STNode {
private STNodeOption m_op_time_in;
protected override void OnCreate() {
base.OnCreate();
this.Title = "ShowTime";
//use "single-connection" model
m_op_time_in = this.InputOptions.Add("--", typeof(DateTime), true);
//This event is triggered when data is transferred to m_op_time_in
m_op_time_in.DataTransfer += new STNodeOptionEventHandler(op_DataTransfer);
}
void op_DataTransfer(object sender, STNodeOptionEventArgs e) {
//This event is not only triggered when there is data coming in.
//This event is also triggered when there is a connection or disconnection,
//so you need to check the connection status.
if (e.Status != ConnectionStatus.Connected || e.TargetOption.Data == null) {
//When STNode.AutoSize=true, it is not recommended to use STNode.SetOptionText
//the STNode will recalculate the layout every time the Text changes.
//It should be displayed by adding controls.
//Since STNodeControl has not yet been mentioned,
//the current design will be used for now.
this.SetOptionText(m_op_time_in, "--");
} else {
this.SetOptionText(m_op_time_in, ((DateTime)e.TargetOption.Data).ToString());
}
}
}
可以看到,ShowClockNode
每秒刷新着。
上面的例子是一个比较复杂的例子,这里就不给出代码了。
点击Open Image按钮,选择一张图片,并展示在节点中。在ImageSizeNode
连接之后,图片的大小会被显示出来。
对于ImageShowNode
,它只是提供了数据源,并进行显示。而ImageSizeNode
和ImageChannel
节点,它们不知道会连接什么样的节点,它们只是完成自己的功能,并将结果打包到输出选项,等待下一个节点连接。
执行逻辑完全由用户自己将功能连接起来。在开发的时候,节点与节点之间没有任何交互。唯一将它们绑定的就是Image
数据类型,所以节点与节点之间没有耦合关系。高内聚低耦合。
更多教程,请参考此链接。
关于未来
当前框架并不完善,只提供了一些非常基础的功能,还有很多功能待实现。作者将在后续版本中持续更新。比如提供一些常用的节点控件,以及一套可执行框架,让开发者只需要提供包含STNode
的DLL文件。像这张图一样
这是作者最初的想法和第一个Demo。开发者只需要提供包含STNode
的DLL文件。程序会自动加载到TreeView
中,用户只需要将Node
拖拽到NodeEditor
中,就可以组合逻辑和执行流程。
你可以看到上面有一个Start按钮,是的,在一些应用场景下,开发者希望用户在处理完逻辑之后,点击Start按钮来启动执行过程。
在当前版本中也可以实现,但是需要你自己去实现。首先,你需要定义一个规则,例如,每个节点必须包含Start
和Stop
函数。或者只需要提供数据输入的节点,也必须包含Start
和Stop
函数。
例如
//define the BaseNode
public abstract class BaseNode : STNode
{
public abstract void Start();
public abstract void Stop();
}
//===================================================================
//InputNode is the starting node and provides data input entry.
public abstract class InputNode : BaseNode { }
//OutputNode is the ending node and process the result data
//such as save data to file or database .etc
public abstract class OutputNode : BaseNode { }
//other function node
public abstract class ExecNode : BaseNode { }
//===================================================================
//create a TestInputNode to provides a string to start
public class TestInputNode : InputNode
{
//Use "STNodeProperty" will be display in "STNodePropertyGrid"
[STNodeProperty("NameInPropertyGrid", "Description")]
public string TestText { get; set; }
private STNodeOption m_op_out;
protected override void OnCreate() {
base.OnCreate();
this.Title = "StringInput";
m_op_out = this.OutputOptions.Add("OutputString", typeof(string), false);
}
public override void Start() {
//When user click the "Start" transfer the data to all the connected nodes
m_op_out.TransferData(this.TestText);
this.LockOption = true;//Lock all the options
}
public override void Stop() {
this.LockOption = false;//unlock all the options
}
}
//===================================================================
//create a TextFileOutputNode to save text to file
public class TextFileOutputNode : OutputNode
{
[STNodeProperty("FileName", "Description")]
public string FileName { get; set; }
private StreamWriter m_writer;
protected override void OnCreate() {
base.OnCreate();
this.InputOptions.Add("Text", typeof(string), false)
.DataTransfer += new STNodeOptionEventHandler(op_DataTransfer);
}
void op_DataTransfer(object sender, STNodeOptionEventArgs e) {
if (e.Status != ConnectionStatus.Connected) return;
if (e.TargetOption.Data == null) return;
if (m_writer == null) return;
//When get a text write it to file
lock (m_writer) m_writer.WriteLine(e.TargetOption.Data.ToString());
}
public override void Start() {
m_writer = new StreamWriter(this.FileName, false, Encoding.UTF8);
this.LockOption = true;
}
public override void Stop() {
this.LockOption = false;
if (m_writer == null) return;
m_writer.Close();
m_writer = null;
}
}
而当Start按钮被点击的时候
public void OnClickStart() {
List<InputNode> lst_input = new List<InputNode>();
List<OutputNode> lst_output = new List<OutputNode>();
List<BaseNode> lst_other = new List<BaseNode>();
foreach (var v in stNodeEditor.Nodes) {
if ((v is BaseNode)) continue;
if (v is InputNode) {
lst_input.Add((InputNode)v);
} else if (v is OutputNode) {
lst_output.Add((OutputNode)v);
} else {
lst_other.Add((BaseNode)v);
}
}
//before the start you should check something.
if (lst_output.Count == 0)
throw new Exception("Can not found [OutputNode] please add it.");
if (lst_input.Count == 0)
throw new Exception("Can not found [InputNode] please add it.");
foreach (var v in lst_other) v.Start();
foreach (var v in lst_output) v.Start();
//The InputNode should be Start at the last.
foreach (var v in lst_input) v.Start();
stNodePropertyGrid1.ReadOnlyModel = true;//not forget this
}
如果你想要只添加一个InputNode
stNodeEditor.NodeAdded += new STNodeEditorEventHandler(stNodeEditor_NodeAdded);
void stNodeEditor_NodeAdded(object sender, STNodeEditorEventArgs e) {
int nCounter = 0;
foreach (var v in stNodeEditor.Nodes) {
if (v is InputNode) nCounter++;
}
if (nCounter > 1) {
System.Windows.Forms.MessageBox.Show("Only one InputNode can be added");
stNodeEditor.Nodes.Remove(e.Node);
}
}
但是我觉得没有人有这种需求吧?
当然,上面的代码没有任何异常处理,只是给你展示如何实现逻辑。实际上,要想做一个通用的框架,还需要补充和改进大量的代码,所以作者打算在后续版本中完成。上面的代码只是给你展示,如果你有类似上面的需求,如何自己实现。
关注点
当有很多应用程序(模块)的时候,它们需要互相调用,传递数据,来完成一套流程。
开发一个单一功能的应用程序(模块)很容易,但是开发一个互相调用,有很多功能的完整应用程序集,却很繁琐。
使用此框架的开发者,只需要定义好数据类型,然后开发单一功能的节点,至于执行过程,就交给框架和用户的连接吧。
更多信息,请参考https://debugst.github.io/STNodeEditor/index_en.html。
如果你觉得STNodeEditor
对你有帮助,请推荐给你的朋友,并给它点个赞。
感谢阅读!
历史
- 2021年5月19日:初始版本