FUI 导航实践:拆开 Layer、History、Coverage 与 Cache 的组合语义

系列第 11 篇,面向熟悉 Unity 和 C#、开始写框架的开发者。以 FUI 当前 RoutePolicy、OpenOptions 和 Navigator 源码为依据,先拆一个返回键错误,再沿生成和运行路径验证组合语义;教学模型与真实API分开标记。

一段看起来能用、却会让返回键失控的代码

下面是传统 UIManager 的错误教学伪代码,不是 FUI API:

csharp 复制代码
void Open(GameObject page, bool addHistory, bool popup)
{
    page.transform.SetAsLastSibling();
    if (popup) current.SetActive(false);
    if (addHistory) history.Add(page);
    current = page;
}

void Back()
{
    Destroy(root.GetChild(root.childCount - 1).gameObject);
}

先开大厅,再开活动弹窗,最后显示 Toast。Toast 被放在最后一个 sibling,却没有进入 history。Back 仍按 Transform 选择它,返回历史和视觉对象立即分叉;如果最后一个 child 恰好是公共遮罩,删掉的甚至不是页面。popup 分支还会直接禁用 current,没有区分覆盖、关闭与资源释放。

把两个列表同步维护也不够。Close 若有退出动画,Destroy 尚未完成时第二次 Back 可能再选到它;缓存又要求保留对象而撤销旧身份。真正需要统一的是状态转换,而不只是给 Manager 再加一个 List。

很多 UIManager 在需求变多后,会长出一批参数:isPopupneedBackhideOtherscachesingleton。这些参数单独都合理,组合后却经常互相打架。

问题不是参数太多,而是页面固有规则被每个调用方反复决定。

先给结论

导航策略应满足三点:

  • 页面固有规则只有一个来源;
  • Layer、History、Coverage、Cache、AllowMultiple 等维度正交表达;
  • 调用时 Options 只能描述本次意图,不能偷偷改写页面身份。

FUI 将 RoutePolicy 由 Source Generator 编译进 Route,Navigator 在运行时执行统一语义,不再扫描 Attribute 或让调用点携带一串布尔值。

四种策略存放方式对比

方式 优点 主要问题
Open 参数 灵活、就近可见 每个调用点可能定义不同页面语义
View 子类字段/Inspector 美术可配置 加载前看不到,跨 Prefab 难审查
中央配置表 统一 类型弱、容易与代码声明漂移
RoutePolicy 编译进 Route 类型化、可审查、运行时直接使用 需要生成/注册步骤,动态页面另行处理

中央配置不是不能用;如果策划需要热更新页面策略,它很合适。但固定核心页面应优先静态化,远端覆盖必须经过验证并限制可变字段。

为什么策略维度必须正交

不要只定义 PageType = Popup,因为"弹窗"经常同时暗含五六种行为,而且项目之间定义不同。

这是当前 RoutePolicy 的属性摘录,省略构造函数和过渡效果 Provider 属性,不是可独立编译的完整类:

csharp 复制代码
public sealed class RoutePolicy
{
    public Layer Layer { get; }
    public HistoryMode HistoryMode { get; }
    public CoverageMode CoverageMode { get; }
    public CacheMode CacheMode { get; }
    public bool AllowMultiple { get; }
    public int CacheCapacity { get; }
    public float CacheDuration { get; }
    public RouteDependency[] Dependencies { get; }
}

每个字段只回答一个问题,Navigator 再定义它们组合时的唯一语义。

Layer 不是一个随便的 sortingOrder

设计框架时可以考虑让 Layer 参与视觉排序和相关导航规则,但不能因为字段叫 Layer,就推断它自动控制父节点、全局输入优先级或覆盖范围。FUI 当前 Layer 的直接含义是独立 Canvas 排序基准;焦点与覆盖由另一套导航状态维护。

如果调用方直接传 sortingOrder = 2000,框架无法判断两个页面的语义关系。FUI 当前定义 BackgroundScenePanelPopupTipsOverlayTop,基准值分别为 -30000、-20000、0、8000、16000、24000、30000。ReorderLayer 遍历全局 activeOrder,只压缩目标层的 LocalOrder,每层最多 1000 个活动 View,并写入 Instance.Layer 与 Order。超出限制会抛异常,不是无限叠加。

BringToFront 接收准确的 ViewHandle,只移动同层顺序,并切换显式页面的焦点;它不跨 Layer 改写排序。反过来,Layer 较高也不意味着 history 最后一个就是它。视觉、焦点和返回目标应分别验证。

