Windows Blazor Maui 支付页面重定向后如何恢复原实例
结论
Windows 收到 testapp://... 协议回调时,可能会启动一个新进程。要让回调返回原来的 MAUI Blazor 实例,可以使用 Windows App SDK 的 AppInstance 实现单实例重定向:
- 为主实例注册一个固定的实例键。
- 新进程收到协议激活后,调用
RedirectActivationToAsync,将激活参数转发给主实例。 - 主实例监听
AppInstance.Activated。 - 将回调 URI 交给 MAUI 层的单例服务。
- Blazor 组件订阅该服务,并调用
NavigationManager.NavigateTo完成导航。
不要直接在 Windows 的 OnLaunched 中操作 Razor 路由。这里收到的是原生 Windows 激活事件,此时 Blazor 渲染器和 NavigationManager 可能还没有准备好。
注册自定义协议
在 Platforms/Windows/Package.appxmanifest 的 <Application> 节点中注册协议:
<Applications>
<Application
Id="App"
Executable="$targetnametoken$.exe"
EntryPoint="$targetentrypoint$">
<Extensions>
<uap:Extension Category="windows.protocol">
<uap:Protocol Name="testapp" />
</uap:Extension>
</Extensions>
</Application>
</Applications>
同时确认 manifest 根节点已经声明 uap 命名空间,例如:
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
协议名应填写 testapp,不要包含 ://。
建立支付回调服务
该服务用于暂存协议 URI,并在 Blazor 初始化完成后通知组件。
public sealed class PaymentCallbackService
{
private readonly object _syncRoot = new();
private Uri? _pendingUri;
public event Func<Uri, Task>? CallbackReceived;
public async Task PublishAsync(Uri uri)
{
Func<Uri, Task>? callback;
lock (_syncRoot)
{
callback = CallbackReceived;
if (callback is null)
{
_pendingUri = uri;
return;
}
}
await callback.Invoke(uri);
}
public Uri? TakePendingUri()
{
lock (_syncRoot)
{
var uri = _pendingUri;
_pendingUri = null;
return uri;
}
}
}
在 MauiProgram.cs 中将其注册为单例:
builder.Services.AddSingleton<PaymentCallbackService>();
在 Windows 层处理实例重定向
修改 Platforms/Windows/App.xaml.cs。这里的 App 应继承 MauiWinUIApplication,不要与共享项目中继承 Microsoft.Maui.Controls.Application 的 App.xaml.cs 混淆。
using Microsoft.Maui;
using Microsoft.UI.Xaml;
using Microsoft.Windows.AppLifecycle;
using Windows.ApplicationModel.Activation;
namespace YourApp.WinUI;
public partial class App : MauiWinUIApplication
{
private const string MainInstanceKey = "YourApp.MainInstance";
private readonly AppInstance _currentInstance;
private bool _isMainInstance;
public App()
{
InitializeComponent();
_currentInstance =
AppInstance.FindOrRegisterForKey(MainInstanceKey);
_isMainInstance = _currentInstance.IsCurrent;
if (_isMainInstance)
{
_currentInstance.Activated += OnAppInstanceActivated;
}
}
protected override async void OnLaunched(LaunchActivatedEventArgs args)
{
var activationArgs =
AppInstance.GetCurrent().GetActivatedEventArgs();
if (!_isMainInstance)
{
await _currentInstance.RedirectActivationToAsync(
activationArgs);
Environment.Exit(0);
return;
}
base.OnLaunched(args);
await HandleActivationAsync(activationArgs);
}
private void OnAppInstanceActivated(
object? sender,
AppActivationArguments args)
{
_ = HandleActivationAsync(args);
}
private async Task HandleActivationAsync(
AppActivationArguments args)
{
if (args.Kind != ExtendedActivationKind.Protocol ||
args.Data is not ProtocolActivatedEventArgs protocolArgs)
{
return;
}
var uri = protocolArgs.Uri;
if (!string.Equals(
uri.Scheme,
"testapp",
StringComparison.OrdinalIgnoreCase))
{
return;
}
var callbackService =
IPlatformApplication.Current?.Services
.GetService<PaymentCallbackService>();
if (callbackService is null)
{
return;
}
await callbackService.PublishAsync(uri);
var window = Microsoft.Maui.Controls.Application
.Current?
.Windows
.FirstOrDefault()?
.Handler?
.PlatformView as Microsoft.UI.Xaml.Window;
window?.Activate();
}
protected override MauiApp CreateMauiApp()
{
return MauiProgram.CreateMauiApp();
}
}
这段代码按以下方式工作:
FindOrRegisterForKey用于查找或注册主实例。- 当前进程不是主实例时,调用
RedirectActivationToAsync转发激活参数。 - 新进程完成转发后退出。
- 原进程通过
Activated事件接收协议 URI。 - 应用第一次被协议冷启动时,当前进程就是主实例。代码会先正常创建 MAUI 窗口,再处理 URI。
不同版本的 .NET MAUI 和 Windows App SDK 可能有略微不同的启动顺序。如果冷启动时 IPlatformApplication.Current.Services 仍为空,应先将 URI 放入静态启动队列,等 CreateMauiApp() 完成后再投递,不能直接丢弃回调。
从 Blazor 导航到结果页面
可以在 MainLayout.razor、Routes.razor 或其他在应用生命周期内始终存在的组件中订阅回调。
@implements IDisposable
@inject PaymentCallbackService PaymentCallbacks
@inject NavigationManager Navigation
@code {
protected override async Task OnInitializedAsync()
{
PaymentCallbacks.CallbackReceived += OnPaymentCallbackAsync;
var pendingUri = PaymentCallbacks.TakePendingUri();
if (pendingUri is not null)
{
await OnPaymentCallbackAsync(pendingUri);
}
}
private Task OnPaymentCallbackAsync(Uri uri)
{
return InvokeAsync(() =>
{
var route = ResolveRoute(uri);
if (route is not null)
{
Navigation.NavigateTo(route);
}
});
}
private static string? ResolveRoute(Uri uri)
{
// testapp://payment-success
if (uri.Host.Equals(
"payment-success",
StringComparison.OrdinalIgnoreCase))
{
return "/payment/success";
}
// testapp://payment-failed
if (uri.Host.Equals(
"payment-failed",
StringComparison.OrdinalIgnoreCase))
{
return "/payment/failed";
}
// testapp://component1/success-page
if (uri.Host.Equals(
"component1",
StringComparison.OrdinalIgnoreCase) &&
uri.AbsolutePath.Equals(
"/success-page",
StringComparison.OrdinalIgnoreCase))
{
return "/payment/success";
}
return null;
}
public void Dispose()
{
PaymentCallbacks.CallbackReceived -=
OnPaymentCallbackAsync;
}
}
结果页面使用普通的 Blazor 路由即可:
@page "/payment/success"
<h1>支付成功</h1>
<p>支付结果正在确认。</p>
失败页面:
@page "/payment/failed"
<h1>支付未完成</h1>
<p>支付失败或已被取消。</p>
这里需要使用 InvokeAsync,因为 AppInstance.Activated 不一定发生在 Blazor 渲染线程上。
正确解析回调地址
对于以下两个 URI:
testapp://payment-success
testapp://component1/success-page
第一个 URI 的解析结果是:
Host = payment-success
AbsolutePath = ""
第二个 URI 的解析结果是:
Host = component1
AbsolutePath = /success-page
建议使用 Uri.Host 和 Uri.AbsolutePath 进行判断。Authority 可能包含端口等额外信息,不适合直接作为路由判断条件。
也可以统一采用更容易解析的格式:
testapp://payment/result?status=success&paymentId=123
不过,status=success 只能用来决定界面展示流程,不能作为最终的支付凭证。
安全与可靠性注意事项
本机其他程序也可以主动调用自定义协议,因此不能只根据以下地址认定付款成功:
testapp://payment-success
更可靠的处理流程是:
- 发起支付时生成不可预测的
state或订单关联值。 - 回调只携带订单编号、支付会话编号或一次性凭据。
- 应用收到回调后,调用后端 API 查询支付状态。
- 后端向支付平台验证结果。
- API 确认成功后,页面再显示最终的支付成功状态。
实现时还要处理这些情况:
- 用户关闭应用后才完成支付,协议会冷启动应用。
- 协议可能被连续调用多次,需要避免重复处理同一笔交易。
- 如果 Blazor 尚未初始化,必须暂存回调。
- 应用窗口处于最小化状态时,收到回调后应激活窗口。
RedirectActivationToAsync只会转交激活参数,不会完成 Blazor 页面导航。- 调试协议激活时,Visual Studio 的启动和调试生命周期可能影响进程退出行为,最好同时测试安装或打包后的应用。
备注:内容仅供参考。
