1. 先把这个项目定义掰扯清楚
1.1 “三方库”这个叫法的真实来源
先说一个不少人都有的误解。严格意义上,Inno Setup 并不是 Flutter pub 生态里的一个 Dart 三方库,你不会在 pubspec.yaml 里把它写成依赖,也不能在 Dart 代码里 import 它。它其实是一套基于 PascalScript 的安装包制作引擎,也就是负责把“一堆应用文件”变成“一个可以双击执行的安装向导”。
那这个项目为什么最开始会被称作“Flutter 三方库 innosetup 的鸿蒙化适配”?我理解下来,主要是因为我们当时把一个 Flutter 写的跨平台企业应用迁到 OpenHarmony 桌面环境,需要把编译出来的产物做成一键式安装包。Inno Setup 就因此被“缝”进了 Flutter 的构建流程里,成了整个产物交付链路中不可或缺的外挂模块。它虽然不是 Dart 库,但在打包分发这条路上,地位和三方库差不多:不装它,你的发布流程就跑不通。
把这个概念理清很重要。如果一开始就按“Dart 依赖”去搜资料,你会一头雾水;如果按“独立安装程序制作器”去理解,后面所有脚本设计和参数化改造,都会顺理成章得多。整件事的本质,不是在 Flutter 里调用 Inno Setup 的某个 API,而是把 Inno Setup 当成“构建产物后处理阶段”的独立工具,把它嵌入到整个发布流水线里。
1.2 鸿蒙化适配的边界在哪里
“鸿蒙化适配”听起来像一个很大的重构,实际落到这个项目里,边界比想象中窄。我们要做的,不是修改 OpenHarmony 的系统组件,也不是去改 Flutter 引擎,而是把已经能跑起来的桌面应用,以合适的方式“安放”进目标系统,让它安装、启动、升级、卸载都符合桌面环境的使用习惯。
这里有个容易被忽略的点:OpenHarmony 的桌面环境和移动环境不完全一样。移动端通常用安装框架批量安装应用包,桌面端则需要面对多用户、安装目录、服务注册、开机自启等问题。Flutter 跨平台能力解决的是“同一套 UI 逻辑可以跑”,但“怎么打包、怎么装、怎么常驻”这件事,Flutter 并不负责。
所以鸿蒙化适配落地到 Inno Setup 侧,主要做三件事:一是把应用编译产物按目标系统的目录规范放好;二是处理运行依赖和签名校验;三是把“系统层深度驻留”变成可见、可卸载、可恢复的常规机制。说白了就是四个字:安装编排。
搞清楚边界之后,你会发现这个项目的困难点不在“它们有多高级”,而在“细节是否严丝合缝”。版本号对不上、路径带空格、服务启动顺序颠倒,都可能导致最终用户拿到安装包后一脸懵。
1.3 为什么需要一套独立的安装包编排层
有读者可能会问:OpenHarmony 系统自己不是有应用安装机制吗?为什么还要绕一圈用 Inno Setup?这个问题的答案取决于你的分发场景。
如果只是通过应用商店渠道发布,确实不需要第三方安装器。但当时我们做的是企业内部桌面端离线交付,目标用户拿到的是一个独立安装包,需要在没有统一商店的情况下完成部署,还要支持静默升级和后台驻留。这种场景下,Inno Setup 的优势就很明显:生成的安装程序体积小,PascalScript 可编程,可以按条件注册服务、写计划任务、做升级判断,而且它完全可以通过命令行驱动,适合被 CI 流水线调用。
选型上,我们不是没有考虑过其他方案。有的方案依赖特定运行时,有的方案对多语言支持不友好,有的方案无法无界面执行。Inno Setup 属于那种“功能够用、脚本可控、没有花架子”的典型工具。把安装包编排逻辑集中到一层之后,Flutter 工程本身不需要知道安装细节,安装脚本也不需要了解 Flutter 的构建过程,两者只通过约定好的产物目录和参数传递来沟通。这样分工,后面出问题时排错也轻松很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 交付之前的产物布局与安装目标
2.1 先列清楚OpenHarmony桌面应用会产出什么
很多适配项目失败,不是因为安装脚本写得不对,而是压根没搞清楚要往安装包里塞哪些文件。以这次 Flutter 桌面应用发行为例,编译完成之后,至少会有这么几类东西:
第一类是主程序入口文件,也就是最终用户点击启动的可执行文件。第二类是动态库和引擎相关文件,Flutter 渲染、Dart 运行时在某些情况下会以动态库的形式出现,这些文件如果漏掉了,安装包装完必然闪退。第三类是资源目录,包括图片、字体、国际化文案、默认配置模板等。第四类是运行时生成数据的目录,比如日志、缓存、用户配置,这类文件不能塞进安装目录,而应该放到用户数据目录。
在实际工程里,我们专门写了一个“产物汇聚脚本”,把分散在 Flutter 构建目录、第三方依赖目录、原生插件目录里的文件统一拷贝到一个 staging 目录。这个 staging 目录就是 Inno Setup 的输入源。
这一步的意义怎么强调都不过分。Flutter 构建出来的产物可能分布在好几个位置,如果靠人工拖拽或者凭印象写安装脚本,漏文件只是时间问题。我见过太多次“安装包能生成,但装完就闪退”的案例,最后定位下来都是缺了某个不起眼的动态库。
2.2 安装目录、用户数据目录和日志目录怎么分层
桌面应用最容易犯的错误,就是把所有东西都写进程序安装目录。程序目录在 Windows 风格的权限模型里往往受到系统保护,普通用户可能没有写权限。我们的做法是严格分成三层。
第一层是程序目录,只放可执行文件、动态库和只读资源。第二层是用户数据目录,放配置文件、本地数据库、下载缓存,这一层必须按当前用户动态计算,不能写死在安装脚本里。第三层是日志目录,可以放到用户数据目录下,单独开一个 logs 子目录,方便出问题时统一收集。
这样的分层至少在 Uninstall 卸载时也能受益。卸载只需要删除程序目录和注册表项,用户数据目录可以选择性保留,这样即使重装应用,用户之前的配置也不至于直接丢失。很多企业桌面应用对“升级不清配置”有硬性要求,分层设计可以帮你天然满足它。
在 Inno Setup 脚本里,我用常量区分安装目录和用户目录,例如 {localappdata} 或 {userdocs}。至于具体用哪个,取决于目标系统在这类桌面环境默认提供的用户可写目录。脚本里最好不要硬编码用户名,否则多个账号切换时会闹出权限笑话。
2.3 资源落地与快捷方式处理
资源文件在安装脚本里看起来简单,其实有几处容易翻车。第一是 Unicode 文件名,如果工程里有中文名或者带特殊字符的目录,安装包的编码处理不到位,装到中文系统上就可能变成乱码。Inno Setup 的脚本文件在保存时最好显式声明 UTF-8,同时给编译器传对参数,不然文件名字符集对不上,安装过程会把文件名写坏。
第二是快捷方式。桌面快捷方式和开始菜单快捷方式不是随便创建一下就行,还要考虑图标路径、工作目录和启动参数。工作目录这一项特别容易被忽略:某些应用是相对路径读取资源的,如果快捷方式的工作目录不对,用户从快捷方式启动和从命令行启动,行为就不一致。
我们当时在安装脚本里统一做了两件事:把快捷方式的目标指向程序可执行文件的绝对路径,并把工作目录指向程序安装目录;同时在卸载脚本里删除所有已创建的快捷方式。快捷键没有办法直接通过普通文件安装方式清理,必须在 PascalScript 的卸载函数里显式处理。
2.4 配置文件的写入时机
安装包里最多只能带“默认配置模板”,真正落地的配置文件要在安装过程中再生成。原因很简单:默认配置里的路径、机器标识、用户偏好,往往只有到了目标机器上才能确定。
我们在 Inno Setup 安装过程中加入了一个配置初始化步骤。安装程序先把模板文件放到程序目录,然后读取机器的环境变量,替换掉模板里的占位符,最后把生成好的配置文件写到用户数据目录。这里我们踩过一个坑:占位符替换不能在安装程序的 [Files] 阶段做,因为那个阶段只是复制文件。必须等到 [Run] 阶段,用 PascalScript 的字符串替换函数逐行处理。
配置文件生效之后,还要在安装结束前做一次基本校验。比如判断关键配置文件是否存在、大小是否为零,如果发现异常,安装程序应该弹出明确提示,而不是让用户装完打开应用才发现一切白费。
3. 一键式封包分发的脚本设计与参数化配置
3.1 三轮驱动:构建、汇聚、编译
一键式封包的核心,不是把多个命令塞进同一个脚本,而是把整个流程切成相互独立的三段。
第一段是 Flutter 构建,负责产出应用本体。第二段是产物汇聚,把所有散落在不同位置的文件统一整理到 staging 目录,并清洗掉中间调试文件。第三段才是 Inno Setup 编译,把 staging 目录变成最终安装包。
我用一个命令入口串联这三段。以类 PowerShell 环境的示例来说,大概是这样的逻辑:
powershell复制# 1. 构建
flutter build <目标平台参数> --release --dart-define=API_BASE=$env:API_BASE
# 2. 汇聚产物
.\scripts\collect-artifacts.ps1 -BuildDir .\build\xxx -StagingDir .\staging
# 3. 编译安装包
ISCC.exe .\installer\setup.iss /DAppVersion=$env:APP_VERSION
这里有一个容易被忽略的细节:所有参数都应该通过环境变量或命令行参数传递,而不是硬编码在脚本里。版本号、构建时间、API 地址,只要是需要频繁变化的信息,全部走参数注入。这样同一个脚本既能出内测包,又能出正式包,还能出带独立版本号的演示包。
3.2 版本号、产物行踪和安装包命名
版本号联动看起来简单,实际操作时非常考验设计。安装包里的版本号至少要出现在三个地方:安装程序自身的文件版本号、应用配置里的版本字段、安装目录名或安装包文件名。
Inno Setup 支持通过 #define 方式在编译期注入变量。我们会在命令行里传入 AppVersion,然后在脚本顶部统一关联到 AppVersion、VersionInfoVersion、OutputBaseFilename 等属性上。这样每次发布时,只需要改一次版本号,所有地方就一致了。
安装包命名也别用“setup”这种笼统的名字,最好带上项目代号、版本号、目标平台和日期。比如形如 Client-1.4.2-win-x64-20250130 的命名方式,能让你在分发文件堆积如山时快速定位到正确版本。
3.3 静默安装和命令行参数设计
企业环境里经常需要帮一批机器批量装应用,引导式安装肯定不行。Inno Setup 天然支持静默安装参数,我们封装时用到的有这几个:
/VERYSILENT:不显示安装界面/SUPPRESSMSGBOXES:抑制所有提示框/NORESTART:安装结束后不重启系统/DIR="D:\CustomPath":指定自定义安装路径
但要注意,静默安装不等于完全没有反馈。我们的脚本在安装结束时,会把安装结果写入一个日志文件,包括安装目录、版本号、是否注册服务、最终返回码。这样即使全程黑屏,事后也能从日志里确认某台机器上安装是否成功。
高级一点的做法,是定义自己的命令行参数。例如允许调用方传入 -RegisterService=0 表示这次安装不注册后台服务,满足部分场景下“只装客户端、不启动驻留服务”的需求。Inno Setup 里用 GetCommandLineParam 自己解析即可,这块灵活性可以按业务需要扩展。
3.4 分发链路里的签名与校验
安装包做好了,并不代表可以立刻分发。我们会在流水线里对安装包做两层处理。
第一层是数字签名。有签名的安装包能大幅降低安全软件误报概率,也能让用户在安装时看到发布者信息。没有签名的安装包,在部分受管设备上甚至会被直接禁掉。第二层是校验和。生成安装包的同时,计算它的 SHA256,把哈希值和安装包放在一起发布。目标机器拿到文件后,先比对哈希再执行安装,能在源头上避开中途被替换的问题。
签名的生成要放在最终产物定稿之后。因为一旦签名,安装包内容就不能再做任何修改;修改一个字节,签名都会失效。这一点我们也吃过亏:曾经在签名后又用脚本往安装包里塞了一个说明文件,结果整个包被系统判定为“不可信”,排查了很长时间才想起来是签名被破坏了。
4. 系统层深度驻留:服务、自启和守护的正确姿势
4.1 “深度驻留”的合法实现路径
“系统层深度驻留”这个说法听起来很吓人,但落到正经企业应用里,无非是三种常规手段:注册为系统服务、创建开机启动项、注册计划任务。这三种手段的目的都一样:让应用在用户没有主动点击时也能按策略自动运行。
选择哪种方式,取决于应用的业务类型。需要持续接收指令、做后台调度、跨用户运行的,优先做系统服务;单纯希望用户登录后拉起客户端界面的,做开机启动项;需要定时执行清理、上报、同步的,用计划任务更合适。
我们的工程多管齐下:一个面向数据处理的后台服务负责常驻调度,一个面向用户的客户端程序负责交互。两者通过本地 IPC 通信。安装脚本在安装阶段完成服务注册,在卸载阶段做完整清理。整个过程都是常规软件安装的一部分,不是某种隐藏机制。这一点必须在文档里写清楚,也要让安装界面上的说明可查,避免用户产生不信任感。
4.2 PascalScript里的服务注册与清理
Inno Setup 的 [Files] 阶段没法直接创建服务,我们要在 [Run] 阶段调用系统命令完成。我建议不要自己去写服务进程的启动参数映射,直接通过 Exec 调用 sc 命令,维护成本最低。
pascal复制[Run]
Filename: "sc.exe"; Parameters: "create ""AppGuardService"" binPath= ""{app}\AppGuardService.exe"" start= auto DisplayName= ""App Guard Service"""; Flags: runhidden
对应卸载时,用 [UninstallRun] 做反向操作:
pascal复制[UninstallRun]
Filename: "sc.exe"; Parameters: "stop ""AppGuardService"""; Flags: runhidden
Filename: "sc.exe"; Parameters: "delete ""AppGuardService"""; Flags: runhidden
直接写系统命令的好处是简单、可控,坏处是容易写错。sc 命令对参数的等号和空格非常敏感,比如 binPath= 后接路径如果含空格,必须用双引号包完整。我们被这个问题坑过一次,后来把所有服务参数集中到一个常量表里,再通过 PascalScript 拼装命令,避免手写出错。
4.3 计划任务与开机自启的选择
有些功能不放服务更合适,放计划任务反而更稳。比如每天凌晨做一次日志归档、每周拉取一次配置清单,这类任务用 schtasks 注册即可。与常驻服务相比,计划任务不会一直占用内存,失败后可以设置重试策略,而且卸载时按任务名注册清理就好。
powershell复制schtasks /Create /F /TN "AppDailyCheck" /TR "..." /SC DAILY /ST 02:30 /RU SYSTEM
需要注意的是,计划任务如果用了 /RU SYSTEM,执行上下文和当前登录用户完全不同,访问用户级配置时要格外小心。反过来,如果任务指定为当前用户运行,又必须保证该用户已登录。设计安装脚本时,要根据业务对上下文的要求来选择。
开机启动项是在注册表里写入执行命令,做法上最轻量。但它的缺点是容易被安全软件扫描提醒,而且与卸载逻辑必须强绑定。我的建议是能用计划任务就不用注册表启动项,既能满足“开机后自动拉起”,又能被系统任务计划程序统一管理。
4.4 卸载时把驻留痕迹全部撤干净
安装时多积极,卸载时就要多彻底。我们专门画了一张“驻留资产清单”,包括:服务、计划任务、启动项、注册表键、快捷方式、安装目录、用户数据目录、日志目录。每次发布前,都会对照清单逐项检查卸载脚本。
卸载的顺序也有讲究。先停服务,再删服务;先结束正在运行的应用进程,再删除程序目录。如果顺序颠倒,很容易出现文件被占用、删除失败的情况。Inno Setup 允许在卸载前执行 PascalScript,我们把进程检查放在卸载的第一步,发现还在运行就尝试优雅退出,等几秒后再继续。
这里有一个容易被忽略的场景:用户不通过自带卸载程序,而是直接删目录,或者通过系统“添加/删除程序”操作异常。为了降低这种风险,除了提供卸载器之外,还可以在安装目录里单独放一个卸载日志,里面记录所有安装过程中创建的资源路径。万一出问题,排查人员能照着日志手工清理。
5. 安装包能生成却装不上:高频故障的完整排查链路
5.1 解压后缺依赖,闪退最常出在哪
安装包在开发机上一切正常,换到目标机器就闪退,这类问题的排查链路其实是有规律可循的。我处理过的最多案例,集中在“依赖没有跟着主程序走”或者“安装过程把文件放错了目录”。
排查思路是这样:先看事件日志,确认闪退模块;再用依赖查看工具检查主程序引用的动态库是否能被找到。如果系统提示缺少运行库,多半是安装包没有把运行环境一并带上。如果是自定义动态库找不到,多半是安装目录结构不完整,比如 libs 目录漏掉了。
我们的解决方案是在产物汇聚阶段加一道依赖扫描:扫描主程序引用的所有动态库,标记出哪些是系统自带的、哪些需要随安装包分发,最后生成一份依赖清单,并在安装完成后做一次启动自检。把这个自检做成安装程序最后一步,能直接把“装完就闪退”的问题挡在交付之前。
5.2 中文环境里的路径、编码与权限问题
中文系统环境下的安装问题,往往是三件事同时发生:系统用户名可能是中文,安装路径可能是包含中文字符的目录,安装程序的脚本编码和实际系统编码不一致。
第一类问题是路径。某些老牌运行时对中文路径支持不好,应用本身倒是没问题,一旦装到含中文用户名或中文目录的路径下,内部文件查找就串了。我们的做法是在安装脚本里默认把安装目录建议到不含中文的路径下,同时在启动时对目录做一次合法性检查。
第二类问题是编码。Inno Setup 脚本保存成 UTF-8 之后,少数命令工具仍然按系统本地代码页解析参数。我们在脚本里对所有非 ASCII 字符串的注入位置格外小心,统一用十六进制或转义方式绕过中间的编码转换。这类问题表面上看起来是“文件名乱码”,实际是“字符集断裂”。
第三类是权限。普通用户在系统盘根目录没有写权限,安装程序必须把“需要写入的文件”和“需要执行的命令”分开。写用户目录的照常写,写系统目录的操作必须在安装流程中请求管理员权限。Inno Setup 的 PrivilegesRequired=admin 可以帮你做到这一点,但它会让静默安装也弹出 UAC 确认,这个代价要提前评估好。
5.3 安全软件拦截自启怎么办
任何涉及注册表自启、服务自注册的安装包,都可能被安全软件盯上。这不是安装脚本写得不规范,而是这类操作从行为特征上很容易被归类为“可疑”。
我处理这类问题的经验,概括成三条。第一,给安装程序加上有效数字签名,这是降低误报最有效的手段。第二,把服务名称、启动项名称设置得和产品强相关,不要用模糊的通用名词,安全软件更信任能明确标识来源的对象。第三,在安装交互界面上清楚展示应用名称、发布者信息、服务作用说明,不要藏着掖着。
即便如此,仍然有极少数设备会拦截。安装脚本里最好保留一份操作日志,记录哪些动作成功、哪些被拦截。这样用户把日志反馈过来,我们能立刻定位到具体被拦的是服务注册还是启动项写入,再针对性给出白名单配置说明。
5.4 静默安装失败后如何拿到第一手日志
静默安装最大的痛点是:装失败了,用户却拿不出任何信息。为了解决这个问题,我们给安装脚本塞了好几个“钩子”。
第一步,在安装脚本开头记录开始时间、命令行参数、系统版本、是否管理员权限。第二步,在每个关键动作之后记录返回码。第三步,卸载脚本同样写日志。这样,无论安装、升级还是卸载,都能生成一份可阅读的安装状态文件。
回传日志的路径也做了约定:如果安装失败,脚本自动把日志写到系统临时目录,文件名带上时间戳。用户只要把这个文件发给支持人员,就能复现整个过程。实际上,我们通过这个机制解决过不少问题,而且修复之后可以通过日志确认修对了,而不是靠猜。
6. 把打包流程放进流水线,以及最后想说的几句实话
6.1 一条命令跑完整个安装包构建
如果你已经看完了前面所有细节,大概率会想到这个问题:这堆事情如果每次发布都手工点一遍,迟早会出错。所以我们把整个流程收敛成一个入口命令和一个参数文件。
入口命令长这样:
bash复制./release.sh --version 1.4.2 --channel beta --output ./dist
它内部依次完成构建、汇聚、编译、签名、生成校验和、输出安装包清单。参数文件里定义了产物目录、签名工具路径、服务名、显示名称等环境相关配置。日常开发和打包机使用同一套脚本,只是参数不同。
这个改造带来的效率提升非常明显。原来从构建到发布,人工处理需要接近半小时,还动不动漏这少那;现在一条命令跑完,平均几分钟就能出一个可安装验证的包。更重要的是,每次发布的产物都来自同一条路径,行为可复现。
6.2 流水线里的校验、重试与产物留档
进入持续集成流水线之后,我又加了三个环节。第一是产物校验:安装包生成后自动执行一次静默安装到临时目录,验证安装返回值、检查关键文件是否存在、卸载后确认清理干净。这道校验的命中程度远高于编译成功本身。
第二是结果重试:对签名或网络传输等偶发失败动作,设置重跑策略。但重试必须限定在可安全重复的步骤上,比如签名重跑没问题,安装校验如果失败则不能默默重试,而要让流水线直接标红。否则失败被掩盖,问题会在交付之后爆发。
第三是产物留档:所有历史安装包按版本号归档,连同构建参数、依赖清单、校验和一起保存。这个做法在多版本并行支持时非常有用,遇到线上问题可以直接翻出对应版本的产物复现,而不需要重新构建一个可能和线上不一致的包。
6.3 几点个人体会
回头再看这个项目,我最想提醒后来者的,不是某个具体函数怎么调,而是别把安装打包想成“最后一步的杂活”。它在企业桌面分发场景里的地位,相当于整个交付链条的闸门:闸门不稳,前面的开发质量再高,最终用户的体感也不好。
我的实践体会是,一开始不要急着上流水线,先把手动脚本跑通;手动的每一步都要能解释清楚“为什么这样做”,再把它固化成自动流程。快速迭代阶段,宁可多花时间把安装包的自检日志做好,也不要追求华丽的自动化界面。
最后再分享一个我认为很值得做的小技巧:把安装脚本里所有硬编码的常量、路径、服务名抽到一个单独的配置文件,用注释写清楚每个字段的用途和修改注意点。这样无论三个月后是你自己回来维护,还是换了另一个同事接手,都不至于对着脚本猜半天。这个项目走到后期,我们几乎不再需要修改脚本主体逻辑,改的都是这个配置文件的参数。能做到这一步,说明整套打包编排已经从“临时脚本”进化成了“可靠工程”。
