06 · 工程文件与加载保存
程序集:Hmi.Project · 源码:src/Hmi.Project/
什么时候你会需要它
- 你要读工程内容:拿点表、拿画面、拿通道配置
- 你要改工程并保存:批量改点、生成画面、调参数
- 你要备份 / 迁移工程:打包、加密、导入导出
- 你要跑工程:运行内核启动第一步就是"加载工程"
- 你打开工程目录看不懂里面那些文件是干嘛的
💡 大多数情况下你不需要直接用
Hmi.Project—— 用第 15 章的Hmi.Automation更省事,它带了校验和审计。这一章讲的是它下面那一层。
一个工程 = 一个目录
工程不是什么单一文件,而是一个目录,里面按功能分子目录放一堆 JSON:
我的工程/
├── project.json 工程元信息:名字、版本、启动画面
├── tags/
│ └── tags.json 点表(数组,含采集点和内部变量)
├── channels/
│ └── channels.json 通道 + 设备(数组)
├── screens/
│ ├── 主画面.screen.json 每个画面一个文件
│ ├── 报警页.screen.json
│ └── 参数设置.screen.json
├── scripts/
│ └── project-scripts.json 全局脚本(启动/退出/周期/条件)
├── modules/
│ └── 公共函数.module.json 每个脚本模块一个文件
├── recipes/
│ └── recipes.json 配方
├── alarms/
│ └── alarm-config.json 报警配置
├── history/
│ └── history-config.json 历史记录配置
├── reports/
│ └── reports.json 报表定义
├── i18n/
│ └── translations.json 多语言词条
├── notify/
│ └── notify.json 邮件通知配置
├── styles/
│ └── buttons.json 自定义按钮皮肤
└── users.db 账号库(运行期生成,见第 19 章)
几点说明:
- 画面是"一画面一文件",文件名就是画面名。这样改一个画面不会动到其它画面的文件,便于版本管理。
- 模块也是"一模块一文件",按模块名索引。
users.db不是 JSON,是 SQLite 库(账号属于工程配置,所以放在工程目录里)。- 邮件密码故意不写在
notify.json里 —— 由MailSecretStore用 Windows DPAPI 单独加密存,防止工程文件被拷走就泄露邮箱密码。
加载:三步走
using Hmi.Project.Persistence;
// 最简单:从目录加载
LoadedProject project = ProjectLoader.Load(@"C:\我的工程");
// 目录里没有 project.json 会抛 FileNotFoundException
ProjectLoader 的完整签名:
public static LoadedProject Load(string projectDir);
public static LoadedProject Load(IProjectFileSource src);
public static void Save(LoadedProject proj, string projectDir);
文件源抽象:为什么加载要分两个重载
Load(IProjectFileSource src) 这个重载是为加密运行包准备的:
| 文件源 | 用途 |
|---|---|
DirectoryFileSource |
普通的磁盘目录(就是上面那个目录结构) |
ZipFileSource |
内存里的 zip —— 加密运行包解密后的形态 |
加密运行包的工作方式:整个工程打包成 zip → 用 AES-GCM 加密 → 交付给客户。
运行时输入密码 → 在内存里解密成 ZipFileSource → 直接加载。
⚠ 关键点:明文永远不落盘。所以交付给客户的程序里不会留下可读的工程文件。 这是"交付即安全"的实现方式。
相关工具类
| 类型 | 干什么 |
|---|---|
ProjectArchive |
.hmiproj 明文归档的导入导出(就是"备份/恢复工程") |
ProjectPackage |
.hmipkg 加密包 —— AES-GCM + PBKDF2 |
ProjectJson |
全工程统一的 JSON 序列化配置(别自己 new 一个,格式会不一致) |
RtdbBridge |
工程模型 → 运行时对象(见下) |
ProjectJson.Options 的约定:PascalCase 命名、枚举用字符串、带缩进、忽略 null、容忍大小写差异和尾逗号。
后面这条是为了让人手改 JSON 时不容易出错。
LoadedProject:工程在内存里的样子
加载完拿到的是一个 LoadedProject,它就是一个大容器:
public sealed class LoadedProject
{
public ProjectModel Project { get; } // 工程元信息
public List<TagDef> Tags { get; } // 所有点(含内部变量)
public List<ChannelDef> Channels { get; } // 通道+设备
public Dictionary<string, ScreenModel> Screens { get; } // 画面,按名索引
public ProjectScripts ProjectScripts { get; } // 全局脚本
public Dictionary<string, ScriptModule> Modules { get; } // 脚本模块,按名索引
public List<RecipeModel> Recipes { get; } // 配方
public LanguageTable Languages { get; } // 多语言
public NotifyConfig Notify { get; } // 通知
public List<AlarmConfigModel> Alarms { get; } // 报警配置
public List<HistoryConfigModel> History { get; } // 历史配置
public List<ReportModel> Reports { get; } // 报表
public List<CustomButtonSkin> ButtonSkins { get; } // 按钮皮肤
}
注意这些都是可变的 List / Dictionary —— 加载回来直接改内存,改完调 Save() 落盘。
⚠ 但别直接改。直接改会绕过校验和审计。要用
Hmi.Automation提供的方法改(见第 15 章)。
从工程到运行时:RtdbBridge
工程里的 TagDef(带通道/设备/地址的配置)不能直接给实时库用,中间要过一次桥:
using Hmi.Project.Persistence;
// TagDef[] → Tag[](运行时点对象)
Tag[] tags = RtdbBridge.BuildTags(project.Tags);
// ChannelDef[] → ChannelConfig[](驱动能认的配置)
List<ChannelConfig> channels = RtdbBridge.BuildChannels(project.Channels, project.Tags);
BuildTags 有一条硬性校验:点的 Id 必须是 0..N-1 连续无重复,否则抛 FormatException。
这是为了防止"点表里有个洞"导致后面所有 Id 对不上。
绑定与动作:画面上的值是怎么来的
画面上每个元素可以绑定到一个点(BindingModel),运行时要把"点的原始值"翻译成"元素能显示的样子":
using Hmi.Project.Binding;
// 求值:把点值按绑定规则算成显示结果
BindingResult r = BindingEvaluator.Evaluate(binding, value, tagType, quality, unit);
// r.Text / r.Ratio / r.Kind … 交给前端渲染
// 单独用的小工具
double d = BindingEvaluator.AsDouble(tagValue, tagType); // 原始值 → double
string s = BindingEvaluator.FormatValue(d, tagType, "0.0"); // 按格式串格式化
bool ok = BindingEvaluator.EvalPredicate(d, "> 100"); // 条件表达式求值
double ratio = BindingEvaluator.ToRatio(d, min: 0, max: 100); // 算成 0~1 的占比(进度条/仪表盘用)
写动作走 IActionDispatcher 接口:
public interface IActionDispatcher
{
void WriteTag(string name, string value); // 写点
void OpenScreen(string name); // 切画面
void RunScript(string name); // 跑脚本
}
脚本、AI、界面共用这一条写路径,运行期由 Hmi.Backend 提供实现(见第 10 章)。
踩过的坑
- 自己 new
JsonSerializerOptions—— 序列化出来格式和工程不一致,再加载就出错。用ProjectJson.Options。 - 手改 JSON 导致点 Id 跳号 —— 加载时
RtdbBridge.BuildTags会抛FormatException。 - 改了内存忘了
Save()—— 关掉就白改了。注意Hmi.Automation的 RPC 出口有自动保存钩子,直接改LoadedProject没有。 - 以为画面文件名可以随便起 —— 文件名就是画面名,改名等于改画面名,脚本里引用的画面名会失效。
- 备份时直接拷目录 —— 工程正在运行时
users.db可能正被写。用ProjectArchive导出一致性更好。
小结
- 工程 = 一个目录,按功能分子目录放 JSON,一画面一文件、一模块一文件
- 加载:
ProjectLoader.Load(目录);加载后得到LoadedProject(一个可变的大容器) - 保存:
ProjectLoader.Save(proj, 目录) - 加密交付:打包成 zip → AES-GCM 加密 → 运行期在内存解密,明文不落盘
- 工程模型到运行时对象要过
RtdbBridge,它强制点 Id 连续
下一步:第 07 章 驱动是怎么工作的 —— 数据是怎么从 PLC 读上来的。