.NET MAUI 跨平台客户端开发

讲解 .NET MAUI 的工程结构与开发范式,覆盖单项目多目标的项目组织、XAML 与 MVVM 绑定、平台差异化实现与依赖注入,并对比 Blazor Hybrid 的技术取舍与发布裁剪策略。

1. MAUI 的定位与项目结构

一句话总结: MAUI 用一个项目多目标框架的方式统一 Android、iOS、macOS 与 Windows,共享 UI 与逻辑,仅平台特有能力通过条件编译与分部类下沉。

MAUI 是 Xamarin.Forms 的继任者,核心变化是单项目多目标:一个 .csproj 通过 TargetFrameworks 同时产出四个平台的应用,不再需要为每个平台维护独立的头项目。

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFrameworks>net9.0-android;net9.0-ios;net9.0-maccatalyst</TargetFrameworks>
    <TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">
      $(TargetFrameworks);net9.0-windows10.0.19041.0
    </TargetFrameworks>
    <UseMaui>true</UseMaui>
    <SingleProject>true</SingleProject>
    <ApplicationTitle>订单助手</ApplicationTitle>
    <ApplicationId>com.example.orders</ApplicationId>
    <Nullable>enable</Nullable>
  </PropertyGroup>
</Project>

典型的目录组织如下:

目录内容
Platforms/AndroidAndroid 专属代码与清单
Platforms/iOSiOS 专属代码与 Info.plist
Platforms/WindowsWinUI 相关配置
Resources/Images图片资源,按密度自动生成
Resources/Fonts字体,跨平台统一注册
ViewsXAML 页面
ViewModels视图模型
Services业务服务与平台接口实现

资源通过 MauiImage、MauiFont、MauiAsset 等构建动作声明,编译时按平台生成对应密度的资源,避免手工维护多套切图;一个 logo.svg 会被自动转成 Android 的多种 dpi 位图与 iOS 的 Asset Catalog。

1.1 启动与宿主构建

一句话总结: MauiProgram 的 CreateMauiApp 是应用组合根,注册字体、服务、页面与平台实现,等价于 ASP.NET Core 的启动配置。

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
            });

        builder.Services.AddSingleton<IOrderService, OrderService>();
        builder.Services.AddSingleton<AppShell>();
        builder.Services.AddTransient<OrderListViewModel>();
        builder.Services.AddTransient<OrderListPage>();

        return builder.Build();
    }
}

这里用的是标准 Microsoft.Extensions.DependencyInjection 容器,与 ASP.NET Core 完全一致的注册方式,因此服务层代码可以直接复用。

2. XAML 与 MVVM 绑定

一句话总结: XAML 声明视图、ViewModel 持有状态、绑定连接两者;绑定的核心是编译期绑定与 ObservableCollection,前者能提前发现拼写错误,后者让集合变更自动刷新 UI。

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:vm="clr-namespace:Orders.ViewModels"
             x:Class="Orders.Views.OrderListPage"
             x:DataType="vm:OrderListViewModel"
             Title="订单列表">

  <CollectionView ItemsSource="{Binding Orders}"
                  SelectionMode="Single"
                  RemovedCommand="{Binding RemoveCommand}">
    <CollectionView.ItemTemplate>
      <DataTemplate x:DataType="models:Order">
        <Grid Padding="12" ColumnDefinitions="*,Auto">
          <Label Text="{Binding Title}" FontSize="16" />
          <Label Grid.Column="1" Text="{Binding Total, StringFormat='{0:C}'}" />
        </Grid>
      </DataTemplate>
    </CollectionView.ItemTemplate>
  </CollectionView>
</ContentPage>

关键点是 x:DataType:它开启编译期绑定,绑定的属性名写错会在编译时报错,而不是运行时静默失败。这是 MAUI 相比 Xamarin.Forms 最重要的可用性改进之一。

2.1 ViewModel 与可观察属性

