×
¥
查看详情

任务概述

目标:用 .NET 8 + .NET MAUI(Android/iOS)构建一个入门应用,采用 MVVM + CommunityToolkit.Mvvm,实现:

  • 页面导航(NavigationPage + TabbedPage)
  • 登录/注册(本地 JSON 模拟,无网络也可用)
  • 数据绑定与输入校验(邮箱格式、密码长度),DataTrigger 禁用无效按钮
  • 成功登录后保存令牌(首选项/安全存储),导航到主页面显示个人信息
  • 动态主题切换(亮/暗)与中英双语本地化(Resx + 运行时切换)
  • 资源字典集中管理样式与颜色

分步指南

1. 初始化项目与依赖配置

关键代码:

// MauiProgram.cs
using CommunityToolkit.Mvvm; // 仅属性生成,不需显式 using
using Microsoft.Extensions.Localization; // 如果使用 Localization 扩展
using Microsoft.Maui.Controls.Compatibility;
using Microsoft.Maui.Hosting;

namespace MauiAuthSample;

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

        // 依赖注册
        builder.Services.AddSingleton<Services.AuthService>();
        builder.Services.AddSingleton<Services.ThemeService>();

        builder.Services.AddTransient<ViewModels.LoginViewModel>();
        builder.Services.AddTransient<Views.LoginPage>();
        builder.Services.AddTransient<Views.WelcomePage>();
        builder.Services.AddTransient<Views.ProfilePage>();
        builder.Services.AddTransient<Views.SettingsPage>();
        builder.Services.AddTransient<Views.MainTabbedPage>();

        return builder.Build();
    }
}

注意事项:

  • Android 最低版本设置为 26(Android 8.0),iOS 13;在 csproj 中设定 TargetFrameworks 与最低版本。
  • 使用 NavigationPage 作为根页面,登录成功后替换根以防返回到登录页。

2. 搭建 UI:欢迎页、登录页(Grid + DataTrigger)、主页面(TabbedPage)

关键代码:

<!-- Views/LoginPage.xaml -->
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="MauiAuthSample.Views.LoginPage"
             Title="{x:Static resx:AppResources.LoginTitle}">
    <ContentPage.Resources>
        <ResourceDictionary xmlns:resx="clr-namespace:MauiAuthSample.Resources.Strings">
            <!-- 颜色与样式在全局 Styles.xaml 中设置,这里只附加触发器 -->
            <Style TargetType="Button" x:Key="PrimaryButton">
                <Setter Property="BackgroundColor" Value="{DynamicResource PrimaryColor}" />
                <Setter Property="TextColor" Value="{DynamicResource PrimaryTextColor}" />
                <Setter Property="CornerRadius" Value="8" />
                <Setter Property="Padding" Value="12,10" />
            </Style>
        </ResourceDictionary>
    </ContentPage.Resources>

    <Grid Padding="24" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="*,*">
        <Label Grid.ColumnSpan="2" Text="{x:Static resx:AppResources.WelcomeBack}" 
               FontSize="24" FontAttributes="Bold" Margin="0,0,0,16" />

        <Label Grid.Row="1" Grid.ColumnSpan="2" Text="{x:Static resx:AppResources.Email}" />
        <Entry Grid.Row="2" Grid.ColumnSpan="2" Text="{Binding Email}" Keyboard="Email" 
               Placeholder="{x:Static resx:AppResources.EmailPlaceholder}" />

        <Label Grid.Row="3" Grid.ColumnSpan="2" Text="{x:Static resx:AppResources.Password}" />
        <Entry Grid.Row="4" Grid.ColumnSpan="2" Text="{Binding Password}" IsPassword="True"
               Placeholder="{x:Static resx:AppResources.PasswordPlaceholder}" />

        <StackLayout Grid.Row="5" Grid.ColumnSpan="2" Orientation="Horizontal" Spacing="12">
            <Button Text="{x:Static resx:AppResources.Login}" Style="{StaticResource PrimaryButton}">
                <Button.Triggers>
                    <!-- DataTrigger 禁用无效或忙碌状态 -->
                    <DataTrigger TargetType="Button" Binding="{Binding CanLogin}" Value="False">
                        <Setter Property="IsEnabled" Value="False" />
                        <Setter Property="Opacity" Value="0.6" />
                    </DataTrigger>
                    <DataTrigger TargetType="Button" Binding="{Binding IsBusy}" Value="True">
                        <Setter Property="IsEnabled" Value="False" />
                        <Setter Property="Opacity" Value="0.6" />
                    </DataTrigger>
                </Button.Triggers>
                <Button.Command>
                    <Binding Path="LoginCommand" />
                </Button.Command>
            </Button>

            <Button Text="{x:Static resx:AppResources.Register}" Command="{Binding GoRegisterCommand}" />
        </StackLayout>

        <Label Grid.Row="6" Grid.ColumnSpan="2" Text="{Binding ErrorMessage}" TextColor="Red" />

    </Grid>
</ContentPage>

注意事项:

  • 使用 Grid 行列,保证布局在不同屏幕尺寸上自适应。
  • DataTrigger 同时观察 CanLogin 与 IsBusy,避免重复点击。
  • iOS 安全区域:在页面构造函数中调用 On().SetUseSafeArea(true)。

3. MVVM 与输入校验(CommunityToolkit.Mvvm)

关键代码:

// ViewModels/LoginViewModel.cs
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.Text.RegularExpressions;
using System.Text.Json;
using Microsoft.Maui.Storage;

namespace MauiAuthSample.ViewModels;

public partial class LoginViewModel : ObservableObject
{
    private readonly Services.AuthService _authService;

    [ObservableProperty] private string email = string.Empty;
    [ObservableProperty] private string password = string.Empty;
    [ObservableProperty] private bool isBusy;
    [ObservableProperty] private string? errorMessage;

    public bool IsEmailValid => !string.IsNullOrWhiteSpace(Email)
                                && Regex.IsMatch(Email, @"^[^@\s]+@[^@\s]+\.[^@\s]+$");
    public bool IsPasswordValid => !string.IsNullOrWhiteSpace(Password) && Password.Length >= 6;
    public bool CanLogin => IsEmailValid && IsPasswordValid && !IsBusy;

    public LoginViewModel(Services.AuthService authService)
    {
        _authService = authService;
        PropertyChanged += (_, e) =>
        {
            if (e.PropertyName is nameof(Email) or nameof(Password) or nameof(IsBusy))
            {
                OnPropertyChanged(nameof(IsEmailValid));
                OnPropertyChanged(nameof(IsPasswordValid));
                OnPropertyChanged(nameof(CanLogin));
            }
        };
    }

    [RelayCommand]
    private async Task Login()
    {
        if (!CanLogin) return;

        try
        {
            IsBusy = true;
            ErrorMessage = null;

            var result = await _authService.LoginAsync(Email, Password);
            if (!result.Success)
            {
                ErrorMessage = result.Error;
                return;
            }

            // 保存令牌/敏感信息
            Preferences.Default.Set("auth_token", result.Token);
            if (!string.IsNullOrEmpty(result.RefreshToken))
                await SecureStorage.Default.SetAsync("refresh_token", result.RefreshToken);

            // 导航至主页面(替换根,避免返回登录)
            Application.Current!.MainPage = new NavigationPage(new Views.MainTabbedPage());
        }
        catch (Exception ex)
        {
            ErrorMessage = ex.Message;
        }
        finally
        {
            IsBusy = false;
        }
    }

    [RelayCommand]
    private async Task GoRegister() =>
        await Application.Current!.MainPage!.Navigation.PushAsync(new Views.RegisterPage());
}

注意事项:

  • 使用 [ObservableProperty] 与 [RelayCommand] 简化 INotifyPropertyChanged 与命令。
  • 计算属性 CanLogin 决定按钮可用性。DataTrigger 和命令 CanExecute 双重保险更稳妥。

4. 模拟认证与本地存储(无网可用)

关键代码:

// Services/AuthService.cs
using System.Text.Json;

namespace MauiAuthSample.Services;

public class AuthService
{
    private record UserDto(string Email, string Password, string DisplayName);

    public async Task<(bool Success, string Token, string RefreshToken, string? Error, string? DisplayName)> LoginAsync(string email, string password)
    {
        // 读取打包资源 Resources/Raw/mock_users.json
        using var stream = await FileSystem.OpenAppPackageFileAsync("mock_users.json");
        var users = await JsonSerializer.DeserializeAsync<List<UserDto>>(stream) 
                    ?? new List<UserDto>();

        var user = users.FirstOrDefault(u => u.Email.Equals(email, StringComparison.OrdinalIgnoreCase));
        if (user is null || user.Password != password)
            return (false, "", "", "Invalid email or password", null);

        // 生成模拟令牌
        var token = Convert.ToBase64String(Guid.NewGuid().ToByteArray());
        var refresh = Convert.ToBase64String(Guid.NewGuid().ToByteArray());

        // 保存简单的个人信息(示例)
        Preferences.Default.Set("display_name", user.DisplayName);
        Preferences.Default.Set("email", user.Email);

        return (true, token, refresh, null, user.DisplayName);
    }

    public void Logout()
    {
        Preferences.Default.Remove("auth_token");
        Preferences.Default.Remove("display_name");
        Preferences.Default.Remove("email");
        SecureStorage.Default.Remove("refresh_token");
    }

    public bool IsLoggedIn() => Preferences.Default.ContainsKey("auth_token");
}

注意事项:

  • 无网络,无需权限;JSON 放在 Resources/Raw,Build Action: MauiAsset。
  • Token 存 Preferences;敏感 refresh_token 放 SecureStorage(iOS Keychain/Android Keystore)。

5. 多语言(Resx)与动态主题(资源字典 + 切换)

关键代码:

// App.xaml(片段)
<?xml version="1.0" encoding="utf-8" ?>
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             x:Class="MauiAuthSample.App"
             xmlns:styles="clr-namespace:MauiAuthSample.Resources.Styles">
    <Application.Resources>
        <ResourceDictionary>
            <styles:Colors />
            <styles:Styles />
        </ResourceDictionary>
    </Application.Resources>
</Application>

// App.xaml.cs
using System.Globalization;

namespace MauiAuthSample;

public partial class App : Application
{
    public App()
    {
        InitializeComponent();

        // 默认主题跟随系统
        UserAppTheme = AppTheme.Unspecified;

        // 根导航
        MainPage = new NavigationPage(new Views.WelcomePage());
    }

