在API调用时显示等待或旋转图标

44
在我的 Blazor 应用程序中,我正在向后端服务器进行 API 调用,这可能需要一些时间。 我需要向用户显示反馈,等待光标或“旋转器”图像。 在 Blazor 中如何实现这一点? 我尝试使用 CSS 并打开和关闭 CSS,但页面直到调用完成才会刷新。 任何建议将不胜感激。
@functions {
    UserModel userModel = new UserModel();
    Response response = new Response();
    string errorCss = "errorOff";
    string cursorCSS = "cursorSpinOff";

    protected void Submit()
    {
        //Show Sending...
        cursorCSS = "";
        this.StateHasChanged();
        response = Service.Post(userModel);
        if (response.Errors.Any())
        {
            errorCss = "errorOn";
        }
        //turn sending off
        cursorCSS = "cursorSpinOff";
        this.StateHasChanged();
    }
}
10个回答

72

选项1:使用Task.Delay(1)

  • 使用异步方法。
  • 使用await Task.Delay(1)await Task.Yield();来刷新更改。
private async Task AsyncLongFunc()    // this is an async task
{
    spinning=true;
    await Task.Delay(1);      // flushing changes. The trick!!
    LongFunc();               // non-async code
    currentCount++;
    spinning=false;
    await Task.Delay(1);      // changes are flushed again    
}

选项1是一个简单的解决方案,运行良好但看起来有点巧妙。

选项2:使用Task.Run()(不适用于WebAssembly)

在2020年1月,@Ed Charbeneau发布了BlazorPro.Spinkit项目,将长时间的进程封装到任务中以避免阻塞线程:

确保你的LongOperation()是一个Task,如果不是,将其封装为一个Task并等待它:

async Task AsyncLongOperation()    // this is an async task
{
    spinning=true;
    await Task.Run(()=> LongOperation());  //<--here!
    currentCount++;
    spinning=false;
}

效果

a spinner loading data

旋转器和服务器端预渲染

由于Blazor Server应用程序使用预渲染,因此旋转器将不会出现,要显示旋转器,必须在OnAfterRender中进行长时间操作。

使用OnAfterRenderAsync而不是OnInitializeAsync可以避免延迟的服务器端渲染

    // Don't do this
    //protected override async Task OnInitializedAsync()
    //{
    //    await LongOperation();
    //}

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {            
            await Task.Run(()=> LongOperation());//<--or Task.Delay(0) without Task.Run
            StateHasChanged();
        }
    }

更多示例

想要了解如何编写漂亮的旋转器,可以从开源项目BlazorPro.Spinkit中学习,其中包含了一些巧妙的示例。

更多信息


2
谢谢,谢谢,谢谢。我很高兴找到了这个。 - portia
1
我已经搜索了几个小时,而Task.Run正是我在异步函数中从Entity Framework DBContext返回数据所缺少的。谢谢! - chrisbyte
1
你自己最后的编辑使它成为两个选项更好。我会做一些小调整。 - H H
1
非常好的回答。尤其是在服务器端 Blazor 项目中关于 OnAfterRender 的部分。非常感谢。 - n-develop
1
你不知道我有多高兴找到了这个... - Andreas Forslöw
显示剩余15条评论

11
除了@dani在这里的答案之外,我想指出这里有两个独立的问题,最好将它们分开来解决。
1. 何时调用StateHasChanged() Blazor会在初始化之后以及事件之前和之后(在概念上)调用StateHasChanged()。这意味着通常情况下你不需要调用它,只有当你的方法有几个不同的步骤并且你想在中间更新UI时才需要调用它。这就是使用spinner的情况。
当你使用fire-and-forget(async void)或者改变来自不同的源头,比如定时器或者程序中的另一层的事件时,你需要调用它。
2. 如何确保在调用StateHasChanged()之后更新UI StateHasChanged()本身并不会更新UI,它只是排队一个渲染操作。你可以将其视为设置一个“脏标志”。
Blazor会在渲染引擎再次运行在其线程上时立即更新UI。就像其他任何UI框架一样,所有的UI操作都必须在主线程上完成。但是你的事件也会(最初)在同一个线程上运行,阻塞了渲染器。
为了解决这个问题,请确保您的事件是异步的,通过返回async Task来实现。Blazor完全支持这一点。不要使用async void。只有在不需要异步行为时才使用void
2.1 使用异步操作
当您的方法在StateHasChanged()之后快速等待一个异步I/O操作,那么您就完成了。控制权将返回给渲染引擎,您的UI将会更新。
 statusMessage = "Busy...";
 StateHasChanged();
 response = await SomeLongCodeAsync();  // show Busy
 statusMessage = "Done.";

