Writing nodes using C# / 用 C# 编写节点
首先你得选一个代码编辑器。
用 C# 给 VL 写自己的节点,不需要任何 VL 相关的知识或准备。本质上你写的就是普通的 C# 代码,VL 再把它变成节点。下面是一份带你上手的分步指南,也有对应的 vvvvTv 视频(英文)。
从模板开始

用内置的 C# 向导(5.0 版本起提供):
Quad→New→C# File- 选一个模板
- 默认会创建一个 .csproj 文件,名字取自你当前的主文档。如果这样的 .csproj 已经存在,就把这个 C# 文件加进去。这里假定的典型情形是:一个项目一个 .csproj 文件,下面可能挂着很多 .cs 文件
- 可选:想改掉这个默认行为,可以展开
Customize下拉:- 手动指定 .cs 文件的名字
- 在可能存在的多个 .csproj 文件里,选择把这个文件加到哪一个
- 取消勾选
Use Existing以新建一个 .csproj 文件
- 在
Open on Create下拉里你可以选:- 打开 .csproj:最好你装了 Visual Studio 2022 这样的 IDE,然后打开这个 .csproj 文件
- 打开 .cs 文件:如果你没装完整的 IDE,用任何文本编辑器编辑 .cs 文件也行
- 打开文件夹:如果你这会儿不想改文件,也可以只是让资源管理器打开、指到它所在的位置
- 按
Create- 这会在磁盘上创建这些文件,并把 .csproj 文件引用进你当前的主文档
在 Visual Studio 2022 里打开的 Static Utils 模板
一个新的 .csproj 文件第一次被创建时,你会看到它自动被引用进了你的当前文档,像这样:
一个 .vl 文档里引用的 .csproj 文件
如果你正在做的节点库将来要以 NuGet 的形式发布,就不要用引用 .csproj 文件这一招!那会强制整个包、以及所有依赖它的包变成可编辑的,于是你就失去了只读包带来的好处。
创建节点
打开节点浏览器,按名字找到你的 C# 文件里的方法和类。
比如 Utils 模板的代码,在 VL 里就会变成这样一个节点:
在 VL 里生成的节点
编译与热替换
每当你改动一个 .cs 文件并保存,就会触发一次代码编译,正在运行的代码会立刻被「热替换」。
静态方法
只要你用的都是静态方法,这套机制就毫无瑕疵 —— 静态方法可以在运行中被替换掉,不产生任何副作用。
如果你的 C# 代码里有错误,来自同一个项目的所有节点都会变红,提示框会指出这个项目有错误,并把你指向第一个错误所在的具体 .cs 文件和行号。