History 决定 Back,不等于视觉顺序

视觉最上层页面不一定进入返回栈:Toast、Loading、常驻 HUD 通常不应成为 Back 目标。

csharp 复制代码
public enum HistoryMode
{
    Stack,
    Transient
}

Navigator 应维护独立的 activeOrderhistory

text 复制代码
activeOrder: Home, HUD, Settings, Toast
history:     Home, Settings

当前 Back 从 history 尾部向前遍历,找到 IsAlive 的 Handle 就调用 Close(handle)。它没有承诺"先关闭视觉最上层",也没有自动忽略仍存活但已经处于关闭过程的条目继续找另一个。BackAsync 等待所选 Handle 的 CloseAsync。

一个容易踩的坑是把 Loading 配为 Transient 后,以为它既不进历史也不抢焦点。实际上 CommitOpen 对显式打开的页面都会设置焦点,只有 history.Add 受 HistoryMode.Stack 控制。于是 Loading 显式打开后可以覆盖旧焦点,但 Back 仍可能选择下方的 Stack 页面。Transient 只回答"是否进入返回历史",不是"忽略一切导航行为"。

Coverage 决定下层页面怎样变化

FUI 当前把下层视觉处理收敛成两种:

  • KeepVisible:仍显示,只被输入遮挡;
  • Hide:暂时隐藏,关闭 Popup 后恢复;

更精确地说,当前 Focus/CommitOpen 读取新目标的 CoverageMode,改变上一个焦点页面:Interactable=false,记录 HiddenByCoverage,进入 Covered,并调用 Covered(hide)。不是按 Layer 扫描所有低层页面,也不是某个透明度检测系统。KeepVisible 仍会让旧焦点不可交互;Hide 也不等于 Close。

关闭当前焦点后,ReleaseFocusAfterClose 从 activeOrder 末端向前寻找仍可导航且有 ExplicitOwner 的页面,执行 Revealed 并恢复焦点。它还检查进入动画是否未完成,避免提前开放交互。这里的"恢复哪一页"不能直接用 Canvas 数值推断。

Cache 与 History 是两套不同规则

Cache = true 只说明关闭后实例可能保留,不表示它还在返回栈。

text 复制代码
Open/Covered → Back/Close → Closing
        → 从 History 移除
        → Cached(保留 Lease 和 View)

再次打开时从 Cache 取实例,创建新 Handle,再按 HistoryMode 重新入栈。

FUI 当前缓存策略是 NoneKeepAliveTimed;容量由 CacheCapacity 控制,Timed 模式还要求正的 CacheDuration。当前容量淘汰按同一 Route 的 LastUse 顺序选择旧项,并不是一个叫 LRU 的公开枚举。Timed 的年龄由 Navigator.Tick(unscaledDeltaTime) 推进;宿主没有调用 Tick,就不能指望缓存自动计时淘汰。

AllowMultiple 的组合难点

多开不仅影响查找,还影响历史和缓存键:

  • Route + 参数是否构成逻辑唯一键?
  • 同类型两个实例 Back 的顺序是什么?
  • 缓存按 Route 存一个还是多个?
  • BringToFront 需要 Handle 还是 Route?
  • 依赖页面由两个 Owner 共享时何时关闭?

当前 RoutePolicy 默认 AllowMultiple=false。活动单实例重复打开会复用原 Handle,不是分配第二个页面;缓存恢复才获得新 Handle。FUI 当前不是把 Route 加任意参数对象当成实例键:参数不同不能自动推导出不同页面。

OpenWithViewModel 传入不同的 ViewModel 时,默认 Keep 模式抛异常;显式选择 OpenOptions.Rebind 才会保留已有 View 与 Handle,换绑到新数据。同一个购物详情页面究竟要换绑还是多开,应该由业务需求决定,不能靠调用顺序碰运气。

页面依赖为什么也适合写进 Policy

例如商城页创建前需要货币栏,父页创建后还要附加一个教学提示。AttachedAfter 是"创建后附加",不是"关闭后打开"。下面是 Attribute 配置片段,假设这些 ViewModel 已有符合框架的 ViewContract 和默认 Route:

csharp 复制代码
[RoutePolicy(
    RequiredBefore = new[] { typeof(CurrencyBarViewModel) },
    AttachedAfter = new[] { typeof(ShopGuideViewModel) })]

依赖关系需要所有权:

text 复制代码
Shop ─owns→ CurrencyBar
Inventory ─owns→ CurrencyBar