2.2 插入一个小的异步操作
当你的代码需要大量的CPU资源时,它可能无法快速释放主线程。当你调用一些外部代码时,你并不总是知道它的异步程度。因此,我们有一个常用的技巧:
 statusMessage = "Busy...";
 StateHasChanged();
 await Task.Delay(1);      // flush changes - show Busy
 SomeLongSynchronousCode();
 statusMessage = "Done.";

更合理的版本是使用Task.Yield(),但在WebAssembly上往往会失败。

2.3 使用额外的线程和Task.Run()

当您的事件处理程序需要调用一些非异步的代码,例如CPU密集型工作,并且您正在使用Blazor-Server,您可以使用Task.Run()来注册一个额外的线程:

 statusMessage = "Busy...";
 StateHasChanged();
 await Task.Run( _ => SomeLongSynchronousCode());  // run on other thread
 statusMessage = "Done.";

当你在Blazor-WebAssembly上运行时,它没有任何效果。在浏览器环境中没有“额外的线程”可用。
当你在Blazor-Server上运行时,你应该意识到使用更多的线程可能会损害可扩展性。如果你计划在服务器上运行尽可能多的并发客户端,那么这是一种非最优化的做法。
当你想要进行实验时:
void SomeLongSynchronousCode()
{ 
   Thread.Sleep(3000);
}

Task SomeLongCodeAsync()
{ 
   return Task.Delay(3000);
}

10
很多关于StateHasChanged()的讨论,但是为了回答OP的问题,这里提供了另一种在后端API的HttpClient调用中实现一个通用的spinner的方法。
这段代码来自一个Blazor WebAssembly应用程序...

Program.cs

public static async Task Main(string[] args)
{
    var builder = WebAssemblyHostBuilder.CreateDefault(args);
    builder.RootComponents.Add<App>("#app");
    
    builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
    builder.Services.AddScoped<SpinnerService>();
    builder.Services.AddScoped<SpinnerHandler>();
    builder.Services.AddScoped(s =>
    {
        SpinnerHandler spinHandler = s.GetRequiredService<SpinnerHandler>();
        spinHandler.InnerHandler = new HttpClientHandler();
        NavigationManager navManager = s.GetRequiredService<NavigationManager>();
        return new HttpClient(spinHandler)
        {
            BaseAddress = new Uri(navManager.BaseUri)
        };
    });

    await builder.Build().RunAsync();
}

SpinnerHandler.cs 注意:记得取消注释的人工延迟。如果你在Visual Studio中使用开箱即用的Webassembly模板,请点击“天气预报”来查看旋转器的演示。
public class SpinnerHandler : DelegatingHandler
{
    private readonly SpinnerService _spinnerService;

    public SpinnerHandler(SpinnerService spinnerService)
    {
        _spinnerService = spinnerService;
    }

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        _spinnerService.Show();
        //await Task.Delay(3000); // artificial delay for testing
        var response = await base.SendAsync(request, cancellationToken);
        _spinnerService.Hide();
        return response;
    }
}

SpinnerService.cs

public class SpinnerService
{
    public event Action OnShow;
    public event Action OnHide;

    public void Show()
    {
        OnShow?.Invoke();
    }

    public void Hide()
    {
        OnHide?.Invoke();
    }
}

MainLayout.razor

@inherits LayoutComponentBase

<div class="page">
    <div class="sidebar">
        <NavMenu />
    </div>

    <div class="main">
        <div class="top-row px-4">
            <a href="http://blazor.net" target="_blank" class="ml-md-auto">About</a>
        </div>

        <div class="content px-4">
            @Body
            <Spinner />
        </div>
    </div>
</div>

Spinner.razor 注意:为了增加一些变化,你可以在OnIntialized()方法中生成一个随机数,并在div内使用switch语句来选择一个随机的spinner类型。在这个方法中,每个HttpClient请求,最终用户将观察到一个随机的spinner类型。为了简洁起见,这个示例只包含了一种spinner类型。
@inject SpinnerService SpinnerService

