单实例、启动流程与配置文件:App 里那 526 行到底在干什么
这是「从零搭建半导体设备上位机」系列第4篇。上一篇讲了两套 MVVM 框架怎么分。这一篇拆开那个 526 行的
App.xaml.cs:启动时到底按什么顺序做事、两套配置文件各管什么、以及几个"看起来没事其实有坑"的写法。
打开这个文件之前
上一篇我说过,RegisterTypes有两百行。那剩下的三百多行在干什么?
答案是:启动流程。
工控上位机的启动比普通桌面软件麻烦得多。普通软件启动失败,用户关掉重开就行。设备上的上位机启动失败,操作员站在机器前,后面排着生产计划,你没法跟他说"重启试试"——启动阶段必须把能想到的失败都兜住,并且给出人话的错误。
这 526 行里,真正跟界面有关的不到 50 行,剩下全是"把设备连上、把配置读对、把日志开起来"。
一、OnStartup 的真实顺序
先看代码里实际的执行顺序(不是教科书上的理想顺序):
| 顺序 | 做什么 | 失败会怎样 |
|---|---|---|
| 1 | 决定日志目录 | 建目录失败 → 整个启动中断 |
| 2 | 读"开发标记"文件 | 无(只是设置一个全局标志) |
| 3 | 读AppConfig(JSON) | 解析失败 → 弹错误框后退出 |
| 4 | 读 / 补Config.ini | 文件缺失 → 从安装目录复制一份默认的 |
| 5 | 决定 ADS 的 NetID(本地 / 远程) | NetID 为空 → 弹配置确认框 |
| 6 | 创建单实例 Mutex | 已有实例 → 提示后退出 |
| 7 | 注册全局异常处理 | — |
| 8 | 进入 Prism 的CreateShell | — |
整个流程包在一个try/catch里,catch里做两件事:写日志、弹框、清理资源、Shutdown()。
1.1 日志目录不是固定的
varlogRoot=Path.Combine(Golbal.WorkRootPath+@"\Logs");if(File.Exists(@"D:\App\RoboCell\LogNet.tag"))logRoot=Path.Combine(Golbal.LotRootNetPath+@"\Logs");第一行是默认:日志写在软件自己的工作目录下。
第二行是例外:如果存在某个标记文件,日志改写到网络路径。
为什么要有这个?因为现场设备装在无尘室里,工程师不能随便进去。设备出问题的时候,要么穿无尘服进去拷日志,要么——让软件自己把日志写到网络盘上,在外面看。
一个标记文件就切换了日志落点,成本极低,收益极大。这个设计后来被复用到了好几个地方。
1.2 "开发标记"文件:用文件系统当配置开关
Golbal.IsDevDebug=File.Exists(Path.Combine(@"D:\App\RoboCell","dev.flag"));整个应用里,凡是"开发时想要、生产时不要"的行为,都挂在这个布尔值上:
- 默认启动页面不同(开发进调试界面,生产进正式界面)
- 部分调试按钮显隐
- 日志级别
为什么用文件而不是配置文件里的一个字段?
因为改配置文件要重启软件,而现场工程师不一定知道配置文件在哪。放一个空文件在固定路径,谁都能做:要开调试就建文件,要关就删掉。
代价是这个行为完全不可见——你在配置文件里找不到它,只能读代码才知道。这就是第 26 篇要讲的"调试标记系统"的由来,也是它后来必须被收口的原因。
1.3 配置文件里的"初始页面"永远不生效
这段代码值得单独看:
varprogramDefaultView=CurAppConfig.DefaultView;// 先存代码里的默认值if(!string.IsNullOrWhiteSpace(configStr)){CurAppConfig=JsonConvert.DeserializeObject<AppConfig>(configStr)!;CurAppConfig.DefaultView=programDefaultView;// 反序列化完再覆盖回去}意思是:JSON 里存的DefaultView被丢弃,永远用代码里算出来的那个。
为什么?因为默认页面依赖IsDevDebug:
publicstringDefaultView{get;set;}=Golbal.IsDevDebug?ProcessView.ViewName:DesignView.ViewName;开发模式和生产模式要进不同页面。如果允许配置文件覆盖,就出现"生产环境读了开发环境的配置,开机进调试界面"这种事——现场是要报故障的。
所以设计上直接堵死:这个字段只是"能被序列化出去给人看",不能被读回来生效。
代价是它很反直觉。后来我在字段注释里写清楚了,但注释这种东西,改代码的人不一定看。
二、两套配置文件,分工是明确的
| 文件 | 格式 | 管什么 | 谁改 |
|---|---|---|---|
Configs/AppConfig.json | JSON | 窗口宽高、是否最大化、初始页面 | 软件自己写回 |
Config.ini | INI | PLC 连接(本地/远程、NetID、端口)、数据库访问模式、传感器读取延迟、是否开机自启 | 现场工程师手改 |
为什么不用一套
因为改动频率和责任人不一样。
窗口大小是"用户拖一下就变",软件自己记;PLC 的 NetID 是"装机时配一次,之后只在换设备时改",必须是人手改,而且必须是纯文本、能拿记事本打开。
用 JSON 存 NetID,现场工程师改错一个逗号,整个文件解析失败,软件起不来。INI 容错性好得多——写错了顶多某个键读不到。
工控软件的配置文件格式选择,第一原则是"人手改不出致命错误",不是"结构优雅"。
数据库访问模式:一个枚举管四种环境
Config.ini里有个字段控制数据库连哪:
| 值 | 模式 | 说明 |
|---|---|---|
| 0 | Dev | 本地开发库 |
| 1 | Test | 测试库 |
| 2 | Product_In | 生产模式(无尘室内部网络) |
| 3 | Product_Out | 生产模式(外部网络) |
同一个软件,四种数据库落点。这不是设计得漂亮,是被网络环境逼出来的:无尘室内的网络和外面的网络不通,设备在里面跑,数据要同时给里面和外面看。
切换靠改一个数字。好处是简单;坏处是改错了没人知道——连到测试库,生产数据写进了测试表,界面上一切正常。
后来加了一条:启动时把当前数据库模式打进日志第一行,并且显示在状态栏角落。让"连的是哪个库"这件事始终可见。
三、Mutex 放在最后,是个真实隐患
单实例锁是这么写的:
IMutex=newMutex(true,"RoboCell_Run_App");if(IMutex.WaitOne(0,false)){base.OnStartup(e);DispatcherUnhandledException+=App_DispatcherUnhandledException;}else{MessageBox.Show("程序已经启动,只能运行一个实例","提示",...);Shutdown();}问题在于:它在配置读取之后。
也就是说,第二个实例启动时,会先把日志目录建一遍、读一遍配置文件、初始化一遍 ADS 参数,然后才发现"已经有实例在跑了"。
后果有三个,都不致命但很烦:
- 第二个实例会往同一个日志文件里写几行(日志框架允许共享写,但内容会交错)。
- 如果配置文件损坏,第二个实例会先弹"配置读取失败",再弹"已经启动",两个框叠在一起。
- 启动慢——读了配置才退出,白白多做几百毫秒。
正确顺序是把 Mutex 提到最前面,进OnStartup第一件事就是抢锁,抢不到直接退出。
当时没这么改,是因为"先读配置"这个顺序已经跑了两年没出过事——没出过事不等于没问题,只是没被触发过。
踩坑记录
坑一:配置文件损坏 = 软件起不来
catch里是这么处理的:
catch(Exceptionex){AppLog.Error("读取App配置文件失败,请联系开发人员!",ex);MessageBox.Show($"读取App配置文件失败...\r\n错误信息:{ex.Message}","错误",...);CleanupResources();Shutdown();}合理,但不够。
真实情况是:配置文件损坏之后,每次启动都弹同一个框然后退出,操作员没有任何自救手段——他不知道文件在哪、也不知道该删哪个。
后来改进成:配置文件解析失败时,自动备份坏文件并回退到默认配置,同时在界面上用红色横幅提示"配置已重置,请检查 PLC 连接参数"。
原则是:能让软件起来就先起来,起来之后再用显眼的方式告诉人哪里不对。一个连界面都进不去的软件,现场只能叫工程师。
坑二:本地 PLC 和远程 PLC 走的是两条路
if(AppIniConfig.IsLocalPlc)_netID=AmsNetId.Local.ToString();else_netID=AppIniConfig.PlcNetId;本地模式下 NetID 用本机 AMS 地址,远程模式用配置里的。
这条分支看着简单,实际踩过:开发机上IsLocalPlc=true,装机时忘了改,软件能连上(因为开发机上也装了 TwinCAT 运行时),但连的是本机的虚拟 PLC,不是设备上的真 PLC。
现象是:界面数据全在动,但都是假的。查了两小时才反应过来。
后来加了一条启动日志:连接成功之后,把实际的 NetID 和端口打印出来,并且和配置文件里的值做一次比对,不一致就告警。
坑三:C:\下的路径
早期所有路径都硬编码在C:\下(日志、配置、标记文件)。装机时遇到两个问题:
- 部分设备的系统盘开了还原,重启后
C:\下的配置全没了。 - 没有管理员权限时,往
C:\根目录写文件会被拦。
现在统一挪到了软件目录下(Golbal.WorkRootPath),只有标记文件还留在固定路径——因为它需要在软件目录之外,否则重装软件会把它一起删掉。
“标记文件要独立于软件目录”这个原则,是从"重装软件把调试开关一起删了、结果生产环境进了调试界面"那次事故之后定下的。
本篇小结
- 启动流程的顺序是有讲究的:日志先起、配置后读、锁要最先抢。
- 两套配置文件按"谁改、改多频繁"分:JSON 给软件写,INI 给人改,因为人手改不出致命错误更重要。
- 文件系统当开关(标记文件)成本低、可见性差,用完必须有收口机制。
- 配置文件损坏时的正确做法是降级启动,不是弹框退出——现场没人能改代码。
下一篇进界面层。三套 UI 库(HandyControl、MaterialDesign、Wu.Wpf)同时装在一个 WPF 应用里,样式到底怎么不打架。
下期预告
第5篇:三套 UI 库共存:HandyControl、MaterialDesign、Wu.Wpf 的样式基线