去年年初我接了一个设备监控上位机的项目,核心任务是把西门子 S7-1500 里的一个大型数据块实时映射到 WPF 界面上。那个 DB 块里有 100 多个变量,Bool、Int、Real、二维数组全都有。我的第一反应是直接手写 ViewModel,每个变量一行 [ObservableProperty],再写读接口、写接口、初始化逻辑。结果写了不到两天我就意识到,这个活儿本质上就是给 PLC 变量当搬运工:改一个 DB 变量名,就得跑到三处地方去改,漏一处要么编译不过,要么更糟——编译能过但界面数据对不上。
这篇文章聊聊我最后是怎么把这件事变成自动化流水线的:用机器可读的方式描述 DB 块结构,再由 T4 模板直接生成基于 CommunityToolkit.Mvvm 的 ViewModel。如果你也整天和西门子 DB 块 + WPF/WinUI/MAUI 打交道,被“结构一变就要改一大片”这件事折磨过,这篇内容应该能给你一条可以直接落地的路子。
1. 手工映射 DB 块的维护成本:我为什么决定用代码生成器
1.1 一个 100 变量的 DB 块到底意味着什么
在谈自动化之前,先把手工映射这笔账算清楚。假设 DB 块里有 100 个变量,用 CommunityToolkit.Mvvm 写,每个变量最少也要三行代码:
csharp复制[ObservableProperty]
private bool _alarmActive;
如果不用框架,直接实现 INotifyPropertyChanged,每个变量得重复写这种模式:
csharp复制private bool _alarmActive;
public bool AlarmActive
{
get => _alarmActive;
set
{
if (_alarmActive == value) return;
_alarmActive = value;
OnPropertyChanged(nameof(AlarmActive));
}
}
100 个变量就是 400 到 500 行机械代码。听起来还不算多?真正折磨人的是行数以外的东西。
第一,命名不一致。PLC 工程师起变量名喜欢用 Alarm_Active、Set_Speed_01、Motor[2].Temp 这种风格,直接搬进 C# 里既不符合命名规范,也容易在转写的时候出错。要么你额外维护一张映射表,要么你每次手动转成 PascalCase,两条路都会增加出错率。
第二,类型映射容易踩坑。西门子的 Int 是短整型,Real 是单精度浮点,Word 是 16 位无符号整数,DWord 是 32 位无符号整数。这些东西我本身不会输错,但项目一大就说不定了,特别是在 OPC UA 或者 S7 通信的字节解析层,把 short 当成 int 去读,数据直接错到离谱,还很难排查。
第三,UI 绑定依赖属性名。DB 结构一调整,比如把某个 Bool 从“报警”改成“报警使能”,ViewModel 属性名一变,XAML 里所有相关的绑定路径全部作废。漏改一处,编译器不会报错,运行期绑定静默失效——界面上该报警的地方不报警,这种问题是最难定位的。
第四,双向同步的代码形状完全重复。每个属性都要配套一段读逻辑和一段写逻辑,结构一模一样,却必须一个变量一个变量地敲。我后来粗略统计过,光“从 PLC 读值并赋值给 ViewModel”这段代码,在一个 120 变量的项目里,能占到 600 行以上。
所以对我来说,手工写 ViewModel 的问题不是“慢”,而是“不可维护”。DB 一变更,整套代码就要人工回归。这也是我后来把项目彻底改成“定义驱动 + 代码生成”的根本原因:我不想让编译器替我发现哪些变量漏了,我想让 DB 结构的变更一秒同步到代码层。
1.2 我给自动化方案定的目标和边界
我给自己定的目标很清晰:把“DB 块结构”这份信息,变成 C# ViewModel 代码,并且保证两端永远一致。把这个目标拆开,其实是三个子目标:
- DB 结构以某种机器可读的格式保存,例如 JSON 或者 Excel 导出表;
- 用 T4 模板读取这份结构,生成完整的 ViewModel 文件;
- 配合 CommunityToolkit.Mvvm 的源生成器,把字段转成带通知的属性,省掉手工写重复代码的功夫。
至于“如何从博图直接读取 DB 结构”,我的边界是:不做完美的博图工程文件解析器。TIA Portal 的工程文件格式跟版本强绑定,解析成本极高,今天能用,明天升级一个版本可能就废了。我采用的是“半自动”路线:用一份人工维护好、或者从标签表简单转换来的 JSON 作为唯一数据源。后面第 2 节我会详细说这个选择背后的考虑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. DB 块结构数据的获取:先让 PLC 变量变成机器可读
2.1 三种候选方案,以及我最终的选择
我先说结论:DB 块自动化项目里最容易被低估的,就是“数据源从哪来”这个问题。模板再怎么写,有个稳定可靠的结构描述文件才是地基。我前前后后试过三条路。
方案 A:直接从博图导出 XML。想法很好,但现实比你想象的复杂。TIA Portal 的 DB 块导出 XML 往往需要额外接口,或者只能在特定操作里拿到,而且不同版本的导出 Schema 不一样。你费劲把解析代码写好,下个博图版本升级后格式一变,解析层就要返工。这条路线长期维护成本太高,我第一个否掉了。
方案 B:从博图的符号表/标签表导出 Excel,再用一个脚本转成 JSON。这个方案对标签表比较规整的项目可行,但 DB 块里的结构体、数组在标签表里常常被展开成平铺字段,层级关系丢了。要还原嵌套结构,只能靠地址偏移量去猜,猜错一次,整个映射就错位。我试了两天,放弃了。
方案 C:定义一份“DB 结构描述 JSON”,由电气工程师和上位机开发一起约定规则,手工维护这份文件。这是我最终采用的方案。原因很现实:这份 JSON 同时是 T4 模板的输入、运行时读写寻址的元数据、以及 UI 翻译的参考。一次维护,多个环节复用。DB 结构变更时,改 JSON 比改 C# 代码直观太多。
有人会问:这不是多了一步人工维护吗?确实多了一步。但我的经验是,DB 结构调整的频率是“周级别”,而手工维护 ViewModel 代码的成本是“每次变更都要全部做一遍”。把变更集中到一个 JSON 文件里,剩下的事情都交给模板机械化处理,收益远大于成本。而且这份 JSON 可以让你和 PLC 工程师共同维护,把“改完 PLC 代码顺手同步一下接口描述”这件事变成常态,而不是靠记忆。
2.2 结构描述文件到底长什么样
我最终的 JSON 结构刻意做得简单,只保留生成代码和运行时寻址所需的最小字段:
json复制{
"namespace": "MyApp.ViewModels.Generated",
"dbName": "MachineData",
"items": [
{
"name": "AlarmActive",
"description": "系统报警中",
"s7Type": "Bool",
"address": "DB10.DBX0.0",
"writable": false
},
{
"name": "SetSpeed",
"description": "主轴设定速度",
"s7Type": "Real",
"address": "DB10.DBD4",
"writable": true,
"unit": "rpm"
},
{
"name": "MotorTemp",
"description": "电机温度数组",
"s7Type": "Real",
"address": "DB10.DBD8",
"length": 4,
"writable": false
}
]
}
字段不多,但已经够用:name 决定生成的属性名,s7Type 决定映射成哪个 C# 类型,address 供运行时读写寻址,writable 决定要不要生成写回接口,length 标记数组长度。
如果遇到结构体嵌套,我处理的方式是把结构体拆成独立的子对象,用 parent 字段表达从属关系:
json复制{
"name": "Motor2",
"description": "二号电机",
"s7Type": "Struct",
"address": "DB10.DBX50.0",
"parent": "MachineData",
"children": ["Motor2Temp", "Motor2Speed"]
}
然后让模板根据这个层级关系生成子 ViewModel。结构体的层级信息还在,但模板生成逻辑不会变得太复杂。
2.3 S7 类型到 C# 类型的映射表
这一部分直接给表,覆盖我遇到过的绝大多数情况:
| S7 类型 | C# 类型 | 说明 |
|---|---|---|
| Bool | bool | 位变量 |
| Byte | byte | 无符号 8 位 |
| Char | char | 字符 |
| SInt | sbyte | 有符号 8 位 |
| Int | short | 有符号 16 位 |
| Word | ushort | 无符号 16 位 |
| DInt | int | 有符号 32 位 |
| DWord | uint | 无符号 32 位 |
| Real | float | 单精度浮点 |
| LReal | double | 双精度浮点 |
| String[n] | string | 字符串,n 仅作长度参考 |
| Date/Time | DateTime | 时间和日期 |
| Array of T | T[] / IList |
数组映射为集合或数组 |
这里有个细节值得单独强调:Word 映射成 ushort,Int 映射成 short,不要图省事把它们全合并成 int。在 OPC UA 或者 S7 通信里,原始字节长度是确定的,你用 int 去匹配 S7 的 Int,边界上会出问题。尤其是 OPC UA 的数据转换,类型不匹配会直接导致节点值转换失败。宁可后面在 ViewModel 层再做一次转换,也别在映射层偷懒。
3. T4 模板工程的关键设计:解析与生成分离
3.1 为什么我不在模板里直接写解析逻辑
最开始我图省事,把 JSON 文件读取直接写进 .tt 文件里,用正则去拆字段。结果模板又臭又长,而且 .tt 文件里的代码调试体验极差——报错的行号根本对不上实际问题,输出了十几行错误也不知道是哪一段逻辑炸了。
这个坑踩完之后,我把工程结构调整成了两层:
- 解析层:一个独立的小类库
DbSchemaParser,负责读取 JSON、校验字段、提供强类型的DbBlockModel对象集合; - 生成层:
.tt模板只做一件事——接收一个DbBlockModel,按预设规则输出 C# 文本。
这样设计的好处有三个。第一,解析逻辑可以被单元测试覆盖。第二,JSON 格式一旦调整,改解析类比改模板直观得多,模板本身可以保持稳定,不用每次跟着 JSON 变动。第三,同一个解析器还能喂给其他生成器用,比如生成 OPC UA 节点清单,或者生成帮助文档。
解析层的核心其实很简单,我贴一个简化版本:
csharp复制using System.Text.Json;
namespace DbSchemaParser
{
public class DbItemModel
{
public string Name { get; set; }
public string Description { get; set; }
public string S7Type { get; set; }
public string Address { get; set; }
public bool Writable { get; set; }
public int Length { get; set; }
public string Parent { get; set; }
}
public class DbBlockModel
{
public string Namespace { get; set; }
public string DbName { get; set; }
public List<DbItemModel> Items { get; set; } = new();
}
public static class DbBlockParser
{
public static DbBlockModel Load(string path)
{
var text = File.ReadAllText(path);
return JsonSerializer.Deserialize<DbBlockModel>(text, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
}
}
}
注意这里的类库目标框架我建议选 netstandard2.0 或者 net462。原因在下一小节展开。
3.2 搭建模板项目时最容易卡住的三个点
很多人卡在 T4 上,其实不是不会写模板,而是环境配置没搞对。我踩过三个最深的坑,照着下面做能省很多时间。
第一个是程序集引用。.tt 文件是 Visual Studio 的文本模板主机执行的,它运行在一个临时 AppDomain 里,引用的程序集路径只能是绝对路径或者 VS 宏变量。我推荐在模板头部这样写:
csharp复制<#@ assembly name="$(SolutionDir)\DbSchemaParser\bin\Debug\DbSchemaParser.dll" #>
注意这里不要用 $(Configuration) 代替 Debug,因为 VS 在执行设计时模板时,不一定总能正确替换成当前解决方案配置。稳妥做法是让解析器项目固定输出到 bin\Debug,或者干脆引用编译好的绝对路径。还有一点:如果解析器类库用了 .NET 6 的运行时,T4 主机可能加载不了,因为文本模板主机默认跑在 .NET Framework 上。所以才说把目标框架选成 netstandard2.0 或 net462 最保险。
第二个是 Host.TemplateFile 的可用性。如果你要在模板里根据 .tt 文件的位置去找 JSON 文件路径,必须在头部声明 hostspecific="true",否则模板里的 Host 对象是 null,一调用就抛异常。这是个非常阴的坑,新手几乎都会踩。
第三个是模板输出编码。默认情况下 extension=".cs" 输出的文件带 UTF-8 BOM,某些代码审查工具里会显示成乱码。如果你有严格的无 BOM 要求,可以在模板输出指令里显式指定编码:
csharp复制<#@ output extension=".cs" encoding="utf-8" #>
3.3 一个能跑通的最小模板骨架
下面是我简化过的模板骨架,你可以直接在自己项目里套用:
csharp复制<#@ template debug="false" hostspecific="true" language="C#" #>
<#@ assembly name="$(SolutionDir)\DbSchemaParser\bin\Debug\DbSchemaParser.dll" #>
<#@ import namespace="System.IO" #>
<#@ import namespace="DbSchemaParser" #>
<#@ output extension=".cs" encoding="utf-8" #>
<#
var jsonPath = Path.Combine(Path.GetDirectoryName(Host.TemplateFile), "..", "Definitions", "MachineData.json");
var db = DbBlockParser.Load(jsonPath);
#>
namespace <#= db.Namespace #>
{
public partial class <#= db.DbName #>ViewModel : global::CommunityToolkit.Mvvm.ComponentModel.ObservableObject
{
<# foreach (var item in db.Items) { #>
[global::CommunityToolkit.Mvvm.ComponentModel.ObservableProperty]
private <#= item.CsType #> _<#= item.CamelCaseName #>;
<# } #>
}
}
这段代码的作用是:读 JSON,遍历 items,为每个字段写出一个带 [ObservableProperty] 的私有字段。CommunityToolkit.Mvvm 的源生成器会在编译时把这些字段升级成可通知的公开属性。注意我用了 global:: 完整限定名,这样做是为了避免文件被放进不规范命名空间时产生命名冲突,尤其是字段名里出现 System、Math 这种基础命名空间时,能省掉一堆莫名其妙的编译错误。
4. CommunityToolkit.Mvvm 生成代码的写法与扩展点
4.1 源生成器到底替我们做了什么
不用框架的时候,一个具备通知能力的属性长什么样,我在第 1 节已经展示过。而用 CommunityToolkit.Mvvm 之后,你只需要写一行:
csharp复制[ObservableProperty]
private bool _alarmActive;
它在编译期把字段提升成一个完整的、带变更通知的属性,并自动在 setter 里调用 SetProperty。所以我的方案实际上是双层代码生成:T4 负责把 JSON 转成带特性的字段,CommunityToolkit 的源生成器负责把字段转成属性。两层各管一段,职责非常清楚。
用到 CommunityToolkit.Mvvm 8.x 的时候,有几个版本相关的注意点。标记的字段必须是非公共字段,所在的类必须是 partial class。我用 _ 前缀命名,这样生成的属性名就是去掉下划线后的 PascalCase:_alarmActive 变成 AlarmActive。如果你需要自定义属性名,可以在特性的 PropertyName 参数里指定,但大部分场景不需要。
4.2 T4 模板里除了字段,还要生成哪些东西
光生成标量字段远远不够。一个能用的 ViewModel 至少还要考虑下面三件事。
首先,数组和结构体。S7 里的数组元素和结构体不是标量,直接把整个数组变成 ObservableCollection 更符合 UI 绑定习惯。我的生成逻辑是:遇到 length > 0 的项,如果数组元素是基础类型,比如 Real[4],就生成 float[] 属性;如果数组元素是结构体,就生成 ObservableCollection<T>,并在构造函数里预先填充和 length 等量的子 ViewModel。每个子 ViewModel 再递归生成,数据层级和 DB 里完全对齐。
其次,可写标志。writable=true 的项,我除了生成属性,还会生成一个空实现的 partial 方法:
csharp复制partial void OnSetSpeedChanged(float value);
为什么空实现?因为 CommunityToolkit.Mvvm 的源生成器会自动在属性 setter 里调用 OnSetSpeedChanged(value)。你可以在另一个手工 partial 类文件里去实现它,做写回 PLC 的操作。这样 T4 只负责搭骨架,不把你的业务逻辑和生成代码混在同一份文件里。
最后,默认值初始化。比如 Real 类型的变量,如果不写默认值,生成的属性就是 0,这在界面上看起来没问题,但某些工艺参数要求初始显示一个非零值,比如默认 50% 的速度。所以我允许 JSON 里加一个 defaultValue 字段,模板在字段初始化的地方输出这个默认值。
4.3 通过 partial 类给手工代码留空间
这是生成器方案里最容易被忽略、也最重要的一点。生成的 .g.cs 文件每次跑模板都会被覆盖,绝对不要直接在上面改代码。但 ViewModel 总会有命令、校验逻辑、单位换算这类需要手写的业务代码。解决办法就是“双文件模式”:
MachineDataViewModel.g.cs:T4 生成,只放字段标记、构造函数占位、partial 方法声明;MachineDataViewModel.Logic.cs:手工维护,放命令、事件、私有读写方法。
两个文件都声明 public partial class MachineDataViewModel,编译时合并成一个类。这样既能享受生成效率,又保住了手写自由度。
T4 模板的职责边界我也要强调一下:不要拿 T4 去生成 [RelayCommand] 这类和业务行为强相关的代码。命令往往是“读全部”“写全部”“复位报警”这类操作,属于人的行为逻辑,不是数据结构映射。如果你硬要生成,模板里的条件分支会越来越多,最后比手写还难维护。生成器只负责“结构化数据映射”,其他的一律留给手工代码。
5. 把生成的 ViewModel 接入 OPC UA / S7 通讯层的完整链路
5.1 数据流向设计,以及一个容易忽略的冲突
生成的 ViewModel 不会自己从 PLC 拿数据,它只负责把数据变成可绑定的属性。真正的数据流是这样的:
- 一个后台服务周期性地读取 DB 变量当前值,通常用 250ms 到 1000ms 的轮询,或者走 OPC UA 订阅;
- 服务拿到新值后,更新 ViewModel 对应的属性;
- UI 通过绑定自动刷新显示。
反向写回时,用户修改了界面上的输入框,ViewModel 属性 setter 被触发,CommunityToolkit 调用 partial 方法,方法里把值写回 PLC。
这里有个我反复强调的经验:读和写千万不要同时闯进 ViewModel。如果后台轮询正在更新 SetSpeed,用户同时又在界面输入了新的 SetSpeed,两个方向会互相覆盖。我的做法是在 ViewModel 里加一个 _isRefreshing 标志,批量更新时跳过写回逻辑:
csharp复制private bool _isRefreshing;
partial void OnSetSpeedChanged(float value)
{
if (_isRefreshing) return;
_ioService.WriteFloat("DB10.DBD4", value);
}
这个技巧虽然简单,却在很大程度上决定了一个现场会不会出现“界面数字来回跳”的诡异问题。不要省这一步——我实际项目里就遇到过用户输入速度值,结果一秒后被旧值覆盖回去,排查了大半天才发现是轮询更新和写回逻辑在抢同一个属性。
5.2 让 T4 同时生成 IO 映射代码
既然 JSON 里已经有 address 字段,我建议不要只生成 ViewModel,再写一个 MachineDataIoMap.g.cs 模板,把地址常量批量生成出来:
csharp复制public static partial class MachineDataAddresses
{
public const string AlarmActive = "DB10.DBX0.0";
public const string SetSpeed = "DB10.DBD4";
public const string MotorTemp = "DB10.DBD8";
}
然后在通讯层里直接引用常量名读写。这样你改 DB 地址时只需要改 JSON 一处,IO 映射代码和 ViewModel 都会跟着重新生成。以前那些散落在 C# 各处的硬编码字符串,也顺带治好了。我这里用的是博图风格的字符串地址做示例,如果你接的是 OPC UA,那么 JSON 的 address 字段存 OPC 的 NodeId 即可,原理完全一样。
5.3 读取方向的最小实现示例
读取方向的代码,用 OPC UA 订阅或者 S7 轮询都差不多,核心还是“找到 ViewModel 属性并赋值”:
csharp复制public void ApplyValue(string itemName, object value)
{
switch (itemName)
{
case MachineDataAddresses.AlarmActive:
_viewModel.AlarmActive = (bool)value;
break;
case MachineDataAddresses.SetSpeed:
_viewModel.SetSpeed = Convert.ToSingle(value);
break;
}
}
这个 switch 看起来朴素,但它是 T4 生成器输出的,不是手敲的。你只要有一份完整的 JSON,这个巨大的 switch 可以直接由模板生成出来。我当初生成这个文件时,一个 100 多个 case 的 switch 一分钟写出来,编译一次通过,那种感觉非常直观——所谓的自动化,就是把你反复要做的机械劳动一次锁定。
如果你没有 OPC UA 环境,直接用 S7 协议,思路完全一致。Sharp7 或者 S7.Net Plus 读回来的是一个字节数组,你按 JSON 里的类型和地址长度去解析字节,然后赋给同一个属性就行。关键点不在于协议细节,而在于“结构驱动赋值”——所有变量的处理流程都是同一套逻辑,只是类型和地址不同。
6. 项目落地后的坑与维护经验
6.1 T4 不重新生成的几个原因
T4 这工具最让我头疼的不是写模板,而是它“有时候不跑”。正常保存 .tt 文件应该触发生成,但遇到这些情况,它就不动了:
.tt文件没被识别为自定义工具。右键.tt文件 → 属性 → 自定义工具设为TextTemplatingFileGenerator。- 保存了但没触发生成。手动右键 → 运行自定义工具,或者保存整个解决方案,通常能重新触发。
- 生成结果被 Visual Studio 的文本模板缓存挡住。这是 VS 一个比较隐蔽的问题。最稳妥的办法是关掉相关设计器窗口,或者干脆把已有的输出文件删除,再运行一次自定义工具,它会强制重建。
我的经验是:改动 JSON 定义之后,每次固定跑两个模板文件,然后立刻检查 .g.cs 的 diff 确认生成结果,不要盲信“模板保存了就自动更新”。
6.2 DB 结构变更后的完整操作流程
我现在每天的日常节奏已经固定成这样的流程:
- PLC 工程师改完 DB 块,同步更新 JSON 描述文件;
- 在 VS 里运行两个自定义工具:ViewModel 模板和 IO Map 模板;
- 编译;
- 跑一遍自动化测试,确认关键属性读写正常。
整个过程控制在五分钟以内。过去手工改动至少一上午,而且手工改完还要担心漏变量,这个效率差距已经不是同一个量级。
这里有一个细节:生成的代码文件必须纳入版本控制,并且跟着正常代码一起 review。不要因为它是自动生成的就放任不管。恰恰因为它是自动的,review diff 时要特别留意新增了哪些属性、删除了哪些属性——这些往往是 DB 结构变更的直接证据,也是排查问题的关键线索。我在一次版本提交里就通过 diff 发现 PLC 工程师把某个变量的数据类型从 Real 改成了 LReal,从而提前调整了 UI 的显示格式,避免了一次现场事故。
6.3 从这些实践中我学到的通用方法论
这个项目做下来,我最大的感受是:生成器适合“结构明确、反复变化、规则简单”的场景。DB 块和 ViewModel 的映射就是最典型的例子。它不需要多聪明的逻辑,只需要一个可靠的元数据源和一个稳定复现的生成引擎。
反过来说,如果某个项目的字段经常没规律地变化,或者每个变量都有自己的特殊业务逻辑,那别硬套生成器。否则模板里的 if 分支会越来越多,最后比手写代码还难维护。我的经验是,一个 T4 模板里最好不要出现超过三个条件分支;一旦超过,说明你的元数据模型设计出了问题,应该回到模型设计去解决,而不是在模板里堆特判。
最后再分享一个小技巧:生成的 ViewModel 文件统一放在 ViewModels/Generated/ 目录下,文件名统一加 .g.cs 后缀。这样无论在文件搜索还是在 git 的 diff 界面里,一眼就能分清哪些文件是机器产物、哪些需要人工 review。这个小约定帮我省掉了无数“这个文件到底能不能改”的纠结。
如果你也正在被手动维护西门子 DB 映射代码折磨,建议先花半小时把 JSON 定义理顺,再搭一个最小 T4 模板。自动化这步真的比想象中更容易迈出去,而一旦迈出去,你就再也不想回到手工搬运的时代了。