@if (isVisible)
{
    <div class="spinner-container">
        <Spinner_Wave />
    </div>
}

@code
{
    protected bool isVisible { get; set; }

    protected override void OnInitialized()
    {
        SpinnerService.OnShow += ShowSpinner;
        SpinnerService.OnHide += HideSpinner;
    }

    public void ShowSpinner()
    {
        isVisible = true;
        StateHasChanged();
    }

    public void HideSpinner()
    {
        isVisible = false;
        StateHasChanged();
    }

    public void Dispose()
    {
        SpinnerService.OnShow -= ShowSpinner;
        SpinnerService.OnHide -= HideSpinner;
    }
}

Spinner-Wave.razor
Credit to: https://tobiasahlin.com/spinkit/
Note: 这个旋转工具包有一个Nuget包。Nuget包的缺点是您无法直接访问CSS来进行调整。在这里,我调整了旋转器的大小,并将背景颜色设置为与网站的主要颜色相匹配,这对于在整个网站上使用CSS主题(或多个CSS主题)非常有帮助。

@* Credit: https://tobiasahlin.com/spinkit/ *@

<div class="spin-wave">
    <div class="spin-rect spin-rect1"></div>
    <div class="spin-rect spin-rect2"></div>
    <div class="spin-rect spin-rect3"></div>
    <div class="spin-rect spin-rect4"></div>
    <div class="spin-rect spin-rect5"></div>
</div>
<div class="h3 text-center">
    <strong>Loading...</strong>
</div>

<style>
    .spin-wave {
        margin: 10px auto;
        width: 200px;
        height: 160px;
        text-align: center;
        font-size: 10px;
    }

        .spin-wave .spin-rect {
            background-color: var(--primary);
            height: 100%;
            width: 20px;
            display: inline-block;
            -webkit-animation: spin-waveStretchDelay 1.2s infinite ease-in-out;
            animation: spin-waveStretchDelay 1.2s infinite ease-in-out;
        }

        .spin-wave .spin-rect1 {
            -webkit-animation-delay: -1.2s;
            animation-delay: -1.2s;
        }

        .spin-wave .spin-rect2 {
            -webkit-animation-delay: -1.1s;
            animation-delay: -1.1s;
        }

        .spin-wave .spin-rect3 {
            -webkit-animation-delay: -1s;
            animation-delay: -1s;
        }

        .spin-wave .spin-rect4 {
            -webkit-animation-delay: -0.9s;
            animation-delay: -0.9s;
        }

        .spin-wave .spin-rect5 {
            -webkit-animation-delay: -0.8s;
            animation-delay: -0.8s;
        }

    @@-webkit-keyframes spin-waveStretchDelay {
        0%, 40%, 100% {
            -webkit-transform: scaleY(0.4);
            transform: scaleY(0.4);
        }

        20% {
            -webkit-transform: scaleY(1);
            transform: scaleY(1);
        }
    }

    @@keyframes spin-waveStretchDelay {
        0%, 40%, 100% {
            -webkit-transform: scaleY(0.4);
            transform: scaleY(0.4);
        }

        20% {
            -webkit-transform: scaleY(1);
            transform: scaleY(1);
        }
    }
</style>

真美啊

Animation of result


