跨平台分发.NET桌面应用的例子,包含各个平台可能会遇到的坑
前言
目前的.NET跨平台技术已经非常成熟,除了控制台程序以外,我们还有诸如Avalonia这样的跨平台UI框架可选。
本篇文章的例子将使用Avalonia框架(当然你不用也行)构建具有图形界面的桌面应用,并在程序中附带动态链接库和可执行文件,最后分别在三大平台
- macOS下打包为CFBundle,不可用于商店提交(由于苹果商店提交需要苹果开发者账号,此处不考虑)
- Windows下打包为APPX Bundle,可用于商店提交
- Linux下打包为AppImage
操作平台:
- x86平台的macOS Monterey
- x86平台的Windows 11
- x86平台的Arch Linux
代码编写
首先我们需要将用于打包的应用程序的代码编写好。
创建动态库
随便写一个c语言的动态库,代码如下
// file mycode1.c
#ifdef _WIN32
#define API __declspec(dllexport)
#else
#define API
#endif
API int add(int a,int b){
return a+b;
}
API char* say(){
return "Hello World!";
}
编译
windows cl /LD mycode1.c
linux cc mycode1.c -shared -o libmycode1.so
macOS cc mycode1.c -shared -o libmycode1.dylib -install_name @executable_path/libmycode1.dylib
确保你得到了mycode1.dll和mycode1.lib,或者libmycode1.so或dylib这些动态库文件。
创建可执行文件
随便写一个可执行程序,代码如下
// file test.c
#include<stdio.h>
extern char* say();
extern int add(int,int);
int main(){
char* a = say();
int b = add(1,2);
printf("%s,%d\n",a,b);
return 0;
}
编译
windows cl test.c mycode1.lib
linux cc test.c -L. -lmycode1 -o test -Wl,-rpath='$ORIGIN'
macOS cc test.c -L. -lmycode1 -o test
确保你得到了test.exe或者test这些可执行文件
编写.NET应用程序
创建Avalonia应用
安装模版dotnet new -i Avalonia.Templates
创建名为MyApp1的项目dotnet new avalonia.app -o MyApp1
修改代码文件
MainWindow.axaml
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="MyApp1.MainWindow"
Title="MyApp1"
Width="400" Height="400">
<StackPanel>
<Button x:Name="btn1" Content="btn1" Click="Click1"/>
<TextBlock x:Name="tb1" Text="tb1"/>
<Button x:Name="btn2" Content="btn2" Click="Click2"/>
<TextBlock x:Name="tb2" Text="tb2"/>
</StackPanel>
</Window>
MainWindow.axaml.cs
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Avalonia.Controls;
namespace MyApp1
{
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
[DllImport("mycode1", EntryPoint = "say")]
extern static IntPtr Say(); // C语言的char*字符串不能用string来接收,应该作为指针接收,并且转换为clr字符串
[DllImport("mycode1", EntryPoint = "add")]
extern static int Add(int a, int b);
private void Click1(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var a = Marshal.PtrToStringAnsi(Say());
var b = Add(1, 2);
var c = $"{a},{b}";
this.tb1.Text = c;
}
private void Click2(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var p = new Process()
{
StartInfo = new ProcessStartInfo("test")
{
RedirectStandardOutput = true
}
};
p.Start();
p.WaitForExit();
var a = p.StandardOutput.ReadToEnd();
this.tb2.Text = a;
}
}
}
测试
执行dotnet build,并将动态库和可执行文件全部复制到输出目录(默认是./bin/Debug/net6.0/),然后执行dotnet run运行程序。
可以看到如下界面。

分别点击btn1和btn2,两个TextBlock的内容被替换并且相同。