    public static void SetCulture(string cultureName)
    {
        var culture = new CultureInfo(cultureName);
        CultureInfo.DefaultThreadCurrentCulture = culture;
        CultureInfo.DefaultThreadCurrentUICulture = culture;

        // 重新创建根以刷新 UI 文案
        Current!.MainPage = new NavigationPage(new Views.WelcomePage());
    }
}
<!-- Views/SettingsPage.xaml:主题与语言切换 + 退出 -->
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="MauiAuthSample.Views.SettingsPage"
             Title="{x:Static resx:AppResources.Settings}">
    <VerticalStackLayout Padding="24" Spacing="16">
        <Label Text="{x:Static resx:AppResources.Theme}" FontAttributes="Bold" />
        <HorizontalStackLayout Spacing="12">
            <Button Text="{x:Static resx:AppResources.Light}" Clicked="LightTheme_Clicked"/>
            <Button Text="{x:Static resx:AppResources.Dark}" Clicked="DarkTheme_Clicked"/>
            <Button Text="{x:Static resx:AppResources.System}" Clicked="SystemTheme_Clicked"/>
        </HorizontalStackLayout>

        <Label Text="{x:Static resx:AppResources.Language}" FontAttributes="Bold" />
        <HorizontalStackLayout Spacing="12">
            <Button Text="English" Clicked="En_Clicked"/>
            <Button Text="中文" Clicked="Zh_Clicked"/>
        </HorizontalStackLayout>

        <Button Text="{x:Static resx:AppResources.Logout}" TextColor="Red" Clicked="Logout_Clicked"/>
    </VerticalStackLayout>
</ContentPage>
// Views/SettingsPage.xaml.cs
using MauiAuthSample.Services;

namespace MauiAuthSample.Views;

public partial class SettingsPage : ContentPage
{
    private readonly AuthService _auth;

    public SettingsPage()
    {
        InitializeComponent();
        _auth = Application.Current!.Services.GetService<AuthService>()!;
    }

    void LightTheme_Clicked(object sender, EventArgs e) => Application.Current!.UserAppTheme = AppTheme.Light;
    void DarkTheme_Clicked(object sender, EventArgs e) => Application.Current!.UserAppTheme = AppTheme.Dark;
    void SystemTheme_Clicked(object sender, EventArgs e) => Application.Current!.UserAppTheme = AppTheme.Unspecified;

    void En_Clicked(object sender, EventArgs e) => App.SetCulture("en");
    void Zh_Clicked(object sender, EventArgs e) => App.SetCulture("zh");

    async void Logout_Clicked(object sender, EventArgs e)
    {
        _auth.Logout();
        Application.Current!.MainPage = new NavigationPage(new WelcomePage());
        await Task.CompletedTask;
    }
}

注意事项:

  • 通过重建根页面刷新多语言(简单可靠的方式)。
  • 主题颜色通过 DynamicResource 绑定到资源字典中的颜色键,实现即时切换。

完整示例

项目结构(关键文件)

  • MauiProgram.cs
  • App.xaml / App.xaml.cs
  • Resources
    • Styles/Colors.xaml
    • Styles/Styles.xaml
    • Raw/mock_users.json
    • Strings/AppResources.resx、AppResources.zh.resx
  • Models/User.cs
  • Services/AuthService.cs、ThemeService.cs(可选)
  • ViewModels/LoginViewModel.cs
  • Views/WelcomePage.xaml(.cs)、LoginPage.xaml(.cs)、RegisterPage.xaml(.cs)、MainTabbedPage.xaml(.cs)、ProfilePage.xaml(.cs)、SettingsPage.xaml(.cs)

csproj 主要配置

<!-- MauiAuthSample.csproj 片段 -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFrameworks>net8.0-android;net8.0-ios</TargetFrameworks>
    <OutputType>Exe</OutputType>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <SupportedOSPlatformVersion>$(TargetFramework)</SupportedOSPlatformVersion>
    <ApplicationTitle>MauiAuthSample</ApplicationTitle>
    <ApplicationId>com.example.mauiauthsample</ApplicationId>
    <MinimumOSVersion>13.0</MinimumOSVersion> <!-- iOS -->
    <UseMaui>true</UseMaui>
  </PropertyGroup>

  <ItemGroup>
    <MauiAsset Include="Resources\Raw\mock_users.json" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
  </ItemGroup>
</Project>

全局样式与颜色

<!-- Resources/Styles/Colors.xaml -->
<?xml version="1.0" encoding="utf-8" ?>
<ResourceDictionary xmlns="http://schemas.microsoft.com/dotnet/2021/maui">
  <!-- 使用 AppThemeColor 支持亮暗模式 -->
  <AppThemeColor x:Key="PrimaryColor" Light="#2563EB" Dark="#60A5FA" />
  <AppThemeColor x:Key="PrimaryTextColor" Light="White" Dark="Black" />
  <Color x:Key="PageBackground">#FFFFFF</Color>
</ResourceDictionary>

<!-- Resources/Styles/Styles.xaml -->
<?xml version="1.0" encoding="utf-8" ?>
<ResourceDictionary xmlns="http://schemas.microsoft.com/dotnet/2021/maui">
  <Style TargetType="ContentPage">
    <Setter Property="BackgroundColor" Value="{DynamicResource PageBackground}" />
    <Setter Property="Padding" Value="16" />
  </Style>
  <Style TargetType="Entry">
    <Setter Property="FontSize" Value="16" />
  </Style>
  <Style TargetType="Label">
    <Setter Property="FontSize" Value="14" />
  </Style>
</ResourceDictionary>

资源字符串(示例键)

// Resources/Strings/AppResources.resx(英文默认)
// 添加以下键值:
// LoginTitle = Login
// Welcome = Welcome
// WelcomeBack = Welcome back!
// Email = Email
// EmailPlaceholder = you@example.com
// Password = Password
// PasswordPlaceholder = enter password (min 6)
// Login = Login
// Register = Register
// Settings = Settings
// Theme = Theme
// Light = Light
// Dark = Dark
// System = System
// Language = Language
// Logout = Logout
// Profile = Profile
// Resources/Strings/AppResources.zh.resx(中文)
// 对应键值翻译:
// LoginTitle = 登录
// Welcome = 欢迎
// WelcomeBack = 欢迎回来!
// Email = 邮箱
// EmailPlaceholder = 你@示例.com
// Password = 密码
// PasswordPlaceholder = 输入密码(至少6位)
// Login = 登录
// Register = 注册
// Settings = 设置
// Theme = 主题
// Light = 亮色
// Dark = 暗色
// System = 跟随系统
// Language = 语言
// Logout = 退出登录
// Profile = 个人资料

模拟用户数据

// Resources/Raw/mock_users.json
[
  { "Email": "user@example.com", "Password": "password123", "DisplayName": "Alice" },
  { "Email": "test@demo.com",   "Password": "demo12345",   "DisplayName": "Bob" }
]

页面与导航

<!-- Views/WelcomePage.xaml -->
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="MauiAuthSample.Views.WelcomePage"
             Title="{x:Static resx:AppResources.Welcome}">
  <VerticalStackLayout Spacing="16" Padding="24">
    <Label Text="{x:Static resx:AppResources.Welcome}" FontSize="28" FontAttributes="Bold" />
    <Button Text="{x:Static resx:AppResources.Login}" Clicked="Login_Clicked" />
    <Button Text="{x:Static resx:AppResources.Register}" Clicked="Register_Clicked" />
  </VerticalStackLayout>
</ContentPage>
// Views/WelcomePage.xaml.cs
using Microsoft.Maui.Controls.PlatformConfiguration;
using Microsoft.Maui.Controls.PlatformConfiguration.iOSSpecific;

namespace MauiAuthSample.Views;

public partial class WelcomePage : ContentPage
{
    public WelcomePage()
    {
        InitializeComponent();
        On<iOS>().SetUseSafeArea(true);
    }

    async void Login_Clicked(object sender, EventArgs e) =>
        await Navigation.PushAsync(new LoginPage());

    async void Register_Clicked(object sender, EventArgs e) =>
        await Navigation.PushAsync(new RegisterPage());
}
<!-- Views/MainTabbedPage.xaml -->
<?xml version="1.0" encoding="utf-8" ?>
<TabbedPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
            xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
            x:Class="MauiAuthSample.Views.MainTabbedPage">
  <NavigationPage Title="{x:Static resx:AppResources.Profile}">
    <x:Arguments>
      <views:ProfilePage xmlns:views="clr-namespace:MauiAuthSample.Views" />
    </x:Arguments>
  </NavigationPage>
  <NavigationPage Title="{x:Static resx:AppResources.Settings}">
    <x:Arguments>
      <views:SettingsPage xmlns:views="clr-namespace:MauiAuthSample.Views" />
    </x:Arguments>
  </NavigationPage>
</TabbedPage>
<!-- Views/ProfilePage.xaml -->
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="MauiAuthSample.Views.ProfilePage"
             Title="{x:Static resx:AppResources.Profile}">
  <VerticalStackLayout Padding="24" Spacing="8">
    <Label Text="{x:Static resx:AppResources.Profile}" FontSize="24" FontAttributes="Bold"/>
    <Label Text="{Binding DisplayName}" />
    <Label Text="{Binding Email}" />
  </VerticalStackLayout>
</ContentPage>
// Views/ProfilePage.xaml.cs
namespace MauiAuthSample.Views;

public partial class ProfilePage : ContentPage
{
    public ProfilePage()
    {
        InitializeComponent();
        BindingContext = new
        {
            DisplayName = Preferences.Default.Get("display_name", "User"),
            Email = Preferences.Default.Get("email", "user@example.com")
        };
    }
}
<!-- Views/RegisterPage.xaml(示例占位,可扩展) -->
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             x:Class="MauiAuthSample.Views.RegisterPage"
             Title="{x:Static resx:AppResources.Register}">
  <VerticalStackLayout Padding="24">
    <Label Text="Registration demo (not implemented)" />
  </VerticalStackLayout>
</ContentPage>
// Views/LoginPage.xaml.cs
using MauiAuthSample.ViewModels;
using Microsoft.Maui.Controls.PlatformConfiguration;
using Microsoft.Maui.Controls.PlatformConfiguration.iOSSpecific;

namespace MauiAuthSample.Views;

public partial class LoginPage : ContentPage
{
    public LoginPage()
    {
        InitializeComponent();
        On<iOS>().SetUseSafeArea(true);
        BindingContext = Application.Current!.Services.GetService<LoginViewModel>()!;
    }
}

