65.9K
CodeProject 正在变化。 阅读更多。
Home

STNodeEditor - 将所有功能节点化

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.84/5 (7投票s)

2021年5月20日

MIT

6分钟阅读

viewsIcon

16155

你有没有想过你的流程图是可执行的?

引言

那是一个冬天,作者在研究无线安全,使用了 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。
一个节点最重要的功能就是数据的输入和输出。对于一个节点,有两个重要的属性,InputOptionsOutOptions,数据类型是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()
    }
}

现在可以看到,节点拥有一个可以用来连接的点。但是目前,连接点还没有任何功能。

从上面的案例可以看出,STNodeOptionSTNode的连接选项。连接选项可以有多连接单连接模式。

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每秒刷新着。

ImageNode

上面的例子是一个比较复杂的例子,这里就不给出代码了。

点击Open Image按钮,选择一张图片,并展示在节点中。在ImageSizeNode连接之后,图片的大小会被显示出来。

对于ImageShowNode,它只是提供了数据源,并进行显示。而ImageSizeNodeImageChannel节点,它们不知道会连接什么样的节点,它们只是完成自己的功能,并将结果打包到输出选项,等待下一个节点连接。

执行逻辑完全由用户自己将功能连接起来。在开发的时候,节点与节点之间没有任何交互。唯一将它们绑定的就是Image数据类型,所以节点与节点之间没有耦合关系。高内聚低耦合。

更多教程,请参考此链接

关于未来

当前框架并不完善,只提供了一些非常基础的功能,还有很多功能待实现。作者将在后续版本中持续更新。比如提供一些常用的节点控件,以及一套可执行框架,让开发者只需要提供包含STNodeDLL文件。像这张图一样

这是作者最初的想法和第一个Demo。开发者只需要提供包含STNode的DLL文件。程序会自动加载到TreeView中,用户只需要将Node拖拽到NodeEditor中,就可以组合逻辑和执行流程。

你可以看到上面有一个Start按钮,是的,在一些应用场景下,开发者希望用户在处理完逻辑之后,点击Start按钮来启动执行过程。

在当前版本中也可以实现,但是需要你自己去实现。首先,你需要定义一个规则,例如,每个节点必须包含StartStop函数。或者只需要提供数据输入的节点,也必须包含StartStop函数。

例如

//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日:初始版本
© . All rights reserved.