一句话总结: 属性变更通知用 CommunityToolkit.Mvvm 的源生成器实现,[ObservableProperty] 与 [RelayCommand] 消除全部样板代码。

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

public partial class OrderListViewModel : ObservableObject
{
    private readonly IOrderService _service;

    public OrderListViewModel(IOrderService service) => _service = service;

    public ObservableCollection<Order> Orders { get; } = new();

    [ObservableProperty]
    private bool _isBusy;

    [ObservableProperty]
    private string _keyword = "";

    [RelayCommand]
    private async Task LoadAsync(CancellationToken ct)
    {
        if (IsBusy) return;
        IsBusy = true;
        try
        {
            Orders.Clear();
            await foreach (var order in _service.StreamAsync(Keyword, ct))
                Orders.Add(order);
        }
        finally
        {
            IsBusy = false;
        }
    }
}

源生成器会为 _isBusy 生成 IsBusy 属性并在赋值时触发 OnPropertyChanged,为 LoadAsync 生成 LoadCommand。ObservableCollection<T> 的增删会触发 CollectionChanged,CollectionView 据此增量刷新,无需整表重建。

2.2 值转换与格式化

一句话总结: 显示格式优先用 StringFormat 与属性包装,复杂转换用 IValueConverter,避免在 XAML 中堆砌转换器。

public sealed class BoolToColorConverter : IValueConverter
{
    public object Convert(object? value, Type targetType, object? parameter,
        CultureInfo culture)
        => value is true ? Colors.SeaGreen : Colors.IndianRed;

    public object ConvertBack(object? value, Type targetType, object? parameter,
        CultureInfo culture)
        => throw new NotSupportedException();
}

不过多数场景更好的做法是在 ViewModel 上直接暴露已格式化好的属性,转换器只用于纯展示逻辑。

3. 平台差异化实现

一句话总结: 平台差异通过三种手段处理——条件编译、分部类与平台实现注册,业务代码只依赖接口,具体实现按平台注入。

第一种是条件编译,适合少量差异:

public static string 平台名称 =>
#if ANDROID
    "Android";
#elif IOS
    "iOS";
#elif MACCATALYST
    "macOS";
#elif WINDOWS
    "Windows";
#else
    "Unknown";
#endif

第二种是分部类,Platforms 目录下同名文件会被按目标平台自动包含:

// Services/HapticService.cs
public partial class HapticService
{
    public partial void Vibrate();
}

// Platforms/Android/HapticService.cs
public partial class HapticService
{
    public partial void Vibrate()
        => Android.OS.Vibrator.Default?.Vibrate(
               Android.OS.VibrationEffect.CreateOneShot(50, 128));
}

第三种是接口加注册,最适合有实质差异的能力:

public interface INotificationScheduler
{
    Task ScheduleAsync(string title, DateTimeOffset at);
}

// MauiProgram 中按平台注册
#if ANDROID
    builder.Services.AddSingleton<INotificationScheduler, AndroidScheduler>();
#elif IOS
    builder.Services.AddSingleton<INotificationScheduler, IosScheduler>();
#endif

3.1 权限与生命周期

一句话总结: 权限请求必须走 MAUI Essentials 的统一 API,生命周期事件用平台回调包装,避免直接引用平台 SDK 造成编译失败。

Permissions 与 Connectivity、SecureStorage、Preferences 同属 Essentials,它们在不同平台上分别映射到各自的系统 API,是跨平台代码的首选抽象。请求权限的标准流程是先 CheckStatusAsync 查询当前状态,若不是 Granted 再 RequestAsync 触发系统弹窗,两个方法都接受 Permissions.LocationWhenInUse 这类泛型参数。

4. 与 Blazor Hybrid 的取舍

一句话总结: Blazor Hybrid 用 Web 技术写 UI、用原生宿主渲染,适合团队已有 Web 技能栈且需要与 Web 端共享组件的场景;纯 XAML 在原生观感与性能上更优。