常见问题

  1. 登录按钮总是不可用
  • 原因:未触发 CanLogin 更新。
  • 解决:在 ViewModel 构造函数中监听 PropertyChanged 并调用 OnPropertyChanged(nameof(CanLogin));确保 Regex 合法、密码长度 >= 6;DataTrigger 与命令同时控制可用性最佳。
  1. 切换语言后文案不刷新
  • 原因:x:Static 绑定不会自动更新。
  • 解决:切换 Culture 后重建根页面(替换 MainPage)或实现资源管理器并全局广播更新。示例中使用重建根页面的方式。
  1. iOS 顶部内容被状态栏遮挡
  • 原因:未启用安全区域。
  • 解决:在页面构造函数调用 On().SetUseSafeArea(true)。或在全局样式中为 iOS 设置额外顶部 Padding。

UI/UX 设计最佳实践

  • 使用 Grid/StackLayout 组合,控制最少嵌套提高性能;保证触控目标至少 44x44。
  • 异步操作显示忙碌状态(IsBusy)并禁用按钮,避免重复提交。
  • 主题用 DynamicResource 绑定,保证切换即时生效;对比度足够,文字与背景颜色在深浅主题下均清晰。
  • 登录失败提供明确、短句式错误信息,不泄露具体账号存在与否的细节(示例中已简化)。

按以上步骤与示例即可在 Android 8.0+ 和 iOS 13+ 上完成一个可运行的 MVVM 入门应用,具备导航、认证、本地化与主题切换能力。

任务概述

目标:实现“巡检记录”模块(Android/iOS/Windows,.NET 8 + .NET MAUI),功能包括:

  • 使用系统媒体选择器拍照(Windows无相机时降级为文件选择),JPEG压缩与尺寸限制,规范化文件命名。
  • 获取当前位置(高精度,含权限处理与超时;定位不可用时降级为手动输入)。
  • 保存为本地草稿(SQLite + 照片文件),支持编辑描述。
  • 队列化上传至API(Bearer令牌),显示上传进度,断点续传/重试与失败重传,记录上传历史。
  • 后台任务:在网络可用时自动重试(前台/恢复时触发),跨平台权限声明与运行时请求。

重点:平台差异与适配、完整可运行示例、测试与调试方法、常见错误预防。


分步指南

  1. 基础搭建与依赖
  • 安装依赖与注册服务
  • 配置平台权限声明

关键代码

// 命令行安装 NuGet
// dotnet add package CommunityToolkit.Mvvm
// dotnet add package SQLite-net-pcl
// dotnet add package SkiaSharp
// dotnet add package SkiaSharp.Views.Maui.Controls

// MauiProgram.cs
using CommunityToolkit.Mvvm.DependencyInjection;
using Microsoft.Extensions.Logging;
using SkiaSharp.Views.Maui.Controls.Hosting;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .UseSkiaSharp() // 用于图片压缩
            .ConfigureFonts(fonts => fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"));

        // HttpClient(注入Bearer令牌在运行时设置)
        builder.Services.AddHttpClient("Api", c =>
        {
            c.Timeout = TimeSpan.FromMinutes(2);
            c.BaseAddress = new Uri("https://api.example.com/"); // 替换为真实地址(HTTPS)
        });

        // 数据库、服务、VM
        builder.Services.AddSingleton<Database>();
        builder.Services.AddSingleton<IInspectionRepository, InspectionRepository>();
        builder.Services.AddSingleton<IPhotoService, PhotoService>();
        builder.Services.AddSingleton<ILocationService, LocationService>();
        builder.Services.AddSingleton<IUploadService, UploadService>();
        builder.Services.AddSingleton<QueueViewModel>();
        builder.Services.AddSingleton<NewRecordViewModel>();

        builder.Services.AddSingleton<AppShell>();

        builder.Logging.AddDebug();

        return builder.Build();
    }
}

注意事项

  • Android: 在 Platforms/Android/AndroidManifest.xml 声明权限。
  • iOS: 在 Platforms/iOS/Info.plist 声明用途描述(UsageDescription)。
  • Windows: 在 Package.appxmanifest 勾选 Location capability;Windows端默认降级为文件选择与手动经纬度输入。

AndroidManifest 片段

<!-- Platforms/Android/AndroidManifest.xml -->
<manifest ...>
  <uses-permission android:name="android.permission.INTERNET" />
  <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
  <uses-permission android:name="android.permission.CAMERA" />
  <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
  <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
  <!-- 无需外部存储权限,保存至AppData目录 -->
  <application android:usesCleartextTraffic="false" ... />
</manifest>

iOS Info.plist 片段

<!-- Platforms/iOS/Info.plist -->
<plist version="1.0">
  <dict>
    <key>NSCameraUsageDescription</key>
    <string>用于巡检记录拍照</string>
    <key>NSLocationWhenInUseUsageDescription</key>
    <string>用于记录巡检位置</string>
    <key>NSPhotoLibraryUsageDescription</key>
    <string>用于选择现有照片</string>
    <!-- 鉴于ATS,确保API为HTTPS -->
  </dict>
</plist>

Windows capability 片段

<!-- Platforms/Windows/Package.appxmanifest (Capabilities) -->
<Capabilities>
  <Capability Name="internetClient" />
  <DeviceCapability Name="location" />
</Capabilities>
  1. 数据模型与本地存储(SQLite + 文件命名)
  • 设计记录实体、状态机字段(Draft/PendingUpload/Uploading/Uploaded/Failed)
  • 统一文件命名:{yyyyMMdd_HHmmss}_{guid}_lat{lat}_lon{lon}.jpg

关键代码

// Models/InspectionRecord.cs
using SQLite;

public enum UploadStatus { Draft, PendingUpload, Uploading, Uploaded, Failed }

[Table("InspectionRecords")]
public class InspectionRecord
{
    [PrimaryKey, AutoIncrement] public int Id { get; set; }
    [Indexed] public UploadStatus Status { get; set; } = UploadStatus.Draft;
    public string? PhotoPath { get; set; }
    public double? Latitude { get; set; }
    public double? Longitude { get; set; }
    public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
    public string? Description { get; set; }
    public int RetryCount { get; set; }
    public string? LastError { get; set; }
    public DateTimeOffset? UploadedAt { get; set; }
    public string? RemoteId { get; set; }
}

// Data/Database.cs
using SQLite;

public class Database
{
    private readonly SQLiteAsyncConnection _conn;
    public Database()
    {
        var dbPath = Path.Combine(FileSystem.AppDataDirectory, "app.db3");
        _conn = new SQLiteAsyncConnection(dbPath);
        _conn.CreateTableAsync<InspectionRecord>().Wait();
    }
    public SQLiteAsyncConnection Connection => _conn;
}

// Data/InspectionRepository.cs
public interface IInspectionRepository
{
    Task<int> AddAsync(InspectionRecord rec);
    Task UpdateAsync(InspectionRecord rec);
    Task<List<InspectionRecord>> GetByStatusAsync(UploadStatus status, int take = 50);
    Task<InspectionRecord?> GetAsync(int id);
}

public class InspectionRepository : IInspectionRepository
{
    private readonly SQLiteAsyncConnection _db;
    public InspectionRepository(Database db) => _db = db.Connection;

    public Task<int> AddAsync(InspectionRecord rec) => _db.InsertAsync(rec);
    public Task UpdateAsync(InspectionRecord rec) => _db.UpdateAsync(rec);
    public Task<InspectionRecord?> GetAsync(int id) => _db.Table<InspectionRecord>().FirstOrDefaultAsync(x => x.Id == id);
    public Task<List<InspectionRecord>> GetByStatusAsync(UploadStatus status, int take = 50) =>
        _db.Table<InspectionRecord>().Where(x => x.Status == status).OrderBy(x => x.CreatedAt).Take(take).ToListAsync();
}

// Utils/Naming.cs
public static class Naming
{
    public static string BuildPhotoFileName(DateTimeOffset timestamp, double? lat, double? lon)
    {
        var ts = timestamp.ToLocalTime().ToString("yyyyMMdd_HHmmss");
        var guid = Guid.NewGuid().ToString("N").Substring(0, 8);
        var loc = (lat.HasValue && lon.HasValue) ? $"_lat{lat.Value:F6}_lon{lon.Value:F6}" : "";
        return $"{ts}_{guid}{loc}.jpg";
    }
}

注意事项

  • SQLite 使用单个异步连接实例,避免“database is locked”。
  • 所有文件保存至 FileSystem.AppDataDirectory,避免外部存储权限问题。
  1. 拍照/选择图片、压缩保存与定位获取
  • 使用 MediaPicker 拍照;Windows 无相机时使用 FilePicker 选择图片。
  • 使用 SkiaSharp 压缩至 JPEG(最大边 1600px,质量 80)。
  • 获取定位(高精度 + 超时),失败时降级为手动输入(Windows优先提示)。

关键代码

// Services/PhotoService.cs
using SkiaSharp;

public interface IPhotoService
{
    Task<string?> CaptureOrPickAndCompressAsync(CancellationToken ct);
}

public class PhotoService : IPhotoService
{
    private const int MaxSize = 1600;
    private const int JpegQuality = 80;

    public async Task<string?> CaptureOrPickAndCompressAsync(CancellationToken ct)
    {
        FileResult? file = null;

        // 优先尝试拍照
        try
        {
            if (MediaPicker.Default.IsCaptureSupported)
            {
                var camStatus = await Permissions.RequestAsync<Permissions.Camera>();
                if (camStatus == PermissionStatus.Granted)
                    file = await MediaPicker.Default.CapturePhotoAsync(new MediaPickerOptions { Title = "巡检拍照" });
            }
        }
        catch { /* 忽略,转用文件选择 */ }

        // 降级为选择照片/文件
        if (file == null)
        {
            try
            {
#if WINDOWS
                // Windows 更符合习惯:直接文件选择
                var picked = await FilePicker.Default.PickAsync(new PickOptions
                {
                    PickerTitle = "选择巡检照片",
                    FileTypes = FilePickerFileType.Images
                });
                file = picked;
#else
                var picked = await MediaPicker.Default.PickPhotoAsync();
                file = picked;
#endif
            }
            catch { return null; }
        }

        if (file == null) return null;

        using var stream = await file.OpenReadAsync();
        using var skStream = new SKManagedStream(stream);
        using var codec = SKCodec.Create(skStream);
        using var bitmap = SKBitmap.Decode(codec);

        if (bitmap == null) return null;

        int w = bitmap.Width, h = bitmap.Height;
        var scale = Math.Min(1.0, (double)MaxSize / Math.Max(w, h));
        int newW = (int)(w * scale);
        int newH = (int)(h * scale);

        using var resized = new SKBitmap(newW, newH, bitmap.ColorType, bitmap.AlphaType);
        bitmap.ScalePixels(resized, SKFilterQuality.Medium);

        var fileName = Naming.BuildPhotoFileName(DateTimeOffset.UtcNow, null, null); // 先不带坐标,稍后更新文件名或记录
        var outPath = Path.Combine(FileSystem.AppDataDirectory, fileName);

        using var image = SKImage.FromBitmap(resized);
        using var data = image.Encode(SKEncodedImageFormat.Jpeg, JpegQuality);
        using var fs = File.Open(outPath, FileMode.Create, FileAccess.Write, FileShare.None);
        data.SaveTo(fs);

        return outPath;
    }
}