1
太棒了!这几乎就是我要找的,因为我想在现有项目中添加“加载/处理”指示器,但不想修改每个页面/组件以添加“旋转器”代码。唯一的问题似乎是当后端API有多个同时调用且某些调用时间比其他调用长时。使用此代码,只要第一个调用完成,旋转器就会隐藏。我认为我可以稍微调整一下它,以便在隐藏之前“知道”是否仍在处理调用。再次感谢! - Redwing19
@Redwing19,很高兴我能帮到你并感谢你的热情。关于多个并行API请求,这取决于具体情况。我简化了这个答案,以便人们可以根据需要进行自定义。对于我的用途,我通过向SpinnerService添加一个布尔型full-property来解决多个请求问题,以充当在调用Hide()时不关闭旋转器的覆盖。当我完成数据加载时,我的组件调用属性的set方法,该方法将调用Hide(),然后关闭旋转器。但这只是一个例子。如果您想出更通用的东西,请分享。 - Brian
2
谢谢提供的信息!我最终在SpinnerHandler类中添加了一个静态int _callCounter成员变量。在调用SendAsync之前,它会递增,之后再递减。然后,只有当_callCounter为0时才调用_spinnerService.Hide()。这并不完美,如果连续进行多个调用并且第一个调用在第二个调用开始之前完成,它会闪烁一下,但对于并发调用来说效果很好,也满足我的需求。 - Redwing19
@Redwing19 我知道这条评论已经有一段时间了,但你能否分享一下你是如何实现解决方案的公共存储库的要点? - Chris Langston
@Redwing19 我知道这条评论已经有一段时间了,但你能否简要介绍一下你是如何在公共仓库中实现你的解决方案的? - undefined
这里有一篇博客文章,其中包含一个几乎相同的实现和一个存储库 :) https://bipinpaul.com/posts/display-spinner-on-each-api-call-automatically-in-blazor - VinKel

2

不仅适用于API调用,而是适用于每个服务调用:

SpinnerService:

 public class SpinnerService
 {
    public static event Action OnShow;
    public static event Action OnHide;

    public void Show()
    {
        OnShow?.Invoke();
    }

    public void Hide()
    {
        OnHide?.Invoke();
    }
}

旋转组件:

你的旋转器在这里,我使用的是 MudProgressCircular

@if (IsVisible)
{
    <MudProgressCircular Color="Color.Primary"
                         Style="position: absolute;top: 50%;left: 50%;"
                         Indeterminate="true" />
}

@code{
    protected bool IsVisible { get; set; }

    protected override void OnInitialized()
    {
        SpinnerService.OnShow += ShowSpinner;
        SpinnerService.OnHide += HideSpinner;
    }

    public void ShowSpinner()
    {
        IsVisible = true;
        StateHasChanged();
    }

    public void HideSpinner()
    {
        IsVisible = false;
        StateHasChanged();
    }
}

服务调用者:

public class ServiceCaller
{
    private readonly IServiceProvider services;
    private readonly SpinnerService spinnerService;

    public ServiceCaller(IServiceProvider services, SpinnerService spinnerService)
    {
        this.services = services;
        this.spinnerService = spinnerService;
    }

    public async Task<TResult> CallAsync<TService, Task<TResult>>(Func<TService, TResult> method)
        where TService : class
    {
        var service = this.services.GetRequiredService<TService>();

        try
        {
            spinnerService.Show();

            await Task.Delay(500); // ToDo: this line is not necessary

            TResult? serviceCallResult = await Task.Run(() => method(service));

            return serviceCallResult;
        }
        finally
        {
            spinnerService.Hide();
        }
    }

     public async Task CallAsync<TService, TAction>(Func<TService, Action> method)
        where TService : class
    {
        var service = this.services.GetRequiredService<TService>();

        try
        {
            spinnerService.Show();

            await Task.Delay(500); // ToDo: this line is not necessary

            await Task.Run(() => method(service).Invoke());
        }
        finally
        {
            spinnerService.Hide();
        }
    }
}

如何使用它?

@page "/temp"

@inject ServiceCaller serviceCaller;

<h3>Temp Page</h3>

<MudButton OnClick="CallMethodReturnsString">CallMethodReturnsString</MudButton>

<MudButton OnClick="CallVoidMethodAsync">CallVoidMethodAsync</MudButton>

<MudButton OnClick="CallTaskMethodAsync">CallTaskMethodAsync</MudButton>

<MudButton OnClick="CallMany">CallMany</MudButton>


@if (!string.IsNullOrEmpty(tempMessage)){
    @tempMessage
}

@code{
    string tempMessage = string.Empty;

    // call method returns string
    private async Task CallMethodReturnsString()
    {
        await serviceCaller.CallAsync<ITempService, string>(async x => this.tempMessage = await x.RetrieveStringAsync());
    }

    // call void method
    private async Task CallVoidMethodAsync()
    {
        await serviceCaller.CallAsync<ITempService, Task>(x => () => x.MyVoidMethod());
    }

    // call task method
    private async Task CallTaskMethodAsync()
    {
        await serviceCaller.CallAsync<ITempService, Task>(x => () => x.TaskMethod());
    }

    // call many methods
    private async Task CallMany()
    {
        await serviceCaller.CallAsync<ITempService, Action>(x => async () =>
        {
            this.tempMessage = await x.RetrieveStringAsync();
            x.MyVoidMethod();
            x.TaskMethod();
        });
    }
}

