简介:这份压缩包是一套面向VC++初学者与Windows桌面开发入门者的安装向导源代码,围绕软件安装流程演示如何组织欢迎页、许可协议、安装目录选择与进度提示等典型向导环节,并以偏传统的界面风格呈现,便于理解安装程序的基本结构。包内共26个文件,以9个头文件和8个cpp源文件为主体,配合rc资源脚本、ico图标、avi动画以及dsp、dsw工程文件,压缩包仅48KB,体量轻巧却保留了较完整的MFC工程骨架。已有213人学习或下载,适合作为小型参考案例。读者可从中观察多对话框类之间的切换与参数传递、TAB控件在向导页中的使用方式,以及资源脚本与工程配置的对应关系,并借助随附说明快速编译运行;对希望弄懂安装向导从界面到流程控制如何落地的新手,具有直接的借鉴价值。
1. 三十台机器装同一个工具,最后装出了三种目录结构
上个月帮一个做产线检测的团队收尾,他们要把自己写的小工具铺到三十多台工控机上。做法很原始:压缩包发过去,让对方自己解压、自己拖快捷方式。结果十台机器里有三台把目录建到了桌面,还有两台压根没解压就双击了 exe,运行起来当然报找不到配置。负责人问我有没有能直接改的安装向导骨架,不想为了这点事去啃一整套打包脚本。我翻出来的就是这份 VC 安装向导源代码,MFC 属性表向导,五六个页面点下去,真正干的活其实全是文件操作:建目录、拷文件、写快捷方式、登记卸载信息。它不追求做成通用安装器,胜在够小、能读、能改,适合手上有 VC 环境、需要给自己程序配一个体面入口的开发者,也适合想搞清楚安装器内部到底做了哪些文件操作的人。
2. 项目定位与技术选型:MFC 属性表向导加 Win32 文件操作的最小闭环
2.1 它解决的是什么问题:一个「够用就好」的安装器骨架
市面上的通用打包工具能做的事很多,代价是你得学一套脚本语法,出问题时还得去读别人的日志。这份源码走的是另一条路:把安装过程当成一个普通的 MFC 对话框程序来写,页面是你的对话框资源,逻辑是你的 C++ 代码,出问题直接下断点,不用猜黑匣子里发生了什么。
它覆盖的安装动作很朴素,但正好是绝大多数小工具需要的那几件:欢迎页确认、许可协议、选择安装路径并检查磁盘空间、显示进度条并支持取消、完成后可选立即启动。这五步对应的文件操作链路是:解析目标路径 → 递归创建目录 → 遍历源目录逐文件拷贝 → 创建桌面和开始菜单快捷方式 → 往注册表写卸载项。链路清晰,没有多余的抽象层,你在第 4 章会看到,整条链路的核心代码加起来不到三百行。
这套东西适合谁?给内部工具做分发的人、需要给自家 exe 配一个「双击就能装」入口的独立开发者、以及带学生做课程设计时想讲清楚安装器原理的人。它不适合需要增量更新、需要代码签名回滚、需要多语言切换的商业发布场景,那些需求会把这套骨架撑爆。
2.2 技术栈拆解:CPropertySheet 页面栈、资源段嵌入、Shell 与注册表 API
界面层用的是 MFC 的CPropertySheet加一组CPropertyPage,打开向导模式后系统会自动给你「上一步 / 下一步 / 完成 / 取消」四个按钮,页面切换、按钮启用状态、键盘回车响应都由框架托管。这是这套代码最省事的地方:你只需要关心每一页的数据校验和页面之间的跳转关系,按钮的显示逻辑基本不用自己写。
文件操作层是纯 Win32 API,没有套CFile之类的封装。目录遍历用FindFirstFile/FindNextFile,创建目录用CreateDirectory,拷贝用CopyFileEx,删除用DeleteFile加RemoveDirectory。这套组合的好处是进度回调、取消、错误码都是现成的,CopyFileEx直接支持进度回调函数,GetLastError能告诉你到底是权限不够还是磁盘满了。
外围还有三块:一是IShellLink,用来生成.lnk快捷方式,注意它需要在 COM 初始化之后才能用;二是注册表 API,往系统约定的卸载信息位置写DisplayName、UninstallString这类值,控制面板的「程序和功能」才认;三是资源段,待安装的文件既可以放在 exe 同目录的payload文件夹里随包分发,也可以用RCDATA类型编进 exe 内部,运行时用FindResource/LoadResource/LockResource取出来写到磁盘。前者改文件方便,后者分发时只有一个 exe 更干净。
2.3 目录与文件构成:一份源码里各文件的分工
解压之后的结构各家版本会有出入,但骨架基本是下面这样。我给每行补了改动频率,你二次开发时按这个优先级去看代码,能少走很多弯路。
| 文件 / 目录 | 职责 | 改动频率 |
|---|---|---|
InstallApp.cpp/.h | CWinApp派生类,程序入口、命令行解析、COM 初始化 | 低 |
InstallSheet.cpp/.h | CPropertySheet派生类,页面容器的持有者 | 中 |
PageWelcome.cpp | 欢迎页,只放说明文字和版本号 | 低 |
PageLicense.cpp | 许可协议页,通常是一个只读的CEdit或多行静态文本 | 中 |
PagePath.cpp | 路径选择页,含「浏览」按钮和磁盘剩余空间检查 | 高 |
PageProgress.cpp | 进度页,进度条 + 当前文件名 + 取消按钮 | 高 |
PageFinish.cpp | 完成页,含「立即运行」复选框 | 中 |
FileOps.cpp/.h | 目录遍历、递归建目录、拷贝、清理 | 高 |
ShellUtil.cpp | 快捷方式创建、启动外部进程 | 中 |
RegUtil.cpp | 卸载项写入、文件关联登记 | 中 |
res/*.rc2、app.manifest | 图标、版本信息、权限清单 | 中 |
payload/或RCDATA资源 | 待安装的实际文件 | 每次发版 |
看这份清单就能明白设计意图:页面负责交互,FileOps负责所有落盘动作,ShellUtil和RegUtil负责系统集成。这种切分的价值在于,你换界面的时候不用碰文件操作,改安装逻辑的时候不用碰页面。我见过不少自己写的安装器把拷贝代码直接塞在OnWizardNext里,后来加个「静默安装」就得把整段逻辑复制一遍,那种代码改两次就没人敢动了。
2.4 边界在哪:什么场景别用它
有几个场景我会直接劝人换方案。第一是安装包超过几百兆,这种体量用RCDATA编进 exe 会让内存占用飙升,得改成边解压边写盘,或者干脆外部payload加压缩。第二是需要增量升级,这套骨架没有版本比对和差分能力。第三是需要安装失败回滚,它只有「取消时删掉已拷贝的文件」这种粗糙清理,如果中途改过注册表、装过服务,回滚逻辑得自己补。
第四是分发到不受控的机器上。没有数字签名的安装包在陌生环境里会被各种安全提示拦,这不是代码能解决的问题。想清楚这四条边界,你就知道它是个内部工具分发的利器,不是一个通用的安装器产品。
3. 编译与首次运行:把 .rar 变成一个能弹出向导的 exe
3.1 解压与工程载入:先确认能打开、能编译
.rar解出来之后,先找.sln或者.dsp/.vcxproj文件。老一点的工程只有.dsp,新版本 IDE 打开时会提示转换,转换过程一般不会丢东西,但转换完记得看一眼资源视图里的对话框还在不在,我遇到过转换后.rc里的中文全变问号的情况,那是编码问题,第 5 章会讲。
打开工程后不要急着编译,先做三件事。第一,看解决方案配置管理器里的平台是Win32还是x64,如果你的目标机器是 64 位且程序本身要用到大内存,就把两个平台都配上,安装器本身用Win32编译更省事,它在 64 位系统上照样跑。第二,右键工程看属性里的「MFC 的使用」是否为空,如果是空的,说明这份工程原本是纯 Win32 写的,代码里就不会有CPropertySheet,那你看到的页面切换逻辑会是手写的ShowWindow,读代码前先确认这一点。第三,检查payload目录或者资源文件里的待安装文件是不是齐全,不少流传的源码包会把这个目录清空,编译能过,但跑起来拷不出东西。
编译前还有一个容易忽略的点:工程里如果引用了zlib、lzma这类压缩库,源码包一般会带lib或者第三方源码目录。缺失的话链接会报一堆LNK2019,这时候别急着怀疑代码,先看附加库目录里配的路径是不是还指向原作者机器上的绝对路径,那是最常见的链接失败原因。
3.2 三处必须改的工程属性:字符集、运行库、MFC 链接方式
这份源码默认的工程配置大概率是作者当年的环境,拿到自己机器上要改三处。改错任何一处,表现形式都是「编译通过、运行异常」,排查起来很烦。
| 属性位置 | 建议值 | 改错的表现 |
|---|---|---|
| 配置属性 → 高级 → 字符集 | 使用 Unicode 字符集 | 混用char*和CString时编译报错,或界面中文变方块 |
| C/C++ → 代码生成 → 运行库 | 多线程(/MT) | 换台没装运行库的机器,双击提示缺少 dll |
| 常规 → MFC 的使用 | 在静态库中使用 MFC | 同上,且错误提示里会出现mfc140u.dll之类的名字 |
运行库和 MFC 这两项要一起改。原理很简单:动态链接意味着目标机器上必须有对应版本的运行库和 MFC 库,工控机、内网机器、客户的老电脑上大概率没有;静态链接会把库代码打进 exe,体积大一点,但拷过去就能跑,这对一个安装器来说是必要的取舍,因为安装器本身就是「在干净机器上第一个运行的程序」,它不能有依赖。
字符集这项要格外小心。源码如果是多字节写的,里面会有大量char缓冲区和sprintf,直接切到 Unicode 会编译报错一片。两个选择:要么把工程保持多字节,代价是新系统上某些 API 的宽字符版本用不了;要么老老实实把字符串全改成TCHAR加_T()宏,工作量大但一劳永逸。如果代码里已经大量使用了CString,其实改造成本不高,因为CString本身就是跟着_UNICODE宏走的。改完可以加一段编译期自检,让自己一眼看到当前配置:
// CharsetCheck.h —— 编译时就把当前字符集打出来,省得运行后才发现字符串全乱 #if defined(_UNICODE) #pragma message("== 当前工程按 Unicode 编译,TCHAR 为 wchar_t,注册表写入按 2 字节算 ==") #else #pragma message("== 当前工程按多字节编译,TCHAR 为 char,注意与宽字符 API 的转换 ==") #endif // 静态断言兜底:如果后面有人把 TCHAR 相关假设写死成 1 字节,这里会直接编译失败 static_assert(sizeof(TCHAR) == sizeof(wchar_t) || sizeof(TCHAR) == 1, "TCHAR 尺寸异常,检查工程字符集设置");#pragma message只是在编译输出窗口打一行提示,不影响生成结果,但它的价值在于:你打开任何一个源文件重新编译,都会在输出里看到当前字符集,不会出现「以为改了其实改的是另一个配置」这种翻车。static_assert是兜底,防止有人后面加了硬编码字节长度的代码。
注意:改完字符集后一定要「重新生成」而不是「生成」,增量编译可能有部分
.obj还是旧配置产出的,链接出来的 exe 行为会很诡异。
3.3 清单文件与管理员权限:让向导能往 Program Files 写文件
这是新手最容易翻车的一步。安装器要往C:\Program Files下建目录、往HKEY_LOCAL_MACHINE写注册表,这两件事在普通权限下都会失败。更坑的是,写注册表那一步可能「看起来成功了」,实际上被系统重定向到了一个虚拟存储位置,你在注册表编辑器里怎么找都找不到。
解决办法是给 exe 加一个清单文件,声明需要管理员权限。工程里通常已经有一个app.manifest,检查里面这一行:
<!-- app.manifest —— 声明需要管理员权限,避免写入 Program Files 时被虚拟化重定向 --> <requestedPrivileges> <requestedExecutionLevel level="requireAdministrator" uiAccess="false" /> </requestedPrivileges>level有三个取值:asInvoker表示继承父进程权限,highestAvailable表示用当前用户能拿到的最高权限,requireAdministrator表示必须是管理员。安装器选第三个。加了这一行之后,双击 exe 会弹权限确认框,用户点「是」之后进程才真正拿到管理员令牌。
uiAccess保持false,它是给辅助功能程序用的,安装器不需要。清单文件要在工程属性的「清单工具 → 输入和输出 → 附加清单文件」里指定,或者在.rc里用1 RT_MANIFEST "app.manifest"引进去,两种方式选一种,重复引入会链接报错。
如果不想强制要求管理员权限,另一种做法是运行时提权:进程启动后检测自己是不是管理员,不是就用ShellExecuteEx带runas动词重新启动自己,然后退出当前进程。这个方案对用户更友好,但代码量和坑都多一些,要处理传参、要处理用户点了「否」的情况。内部工具我一般直接用清单强制提权,简单直接。
3.4 首次运行验证清单:五个必须走到的点
编译出 exe 之后,别急着打包发给别人,按下面五步自己走一遍,能挡住八成问题。
第一步,双击 exe,确认弹出的是欢迎页而不是直接闪退或者弹一个错误框。闪退常见原因是InitInstance里 COM 初始化失败或者页面对象的构造顺序有问题。第二步,点「下一步」走到路径页,把默认路径改成一个 D 盘的目录,确认「浏览」按钮能弹出目录选择框,且选择后路径正确回填。第三步,走到进度页,观察进度条是不是随拷贝推进,当前文件名是不是在变。第四步,完成后去目标目录看文件是不是都在,尤其是子目录里的文件,很多实现只拷了第一层。第五步,去桌面看快捷方式在不在,双击能不能启动目标程序,再去控制面板的卸载列表里看有没有登记上。
这五步里最容易出问题的是第三步和第五步。进度条不动通常是回调里的PostMessage没送到 UI 线程,卸载列表找不到是注册表写错了位置,这两个第 5 章都会细说。
4. 核心模块拆解:一次安装里文件操作的完整链路
4.1 安装上下文:一个结构体承载整条向导的数据
向导的页面之间要传数据:路径页选了目录,进度页要知道拷到哪;完成页要显示拷了多少文件;取消按钮要能让拷贝函数停下来。用全局变量是最快的写法,也是最难维护的写法。这套源码用的是「一个上下文结构体 + 指针传递」,所有页面持有同一个指针。
// InstallContext.h —— 整条向导共享的一份安装状态,页面之间只传这个指针 struct CInstallContext { CString sInstallDir; // 目标安装目录,形如 D:\Apps\MyTool,路径页写入 CString sSourceDir; // 源目录:payload 文件夹路径,或资源解出后的临时目录 CString sAppName; // 显示名,快捷方式名和卸载项 DisplayName 都用它 CString sExeName; // 主程序文件名,用于拼快捷方式的目标路径 BOOL bDesktopShortcut; // 是否创建桌面快捷方式,完成页的复选框控制 BOOL bLaunchAfter; // 装完是否立即启动,完成页的复选框控制 BOOL bCanceled; // 用户在进度页按了取消,拷贝回调靠它判断是否中止 HWND hProgressWnd; // 进度页窗口句柄,工作线程里 PostMessage 的目标 int nCopiedFiles; // 已成功拷贝的文件数,完成页要显示这个数字 };字段分成三类看。路径类三个:sInstallDir是用户选的,sSourceDir是程序自己算出来的,sExeName是常量。开关类三个:桌面快捷方式、装完启动、取消标志,都是布尔量,页面上的复选框直接绑。通信类两个:hProgressWnd让后台拷贝能通知界面,nCopiedFiles让完成页能显示结果。
这里有个设计细节值得学:bCanceled用的是上下文字段而不是事件或者线程终止。拷贝回调里每次被调用都检查一次这个标志,看到TRUE就返回取消码,CopyFileEx自己会把半截文件删掉并返回失败。这比强行TerminateThread安全一百倍,后者会留下锁住的堆和没关闭的文件句柄,是标准的血泪经验。
4.2 目录遍历与递归创建:FindFirstFile 那套老搭档
安装的第一步是建目录。CreateDirectory只会创建一级,如果父目录不存在就直接失败。比如你要建D:\Apps\MyTool\bin,而D:\Apps不存在,这一次调用就白费了。所以得自己写一个逐级创建的版本。
// FileOps.cpp —— 逐级创建目录,父目录不存在时先补父目录 static BOOL EnsureDir(LPCTSTR pszDir) { CString s(pszDir); if (s.IsEmpty()) return FALSE; if (s.Right(1) == _T("\\")) s.TrimRight(_T("\\")); // 去掉尾部反斜杠,否则最后会去建根目录 int nPos = s.Find(_T('\\'), 0); while (nPos != -1) { CString sPart = s.Left(nPos); // "D:" 这种只有盘符的片段不能单独 CreateDirectory,会失败并留下脏错误码 if (!sPart.IsEmpty() && sPart.Right(1) != _T(":")) ::CreateDirectory(sPart, NULL); // 已存在返回 FALSE,属正常,不判错 nPos = s.Find(_T('\\'), nPos + 1); } // 最后一级单独建,用 ERROR_ALREADY_EXISTS 区分「已存在」和「真失败」 if (::CreateDirectory(s, NULL)) return TRUE; return ::GetLastError() == ERROR_ALREADY_EXISTS; }这段代码有三个点值得说。第一处是TrimRight,如果不做,路径D:\Apps\会先被切成D:再去建,行为不可预期。第二处是盘符判断,CreateDirectory("D:")是个陷阱,它不报错但语义是「当前盘当前目录」,容易搞出莫名其妙的目录。第三处是最后的ERROR_ALREADY_EXISTS判断,很多实现直接看返回值,结果目录已存在时误判为失败,安装流程直接中止,用户一脸懵。
目录建好之后是遍历源目录收集文件。这里要先把文件列表和总字节数都算出来,因为进度条需要总量,而CopyFileEx的回调只告诉你单个文件的进度。
// 递归收集源目录下的所有文件,同时累加总字节数用于算总进度 void CollectFiles(LPCTSTR pszDir, std::vector<CString>& files, ULONGLONG& ullTotalBytes) { CString sPattern; sPattern.Format(_T("%s\\*.*"), pszDir); WIN32_FIND_DATA fd = { 0 }; HANDLE hFind = ::FindFirstFile(sPattern, &fd); if (hFind == INVALID_HANDLE_VALUE) return; // 目录为空或不可读,直接返回 do { // "." 和 ".." 必须跳过,否则递归会无限循环直到栈溢出 if (_tcscmp(fd.cFileName, _T(".")) == 0 || _tcscmp(fd.cFileName, _T("..")) == 0) continue; CString sFull; sFull.Format(_T("%s\\%s"), pszDir, fd.cFileName); if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { CollectFiles(sFull, files, ullTotalBytes); // 子目录递归 } else { files.push_back(sFull); // nFileSizeHigh 和 nFileSizeLow 要拼回 64 位,直接相加会溢出 ullTotalBytes += ((ULONGLONG)fd.nFileSizeHigh << 32) | fd.nFileSizeLow; } } while (::FindNextFile(hFind, &fd)); ::FindClose(hFind); // 漏掉这句就是句柄泄漏,装一千个文件能泄漏一千个句柄 }WIN32_FIND_DATA里的文件大小分成高低两个 32 位字段,这是 32 位时代的遗留。拼接时必须先转ULONGLONG再左移,顺序写反就是溢出。另外FindClose千万别漏,安装器一次要遍历成百上千个文件,句柄泄漏累积起来会导致后面CreateFile全部失败,报错还是ERROR_TOO_MANY_OPEN_FILES,看着完全不像句柄问题。
4.3 带进度和取消的拷贝:CopyFileEx 的回调怎么写才不卡界面
拷贝是整个安装过程里唯一耗时的环节,也是唯一需要给用户反馈的环节。自己写读写循环当然可以,但CopyFileEx已经把进度回调、取消、属性复制、错误上报都做好了,没理由不用。
// 进度回调:系统每拷一段就调一次,返回值决定继续还是中止 static DWORD CALLBACK CopyProgress( LARGE_INTEGER TotalFileSize, // 当前文件总大小 LARGE_INTEGER TotalBytesTransferred, // 当前文件已拷字节 LARGE_INTEGER StreamSize, // 当前流大小,普通文件等于 TotalFileSize LARGE_INTEGER StreamBytesTransferred, // 当前流已拷字节 DWORD dwStreamNumber, // 流编号,普通文件恒为 1 DWORD dwCallbackReason, // 回调原因:开始、进行中、结束 HANDLE hSourceFile, HANDLE hDestinationFile, LPVOID lpData) // 就是我们传进去的 CInstallContext 指针 { CInstallContext* pCtx = (CInstallContext*)lpData; if (pCtx->bCanceled) // 用户在界面上按了取消 return PROGRESS_CANCEL; // 系统会删掉这个半截文件,然后 CopyFileEx 返回失败 int nPercent = 0; if (TotalFileSize.QuadPart > 0) nPercent = (int)(TotalBytesTransferred.QuadPart * 100 / TotalFileSize.QuadPart); // 不直接 SetPos 操作控件,交给 UI 线程,否则跨线程操作窗口是未定义行为 ::PostMessage(pCtx->hProgressWnd, WM_APP + 10, (WPARAM)nPercent, 0); return PROGRESS_CONTINUE; }参数里真正有用的是前两个和最后一个。前两个算百分比,最后一个拿上下文。dwCallbackReason可以判断是刚开始还是快结束,用于更新「当前正在安装 xxx.dll」这种文案。返回值只有两个选项有意义:PROGRESS_CONTINUE继续,PROGRESS_CANCEL中止。还有PROGRESS_QUIET和PROGRESS_STOP,前者是「别再调我了」但继续拷,后者是暂停,安装场景基本用不到。
调用的地方要注意错误码的处理方式:
// 拷贝单个文件,把「用户取消」和「真失败」区分开 BOOL CopyOneFile(LPCTSTR pszSrc, LPCTSTR pszDst, CInstallContext* pCtx) { BOOL bAbort = FALSE; BOOL bOk = ::CopyFileEx(pszSrc, pszDst, CopyProgress, pCtx, &bAbort, 0); if (!bOk) { DWORD dwErr = ::GetLastError(); if (dwErr == ERROR_REQUEST_ABORTED) // 用户主动取消,不是错误 return FALSE; // 其余错误记进日志:磁盘满、无权限、路径过长,处理方式各不相同 LogError(pszSrc, dwErr); } return bOk; }COPY_FILE_FAIL_IF_EXISTS这个标志看需求加,覆盖安装时不加,全新安装时可以加,避免误覆盖用户已有文件。pbCancel那个参数是给跨线程取消用的指针,回调里已经用上下文判断了,这里传个局部变量占位即可。
还有一个绕不开的问题:CopyFileEx是阻塞调用,如果在 UI 线程里循环调它,界面会卡死,进度条也不会刷新。正确做法是开一个工作线程跑整个拷贝循环,UI 线程只负责收WM_APP + 10消息并SetPos。线程里不要碰任何控件,所有界面更新都通过PostMessage投递,这是 MFC 里最稳的写法。
4.4 快捷方式:IShellLink 五步才稳,少一步就是空文件
创建快捷方式看起来只有「新建一个 .lnk 文件」这么简单,实际要走 COM 接口。完整流程是五步:初始化 COM、创建IShellLink实例、设置各项属性、查询IPersistFile接口、保存到磁盘。少任何一步都会失败,最常见的现象是生成了一个 0 字节的 .lnk 文件。
// ShellUtil.cpp —— 创建一个快捷方式,返回 HRESULT 方便上层判断 HRESULT CreateShortcut(LPCTSTR pszLnkPath, LPCTSTR pszTarget, LPCTSTR pszArgs, LPCTSTR pszWorkDir) { IShellLink* pLink = NULL; HRESULT hr = ::CoCreateInstance(CLSID_ShellLink, NULL, CLSCTX_INPROC_SERVER, IID_IShellLink, (void**)&pLink); if (FAILED(hr)) return hr; pLink->SetPath(pszTarget); // 目标 exe 的完整路径,必须是绝对路径 pLink->SetArguments(pszArgs); // 没有参数就传 _T(""),不要传 NULL pLink->SetWorkingDirectory(pszWorkDir); // 工作目录,不设的话目标程序取当前目录会出错 pLink->SetIconLocation(pszTarget, 0); // 第二个参数是图标索引,多图标 exe 才需要改 pLink->SetDescription(_T("启动程序")); // 鼠标悬停时的提示文字,可省略 IPersistFile* pFile = NULL; hr = pLink->QueryInterface(IID_IPersistFile, (void**)&pFile); if (SUCCEEDED(hr)) { hr = pFile->Save(pszLnkPath, TRUE); // TRUE 表示同时保存相对路径信息 pFile->Release(); } pLink->Release(); return hr; }SetWorkingDirectory这一步很多人省掉,后果是目标程序启动后调用GetCurrentDirectory拿到的是快捷方式所在目录,读同目录的配置文件就找不到。SetArguments传NULL在部分系统版本上会崩,养成传空字符串的习惯。
COM 初始化在CWinApp::InitInstance里做,用CoInitializeEx(NULL, COINIT_APARTMENTTHREADED),退出前CoUninitialize。如果初始化失败,后面所有CoCreateInstance都会返回CO_E_NOTINITIALIZED,现象就是快捷方式建不出来但没有任何其他提示。
桌面和开始菜单的路径不要硬编码。桌面对应SHGetFolderPath(NULL, CSIDL_DESKTOPDIRECTORY, ...),开始菜单程序组对应CSIDL_PROGRAMS。硬编码C:\Users\xxx\Desktop在别人机器上必然失败,这是新手最常见的错误之一,而且因为只是「快捷方式没出现」,很容易被忽略。
4.5 卸载登记与自删除:给用户留一颗后悔药
安装器写进去的东西要能被干净地拿掉,否则用户装两次就有两个卸载项,删都删不干净。登记卸载信息的位置是系统约定的,键值有固定几个字段:
// RegUtil.cpp —— 写卸载项,控制面板里能不能看到、能不能卸掉全靠这段 BOOL WriteUninstallEntry(const CInstallContext& ctx, LPCTSTR pszUninstallExe) { // kUninstallRoot 是常量,指向系统约定的卸载信息根路径,子键建议用 GUID 避免重名 CString sKey = kUninstallRoot + ctx.sAppName; HKEY hKey = NULL; // KEY_WOW64_64KEY 强制写 64 位视图,否则 32 位安装器会被重定向到 WOW6432Node if (::RegCreateKeyEx(HKEY_LOCAL_MACHINE, sKey, 0, NULL, 0, KEY_WRITE | KEY_WOW64_64KEY, NULL, &hKey, NULL) != ERROR_SUCCESS) return FALSE; // 字符串长度按字节算:Unicode 下要乘 sizeof(TCHAR),写死 +1 会截断 ::RegSetValueEx(hKey, _T("DisplayName"), 0, REG_SZ, (const BYTE*)(LPCTSTR)ctx.sAppName, (ctx.sAppName.GetLength() + 1) * sizeof(TCHAR)); CString sUninstall; sUninstall.Format(_T("\"%s\" /uninstall"), pszUninstallExe); // 带引号,路径有空格才不被截断 ::RegSetValueEx(hKey, _T("UninstallString"), 0, REG_SZ, (const BYTE*)(LPCTSTR)sUninstall, (sUninstall.GetLength() + 1) * sizeof(TCHAR)); DWORD dwSizeKb = 1024; // 单位是 KB,只是显示用,估个大概即可 ::RegSetValueEx(hKey, _T("EstimatedSize"), 0, REG_DWORD, (const BYTE*)&dwSizeKb, sizeof(dwSizeKb)); ::RegCloseKey(hKey); return TRUE; }字符串长度那行是最容易出错的地方。RegSetValueEx要求的是字节数,不是字符数。多字节工程下每个字符一字节,+1刚好;Unicode 工程下每个字符两字节,写GetLength() + 1就只写了一半的数据,注册表里显示出来是乱码。用sizeof(TCHAR)乘一下就两边都对。
卸载程序本身有个经典难题:它没法删掉自己。程序运行的时候文件被系统锁住,DeleteFile会返回ERROR_ACCESS_DENIED。两个解法。轻量解法是把自己拷到临时目录再从那里运行,删完原目录后让自己的进程退出,临时文件下次开机再清。稳妥解法是调用:
// 让系统在下次重启时删除自己,不需要额外的进程 ::MoveFileEx(pszSelfExe, NULL, MOVEFILE_DELAY_UNTIL_REBOOT);这个调用需要管理员权限,而且文件要等到重启才消失,用户可能会觉得「没卸干净」。所以更好的组合是:先删安装目录下的其他文件,把自己复制到%TEMP%并带参数重新启动,原进程退出,新进程删掉原目录和注册表项,最后用MoveFileEx把临时目录里这个副本标记为重启删除。多写三十行代码,体验完全不一样。
5. 避坑与排查:五个让我返工的点
5.1 进度条一直停在 0%,结束那一秒直接跳到 100%
现象:安装能正常完成,文件也都拷对了,但进度条全程不动,直到最后一刻跳到满格。换成大文件测试时更明显,几十秒的拷贝过程里界面完全冻结。
原因:拷贝循环跑在 UI 线程上,CopyFileEx阻塞期间消息泵没有机会处理WM_APP + 10,回调里PostMessage投递的消息全堆在队列里,等拷贝结束才被一次性处理。另一种情况是回调里直接SetPos操作了控件,跨线程操作窗口在 MFC 里行为未定义,有时静默失败。
解决:把整个拷贝循环挪进AfxBeginThread起的工作线程,线程函数里只做文件操作和PostMessage,UI 线程在消息处理函数里更新进度条。上下文里的hProgressWnd用GetSafeHwnd()取,线程结束用WaitForSingleObject等,收尾时再切到完成页。
5.2 装到 Program Files 下,配置文件写不进去但也不报错
现象:程序装在C:\Program Files\MyTool,第一次运行时往自己目录写配置文件,退出后重新打开配置全丢了。用记事本手动在那个目录建文件会提示需要管理员权限。
原因:进程没有管理员权限,写入被系统的文件虚拟化机制重定向到了用户目录下的VirtualStore。程序读的时候还能读到(因为读也被重定向),但换个用户登录或者换个程序读就找不到了,表现为「玄学丢配置」。
解决:安装器加requireAdministrator清单,确保写Program Files真的写进去了。同时改目标程序的配置存储位置,写到%APPDATA%下而不是自己目录,这是规范做法。配置文件路径用SHGetFolderPath(CSIDL_APPDATA)拼,不要硬编码用户名。
5.3 64 位系统上卸载列表里找不到自己
现象:安装完成,程序能跑,快捷方式也在,但控制面板的卸载列表里没有这一项,或者有但点「卸载」提示找不到程序。
原因:32 位安装器在 64 位系统上运行,写HKEY_LOCAL_MACHINE\SOFTWARE时被重定向到了WOW6432Node子键。系统的卸载列表只读 64 位视图,所以看不到。另一个可能原因是UninstallString里的路径带空格没加引号,被当成了「程序 + 参数」拆开。
解决:RegCreateKeyEx时加KEY_WOW64_64KEY标志,强制写 64 位视图。同时检查UninstallString的格式,路径一律用双引号包住,参数跟在引号后面。如果安装器同时要支持两个平台,建议统一编成 32 位并强制写 64 位视图,兼容性最好。
5.4 取消安装后,目标目录里残留一堆半截文件,删还删不掉
现象:进度页点取消,向导退出了,但目标目录里有一堆文件,有的还是 0 字节,手动删除提示「文件正在被另一个程序使用」。
原因:取消时只设置了bCanceled标志,没有做清理。CopyFileEx收到PROGRESS_CANCEL会删掉当前这个文件,但之前已经拷完的文件都还在。文件被占用是因为某个拷贝线程还在跑,主线程已经开始清理了,两个线程抢同一个文件。
解决:取消流程改成三步。第一步设标志,第二步等拷贝线程真正退出(WaitForSingleObject带超时,比如 5 秒),第三步再递归删除目标目录。删除本身也要容忍失败,DeleteFile失败就记日志跳过,别因为一个文件删不掉让整个清理流程中断。清理完成后弹一个提示告诉用户「已取消并清理」,而不是静默退出。
5.5 中文路径下拷贝失败,GetLastError返回 2
现象:安装目录选成D:\工具\我的程序,安装过程走到拷贝就失败,错误码 2,意思是「找不到文件」。同样的目录换成英文就一切正常。
原因:工程是多字节字符集,但代码里某个地方调了宽字符版本的 API,或者反过来。多字节工程下CString存的是char,传给FindFirstFileW之类需要wchar_t的函数时,编译器可能做了一次隐式转换,把中文变成问号,路径自然找不到。
解决:统一字符集是最彻底的。要么全工程切 Unicode 并把char缓冲区改成TCHAR,要么全部用
本文还有配套的精品资源,点击获取