// Services/LocationService.cs
public interface ILocationService
{
    Task<(double? lat, double? lon)> GetHighAccuracyAsync(TimeSpan timeout, CancellationToken ct);
}

public class LocationService : ILocationService
{
    public async Task<(double? lat, double? lon)> GetHighAccuracyAsync(TimeSpan timeout, CancellationToken ct)
    {
        try
        {
#if WINDOWS
            // Windows 设备可能无GPS,先请求权限,失败/不支持则返回空
#endif
            var status = await Permissions.RequestAsync<Permissions.LocationWhenInUse>();
            if (status != PermissionStatus.Granted) return (null, null);

            var req = new GeolocationRequest(GeolocationAccuracy.Best, timeout);
            var loc = await Geolocation.Default.GetLocationAsync(req, ct);
            if (loc == null) return (null, null);
            return (loc.Latitude, loc.Longitude);
        }
        catch (FeatureNotSupportedException) { return (null, null); }
        catch (PermissionException) { return (null, null); }
        catch (Exception) { return (null, null); }
    }
}

注意事项

  • iOS 安全区:页面上使用 On().SetUseSafeArea(true) 或 Shell 自动处理。
  • Android 部分设备返回的图片旋转信息保存在 EXIF;如需保持方向,可读取 EXIF 旋转再应用(示例为简版)。
  • 定位超时建议 10–15 秒;若失败,提示用户手动输入经纬度。
  1. 队列化上传、进度与重试
  • 记录状态机:Draft -> PendingUpload -> Uploading -> Uploaded/Failed
  • 上传前检查网络;监听 Connectivity.ConnectivityChanged 自动触发。
  • 失败重试:指数退避,最多 N 次;应用启动/恢复时自动扫描重试。
  • 进度上报:自定义 ProgressableStreamContent。

关键代码

// Services/UploadService.cs
using System.Net.Http.Headers;

public interface IUploadService
{
    IProgress<(int id, double progress)>? Progress { get; set; }
    Task EnqueueAsync(int recordId);
    Task StartPendingUploadAsync(CancellationToken ct);
    Task SetBearerAsync(string token);
}

public class UploadService : IUploadService
{
    private readonly IHttpClientFactory _httpFactory;
    private readonly IInspectionRepository _repo;

    public IProgress<(int id, double progress)>? Progress { get; set; }
    private string? _token;

    public UploadService(IHttpClientFactory httpFactory, IInspectionRepository repo)
    {
        _httpFactory = httpFactory;
        _repo = repo;
        Connectivity.ConnectivityChanged += async (_, __) =>
        {
            if (Connectivity.Current.NetworkAccess == NetworkAccess.Internet)
                await StartPendingUploadAsync(CancellationToken.None);
        };
    }

    public Task SetBearerAsync(string token) { _token = token; return Task.CompletedTask; }

    public async Task EnqueueAsync(int recordId)
    {
        var rec = await _repo.GetAsync(recordId);
        if (rec == null) return;
        rec.Status = UploadStatus.PendingUpload;
        rec.RetryCount = 0;
        rec.LastError = null;
        await _repo.UpdateAsync(rec);
    }

    public async Task StartPendingUploadAsync(CancellationToken ct)
    {
        if (Connectivity.Current.NetworkAccess != NetworkAccess.Internet) return;

        var list = await _repo.GetByStatusAsync(UploadStatus.PendingUpload, 20);
        foreach (var rec in list)
        {
            ct.ThrowIfCancellationRequested();
            await UploadOneAsync(rec, ct);
        }
    }

    private async Task UploadOneAsync(InspectionRecord rec, CancellationToken ct)
    {
        var client = _httpFactory.CreateClient("Api");
        if (!string.IsNullOrEmpty(_token))
            client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _token);

        rec.Status = UploadStatus.Uploading;
        await _repo.UpdateAsync(rec);

        try
        {
            using var fs = File.OpenRead(rec.PhotoPath!);
            var content = new MultipartFormDataContent();
            var fileContent = new ProgressableStreamContent(fs, 64 * 1024, p =>
            {
                Progress?.Report((rec.Id, p));
            });
            fileContent.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
            content.Add(fileContent, "file", Path.GetFileName(rec.PhotoPath!));
            content.Add(new StringContent(rec.Description ?? ""), "description");
            content.Add(new StringContent(rec.CreatedAt.ToString("O")), "timestamp");
            if (rec.Latitude.HasValue && rec.Longitude.HasValue)
            {
                content.Add(new StringContent(rec.Latitude.Value.ToString("F6")), "lat");
                content.Add(new StringContent(rec.Longitude.Value.ToString("F6")), "lon");
            }

            using var resp = await client.PostAsync("inspections/upload", content, ct);
            if (!resp.IsSuccessStatusCode)
                throw new HttpRequestException($"HTTP {(int)resp.StatusCode}");

            // 假设返回JSON含remoteId
            var remoteId = await resp.Content.ReadAsStringAsync();
            rec.RemoteId = remoteId;
            rec.Status = UploadStatus.Uploaded;
            rec.UploadedAt = DateTimeOffset.UtcNow;
            rec.LastError = null;
            rec.RetryCount = 0;
            await _repo.UpdateAsync(rec);
            Progress?.Report((rec.Id, 1.0));
        }
        catch (Exception ex)
        {
            rec.Status = UploadStatus.Failed;
            rec.RetryCount += 1;
            rec.LastError = ex.Message;
            await _repo.UpdateAsync(rec);

            // 指数退避简单实现(异步调度)
            var delay = TimeSpan.FromSeconds(Math.Min(60, Math.Pow(2, rec.RetryCount)));
            _ = Task.Run(async () =>
            {
                await Task.Delay(delay);
                if (rec.RetryCount <= 5)
                {
                    rec.Status = UploadStatus.PendingUpload;
                    await _repo.UpdateAsync(rec);
                    await StartPendingUploadAsync(CancellationToken.None);
                }
            });
        }
    }
}

// Utils/ProgressableStreamContent.cs
public class ProgressableStreamContent : HttpContent
{
    private const int defaultBufferSize = 4096;
    private readonly Stream _content;
    private readonly int _bufferSize;
    private readonly Action<double> _progress;

    public ProgressableStreamContent(Stream content, int bufferSize, Action<double> progress)
    {
        _content = content;
        _bufferSize = bufferSize <= 0 ? defaultBufferSize : bufferSize;
        _progress = progress;
        Headers.ContentLength = _content.Length;
    }
    protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context)
    {
        var buffer = new byte[_bufferSize];
        long uploaded = 0;
        int read;
        while ((read = await _content.ReadAsync(buffer, 0, buffer.Length)) > 0)
        {
            await stream.WriteAsync(buffer.AsMemory(0, read));
            uploaded += read;
            _progress(Math.Min(1.0, (double)uploaded / _content.Length));
        }
    }
    protected override bool TryComputeLength(out long length)
    {
        length = _content.Length;
        return true;
    }
}

注意事项

  • iOS 后台上传受系统限制;示例通过前台/应用恢复与网络变化触发队列重试,满足大多数业务场景。
  • 使用 HTTPS API,避免ATS/网络安全策略问题。
  • 大文件上传建议服务端支持分块或断点续传(示例为整文件上传)。
  1. 页面与导航(TabbedPage)
  • 新建记录页:拍照、定位、描述、保存草稿、入队上传
  • 队列/历史页:显示进度、重试、失败提示

关键代码

<!-- AppShell.xaml -->
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
       xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
       xmlns:views="clr-namespace:InspectionApp.Views"
       x:Class="InspectionApp.AppShell">
  <TabBar>
    <ShellContent Title="新建" ContentTemplate="{DataTemplate views:NewRecordPage}" />
    <ShellContent Title="队列/历史" ContentTemplate="{DataTemplate views:QueuePage}" />
  </TabBar>
</Shell>
<!-- Views/NewRecordPage.xaml -->
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="InspectionApp.Views.NewRecordPage"
             Title="新建巡检">
  <ScrollView>
    <VerticalStackLayout Padding="16" Spacing="12">
      <Image x:Name="Preview" HeightRequest="200" Aspect="AspectFill" />
      <Button Text="拍照/选择图片" Command="{Binding CaptureCommand}" />
      <HorizontalStackLayout Spacing="8">
        <Button Text="获取定位" Command="{Binding GetLocationCommand}" />
        <Label Text="{Binding LocationText}" VerticalOptions="Center" />
      </HorizontalStackLayout>
      <!-- Windows 手动输入降级 -->
      <Grid ColumnDefinitions="*,*" >
        <Entry Placeholder="纬度(可手动)" Text="{Binding Latitude}" Keyboard="Numeric"/>
        <Entry Placeholder="经度(可手动)" Text="{Binding Longitude}" Keyboard="Numeric" Grid.Column="1"/>
      </Grid>
      <Editor Placeholder="描述..." AutoSize="TextChanges" Text="{Binding Description}" />
      <HorizontalStackLayout Spacing="8">
        <Button Text="保存草稿" Command="{Binding SaveDraftCommand}" />
        <Button Text="保存并入队上传" Command="{Binding SaveAndEnqueueCommand}" />
      </HorizontalStackLayout>
      <Label Text="{Binding Message}" TextColor="Gray" />
    </VerticalStackLayout>
  </ScrollView>
</ContentPage>
// ViewModels/NewRecordViewModel.cs
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.Globalization;

public partial class NewRecordViewModel : ObservableObject
{
    private readonly IPhotoService _photo;
    private readonly ILocationService _loc;
    private readonly IInspectionRepository _repo;
    private readonly IUploadService _upload;