当前 Navigator 确实有 Owners 集合,同时还保存 ExplicitOwner。ReleaseOwner 只有在 Owners.Count==0 且没有显式所有权时才调用 Close。若依赖后来被用户显式打开,不能因为父页退出就将它关掉。AttachedAfter 依赖只调整视觉顺序,不通过 BringToFront 偷拿焦点,也不独立进入返回历史。

RouteGenerator 当前用 FUI0011 报告缺失的依赖默认 Route,用 FUI0012 报告静态依赖环。运行时仍有动态环防御。若声明 AllowMultiple,同一依赖的重复节点还需要各自的可转移资源 Lease;不能简单把所有重复类型都去重。

RoutePolicy 为什么应不可变

如果页面打开后任意代码都能改写固有策略,活动 Entry、历史和缓存的解释就可能分叉。FUI 当前的访问路径是 route.Descriptor.Settings,不是 route.Policy;CacheMode 等标量属性只有 getter。

构造后不可变有三个好处:

  • 可安全共享同一 Route;
  • 测试输入确定;
  • 生成器可以直接产生构造代码。

但这里不能夸大为深度不可变:Dependencies 是公开数组,构造函数直接保存传入数组,没有防御性复制。调用方仍能替换数组项;数组中的 RouteDependency 还保存 Func。这是当前实现的边界。若希望强保证,可继续改为防御性复制与只读集合,并限制依赖工厂行为,不能把这项建议说成已有能力。

当前 OpenOptions 实际只包含 OpenMode(Push/Replace)与 ExistingViewModelMode(Keep/Rebind),并提供 Push、Replace、Rebind 三个静态入口。BringToFront 是 Navigator 独立方法,CancellationToken 是异步 API 参数,都不是 OpenOptions 字段。

组合规则必须集中,而不是散在 if 中

可以先建立决策表:

场景 History Coverage Cache 结果
活动弹窗 Stack KeepVisible KeepAlive 可进入返回历史;关闭后可缓存
Loading Transient KeepVisible None 不进历史;显式打开仍可能接管焦点
主面板 Stack Hide None 新页面打开时暂时隐藏下层
Toast Transient KeepVisible None 不进历史,但这不足以保证不抢焦点

这张表是需求起点,不是完整的产品行为证明。比如不抢焦点的 Toast,可以评估由页面的受控展示通道或不取焦点的附加依赖承载;若要新增独立的非焦点通知 API,需要另行设计其寿命与所有权。当前没有一个声明就能把任意显式页面变成完整 Toast 系统。

让 Navigator 集中执行组合语义,不意味着所有组合都自动正确。框架应把已定义的规则统一起来,也应把不支持的组合尽早显露出来。

常见坑点

坑一:布尔字段产生非法组合

addHistory=falsereplaceHistory=true 同时出现没有意义。使用 enum 表达互斥状态,并在构造时验证。

坑二:Back 与 Close 使用两套路径

Back 最终应选择 Handle 后调用同一 Close 状态机,否则动画、缓存和所有权会分叉。

坑三:视觉排序就是打开顺序

不同 Layer 的排序规则不同,BringToFront 也可能只在 Layer 内生效。显式维护视觉顺序。

坑四:依赖只有列表,没有 Owner

共享依赖会被过早关闭。必须记录谁拥有它。

坑五:远端策略可改所有字段

热更新 Layer/依赖可能破坏静态验证。只开放确有业务需求且能校验的字段。

可执行验证

  • Toast/Loading 不进入 Back 历史。
  • Popup 覆盖和关闭后,下层页面收到一次 Cover/Reveal。
  • Replace 不遗留旧历史条目。
  • Cache 页从历史移除,复用时重新入栈并获得新 Handle。
  • 两个 Owner 共享依赖时,释放一个不会关闭依赖。
  • 静态依赖环在编译期报错。
  • 构造函数拒绝 CacheCapacity<1,以及 Timed 且 CacheDuration<=0;这些不是对所有枚举强转值和所有业务组合的完备校验。
  • 同一 Route 在不同调用点保持相同固有语义。

以上是接入验收清单,不是已经运行通过的测试报告。

从声明到运行:生成器究竟替我们做了什么

RoutePolicyAttribute 是编译期输入,RoutePolicy 是运行时配置,两者不能混为一物。RouteGenerator.BuildSettings 读取构造参数和 NamedArguments,生成明确的 new RoutePolicy(...);依赖则生成带 Route 工厂的 RouteDependency 数组。BuildRoute 把这些配置交给 GeneratedRouteFactory,与 ViewModel、Presenter、Binding 工厂一起装配。

这条链路可以概括为:

