第八章:Uno 导航系统:跨平台页面跳转

第八章:Uno 导航系统:跨平台页面跳转

本章导读:想象你在逛一家大型博物馆。每个展厅是一件艺术品,而连接它们的走廊和指示牌就是导航系统。没有清晰的导航,你会迷失在迷宫般的建筑中;有了好的导航,你能轻松地在不同展区之间穿梭,随时知道自己在哪、能去哪、怎么回。在应用开发中,导航系统同样至关重要——它定义了用户如何在你的应用中探索,如何从一个任务流向另一个任务。本章将揭示 Uno Platform 导航系统的奥秘。


🖼️ 8.1 导航的哲学:从 Web 到原生

在深入技术细节之前,让我们先理解不同平台的导航范式,这将帮助你更好地理解 Uno 的设计选择。

🌐 8.1.1 三种导航范式

Web 导航基于超链接的概念。每个页面有一个 URL,点击链接就是加载一个新的 URL。浏览器维护历史记录,用户可以通过"前进"和"后退"按钮导航。这种模式简单直观,但 URL 的存在也带来了一些限制(如敏感信息不应出现在 URL 中)。

iOS 导航使用导航控制器(Navigation Controller)的概念。页面被推入(push)和弹出(pop)一个栈,类似于 iOS 的 UINavigationController。屏幕顶部有导航栏,显示当前标题和返回按钮。

Android 导航历史上使用 Activity 栈,现代 Android 开发则推荐使用 Navigation Component,它基于 Fragment 和 NavHost 的概念,提供了类型安全的导航和深度链接支持。

🎯 8.1.2 Uno 的选择:Frame 模型

Uno Platform 统一了这些不同的范式,采用了一种类似 iOS 的Frame 模型

Frame 是一个容器控件,它显示一个 Page 并维护一个导航历史栈。当你调用 Navigate() 时,新页面被推入栈顶;当你调用 GoBack() 时,当前页面被弹出,显示前一个页面。

┌─────────────────────────────────────┐
│              Frame                  │
│  ┌─────────────────────────────┐   │
│  │      Page (Current)         │   │
│  │                             │   │
│  │    [用户看到的界面]          │   │
│  │                             │   │
│  └─────────────────────────────┘   │
│                                     │
│  Back Stack:                        │
│  [MainPage] -> [ListPage]           │
└─────────────────────────────────────┘

第一性原理:为什么选择栈结构?

栈(后进先出)完美地匹配了用户的导航心理模型:我一步步深入,然后一步步返回。当你在设置页面中进入网络设置,再进入 WiFi 设置,按返回键应该先回到网络设置,再回到设置页面,最后回到主页。这种线性的、可预测的返回路径让用户始终有"家"可归。


🚀 8.2 基础导航操作

让我们从最基础的导航操作开始,逐步构建复杂的导航场景。

📄 8.2.1 创建 Frame 和初始页面

在典型的 Uno 应用中,Window 的内容被设置为一个 Frame,然后导航到初始页面:

// App.xaml.cs
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
    var window = new Window();
    
    // 创建 Frame 作为导航容器
    var frame = new Frame();
    
    // 将 Frame 设置为窗口内容
    window.Content = frame;
    
    // 导航到初始页面
    frame.Navigate(typeof(MainPage));
    
    window.Activate();
}

在页面内部,你可以通过 this.Frame 属性访问所属的 Frame。

➡️ 8.2.2 前进导航

Navigate 方法接受一个页面类型作为参数,创建该页面的新实例并显示:

// 基础导航
this.Frame.Navigate(typeof(DetailsPage));

// 带参数的导航
this.Frame.Navigate(typeof(DetailsPage), itemId);

// 带动画过渡信息的导航(高级用法)
this.Frame.Navigate(typeof(DetailsPage), null, new SlideNavigationTransitionInfo());

技术细节:Navigate 做了什么?

  1. 使用反射创建目标 Page 类型的新实例
  2. 将当前 Page 推入 Back Stack
  3. 将新 Page 设置为 Frame 的 Content
  4. 触发新 Page 的 OnNavigatedTo 生命周期方法

⬅️ 8.2.3 后退导航

在执行后退之前,必须检查 CanGoBack 属性,否则在栈顶调用 GoBack 会导致异常:

private void OnBackClick(object sender, RoutedEventArgs e)
{
    if (this.Frame.CanGoBack)
    {
        this.Frame.GoBack();
    }
}

也可以清除整个导航历史:

