PAYATHON 2026

Windows Blazor Maui 支付页面重定向后如何恢复原实例

支付小周

结论

Windows 收到 testapp://... 协议回调时,可能会启动一个新进程。要让回调返回原来的 MAUI Blazor 实例,可以使用 Windows App SDK 的 AppInstance 实现单实例重定向:

  1. 为主实例注册一个固定的实例键。
  2. 新进程收到协议激活后,调用 RedirectActivationToAsync,将激活参数转发给主实例。
  3. 主实例监听 AppInstance.Activated。
  4. 将回调 URI 交给 MAUI 层的单例服务。
  5. 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

更可靠的处理流程是:

  1. 发起支付时生成不可预测的 state 或订单关联值。
  2. 回调只携带订单编号、支付会话编号或一次性凭据。
  3. 应用收到回调后,调用后端 API 查询支付状态。
  4. 后端向支付平台验证结果。
  5. API 确认成功后,页面再显示最终的支付成功状态。

实现时还要处理这些情况:

  • 用户关闭应用后才完成支付,协议会冷启动应用。
  • 协议可能被连续调用多次,需要避免重复处理同一笔交易。
  • 如果 Blazor 尚未初始化,必须暂存回调。
  • 应用窗口处于最小化状态时,收到回调后应激活窗口。
  • RedirectActivationToAsync 只会转交激活参数,不会完成 Blazor 页面导航。
  • 调试协议激活时,Visual Studio 的启动和调试生命周期可能影响进程退出行为,最好同时测试安装或打包后的应用。

备注:内容仅供参考。