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/Android | Android 专属代码与清单 |
| Platforms/iOS | iOS 专属代码与 Info.plist |
| Platforms/Windows | WinUI 相关配置 |
| Resources/Images | 图片资源,按密度自动生成 |
| Resources/Fonts | 字体,跨平台统一注册 |
| Views | XAML 页面 |
| 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 + MVVM | Blazor Hybrid |
|---|---|---|
| UI 语言 | XAML | Razor 与 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 包。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。