Files
AtomUI/docs/AsyncLoadingArchitecture.md
T

18 KiB
Raw Permalink Blame History

AtomUI 异步加载体系架构与使用指南

适用于 AtomUI.Desktop.Controls 下所有需要异步拉取数据的控件:AutoComplete / Mentions / Select(搜索类),Cascader / TreeView / TreeSelect(展开类)。

1. 设计目标

异步加载面对的几个常见工程问题:

  • 搜索类场景用户高频输入,需要 debounce 避免每敲一键发一个请求
  • 展开类场景同一节点可能被多次触发加载,需要去重避免并发重入
  • 无论哪种场景,请求都可能卡在慢后端,需要硬超时兜底
  • 取消、超时、被覆盖这几种"非成功终态"需要统一表达,调用方能分辨

AtomUI 把这些需求抽成两种场景专用的协调器,放在 AtomUI.Controls.Shared/AsyncLoad/。控件只负责"发出请求 + 处理结果",一切时序和去重细节交给协调器。

2. 核心契约

2.1 AsyncLoadStatus

异步加载的终态枚举。

public enum AsyncLoadStatus
{
    Success,    // 加载成功
    Cancelled,  // 外部 CancellationToken 触发
    TimedOut,   // 达到 Timeout
    Faulted,    // loader 抛异常
    Skipped     // 搜索类专用:被后续调用覆盖,本次结果丢弃
}

2.2 AsyncLoadOutcome<TResult>

协调器返回的结果包装。

public sealed class AsyncLoadOutcome<TResult> where TResult : class
{
    public AsyncLoadStatus Status { get; init; }
    public TResult?        Result { get; init; }  // Status == Success 时非空
    public Exception?      Error  { get; init; }  // Status == Faulted 时非空

    public bool IsSuccess   { get; }
    public bool IsTimedOut  { get; }
    public bool IsCancelled { get; }
    public bool IsFaulted   { get; }
    public bool IsSkipped   { get; }
}

调用方处理模式:

var outcome = await coordinator.LoadAsync(context, (ctx, token) => loader.LoadAsync(ctx, token));

if (outcome.IsSkipped) return;  // 被后续请求覆盖,不触发任何回调
if (outcome.IsSuccess) { /* 用 outcome.Result */ }
else                   { /* 上报超时 / 取消 / 错误 */ }

2.3 AsyncSearchLoadCoordinator<TContext, TResult>

搜索类专用。特性:

  • 取消前一次:每次 LoadAsync 调用会取消前一个进行中的请求
  • Debounce:DebounceInterval > Zero 时,请求被延迟启动;延迟期间来新请求直接替换(旧请求返回 Skipped)
  • Timeout:Timeout 控制总超时(含 debounce 时间)
public sealed class AsyncSearchLoadCoordinator<TContext, TResult> where TResult : class
{
    public TimeSpan DebounceInterval { get; set; } = TimeSpan.Zero;
    public TimeSpan Timeout          { get; set; } = System.Threading.Timeout.InfiniteTimeSpan;

    public Task<AsyncLoadOutcome<TResult>> LoadAsync(
        TContext context,
        Func<TContext, CancellationToken, Task<TResult>> loader,
        CancellationToken external = default);

    public void Cancel();
}

TContext 可以是引用类型也可以是值类型,无 notnull 约束,允许 null 作为"空搜索"上下文。

2.4 AsyncExpandLoadCoordinator<TContext, TResult>

展开类专用。特性:

  • in-flight 去重:同一 TContext key 正在加载中时,新调用复用同一个 Task
  • Timeout:每个请求独立计时
  • CancelAll:控件 detach 时一次性取消所有在飞请求
