3. SusScreen — 屏幕、生命周期与接入
屏幕(SusScreen)是由 SusRouter 管理的全屏视图。 它继承自 SusComponent,因此拥有完整的核心响应式工具集 (Prop<T>、Watch、Build()、Mounted()),此外还具备路由器生命周期,并可访问 路由参数。
1. 最小屏幕示例
using UnityEngine.UIElements;
using Sharq.Router;
public class HomeScreen : SusScreen
{
protected override void Build()
{
AddToClassList("home-screen"); // companion USS: .home-screen { flex-grow: 1; }
Add(new Label("Home"));
var btn = new Button { text = "Go to About" };
btn.RegisterCallback<ClickEvent>(_ => Router.Push("/about"));
Add(btn);
}
}屏幕根节点上的布局与其他组件一样使用 USS 类,而不是 style.flexGrow——参见 Design tokens §1.4。
Build() 用于构建树(来自 SusComponent)。此时 Router 和 Props尚未在 Build() 中设置——请在 OnBeforeEnter() / OnEntered() 中读取它们。
2. 生命周期——重写 On* 方法
⚠️ 重要:公开的
BeforeEnter/Entered/BeforeLeave/Leaving/Left/BeforeRouteUpdate由路由器自身调用——不要重写它们。请重写protected virtual的On*钩子(模板方法模式)。
public class BattleScreen : SusScreen
{
// 1. Enter validation + reading Props. false = enter cancelled.
protected override bool OnBeforeEnter(SusRoute fromRoute)
{
var matchId = GetParam("matchId");
return !string.IsNullOrEmpty(matchId);
}
// 2. Screen is already in the DOM — start animations, load data.
protected override void OnEntered() => StartBattle();
// 3. Props changed on the SAME screen instance (e.g. /users/1 → /users/2).
protected override bool OnBeforeRouteUpdate(SusRoute toRoute) => true;
// 4. Leave guard. false = navigation blocked (e.g. dirty form).
protected override bool OnBeforeLeave(SusRoute toRoute)
{
if (_isDirty)
{
Router.Modal(typeof(ConfirmLeaveDialog), new() { ["message"] = "Leave without saving?" });
return false;
}
return true;
}
// 5. Navigation is committed and the screen is still in the tree —
// undo anything it changed on its parents (shell captions, classes).
protected override void OnLeaving(SusRoute toRoute) => ClearShellCaption();
// 6. Screen is removed and was not cached — unsubscribe, release resources.
protected override void OnLeft() => CleanupBattle();
}完整流程
Push("/battle/42"):
1. Activator.CreateInstance / LazyFactory()
Created() ← SusComponent; Router/Props NOT set yet
Build() ← tree is built
2. screen.Router = router
3. screen.Props = params + query + DefaultProps + PropsFn(...)
4. OnBeforeEnter(from) ← false = cancel entire navigation
5. history updates, SusRouteView swaps screen + transition.PlayIn()
6. OnEntered() ← screen in DOM
7. (next frame) Mounted() ← deferred SusComponent hook
Leave:
8. OnBeforeLeave(to) ← guard, false = cancel
(then CanLeave, BeforeEach, CanEnter, per-route BeforeEnter)
9. OnLeaving(to) ← leave/enter guards passed; screen still in the tree
10. (BeforeResolve, target BeforeEnter — last cancellation points)
11. OnLeft() ← once, leaf → root, screen still in the tree
12. SusRouteView removes screen (or moves it into the KeepAlive cache)
13. Unmounted() ← SusComponent; NOT called on KeepAlive使用 KeepAlive 时屏幕不会被重新创建,并且离开时不会调用 OnLeft(): 实例会被分离到缓存中(而不是销毁);返回时会再次触发 OnBeforeEnter() / OnEntered(), 但不会调用新的 Build()。OnLeft() 会在缓存逐出该屏幕时(见下文)才到达。
OnLeft 清理合约
OnLeft() 是取消订阅事件和释放资源的地方。它遵守五条不变式:
- 仅一次。 导航从活动链中移除、且未放入 KeepAlive 缓存的每个屏幕,恰好获得一次
OnLeft()。 - 从叶到根。 与
OnLeaving的集合和顺序相同:嵌套子级先于父级执行。 - 在树中。 它在路由器分离任何东西之前执行,因此
panel与parent仍然有效 (KeepAlive 逐出之后除外,见第 5 条)。 - 在最后一个取消点之后。 它在守卫、
BeforeResolve以及目标链中每个屏幕的BeforeEnter之后执行。OnBeforeEnter返回false会在嵌套目标的任意层级(根、中间或叶子)以及从 KeepAlive 缓存复用的屏幕上取消导航;OnBeforeRouteUpdate会在目标链中每个复用的共享前缀屏幕上被调用(无论其Props是否变化),任一屏幕返回false即取消导航。 在修改状态之前做决定:拒绝回调中所做的任何更改都不会回滚。 被取消的导航永远不会留下一个已被清理过的活动屏幕。 - KeepAlive。 被移入缓存的屏幕在离开时不会获得
OnLeft()。它会在缓存逐出(LRU) 或ClearKeepAliveCache时获得,此时它在树之外执行:缓存中保存的是已分离的屏幕。
| 路径 | OnLeaving | OnLeft | OnLeft 时的树 |
|---|---|---|---|
| 单层路由 → 任意路由 | 一次 | 一次 | 存活 |
嵌套链 → 单层路由(/a/x → /b) | 叶,然后根 | 叶,然后根 | 存活 |
嵌套链 → 另一条链(/a/x → /c/z) | 叶,然后根 | 叶,然后根 | 存活 |
保留公共前缀(/a/x → /a/y) | 仅 x | 仅 x;/a 保留 | 存活 |
带 KeepAlive 的层级离开 | 一次 | 不调用;屏幕在缓存中 | 不适用 |
| 缓存屏幕被逐出或缓存被清空 | 不再次调用 | 一次 | 已分离 |
不带 KeepAlive 的父级离开,而其 ChildView 缓存了子级 | 每屏一次 | 被缓存的子级(先清空缓存),然后父级:从叶到根 | 被缓存的子级已分离,父级存活 |
导航被取消(守卫、BeforeResolve、嵌套目标任意层级上 OnBeforeEnter / OnBeforeRouteUpdate 返回 false,包括 KeepAlive 复用的屏幕) | 可能已执行 | 不调用 | 不适用 |
经验法则:
- 依赖树的清理(读取或写入祖先、外壳标题、焦点)放在
OnLeaving中,它总是在树存活时执行。 - 不依赖树的清理(取消订阅、dispose、取消计时器)放在
OnLeft中,并且不应假设parent或panel仍然存活,因为被缓存的屏幕是在树之外被逐出的。 KeepAlive按嵌套链的每一层决定。被保留的子级缓存在其父级的ChildView(outlet)中, 只有在同一个父级实例被复用时才会返回。如果父级被重新创建,其缓存为空,子级会重新构建; 如果父级不带KeepAlive离开,其缓存会被清空,被缓存的子级会收到OnLeft。- 此前,从嵌套链离开前往单层路由时会跳过
OnLeft;而 KeepAlive 子级离开前往同级时会丢失OnLeft。依赖它不触发的代码(重复取消订阅、访问已释放的状态)现在必须能容忍这次调用。 与下一个屏幕之间的顺序也发生了变化:下一个屏幕的BeforeEnter现在在旧屏幕的OnLeft之前执行。
OnLeaving —— 树仍存活时的最后一次调用
OnLeaving(toRoute) 在每次离开时调用一次:在所有离开与进入守卫都已通过之后, 并且在路由器分离任何屏幕之前。此时 parent、panel 以及屏幕的祖先——例如 承载嵌套 ChildView 的外壳——都仍然存活。可在这里撤销屏幕对其祖先所做的改动:
// A nested pane writes a caption into its shell on enter
// and clears it while the shell is still reachable.
protected override void OnEntered() =>
GetFirstAncestorOfType<SettingsScreen>()?.ShowPane("Profile");
protected override void OnLeaving(SusRoute toRoute) =>
GetFirstAncestorOfType<SettingsScreen>()?.ClearPane();AdvancedRouting 示例正是这样做的(SettingsPaneScreen)。
调用时机:
- 对每一个离开活动路由链的屏幕调用:无论是从树中移除的屏幕,还是移入 KeepAlive 缓存的屏幕。
- 按叶 → 根的顺序调用,因此嵌套的子屏幕先于其父屏幕执行。
- 对保留下来的屏幕不调用:嵌套链的共同前缀(
/settings/profile→/settings/privacy会保留/settings),或同一屏幕实例的 props 更新 (OnBeforeRouteUpdate)。 - 缓存的屏幕被逐出 KeepAlive 时不会再次调用——它早已离开了路由链。
请注意:
OnLeaving先于OnLeft执行,并且始终在树存活时执行;关于这一对中的另一半, 参见OnLeft。BeforeResolve守卫在OnLeaving之后运行。如果它中止了导航,屏幕会保持激活, 尽管OnLeaving已被调用,并且OnEntered不会再次调用。此时OnLeft没有执行, 因此屏幕仍然完好。尽管如此,可能取消离开的检查最好放在OnBeforeLeave或其他在OnLeaving之前运行的守卫中,这样OnLeaving的副作用就不会被半途撤销。
3. 访问路由数据
SusRouter 会把路径参数(:id)和查询参数(?tab=x)合并到同一个 Props 字典中。可以通过以下辅助方法读取:
// route "/users/:id", URL "/users/42?tab=profile"
string id = GetParam("id"); // "42" (path param)
string tab = GetQuery("tab"); // "profile" (query)
int page = GetProp("page", 1); // typed, with default
object raw = GetProp("payload"); // untyped| 成员 | 类型 | 作用 |
|---|---|---|
Router | SusRouter | 屏幕所属的路由器(导航、模态框) |
Props | Dictionary<string,object> | 参数 + 查询 + 传入的 props(永不为 null) |
IsActive | bool | 屏幕当前是否处于激活状态 |
GetProp<T>(key, def) | T | 带类型转换的类型化读取 |
GetParam(key, def) / GetQuery(key, def) | string | 指向 Props 的别名(params/query 已合并) |
4. 接入屏幕的三种方式
4.1 命令式 —— router.Register(path, type, config?)
var router = new SusRouter();
router.Register("/", typeof(HomeScreen));
router.Register("/about", typeof(AboutScreen));
router.Register("/users/:id", typeof(UserScreen),
new SusRouteConfig { Name = "user", KeepAlive = true });
router.Mount(root, "/"); // or Mount(uiDocument, "/")Register 返回 SusRouteRecord——可以将其嵌套到父级的 Children 中(参见 §5,嵌套路由)。
4.2 声明式 —— 通过 SusApp.UseRouter 使用 SusRouteBuilder
SusApp.Create(uiDocument)
.UseTheme(SusTheme.Dark)
.UseRouter(new SusRouter(), routes => routes
.Route("/", typeof(HomeScreen)).Name("home")
.Route<LoginScreen>("/login").Alias("/signin")
.Route("/users/:id", typeof(UserScreen))
.Name("user").KeepAlive().Meta("requiresAuth", true)
.Children(c => c
.Route("profile", typeof(ProfileScreen))
.Route("posts", typeof(PostsScreen))),
initialPath: "/")
.Run();4.3 命令式 UseRouter(在 SusApp 内部 Register)
SusApp.Create(uiDocument)
.UseRouter(new SusRouter(), r =>
{
r.Register("/", typeof(HomeScreen));
r.Register("/settings", typeof(SettingsScreen));
}, initialPath: "/")
.Run();UseRouter 会在 SusApp 正确的收尾阶段嵌入注册逻辑与 Mount (在 token 级联/自定义样式之后,主题应用之前)。
5. SusRouteConfig 的全部选项
| 选项 | 类型 | 作用 |
|---|---|---|
Name | string | 供 PushNamed/ReplaceNamed 使用的名称 |
KeepAlive | bool | 离开时不重新创建屏幕(实例缓存) |
Alias | List<string> | 额外解析到该路由的路径 |
Children | List<SusRouteRecord> | 嵌套路由(一层) |
Redirect | string | 进入时改为跳转到此处,而不是当前路由 |
DefaultProps | Dictionary<string,object> | 屏幕的默认 props |
PropsFn | Func<SusRoute, Dictionary<string,object>> | 根据路由生成 props(对应 Vue 的 props: route => ({...})) |
LazyFactory | Func<SusScreen> | 延迟创建屏幕(替代 Activator.CreateInstance) |
Guard | ISusRouteGuard | 单个路由的守卫 CanEnter/CanLeave |
BeforeEnter | SusRouterGuard | 函数式进入守卫(在 Guard.CanEnter 之后执行) |
Transition | SusRouteTransition | 过渡动画(Fade()、SlideLeft() 等) |
Meta | Dictionary<string,object> | 任意元数据(requiresAuth、title) |
CaseSensitive | bool | 路径匹配是否区分大小写(默认关闭) |
Strict | bool | 是否严格匹配结尾斜杠(/a ≠ /a/) |
各选项示例
// KeepAlive + Transition
router.Register("/feed", typeof(FeedScreen),
new SusRouteConfig { KeepAlive = true, Transition = SusRouteTransition.Fade() });
// Named + params → PushNamed
router.Register("/users/:id", typeof(UserScreen), new SusRouteConfig { Name = "user" });
router.PushNamed("user", new() { ["id"] = "42" });
// Alias + Redirect
router.Register("/home", typeof(HomeScreen), new SusRouteConfig { Alias = new() { "/start" } });
router.Register("/old", typeof(HomeScreen), new SusRouteConfig { Redirect = "/home" });
// DefaultProps + PropsFn
router.Register("/report", typeof(ReportScreen), new SusRouteConfig
{
DefaultProps = new() { ["format"] = "pdf" },
PropsFn = route => new() { ["id"] = route.Params.GetValueOrDefault("id") },
});
// Lazy + Meta + Guard
router.Register("/admin", typeof(AdminScreen), new SusRouteConfig
{
LazyFactory = () => new AdminScreen(),
Meta = new() { ["requiresAuth"] = true },
BeforeEnter = (from, to) => Session.IsAdmin,
});
// Nested (Children) via Register records
var users = router.Register("/users", typeof(UsersScreen), new SusRouteConfig
{
Children = new()
{
router.Register("/users/:id", typeof(UserDetailScreen)),
router.Register("/users/search", typeof(UserSearchScreen)),
}
});6. 导航(SusRouter 的方法)
| 方法 | 作用 |
|---|---|
Push(path, props?) | 压入历史记录并导航 |
Replace(path, props?) | 替换当前记录 |
PushNamed(name, pathParams?, props?) | 按路由名称导航 |
ReplaceNamed(name, pathParams?, props?) | 按名称替换 |
Back() / Forward() | 历史导航(基于游标) |
Go(n) | 按 n 步偏移 |
NavigateWithTransition(path, …) | 使用显式动画导航 |
CanGoBack / CanGoForward | 历史记录可用性(用于按钮状态) |
Modal(type, props?) / CloseModal() | 通过 ModalService 显示/关闭模态框 |
所有 Push*/Replace*/Back/Forward/Go 方法都会返回 NavigationResult (成功 / 被守卫中止 / 重定向)。
if (Router.CanGoBack) Router.Back();
Router.Push("/checkout", new() { ["cartId"] = cart.Id });
Router.Modal(typeof(InfoDialog), new() { ["text"] = "Done!" });7. 嵌套屏幕
父屏幕注册一个子级 SusRouteView,路由器会把 子屏幕挂载到这里:
public class UsersScreen : SusScreen
{
protected override void Build()
{
Add(new Label("Users"));
var childView = new SusRouteView();
RegisterChildView(childView); // router mounts /users/:id etc. here
Add(childView);
}
}代码中可以访问 ChildView(第一个)和 ChildViews(全部)。链式解析 会挑选出从根到叶的记录 MatchedChain。
8. 入口点(MonoBehaviour)
[RequireComponent(typeof(UIDocument))]
public class AppEntry : MonoBehaviour
{
[SerializeField] private UIDocument _uiDocument;
private void OnEnable()
{
// Create(UIDocument) applies SusDefault.tss to the panel; prefer it over
// Create(rootVisualElement), which skips TSS.
SusApp.Create(_uiDocument)
.UseTheme(SusTheme.Dark)
.UseRouter(new SusRouter(), routes => routes
.Route("/main-menu", typeof(MainMenuScreen)).Name("menu")
.Route("/battle/:matchId", typeof(BattleScreen))
.KeepAlive().Transition(SusRouteTransition.Fade()),
initialPath: "/main-menu")
.Run();
}
}与 SusComponent 的集成
OnEntered()会在Mounted()之前调用。需要访问尚未在Build()阶段存在的子节点的逻辑,请放到Mounted()中。Router/Props从OnBeforeEnter()起才可用,在Created()/Build()中尚不可用。Unmounted()——从面板分离时调用(在OnLeft()之后);使用 KeepAlive 时 屏幕会被分离到缓存中(在树之外),Unmounted()不会被调用。
相关文档
- 02-router-api.md — 完整的
SusRouterAPI - 04-routeview.md —
SusRouteView与 KeepAlive - 05-modals.md — 模态框
- 06-guards-transitions.md — 守卫流水线与动画