热门角色不仅是灵感来源,更是你的效率助手。通过精挑细选的角色提示词,你可以快速生成高质量内容、提升创作灵感,并找到最契合你需求的解决方案。让创作更轻松,让价值更直接!
我们根据不同用户需求,持续更新角色库,让你总能找到合适的灵感入口。
目标:用 .NET 8 + .NET MAUI(Android/iOS)构建一个入门应用,采用 MVVM + CommunityToolkit.Mvvm,实现:
关键代码:
// 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();
}
}
注意事项:
关键代码:
<!-- 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>
注意事项:
关键代码:
// 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());
}
注意事项:
关键代码:
// 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");
}
注意事项:
关键代码:
// 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;
}
}
注意事项:
<!-- 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>()!;
}
}
UI/UX 设计最佳实践
按以上步骤与示例即可在 Android 8.0+ 和 iOS 13+ 上完成一个可运行的 MVVM 入门应用,具备导航、认证、本地化与主题切换能力。
目标:实现“巡检记录”模块(Android/iOS/Windows,.NET 8 + .NET MAUI),功能包括:
重点:平台差异与适配、完整可运行示例、测试与调试方法、常见错误预防。
关键代码
// 命令行安装 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();
}
}
注意事项
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>
关键代码
// 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";
}
}
注意事项
关键代码
// 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); }
}
}
注意事项
关键代码
// 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;
}
}
注意事项
关键代码
<!-- 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();
}
}
注意事项
最小可运行文件清单(命名空间示例为 InspectionApp):
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);
说明
通过以上步骤,你将获得一个跨平台、可离线运行、具有可靠上传与错误恢复机制的“巡检记录”模块。可在此基础上继续扩展:多语言(Resources + CultureInfo)、动态主题、更多上传并发控制(Channel/SemaphoreSlim)与后台传输(平台特定扩展)。
目标:将一个基于 .NET 8 的轻量待办(Todo)应用通过 CI/CD 打包并上架到 iOS、Android、macOS、Windows 各商店,统一版本策略,配置图标与启动图,完善隐私与权限说明,启用推送能力,完成各平台签名与分发(Android keystore/AAB、iOS 证书/描述文件/归档上传、Windows MSIX、macOS 签名与公证),并准备商店素材与合规项。应用包含多语言(Resx)、本地数据存储(SQLite/文件/首选项),区分测试与发布多环境配置。
关键代码(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>
注意事项
关键代码(资源结构)
Resources/
AppIcon/appicon.svg
Splash/splash.svg
注意事项
关键代码(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
注意事项
关键代码(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());
}
}
注意事项
Android
keytool -genkeypair -v -keystore todo-release.keystore -storetype JKS -keyalg RSA -keysize 2048 -validity 3650 -alias todo
<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>
dotnet publish -f net8.0-android -c Release -p:AndroidPackageFormat=aab
iOS
<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
Windows(MSIX)
<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
macOS(Mac Catalyst,签名与公证)
<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
# 先将 .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
注意事项
关键要点与命令片段
dotnet restore
dotnet publish MyTodoApp.csproj -f net8.0-android -c Release -p:AndroidPackageFormat=aab -p:BuildNumber=${{ github.run_number }}
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 公证(非商店分发)
dotnet restore
dotnet publish MyTodoApp.csproj -f net8.0-windows10.0.19041.0 -c Release -p:WindowsPackageType=MSIX -p:BuildNumber=$(Build.BuildId)
注意事项
说明:以下示例聚焦“多语言 + 本地存储 + 通知权限请求”的最小可运行骨架,省略二次封装与高级错误处理。
// 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 = "请求通知权限"
-->
平台差异与调试方法(简明)
以上流程与示例可直接作为新手上架与打包的“最小可用模板”。在此基础上逐步引入通知后台处理、动态语言切换、更多平台能力,以迭代完善应用。
帮助.NET MAUI初学者快速上手跨平台应用开发,通过结构化、分步骤的实战指导,降低学习门槛,提升开发效率与代码质量,适用于自学、教学或项目原型搭建场景。
可快速理解跨平台开发核心概念,通过结构化步骤完成第一个可运行的移动应用,避免在文档中迷失方向。
在课程项目或毕业设计中高效构建原型,获得符合行业规范的代码示例和平台适配建议,提升作品质量。
平滑过渡到移动开发领域,借助熟悉的技术栈(C#/XAML)快速掌握MAUI特有的UI布局与设备集成方法。
将模板生成的提示词复制粘贴到您常用的 Chat 应用(如 ChatGPT、Claude 等),即可直接对话使用,无需额外开发。适合个人快速体验和轻量使用场景。
把提示词模板转化为 API,您的程序可任意修改模板参数,通过接口直接调用,轻松实现自动化与批量处理。适合开发者集成与业务系统嵌入。
在 MCP client 中配置对应的 server 地址,让您的 AI 应用自动调用提示词模板。适合高级用户和团队协作,让提示词在不同 AI 工具间无缝衔接。
一次支付永久解锁,全站资源与持续更新;商业项目无限次使用