类
如果你处理的是带状态的代码,事情就要棘手一些。这里是两种典型情形:
过程节点
假如你想把自己的 C# 类当作 VL 里的过程节点来用 —— 也就是一个节点一个实例,不动态地生成/销毁实例 —— 那就给它加上 ProcessNode 特性。例子见下文。
这样一来,每当你改动 C# 代码,vvvv 都能按需正确地创建和销毁你这个类的实例。
动态实例
如果你的 C# 类更像是一个「粒子」—— 也就是说你会动态地生成和销毁实例 —— 那么上面提到的转发帮不了你,你仍会碰上销毁方面的麻烦。所以有件事你必须知道:
每一次保存 .cs 文件,你都会丢掉所有由 C# 代码定义的实例的运行状态!
只要你的 C# 代码是完全托管的,这就不算太大的问题。那些实例原先所在的位置,草图里会出现粉色节点并抛出空指针异常(“Object reference not set an instance of an object”),按 F9 重启草图就能回到运行状态。
一旦你的 C# 代码依赖非托管代码(比如 WinForms、设备库等等),事情就麻烦了 —— 那些资源需要手动释放。vvvv 并不知道这些资源的存在,因此没法正确清理它们!这种情况下,每次保存 .cs 文件都会留下没被释放的资源,往往导致不确定的行为(比如某个设备再也访问不了了)。碰上这种局面,只有彻底重启 vvvv 才能回到能用的状态!
调试
用 Visual Studio 编辑代码时,你可以在 C# 代码里设断点。如果断点显示「……当前不会命中」这类警告,你需要改一个 Visual Studio 的设置:在 “Debug” 菜单里选 “Options...”,找到并关掉 “Require source files to exactly match the original version”。
然后附加(英文)到 vvvv.exe 上,就能看到断点被命中了。
示例
这里是一些简单示例,外加几处能帮你写出自己节点的细节。它们也可以从这里获取: https://github.com/vvvv/VL.DemoLib
更多通盘的考量另见:设计指南
命名空间
你在 C# 里指定的命名空间,会成为 VL 里的目录。嵌套的命名空间(用点号语法)会相应地变成嵌套的目录。ImportAsIs 特性允许只导入某一个命名空间,从而把它从最终的 VL 目录里剥掉。
针脚名
为了在 VL 里更好读,运算器的参数会按驼峰式大小写拆开。所以 C# 里的 “firstInput” 到了 VL 里就是 “First Input”。默认的 “return” 返回值在 VL 里叫 “Output”。
public static float PinNames(float firstInput, float secondInput)
{
return firstInput + secondInput;
}
提示框里显示的针脚名
默认值
直接用 C# 的默认值写法,就能给 VL 里的输入定义默认值。
public static float Defaults(float firstInput = 44f, float secondInput = 0.44f)
{
return firstInput + secondInput;
}
输入上的默认值
多个输出
除了返回单个值,你也可以用一个甚至多个 out 参数,它们会在 VL 节点上显示为输出针脚:
public static void MultipleOutputs(float firstInput, float secondInput, out float added, out float multiplied)
{
added = firstInput + secondInput;
multiplied = firstInput * secondInput;
}
一个有多个输出的节点
函数重载
你可以写多个同名的运算器,它们只在输入参数的个数上有差别:
public static float MyAddition(float input, float input2)
{
return input + input2;
}
public static float MyAddition(float input, float input2, float input3)
{
return input + input2 + input3;
}
在节点浏览器里选中相应的节点时,它会再问你一次,让你指定想用哪个版本。
(上游此处待补图:节点浏览器里显示出两个节点)
使用枚举
你可以把自定义的 C# 枚举用作运算器的输入或输出类型:
public enum DemoEnum { Foo, Bar };
public static string StaticEnumDemo(DemoEnum e)
{
return e.ToString();
}
VL 草图里的枚举 IOBox
动态枚举(也就是条目会在运行时变化的枚举)的例子见下文。
使用 Generic
VL 拥抱 Generic,所以你当然可以轻松写出泛化的节点:
public static string Generic<T>(T input)
{
return input.ToString();
}
节点上引出的 Generic 针脚
对 Spread 做运算
C# 的 IEnumerable<> 在 VL 里表现为 Sequence<>:
public static IEnumerable<float> ReverseSequence(IEnumerable<float> input)
{
return input.Reverse();
}
Spread 节点
文档
用 C# 的 XML 文档来给你的节点提供说明:
- Summary:关于这个节点的一句话说明
- Remarks:一些补充说明,比如用法提示、注意事项等等,可以写多行
- Param name:每个输入的简短说明
- Returns:关于这个节点结果的简短说明
///<summary>Multiplies input by two</summary>
///<remarks>Some additional remarks</remarks>
///<param name="a">The A Parameter</param>
///<returns>Returns 2 times a</returns>
public static int HTMLDocuTest(int a)
{
return a*2;
}
文档出现在节点浏览器和提示框里
只有当项目把 GenerateDocumentationFile 属性设为 true 时,xml 文档才会被生成。vvvv 创建的 C# 项目默认就带这一项;如果你引用的是一个已有的项目,可能得自己加上!
C# 的 ref 参数
你可以用 C# 的 ref 参数,但要当心:给这个参数赋值会导致 VL 里出现不确定的行为(目前如此),所以永远不要写 ref 参数,只读它!
public static int RefParams(ref int firstInput)
{
return firstInput + 4444;
}
一个把 ref 参数用作输入的节点
数据类型
任何你在 C# 里定义为 class 或 struct 的数据类型,都能在 VL 里使用:
- 任何构造函数都会以一个
Create节点的形式提供 - 任何公开成员都会以一个 VL 节点的形式提供
- 一个属性最多会产生两个节点,一个取值、一个赋值
- 一个事件会被转换成一个同名节点,返回
IObservable<EventPattern<>>。详见下文。
public class MyDataType
{
private float FX;
public MyDataType(float x)
{
FX = x;
}
public float AddValue(float value)
{
var lastFX = FX;
FX += value;
return FX;
}
}
对应生成的节点
过程节点
给任何一个类加上 ProcessNode 特性,就能把它变成一个过程节点。默认情况下,它的所有公开成员都会被用作这个过程的片段。这个特性提供了多种方式来调整这一行为。
这个特性只有在程序集设置了 [assembly:ImportAsIs] 特性时才生效。vvvv 创建的 C# 项目已经设好了这个特性;如果你引用的是一个已有的项目,就得自己加上,见设置程序集特性(英文)。
[ProcessNode]
public class Counter
{
private int _value;
public int Update(int increment)
{
return _value += increment;
}
}
事件与 Observable
符合 .NET Core 事件模式(英文)的 .NET 事件,VL 会自动把它们转换成 Observable。所以你在代码里照常用事件就行,然后在 VL 里通过 Observable 模式来访问它们。
下面是不带事件参数和带事件参数的两个 C# 事件的例子:
public class MyDataType
{
public event EventHandler OnValueChanged;
public event EventHandler<MyGenericEventArgs<float>> OnValueExceeded;
...
}
public class MyGenericEventArgs<T> : EventArgs
{
public readonly T Value;
public MyGenericEventArgs(T value)
{
Value = value;
}
}
在你的代码里,它们可能这样被调用:
public float AddValue(float value)
{
if (value != 0)
{
FX += value;
OnValueChanged?.Invoke(this, EventArgs.Empty);
}
if (FX > FThreshold)
OnValueExceeded?.Invoke(this, new MyGenericEventArgs<float>(FX));
return FX;
}
在 VL 里,这些事件以同名节点的形式提供,返回一个 Observable<EventPattern<>>:
在 VL 里长这样:a) 成员运算器,b) 不带任何参数的 ValueChanged 事件,c) 带一个参数的 ValueExceeded 事件
-
如果你的事件不带任何参数(上图中的 b 部分),只是在某件事发生时发一个脉冲,那就用 HoldLatest [Reactive] 节点的
On Data输出来获知这个事件。 -
如果你的事件带参数(上图中的 c 部分),你会收到一个
Observable<EventPattern<MyGenericEventArgs<>>>,需要用 EventArgs [Reactive.EventPattern] 节点把它拆开 —— 这个节点由 VL.DevLib 包提供。拆开后你就能拿到 EventArgs 的 Sender 和 Value。
用 EventArgs 拆包
关于使用 Observable 的一般性说明,见响应式编程那一章。
动态枚举
动态枚举适用于这样的场景:你想给用户一个列表来挑选,而这个列表的条目可能在运行时变化。典型例子是那些访问硬件设备的节点 —— 设备随时可能插上或拔掉。
要写一个动态枚举,最好从某个 “Dynamic Enum” C# 文件模板开始。
如果你想更好地理解模板里的代码,接着往下读:
先看一个 C# 里普通的枚举:
enum MyEnum = { Foo, Bar }
这里 MyEnum 是我们所说的类型,而 { Foo, Bar } 构成了它的定义。
我们想在代码里使用这样一个枚举的方式,是把它作为某个运算器的输入参数的类型,像这样:
public static string EnumDemo(MyEnum e)
{
return e.ToString();
}
为 VL 实现动态枚举
要为 VL 造一个动态枚举,我们同样需要那两个要素:类型和定义。两者都得在 C# 里实现为类:
- 类型需要实现
IDynamicEnum - 定义需要实现
IDynamicEnumDefinition
两者都由 VL.Core 这个 NuGet 提供。
为了让它们更好用,还提供了几个基类实现:
VL.Lib.Collections.DynamicEnumBase<T, U>VL.Lib.Collections.DynamicEnumDefinitionBase<U>VL.Lib.Collections.ManualDynamicEnumDefinitionBase<U>
注意,这些定义基类是单例的 —— 也就是说它的实现会保证全局始终只存在一个实例。我们需要这样,因为有一点很重要:任何引用同一个枚举定义的节点,拿到的条目必须完全一致!
用上面这两个基类,你自己的动态枚举实现大概长这样:
1. 创建一个枚举类型
先从 DynamicEnumBase 派生,造出你自己的枚举类型。
[Serializable]
public class MyEnum: DynamicEnumBase<MyEnum, MyEnumDefinition>
{
public MyEnum(string value) : base(value)
{
}
[CreateDefault]
public static MyEnum CreateDefault()
{
//use method of base class if nothing special required
return CreateDefaultBase();
}
}
换成你自己的实现时,上面这段代码多半不需要改多少,除了:
- 给它起个像样的名字,别叫 “MyEnum”,比如叫 “MidiInputDevice”。注意名字用的是单数:这个类型代表枚举中的一个条目。
- 注意第二个类型参数
MyEnumDefinition,它把你的枚举与它的定义连起来,同理应该叫 “MidiInputDeviceDefinition”
2. 提供可选条目
从 DynamicEnumDefinitionBase 派生,实现那个「向系统提供本枚举当前可选条目」的类。这里你只需要覆写两个函数:一个返回当前枚举条目的字符串列表,另一个告诉系统你的枚举条目什么时候变了。
public class MyEnumDefinition : DynamicEnumDefinitionBase<MyEnumDefinition>
{
//return the current enum entries
protected override IReadOnlyDictionary<string, object> GetEntries()
{
}
//inform the system that the enum has changed
protected override IObservable<object> GetEntriesChangedObservable()
{
}
//optionally disable alphabetic sorting
protected override bool AutoSortAlphabetically => false; //true is the default
}
这里的实现会因你的使用场景而异。一个简单的例子大概是这样:
public class ComPortDefinition : DynamicEnumDefinitionBase<ComPortDefinition>
{
protected override IObservable<object> GetEntriesChangedObservable()
{
return HardwareChangedEvents.HardwareChanged;
}
protected override IReadOnlyDictionary<string, object> GetEntries()
{
Dictionary<string, object> portNames = new Dictionary<string, object>();
foreach(var portName in NetSerialPort.GetPortNames()
.Where(n => n.StartsWith("com", StringComparison.InvariantCultureIgnoreCase)))
{
//the return dictionary holds the names of the entries as key with an optional "tag"
//here the tag is null but you can provide any object that you want to associate with the entry
portNames[portName] = null;
}
return portNames;
}
}
关于在 VL 里使用动态枚举,见枚举。