public sealed class AsyncExpandLoadCoordinator<TContext, TResult>
    where TContext : notnull
    where TResult : class
{
    public AsyncExpandLoadCoordinator(IEqualityComparer<TContext>? dedupComparer = null);

    public TimeSpan Timeout { get; set; } = System.Threading.Timeout.InfiniteTimeSpan;

    public Task<AsyncLoadOutcome<TResult>> LoadOrJoinAsync(
        TContext context,
        Func<TContext, CancellationToken, Task<TResult>> loader,
        CancellationToken external = default);

    public void CancelAll();
}

展开类推荐使用 ReferenceEqualityComparer<T>.Instance 作为去重比较器(按节点引用判等):

new AsyncExpandLoadCoordinator<ITreeItemNode, TreeItemLoadResult>(
    ReferenceEqualityComparer<ITreeItemNode>.Instance);

3. 控件 API 总览

搜索类(AutoComplete / Mentions / Select)

属性 类型 默认值 作用
AsyncLoadDebounce TimeSpan Zero(AutoComplete / Mentions) 输入后等待多久才发请求;为 Zero 即不 debounce
AsyncLoadTimeout TimeSpan 10s 单次请求超时
OptionsAsyncLoader / OptionsLoader I*OptionsAsyncLoader? null 实际 loader,null 时不触发异步加载

Select 没有 AsyncLoadDebounce —— 它的异步加载触发点是 Popup 打开,不是输入变化。

展开类(Cascader / TreeView / TreeSelect)

属性 类型 默认值 作用
AsyncLoadTimeout TimeSpan 30s 单次节点加载超时
DataLoader I*NodeLoader? / I*ItemDataLoader? null 节点加载器

展开类没有 AsyncLoadDebounce,因为展开不是高频操作;真正的并发风险由协调器的 in-flight 去重解决。

共同暴露的加载状态

属性/事件 含义
IsLoading 当前是否有请求在飞
AsyncLoaded(展开类 item 上) 该节点是否已经加载过(防止已展开的节点重复请求)
OptionsLoaded / ItemAsyncLoaded / TreeItemLoaded 加载终态事件(含成功/超时/取消/错误)

加载结果类型(CompleteOptionsLoadResult / MentionOptionsLoadResult / SelectOptionsLoadResult / CascaderItemLoadResult / TreeItemLoadResult)共享同一组字段:

public bool           IsSuccess           { get; }  // => StatusCode == Success
public RpcStatusCode  StatusCode          { get; }  // Success / Timeout / Cancelled / ...
public string?        UserFriendlyMessage { get; }
public IReadOnlyList<T>? Data             { get; }

4. 数据流

搜索类

  [用户输入]
        │
        ▼
  FilterValue / SearchText 变化
        │
        ▼
  TryPopulateAsync(text)
        │
        ▼
  _coordinator.LoadAsync(text, loader)
        │
        ├── DebounceInterval 延迟(若 > Zero)
        │      └── 延迟中来了新请求?旧请求返回 Skipped
        │
        ├── 启动 Timeout 倒计时
        │
        ├── loader.LoadAsync(ctx, linkedToken)
        │
        ▼
  AsyncLoadOutcome
        │
        ├── Success   → 更新 OptionsSource + fire *Loaded(Success)
        ├── Skipped   → return, 不触发事件
        ├── TimedOut  → fire *Loaded(StatusCode=Timeout)
        ├── Cancelled → fire *Loaded(StatusCode=Cancelled)
        └── Faulted   → fire *Loaded(StatusCode=Unknown, Error)

展开类

  [用户点开节点]
        │
        ▼
  HandleNodeLoadRequest(item)
        │
        ▼
  _coordinator.LoadOrJoinAsync(nodeRef, loader)
        │
        ├── 同 nodeRef 已有 Task 在飞?复用它
        ├── 否则新建 linkedCts + CancelAfter(Timeout)
        ├── loader.LoadAsync(nodeRef, linkedToken)
        │
        ▼
  AsyncLoadOutcome(在 UI 线程 Dispatcher.InvokeAsync 中处理)
        │
        ├── Success   → item.AsyncLoaded = true; 追加子节点; fire *Loaded
        ├── TimedOut  → fire *Loaded(StatusCode=Timeout)
        ├── Cancelled → fire *Loaded(StatusCode=Cancelled)
        └── Faulted   → fire *Loaded(StatusCode=Unknown, Error)

