第八章: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 做了什么?
- 使用反射创建目标 Page 类型的新实例
- 将当前 Page 推入 Back Stack
- 将新 Page 设置为 Frame 的 Content
- 触发新 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 本地存储与文件访问,掌握如何在跨平台环境中持久化应用数据。
动手实验:
- 创建一个简单的两页应用:主页有一个列表,点击列表项导航到详情页,详情页显示选中项的信息,有返回按钮回到主页。
- 实现一个导航参数传递场景:在列表页选择一个用户,导航到编辑页,编辑完成后返回列表页并刷新数据。
- 尝试使用 Uno.Extensions.Navigation 重写实验 1,体验声明式导航的优势。
- (进阶)实现一个三栏布局的 Shell:左侧导航栏、中间内容区、右侧详情面板。每个区域有独立的 Frame 和导航历史。