    [ObservableProperty] string? photoPath;
    [ObservableProperty] string? description;
    [ObservableProperty] string message;
    [ObservableProperty] string locationText = "未获取";
    [ObservableProperty] string? latitude;
    [ObservableProperty] string? longitude;

    public NewRecordViewModel(IPhotoService photo, ILocationService loc, IInspectionRepository repo, IUploadService upload)
    {
        _photo = photo; _loc = loc; _repo = repo; _upload = upload;
    }

    [RelayCommand]
    private async Task Capture(CancellationToken ct)
    {
        try
        {
            var path = await _photo.CaptureOrPickAndCompressAsync(ct);
            if (path != null)
            {
                PhotoPath = path;
                Message = "图片已选择并压缩";
            }
        }
        catch (Exception ex) { Message = $"图片失败: {ex.Message}"; }
    }

    [RelayCommand]
    private async Task GetLocation(CancellationToken ct)
    {
        var (lat, lon) = await _loc.GetHighAccuracyAsync(TimeSpan.FromSeconds(12), ct);
        if (lat.HasValue && lon.HasValue)
        {
            Latitude = lat.Value.ToString("F6", CultureInfo.InvariantCulture);
            Longitude = lon.Value.ToString("F6", CultureInfo.InvariantCulture);
            LocationText = $"Lat:{Latitude}, Lon:{Longitude}";
        }
        else
        {
#if WINDOWS
            Message = "定位不可用,请手动输入经纬度。";
#else
            Message = "定位失败,可稍后重试或手动输入。";
#endif
        }
    }

    [RelayCommand]
    private async Task SaveDraft(CancellationToken ct) => await SaveInternalAsync(false);

    [RelayCommand]
    private async Task SaveAndEnqueue(CancellationToken ct) => await SaveInternalAsync(true);

    private async Task SaveInternalAsync(bool enqueue)
    {
        if (string.IsNullOrWhiteSpace(PhotoPath))
        {
            Message = "请先拍照或选择图片。";
            return;
        }
        double? lat = double.TryParse(Latitude, NumberStyles.Float, CultureInfo.InvariantCulture, out var la) ? la : null;
        double? lon = double.TryParse(Longitude, NumberStyles.Float, CultureInfo.InvariantCulture, out var lo) ? lo : null;

        var rec = new InspectionRecord
        {
            PhotoPath = PhotoPath,
            Description = Description,
            Latitude = lat,
            Longitude = lon,
            Status = enqueue ? UploadStatus.PendingUpload : UploadStatus.Draft,
            CreatedAt = DateTimeOffset.UtcNow
        };
        await _repo.AddAsync(rec);
        if (enqueue) await _upload.EnqueueAsync(rec.Id);

        Message = enqueue ? "已保存并加入上传队列" : "草稿已保存";
        // 可清理页面状态
    }
}
<!-- Views/QueuePage.xaml -->
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="InspectionApp.Views.QueuePage"
             Title="队列与历史">
  <VerticalStackLayout Padding="12" Spacing="8">
    <Button Text="开始上传(手动)" Command="{Binding StartUploadCommand}" />
    <CollectionView ItemsSource="{Binding Items}">
      <CollectionView.ItemTemplate>
        <DataTemplate>
          <Grid ColumnDefinitions="Auto,*,Auto" Padding="6">
            <Image Source="{Binding PhotoPath}" WidthRequest="60" HeightRequest="60" Aspect="AspectFill"/>
            <VerticalStackLayout Grid.Column="1" Spacing="2">
              <Label Text="{Binding Description}" LineBreakMode="TailTruncation"/>
              <Label Text="{Binding Status}" FontSize="12" TextColor="Gray"/>
              <ProgressBar Progress="{Binding Progress}" />
              <Label Text="{Binding LastError}" FontSize="10" TextColor="Tomato"/>
            </VerticalStackLayout>
            <Button Text="重试" Grid.Column="2"
                    Command="{Binding BindingContext.RetryCommand, Source={x:Reference Name=ThisPage}}"
                    CommandParameter="{Binding .}" />
          </Grid>
        </DataTemplate>
      </CollectionView.ItemTemplate>
    </CollectionView>
  </VerticalStackLayout>
</ContentPage>
// ViewModels/QueueViewModel.cs
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.Collections.ObjectModel;

public partial class QueueItemVM : ObservableObject
{
    public int Id { get; }
    public string? PhotoPath { get; }
    public string? Description { get; }
    [ObservableProperty] public UploadStatus status;
    [ObservableProperty] public double progress;
    [ObservableProperty] public string? lastError;

    public QueueItemVM(InspectionRecord r)
    {
        Id = r.Id; PhotoPath = r.PhotoPath; Description = r.Description;
        Status = r.Status; Progress = r.Status == UploadStatus.Uploaded ? 1 : 0;
        LastError = r.LastError;
    }
}

public partial class QueueViewModel : ObservableObject
{
    private readonly IInspectionRepository _repo;
    private readonly IUploadService _upload;
    public ObservableCollection<QueueItemVM> Items { get; } = new();

    public QueueViewModel(IInspectionRepository repo, IUploadService upload)
    {
        _repo = repo; _upload = upload;
        _upload.Progress = new Progress<(int id, double progress)>(p =>
        {
            var item = Items.FirstOrDefault(x => x.Id == p.id);
            if (item != null) item.Progress = p.progress;
        });
        _ = RefreshAsync();
    }

    private async Task RefreshAsync()
    {
        Items.Clear();
        var all = new List<InspectionRecord>();
        all.AddRange(await _repo.GetByStatusAsync(UploadStatus.PendingUpload, 100));
        all.AddRange(await _repo.GetByStatusAsync(UploadStatus.Uploading, 100));
        all.AddRange(await _repo.GetByStatusAsync(UploadStatus.Failed, 100));
        all.AddRange(await _repo.GetByStatusAsync(UploadStatus.Uploaded, 100));
        foreach (var r in all.OrderByDescending(x => x.CreatedAt))
            Items.Add(new QueueItemVM(r));
    }

    [RelayCommand]
    private async Task StartUpload(CancellationToken ct)
    {
        await _upload.StartPendingUploadAsync(ct);
        await RefreshAsync();
    }

    [RelayCommand]
    private async Task Retry(QueueItemVM item, CancellationToken ct)
    {
        var rec = await _repo.GetAsync(item.Id);
        if (rec == null) return;
        rec.Status = UploadStatus.PendingUpload;
        rec.LastError = null;
        rec.RetryCount = 0;
        await _repo.UpdateAsync(rec);
        await _upload.StartPendingUploadAsync(ct);
        await RefreshAsync();
    }
}

注意事项

  • 进度显示仅在“上传中”阶段生效;Uploaded 强制设置为 100%。
  • 历史记录:示例中 Uploaded 也加入列表,可根据需求分页。

完整示例

最小可运行文件清单(命名空间示例为 InspectionApp):

  • MauiProgram.cs(上文)
  • App.xaml / App.xaml.cs
  • AppShell.xaml / AppShell.xaml.cs
  • Models/InspectionRecord.cs
  • Data/Database.cs, Data/InspectionRepository.cs
  • Services/PhotoService.cs, LocationService.cs, UploadService.cs, Utils/ProgressableStreamContent.cs, Utils/Naming.cs
  • ViewModels/NewRecordViewModel.cs, QueueViewModel.cs
  • Views/NewRecordPage.xaml(.cs), Views/QueuePage.xaml(.cs)
  • 平台清单:AndroidManifest.xml, Info.plist, Package.appxmanifest

App.xaml

<?xml version="1.0" encoding="UTF-8" ?>
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             x:Class="InspectionApp.App">
  <Application.Resources>
  </Application.Resources>
</Application>

App.xaml.cs

public partial class App : Application
{
    public App(AppShell shell, IUploadService upload)
    {
        InitializeComponent();
        MainPage = shell;

        // 应用启动时尝试上传未完成任务(前台)
        _ = upload.StartPendingUploadAsync(CancellationToken.None);
    }
}

AppShell.xaml(上文)

public partial class AppShell : Shell
{
    public AppShell(NewRecordViewModel newVM, QueueViewModel queueVM)
    {
        InitializeComponent();
        // 通过 Shell 路由注入 VM(简化:让页面自己Resolve或在 .xaml.cs 里赋值)
    }
}

Views/NewRecordPage.xaml.cs

public partial class NewRecordPage : ContentPage
{
    public NewRecordPage(NewRecordViewModel vm)
    {
        InitializeComponent();
        BindingContext = vm;
#if IOS
        this.On<iOS>().SetUseSafeArea(true);
#endif
        vm.PropertyChanged += (_, e) =>
        {
            if (e.PropertyName == nameof(vm.PhotoPath) && !string.IsNullOrEmpty(vm.PhotoPath))
                Preview.Source = ImageSource.FromFile(vm.PhotoPath);
        };
    }
}

Views/QueuePage.xaml.cs

public partial class QueuePage : ContentPage
{
    public QueuePage(QueueViewModel vm)
    {
        InitializeComponent();
        BindingContext = vm;
        this.SetValue(NameProperty, "ThisPage"); // x:Reference
    }
}

Bearer 令牌设置(登录后)

// 假设登录后获取 token
await SecureStorage.SetAsync("token", token);
var upload = Ioc.Default.GetService<IUploadService>()!;
await upload.SetBearerAsync(token);

说明

  • API 路径、返回解析请按实际接口调整。
  • 如果需要 Shell 级别路由和更多页面,可在 AppShell 中注册。

常见问题

  1. 权限被拒或定位不可用
  • 现象:GetLocationAsync 抛出异常或返回 null;Android 13+ 精确定位未授权。
  • 解决:
    • 使用 Permissions.LocationWhenInUse 并在系统设置中启用“精确定位”。
    • 超时后提示手动输入经纬度(Windows 优先降级)。
    • 对 iOS,确保 Info.plist 中的用途描述清晰,避免系统拒绝。
  1. 媒体选择与图片方向/体积过大
  • 现象:照片显示旋转、上传慢。
  • 解决:
    • 使用 SkiaSharp 解码并统一压缩尺寸(最大边 1600px),质量 80。
    • 如需处理旋转,读取 EXIF Orientation 并在压缩前旋转矫正。
  1. 上传失败与重试风暴
  • 现象:网络不稳定时频繁失败,消耗流量与电量。
  • 解决:
    • 在失败时切换状态为 Failed,采用指数退避并限制最大重试次数(如 5 次)。
    • 监听 Connectivity,仅在 NetworkAccess.Internet 时启动上传。
    • 服务端返回 4xx(鉴权失败)不要重试,要求用户重新登录获取 Bearer 令牌。