5. 使用示例

5.1 最简:给 Select 配一个异步 loader

myselect.OptionsLoader = new MyAsyncLoader();
myselect.AsyncLoadTimeout = TimeSpan.FromSeconds(5);  // 默认 10s
public sealed class MyAsyncLoader : ISelectOptionsAsyncLoader
{
    public async Task<SelectOptionsLoadResult> LoadAsync(object? context, CancellationToken token)
    {
        var items = await _api.FetchUsersAsync(token);
        return new SelectOptionsLoadResult { Data = items };
    }
}

无需手写取消 / 超时 / 线程调度,控件内部用 coordinator 处理好。

5.2 AutoComplete 开启 debounce

<atom:AutoComplete OptionsAsyncLoader="{Binding Loader}"
                   AsyncLoadDebounce="0:0:0.3"
                   AsyncLoadTimeout="0:0:8" />

用户每次敲键都会重置 debounce 计时器;停止敲键 300ms 后才真正发请求。被后续请求覆盖的旧请求返回 Skipped,不触发 OptionsLoaded 事件。

5.3 处理加载失败

mycascader.ItemAsyncLoaded += (sender, args) =>
{
    if (!args.Result?.IsSuccess == true)
    {
        switch (args.Result?.StatusCode)
        {
            case RpcStatusCode.Timeout:
                ShowToast("加载超时,请重试");
                break;
            case RpcStatusCode.NetworkFailure:
                ShowToast("网络异常");
                break;
            default:
                ShowToast($"加载失败: {args.Result?.UserFriendlyMessage}");
                break;
        }
    }
};

timeout / cancel / faulted 都会触发事件,便于统一处理用户提示。

5.4 TreeView 展开时加载子节点

mytree.DataLoader = new MyNodeLoader();
mytree.AsyncLoadTimeout = TimeSpan.FromSeconds(30);  // 默认值
public sealed class MyNodeLoader : ITreeItemNodeLoader
{
    public async Task<TreeItemLoadResult> LoadAsync(ITreeItemNode parent, CancellationToken token)
    {
        var children = await _api.FetchChildrenAsync(parent.Id, token);
        return new TreeItemLoadResult
        {
            Data      = children,
            StatusCode = RpcStatusCode.Success
        };
    }
}

同一父节点即使被快速连续点开两次,也只会触发一次实际请求(第二次 await 第一次的 Task)。首次成功后 item.AsyncLoaded = true,后续不再触发请求。

6. 新控件接入指引

如果你在开发新控件,需要异步数据加载,按场景选一个协调器即可。

6.1 搜索类控件模板

using AtomUI.Controls.AsyncLoad;

public class MySearchControl : TemplatedControl
{
    public static readonly StyledProperty<TimeSpan> AsyncLoadDebounceProperty =
        AvaloniaProperty.Register<MySearchControl, TimeSpan>(
            nameof(AsyncLoadDebounce),
            TimeSpan.Zero,
            validate: v => v.TotalMilliseconds >= 0);

    public static readonly StyledProperty<TimeSpan> AsyncLoadTimeoutProperty =
        AvaloniaProperty.Register<MySearchControl, TimeSpan>(
            nameof(AsyncLoadTimeout),
            TimeSpan.FromSeconds(10));

    public static readonly StyledProperty<IMyAsyncLoader?> LoaderProperty =
        AvaloniaProperty.Register<MySearchControl, IMyAsyncLoader?>(nameof(Loader));

    public TimeSpan AsyncLoadDebounce { ... }
    public TimeSpan AsyncLoadTimeout  { ... }
    public IMyAsyncLoader? Loader     { ... }

