返回背包时选中项丢了:导航栈不能把页面状态交给视图池

玩家从背包的第 17 件物品进入详情页,再按返回键,合理结果不只是“重新显示背包”:选中项仍应是 17,滚动偏移仍应是 240。若页面状态存在被池化的视图组件里,返回动作恰好可能拿到同一个槽位,却拿不到同一段历史。更危险的是,槽位已经交给别的页面后,旧回调仍能把过期状态写回新实例。

导航历史与视图池分离的主视觉

这里需要分开的不是两个类名,而是两种生命周期。导航条目从 Push 活到对应的 Pop;视图实例只从 Rent 活到 Return,之后可以立刻服务另一条导航记录。只要两者共用一个身份,池复用就会悄悄改写历史。

返回操作需要恢复导航条目,不是寻找旧 GameObject

探针把可恢复状态放进 PageState,再由 NavigationEntry 持有稳定的 NavigationIdViewLease 只有槽位号和代次,它说明某个视图实例当前属于谁,却不保存路由语义。

public readonly record struct NavigationId(long Value);
public readonly record struct ViewLease(int Slot, int Generation);
public sealed record PageState(string Route, int SelectedItem, int ScrollOffset);
public sealed record NavigationEntry(NavigationId Id, PageState State);

public sealed class NavigationCoordinator(ViewPool pool)
{
    private readonly List<NavigationEntry> stack = [];
    private long nextId;

    public (NavigationEntry Entry, ViewLease Lease) Push(PageState state)
    {
        NavigationEntry entry = new(new NavigationId(++nextId), state);
        stack.Add(entry);
        return (entry, pool.Rent(entry.Id));
    }

    public NavigationEntry Pop(ViewLease lease)
    {
        NavigationEntry top = stack[^1];
        if (!pool.IsOwnedBy(lease, top.Id))
            throw new InvalidOperationException("Lease does not own top entry");

        pool.Return(lease);
        stack.RemoveAt(stack.Count - 1);
        return top;
    }
}

这条边界让返回流程变得明确:详情页退出时归还它的视图租约,背包条目仍在栈顶;随后为背包重新租一个视图,并把条目保存的 selected=17scroll=240 填回去。Unity 中可以由导航协调器驱动 MonoBehaviour 视图,Godot 中可以驱动 Control 节点;引擎对象都只是呈现载体,不应反过来成为历史记录。

状态快照的粒度也要由路由语义决定。背包需要保存选中物品与滚动位置,详情页可能只需要物品 ID;临时悬浮提示则可能根本不进入历史。把所有组件字段序列化成一团通用字典,看似省去类型设计,实际会让恢复条件、默认值和版本迁移失去编译期约束。探针使用不可变 PageState,是为了让入栈时刻的状态成为一份可以整体替换、直接比较的值;生产项目可以为不同路由定义不同状态类型,再由路由表完成受控转换。

槽位复用必须改变代次

仅检查槽位号仍不够。固定场景里,详情页使用租约 1:1,归还后背包恢复也拿到槽位 1。如果身份只有整数 1,旧异步回调与新持有者无法区分。池在每次租出时递增代次,使第二次租约成为 1:2

public ViewLease Rent(NavigationId owner)
{
    Slot slot = slots.First(candidate => candidate.Owner is null);
    slot.Generation++;
    slot.Owner = owner;
    return new ViewLease(slot.Index, slot.Generation);
}

public bool IsOwnedBy(ViewLease lease, NavigationId owner)
{
    Slot slot = slots[lease.Slot];
    return slot.Generation == lease.Generation && slot.Owner == owner;
}

导航条目与视图租约的固定数据关系图

代次不是为了保证线程安全,而是补全复用对象的身份。输入、动画或资源加载回调到达时,调用者必须同时提交槽位与代次;不匹配就返回无副作用结果。这样,迟到的 1:1 不会修改已经属于背包的 1:2

Restore 还检查目标条目是否仍是栈顶。这个条件阻止一个较早的返回请求越过当前页面直接恢复深层历史。若产品需要跳回任意已有页面,应把它建模为一次明确的栈裁剪:先确定目标条目,再依次退出其上的条目,最后为目标租出新视图。绕过栈顶检查直接绑定视图,会使被跨过页面的退出回调、资源租约和焦点所有权全部悬空。

固定流程同时验证恢复与拒绝

源码位于 .tmp/daily-labs/2026-09-01-1930/Program.cs,决定性入口是 NavigationCoordinator.PushPopRestore,对应断言由 Program.Check 汇总。发布前重新执行 Release 构建和固定输入:

cd "$WORK_DIR/.tmp/daily-labs/2026-09-01-1930"
dotnet restore UiNavigationOwnershipLab.csproj -p:NuGetAudit=false
dotnet build UiNavigationOwnershipLab.csproj -c Release --no-restore
dotnet run --project UiNavigationOwnershipLab.csproj -c Release --no-build
Build succeeded.
    0 Warning(s)
    0 Error(s)
fixed_input=Inventory(selected=17,scroll=240)>ItemDetail/17>Back
restored_route=Inventory selected=17 scroll=240
old_lease=1:1 new_lease=1:2 stale_result=StaleLease
pass=8 fail=0 skip=0 elapsed_ms=30.886

正常样本证明视图实例变化后,导航状态仍能完整恢复;失败样本证明旧租约和非栈顶条目都不能获得提交资格。这里没有据此声称真实设备的 UI 性能有所改善,因为探针只验证所有权与身份合同,没有测量实例化成本、帧时间或内存。

测试中的八个断言分别覆盖两次入栈、详情页出栈、背包恢复、状态值保持、槽位代次递增、陈旧租约拒绝与非栈顶拒绝。它们刻意同时观察逻辑身份和物理实例:只断言路由正确,会漏掉旧租约仍可写入;只断言代次变化,又无法证明玩家实际回到了原来的选择位置。两组观察必须共同通过,才能说明所有权分离没有把恢复能力一并删除。

过渡动画与异步加载仍需单独建模

这套结构解决的是“谁保存历史”和“谁可以写当前视图”。它没有处理返回动画进行中再次导航、资源加载取消、多窗口各自维护栈,或深链路恢复失败。接入这些能力时,导航条目仍应保持稳定身份,但需要增加过渡状态与取消令牌,不能把 ViewLease 扩张成包办所有流程的会话对象。

回到背包丢选中项的问题,修复点不在 OnEnable 里补一次缓存,而在所有权边界:导航栈保存可恢复的页面状态,视图池保存可复用实例,带代次的租约只负责把两者在当前显示周期内绑定。只要这三种职责没有混在一起,返回恢复和池化复用就能分别演进,也能分别被回归测试约束。