12 · 脚本运行时与脚本形态
程序集:Hmi.ScriptRuntime · 源码:src/Hmi.ScriptRuntime/
什么时候你会需要它
- 你要在工程里加逻辑:开机初始化、按钮点一下干点什么、定时巡检
- 你的脚本写不进某个点,想知道为什么
- 你要知道有哪几种脚本、分别什么时候被触发
- 你要在宿主程序里跑工程的脚本
有六种脚本,触发时机完全不同
| 形态 | 配在哪 | 什么时候跑 |
|---|---|---|
| 全局启动脚本 | 工程 → 启动 → 启动时运行 | 程序启动时一次 |
| 全局退出脚本 | 工程 → 启动 → 退出时运行 | 程序退出时一次 |
| 后台定时脚本 | 全局脚本 → 程序周期 | 每隔 N 毫秒 或 走到指定时刻跑一次,程序运行期间一直跑 |
| 全局条件脚本 | 全局脚本 → 条件 | 条件表达式成立时跑(可设边沿/电平) |
| 画面脚本 | 画面 → 进入/退出/周期 | 切到这个画面时开始跑,切走就停 |
| 元素事件脚本 | 元素 → 事件 | 用户对那个元素做了动作(点击、鼠标移入…) |
全局启动 / 退出
启动:OnStartup 代码 → 然后按顺序调 StartupCalls 里列的函数
退出:OnShutdown 代码 → 然后按顺序调 ShutdownCalls 里列的函数
单个函数失败不会中断后面的 —— 会记日志然后继续。这样不至于因为一个初始化函数出错,后面的都不跑了。
后台定时脚本
触发方式二选一:
// ① 按间隔:每 500 毫秒跑一次
PeriodicScript { IntervalMs = 500, Code = "…" }
// ② 按时刻:每小时整点跑一次(At 非空 ⇒ 按墙钟时刻,IntervalMs 无意义)
PeriodicScript { At = "*:00", Code = "…" }
时刻表 At = 逗号分隔的 HH:mm,HH 可以用 * 表示每个小时:
*:00 每小时整点、*:30 每半点、08:00,20:00 每天早八晚八。
保存时校验/归一只有一份实现(Hmi.Project.Scripts.ScheduleSpec),运行态命中判断也调它 —— 组态期说合法、运行态就一定认。
⚠ 上一拍没跑完,这一拍会被直接丢弃(这叫 TickGate)。 也就是说定时脚本不会堆积 —— 宁可少跑几次,也不让任务越积越多。 所以脚本要写得快,别在里面做耗时操作。
⚠ 按时刻的条目由宿主的 1 秒心跳扫时刻表,同一分钟只跑一次;停机期间错过的时刻不补跑 (按时刻 = 绝对时刻,不是"每 N 毫秒"那种把错过的补回来的语义)。
⚠ 画面周期脚本不支持
At:画面周期是"该画面打开期间才跑",墙钟时刻与画面生命周期语义冲突 (画面没开就永远不触发)。需要整点/定时触发请建工程级的后台定时脚本。
全局条件脚本
new ConditionScript
{
Name = "温度过高联锁",
Condition = "#PLC1.锅炉温度.实时值 > 100", // 布尔表达式(字符串)
Action = new ScriptFragment { Code = "Alarm.Trigger(\"温度过高\");" }, // 动作也是 ScriptFragment
EdgeTriggered = true, // 只在"从假变真"那一刻跑一次
IntervalMs = 200, // 求值周期(毫秒)
Enabled = true,
}
EdgeTriggered 的区别很重要:
true(边沿触发) —— 条件从假变真时跑一次,之后一直为真也不再跑。适合"报警一次"。false(电平触发) —— 只要条件为真,每一拍都跑。适合"持续处理"。
画面脚本
OnEnter —— 进入这个画面时跑一次
OnExit —— 离开这个画面时跑一次
Periodic —— 停在这个画面期间周期跑;一切走就停
画面脚本是随画面生死的 —— 切走了就不跑了。所以适合做"这个画面专有的刷新逻辑"。
元素事件脚本
鼠标 12 个事件,字面量固定,不要改:
| 类别 | 事件名 |
|---|---|
| 鼠标按键(左键) | Click、DoubleClick、MouseDown、MouseUp |
| 鼠标按键(右键) | RightClick、RightDoubleClick、RightMouseDown、RightMouseUp |
| 鼠标位置 | MouseEnter、MouseHover、MouseMove、MouseLeave |
另有 3 个组件事件(2026-10-13 起),只在报警列表(AlarmList)的「选事件」窗里出现,由删报交互触发:
| 事件名 | 什么时候触发 | 程序集子程序名 |
|---|---|---|
AlarmDelete |
行内删除报警(脚本读 Alarm.LastClickKey() 拿被删的那条) |
_元素_删除报警 |
AlarmDeleteMany |
批量删除报警(读 Alarm.LastCheckedKeys()) |
_元素_批量删除报警 |
AlarmDeleteAll |
全部删除报警(读 Alarm.LastCheckedKeys()) |
_元素_全部删除报警 |
组件事件不是鼠标语义:没建这些事件时,删报走内置硬删(与旧版行为一致)。
⚠ 左键那四个历史上就叫
Click/MouseDown,不叫LeftClick/LeftMouseDown—— 这是历史命名,改掉会破坏已有工程。别顺手"统一"成Left*。
模块与函数
模块是一组可复用的函数,一个模块一个文件(modules/<模块名>.module.json)。
ScriptModule + ScriptFunction
↓ ScriptModuleCompiler.Render
static class 模块名
{
public static 返回类型 函数名(参数) { … }
}
渲染出来的这个类会被拼到脚本前面,所以脚本里可以直接调模块里的函数。
⭐ 模块名 == 画面名 → 该模块属于那个画面,随窗口生死。 这是这套软件的约定,别搞混 —— 同名模块和画面是一对。
⭐ 写值边界:为什么你的脚本写不进去
这是最容易踩的坑,单独讲。
脚本想写一个点,要经过 RuntimeWriteGate 这道门:
脚本 #设备名.点名.实时值 = 值
│
▼
RuntimeWriteGate
│
├─ 这个点是"内部变量"(IsInternal = true)?
│ → 放行 ✅
│
└─ 这个点是"采集点"(来自 PLC)?
→ 抛 InvalidOperationException ❌
为什么这么设计:
往采集点写值,意味着要下发到 PLC。而"运行态写值"是这套软件刻意不对外开放的能力 (见第 01 章的"不可逾越的边界")。
- 界面上的按钮写采集点 → 允许(那是操作工的主动操作,有人负责)
- 脚本写采集点 → 禁止(脚本是自动跑的,没人盯着,一旦逻辑错了会直接改设备)
那脚本想影响设备怎么办? 两条路:
- 写一个内部变量,再由界面绑定或其它机制去用它
- 通过
Comm库做通信类操作(受控的出口)
连接变量是个特例:写它时会先映射回它对应的采集点,然后同样被门禁拦下。
💡 报错信息是"通道点写入被拒绝"之类 —— 看到这个提示,先确认你要写的点是不是采集点。
装配:两种方式
方式一:ScriptRuntimeBuilder(推荐)
链式装配,每一步加一个能力:
using Hmi.ScriptRuntime;
ScriptGlobals globals = new ScriptRuntimeBuilder(rtdb, project.Tags, writer)
.WithHistorian(historian)
.WithAlarms(alarmEngine)
.WithLog(logSink)
.WithCurrentUser(() => auth.CurrentUserName)
.WithDatabase(dbService)
.WithUi(uiService)
.WithReport(reportService)
.WithRecipe(recipeService)
.WithComm(commService)
.WithFileRoot(@"C:\我的工程\files") // Fs 库的沙箱根目录
.Build();
没调的 With* 对应的库会变成"未就绪"占位件,脚本调它时抛 ScriptApiUnavailableException。
方式二:ScriptRuntimeHost(整机装配)
如果要把"脚本按工程的配置自动跑起来",用这个:
var dispatcher = new ScriptDispatcher(project, engine, globals);
using var host = new ScriptRuntimeHost(dispatcher, project);
await host.StartAsync(); // 跑启动脚本,起周期/条件
await host.OpenScreenAsync("主画面"); // 触发画面脚本
// …
await host.StopAsync(); // 跑退出脚本
ScriptRuntimeHost 会做这些事:
- 程序启动时跑全局启动脚本
- 按配置起全局周期脚本和条件脚本
- 切画面时:停旧画面的脚本、跑 OnExit、跑 OnEnter、起新画面的周期脚本
- 程序退出时跑全局退出脚本
执行模型:全部串行
⚠ 所有脚本在同一个信号量(
SemaphoreSlim(1,1))上串行执行。
这意味着一件事:脚本之间不会并发。 一个脚本跑着的时候,别的脚本在排队。
为什么这么设计:
- 脚本会读写共享的点值,并发会让逻辑变得难以推理
- 用户写的脚本质量参差,串行是最不容易出错的模型
代价:一个慢脚本会拖住所有脚本。所以脚本要写得快 —— 周期脚本尤其。
但有两条例外:
- 生命周期事件(启动/退出/进入画面/离开画面)一定会等到,不会被丢弃
- 周期脚本和条件脚本上一拍没跑完,这一拍直接丢(TickGate),不会堆积
公开类型
| 类型 | 干什么 |
|---|---|
ScriptRuntimeBuilder |
链式装配 ScriptGlobals |
ScriptRuntimeHost |
按工程配置驱动所有脚本 |
ScriptDispatcher |
决定什么时候跑哪个脚本 |
RuntimeWriteGate |
写值门禁(采集点拦截) |
RtdbTagGateway |
ITagGateway 的真实现(脚本读写的底层) |
RuntimeDataServices |
一次性装配报警 + 历史 + 保留清理 |
ScriptValidator / RoslynScriptChecker |
编译校验 |
ScriptModuleCompiler |
模块渲染成 static class |
LinkedVariableMirror |
联动变量镜像 |
AlarmConfigMapper / HistoryConfigMapper |
配置映射 |
ReportGenerator / AlarmNotifier |
报表生成 / 报警通知 |
踩过的坑
- 脚本写采集点被拒 —— 这是设计,不是 bug。改成写内部变量。
- 周期脚本做耗时操作 —— 会丢拍,而且拖慢所有其它脚本(串行执行)。
- 把左键事件名写成
LeftClick—— 正确是Click。 - 以为脚本并发执行 —— 全部串行,别指望并行。
- 模块名和画面名不一致 —— 那个模块就不属于这个画面了。
Fs库越界读写 —— 被限制在fileRoot沙箱内,越界会安全失败(读返回 fallback、写返回 false),不抛异常。
小结
- 六种脚本:全局启动/退出/周期/条件、画面(进入/退出/周期)、元素事件
- 周期脚本丢拍不堆积;所有脚本串行执行
- 写值门禁:脚本能写内部变量,不能写采集点
- 元素事件字面量固定:鼠标 12 个(左键那四个是历史命名,
Click不是LeftClick)+ 报警列表组件 3 个 - 模块名 == 画面名,该模块随画面生死
- 装配用
ScriptRuntimeBuilder,整机驱动用ScriptRuntimeHost
下一步:第 13 章 脚本函数库速查 —— 15 个库里都有什么函数。