// 清除所有历史,用户无法返回
this.Frame.BackStack.Clear();

// 或者导航到主页并清除历史
this.Frame.Navigate(typeof(MainPage));
this.Frame.BackStack.Clear();

📱 8.2.4 处理系统返回键

Uno Platform 自动处理了不同平台的返回机制:

Android:物理返回键自动触发 GoBack()

iOS:屏幕边缘滑动手势触发返回。

WebAssembly:浏览器的前进/后退按钮与 Frame 导航同步。

在 Windows 上,你可以通过 SystemNavigationManager 自定义返回按钮的行为:

// 在页面构造函数中
var navManager = SystemNavigationManager.GetForCurrentView();
navManager.BackRequested += OnBackRequested;

private void OnBackRequested(object sender, BackRequestedEventArgs e)
{
    if (this.Frame.CanGoBack)
    {
        this.Frame.GoBack();
        e.Handled = true; // 标记为已处理
    }
}

📦 8.3 页面间数据传递

导航很少是简单的页面切换——通常需要将数据从源页面传递到目标页面。

🎁 8.3.1 使用导航参数

Navigate 方法的第二个参数是导航参数,可以是任何对象:

// 传递整数 ID
this.Frame.Navigate(typeof(UserProfilePage), userId);

// 传递复杂对象(不推荐,详见下文)
this.Frame.Navigate(typeof(EditPage), selectedItem);

// 传递字典
var parameters = new Dictionary<string, object>
{
    ["UserId"] = userId,
    ["Source"] = "notification"
};
this.Frame.Navigate(typeof(UserProfilePage), parameters);

在目标页面中,重写 OnNavigatedTo 方法接收参数:

public sealed partial class UserProfilePage : Page
{
    private int _userId;

    protected override void OnNavigatedTo(NavigationEventArgs e)
    {
        base.OnNavigatedTo(e);

        // 简单类型
        if (e.Parameter is int userId)
        {
            _userId = userId;
            LoadUserData(userId);
        }

        // 字典类型
        if (e.Parameter is Dictionary<string, object> parameters)
        {
            if (parameters.TryGetValue("UserId", out var id))
            {
                _userId = (int)id;
            }
        }

        // 导航模式:New(新导航)、Back(返回)、Forward(前进)
        if (e.NavigationMode == NavigationMode.New)
        {
            // 首次进入页面的初始化
        }
    }
}

⚠️ 8.3.2 参数传递的最佳实践

不要传递复杂对象:导航参数在页面挂起(Suspend)和恢复(Resume)时会被序列化。复杂对象可能无法正确序列化,或者占用大量内存。

推荐做法:传递 ID 或关键字,在目标页面中重新加载数据。

// 不推荐
this.Frame.Navigate(typeof(EditPage), userObject);

// 推荐
this.Frame.Navigate(typeof(EditPage), userObject.Id);

// 然后在 EditPage 中
protected override void OnNavigatedTo(NavigationEventArgs e)
{
    if (e.Parameter is int userId)
    {
        // 从数据库或服务重新加载
        _user = await _userService.GetUserAsync(userId);
    }
}

费曼技巧提问:为什么不能直接传递对象?

想象你传递了一个包含 1MB 图片数据的用户对象。当用户导航到下一页、再下一页时,每个页面的 Back Stack 都保留了这个对象的引用。如果用户浏览了 10 个页面,就有 10MB 的数据被锁定在内存中。更糟糕的是,当应用被系统挂起时,这些数据需要被序列化到磁盘,导致启动恢复变慢。传递 ID 并重新加载虽然多了一次数据库查询,但内存占用更小,应用更稳定。


🏗️ 8.4 现代导航:Uno.Extensions.Navigation

随着应用规模增长,直接使用 Frame.Navigate 会导致代码高度耦合——ViewModel 需要知道具体页面类型,这违反了 MVVM 的关注点分离原则。

Uno.Extensions.Navigation 提供了更现代、更解耦的导航方式。

🔧 8.4.1 配置导航扩展

首先,在应用启动时配置导航:

// App.xaml.cs 或 Program.cs
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
    var window = new Window();
    
    var host = Host.CreateBuilder()
        .ConfigureServices(services =>
        {
            // 注册导航
            services.AddNavigation();
            
            // 注册页面和 ViewModel
            services.AddTransient<MainPage, MainViewModel>();
            services.AddTransient<DetailsPage, DetailsViewModel>();
        })
        .Build();
    
    // 初始化导航
    var navigator = host.Services.GetService<INavigator>();
    
    window.Content = new Frame();
    window.Activate();
}