2
回答@daniherrera's solution中的通知,这里提出了另外三种更优雅的解决方案here
简而言之:
  • 为模型实现INotifyPropertyChanged并在模型的PropertyChangedEventHandler事件属性上调用StateHasChanged()
  • 使用委托在模型上调用StateHasChanged()
  • EventCallBack<T>参数添加到视图的组件或页面,并将其分配给应更改组件及其父级的函数。StateHasChanged()在此选项中不是必需的)
最后一种选项最简单、灵活和高级,但可以根据您的需要选择。
总的来说,如果您关注应用程序的安全性,我建议使用这些解决方案中提供的其中一种,而不是await Task.Delay(1);
编辑:经过更多阅读,this link提供了一个强有力的解释,介绍如何在C#中处理事件,主要使用EventCallBack

2
总的来说,如果您关心应用程序的安全性,我建议使用这些解决方案中的一个,而不是 await Task.Delay(1)。您是什么意思?这些渲染器机制在安全方面有何不同? - Brett Caswell

1
不要重复我的错误,使用Thread.Sleep(n)测试等待图标。
protected override async Task OnInitializedAsync()
{
    // Thread.Sleep(3000); // By suspending current thread the browser will freeze.
    await Task.Delay(3000); // This is your friend as dani herrera pointed out. 
                      // It creates a new task that completes 
                      // after a specified number of milliseconds.

    forecasts = await ForecastService.GetForecastAsync(DateTime.Now);
}

似乎不起作用。我收到了警告:“因为此调用未被等待,所以在完成调用之前,当前方法的执行将继续进行。请考虑对调用结果应用'await'运算符。” - Rye bread
嗨,我猜你没有等待调用 --> await SomethingToAwait.SomeAsyncMethod() - albin
@rugbrød,警告建议您需要等待“Task.Delay(3000)”,否则延迟不会发生。我编辑了这个例子来修复这个警告。 - Matt

0

使用 InvokeAsync(StateHasChanged),希望它能正常工作。

protected async void Submit()
    {
        //Show Sending...
        cursorCSS = "";
        this.StateHasChanged();
        response = Service.Post(userModel);
        if (response.Errors.Any())
        {
            errorCss = "errorOn";
        }
        //turn sending off
        cursorCSS = "cursorSpinOff";
        await InvokeAsync(StateHasChanged);
    }

不要使用 async void,这里不需要它。 - H H

0
我调用Web API并自动显示和隐藏MudOverlay,而无需为每个API调用编写任何代码,就像这样:
protected override async Task OnInitializedAsync()
{
    users = await Http.GetFromJsonAsync<List<GetAllUsersDto>>("User/GetAll");
}

