在统信 UOS 上做 .NET 应用分发,最容易被卡住的一步往往不是程序本身能不能跑,而是怎么把它装到别人的机器上。你把编译出来的二进制直接拷过去,大概率会踩到一串麻烦:启动器里找不到入口、图标不显示、依赖缺这缺那,想卸载只能靠手动删文件。这篇文章会把“Avalonia 程序如何打包成 deb 安装包”这条链路完整走一遍,从发布模式选型到包目录结构,再到实际安装验证和踩坑排查,让最后做出来的产物可以直接交给其他人用。
适合两类读者:一类是刚接触统信生态的 .NET 开发者,想知道自包含发布和框架依赖发布该怎么选;另一类是程序已经能跑、但卡在打包交付环节的人,想快速把 deb 格式吃透。下面讲的内容,都是我在这条路线上实际试过并且反复验证过的做法,不一定是最短路径,但每一条都有原因。
1. 为什么不能直接拷二进制:deb 分发绕不开的三个坎
1.1 坎一:桌面集成缺失
统信桌面环境的启动器不会自动扫描你自定义目录里的二进制。它只认/usr/share/applications下的 .desktop 文件,以及/usr/share/icons下的图标资源。你把一个 Avalonia 程序随手放到某个目录,双击或许能跑,但用户下次想再找它,就必须重新翻文件管理器,既没有菜单入口,也没有任务栏分组,体验上基本等于一个“能运行的半成品”。
.desktop 文件本质上是一个文本配置文件,它告诉桌面环境:这个应用叫什么、从哪里启动、用什么图标、属于哪个分类。没有它,哪怕你程序写得再好,在统信上也无法获得正常的入口。我之前给内部交付过一个小工具,一开始也偷懒直接发压缩包,后来所有反馈都集中在“装完找不到”“图标是白板”这类问题上,逼着我补上了这些基础设施。所以打包 deb 的第一个目的,就是把桌面集成做得像原生应用一样。
1.2 坎二:运行时与系统库对不上
这是 .NET 应用在 Linux 上最容易翻车的地方。统信 UOS 的默认软件源里不一定带了对应版本的 .NET 运行时,就算带了,版本也未必和程序匹配。你开发时用的是 .NET 8,目标机器可能只有 .NET 6,双击之后大概率就是一段看不懂的报错,或者干脆没反应。
更麻烦的是间接依赖的系统库。Avalonia 底层依赖 SkiaSharp、HarfBuzzSharp 等原生库,这些库在运行时会链接 libX11、libxcb、libGL、fontconfig 之类的系统基础组件。开发机上因为是完整桌面环境,一般没问题;但某些经过裁剪的统信系统镜像,缺的库比你想的多。直接拷二进制过去,碰到缺库只能一台一台排查,效率非常低。
deb 包里的Depends字段能帮你解决一部分问题。安装时 dpkg 会检查目标机上的 libc、libstdc++、libX11 等版本,缺了会明确告诉你要先装什么。虽然这不能解决所有运行时问题,但至少把“这台机器能不能装这个包”的检查自动化了,不用人肉去看每台机器。
1.3 坎三:安装、卸载与升级没有任何“账本”
Linux 下的软件管理不只是把一个文件夹放对位置。dpkg 会记录这个包安装了哪些文件、版本是多少、安装状态是什么。有了这份“账本”,你才能用dpkg -r干净卸载,用新版 deb 直接覆盖升级,用dpkg -l一眼看出哪些机器装了哪个版本。
直接拷贝二进制时,这一切都不存在。旧版本残留、新版本覆盖、配置文件越积越多,时间一长系统越来越脏。尤其当你的应用要部署到十几台甚至上百台机器时,没有包管理系统兜底,光靠人肉同步一定会出乱子。
1.4 哪些场景可以暂时偷懒
也不是所有情况都非得打 deb。如果这个 Avalonia 程序只是你自己开发机器上的调试产物,或者只在三两台固定机器上测试,用 rsync 同步发布目录也完全够用。但只要你需要把程序交付给别人、在公司内网推广、做批量部署,或者后续打算上应用商店,deb 就是绕不开的格式。决策很简单:只要你不在那个特定的目标机器上手动操作,就直接走打包路线。
2. 选对发布模式:统信 UOS 上优先自包含
2.1 两种发布模式的取舍
.NET 应用可以按运行时是“随程序走”还是“借系统的”分成两种。框架依赖发布依赖目标机器自带 .NET 运行时,自包含发布会把运行时也打进产物。两者的差异直接影响到你的交付策略和踩坑概率,我整理了一张表方便对比:
| 对比项 | 框架依赖发布 | 自包含发布 |
|---|---|---|
| 目标机运行时 | 需要系统安装对应的 .NET 运行时 | 不需要,产物自带运行时 |
| 产物体积 | 几十 MB,较小 | 通常 100 MB 到 200 MB,偏大 |
| 离线安装 | 要先装运行时,依赖外部软件源 | 可直接安装,离线下友好 |
| 版本兼容风险 | 目标机运行时版本不匹配就会挂 | 运行时版本由你本地控制 |
| 问题排查难度 | 出错点可能在程序也在运行时 | 出错点主要集中在基础系统库 |
统信 UOS 上我优先推荐自包含,原因不是体积无所谓,而是这套系统里的 .NET 运行时生态比较不确定。内网机器往往无法访问外部软件源,现装运行时本身就是个麻烦;不同批次机器上跑的系统版本也不一致,运行时版本更是参差不齐。自包含发布把这些不确定性拦在门外,目标机只需要有最基础的系统库就能跑。代价是包体积变大,但在内网环境下,这个代价通常可以接受。
2.2 一条 dotnet publish 命令产出的完整文件集
确定了自包含后,发布命令长这样:
dotnet publish DemoApp.csproj -c Release -r linux-x64 --self-contained true \ -p:PublishSingleFile=false -p:DebugType=none -p:DebugSymbols=false -o publish/linux-x64有两点要单独提一下。
第一,我特意把PublishSingleFile保持为false。Avalonia 程序包含多个原生库,单文件模式会在运行时把原生依赖解压到临时目录,在统信这类基线比较保守的系统上偶尔会触发临时目录权限问题,而且报错信息很难查。普通目录发布,文件都摆在明面上,出问题一眼就能看出来。
第二,DebugType=none和DebugSymbols=false是为了不产生 .pdb 调试符号文件。这些文件对最终用户没有用,还会让目录显得很乱,打包时带着它们也没有意义。
发布完成后,publish/linux-x64目录里的内容是完整产物。常见会看到这些文件:
DemoApp:无扩展名的可执行宿主文件DemoApp.dll、DemoApp.deps.json、DemoApp.runtimeconfig.json:主程序集和启动配置libSkiaSharp.so、libHarfBuzzSharp.so:Avalonia 依赖的原生图形库- 若干依赖的 .dll 程序集资源文件
这里要特别强调一个原则:这个目录里的东西,一个都不能少。deps.json 里登记了所有依赖程序集的名称和哈希,你在市面上看到各种“能不能删掉某个文件”的提问,本质都是没理解 deps.json 的作用。打包时最好整目录拷贝,不要自作聪明做裁剪。
2.3 双架构思路:amd64 与 arm64 一次搞定
统信 UOS 同时有 x86_64 和 ARM64 的设备,尤其是国产化场景里 ARM 机器占比不小。除非你的应用只跑在一种机型上,否则建议从一开始就规划好双架构产物。
这里有个容易搞混的地方:dotnet 的 RID(Runtime Identifier)命名和 deb 包里的架构名不是一回事。对照关系我列在下面:
| deb 包架构名 | dotnet RID 参数 | 说明 |
|---|---|---|
| amd64 | linux-x64 | 最常见的 x86_64 桌面机 |
| arm64 | linux-arm64 | 飞腾、麒麟等多个 ARM 平台 |
打包脚本里把架构作为参数传进去,需要哪个就构建哪个。这样一份源码出两个包,实际维护成本比你想象的低很多。至于更小众的龙芯等平台,Avalonia 的支持情况要先验证再决定,别急着加进发布矩阵里。
3. 先搭 deb 包骨架:DEBIAN 目录里到底放什么
打个比方:deb 包就是一个快递箱,DEBIAN目录是快递单,其他目录是货物。快递单声明了收件人、体积、依赖关系,货物本身才是用户真正用的东西。dpkg 安装时先看快递单,确认没问题再把货物放到系统里。
一个标准的构建目录长这样:
build/deb/demoapp/ ├── DEBIAN/ │ ├── control │ ├── md5sums │ ├── postinst │ └── postrm ├── opt/ │ └── demoapp/ │ ├── DemoApp │ ├── DemoApp.dll │ ├── libSkiaSharp.so │ └── ... └── usr/ ├── share/applications/ │ └── demoapp.desktop └── share/icons/hicolor/256x256/apps/ └── demoapp.png前面说过,程序本体我放在/opt/demoapp,桌面文件和图标放在/usr/share下对应的标准目录。这套布局是 Debian 系系统推荐的,统信 UOS 作为基于 Debian 生态的系统,完全兼容这套规则。
3.1 control 文件字段逐个拆解
control文件是整个包的“身份证”,dpkg 在安装前的检查基本都是围绕它展开。一个典型的 control 文件长这样:
Package: demoapp Version: 1.0.0 Section: utils Priority: optional Architecture: amd64 Depends: libc6 (>= 2.31), libstdc++6 (>= 10), libx11-6 Maintainer: maintainer <maintainer@example.com> Installed-Size: 524288 Description: 一个基于 Avalonia 的示例内网工具 用于内部数据查看和辅助操作。几个字段的实际含义值得展开讲。
Package是包名,只能用小写字母、数字、连字符和点,不能有中文,不能以连字符开头,也不能用大写字母。很多人会把产品名当包名,但产品名带大写时,deb 包名里就必须换成小写连字符形式,比如DemoApp对应的包名是demoapp,否则 dpkg 会直接报包名不规范。
Version是版本号,建议用1.0.0这种纯数字加点的格式。如果要区分同一版本的不同修订,可以写成1.0.0-1。别加v前缀,别用下划线,dpkg 对版本号的解析规则比你想的严格得多。
Architecture对应 deb 包的架构名,就是前文表格里说的amd64或arm64。这里写错了,安装时会直接报架构不匹配,是最低级的坑。
Depends是运行时依赖。自包含发布之后,你的程序主要依赖系统基础库,一般从libc6、libstdc++6、libx11-6、libgl1、libfontconfig1里选。没有把握时宁可写宽松一点,也别写一个你完全不确定的库名,否则安装时 dpkg 会因为找不到依赖而拒绝安装。
Installed-Size是安装后的占用大小,单位是 KB。可以用du -sk命令计算发布目录的大小后填进去。它不影响安装正确性,但会影响 dpkg 安装时显示的空间占用预估,有总比没有好。
3.2 md5sums 需要自己生成,别等 dpkg 帮你
dpkg-deb --build不会自动生成md5sums文件。这个文件记录包内所有文件的 MD5 校验值,作用是在安装后做完整性校验,比如某些系统工具会用它对已安装文件做一致性检查。缺了它,包能装,但不算完整,校验阶段会有告警。
所以打包脚本里要自己生成。命令很简单:
cd build/deb/demoapp find . -type f ! -path "./DEBIAN/*" -print0 | xargs -0 md5sum | sed 's|^\./||' > DEBIAN/md5sums这里有个细节:find输出的路径是./opt/demoapp/DemoApp这种带./前缀的形式,而 dpkg 校验时要求路径不能带./,所以最后用sed把前缀去掉。我第一次写打包脚本时就漏了这个,生成的 md5sums 里每条路径都带./,安装后 dpkg 校验文件路径全部对不上。
3.3 postinst / postrm:什么情况下是必选项
如果只是塞一个程序和桌面文件进去,不写postinst和postrm也能装上。但只要你的应用把文件放到了/usr/share/applications和/usr/share/icons下,这两个生命周期脚本就基本变成必选项。
postinst在安装完成后执行,主要用来刷新桌面环境的缓存。统信桌面环境有自己的图标缓存和 desktop 数据库,如果你不刷新,装完启动器里可能看不到新应用,或者图标显示成默认图标。脚本我常用下面这个版本:
#!/bin/sh set -e gtk-update-icon-cache -f /usr/share/icons/hicolor 2>/dev/null || true update-desktop-database /usr/share/applications 2>/dev/null || true exit 0postrm则在卸载时执行。注意这里有个容易踩的细节:dpkg 卸载分remove和purge两级,remove只是移除自己记录的文件清单,而你在postrm里额外加的清理动作要区分这两种情况。比如你要在卸载时删掉/opt/demoapp下的日志目录,就只在purge阶段做,否则用户卸载后日志也一起没了。
#!/bin/sh set -e if [ "$1" = "purge" ]; then rm -rf /opt/demoapp fi exit 04. 写一个能反复用的打包脚本
手动调用dpkg-deb --build打包一次不难,难的是每次发布都保持同样的目录结构、权限字段和 md5sums。所以我后来把整套流程固化成了脚本,发布新版本时只改版本号,脚本自动跑完发布、封装、校验。
4.1 目录规划决定了脚本的稳定性
脚本里最关键的一点,是确定一个干净、可重建的构建目录。我习惯用build/deb/包名作为工作目录,每次打包前先把整个目录删掉重建,避免上一次的残留文件混进新包。发布目录也单独放在publish/linux-x64这种按 RID 区分的路径下,这样 x64 和 arm64 的产物永远不会互相覆盖。
程序本体放/opt/包名,desktop 文件放/usr/share/applications,图标放/usr/share/icons/hicolor/256x256/apps。这个分布是固定的,脚本里全部硬编码,省得每次还要想一遍。
4.2 脚本主体:从发布到压出 .deb
下面是我实际在用的脚本主干。变量抽出来了,架构作为第一个参数传入,amd64或者arm64,脚本自动映射到对应的 dotnet RID。
#!/bin/bash set -euo pipefail APP_NAME="demoapp" APP_BIN="DemoApp" VERSION="1.0.0" ARCH="${1:-amd64}" case "$ARCH" in amd64) RID="linux-x64" ;; arm64) RID="linux-arm64" ;; *) echo "不支持的架构: $ARCH"; exit 1 ;; esac WORK_DIR="build/deb" PKG_ROOT="$WORK_DIR/$APP_NAME" PUBLISH_DIR="publish/$RID" # 1. 清理并重建构建目录 rm -rf "$WORK_DIR" mkdir -p "$PKG_ROOT/opt/$APP_NAME" mkdir -p "$PKG_ROOT/usr/share/applications" mkdir -p "$PKG_ROOT/usr/share/icons/hicolor/256x256/apps" # 2. 发布自包含产物 dotnet publish DemoApp.csproj -c Release -r "$RID" --self-contained true \ -p:PublishSingleFile=false -p:DebugType=none -p:DebugSymbols=false -o "$PUBLISH_DIR" # 3. 整目录拷贝发布产物,并给主程序加执行权限 cp -a "$PUBLISH_DIR/." "$PKG_ROOT/opt/$APP_NAME/" chmod +x "$PKG_ROOT/opt/$APP_NAME/$APP_BIN" # 4. 写入 desktop 文件 cat > "$PKG_ROOT/usr/share/applications/$APP_NAME.desktop" <<EOF [Desktop Entry] Type=Application Version=1.0 Name=$APP_NAME GenericName=Demo Application Comment=一个基于 Avalonia 的示例应用 Exec=/opt/$APP_NAME/$APP_BIN Icon=$APP_NAME Categories=Utility; Terminal=false StartupNotify=true StartupWMClass=$APP_BIN EOF # 5. 拷贝图标 cp assets/icons/$APP_NAME.png "$PKG_ROOT/usr/share/icons/hicolor/256x256/apps/" # 6. 写 control 文件 INSTALLED_SIZE=$(du -sk "$PUBLISH_DIR" | cut -f1) cat > "$PKG_ROOT/DEBIAN/control" <<EOF Package: $APP_NAME Version: $VERSION Section: utils Priority: optional Architecture: $ARCH Depends: libc6, libstdc++6, libx11-6 Maintainer: maintainer <maintainer@example.com> Installed-Size: $INSTALLED_SIZE Description: 一个基于 Avalonia 的示例内网工具 用于内部数据查看和辅助操作。 EOF # 7. 生成 md5sums cd "$PKG_ROOT" find . -type f ! -path "./DEBIAN/*" -print0 | xargs -0 md5sum | sed 's|^\./||' > DEBIAN/md5sums cd - > /dev/null # 8. 封装 deb 包 dpkg-deb --build --root-owner-group "$PKG_ROOT" "${APP_NAME}_${VERSION}_${ARCH}.deb" echo "构建完成: ${APP_NAME}_${VERSION}_${ARCH}.deb"脚本的思路很简单:先生成发布产物,再把它和桌面集成文件一起安排到构建目录里,写好 control 和 md5sums,最后用dpkg-deb --build压包。
4.3 那几行容易被忽略的权限与清理命令
这个脚本里有几行看起来不起眼,但实际作用很大。
第一是chmod +x。发布出来的宿主文件一般自带执行权限,但如果你发布后手动做过文件操作,或者从别处拷贝过目录,执行权限可能丢失。deb 包在安装时基本保持打包时的权限原样放进去,所以这里显式加一次执行权限,能避免“装完双击没反应”这种莫名其妙的问题。
第二是--root-owner-group。dpkg-deb 在构建时如果检测到 DEBIAN 目录的所有者不是 root,会报错,或者把普通用户的 UID/GID 写进包内。加上这个参数后,包内所有文件的 owner 会被统一设置为 root:root。我之前没加这个参数时,打出来的包在部分统信机器上安装后,/opt下的文件 owner 显示成了普通用户,看起来非常奇怪。
第三是脚本开头的rm -rf "$WORK_DIR"。这条命令看起来很危险,但只要你严格保证WORK_DIR是固定字符串拼接出来的路径,而不是用户输入参数,它就是安全的。定期手动打包时如果忘记清理构建目录,很容易把旧版本的残留文件混进新包,干净重建是最可靠的保证。
5. 安装实测:从 dpkg -i 到任务栏图标
打包好之后,立刻用一个干净的目标机器做安装验证。统信上安装 deb 包只需要一条命令:
sudo dpkg -i demoapp_1.0.0_amd64.deb如果依赖有问题,dpkg 会给出明确提示。没有输出报错就说明安装成功了。下面是我每次安装后都要过一遍的检查清单,不全但覆盖了最关键的几个点。
5.1 安装后的对照检查清单
| 检查点 | 命令/动作 | 预期结果 |
|---|---|---|
| 包状态 | dpkg -l demoapp | 状态显示ii,表示已安装 |
| 文件清单 | dpkg -L demoapp | 列出/opt/demoapp下所有文件 |
| 图标文件 | ls /usr/share/icons/hicolor/256x256/apps/demoapp.png | 文件存在 |
| 启动器入口 | 打开统信启动器,搜索应用名 | 出现应用图标和名称 |
| 命令行启动 | 终端执行/opt/demoapp/DemoApp | 窗口正常打开,无报错输出 |
| 任务栏分组 | 打开窗口,观察任务栏显示 | 窗口与图标正确分组显示 |
前两项检查的是 dpkg 层面的安装正确性,后四项检查的是桌面集成效果。这里有个常见误解:很多人只验证“能启动”就认为打包成功了,忽略了图标和启动器入口。但实际上,右下角启动器里找不到、图标是白板,用户反馈的问题绝大多数来自这里。
5.2 卸载与重装别踩的细节
卸载用sudo dpkg -r demoapp。这个命令会移除包里记录的文件,但根据我在 3.3 里写的,remove阶段不会触发清除/opt下额外数据的逻辑。如果确认不需要了,可以继续执行sudo dpkg -P demoapp彻底清理。
重装升级场景反而更要小心。当你用新版本 deb 直接覆盖旧版本时,dpkg 会先停掉旧包记录的进程,再安装新文件。如果你的旧版本还在运行,升级过程中可能出现二进制被占用的问题。所以升级前最好先让用户关闭程序,或者在postinst脚本里做退出提示。
另外一个容易忽略的点是:升级时postinst脚本会再次执行,所以它必须是幂等的。重复刷新图标缓存和 desktop 数据库不会报错,但如果你在postinst里写死了“如果某文件不存在则报错退出”,升级阶段就会出问题。
5.3 交付与上架场景下的签名问题
如果只是内网交付,dpkg -i就能完成安装,不需要额外签名。但如果你想把这个包提交到平台市场上架,通常需要开发者证书对包做签名,平台会校验签名后才允许展示。签名工具和流程每家都有自己的规范,最稳妥的做法是提前找平台方确认签名算法和公钥格式,不要等打完了包才临时研究。
需要提醒的是,签名不是“打了包之后随手签一下”的事,它影响包的文件清单和校验和。所以签名的动作应该放在dpkg-deb --build完成之后,而且签完名就不要再去改动 deb 包内部内容,否则签名就失效了。
6. 打包排错实录:三个翻车现场和完整排查链路
6.1 图标不显示:从 desktop 文件反推
现象:deb 安装成功,启动器里能看到应用名称,但图标是系统默认白板。
我当时的排查链路是先用dpkg -L demoapp确认桌面文件确实装到了/usr/share/applications/demoapp.desktop,然后查看这个文件里的Icon字段。问题立刻暴露了:desktop 文件里写的是Icon=demoapp,但我在打包时把图标拷贝到了demoapp.png。大小写对不上,桌面环境自然找不到。
除此之外还有一个更隐蔽的坑:图标文件虽然是 .png 扩展名,但如果你把一张 .ico 文件直接改成 .png,文件头部内容还是 ICO 格式,统信桌面环境读不出来。Avalonia 项目里自带的图标资源很多时候是 .ico 或 .drawio 格式,用来做 Linux 桌面图标并不可靠。正确做法是用图像处理工具导出 256x256 的 PNG,命名与 desktop 文件里的 Icon 字段完全一致。
排完这个错后,我还手动执行了sudo gtk-update-icon-cache -f /usr/share/icons/hicolor刷新缓存。这一步很可能就是你postinst脚本里没写而缺失的那一环。
6.2 双击没反应:先回到命令行找真相
现象:图标显示正常,但双击启动器里的应用,窗口不出来,也没报错。
桌面环境里的报错信息几乎不可见,所以要先在终端手动跑一次程序,把错误输出捞出来:
/opt/demoapp/DemoApp如果终端直接输出libX11.so.6: cannot open shared object file之类的内容,定位就很快了。这通常是目标机器的libx11-6没装上,或者系统库版本偏旧。处理方式是先安装基础库,再重新测试:
sudo apt update sudo apt install libx11-6 libgl1 libfontconfig1还有一次,我终端跑程序时看到的报错是libSkiaSharp.so相关的内容,排查下来是发布目录里压根没有这个文件。原因是我在发布后手动清理了目录,觉得“SkiaSharp 应该用不到那么多样式库”,结果把关键原生库删了。后来我改成了整目录复制,再没出现过这种问题。
排错顺序很重要,先命令行、后图形界面,因为终端输出能直接告诉你缺什么,而双击的表现只有一个“没反应”,信息量太少。
6.3 打包时“bad permissions”:权限被悄悄改掉的教训
现象:打包脚本跑到最后一步报错,类似dpkg-deb: error: control directory has bad permissions 2777。
我当时的构建目录是用压缩包从另一台机器同步过来的,结果解压工具把整个目录权限统一改成了 777,还带上了 setgid 位。dpkg-deb 对 DEBIAN 目录和其中的控制文件权限有严格要求,普通文件不要求 000,但也不能是 777 这种带着危险位的组合。
排查链路是ls -ld build/deb/demoapp/DEBIAN,看到权限果然变成了2777。解决方法是把 DEBIAN 目录及里面的文件全部修改成常规权限,再加上前文提过的--root-owner-group参数统一 owner,重新打包后问题消失。
这个坑在 Windows 下用 zip 打包构建目录再拷到 Linux 解压时最常见,最容易在项目交接过程中出现。如果打包脚本自己完成目录搭建,不依赖外部同步的压缩包,基本碰不到这个问题。
我后来把所有打包操作都固化成了脚本,目的就是减少手动操作带来的权限漂移。构建目录每次从零重建,文件权限完全由脚本控制,deb 包内容可预测,排错也省了一半力气。如果你也卡在类似的打包问题上,建议从目录权限这一步开始查起,大多数“看起来哪儿都对但就是打不出来”的问题,根子都在权限上。