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 章)。

踩过的坑

  1. 自己 new JsonSerializerOptions —— 序列化出来格式和工程不一致,再加载就出错。用 ProjectJson.Options。
  2. 手改 JSON 导致点 Id 跳号 —— 加载时 RtdbBridge.BuildTags 会抛 FormatException。
  3. 改了内存忘了 Save() —— 关掉就白改了。注意 Hmi.Automation 的 RPC 出口有自动保存钩子,直接改 LoadedProject 没有。
  4. 以为画面文件名可以随便起 —— 文件名就是画面名,改名等于改画面名,脚本里引用的画面名会失效。
  5. 备份时直接拷目录 —— 工程正在运行时 users.db 可能正被写。用 ProjectArchive 导出一致性更好。

小结

  • 工程 = 一个目录,按功能分子目录放 JSON,一画面一文件、一模块一文件
  • 加载:ProjectLoader.Load(目录);加载后得到 LoadedProject(一个可变的大容器)
  • 保存:ProjectLoader.Save(proj, 目录)
  • 加密交付:打包成 zip → AES-GCM 加密 → 运行期在内存解密,明文不落盘
  • 工程模型到运行时对象要过 RtdbBridge,它强制点 Id 连续

下一步:第 07 章 驱动是怎么工作的 —— 数据是怎么从 PLC 读上来的。