异步页面加载完成时,导航代次决定它还能不能显示

玩家点开背包,加载尚未完成,又立刻返回主页。此时背包资源恰好到达,如果完成回调只执行 view.Show(),主页会被一个已经失效的页面覆盖。更隐蔽的版本发生在连续跳转中:背包和设置页同时加载,先发出的请求后完成,却把后发页面顶掉。

这个错误不能靠“通常加载很快”或在视图里检查可见性消除。加载回调掌握的是过去一次导航的结果,而当前页面属于导航状态机。两者必须在一个明确的提交点重新核对。

异步页面结果被导航代次分流,当前设置页保持稳定,陈旧背包页回到视图池

页面名不足以证明这次加载仍然有效

只比较页面名会漏掉重复进入同一页面的情况。Home -> Inventory -> Home -> Inventory 的两个背包请求名字相同,却属于不同导航意图。探针因此让 UiNavigationCoordinator 独占一个单调递增的 generation:每次 PushPop 都推进代次,票据记录发起加载时的代次与页面。

状态所有权也随之清晰。历史栈决定当前页面,待完成表持有尚未提交的视图租约,视图池只负责租出和收回实例。异步加载器可以携带 NavigationTicket 返回,但不能直接改变页面历史或显示视图。

关键实现位于 $PROBE_ROOT/UiNavigationCoordinator.cs。下面保留完整的提交链;视图实例只有绑定、显示和隐藏三种操作,没有自行判断导航是否有效的权限。

namespace UiNavigationProbe;

public readonly record struct NavigationTicket(long Generation, string Page);

public enum CompletionResult
{
    Shown,
    StaleNavigation
}

public sealed class ViewInstance(string id)
{
    public string Id { get; } = id;
    public string? BoundPage { get; private set; }
    public bool Visible { get; private set; }

    public void Bind(string page)
    {
        BoundPage = page;
        Visible = false;
    }

    public void Show() => Visible = true;
    public void Hide() => Visible = false;
}

public sealed class ViewPool(IEnumerable<ViewInstance> views)
{
    private readonly Queue<ViewInstance> _available = new(views);

    public int AvailableCount => _available.Count;

    public ViewInstance Rent(string page)
    {
        if (!_available.TryDequeue(out var view))
            throw new InvalidOperationException("view pool exhausted");

        view.Bind(page);
        return view;
    }

    public void Return(ViewInstance view)
    {
        view.Hide();
        _available.Enqueue(view);
    }
}

public sealed class UiNavigationCoordinator(string rootPage, ViewPool pool)
{
    private readonly List<string> _history = [rootPage];
    private readonly Dictionary<long, ViewInstance> _pending = [];
    private long _generation;

    public string CurrentPage => _history[^1];
    public IReadOnlyList<string> History => _history;

    public NavigationTicket Push(string page)
    {
        AdvanceGeneration();
        _history.Add(page);
        var ticket = new NavigationTicket(_generation, page);
        _pending.Add(ticket.Generation, pool.Rent(page));
        return ticket;
    }

    public string Pop()
    {
        if (_history.Count == 1)
            throw new InvalidOperationException("cannot pop root page");

        AdvanceGeneration();
        var removed = _history[^1];
        _history.RemoveAt(_history.Count - 1);
        return removed;
    }

    public CompletionResult Complete(NavigationTicket ticket)
    {
        if (!_pending.Remove(ticket.Generation, out var view))
            return CompletionResult.StaleNavigation;

        if (ticket.Generation != _generation || CurrentPage != ticket.Page)
        {
            pool.Return(view);
            return CompletionResult.StaleNavigation;
        }

        view.Show();
        return CompletionResult.Shown;
    }

    private void AdvanceGeneration()
    {
        _generation++;
        foreach (var view in _pending.Values)
            pool.Return(view);
        _pending.Clear();
    }
}

AdvanceGeneration 不只让旧票据失效,还同步归还旧租约。只递增计数而等待迟到回调清理,会让永远不回调的请求长期占住池容量。导航协调器既然拥有待完成表,就应在导航意图变化时立即结束这段租约。

提交需要同时通过身份与所有权检查

Complete 的第一道检查不是页面名,而是能否从待完成表中移除该代次。它同时完成一次性消费:同一回调重复触发时,第二次已经没有租约可取,只能得到 StaleNavigation。第二道检查才比较当前代次与当前页面,防止票据和历史状态发生不一致。

这使提交条件可以精确写成:待完成表仍拥有该票据对应的视图,并且票据代次等于当前代次、票据页面等于栈顶页面。只有满足全部条件的协调器才能调用 Show。陈旧回调不是异常,也不需要恢复页面;它只是一个不再拥有提交权的结果。

导航代次推进、租约回收、陈旧完成拒绝与当前页面提交关系图

Pop 与替换导航必须留下无副作用结果

探针用三条固定路径覆盖最容易混淆的状态。第一条在背包加载期间执行 Pop,随后交付旧票据;结果保持 Home,池中可用视图恢复为 1。第二条正常完成设置页,再重复交付同一票据;首次显示,重复完成被拒绝。第三条先 Push 背包,再 Push 设置页;代次从 1 变为 2,旧视图立即归池,最终只有设置页可见。

对应回归还检查根页面不可 Pop,避免空历史让 CurrentPage 失去定义。执行使用项目内相对路径,命令没有依赖外部包:

dotnet build $PROBE_ROOT/UiNavigationProbe.csproj -c Release
dotnet run --project $PROBE_ROOT/UiNavigationProbe.csproj -c Release --no-build

Release 构建退出码为 0,0 警告、0 错误;探针进程退出码为 0,14 项断言全部通过,失败 0、跳过 0,进程墙钟 1.46 秒。决定性输出如下:

pop-before-complete: ticket=1 popped=Inventory result=StaleNavigation current=Home available=1
current-complete: ticket=1 result=Shown duplicate=StaleNavigation current=Settings visible=true
replaced: oldTicket=1 newTicket=2 old=StaleNavigation latest=Shown current=Settings available=1
tests: passed=14 failed=0 skipped=0 elapsedMs=9.6712

代次拒绝不等于取消资源工作

这个方案保证的是状态安全,而不是资源加载已经停止。底层若支持取消,AdvanceGeneration 仍应把取消信号传给下载、解码或实例化任务,减少无效工作;即使取消与完成同时发生,提交点仍要保留代次检查,因为取消通常不是瞬时且绝对的屏障。

视图池容量也不能拿来掩盖所有权错误。若一次替换导航没有立即归还旧租约,连续五次快速跳转就可能占住五个实例,即使屏幕始终只需要一个页面。扩大池只会延后耗尽时刻。这里在代次推进时清空待完成表,使占用上限回到“当前允许并行的提交域数量”;对本探针的单页面模型而言,待完成租约最多为 1。

当前探针只覆盖单一活动页面和固定容量视图池。分栏界面可能同时允许多个提交域,此时每个区域应拥有独立代次,而不是共享一个全局计数。带过渡动画的页面还需要把“已加载”和“允许显示”拆成两个状态,但最终显示权仍应回到导航协调器。

开篇的迟到背包页因此不需要特殊补救:Pop 已推进代次并归还租约,完成回调只得到一个可记录、无副作用的陈旧结果。只要导航历史、代次和待完成租约由同一个边界管理,异步完成顺序就不会再决定玩家最后看见哪个页面。