🎯 8.4.2 在 ViewModel 中导航

使用 INavigator 接口,ViewModel 完全不需要知道页面类型:

public partial class MainViewModel
{
    private readonly INavigator _navigator;
    private readonly IUserService _userService;

    public MainViewModel(INavigator navigator, IUserService userService)
    {
        _navigator = navigator;
        _userService = userService;
    }

    // 简单导航
    [RelayCommand]
    private async Task GoToSettings()
    {
        await _navigator.NavigateViewAsync<SettingsPage>(this);
    }

    // 带参数导航
    [RelayCommand]
    private async Task ViewUser(int userId)
    {
        await _navigator.NavigateViewAsync<UserProfilePage>(this, data: userId);
    }

    // 导航并等待结果返回
    [RelayCommand]
    private async Task SelectItem()
    {
        var result = await _navigator.NavigateViewForResultAsync<ItemPickerPage, Item>(this);
        if (result.IsSome)
        {
            SelectedItem = result.Some();
        }
    }
}

📋 8.4.3 声明式路由配置

你可以使用路由映射声明性地定义导航结构:

public class AppRoutes : RouteMap
{
    public AppRoutes()
    {
        new RouteMap
        {
            Routes = new[]
            {
                new RouteMap
                {
                    Path = "home",
                    View = typeof(HomePage)
                },
                new RouteMap
                {
                    Path = "users/{userId}",
                    View = typeof(UserProfilePage),
                    Init = (parameters) => new { UserId = parameters["userId"] }
                },
                new RouteMap
                {
                    Path = "settings",
                    View = typeof(SettingsPage)
                }
            }
        };
    }
}

然后使用路径导航:

await _navigator.NavigateAsync("users/123");

设计优势:这种声明式路由配置使得导航逻辑集中管理,便于维护和理解整个应用的导航结构。它还支持深度链接——外部 URL 可以直接映射到应用内页面。


📱 8.5 多区域导航

复杂应用通常有多个导航区域——比如侧边栏和主内容区各自独立的导航。Uno 支持这种场景。

🏠 8.5.1 理解导航区域

┌──────────────────────────────────────────────────┐
│                    Main Frame                     │
├────────────┬─────────────────────────────────────┤
│            │                                     │
│  Sidebar   │           Content Frame             │
│  Frame     │                                     │
│            │                                     │
│  - Home    │     [MainPage]                      │
│  - Search  │                                     │
│  - Profile │                                     │
│            │                                     │
└────────────┴─────────────────────────────────────┘

🔧 8.5.2 实现多 Frame 导航

<Page>
    <Grid ColumnDefinitions="200,*">
        <!-- 侧边栏区域 -->
        <Frame x:Name="SidebarFrame" Grid.Column="0">
            <Frame.Content>
                <local:SidebarPage />
            </Frame.Content>
        </Frame>
        
        <!-- 主内容区域 -->
        <Frame x:Name="ContentFrame" Grid.Column="1" />
    </Grid>
</Page>
public sealed partial class ShellPage : Page
{
    public Frame ContentFrame => ContentFrame;

    public ShellPage()
    {
        InitializeComponent();
        ContentFrame.Navigate(typeof(HomePage));
    }

    public void NavigateToPage(Type pageType)
    {
        ContentFrame.Navigate(pageType);
    }
}

🔗 8.6 深度链接

深度链接允许用户通过外部链接直接进入应用的特定页面。这在营销、推送通知、跨应用跳转等场景中非常有用。

📲 8.6.1 协议激活

Package.appxmanifest 中注册自定义协议:

<Package>
    <Applications>
        <Application>
            <Extensions>
                <uap:Extension Category="windows.protocol">
                    <uap:Protocol Name="myapp">
                        <uap:DisplayName>My App</uap:DisplayName>
                    </uap:Protocol>
                </uap:Extension>
            </Extensions>
        </Application>
    </Applications>
</Package>

然后处理协议激活:

// App.xaml.cs
protected override void OnActivated(ActivationActivatedEventArgs args)
{
    if (args.Kind == ActivationKind.Protocol)
    {
        var protocolArgs = (ProtocolActivatedEventArgs)args;
        var uri = protocolArgs.Uri;
        
        // 解析 URI 并导航到对应页面
        // 例如 myapp://user/123
        if (uri.Host == "user")
        {
            var userId = uri.PathAndQuery.Trim('/');
            var frame = new Frame();
            frame.Navigate(typeof(UserProfilePage), int.Parse(userId));
            Window.Current.Content = frame;
        }
    }
}