平台差异与适配要点(摘要)

  • Android:需 CAMERA、ACCESS_FINE_LOCATION 权限;MediaPicker.CapturePhotoAsync 正常工作;AppDataDirectory 无需外部存储权限。
  • iOS:必须 HTTPS;Info.plist 用途描述;后台上传受限,前台/恢复时触发队列足够;使用安全区域。
  • Windows:可能无相机与GPS;使用 FilePicker 选择图片并手动输入位置;启用 location capability;保存在 AppData 目录。

测试与调试方法

  • 模拟定位
    • Android Emulator:设置扩展控件 > Location,提供固定坐标。
    • iOS Simulator:Features > Location 选择 Custom Location。
    • Windows:不依赖 GPS,测试手动输入经纬度流程。
  • 网络切换
    • 断开/连接网络,验证队列触发与重试行为。
  • 大图压力测试
    • 使用 >10MB 照片,确认压缩后尺寸与体积符合预期,上传进度条正常。
  • 异常路径
    • 拒绝相机/定位权限、API 返回 401/500、存储空间不足(捕获并提示)。

通过以上步骤,你将获得一个跨平台、可离线运行、具有可靠上传与错误恢复机制的“巡检记录”模块。可在此基础上继续扩展:多语言(Resources + CultureInfo)、动态主题、更多上传并发控制(Channel/SemaphoreSlim)与后台传输(平台特定扩展)。

任务概述

目标:将一个基于 .NET 8 的轻量待办(Todo)应用通过 CI/CD 打包并上架到 iOS、Android、macOS、Windows 各商店,统一版本策略,配置图标与启动图,完善隐私与权限说明,启用推送能力,完成各平台签名与分发(Android keystore/AAB、iOS 证书/描述文件/归档上传、Windows MSIX、macOS 签名与公证),并准备商店素材与合规项。应用包含多语言(Resx)、本地数据存储(SQLite/文件/首选项),区分测试与发布多环境配置。


分步指南

1. 统一版本与多环境配置(版本号、构建号、应用ID)

关键代码(Directory.Build.props:语义化版本统一写入所有平台)

<Project>
  <PropertyGroup>
    <!-- 统一语义化版本,示例:1.2.0 -->
    <SemanticVersion>1.2.0</SemanticVersion>

    <!-- 构建号(CI 注入,如 GitHub Actions/ADO 的运行号);必须为纯数字以满足 iOS/Android 要求 -->
    <BuildNumber Condition="'$(BuildNumber)'==''">123</BuildNumber>

    <!-- MAUI 通用版本字段 -->
    <ApplicationDisplayVersion>$(SemanticVersion)</ApplicationDisplayVersion>
    <ApplicationVersion>$(BuildNumber)</ApplicationVersion>
  </PropertyGroup>
</Project>

关键代码(MyTodoApp.csproj:应用ID/包名、平台映射、资源)

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFrameworks>net8.0-android;net8.0-ios;net8.0-maccatalyst;net8.0-windows10.0.19041.0</TargetFrameworks>
    <UseMaui>true</UseMaui>

    <!-- AppID/包名(注意:Android 与 iOS/macOS/Windows 各自规范) -->
    <ApplicationId Condition="'$(TargetFramework)'=='net8.0-android'">com.example.todo</ApplicationId>
    <BundleIdentifier Condition="'$(TargetFramework)'=='net8.0-ios'">com.example.todo</BundleIdentifier>
    <BundleIdentifier Condition="'$(TargetFramework)'=='net8.0-maccatalyst'">com.example.todo</BundleIdentifier>
    <WindowsPackageIdentityName Condition="'$(TargetFramework.Contains(windows))'">ExampleCompany.Todo</WindowsPackageIdentityName>

    <!-- 版本统一来自 Directory.Build.props -->
    <ApplicationDisplayVersion>$(ApplicationDisplayVersion)</ApplicationDisplayVersion>
    <ApplicationVersion>$(ApplicationVersion)</ApplicationVersion>

    <!-- Android AAB 打包 -->
    <AndroidPackageFormat>aab</AndroidPackageFormat>
    <!-- Android 13+ 支持通知权限 -->
    <TargetSdkVersion>34</TargetSdkVersion>
  </PropertyGroup>

  <ItemGroup>
    <!-- 资源:图标与启动图 -->
    <MauiIcon Include="Resources\AppIcon\appicon.svg" />
    <MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#512BD4" />
    <!-- 多语言资源 -->
    <EmbeddedResource Include="Resources\Strings\AppResources.resx" />
    <EmbeddedResource Include="Resources\Strings\AppResources.zh.resx" />
  </ItemGroup>

  <!-- 构建时按环境拷贝配置文件 -->
  <ItemGroup Condition="'$(Configuration)'=='Release'">
    <None Include="Resources\Config\appsettings.production.json" CopyToOutputDirectory="Always" Link="appsettings.json" />
  </ItemGroup>
  <ItemGroup Condition="'$(Configuration)'!='Release'">
    <None Include="Resources\Config\appsettings.staging.json" CopyToOutputDirectory="Always" Link="appsettings.json" />
  </ItemGroup>

  <!-- RESX 未翻译键检测(CI 中让构建失败) -->
  <UsingTask TaskName="CheckResxKeys" TaskFactory="CodeTaskFactory" AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll">
    <ParameterGroup>
      <BaseFile ParameterType="System.String" Required="true"/>
      <LocalizedFile ParameterType="System.String" Required="true"/>
    </ParameterGroup>
    <Task>
      <Reference Include="System.Xml.Linq" />
      <Code Type="Fragment" Language="cs"><![CDATA[
        var b = System.Xml.Linq.XDocument.Load(BaseFile);
        var l = System.Xml.Linq.XDocument.Load(LocalizedFile);
        var bkeys = new HashSet<string>(b.Descendants("data").Select(x => (string)x.Attribute("name")));
        var lkeys = new HashSet<string>(l.Descendants("data").Select(x => (string)x.Attribute("name")));
        var missing = bkeys.Except(lkeys).ToList();
        if (missing.Any())
        {
          Log.LogError("Missing translations in {0}: {1}", LocalizedFile, string.Join(", ", missing));
          return false;
        }
        return true;
      ]]></Code>
    </Task>
  </UsingTask>

  <Target Name="VerifyResx" AfterTargets="Build">
    <CheckResxKeys BaseFile="Resources\Strings\AppResources.resx"
                   LocalizedFile="Resources\Strings\AppResources.zh.resx" />
  </Target>
</Project>

注意事项

  • iOS 的 CFBundleVersion(ApplicationVersion)必须是纯数字;Android 的 versionCode 也是纯数字,使用同一 BuildNumber 即可。
  • Windows PackageVersion 需要四段(主.次.修订.构建),MAUI 会自动从 ApplicationDisplayVersion/ApplicationVersion 映射;如需固定四段,可在 Windows 平台条件下再补齐。
  • 多环境配置可通过构建配置(Debug/Release)或 CI 变量(如 -p:Environment=Production)切换。

2. 配置图标与启动图(多平台统一)

关键代码(资源结构)

Resources/
  AppIcon/appicon.svg
  Splash/splash.svg
  • 建议使用 SVG 矢量,MAUI 会自动生成各尺寸位图。确保图标无透明度问题、Splash 背景色与前景对比清晰。

注意事项

  • Android 自适应图标建议留出安全内边距(图形限定至 72x72 的 108x108 画布中心区域概念)。
  • iOS App Store 要求上传 1024x1024 的无透明背景图标(商店素材,不是包内图标)。
  • Windows 支持浅色/深色主题下的图标对比度,避免纯白/纯黑。

3. 权限、隐私说明与推送能力(开启权限,不绑定供应商)

关键代码(Platforms/Android/AndroidManifest.xml)

<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.todo">
  <uses-permission android:name="android.permission.INTERNET" />
  <!-- Android 13+ 通知权限 -->
  <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
  <application android:name="android.app.Application"
               android:allowBackup="true">
    <!-- 若使用 FCM,需声明 MessagingService;此处仅示例能力声明 -->
    <!--
    <service android:name=".MyFirebaseMessagingService" android:exported="false">
      <intent-filter>
        <action android:name="com.google.firebase.MESSAGING_EVENT" />
      </intent-filter>
    </service>
    -->
  </application>
</manifest>

