异步页面加载完成时,导航代次决定它还能不能显示
玩家点开背包,加载尚未完成,又立刻返回主页。此时背包资源恰好到达,如果完成回调只执行 view.Show(),主页会被一个已经失效的页面覆盖。更隐蔽的版本发生在连续跳转中:背包和设置页同时加载,先发出的请求后完成,却把后发页面顶掉。
这个错误不能靠“通常加载很快”或在视图里检查可见性消除。加载回调掌握的是过去一次导航的结果,而当前页面属于导航状态机。两者必须在一个明确的提交点重新核对。

页面名不足以证明这次加载仍然有效
只比较页面名会漏掉重复进入同一页面的情况。Home -> Inventory -> Home -> Inventory 的两个背包请求名字相同,却属于不同导航意图。探针因此让 UiNavigationCoordinator 独占一个单调递增的 generation:每次 Push 或 Pop 都推进代次,票据记录发起加载时的代次与页面。
状态所有权也随之清晰。历史栈决定当前页面,待完成表持有尚未提交的视图租约,视图池只负责租出和收回实例。异步加载器可以携带 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 已推进代次并归还租约,完成回调只得到一个可记录、无副作用的陈旧结果。只要导航历史、代次和待完成租约由同一个边界管理,异步完成顺序就不会再决定玩家最后看见哪个页面。