🌐 8.6.2 WebAssembly 路由

在 WebAssembly 中,URL 路由自动与 Frame 导航同步。用户可以直接通过 URL 访问特定页面,刷新页面也能保持当前位置。

// 配置 URL 路由
services.AddNavigation()
    .ConfigureRoutes(routes =>
    {
        routes.MapRoute("home", typeof(HomePage));
        routes.MapRoute("user/{id}", typeof(UserProfilePage));
        routes.MapRoute("settings", typeof(SettingsPage));
    });

📊 8.7 导航生命周期

理解导航生命周期对于正确管理资源和状态至关重要。

🔄 8.7.1 页面导航事件

每个 Page 都有以下导航相关的生命周期方法:

public sealed partial class DetailsPage : Page
{
    // 导航到此页面时调用
    protected override void OnNavigatedTo(NavigationEventArgs e)
    {
        base.OnNavigatedTo(e);
        
        // e.NavigationMode 告诉你是新导航还是返回
        // - NavigationMode.New: 首次进入
        // - NavigationMode.Back: 从下一个页面返回
        // - NavigationMode.Forward: 从上一个页面前进
        // - NavigationMode.Refresh: 刷新
        
        if (e.Parameter is int id)
        {
            LoadData(id);
        }
    }
    
    // 从此页面离开时调用
    protected override void OnNavigatingFrom(NavigatingCancelEventArgs e)
    {
        base.OnNavigatingFrom(e);
        
        // e.SourcePageType 是目标页面
        // e.NavigationMode 是导航方向
        
        // 可以取消导航
        if (HasUnsavedChanges && !e.IsCancelable)
        {
            e.Cancel = true;
            ShowSavePrompt();
        }
    }
    
    // 已经离开此页面时调用
    protected override void OnNavigatedFrom(NavigationEventArgs e)
    {
        base.OnNavigatedFrom(e);
        
        // 清理资源、取消订阅等
        CleanupResources();
    }
}

💾 8.7.2 状态保存与恢复

当应用被系统挂起(如用户切换到其他应用)时,需要保存导航状态:

// 在 App.xaml.cs 中
private void OnSuspending(object sender, SuspendingEventArgs e)
{
    var deferral = e.SuspendingOperation.GetDeferral();
    
    // 保存 Frame 的导航状态
    var frameState = new Dictionary<string, object>();
    frameState["BackStack"] = frame.BackStack.ToList();
    frameState["CurrentPage"] = frame.Content.GetType().FullName;
    
    // 保存到应用设置
    ApplicationData.Current.LocalSettings.Values["NavigationState"] = 
        JsonConvert.SerializeObject(frameState);
    
    deferral.Complete();
}

private void OnResuming(object sender, object e)
{
    // 恢复导航状态(如果需要)
    // 通常 Frame 会自动恢复
}

📝 本章小结

导航系统是应用的骨架,它决定了用户如何在功能模块之间穿梭。本章我们从第一性原理出发,理解了 Uno Platform 采用的 Frame 模型——基于栈的导航结构简单、可预测,完美匹配用户的心理模型。

我们学习了基础导航操作:前进、后退、参数传递。我们也探讨了页面间数据传递的最佳实践——传递 ID 而非复杂对象,在目标页面重新加载数据。

对于更复杂的场景,Uno.Extensions.Navigation 提供了现代的、MVVM 友好的导航方式。通过 INavigator 接口,ViewModel 完全不需要知道具体的页面类型,实现了真正的关注点分离。

深度链接让应用能够响应外部请求,直接跳转到特定页面。这在推送通知、跨应用跳转等场景中非常重要。

在下一章中,我们将从导航转向数据存储——学习 Uno 本地存储与文件访问,掌握如何在跨平台环境中持久化应用数据。


动手实验

  1. 创建一个简单的两页应用:主页有一个列表,点击列表项导航到详情页,详情页显示选中项的信息,有返回按钮回到主页。
  2. 实现一个导航参数传递场景:在列表页选择一个用户,导航到编辑页,编辑完成后返回列表页并刷新数据。
  3. 尝试使用 Uno.Extensions.Navigation 重写实验 1,体验声明式导航的优势。
  4. (进阶)实现一个三栏布局的 Shell:左侧导航栏、中间内容区、右侧详情面板。每个区域有独立的 Frame 和导航历史。
← 返回目录