Blazor Hybrid 通过 BlazorWebView 在原生应用内承载 Razor 组件,组件运行在 .NET 进程中,DOM 渲染在 WebView 内,因此可以直接调用原生 API,而不是像 Blazor WebAssembly 那样受浏览器沙箱限制。

<BlazorWebView HostPage="wwwroot/index.html">
  <BlazorWebView.RootComponents>
    <RootComponent Selector="#app" ComponentType="{x:Type local:Routes}" />
  </BlazorWebView.RootComponents>
</BlazorWebView>
维度XAML + MVVMBlazor Hybrid
UI 语言XAMLRazor 与 HTML/CSS
原生观感最贴近平台依赖样式还原
代码复用与 Web 端不共享可与 Blazor 组件共享
团队技能需学 XAML复用 Web 技能
复杂列表性能优,原生虚拟化一般,受 WebView 限制
生态组件平台原生控件Web 组件库

选择依据很直接:若已有 Blazor 组件资产或团队以 Web 技术为主,选 Blazor Hybrid;若追求原生体验与极致性能,选 XAML。 两者可以混合,用 BlazorWebView 承载部分页面,其余页面仍用 XAML。

4.1 共享逻辑层

一句话总结: 无论选哪种 UI 技术,业务逻辑、数据访问与网络层都应放在独立的 .NET 类库中,被 MAUI 项目与 ASP.NET Core 项目共同引用。

// 独立类库 Orders.Core,不含任何 UI 依赖
public sealed class OrderService : IOrderService
{
    private readonly HttpClient _http;

    public OrderService(HttpClient http) => _http = http;

    public async IAsyncEnumerable<Order> StreamAsync(
        string keyword, [EnumeratorCancellation] CancellationToken ct)
    {
        var page = 1;
        while (true)
        {
            var url = $"api/orders?keyword={Uri.EscapeDataString(keyword)}&page={page}";
            var batch = await _http.GetFromJsonAsync<List<Order>>(url, ct)
                        ?? new List<Order>();
            if (batch.Count == 0) yield break;

            foreach (var o in batch) yield return o;
            page++;
        }
    }
}

这样客户端与服务端共享同一套 DTO 与业务规则,避免两端各自实现一遍导致行为漂移。

5. 数据与状态管理

一句话总结: 本地持久化优先用 SQLite 与 Preferences,敏感数据用 SecureStorage,网络层复用 HttpClient 与 Polly,离线优先场景需实现本地队列与冲突解决。

public sealed class LocalStore
{
    private readonly SQLiteAsyncConnection _db;

    public LocalStore(string dbPath)
    {
        _db = new SQLiteAsyncConnection(dbPath,
            SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create | SQLiteOpenFlags.SharedCache);
    }

    public Task InitAsync() => _db.CreateTableAsync<OrderRecord>();

    public Task<List<OrderRecord>> 待同步Async()
        => _db.Table<OrderRecord>().Where(r => r.Synced == false).ToListAsync();

    public Task 标记已同步Async(int id)
        => _db.ExecuteAsync("UPDATE OrderRecord SET Synced = 1 WHERE Id = ?", id);
}

敏感数据(令牌、密钥)不能放 Preferences(明文存储),必须用 SecureStorage,它在 Android 上用 Keystore、iOS 上用 Keychain,通过 SetAsync 与 GetAsync 存取。网络层则直接复用 IHttpClientFactory 与弹性策略,与 ASP.NET Core 侧的写法一致。

6. 性能与发布

一句话总结: 客户端性能的关键是列表虚拟化、图片尺寸匹配与启动路径精简;发布阶段用裁剪与 AOT 缩小体积,但要注意反射依赖被裁掉的风险。

列表是移动端最常见的性能瓶颈。CollectionView 默认虚拟化,但模板复杂度会直接决定滚动帧率:

  • 模板层级越浅越好,避免嵌套多层 Grid 与 StackLayout。
  • 固定行高时设置 ItemSizingStrategy="MeasureFirstItem",避免逐项测量。
  • 图片用 MauiImage 声明并按显示尺寸提供,不要加载原图再缩放。