到这里,.NET应用程序就算编写完成了,接下来就进入打包阶段。
打包应用程序
首先,publish我们的应用程序,执行dotnet publish -c Release -r osx-x64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true
先解释一下各个参数的作用
- -c和-r这两个就不说了,记得把-r替换成自己的平台
- --self-contianed 自包含模式,用户可以不安装.NET运行时就启动程序
- -p:PublishSingleFile 可以将编写的程序生成为单个可执行文件(不包含native依赖)
- -p:PublishTrimmed 裁剪程序集,可以减小生成的文件大小(慎用)
macOS
在macOS下,一个应用程序包就是一个CFBundle,即名称为以.app结尾的文件夹,并且具有一定的目录结构,如下
MyProgram.app
|
----Contents\
|
------_CodeSignature\ (stores code signing information)(储存代码签名,自动生成)
| |
| ------CodeResources
|
------MacOS\ (all your DLL files, etc. -- the output of `dotnet publish`)(publish后的文件、各种依赖库和可执行文件都要放在这里)
| |
| ---MyProgram
| |
| ---MyProgram.dll
| |
| ---Avalonia.dll
|
------Resources\
| |
| -----MyProgramIcon.icns (icon file)(图标文件)
|
------Info.plist (stores information on your bundle identifier, version, etc.)(程序包的清单)
------embedded.provisionprofile (file with signing information)
本示例来自 https://docs.avaloniaui.net/docs/distribution-publishing/macos
所以先创建一个文件夹,命名为MyApp1.app,然后根据上面的结构,先创建好文件夹和包清单文件,结构如下
% tree MyApp1.app
MyApp1.app
└── Contents
├── Info.plist
└── MacOS
2 directories, 1 file
然后将publish后的文件(位于项目的./bin/Release/net6.0/osx-x64/publish中),以及自己编译的动态库和静态库,都复制到MacOS文件夹,可以删除其中的pdb文件因为那是调试才用到的,复制完成后目录结构如下
% tree MyApp1.app
MyApp1.app
└── Contents
├── Info.plist
└── MacOS
├── MyApp1
├── libAvaloniaNative.dylib
├── libHarfBuzzSharp.dylib
├── libSkiaSharp.dylib
├── libmycode1.dylib
└── test
2 directories, 7 files
接下来,需要编写清单文件Info.plist,这里我提供一个样例
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>com.mycompany</string>
<key>CFBundleName</key>
<string>MyApp1</string>
<key>CFBundleDisplayName</key>
<string>MyApp1</string>
<key>CFBundleVersion</key>
<string>1.0.0</string>
<key>LSMinimumSystemVersion</key>
<string>10.12</string>
<key>CFBundleExecutable</key>
<string>MyApp1</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
需要注意的是其中的CFBundleExecutable代表了包的入口可执行文件的名称,其中CFBundleName和CFBundleDisplayName的区别我也不太清楚。。。
现在,双击我们的程序包,可以发现程序已经可以正常启动。但是还差非常重要的一步,签名。
如果没有正确签名,那么分发后的程序包在其他人的电脑上打开时,会提示“包已损坏,请移至废纸篓”(这个坑把我折磨了好几天)。
使用如下命令签名codesign --force --deep --timestamp=none --sign - MyApp1.app
- --force 强制签名,即替换已有签名
- --deep 自动递归签名所有的可执行文件和动态链接库
- --timestamp 时间戳,一定要设为none,不然别人下载后可能会提示包已损坏
- --sign xxx 执行签名,xxx原本应该是有效苹果开发者证书的名称,但是我没有所以是-
- 最后跟上程序包的名称
到此为止,macOS下的打包就已经完成了,你现在已经可以把这个程序包丢到finder的应用程序文件夹里面,并且从launchpad启动了。但是还有几个注意事项
- 由于签名没有有效证书,所以其他用户第一次打开程序包时,会提示来自未知开发者,这时必须从设置安全性里面选择仍然打开。
- 为了防止可执行文件的unix执行权限丢失,最好使用tar等格式来打包这个程序包。
- 本机可执行文件可以只编译到x86平台,因为m1/m2芯片的mac可以转译运行,为了方便起见可以不编译arm64平台的。但是.NET程序生成到osx-arm64平台非常方便,并且可以避免转译带来的启动时间变长。
- 如果需要上传mac app store,那么签名步骤中的--sign后面需要一个有效的苹果开发者证书,然而众所周知成为苹果开发者每年要上交$100所以我也没证书(。签名完成后,还要经过认证(称为Notarization的步骤)才能提交。
- 更进一步,还可以制作一个dmg格式的安装镜像,但是这个比较简单本文就不再深入。
Windows
接下来,我们在Windows上进行打包。由于.NET程序在Windows中具有天然优势,VS也提供了良好的打包工具链,所以可以说.NET程序在Windows上打包是最简单的。
为了使用VS,我们需要先建立一个空白解决方案
然后选择添加现有项目

选择之前的.NET项目的csproj文件即可添加。
接下来再创建一个打包项目,命名为MyApp1.Package(好像需要安装通用Windows平台工作负载才能创建打包项目,具体我记不清楚了。。。)

完成后,目录结构如下

这时右键MyApp1.Package的依赖,选择添加项目引用,勾选MyApp1然后确定即可。

接下来,由于VS打包过程是完全自动化的,我们无法像上面macOS打包一样手动复制动态库和可执行文件,因此需要用到的动态库和可执行文件都要先放到项目里面,并且启用复制到生成目录。

这时,调整启动配置,以x64的配置启动MyApp1.Package

成功运行

接下来就可以全自动打包了。首先右键MyApp1.Package项目,选择发布,创建程序包。(此处有坑,请先看完文章再实操)
此时注意
- 如果你打算发布到商店,选第一个,这要求你具有微软开发者账号(费用为一次性$20,这个账号我确实有不过发布商店挺简单的就不演示了),具体之后的操作就一路下一步
- 如果你不打算发布到商店,选第二个,即旁加载
我以旁加载方式作为例子,创建程序包必须对程序包进行签名才可以安装,所以下一步我们要选择创建证书

填写发布者名称和证书密码,然后下一步,我们只能勾选x64,并且以Release模式发布。这是因为我们的动态库和可执行文件是x64平台下的,选择别的平台不能运行。

然后点击创建,就可以开始自动打包了。打包完成后,打开包所在的文件夹,找到名为MyApp1.Package_1.0.0.0_x64.cer的证书文件。如果要安装旁加载模式生成的包,我们必须首先安装对应的安全证书。双击这个证书,点击安装证书,选择本地计算机再下一步,然后选择将所有的证书都放入下列储存,浏览,选择受信任的根证书颁发机构,然后一直下一步就行了。
安装完证书后,就可以双击MyApp1.Package_1.0.0.0_x64.appxbundle文件执行安装了,安装完成后,程序就会出现在开始菜单。那么运行一下,点击btn1程序就崩溃了。打开任务管理器一看,哎不对啊,这玩意怎么成32位的了(

好在经过研究,我找到了解决办法。首先打开配置管理

切换到Release配置,然后点击MyApp1的Any CPU,选择新建

然后选择x64即可

这时,再创建一次程序包,就是正常的64位程序了。
Linux
最后,我们在Arch Linux上操作,使用AppImage的方式打包我们的.NET项目。
首先,我们必须先下载打包工具。根据我的平台,我下载了appimagetool-x86_64.AppImage。
先尝试能不能运行打包工具,运行./appimagetool-x86_64.AppImage,发现缺少依赖。那么先安装依赖,执行pacman -S libappimage即可。再次运行打包工具则运行成功。
接下来,创建一个名为AppDir的文件夹作为工作目录,其结构如下
# tree AppDir
AppDir
├── AppRun
├── MyApp1.desktop
└── opt
1 directory, 2 files
其中AppRun是执行脚本,MyApp1.desktop是包清单文件,opt是储存二进制文件的文件夹。
我们先把.NET publish后的文件都复制到opt文件夹里面(删除pdb文件),再把自己编译的动态库和可执行文件也放进去。此外,还要求有一个图标文件,此处偷懒可以touch icon.png制作一个空图标文件。此时,目录结构如下
# tree AppDir
AppDir
├── AppRun
├── MyApp1.desktop
└── opt
├── icon.png
├── libHarfBuzzSharp.so
├── libmycode1.so
├── libSkiaSharp.so
├── MyApp1
└── test
1 directory, 8 files
至此,文件就已经复制完成了。接下来编写启动脚本AppRun,示例代码如下
#!/bin/sh
cd "$(dirname "$0")"
exec ./opt/MyApp1
注意,这里一定要记得给AppRun文件加上执行权限,不然打包后会触发execv error: Permission denied。执行命令chmod a+x ./AppRun。
最后编写清单文件MyApp1.desktop,示例代码如下
[Desktop Entry]
Version=1.0
Name=MyApp1
Exec=/opt/MyApp1
Icon=/opt/icon
Type=Application
Categories=Utility
这个清单文件的详细说明可以查阅官方文档,值得一提的是Icon那里图标文件不要加后缀名,否则会找不到文件。
这样,AppDir文件夹就制作完成了。现在使用前面下载好的打包工具进行打包,执行./appimagetool-x86_64.AppImage AppDir,成功生成AppImage。测试生成的AppImage,执行./MyApp1-x86_64.AppImage,应用正常运行。
后记
萌新首篇文章,各位大佬请多指教😇
之后可能会补充一些关于图标,以及mac下多语言的问题。

浙公网安备 33010602011771号