text 复制代码
ViewModel 上的 RoutePolicyAttribute
  → Roslyn 读取类型与命名参数
  → 依赖默认 Route 检查、静态环诊断
  → 生成 new RoutePolicy(...) 与 RouteDependency(...)
  → Route.Descriptor.Settings
  → Navigator 处理 Open / Back / Close / Cache

它省掉的不是所有运行时判断,而是运行时扫描 Attribute、拼字符串找类型,以及各调用点自行解释默认规则。状态、焦点、异步和缓存仍然必须在运行时计算。编译器能确认静态依赖图,不可能替你证明任意玩家操作序列符合产品意图。

一个细节值得区分:CacheCapacity 和 Timed 时长的参数校验位于 RoutePolicy 构造函数。生成器能产出构造代码,不等于这些错误全都有编译期 Diagnostic。若生成静态 Route 初始化时传入非法容量,仍可能在初始化阶段失败。希望提前到编译期,需要新增对应诊断测试。

用大厅、弹窗、Loading 跑一条可推理的路径

假设项目已有 HomeViewModel、ActivityViewModel 与 LoadingViewModel 的 ViewContract,下面只列策略,不省略号伪装完整业务代码:

csharp 复制代码
// 配在各自 ViewModel 上的独立 Attribute 示例。
[RoutePolicy(Layer.Panel, HistoryMode = HistoryMode.Stack,
    CoverageMode = CoverageMode.Hide)]
// class HomeViewModel ...

[RoutePolicy(Layer.Popup, HistoryMode = HistoryMode.Stack,
    CoverageMode = CoverageMode.KeepVisible,
    CacheMode = CacheMode.KeepAlive, CacheCapacity = 1)]
// class ActivityViewModel ...

[RoutePolicy(Layer.Overlay, HistoryMode = HistoryMode.Transient)]
// class LoadingViewModel ...

这是三个独立配置片段,不能直接连续粘贴到同一个类;Routes 的生成名还要以自己的 ViewContract 为准。

按当前实现分析:Home 显式打开进入 history,Activity 打开后让旧焦点 Home 保持可见但不可交互。Loading 显式打开不进 history,却仍会成为焦点。此时 Back 选择的可能是 Activity,不是 Loading。如果产品要求加载期间完全不允许返回,应由输入门或明确的上层导航约束拦截,不能只写 Transient。

再看 Activity 关闭进入缓存:先退出生命周期,再移出活动导航身份并保留 Instance 和 Lease。重新打开得到新 Handle。业务持有旧 Handle 的迟到回调不能被当成新页面的控制权。资源复用与操作资格是两件事,把它们分开,新人就不必自行发明一套缓存身份规则。

最小可运行测试:先验证真实配置,再验证教学模型

以下 NUnit 代码使用 FUI.Navigation 的真实 RoutePolicy 与 OpenOptions。放入已引用 FUI 和 NUnit 的测试程序集;这里未执行 Unity 测试,因此只给出预期断言,不报告通过率。

csharp 复制代码
using System;
using FUI.Navigation;
using NUnit.Framework;

public sealed class NavigationPolicyTests
{
    [Test]
    public void Defaults_KeepCommonPageSemanticsTogether()
    {
        var policy = new RoutePolicy();
        Assert.That(policy.Layer, Is.EqualTo(Layer.Panel));
        Assert.That(policy.HistoryMode, Is.EqualTo(HistoryMode.Stack));
        Assert.That(policy.CacheMode, Is.EqualTo(CacheMode.None));
        Assert.That(policy.AllowMultiple, Is.False);
    }

    [Test]
    public void InvalidCacheSettings_FailAtConstruction()
    {
        Assert.Throws<ArgumentOutOfRangeException>(() =>
            new RoutePolicy(cacheCapacity: 0));
        Assert.Throws<ArgumentOutOfRangeException>(() =>
            new RoutePolicy(cacheMode: CacheMode.Timed, cacheDuration: 0));
    }

    [Test]
    public void Rebind_DoesNotMeanReplace()
    {
        Assert.That(OpenOptions.Rebind.OpenMode, Is.EqualTo(OpenMode.Push));
        Assert.That(OpenOptions.Rebind.ExistingViewModelMode,
            Is.EqualTo(ExistingViewModelMode.Rebind));
        Assert.That(OpenOptions.Replace.ExistingViewModelMode,
            Is.EqualTo(ExistingViewModelMode.Keep));
    }