    public event EventHandler<MyLoadedEventArgs>? DataLoaded;

    private readonly AsyncSearchLoadCoordinator<string?, MyLoadResult> _coordinator = new();

    private async Task LoadAsync(string? query)
    {
        if (Loader == null) return;

        _coordinator.DebounceInterval = AsyncLoadDebounce;
        _coordinator.Timeout          = AsyncLoadTimeout;

        IsLoading = true;
        var outcome = await _coordinator.LoadAsync(
            query,
            (ctx, token) => Loader.LoadAsync(ctx, token));

        if (outcome.IsSkipped) return;  // 被后续请求覆盖
        IsLoading = false;

        if (outcome.IsSuccess && outcome.Result != null)
        {
            ApplyResult(outcome.Result);
            DataLoaded?.Invoke(this, new MyLoadedEventArgs(query, outcome.Result));
        }
        else
        {
            var statusCode = outcome.Status switch
            {
                AsyncLoadStatus.TimedOut  => RpcStatusCode.Timeout,
                AsyncLoadStatus.Cancelled => RpcStatusCode.Cancelled,
                _                         => RpcStatusCode.Unknown
            };
            DataLoaded?.Invoke(this, new MyLoadedEventArgs(query, new MyLoadResult
            {
                StatusCode          = statusCode,
                UserFriendlyMessage = outcome.Error?.Message
            }));
        }
    }
}

要点:

  1. _coordinator 作为实例字段,整个控件生命周期共用一个
  2. 每次调用 LoadAsync 前把 DebounceInterval / Timeout 从控件属性同步过去
  3. outcome.IsSkipped 直接 return,不清 IsLoading(因为新请求接管了),不 fire 事件
  4. 所有非成功终态统一转成 StatusCode fire 事件,调用方不需要分辨是 coordinator 内部哪一步失败

6.2 展开类控件模板

using AtomUI.Controls.AsyncLoad;

public class MyTreeControl : TemplatedControl
{
    public static readonly StyledProperty<TimeSpan> AsyncLoadTimeoutProperty =
        AvaloniaProperty.Register<MyTreeControl, TimeSpan>(
            nameof(AsyncLoadTimeout),
            TimeSpan.FromSeconds(30));

    public static readonly StyledProperty<IMyNodeLoader?> NodeLoaderProperty =
        AvaloniaProperty.Register<MyTreeControl, IMyNodeLoader?>(nameof(NodeLoader));

    public TimeSpan AsyncLoadTimeout  { ... }
    public IMyNodeLoader? NodeLoader  { ... }

    public event EventHandler<MyNodeLoadedEventArgs>? NodeLoaded;

    private readonly AsyncExpandLoadCoordinator<IMyNode, MyLoadResult> _coordinator
        = new(ReferenceEqualityComparer<IMyNode>.Instance);

    private async Task LoadNodeAsync(MyTreeItem item, IMyNode node)
    {
        if (NodeLoader == null) return;

        _coordinator.Timeout = AsyncLoadTimeout;
        item.IsLoading       = true;

        var outcome = await _coordinator.LoadOrJoinAsync(
            node,
            (ctx, token) => NodeLoader.LoadAsync(ctx, token));

        await Dispatcher.InvokeAsync(() =>
        {
            item.IsLoading = false;

            if (outcome.IsSuccess && outcome.Result != null)
            {
                item.AsyncLoaded = true;
                ApplyChildren(node, outcome.Result);
                NodeLoaded?.Invoke(this, new MyNodeLoadedEventArgs(item, outcome.Result));
                return;
            }

            var statusCode = outcome.Status switch
            {
                AsyncLoadStatus.TimedOut  => RpcStatusCode.Timeout,
                AsyncLoadStatus.Cancelled => RpcStatusCode.Cancelled,
                _                         => RpcStatusCode.Unknown
            };
            NodeLoaded?.Invoke(this, new MyNodeLoadedEventArgs(item, new MyLoadResult
            {
                StatusCode          = statusCode,
                UserFriendlyMessage = outcome.Error?.Message
            }));
        });
    }

    protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
    {
        base.OnDetachedFromVisualTree(e);
        _coordinator.CancelAll();
    }
}

要点:

  1. 构造时显式传入 ReferenceEqualityComparer<T>.Instance,按引用判等而不是按 Equals —— 避免不同实例但 Equals 相等的节点被错误去重
  2. outcome 处理在 Dispatcher.InvokeAsync 里统一走 UI 线程,避免子节点集合在非 UI 线程被写
  3. OnDetachedFromVisualTree 时调用 CancelAll(),所有在飞请求立即取消
  4. 不写"取消前一次"逻辑 —— 展开类每个节点独立,多节点同时展开是正常场景

6.3 Loader 接口规范

不管搜索类还是展开类,loader 接口都长这样:

public interface IMyAsyncLoader
{
    Task<MyLoadResult> LoadAsync(TContext context, CancellationToken token);
}
  • 永远返回非空 Result;用 StatusCode 表达失败,而不是抛异常或返回 null
  • 尊重 CancellationToken:长操作中要 token.ThrowIfCancellationRequested() 或把 token 传给下游(HttpClient、DB 查询等)
  • 不要自己捕获 OperationCanceledException:让它抛出去,coordinator 会识别是 timeout 还是外部取消

7. 常见问题

Q: Debounce 和 Timeout 的关系?

Timeout 覆盖的是"从 LoadAsync 调用开始到拿到结果"的总时间(含 debounce 等待)。比如 DebounceInterval=300ms、Timeout=5s,那 loader 实际最多跑 5s - 300ms = 4.7s。

Q: 超时的请求会继续在后台跑吗?

不会。coordinator 通过 linked CTS 把 token 传给 loader;CancelAfter(Timeout) 触发时 token 进入 cancelled 状态,loader 里的 HttpClient / DB 操作应该立即响应取消。

Q: 展开类为什么不按 Equals 去重?

Equals 可能把不同实例视作相等(比如两个从 API 返回的值对象)。按引用去重能保证"同一个节点对象"才复用 Task,这是展开语义下最安全的默认。如果你的节点有稳定 Id 且希望按 Id 去重,传入自定义 IEqualityComparer<TContext>。

Q: Skipped 为什么要静默?

搜索类 debounce 场景下,被覆盖的旧请求对用户来说"根本没发生"—— UI 不会看到它的结果,也不应该看到它的超时/错误。如果 fire 事件调用方很可能误更新 UI(比如把旧结果渲染覆盖新结果)。Skipped 是 coordinator 给调用方的"安全返回"信号,直接 return 即可。

Q: Loader 抛异常会怎样?

coordinator 捕获任何 Exception(除了 OperationCanceledException)转成 Faulted 状态,outcome.Error 保留原始异常。调用方应当把 Faulted 也 fire 成事件,让应用层决定怎么显示。

8. 参考实现

源码位置(便于按需对照):

  • 核心契约:src/AtomUI.Controls.Shared/AsyncLoad/
    • AsyncLoadStatus.cs / AsyncLoadOutcome.cs
    • AsyncSearchLoadCoordinator.cs / AsyncExpandLoadCoordinator.cs
    • ReferenceEqualityComparer.cs
  • 搜索类参考:
    • src/AtomUI.Desktop.Controls/AutoComplete/AbstractAutoComplete.cs(debounce + timeout)
    • src/AtomUI.Desktop.Controls/Select/Select.AsyncOptionsLoad.cs(只 timeout,无 debounce)
  • 展开类参考:
    • src/AtomUI.Desktop.Controls/Cascader/CascaderView.AsyncItemDataLoad.cs
    • src/AtomUI.Desktop.Controls/TreeView/TreeView.AsyncItemDataLoad.cs