@if (users is not null) { @* MudDataGrid 或 MudTable *@ }
创建一个ApiService类,其中包含ApiCount属性,用于保存正在执行的API的计数。我选择使用byte数据类型,因为0到255足够了。 public class ApiService { public byte ApiCount { get; set; } = 0;
public event Action ApiCountChanged;
public void IncreaseApiCount() { ApiCount++; ApiCountChanged?.Invoke(); }
public void DecreaseApiCount() { ApiCount--; ApiCountChanged?.Invoke(); }
public bool IsShowOverlay { get { return ApiCount > 0 ? true : false; } set { } } }
将ApiService注入到客户端程序的program.cs中。我通过以下foreach循环来注入所有的Service类:
// 单例AppServices var appServices = typeof(Program).Assembly.GetTypes() .Where(s => s.Name.EndsWith("Service") && s.IsInterface == false).ToList(); foreach (var appService in appServices) builder.Services.Add(new ServiceDescriptor(appService, appService, ServiceLifetime.Singleton));
3. 在 MainLayout.razor.cs 中添加 HandleApiCountChanged 事件处理程序的方法:
protected override async Task OnInitializedAsync()
{
    ApiService.ApiCountChanged += HandleApiCountChanged;
}
private void HandleApiCountChanged()
{
    StateHasChanged();
}
  • 在MainLayout.razor中的标签内添加MudOverlay,其可见性绑定到ApiService.IsShowOverlay布尔方法。

    <MudOverlay ZIndex="9999" @bind-Visible="ApiService.IsShowOverlay" DarkBackground="true" AutoClose="false">

  • 将ApiService注入到HttpStatusCodeService客户端API中间件,并在API调用之前和之后使用ApiService的IncreaseApiCount()和DecreaseApiCount()方法。MainLayout读取IsShowOverlay()方法,在所有应用程序API调用中显示和隐藏MudOverlay。

  • public class HttpStatusCodeService(ISnackbar snackbar, LocalStorageService localStorageService, AuthenticationStateProvider authenticationStateProvider, ApiService apiService) : DelegatingHandler { protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { // 在发送请求之前 apiService.IncreaseApiCount(); // 增加API计数 if (request.RequestUri.AbsolutePath.ToLower().Contains("loginregister")) return await base.SendAsync(request, cancellationToken);

        var token = await localStorageService.GetItem<string>("token");
        if (string.IsNullOrEmpty(token) == false)
            request.Headers.Add("Authorization", $"Bearer {token}");
    
        var response = await base.SendAsync(request, cancellationToken);
    
    
        // after sending the request
        if (response.IsSuccessStatusCode == false)
        {
            if (response.StatusCode == HttpStatusCode.Conflict) // 409
                snackbar.Add(await response.Content.ReadAsStringAsync(), Severity.Warning);
    
            if (response.StatusCode == HttpStatusCode.InternalServerError) // 500
                snackbar.Add("A problem is occured, tell out support team.", Severity.Error);
    
            if (response.StatusCode == HttpStatusCode.Forbidden) // 403
                snackbar.Add("You don't have permission.", Severity.Warning);
    
            if (response.StatusCode == HttpStatusCode.Unauthorized) // 401
                await (authenticationStateProvider as AuthStateProvider).Logout(forceLoad: true);
        }
        apiService.DecreaseApiCount(); // Decrease Api Count
        return response;
    }
    

    }

    我可以在.NET 8中使用主构造函数功能来实现无需私有只读属性和构造函数的注入。要使用它,您需要将"LangVersion>preview/LangVersion>"添加到客户端csproj文件的PropertyGroup中。

    -1
    易于在net7上使用:
    创建模型 SpinnerModel.cs
    ```csharp public class SpinnerModel { public bool IsShow { get; set; } // 添加两个方法来设置属性为 true 和 false } ```
    创建 Razor 组件 Spinner.razor - 添加一些关于等待的信息块 - 将创建的模型作为 [parameter] 添加到 @code 块中 - 将此标记包装在 @if 语句中以检查模型的属性
    在你的某个视图中,添加和初始化spinner-model字段,将spinner组件添加到标记中,并将字段绑定为spinner-component的模型参数。
    然后,你可以在处理程序中将字段的属性设置为true和false(或调用添加的操作来更改绑定的属性),例如,在异步操作之前和之后的按钮点击处理程序中。
    它在"oninitialize"中不起作用,但在另一个自定义处理程序中运行得很好。
    而且你不能使用一些奇怪的代码(例如,将异步操作包装到"Task.Run"中)。
    附注:抱歉,我是用手机写的。

    不需要调用StateHasChanged - Alexey

    -1
    Blazor 服务器端 - 我需要调用 StateHasChanged() 来强制前端更新,以便在代码移动到 ajax 调用之前显示旋转图标。
    /* Show spinner */
    carForm.ShowSpinner = true;
    
    /* Force update of front end */
    StateHasChanged();
    
    /* Start long running API/Db call */
    await _carRepository.Update(item);
    

    在Blazor Server中对我不起作用,我需要dani的选项1。就像StateHasChanged在异步操作期间没有注册一样。 - Steve Greene
    如果在这里考虑调用 StateHasChanged("force" 可能是错误的术语,它实际上表示/标志着“脏”状态),那么你的答案很可能与此问题的情境或关注点无关。你可能需要调用 StateHasChanged,因为 _carRepository.Update 不是此范围内完成的第一个或最后一个任务。参考 多个异步阶段的异步处理程序 - Brett Caswell

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接