    [Test]
    public void DependencyArray_IsNotDeeplyImmutableToday()
    {
        var first = new RouteDependency(DependencyTiming.RequiredBefore,
            () => throw new NotSupportedException());
        var second = new RouteDependency(DependencyTiming.AttachedAfter,
            () => throw new NotSupportedException());
        var dependencies = new[] { first };
        var policy = new RoutePolicy(dependencies: dependencies);
        dependencies[0] = second;
        Assert.That(policy.Dependencies[0], Is.SameAs(second));
    }
}

最后一个测试是边界记录,不是建议修改共享配置。它帮助团队避免把"属性没有 setter"误当成深度不可变。修正设计后,这个测试也应该随新契约修改。

配置测试仍不足以证明 Back 或覆盖正确。下面是独立教学模型,只刻画"显式打开时焦点与历史分开",不冒充 Navigator 的完整替身:

csharp 复制代码
using System.Collections.Generic;
using FUI.Navigation;
using NUnit.Framework;

public sealed class FocusHistoryModelTests
{
    sealed class Model
    {
        readonly List<int> history = new List<int>();
        int next;
        public int Focus { get; private set; }
        public int Open(HistoryMode mode)
        {
            var handle = ++next;
            Focus = handle;
            if (mode == HistoryMode.Stack) history.Add(handle);
            return handle;
        }
        public int BackTarget => history.Count == 0 ? 0 : history[history.Count - 1];
    }

    [Test]
    public void Transient_CanHaveFocusWithoutBeingBackTarget()
    {
        var model = new Model();
        var activity = model.Open(HistoryMode.Stack);
        var loading = model.Open(HistoryMode.Transient);
        Assert.That(model.Focus, Is.EqualTo(loading));
        Assert.That(model.BackTarget, Is.EqualTo(activity));
    }
}

真实框架集成测试还需要 Fake Provider、生成的 Route 与生命周期记录器:打开大厅、弹窗和 Loading,断言焦点通知、历史选择、关闭状态、资源释放次数,再等待 CloseAsync 完成。测试替身应记录调用,不能只看最终画面。若要覆盖真实输入和 Canvas,还需要在 Unity 场景中验证显示与交互,不能拿这个 List 模型替代。

这些约束最后帮到了谁

把规则放到 Route 上,首先帮助的是每天写业务的人。同一页面从商城、背包、活动入口打开,不应得到三套缓存或返回语义;新同事沿默认入口写代码,也不会被迫自行决定何时卸载、关谁、把谁恢复焦点。

对框架作者而言,代价是必须解释组合边界。正交不等于互不影响:History 不决定焦点,Layer 不等于返回顺序,Coverage 不代表关闭,Cache 不保留旧 Handle。把这些限制写成测试,比一个名叫 Popup 的万能枚举更容易扩展。

这是从源码推断出的设计收益,而不是宣称所有误用都被封死。Dependencies 数组仍可变,部分参数仍运行时验证,不抢焦点的独立通知通道也需要明确设计。框架的价值,是让常见正确路径短且一致,让剩余风险可见,而不是把未实现的保证藏在"统一管理"四个字里。

资料与源码索引

先跑配置断言,再用可控Provider与生命周期记录器测真实导航,最后进场景验证输入和显示。每一层只证明自己负责的行为。

下一篇预告:增加自定义血条 Element,讨论怎样扩展控件而不修改 Core、Navigator 或生成器特判。第 12 篇发布后补充同平台跳转。

相关推荐
SmalBox2 小时前
01-08-认知篇-对比-其他资源管理方案
unity3d·游戏开发
淡海水3 小时前
10-05-高级-模式匹配-CSharp如何改变与数据结构的交互方式
数据结构·算法·c#·模式匹配
XiaoZhenHua984 小时前
C# WinForms + 西门子S7 + SQLite + EPPlus生产数据采集系统架构设计
系统架构·sqlite·c#
Lost of 程序猿5 小时前
船岸数据同步链路实战:SendFlag、单调版本号与断网三天后的续传
后端·c#·asp.net
SmalBox17 小时前
01-07-认知篇-对比-原生AssetBundle工作流
unity3d·游戏开发
cjp5601 天前
012.UG二次开发,UG管道服务器与WPF管道通讯,传输JSON数据
c#
jiushidt1 天前
ArcGIS Pro Add‑in 打包流程详解
arcgis·c#·.net·arcgispro
geovindu2 天前
CSharp: Observer Pattern
开发语言·后端·观察者模式·设计模式·c#·.netcore·行为模式
SmalBox2 天前
01-06-认知篇-对比-Addressable Assets深度解析
unity3d·游戏开发