ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C# WPF安装包制作:Inno Setup打包与部署避坑指南

C# WPF安装包制作:Inno Setup打包与部署避坑指南 1. 为什么WPF程序打包这件事比想象中要深做C#/WPF桌面开发的人大概都有过这种体验在Visual Studio里按F5跑得好好的程序发给同事或者客户之后对方回一句打不开提示缺文件报了个错。这时候你才发现从我这里能跑到用户双击就能装、装完就能用中间隔着一条比写业务代码更磨人的沟。本文要聊的就是怎么用Inno Setup把WPF程序打包成一个像样的安装包让交付这件事不再靠U盘拷文件夹加远程指导。先把话说明白这篇内容适合两类人。一类是刚开始接桌面项目、还没正式做过安装包的C#开发者另一类是做过打包但总觉得做出来的东西不够正规、想补齐细节的老手。它解决的核心问题有三个——怎么让程序不依赖用户机器上预装的运行时、怎么让安装过程少出幺蛾子、怎么让卸载和升级都能干净利落。C#、WPF、Inno Setup这几个关键词会贯穿全文但我不会只给你一段脚本让你抄而是把每一步为什么这么做讲清楚。毕竟打包脚本这东西抄别人的十有八九会在自己项目上翻车因为路径、依赖、运行时版本全都不一样。安装包看起来是个小活但它直接决定了别人对你整个项目的第一印象。一个双击没反应、图标是默认白纸、卸载后残留一堆文件夹的安装包技术上也许没毛病可用的人心里已经给你的软件打了折。所以这个环节值得认真对待。2. 打包前的准备工程侧改造比写脚本更重要很多人一上来就打开Inno Setup写脚本写到一半发现文件漏了、运行时没带、程序一运行就崩然后又回头改工程。折腾几轮下来才发现真正该先做的是工程侧的准备。脚本只是把成果装进盒子盒子装什么、怎么装得牢是打包前就要定好的事。2.1 发布模式选择自包含还是依赖运行时这是打包WPF程序的第一个分叉路口也是后面所有麻烦的源头。发布方式大致两种一种是把.NET运行时一起打进发布产物叫自包含发布self-contained另一种是只发布程序本身运行依赖用户机器上的运行时叫框架依赖发布framework-dependent。自包含发布的好处是彻底。用户机器上哪怕一个.NET都没装双击也能跑不用在安装过程中弹窗让别人去下载运行时。代价是体积膨胀得厉害一个简单的WPF工具自包含发布之后动辄一百多兆。框架依赖发布产物体积小可能就几兆到十几兆但前提是目标机器上有对应版本的运行时——.NET 8的WPF程序跑在只装了.NET 6的机器上照样起不来。我的选择逻辑很简单给企业内部同事用、且能确认机器环境统一的走框架依赖对外分发、目标环境完全不可控的走自包含。中间还有个折中方案就是用PublishSingleFile把自包含产物压成一个exe但要注意WPF对单文件发布的支持虽然已经成熟某些依赖原生库的第三方组件仍然会出问题比如一些做工业视觉、相机采集的库它们的原生dll在单文件模式下解压路径会变导致运行时找不到。命令层面自包含发布大概是这样dotnet publish MyWpfApp.csproj -c Release -r win-x64 --self-contained true -p:PublishReadyToRuntruePublishReadyToRun这个参数值得单独说一句。它会把程序集预编译成接近原生代码的形式好处是冷启动更快WPF这种启动时要加载一大堆XAML和程序集的框架开了之后体感差别很明显。代价是产物更大而且某些用反射比较重的库可能有兼容问题上线前一定要在干净环境里实测。2.2 项目文件里那几行必须改的配置与其每次在命令行里敲一长串参数不如把关键配置写进.csproj文件这样在IDE里发布和命令行发布结果一致少一份踩坑的几率。下面这几行是我几乎每个WPF项目都会加的PropertyGroup OutputTypeWinExe/OutputType TargetFrameworknet8.0-windows/TargetFramework UseWPFtrue/UseWPF RuntimeIdentifierwin-x64/RuntimeIdentifier SelfContainedtrue/SelfContained PublishReadyToRuntrue/PublishReadyToRun AssemblyNameMyWpfApp/AssemblyName Version1.2.0/Version FileVersion1.2.0.0/FileVersion AssemblyVersion1.2.0.0/AssemblyVersion ApplicationIconAssets\app.ico/ApplicationIcon Platformsx64/Platforms /PropertyGroup这里有几个容易忽略的点。AssemblyName决定了生成的exe叫什么如果你的项目名带中文或者奇怪的缩写最好在这里改成一个英文、无空格的名字否则后面Inno Setup脚本里到处是转义还容易在某些系统上因为编码问题出岔子。Version这几个版本号要一起改安装包显示版本、文件属性里的版本、程序里Assembly.GetExecutingAssembly().GetName().Version读到的版本是三处独立的地方只改一个会出现安装包显示1.2程序里显示1.0的尴尬。ApplicationIcon是那个让程序在任务栏、资源管理器里看起来不像个半成品的关键。ico文件要求包含多个尺寸16、32、48、256很多在线转换工具生成的只有单一尺寸缩放到小尺寸会糊。这个细节不处理你的软件图标在任务栏上永远是毛边。2.3 那些不会自动进发布产物的文件发布产物不是项目里所有文件都会带上的。典型漏网之鱼包括手动放在项目目录里的配置文件、数据库文件、模板文件、日志目录的占位文件、第三方组件的原生dll目录。判断一个文件会不会进发布产物看它在.csproj里的生成操作属性是不是Content、None以及有没有配CopyToOutputDirectory。正确的做法是这样写ItemGroup None UpdateConfig\settings.json CopyToOutputDirectoryPreserveNewest/CopyToOutputDirectory /None None UpdateTemplates\report.docx CopyToOutputDirectoryPreserveNewest/CopyToOutputDirectory /None /ItemGroupPreserveNewest和Always的区别在于前者只在源文件比目标新时才复制增量发布更快后者每次都复制简单粗暴但慢。日常开发用PreserveNewest就行。配置文件的处理还要多想一层有些配置是打包时给的默认值运行时要能改。这类文件不能放在{app}这个安装目录下直接读写因为程序装到Program Files里之后普通用户权限是写不进去的。正确位置是%APPDATA%或者%LOCALAPPDATA%下的用户目录。这个坑我见过太多次开发时习惯性用相对路径读写配置装到用户机器上就成了设置保存不了。3. Inno Setup脚本核心结构怎么搭准备工作做完才轮到Inno Setup登场。它的脚本文件叫.iss本质是一个分节的INI风格配置。整份脚本里真正决定成败的是[Setup]、[Files]、[Icons]、[Run]和[Code]这几段。下面按重要性拆开讲。3.1 [Setup]段安装包身份证[Setup]段定义了安装包的基本信息写错了轻则界面难看重则装不上或者装到错误位置。下面是一份我常用的模板[Setup] AppId{{8F3A2B1C-4D5E-6F70-8192-A3B4C5D6E7F8} AppNameMyWpfApp AppVersion1.2.0 AppPublisherYourCompany AppPublisherURLhttps://example.com DefaultDirName{autopf}\MyWpfApp DefaultGroupNameMyWpfApp DisableProgramGroupPageyes OutputDirOutput OutputBaseFilenameMyWpfApp_Setup_1.2.0 Compressionlzma2/max SolidCompressionyes WizardStylemodern PrivilegesRequiredadmin ArchitecturesInstallIn64BitModex64compatible ArchitecturesAllowedx64compatible UninstallDisplayIcon{app}\MyWpfApp.exe SetupIconFileAssets\setup.ico AppMutexMyWpfApp_SingleInstance_Mutex CloseApplicationsyes RestartApplicationsno逐个说关键项。AppId那个大括号里的GUID是安装包的唯一标识升级时必须保持不变否则系统会认为这是两个不同的软件装出两套来。DefaultDirName里的{autopf}是64位系统的Program Files比写死路径靠谱得多。Compressionlzma2/max是压缩率和速度比较平衡的档位如果追求极致小巧可以上lzma2/ultra64但编译时间会明显变长。PrivilegesRequiredadmin决定了安装时要不要弹UAC。装到Program Files就必须是admin如果你的程序是给普通用户装的绿色工具可以考虑lowest装到用户目录。ArchitecturesInstallIn64BitMode和ArchitecturesAllowed这对参数要配合用我一般统一按x64处理现在32位机器已经很少见了。AppMutex这个参数很关键它和程序里的单实例互斥锁配合能在安装前检测到程序正在运行避免覆盖安装时提示文件被占用。用法是程序启动时创建一个同名Mutex细节后面在C#代码那节讲。SolidCompressionyes会启用固实压缩把多个文件当一个整体压缩压缩率更高但代价是安装时解压慢一丢丢。对于文件数量多、总体积大的程序差别明显我一般开着。3.2 [Files]段把发布产物搬进去[Files]段负责告诉安装包哪些文件要装到哪里。最常见的写法只有一行[Files] Source: publish\*; DestDir: {app}; Flags: ignoreversion recursesubdirs createallsubdirsSource指向前面dotnet publish生成的目录DestDir{app}就是安装目标目录recursesubdirs createallsubdirs保证子目录也被完整复制。ignoreversion让安装包不比较文件版本直接覆盖——对于自己完整发布的程序这个就没必要比比较反而容易漏文件。如果要单独处理某些文件比如用户数据文件不能覆盖就得拆开写[Files] Source: publish\*; DestDir: {app}; Flags: ignoreversion recursesubdirs createallsubdirs; Excludes: Config\settings.json Source: publish\Config\settings.json; DestDir: {app}\Config; Flags: onlyifdoesntexist uninsneveruninstallonlyifdoesntexist表示目标文件不存在才复制uninsneveruninstall表示卸载时不删。这两个参数放在一起就实现了首次安装给默认配置升级不覆盖用户配置卸载不删用户配置。凡是涉及用户数据的文件这两个标志几乎都要成对出现。还有一个常见的误区有人把发布产物手动拷到脚本目录下再引用结果每次发布都要手动同步一次早晚出错。正确做法是让Source直接指向bin\Release\net8.0-windows\win-x64\publish这个目录或者用构建脚本先publish再编译iss。3.3 [Icons]、[Run]、[Tasks]三兄弟的分工这三段常常一起出现但职责完全不同。[Icons]管快捷方式[Tasks]管可选项比如创建桌面图标那个勾选框[Run]管安装完成后的动作。[Tasks] Name: desktopicon; Description: 创建桌面快捷方式; GroupDescription: 附加任务:; Flags: unchecked [Icons] Name: {autoprograms}\MyWpfApp; Filename: {app}\MyWpfApp.exe; WorkingDir: {app} Name: {autodesktop}\MyWpfApp; Filename: {app}\MyWpfApp.exe; Tasks: desktopicon; WorkingDir: {app} [Run] Filename: {app}\MyWpfApp.exe; Description: 启动 MyWpfApp; Flags: nowait postinstall skipifsilent[Tasks]里的unchecked表示默认不勾选桌面图标这个小细节能减少对用户桌面的污染体验加分。[Icons]里的WorkingDir一定要设而且最好指向{app}因为很多程序会以当前工作目录为基准去找相对路径的资源文件不设的话启动目录是System32之类的地方配置文件立刻找不到。[Run]那段里的postinstall让这个启动动作出现在安装向导的最后一页就是完成按钮旁边那个启动程序勾选skipifsilent保证静默安装时不会自动弹出程序。nowait表示不等待程序退出就结束安装流程这个在处理长期运行的桌面程序时是必须的。3.4 中文语言包别忘加载Inno Setup 6自带的中文简体翻译在Languages\ChineseSimplified.isl但默认不加载不显式声明的话安装向导全是英文。加一段就行[Languages] Name: chinesesimplified; MessagesFile: compiler:Languages\ChineseSimplified.isl如果希望同时提供中英文让用户选就再加一行英文的。compiler:开头表示这个文件在Inno Setup安装目录里找不需要你把isl文件拷到项目里。注意Inno Setup 6.x不同小版本之间中文语言包可能有细微差异升级Inno Setup之后建议在虚拟机里跑一遍向导确认没有出现未翻译的原文或者乱码。4. 进阶让安装包像个正经商业软件基础脚本能跑通之后剩下的功夫都在细节上。这一节讲几个能让安装包从能用升级到专业的点也是我做项目时一定会补上的部分。4.1 依赖检测与运行时引导走框架依赖发布的时候安装前必须确认目标机器上有对应的运行时。.NET Core / .NET 5的检测方式跟老的.NET Framework不一样不能只查NDP\v4\Full那个注册表键了那个只管到.NET Framework 4.x。一个可靠的检测办法是查HKLM\SOFTWARE\dotnet\Setup\InstalledVersions\x64\sharedhost这个位置或者调用dotnet --list-runtimes看输出。前者更直接适合在[Code]段里判断function IsDotNetRuntimeInstalled(): Boolean; var Version: String; begin Result : RegQueryStringValue(HKLM, SOFTWARE\dotnet\Setup\InstalledVersions\x64\sharedhost, Version, Version) and (Version ); end;这个函数只告诉你装了至少一个.NET运行时但装的是不是你的程序要求的那一版比如WPF要的是Microsoft.WindowsDesktop.App而不是Microsoft.NETCore.App它区分不出来。严格的做法是读HKLM\SOFTWARE\dotnet\Setup\InstalledVersions\x64\sharedfx\Microsoft.WindowsDesktop.App下面各个版本号的子键然后比较最低版本。如果检测到没装理想方案是引导用户去下载而不是自己把整个运行时塞进安装包——那等于变相做了自包含发布。Inno Setup 6提供了下载支持可以在[Code]里用idpAddFile加idpDownloadAfter组合在安装过程中下载运行时安装包并静默执行procedure InitializeWizard(); begin if not IsDotNetRuntimeInstalled() then begin idpAddFile(https://your-mirror/windowsdesktop-runtime-win-x64.exe, ExpandConstant({tmp}\dotnet-runtime.exe)); idpDownloadAfter(wpReady); end; end;idpDownloadAfter(wpReady)表示在向导进行到准备安装那一步之后开始下载。这里的下载地址最好指向自己的内网镜像或者可信渠道不要写一个随时可能失效的公开链接。运行时下载失败要有兜底提示告诉用户手动装运行时之后再运行安装包。4.2 卸载时把用户数据一起清干净用户卸载软件的时候心态通常是我不想在这台机器上再看到任何跟它有关的东西。但默认情况下Inno Setup只删它自己知道要删的文件——也就是[Files]段里登记过的。程序运行时生成的日志、缓存、用户配置都存在%APPDATA%或者%LOCALAPPDATA%下卸载后一律留下。这两种需求都存在配置要保留用户重装后还在和全部清干净回收机器所以最稳妥的做法是在卸载时弹一个选择。用CurUninstallStepChanged事件可以实现procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); var DataDir: String; begin if CurUninstallStep usPostUninstall then begin DataDir : ExpandConstant({userappdata}\MyWpfApp); if DirExists(DataDir) then begin if MsgBox(是否同时删除用户配置和缓存数据, mbConfirmation, MB_YESNO) IDYES then DelTree(DataDir, True, True, True); end; end; end;usPostUninstall表示主卸载流程完成之后触发。DelTree的第三个参数是删所有文件第四个是删子目录。{userappdata}对应%APPDATA%{localappdata}对应%LOCALAPPDATA%看你的程序把数据存在哪就清哪个最好是两处都清。提示卸载时删数据的代码一定要放在usPostUninstall而不是usUninstall阶段。放在前面的话如果数据目录里有文件被占用删除会失败而且可能连带影响主卸载流程。4.3 覆盖安装与文件正在运行桌面程序最容易踩的坑就是覆盖安装时目标exe正在运行Windows不允许覆盖正在执行的exe安装向导就会卡在文件被占用请关闭程序上。Inno Setup的CloseApplicationsyes配合AppMutex能自动解决这个问题。AppMutex参数前面提到过它要求程序启动时创建一个同名的系统级Mutex。在WPF里实现单实例的同时顺手就把这个Mutex建出来了public partial class App : Application { private static Mutex _singleInstanceMutex; protected override void OnStartup(StartupEventArgs e) { _singleInstanceMutex new Mutex(true, MyWpfApp_SingleInstance_Mutex, out bool createdNew); if (!createdNew) { MessageBox.Show(程序已经在运行中。, 提示, MessageBoxButton.OK, MessageBoxImage.Information); Shutdown(); return; } base.OnStartup(e); } }这里的MyWpfApp_SingleInstance_Mutex必须和iss脚本里AppMutex后面的字符串完全一致包括大小写。名字对不上安装程序就检测不到程序在运行CloseApplications也就失效了。程序退出时要释放Mutexprotected override void OnExit(ExitEventArgs e) { _singleInstanceMutex?.ReleaseMutex(); _singleInstanceMutex?.Dispose(); base.OnExit(e); }顺带一提单实例这个功能本身就值得做。很多桌面工具类程序如果允许开多个实例会导致它们同时读写同一份配置文件出现设置改了又变回去的诡异现象。用Mutex是最简单可靠的方案比检测进程名的做法稳妥——进程名检测在用户重命名了exe之后就会失效。4.4 代码签名消除未知发布者安装包发给用户之后Windows SmartScreen那个蓝色警告框几乎必然出现Windows已保护你的电脑。这不是杀毒软件误报而是因为exe没有有效的代码签名证书。代码签名证书价格不便宜个人开发者未必愿意为小工具买单。但内部项目可以自建证书体系用企业内部受信任的根证书给安装包签名这样在域内机器上就不会弹警告了。签名工具用signtool.exeInno Setup脚本里可以在[Setup]段加SignTool引用让编译时自动签[Setup] SignToolmysigntool SignedUninstalleryes然后在Inno Setup的配置里注册mysigntool指向你的签名命令。SignedUninstalleryes会连卸载程序也一起签否则卸载的时候又会弹一次警告。如果没有证书那就诚实一点在安装向导首页加一段说明文字告诉用户为什么会出现警告、点更多信息再点仍要运行即可。这比什么都不说要体面。5. 踩坑实录与常见问题速查前面讲的都是应该怎么做这一节讲实际会怎么翻车。这些问题我在不同项目里几乎都遇到过整理出来能省不少排查时间。5.1 本机能跑装完就打不开这个现象最常见原因也最杂。按出现频率排个序第一是缺运行时。框架依赖发布但目标机器没有对应版本双击exe直接闪退或者弹一个.NET下载页。这种情况在开发机上永远不会出现因为开发机的运行时是齐的。第二是缺依赖的原生dll。有些第三方组件尤其是硬件SDK、图像处理库会在安装时把dll放到系统目录或者自己注册而不是跟着发布产物走。发布产物里少了这些dll程序启动时就报DllNotFoundException。解决办法是找到这些dll手动加进.csproj的None项并设置复制。第三是路径里有空格或中文。这个问题现在少多了但某些老组件仍然处理不了。测试的时候把程序装到C:\Program Files\测试 目录\这种路径下跑一遍能提前发现。第四是配置文件的读写路径假设错误。前面提过开发时用相对路径没问题装到Program Files下就没权限了写配置直接抛异常。排查这类问题最有效的办法不是猜是在一台干净的虚拟机或者一台没装过开发环境的同事机器上实际装一遍、跑一遍。开发机上的能跑毫无参考价值。5.2 杀毒软件报毒与中文路径的坑Inno Setup默认的压缩方式偶尔会被某些国产杀软报毒。原因通常有两个一是安装包用了比较激进的压缩比如加了UPX壳二是安装包本身没有签名行为特征和某些恶意软件相似。解决路径基本就是签名。另外避免用UPX之类的额外加壳Inno Setup自带的lzma2压缩已经够用。如果还是报把安装包提交给杀毒厂商的白名单申诉流程走一遍比在代码里折腾有效。中文路径的问题前面提了这里补充一个更隐蔽的Source路径里有中文时某些版本的Inno Setup编译时会报编码错误。稳妥的做法是整个项目路径和中转目录都用纯英文这不算麻烦却能避免一堆玄学问题。5.3 升级之后用户的配置丢了这个问题往往出在[Files]段没加onlyifdoesntexist和uninsneveruninstall两个标志上。覆盖安装时安装包把默认配置又复制了一遍用户的设置被无条件覆盖。还有一种情况是配置的存储位置变了。比如从1.0版本的%APPDATA%\MyApp\config.json换到了1.2版本的%LOCALAPPDATA%\MyApp\config.json老用户升级之后程序读不到旧配置看起来就像配置丢了。这种情况要在程序里写一段迁移逻辑启动时检测旧路径存在就搬过去。5.4 常见问题速查表下面这张表把上面提到的以及一些常见问题汇总了遇到问题可以先按这个对照排查现象可能原因排查方向安装包双击无反应被杀软拦截或文件损坏换机器测试查看杀软日志安装后双击exe闪退缺.NET运行时检查目标机已安装运行时版本报 DllNotFoundException缺第三方原生dll对比发布产物和开发环境依赖设置保存不了配置文件写在Program Files改用%APPDATA%或%LOCALAPPDATA%覆盖安装提示文件占用程序未退出、AppMutex不匹配确认Mutex名称与脚本一致卸载后残留文件夹数据目录未清理加CurUninstallStepChanged处理安装向导显示英文未加载中文语言包检查[Languages]段配置快捷方式启动报找不到文件WorkingDir未设置在[Icons]里设WorkingDir{app}升级后配置被覆盖缺onlyifdoesntexist标志调整[Files]段标志这张表不是万能的但覆盖了八成以上的日常问题。剩下的两成靠的是在干净环境里多装几次、多看日志。Inno Setup的日志可以用/LOGinstall.log参数生成安装出问题时这个日志比任何猜测都有用。6. 把打包接进自动化流程手工点发布、手工开Inno Setup编译、手工改版本号这套流程做一次两次还行项目一旦进入迭代期就变成负担还容易漏改版本号。把打包串成一条命令是很值的投入。最直接的做法是写一个构建脚本比如build.ps1param([string]$Version 1.0.0) # 1. 更新版本号可以改成读取参数或git tag (Get-Content .\MyWpfApp.csproj) -replace Version.*/Version, Version$Version/Version | Set-Content .\MyWpfApp.csproj # 2. 发布 dotnet publish .\MyWpfApp.csproj -c Release -r win-x64 --self-contained true -p:PublishReadyToRuntrue # 3. 编译安装包 $iscc C:\Program Files (x86)\Inno Setup 6\ISCC.exe $iscc /DMyAppVersion$Version .\Installer\setup.iss脚本里通过/D参数把版本号传给iss文件iss里用{#MyAppVersion}引用[Setup] AppVersion{#MyAppVersion} OutputBaseFilenameMyWpfApp_Setup_{#MyAppVersion}这样版本号就只有一个来源改一处全联动。接到CI上比如内部用的流水线之后每次打tag自动出安装包归档到指定目录人工只需要点一下触发。我个人习惯再加一步安装包生成之后在干净虚拟机里自动跑一次静默安装和卸载确认没有残留。静默安装用/SILENT /SUPPRESSMSGBOXES /NORESTART卸载用unins000.exe /SILENT跑完之后检查目标目录和用户数据目录是否清空。这套流程搭起来要花半天但后面每一次发版都在省时间。最后分享一个我踩过好几次的教训打包脚本和构建脚本一定要进版本控制。我遇到过把iss文件放在本地某个临时目录换电脑之后找不到只能凭记忆重建重建出来的和之前那个有细微差别导致某台机器上装出来的程序行为不一样。现在我的习惯是Installer/目录和build.ps1跟源码放在同一个仓库里任何一次发版对应的打包配置都能追溯。这个习惯不花什么成本但在需要回滚或者复现历史版本的时候能救命。
返回列表