关键代码(Platforms/iOS/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>NSUserNotificationUsageDescription</key>
  <string>我们将使用通知提醒待办事项。</string>
  <key>UIBackgroundModes</key>
  <array>
    <string>remote-notification</string>
  </array>
  <!-- 如需 HTTP 明文,设置 ATS(不推荐生产启用) -->
  <!--
  <key>NSAppTransportSecurity</key>
  <dict><key>NSAllowsArbitraryLoads</key><true/></dict>
  -->
</dict></plist>

关键代码(iOS 与 MacCatalyst Entitlements,启用 APNs 能力)

<!-- Platforms/iOS/Entitlements.plist 和 Platforms/MacCatalyst/Entitlements.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>aps-environment</key><string>development</string>
</dict></plist>

关键代码(Platforms/Windows/Package.appxmanifest:网络能力)

<Capabilities>
  <Capability Name="internetClient" />
</Capabilities>

关键代码(请求通知权限:跨平台 Helper)

// Services/NotificationPermissionService.cs
public interface INotificationPermissionService
{
    Task<bool> RequestAsync();
}

#if ANDROID
using Android;
using Android.Content.PM;
using Android.OS;
using AndroidX.Core.App;
using AndroidX.Core.Content;

public class NotificationPermissionService : INotificationPermissionService
{
    public Task<bool> RequestAsync()
    {
        if (Build.VERSION.SdkInt < BuildVersionCodes.Tiramisu)
            return Task.FromResult(true);

        var activity = Platform.CurrentActivity;
        if (ContextCompat.CheckSelfPermission(activity, Manifest.Permission.PostNotifications) == (int)Permission.Granted)
            return Task.FromResult(true);

        ActivityCompat.RequestPermissions(activity, new[] { Manifest.Permission.PostNotifications }, 1001);
        // 在 MainActivity 的 OnRequestPermissionsResult 中处理回调,这里简化直接返回 true
        return Task.FromResult(true);
    }
}
#elif IOS || MACCATALYST
using UserNotifications;
public class NotificationPermissionService : INotificationPermissionService
{
    public async Task<bool> RequestAsync()
    {
        var (granted, _) = await UNUserNotificationCenter.Current.RequestAuthorizationAsync(
            UNAuthorizationOptions.Alert | UNAuthorizationOptions.Badge | UNAuthorizationOptions.Sound);
        if (granted)
            UIKit.UIApplication.SharedApplication.RegisterForRemoteNotifications();
        return granted;
    }
}
#else
public class NotificationPermissionService : INotificationPermissionService
{
    public Task<bool> RequestAsync() => Task.FromResult(true);
}
#endif

注意事项

  • iOS/macOS 的 APNs 需要在 Apple Developer 开启推送能力、生成正确的描述文件(development/production 对应 aps-environment)。
  • Android 13+ 需要运行时请求 POST_NOTIFICATIONS。较旧系统仅需在 Manifest 声明权限。
  • 推送通道(FCM/APNs/Azure Notification Hubs)集成留在服务端/后续迭代;此处确保“权限+能力”正确。

4. 数据存储与多语言(Resx)

关键代码(SQLite 存储)

// Models/TodoItem.cs
using SQLite;
public class TodoItem
{
    [PrimaryKey, AutoIncrement]
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public bool IsDone { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

// Services/TodoRepository.cs
using SQLite;
public class TodoRepository
{
    private readonly SQLiteAsyncConnection _db;
    public TodoRepository(string dbPath)
    {
        _db = new SQLiteAsyncConnection(dbPath);
        _db.CreateTableAsync<TodoItem>().Wait();
    }
    public Task<List<TodoItem>> GetAllAsync() => _db.Table<TodoItem>().OrderByDescending(x => x.Id).ToListAsync();
    public Task<int> AddAsync(TodoItem item) => _db.InsertAsync(item);
    public Task<int> UpdateAsync(TodoItem item) => _db.UpdateAsync(item);
    public Task<int> DeleteAsync(TodoItem item) => _db.DeleteAsync(item);
}

关键代码(多语言 Resx 与使用)

// Resources/Strings/AppResources.resx (默认英语)
// Resources/Strings/AppResources.zh.resx (中文)
// 在 XAML 中通过 x:Static 绑定静态资源(简洁可靠)
<!-- MainPage.xaml -->
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:res="clr-namespace:MyTodoApp.Resources.Strings"
             x:Class="MyTodoApp.MainPage"
             Title="{x:Static res:AppResources.TodosTitle}">
  <VerticalStackLayout Padding="16" Spacing="8">
    <Entry x:Name="Input" Placeholder="{x:Static res:AppResources.NewTodoPlaceholder}" />
    <Button Text="{x:Static res:AppResources.Add}" Clicked="OnAddClicked"/>
    <CollectionView x:Name="List">
      <CollectionView.ItemTemplate>
        <DataTemplate>
          <Grid ColumnDefinitions="*,Auto">
            <Label Text="{Binding Title}" />
            <CheckBox Grid.Column="1" IsChecked="{Binding IsDone}" />
          </Grid>
        </DataTemplate>
      </CollectionView.ItemTemplate>
    </CollectionView>
  </VerticalStackLayout>
</ContentPage>
// App.xaml.cs(设置 UI 文化)
using System.Globalization;
public partial class App : Application
{
    public App()
    {
        InitializeComponent();
        // 从首选项读取用户语言(缺省采用系统)
        var lang = Preferences.Get("lang", CultureInfo.CurrentUICulture.TwoLetterISOLanguageName);
        CultureInfo.CurrentUICulture = new CultureInfo(lang);
        MainPage = new NavigationPage(new MainPage());
    }
}

注意事项

  • x:Static 方案简单稳健;如需“运行时切换语言”,可引入资源管理器包装 + INotifyPropertyChanged,或在切换后重建 Shell/NavigationPage。
  • CI 中用 VerifyResx 目标保证所有键已翻译。

5. 平台打包、签名与分发流程

  • Android

    • 生成签名密钥
      keytool -genkeypair -v -keystore todo-release.keystore -storetype JKS -keyalg RSA -keysize 2048 -validity 3650 -alias todo
      
    • csproj 配置(敏感信息用 CI Secret 注入)
      <PropertyGroup Condition="'$(TargetFramework)'=='net8.0-android'">
        <AndroidKeyStore>true</AndroidKeyStore>
        <AndroidSigningKeyStore>$(MSBuildProjectDirectory)\keystore\todo-release.keystore</AndroidSigningKeyStore>
        <AndroidSigningKeyAlias>todo</AndroidSigningKeyAlias>
        <AndroidSigningKeyPass>$(KeystorePassword)</AndroidSigningKeyPass>
        <AndroidSigningStorePass>$(KeystorePassword)</AndroidSigningStorePass>
      </PropertyGroup>
      
    • 发布命令(AAB 用于 Play 商店)
      dotnet publish -f net8.0-android -c Release -p:AndroidPackageFormat=aab
      
    • 输出:bin/Release/net8.0-android/publish/*.aab
  • iOS

    • Apple Developer 创建 App ID,开启 Push Notifications;创建分发证书与描述文件(App Store)。
    • csproj 指定签名(在 macOS 机器上构建)
      <PropertyGroup Condition="'$(TargetFramework)'=='net8.0-ios'">
        <CodesignKey>Apple Distribution: Example Company (TEAMID)</CodesignKey>
        <CodesignProvision>MyTodoApp_Provision_Profile</CodesignProvision>
        <CodesignEntitlements>Platforms/iOS/Entitlements.plist</CodesignEntitlements>
      </PropertyGroup>
      
    • 归档与导出
      dotnet publish -f net8.0-ios -c Release -p:ArchiveOnBuild=true -p:RuntimeIdentifier=ios-arm64
      
    • 上传:Xcode Organizer 或 Apple Transporter(.ipa)。
  • Windows(MSIX)

    • 生成/导入 PFX 证书(测试可自签,商店分发使用商店签名)
    • csproj
      <PropertyGroup Condition="'$(TargetFramework.Contains(windows))'">
        <WindowsPackageType>MSIX</WindowsPackageType>
        <PackageCertificateKeyFile>certs\mytodoapp.pfx</PackageCertificateKeyFile>
        <PackageCertificatePassword>$(WinCertPassword)</PackageCertificatePassword>
      </PropertyGroup>
      
    • 发布
      dotnet publish -f net8.0-windows10.0.19041.0 -c Release -p:WindowsPackageType=MSIX
      
    • 输出:msixbundle/appinstaller 等,用于 Microsoft Store 提交或侧载测试。
  • macOS(Mac Catalyst,签名与公证)

    • 开启 Hardened Runtime,使用 Developer ID Application 证书签名(商店分发用 App Store Connect)
      <PropertyGroup Condition="'$(TargetFramework)'=='net8.0-maccatalyst'">
        <CodesignKey>Developer ID Application: Example Company (TEAMID)</CodesignKey>
        <CodesignEntitlements>Platforms/MacCatalyst/Entitlements.plist</CodesignEntitlements>
        <UseHardenedRuntime>true</UseHardenedRuntime>
      </PropertyGroup>
      
    • 构建
      dotnet publish -f net8.0-maccatalyst -c Release -p:RuntimeIdentifier=maccatalyst-arm64
      
    • 公证(非 Mac App Store 分发时需要)
      # 先将 .app 压缩
      ditto -c -k --sequesterRsrc --keepParent MyTodoApp.app MyTodoApp.zip
      xcrun notarytool submit MyTodoApp.zip --apple-id "appleid@example.com" --team-id TEAMID --password "app-specific-password" --wait
      xcrun stapler staple MyTodoApp.app
      

注意事项

  • iOS/macOS 构建与签名需在 macOS 上执行(CI 用 macOS 运行器)。
  • Android keystore 一旦丢失将无法更新应用,妥善备份。
  • Windows Store 上架通常由商店签名,侧载测试才使用自签证书。

6. CI/CD 流水线(多平台矩阵与关键校验)

关键要点与命令片段

  • 版本注入:在 CI 设置环境变量 BuildNumber,并传给 dotnet 命令(-p:BuildNumber=$(Build.BuildId))。
  • RESX 校验:默认 AfterTargets=Build 自动触发;CI 失败即阻止提交。
  • Android(Linux/Windows 运行器)
    dotnet restore
    dotnet publish MyTodoApp.csproj -f net8.0-android -c Release -p:AndroidPackageFormat=aab -p:BuildNumber=${{ github.run_number }}
    
  • iOS/Mac(macOS 运行器)
    dotnet restore
    dotnet publish MyTodoApp.csproj -f net8.0-ios -c Release -p:ArchiveOnBuild=true -p:BuildNumber=${{ github.run_number }}
    # 上传用 Xcode/Transporter
    dotnet publish MyTodoApp.csproj -f net8.0-maccatalyst -c Release -p:BuildNumber=${{ github.run_number }}
    # 可选:notarytool 公证(非商店分发)
    
  • Windows(Windows 运行器)
    dotnet restore
    dotnet publish MyTodoApp.csproj -f net8.0-windows10.0.19041.0 -c Release -p:WindowsPackageType=MSIX -p:BuildNumber=$(Build.BuildId)
    

注意事项

  • 秘钥与证书通过 CI Secret 管理(KeystorePassword、WinCertPassword、Apple App-Specific Password)。
  • 不同平台构建可用矩阵 Job;iOS/Mac 必须跑在 macOS。

7. 商店素材、隐私合规、自检清单与回滚

  • 素材规格(常用)
    • iOS:App Icon 1024×1024(无透明)、6.5"/5.5" 截图、隐私实践表单(App Store Connect)。
    • Android:Icon 512×512、Feature Graphic 1024×500、截图、数据安全表单(Play Console)。
    • Windows:应用列表图、截图、描述与隐私政策链接。
    • macOS:截图、图标、描述(App Store Connect)。
  • 隐私与权限
    • 在 iOS Info.plist 中添加用途说明(通知、相册/相机若使用)。
    • 填写 Apple/Google 的隐私与数据收集表,确保与应用实际行为一致。
    • 若使用追踪/广告,请遵循 ATT(iOS)和 Play 相关政策。
  • 预上架自检清单
    • 版本/构建号一致性:iOS CFBundleVersion、Android versionCode、Windows PackageVersion 均已递增。
    • 核心路径测试:首次启动、数据读写(SQLite/文件/首选项)、语言切换(至少中/英)、通知权限请求与本地通知呈现。
    • 网络请求均为 HTTPS,未启用不必要的权限与后台模式。
    • 应用图标/启动图/商店素材完整、无侵权内容。
    • 崩溃与异常日志可获取(AppCenter/自建后端日志)。
  • 回滚策略
    • Android 使用分阶段发布(Staged Rollout),异常即暂停并回滚到上一版。
    • iOS 使用分阶段发布(逐步推出)或立即将销售区域下线恢复旧版;保持上一版本可用。
    • Windows/MSIX:保留上一个包以便回退;企业分发可控制通道。
    • 保留 keystore/证书与旧构建产物,确保快速回退。

完整示例(可运行的简化项目代码)

说明:以下示例聚焦“多语言 + 本地存储 + 通知权限请求”的最小可运行骨架,省略二次封装与高级错误处理。

// MauiProgram.cs
using Microsoft.Extensions.DependencyInjection;
using SQLite;

namespace MyTodoApp;
public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder()
            .UseMauiApp<App>();

        // SQLite 路径
        var dbPath = Path.Combine(FileSystem.AppDataDirectory, "todo.db3");
        builder.Services.AddSingleton(new TodoRepository(dbPath));
        builder.Services.AddSingleton<INotificationPermissionService, NotificationPermissionService>();

        return builder.Build();
    }
}
// App.xaml.cs
using System.Globalization;

namespace MyTodoApp;
public partial class App : Application
{
    public App()
    {
        InitializeComponent();
        var lang = Preferences.Get("lang", CultureInfo.CurrentUICulture.TwoLetterISOLanguageName);
        CultureInfo.CurrentUICulture = new CultureInfo(lang);
        MainPage = new NavigationPage(new MainPage());
    }
}
<!-- App.xaml -->
<?xml version = "1.0" encoding = "UTF-8" ?>
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="MyTodoApp.App">
</Application>
<!-- MainPage.xaml -->
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:res="clr-namespace:MyTodoApp.Resources.Strings"
             x:Class="MyTodoApp.MainPage"
             Title="{x:Static res:AppResources.TodosTitle}">
  <VerticalStackLayout Padding="16" Spacing="8">
    <Button Text="{x:Static res:AppResources.RequestNotification}" Clicked="OnRequestNotify" />
    <Entry x:Name="Input" Placeholder="{x:Static res:AppResources.NewTodoPlaceholder}" />
    <Button Text="{x:Static res:AppResources.Add}" Clicked="OnAddClicked" />
    <CollectionView x:Name="List">
      <CollectionView.ItemTemplate>
        <DataTemplate>
          <Grid ColumnDefinitions="*,Auto" Padding="4">
            <Label Text="{Binding Title}" />
            <CheckBox Grid.Column="1" IsChecked="{Binding IsDone}" />
          </Grid>
        </DataTemplate>
      </CollectionView.ItemTemplate>
    </CollectionView>
  </VerticalStackLayout>
</ContentPage>
// MainPage.xaml.cs
namespace MyTodoApp;
public partial class MainPage : ContentPage
{
    private readonly TodoRepository _repo;
    private readonly INotificationPermissionService _notify;
    public MainPage(TodoRepository repo, INotificationPermissionService notify)
    {
        InitializeComponent();
        _repo = repo;
        _notify = notify;
    }

    protected override async void OnAppearing()
    {
        base.OnAppearing();
        List.ItemsSource = await _repo.GetAllAsync();
    }

    private async void OnAddClicked(object sender, EventArgs e)
    {
        if (string.IsNullOrWhiteSpace(Input.Text)) return;
        await _repo.AddAsync(new TodoItem { Title = Input.Text.Trim() });
        Input.Text = "";
        List.ItemsSource = await _repo.GetAllAsync();
    }

    private async void OnRequestNotify(object sender, EventArgs e)
    {
        var ok = await _notify.RequestAsync();
        await DisplayAlert("Notifications", ok ? "Granted" : "Denied", "OK");
    }
}
// Models/TodoItem.cs
using SQLite;
namespace MyTodoApp;
public class TodoItem
{
    [PrimaryKey, AutoIncrement]
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public bool IsDone { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
// Services/TodoRepository.cs
using SQLite;
namespace MyTodoApp;
public class TodoRepository
{
    private readonly SQLiteAsyncConnection _db;
    public TodoRepository(string dbPath)
    {
        _db = new SQLiteAsyncConnection(dbPath);
        _db.CreateTableAsync<TodoItem>().Wait();
    }
    public Task<List<TodoItem>> GetAllAsync() => _db.Table<TodoItem>().OrderByDescending(x => x.Id).ToListAsync();
    public Task<int> AddAsync(TodoItem item) => _db.InsertAsync(item);
}
// Services/NotificationPermissionService.cs(跨平台实现,见上“分步指南-步骤3”)
<!-- Platforms/Android/AndroidManifest.xml(见上“分步指南-步骤3”) -->
<!-- Platforms/iOS/Info.plist 与 iOS/MacCatalyst Entitlements(见上“分步指南-步骤3”) -->
<!-- MyTodoApp.csproj(见“分步指南-步骤1”完整片段) -->
<!-- Resources/Strings/AppResources.resx 示例键
  TodosTitle = "Todos"
  NewTodoPlaceholder = "New todo..."
  Add = "Add"
  RequestNotification = "Request notification"
-->
<!-- Resources/Strings/AppResources.zh.resx
  TodosTitle = "待办列表"
  NewTodoPlaceholder = "输入新的待办..."
  Add = "添加"
  RequestNotification = "请求通知权限"
-->

常见问题

  1. 构建/版本号被商店拒绝
  • 症状:App Store Connect 报错 CFBundleVersion 非数字或未递增;Google Play 报 versionCode 未递增。
  • 解决:确保 ApplicationVersion 为纯数字且随 CI 递增;Android 使用同一数字作为 versionCode;必要时对每个平台单独覆盖但保持严格递增。
  1. 推送无法收到或审核被拒
  • 症状:iOS 无设备令牌/推送失败;审核提示缺少用途说明或能力。
  • 解决:在 Apple Developer 启用 Push Notifications,使用匹配的描述文件;Info.plist 添加 NSUserNotificationUsageDescription;Entitlements 设置 aps-environment 正确(development/production);Android 13+ 记得请求 POST_NOTIFICATIONS。
  1. Windows/MSIX 安装失败或商店校验不通过
  • 症状:侧载安装提示签名无效或证书不受信任。
  • 解决:测试时使用受信任证书(导入受信任根),或通过商店签名分发;确保 WindowsPackageIdentityName 与商店条目一致,不要随意更改。
  1. 多语言显示为英文或键缺失
  • 症状:中文环境仍显示英文;CI 未阻止未翻译键。
  • 解决:确保资源命名 AppResources.zh.resx;运行设备的 UI 语言为中文或在启动时设置 CultureInfo;启用 VerifyResx 目标保证键一致。
  1. Android 网络失败(明文 HTTP)
  • 症状:Android 9+ HTTP 请求失败。
  • 解决:改用 HTTPS;或在网络调试时临时允许明文(networkSecurityConfig/ATS),但生产应禁用。

平台差异与调试方法(简明)

  • iOS/macOS:推送与签名依赖 Apple 生态;调试使用 TestFlight 内测;日志用 Xcode/Console.app。
  • Android:内部测试轨道(Internal testing)快速灰度;adb logcat 调试通知与权限。
  • Windows:侧载测试使用 Add-AppxPackage;事件查看器检查 UWP/MSIX 日志。
  • macOS(非商店分发):记得公证与 staple,Gatekeeper 才能顺利通过。

以上流程与示例可直接作为新手上架与打包的“最小可用模板”。在此基础上逐步引入通知后台处理、动态语言切换、更多平台能力,以迭代完善应用。

示例详情

解决的问题

帮助.NET MAUI初学者快速上手跨平台应用开发,通过结构化、分步骤的实战指导,降低学习门槛,提升开发效率与代码质量,适用于自学、教学或项目原型搭建场景。

适用用户

刚入门的.NET MAUI自学开发者

可快速理解跨平台开发核心概念,通过结构化步骤完成第一个可运行的移动应用,避免在文档中迷失方向。

高校计算机专业学生

在课程项目或毕业设计中高效构建原型,获得符合行业规范的代码示例和平台适配建议,提升作品质量。

传统.NET桌面开发者转型者

平滑过渡到移动开发领域,借助熟悉的技术栈(C#/XAML)快速掌握MAUI特有的UI布局与设备集成方法。

特征总结

轻松生成分步式.NET MAUI开发指南,将复杂任务拆解为新手可操作的小步骤,降低学习门槛。
自动结合真实业务场景(如天气应用界面或传感器集成)提供针对性指导,提升实践能力。
一键调用结构化输出模板,包含任务概述、分步指南、完整示例和常见问题,开箱即用。
智能适配多平台特性,自动提示iOS安全区域、Android权限等关键差异点,避免兼容性陷阱。
根据用户输入的开发任务类型和功能模块,动态生成可复用的C#或XAML代码片段,带详细注释。
自动识别新手高频错误(如数据绑定失效或线程问题),提前预警并提供解决方案。
支持从界面搭建到部署上线的全链路指导,覆盖项目创建、功能集成、性能优化三大阶段。

如何使用购买的提示词模板

1. 直接在外部 Chat 应用中使用

将模板生成的提示词复制粘贴到您常用的 Chat 应用(如 ChatGPT、Claude 等),即可直接对话使用,无需额外开发。适合个人快速体验和轻量使用场景。

2. 发布为 API 接口调用

把提示词模板转化为 API,您的程序可任意修改模板参数,通过接口直接调用,轻松实现自动化与批量处理。适合开发者集成与业务系统嵌入。

3. 在 MCP Client 中配置使用

在 MCP client 中配置对应的 server 地址,让您的 AI 应用自动调用提示词模板。适合高级用户和团队协作,让提示词在不同 AI 工具间无缝衔接。

MAUI开发任务分步指导

371
33
Dec 6, 2025
本提示词为.NET MAUI初学者提供结构化、可执行的开发任务指导。通过将用户输入的开发任务与功能模块拆解为清晰步骤,并辅以代码示例与平台注意事项,帮助用户高效完成UI设计、功能实现或部署优化等具体目标。适用于自学、教学或项目原型快速开发场景,确保指导内容聚焦、安全且符合最佳实践。
成为会员,解锁全站资源
复制与查看不限次 · 持续更新权益
提示词宝典 · 终身会员

一次支付永久解锁,全站资源与持续更新;商业项目无限次使用

420 +
品类
8200 +
模板数量
17000 +
会员数量