17 · 宿主、进程与壳
相关:第 10 章 运行数据引擎 · 第 15 章 自动化 API 总览
这一章是什么
前面几章讲的都是"库" —— 你把它们引进自己的程序里用。 这一章讲已经装好的那套东西:组态器的窗口、它怎么和网页前端说话、怎么把运行内核拉起来。
什么时候你会需要看这一章:
- 你想知道组态器界面上的按钮点下去,后端到底发生了什么
- 你要给前端加一个新的 RPC 方法(前端发消息、后端接住)
- 你要理解"为什么改完点会自动保存"
- 你要自己做一套壳,把前端页面嵌进自己的程序
这一章涉及 4 个程序集:
| 程序集 | 角色 | 一句话 |
|---|---|---|
Hmi.Shell |
WPF 窗口壳 | 一个窗口 + 一个 WebView2,里面跑前端页面 |
Hmi.HostBridge |
消息路由 | 前端发来的 JSON 在这里分派给各个处理者 |
Hmi.RuntimeCore.Host |
运行内核进程 | 一个独立 exe,专门跑工程 |
Hmi.Supervisor |
进程监督 | 拉起子进程、看它活着没、崩了重启 |
一、组态器长什么样
┌─ Hmi.Shell.exe(WPF 窗口)─────────────────────────┐
│ │
│ ┌─ WebView2 ──────────────────────────────┐ │
│ │ │ │
│ │ frontend/ 那一堆 HTML/JS/CSS │ │
│ │ (就是你在浏览器里看到的那套界面) │ │
│ │ │ │
│ └─────────────────────────────────────────┘ │
│ ↕ 消息(JSON) │
│ ┌─ Hmi.HostBridge ────────────────────────┐ │
│ │ WebMessageRouter → 各个 RpcDispatcher │ │
│ └─────────────────────────────────────────┘ │
│ ↕ │
│ Hmi.Automation / Hmi.Backend / ... │
└──────────────────────────────────────────────────┘
关键点:前端页面和后端不在同一个进程里跑同一套对象 —— 它们是两个世界, 中间靠发 JSON 消息沟通。这条边界很重要,后面所有设计都是围绕它来的。
前端页面是怎么被加载的
不是开个本地 HTTP 服务器再访问 http://localhost:xxxx —— 那样要占端口、可能被防火墙拦。
实际做法是 WebView2 的虚拟域名映射:
await Web.EnsureCoreWebView2Async();
Web.CoreWebView2.SetVirtualHostNameToFolderMapping(
"hmi.app.invalid", // 虚拟主机名(故意用 .invalid 顶级域,保证不会和真域名撞)
webDir, // 前端文件所在目录
CoreWebView2HostResourceAccessKind.Allow);
Web.CoreWebView2.WebMessageReceived += OnWebMessage;
Web.CoreWebView2.Navigate("https://hmi.app.invalid/index.html");
这样前端页面看到的是一个正常的
https://来源(有安全上下文,能用 Web Worker、IndexedDB 等), 但实际读的是本地文件夹,不开任何端口。.用.invalid是故意的 —— 这个顶级域被保留、永远不会被注册,不可能和真实网站撞名。
开哪个页面由命令行决定
| 参数 | 效果 |
|---|---|
| 不带参数 | 打开 index.html(组态器界面) |
--runtime / -r / /runtime |
打开 runtime.html(运行态界面) |
环境变量:HMI_PROJECT_DIR(工程目录)、HMI_WEB_DIR(前端目录)、HMI_MODE。
不带参数时默认打开「文档\鲲鹏恒控\项目\示例工程」。
二、消息路由:前端怎么和后端说话
2.1 消息长什么样
所有消息都是同一个信封结构(WebMessage):
| 字段 | 类型 | 意思 |
|---|---|---|
Id |
string? | 消息编号,用来把"回答"对上"提问" |
Kind |
string | 消息种类,见下表 |
Method |
string? | 要调的方法名(Kind=rpc 时用) |
Topic |
string? | 话题名(Kind=event/sub 时用) |
Ok |
bool? | 成功没有(Kind=rpc-result 时用) |
Message |
string? | 人看的说明文字 |
Payload |
JsonElement? | 数据本体 |
Kind 有 5 种:
| Kind | 方向 | 意思 |
|---|---|---|
rpc |
前端 → 后端 | "帮我做件事" |
rpc-result |
后端 → 前端 | "做完了,结果在这" |
event |
后端 → 前端 | "出事了,通知你一声"(不需要你请求) |
sub |
前端 → 后端 | "我要订阅这些点" |
unsub |
前端 → 后端 | "取消订阅" |
⭐ 两套序列化规则 —— 这是最容易踩的坑
| 用在哪 | 命名风格 | 例子 |
|---|---|---|
| 信封本身 + 事件推送 | camelCase | {"id":"1","kind":"rpc"} |
| rpc-result 的 payload(工程模型对象) | PascalCase | {"Name":"主画面","Width":1280} |
为什么两套:工程模型对象是和磁盘上的
project.json共用同一套序列化配置的 —— 必须和文件里的 PascalCase 一致。而信封是纯通信协议,按 Web 习惯用 camelCase。
踩坑表现:混了的话,反序列化会静默产出空对象,而且 ok 还是 true。
你以为成功了,其实什么都没改。
2.2 路由怎么找处理者
前端发来 {"kind":"rpc","method":"add_tag",...},后端按这个顺序找:
① 路由器内建的一批方法(pick_file / launch_runtime / stop_runtime / preview_start / build_project …)
↓ 没匹配
② HistoryQueryBridge.Handles → query_history / aggregate_history
↓ 没匹配
③ AuthRpcDispatcher.Handles → auth_login / list_users / save_role …
↓ 没匹配
④ AiRpcDispatcher.Handles → ai_chat / ai_list_providers …
↓ 没匹配
⑤ McpControlRpcDispatcher.Handles → mcp_start / mcp_stop / mcp_status …
↓ 没匹配
⑥ AutomationRpcDispatcher → 剩下全部组态方法(约 100 个)
↓ 还没匹配
⑦ 回失败:未知方法:xxx
不是按前缀分派的 —— 是一张张显式的方法名清单。所以加一个新 RPC, 你要在对应的 dispatcher 里明确写出方法名,不会有"自动按前缀兜住"这种事。
2.3 路由器里被显式拦下的方法
有几个方法名在路由器层就被拦下了,不会往下走:
① 明确回"阶段六未开通"的(运行态写值类,刻意不开):
| 方法名 | 为什么拦 |
|---|---|
runtime_write_tag |
写 PLC 有物理后果,不经这条路 |
download_recipe |
运行态下发配方 |
capture_recipe |
运行态读回配方 |
report_get / report_record_click |
运行态报表 |
② 需要宿主帮忙的(要弹文件框、要起进程,这些只有壳做得到):
| 方法名 | 干什么 |
|---|---|
pick_project_dir |
弹"选择文件夹"框 |
pick_file / pick_save_file |
弹"打开/保存文件"框 |
import_publish_icon |
选发布用的图标 |
launch_runtime |
拉起运行态 |
stop_runtime |
停掉编辑器拉起的运行态进程(先请它体面退出跑完工程关闭脚本,超时再强杀进程树) |
open_project_folder |
在资源管理器里打开工程目录 |
preview_start / preview_stop |
画面预览的起停 |
run_script |
跑脚本 |
build_project |
编译成 exe(只有完整模式:quick != true 且指定了 outDir) |
get_ui_overrides |
读界面覆盖配置 |
write_log |
前端日志 → 写进 UiLog |
③ 兜底文案:一个都没匹配上时,回的失败信息是:
未知方法:xxx(运行态写值 runtime_write_tag 不经此路由)。
2.4 后端怎么把结果送回去
两个方向:
// 回一个 rpc-result(payload 用 PascalCase)
private void Reply(string id, AutomationResult r) { ... }
// 主动推一个事件(payload 用 camelCase)
public void PushEvent(string topic, object payload) { ... }
2.5 IWebMessageSink —— 出站的唯一出口
接口只有一个方法:
public interface IWebMessageSink
{
void Post(string json); // 往前端发一段 JSON
}
Hmi.Shell 里的实现是 WebView2Sink:
public WebView2Sink(WebView2 web) { ... }
public void Post(string json)
{
// 在 UI 线程就直接发,否则切过去再发
if (onUiThread) _web.CoreWebView2?.PostWebMessageAsJson(json);
else _web.Dispatcher.BeginInvoke(() => _web.CoreWebView2?.PostWebMessageAsJson(json));
}
为什么要有这个接口:路由器不知道前端是 WebView2 还是浏览器还是别的什么。 抽出一个"只要能发字符串"的接口,以后换成别的宿主(比如换成 Electron、或者做成 WebSocket 服务)不用改路由器。 测试里也可以塞一个假的 sink 把消息收下来断言。
⚠ CoreWebView2 还没就绪时消息会被丢掉(注意那个
?.)。所以壳里要等EnsureCoreWebView2Async完成再开始收消息。
三、实时值是怎么推到界面上的
界面上的数值要实时刷新。不可能让前端每秒发几百个请求问一遍 —— 那太浪费。 实际做法是订阅 + 推送。
3.1 订阅
前端发:
{ "kind": "sub", "topic": "tag-update", "payload": { "tags": ["Oven.Temp", "Oven.Press"] } }
后端 TagSubscriptionBridge:
public TagSubscriptionBridge(RtdbCore rtdb, IReadOnlyDictionary<string,int> idByName, IWebMessageSink sink);
public void Subscribe(IEnumerable<string> names); // 订阅
public void Unsubscribe(IEnumerable<string> names); // 取消
public void OnTagChanged(ReadOnlySpan<TagChange> batch); // 由实时库回调
public void Dispose();
订阅成功会立刻补一帧快照 —— 这样前端马上就有值显示,不用等第一次变化。
3.2 推送的形状
{
"kind": "event",
"topic": "tag-update",
"payload": {
"tags": [
{ "name": "Oven.Temp", "value": 182.5, "quality": "Good", "ts": 1758888888888, "type": "Float" }
]
}
}
| 字段 | 说明 |
|---|---|
name |
点名 |
value |
值。模拟量走 .D(double),离散量走 .L(long) —— 注意类型会变 |
quality |
质量,字符串形式(不是数字) |
ts |
时间戳,毫秒 epoch(不是 tick,不是秒) |
type |
点类型名(Bool / Int16 / Float / …)。两态控件按点域写值用:Bool 点写 "1"/"0",数值点写 "2"/"1"(两态口径是 2=开/1=关,而 Bool 装不下 2) |
⚠ 前端解析时别假设
value一定是数字 —— 布尔点会来一个0/1的 long。
3.3 背压:推不动就丢
TagSubscriptionBridge 内部有个有界队列(容量 8192),策略是 DropOldest。
意思是:如果前端处理不过来、消息堆积超过 8192 条,就把最老的丢掉。
这是故意的,不是 bug:实时值这种东西,最老的数据没意义,保住"最新的"才是对的。 但它也意味着 —— 界面上的值可能会跳变(中间某些帧被丢了)。做趋势曲线要用历史库,不要靠订阅攒数据。
3.4 历史查询走单独的桥
HistoryQueryBridge 认两个方法:
| 方法名 | 参数 | 返回 |
|---|---|---|
query_history |
tagName, fromMs, toMs, maxPoints? |
{ TagName, Points:[{TsMs,Value,Quality}], Count, Truncated } |
aggregate_history |
同上 + func |
{ TagName, Func, Value } |
func 只能是:Avg / Max / Min / Sum / Count。
maxPoints是抽稀用的:查一天的数据可能有 86400 个点,给界面画曲线只要几百个。 传了maxPoints,后端会抽稀,并把Truncated标成 true 告诉你"数据被抽过了"。
四、自动保存:改完为什么不用点保存
Hmi.Shell 里有个 AutoSaveCoordinator:
public AutoSaveCoordinator(
Dispatcher dispatcher,
Func<AutomationResult> save, // 怎么保存
Action<string> log, // 保存了往哪写日志
TimeSpan? debounce = null); // 防抖间隔,默认 1.5 秒
public void Bump(); // 标脏 + 重新排期(任意线程都能调)
public void Flush(); // 立刻落盘
怎么判断"该保存了"
自动化 API 每次改成功后会发一个 ProjectMutated 信号,协调器接住后:
- 只处理成功的操作
- 只处理会落盘的操作 ——
IsPersistingMutation判断 - 排除
get_/list_/describe_开头的方法(这些是只读) - 再排除一张 12 个方法的"不落盘"名单
防抖 1.5 秒是什么意思
你连着改 10 个属性 → 不会存 10 次盘。 每次改动都把计时器往后推 1.5 秒,直到停手 1.5 秒才真正写一次磁盘。
为什么这么做:写盘是有成本的(序列化整个工程 + 文件 I/O)。 拖着鼠标拖一个控件会触发几十次改动,不防抖的话会卡死。
⚠ 只有编辑器装配这个(
--runtime模式不装配)。运行态改的是内存,不碰工程文件。
五、运行内核进程:Hmi.RuntimeCore.Host
这是个独立的 exe,专门用来跑工程。为什么要单独一个进程?
| 好处 | 说明 |
|---|---|
| 崩了不影响编辑器 | 运行内核崩了,组态器还活着,可以重连 |
| 可以单独发给客户 | 客户机器上不用装组态器,只放运行内核 + 加密工程包 |
| 能整树杀掉 | 脚本死循环、驱动卡住,直接杀进程树 |
5.1 命令行
Hmi.RuntimeCore.Host.exe <工程目录> [--crash-after-ready <毫秒>]
| 参数 | 说明 |
|---|---|
| 第 1 个位置参数 | 工程目录(必填)。缺了直接打 RUNTIMECORE FAILED 并以退出码 2 结束 |
--crash-after-ready <ms> |
测试用的:就绪后等这么多毫秒故意崩 |
⚠ 没有环境变量兜底 —— 必须给位置参数。别指望
HMI_PROJECT_DIR之类的东西。
它的 stdout 有两句约定好的话(监督者就靠这个判活):
| 打印内容 | 什么时候 |
|---|---|
RUNTIMECORE READY |
装配工程 + 起采集成功,恰好一行 |
HEARTBEAT <n> |
之后每秒一行,n 递增 |
💡
ManagedProcess.IsStalled(timeout)判的就是"多久没收到 stdout 任何一行了"。 所以心跳停了 = 采集循环卡住了 —— 这比"进程还在不在"更早发现问题。
5.2 它启动了什么
ProjectLoader.Load(工程目录) → 读出工程
↓
new RuntimeHost(工程) → 建实时库、起采集循环
↓
RuntimeDataServices.Compose(...) → 叠上历史库、报警、脚本
↓
host.Start()
↓
StartScriptHost() → 起脚本运行时
5.3 怎么和宿主通信 —— stdio
不是命名管道,不是 socket —— 就是最朴素的标准输入输出:
| 通道 | 用途 |
|---|---|
| stdout | 控制面(就绪信号、心跳) |
| stderr | 日志 |
5.4 就绪信号与心跳
启动完成后,往 stdout 打恰好一行:
RUNTIMECORE READY
之后每秒打一行:
HEARTBEAT <次数>
为什么用"恰好一行"这么严格:父进程靠读这一行判断"起来了没有"。 如果前面多打了别的东西(比如某个库的欢迎语),父进程就可能认不出来,一直等下去。
5.5 退出码
| 码 | 意思 |
|---|---|
0 |
干净停机 |
1 |
装配异常(会往 stderr 打 RUNTIMECORE FAILED: …) |
2 |
缺参数(没给工程目录,或目录不存在) |
5.6 什么时候会停
- 收到 Ctrl+C
- 进程收到退出通知(
ProcessExit) - stdin 读到 EOF ← 这条很关键:父进程一关,stdin 就断了,子进程自己跟着退,不会变孤儿
5.7 运行态日志怎么回到组态器
运行内核是个独立进程,它的日志要显示在组态器的"输出"面板里。做法是打一个哨兵前缀:
子进程往 stdout 写:
##RTLOG##{"level":"warn","text":"烤箱温度偏高","source":"脚本","ts":1758888888888}
父进程读每一行时先看开头:
- 命中
##RTLOG##→ 拆出 JSON →PushEvent("runtime-log", ...)推给前端 - 没命中 → 当普通控制面消息处理(比如
HEARTBEAT)
为什么要这么绕:stdout 是混用的 —— 既要传控制信号,又要传日志。 加个不可能自然出现的哨兵前缀,就能把两类消息分开。
哨兵常量定义在
RuntimeLogWire.Sentinel,别在两处各写一遍字面量。
六、进程监督:Hmi.Supervisor
什么时候用:你有一个"主程序 + 一堆配套服务"的结构(比如组态器要同时跑运行内核、 MCP 服务器、数据采集服务),想让主程序统一管它们的生死。
6.1 ServiceSpec —— 描述一个服务
public sealed class ServiceSpec
{
public ServiceSpec(string name, string exePath); // 名字和 exe 路径是必填的
public string Name { get; } // 只读
public string ExePath { get; } // 只读
public string Arguments { get; init; } = "";
public string? WorkingDirectory { get; init; }
public string? ReadyMarker { get; init; } // 看到这行才算"就绪"
public TimeSpan ReadyTimeout { get; init; } = TimeSpan.FromSeconds(10);
public bool AutoRestart { get; init; } = false; // 崩了自动重启
public int MaxRestarts { get; init; } = 3; // 最多重启几次
}
6.2 ReadyMarker —— 怎么判断"起来了"
子进程的 stdout 只要包含 ReadyMarker 这个子串,就标记为就绪。
ReadyMarker留空 → 进程一起来就算就绪- 有值 → 等它打出这句话
所以
RuntimeCore.Host那句RUNTIMECORE READY就是给这里用的。
6.3 ManagedProcess —— 管一个进程
public sealed class ManagedProcess : IDisposable
{
public ManagedProcess(ServiceSpec spec);
public ServiceSpec Spec { get; }
public int ProcessId { get; } = -1; // 没起来时是 -1
public bool HasExited { get; }
public Action<ManagedProcess,int>? Crashed { get; set; } // 崩了通知你(带退出码)
public Task StartAsync(CancellationToken ct = default);
public bool IsStalled(TimeSpan timeout); // 多久没输出了?
public void Stop(TimeSpan timeout);
public void Dispose();
}
日志收集:stdout 和 stderr 都被重定向(RedirectStandardOutput/Error = true),
任何一行输出都会刷新心跳时间。
所以
IsStalled的判据是"多久没输出任何东西",不是"进程还在不在"。 一个卡死的进程照样活着,但不再输出 —— 这才叫 stalled。
6.4 ProcessSupervisor —— 管一群进程
public sealed class ProcessSupervisor : IDisposable
{
public Action<ServiceSpec,int>? ServiceAbandoned { get; set; } // 放弃重启时通知
public IReadOnlyCollection<string> RunningServices { get; }
public bool IsRunning(string name);
public Task StartSessionAsync(IEnumerable<ServiceSpec> specs, CancellationToken ct = default);
public Task StopSessionAsync(TimeSpan? timeout = null); // 默认 5 秒
public void Dispose();
}
重启策略:
子进程崩了
↓
AutoRestart == false ?
→ 触发 ServiceAbandoned,不再管它
↓ 是
重启次数 > MaxRestarts ?
→ 触发 ServiceAbandoned,放弃
↓ 否
拉起一个新的 ManagedProcess,计数 +1
停止用的是 Kill(entireProcessTree: true) —— 整棵进程树一起杀。
这条很重要:一个服务可能自己又拉起了子进程。只杀父进程会留下孤儿。 整树杀才是干净的。
6.5 一个完整例子
using Hmi.Supervisor;
using var sup = new ProcessSupervisor();
sup.ServiceAbandoned += (spec, code) =>
Console.WriteLine($"服务 {spec.Name} 崩了(退出码 {code}),已放弃重启");
var specs = new[]
{
// ⭐ 构造签名是 (服务名, exe 完整路径) —— 名字在前,别把路径塞进第一个参数
new ServiceSpec("RuntimeCore", @"C:\app\runtime\Hmi.RuntimeCore.Host.exe")
{
Arguments = @"D:\工程\产线", // 运行内核把工程目录当第一个参数
ReadyMarker = "RUNTIMECORE READY", // 它在 stdout 打这一行(精确串)
ReadyTimeout = TimeSpan.FromSeconds(20),
AutoRestart = true,
MaxRestarts = 5,
},
new ServiceSpec("McpHost", @"C:\app\mcp\Hmi.Mcp.Host.exe")
{
Arguments = @"D:\工程\产线 --http 127.0.0.1 8765",
// ⚠ 这里**故意不设 ReadyMarker** —— 原因见下面的坑
},
};
await sup.StartSessionAsync(specs);
Console.WriteLine("都在跑了");
// ... 跑一阵 ...
await sup.StopSessionAsync(); // 默认等 5 秒;参数名是 waitForExit
⚠
ReadyMarker只在子进程的stdout里找,不认stderr。Hmi.RuntimeCore.Host就绪时往 stdout 打RUNTIMECORE READY,所以能用; 而Hmi.Mcp.Host把日志全写在 stderr(stdout 留给 JSON-RPC 协议流), 给它设ReadyMarker会永远等不到 → 超时抛异常。所以要么不设(进程起来即算就绪), 要么改走 §7 的SystemMcpServerProcess(它用"起后等 600 毫秒看有没有立刻死"这套判断)。
七、MCP 服务器子进程:组态器里怎么管它
组态器界面里有个「工具 → MCP 服务器」面板,可以一键启停 MCP 服务器。 底层是这几个东西:
7.1 配置存哪
McpServerConfigStore:
public McpServerConfigStore(string? path = null);
// 默认路径:%APPDATA%\FangLikong\mcp-server.json
public string Raw(); // 读原始 JSON 文本
public void Save(string? json); // 写
public McpServerConfig Parse(); // 解析成对象
McpServerConfig 字段:
| 字段 | 默认值 | 意思 |
|---|---|---|
Transport |
"stdio" |
stdio 还是 http |
HttpHost |
"127.0.0.1" |
http 监听地址 |
HttpPort |
8765 |
http 监听端口 |
AuthToken |
"" |
访问口令(空 = 不校验) |
ExposeTools |
— | 只暴露这些工具(空 = 全部) |
ReadOnly |
false |
只读模式 |
HttpEndpoint 是算出来的:http://{Host}:{Port}/mcp。
7.2 怎么拉起来
接口叫 IMcpServerProcess,实现叫 SystemMcpServerProcess:
public interface IMcpServerProcess
{
bool IsRunning { get; }
int Pid { get; }
string Start(string projectDir, McpServerConfig cfg);
void Stop();
}
public SystemMcpServerProcess(string? exePath);
它拼出来的命令行是:
Hmi.Mcp.Host.exe <工程目录> --http <host> <port> [--token xxx] [--read-only] [--tools a,b,c]
⚠ 起来之后会
WaitForExit(600)—— 等 600 毫秒看它有没有立刻死掉。 这是为了抓"参数写错了、启动就退"这种情况,不然会显示成"启动成功"但实际早没了。
7.3 前端用的 RPC
| 方法名 | 干什么 |
|---|---|
mcp_status |
查状态 |
mcp_start |
启动 |
mcp_stop |
停止 |
mcp_get_config |
读配置 |
mcp_set_config |
写配置 |
mcp_status 返回的字段:Running / ToolCount / Transport / Endpoint / Pid / Message。
✅
ToolCount曾有一个偏差,2026-09-26 已修:McpControlRpcDispatcher里那个 "面板上显示多少个工具"的常量曾经写 79、而ProjectMcpTools实际有 84 个,导致面板少报 5 个。 现在常量已是 84,并加了一条测试(McpToolShapeTests)钉住两者必须相等。 如果你哪天加/删了 MCP 工具,除了改文档,记得回头改那个常量 —— 测试会立刻变红提醒你。
八、界面缩放记在哪
UiZoomStore —— 记住你上次把界面放大/缩小到多少:
public void Load(string role); // role: "editor" 或 "runtime"
public void Save(string role, double zoom);
// 存到 %APPDATA%\FangLikong\ui-zoom.json,值会被夹在 0.25 ~ 5.0
它接到 WebView2.ZoomFactor 上,并通过 ZoomFactorChanged 事件回写。
编辑器和运行态各存一份 —— 你在编辑器里放大到 150%,运行态不会跟着变。
九、两个日志文件别搞混
| 类 | 程序集 | 落哪 | 特点 |
|---|---|---|---|
ShellLog |
Hmi.Shell |
exe 同目录 shell.log |
只记壳自己的事(启动、WebView2 初始化)。任何失败静默 |
UiLog |
Hmi.HostBridge |
%APPDATA%\FangLikong\ui.log |
前端 write_log 打过来的日志。超过 4MB 轮转成 ui.log.1 |
ShellLog的"任何失败静默"是故意的:日志系统自己都写不进去的时候, 不该再抛异常把程序带崩。日志是尽力而为的东西。
十、这一章的几个坑
| # | 坑 | 怎么避 |
|---|---|---|
| 1 | 信封和 payload 命名风格搞混 | 信封 + 事件用 camelCase;rpc-result 的 payload 用 PascalCase |
| 2 | CoreWebView2 没就绪就发消息 | 消息会被静默丢弃。等 EnsureCoreWebView2Async 完成 |
| 3 | 忘了 IsPersistingMutation |
新加只读方法时,名字要用 get_/list_/describe_ 开头,否则会触发多余的自动保存 |
| 4 | 靠订阅攒趋势数据 | 队列满了会 DropOldest,值会跳变。要完整数据用历史库 |
| 5 | ts 当成秒或 tick |
是毫秒 epoch |
| 6 | value 假设一定是 double |
离散量是 long(.L) |
| 7 | 只杀父进程 | 用 Kill(entireProcessTree: true) |
| 8 | ReadyMarker 拼错 |
拼错就永远等不到就绪,超时后失败。要和子进程打印的那行逐字符一致 |
| 9 | stdout 混打非协议内容 | 就绪判定是"恰好一行 RUNTIMECORE READY",前面多打东西会认不出来 |
本章内容以
src/Hmi.Shell、src/Hmi.HostBridge、src/Hmi.Supervisor、src/Hmi.RuntimeCore.Host为准。发现手册和代码对不上,以代码为准,并回头修手册。