<CollectionView ItemsSource="{Binding Orders}"
                ItemSizingStrategy="MeasureFirstItem"
                RemainingItemsThreshold="5"
                RemainingItemsThresholdReachedCommand="{Binding LoadMoreCommand}" />

发布配置方面:

<PropertyGroup Condition="'$(Configuration)'=='Release'">
  <PublishTrimmed>true</PublishTrimmed>
  <TrimMode>partial</TrimMode>
  <RunAOTCompilation>true</RunAOTCompilation>
  <AndroidLinkMode>SdkOnly</AndroidLinkMode>
</PropertyGroup>

裁剪的风险在于反射:JSON 序列化、DI 的反射注册、XAML 的 x:DataType 之外的数据绑定都可能因类型被裁掉而失败。对策是全面使用源生成的序列化上下文,并在真机上做完整的冒烟测试。

6.1 调试与热重载

一句话总结: XAML Hot Reload 能显著缩短 UI 迭代周期,但状态与平台代码的改动仍需重启,真机调试应尽早开始而非留到最后。

部署到真机只需一条命令:dotnet build -t:Run -f net9.0-android,iOS 换成 -f net9.0-ios 即可,构建、安装与启动一次完成。

跨平台开发的常见坑集中在三处:真机上的资源密度与模拟器不一致;后台唤醒与推送在 iOS 上受限严格;平台特有的返回键、安全区与深色模式需要分别适配。把这三个平台的实机验证放进迭代循环,而不是留到发布前,能省下大量返工。

7. 工程实践与测试

一句话总结: ViewModel 与 Service 应完全脱离 UI 框架以便单元测试,UI 层用少量端到端测试覆盖关键路径,CI 中至少构建全部目标框架。

ViewModel 不引用任何 MAUI 类型时可以直接单元测试:

[Fact]
public async Task 加载命令_填充订单集合()
{
    var fake = new FakeOrderService(new[]
    {
        new Order { Id = 1, Title = "A", Total = 10m },
        new Order { Id = 2, Title = "B", Total = 20m },
    });
    var vm = new OrderListViewModel(fake);

    await vm.LoadCommand.ExecuteAsync(null);

    Assert.Equal(2, vm.Orders.Count);
    Assert.False(vm.IsBusy);
}

CI 中的构建矩阵应覆盖所有目标框架:在 macos-latest 上先执行 dotnet workload install maui 安装工作负载,再分别以 -f net9.0-android 与 -f net9.0-ios 各构建一次,至少保证两个平台能编译通过。

另外几条实践建议:把平台专属代码全部收敛到 Platforms 目录与少数分部类中,其余代码保持可移植;统一在 MauiProgram 中注册服务,避免页面里 new 出依赖;用 AppShell 统一路由,页面跳转走 Shell.Current.GoToAsync 而非平台导航 API。

8. 总结

环节要点
项目结构单项目多目标框架,平台代码下沉到 Platforms 目录
组合根MauiProgram 注册服务与页面,复用标准 DI 容器
XAML 绑定用 x:DataType 开启编译期绑定,属性与命令用源生成器
平台差异条件编译处理小差异,分部类与接口注册处理大差异
Blazor Hybrid有 Web 资产或 Web 团队时选它,追求原生体验选 XAML
性能发布列表虚拟化与图片尺寸是关键,裁剪须防反射失效
测试ViewModel 脱离 UI 可测,CI 覆盖全部目标框架构建

MAUI 的价值在于让一份业务逻辑同时抵达四个平台,而代价是必须在平台差异、性能特性与发布裁剪之间持续权衡。把平台专属代码严格隔离、把共享逻辑放在独立类库、把编译期绑定与源生成作为默认选择,就能让这份权衡始终处于可控范围。下一篇将讨论如何把这些共享逻辑打包成可复用的 NuGet 包。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「csharp」更多文章

  1. .NET 机器学习实战
  2. 内存剖析与 dump 分析
  3. 分布式事务与 Saga 编排