跳转到内容

3. SusScreen — 屏幕、生命周期与接入 ​

屏幕(SusScreen)是由 SusRouter 管理的全屏视图。 它继承自 SusComponent,因此拥有完整的核心响应式工具集 (Prop<T>、Watch、Build()、Mounted()),此外还具备路由器生命周期,并可访问 路由参数。


1. 最小屏幕示例 ​

csharp
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* 钩子(模板方法模式)。

csharp
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() 是取消订阅事件和释放资源的地方。它遵守五条不变式:

  1. 仅一次。 导航从活动链中移除、且未放入 KeepAlive 缓存的每个屏幕,恰好获得一次 OnLeft()。
  2. 从叶到根。 与 OnLeaving 的集合和顺序相同:嵌套子级先于父级执行。
  3. 在树中。 它在路由器分离任何东西之前执行,因此 panel 与 parent 仍然有效 (KeepAlive 逐出之后除外,见第 5 条)。
  4. 在最后一个取消点之后。 它在守卫、BeforeResolve 以及目标链中每个屏幕的 BeforeEnter 之后执行。 OnBeforeEnter 返回 false 会在嵌套目标的任意层级(根、中间或叶子)以及从 KeepAlive 缓存复用的屏幕上取消导航; OnBeforeRouteUpdate 会在目标链中每个复用的共享前缀屏幕上被调用(无论其 Props 是否变化),任一屏幕返回 false 即取消导航。 在修改状态之前做决定:拒绝回调中所做的任何更改都不会回滚。 被取消的导航永远不会留下一个已被清理过的活动屏幕。
  5. KeepAlive。 被移入缓存的屏幕在离开时不会获得 OnLeft()。它会在缓存逐出(LRU) 或 ClearKeepAliveCache 时获得,此时它在树之外执行:缓存中保存的是已分离的屏幕。
路径OnLeavingOnLeftOnLeft 时的树
单层路由 → 任意路由一次一次存活
嵌套链 → 单层路由(/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 的外壳——都仍然存活。可在这里撤销屏幕对其祖先所做的改动:

csharp
// 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 字典中。可以通过以下辅助方法读取:

csharp
// 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
成员类型作用
RouterSusRouter屏幕所属的路由器(导航、模态框)
PropsDictionary<string,object>参数 + 查询 + 传入的 props(永不为 null)
IsActivebool屏幕当前是否处于激活状态
GetProp<T>(key, def)T带类型转换的类型化读取
GetParam(key, def) / GetQuery(key, def)string指向 Props 的别名(params/query 已合并)

4. 接入屏幕的三种方式 ​

4.1 命令式 —— router.Register(path, type, config?) ​

csharp
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 ​

csharp
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) ​

csharp
SusApp.Create(uiDocument)
      .UseRouter(new SusRouter(), r =>
      {
          r.Register("/", typeof(HomeScreen));
          r.Register("/settings", typeof(SettingsScreen));
      }, initialPath: "/")
      .Run();

UseRouter 会在 SusApp 正确的收尾阶段嵌入注册逻辑与 Mount (在 token 级联/自定义样式之后,主题应用之前)。


5. SusRouteConfig 的全部选项 ​

选项类型作用
Namestring供 PushNamed/ReplaceNamed 使用的名称
KeepAlivebool离开时不重新创建屏幕(实例缓存)
AliasList<string>额外解析到该路由的路径
ChildrenList<SusRouteRecord>嵌套路由(一层)
Redirectstring进入时改为跳转到此处,而不是当前路由
DefaultPropsDictionary<string,object>屏幕的默认 props
PropsFnFunc<SusRoute, Dictionary<string,object>>根据路由生成 props(对应 Vue 的 props: route => ({...}))
LazyFactoryFunc<SusScreen>延迟创建屏幕(替代 Activator.CreateInstance)
GuardISusRouteGuard单个路由的守卫 CanEnter/CanLeave
BeforeEnterSusRouterGuard函数式进入守卫(在 Guard.CanEnter 之后执行)
TransitionSusRouteTransition过渡动画(Fade()、SlideLeft() 等)
MetaDictionary<string,object>任意元数据(requiresAuth、title)
CaseSensitivebool路径匹配是否区分大小写(默认关闭)
Strictbool是否严格匹配结尾斜杠(/a ≠ /a/)

各选项示例 ​

csharp
// 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 (成功 / 被守卫中止 / 重定向)。

csharp
if (Router.CanGoBack) Router.Back();
Router.Push("/checkout", new() { ["cartId"] = cart.Id });
Router.Modal(typeof(InfoDialog), new() { ["text"] = "Done!" });

7. 嵌套屏幕 ​

父屏幕注册一个子级 SusRouteView,路由器会把 子屏幕挂载到这里:

csharp
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) ​

csharp
[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() 不会被调用。

相关文档 ​