顯示具有 SynchronizationContext 標籤的文章。 顯示所有文章
顯示具有 SynchronizationContext 標籤的文章。 顯示所有文章

2022年7月25日 星期一

C# 非同步 : 在進行 WPF / Windows Forms / Xamarin.Forms 專案開發,可以回到 UI 執行緒來更新 UI 控制項的相同作法

 

在進行 WPF / Windows Forms / Xamarin.Forms 專案開發,可以回到 UI 執行緒來更新 UI 控制項的相同作法

在以往,若再不同 GUI 開發框架下,想要在非同步的多執行緒程式碼下,更新 UI 控制項的屬性,若當前的執行緒不是所謂的 主執行緒 或者 稱之為 UI 執行緒,將會得到 System.InvalidOperationException: '呼叫執行緒無法存取此物件,因為此物件屬於另一個執行緒。' 這樣的例外異常訊息,所以,大家第一時間想到的就是每個開發框架都會有提供這樣相對應的程式碼,可以指定一段委派方法,讓這些程式碼在 UI 執行緒下來執行,而不是在多執行緒下的某個執行緒來執行,例如:

  • Windows Forms

    這裡將會再多執行緒程式碼內,使用 Control.Invoke 方法 來做到,這個方法可以傳入一個委派方法,而該委派方法的程式碼將會在 主執行緒 或者 UI 執行緒 下來執行

  • WPF Windows Presentation Foundation

    在這個開發框架下,將會使用 Dispatcher.Invoke 方法 來做到,同樣的,這個方法可以傳入一個委派方法,而該委派方法的程式碼將會在 主執行緒 或者 UI 執行緒 下來執行

而是否有個簡單與通用的設計做法,不論在哪個開發框架下,都可以使用相同的程式設計代碼,指定一個委派方法,讓這個委派方法可以在 UI 執行緒下來執行。

建立一個 WPF 的專案

為了要說明上面描述的內容,首先,建立一個 WPF 專案

在這個 WPF 專案內找到 [MainWindow.xaml] 檔案,打開這個檔案,使用底下的 XAML 來替換

<Window x:Class="WpfApp2.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WpfApp2"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Grid>
        <TextBlock x:Name="textBlock" Text="Start"
                   FontSize="30" />
        <Button x:Name="btn" Content="OK" Width="200" Height="70"
                Click="btn_Click"
                />
    </Grid>
</Window>

在這個 XAML 檔案內,將會宣告兩個 UI 控制項,一個是 [TextBlock] ,一個是 [Button] 。

在這裡的設計情境將會是,當使用者按下這個 OK 按鈕之後,便會開始執行一個非同步的作業,在這個非同步的多執行緒程式碼中,將會建立一個迴圈,反覆執行 10 次,每次的迴圈,將會把當前的迴圈索引值,指定到 [textBlock] 這個 UI 控制項內,如此,便可以從螢幕上看到當前的迴圈索引值是多少。

在這裡將會分別採用 ThreadPool 執行緒集區 與 Task 工作來實作這個非同步之多執行緒作業,並且比較兩者的差異。

使用工作物件 Task.Run 來建立一個非同步作業

切換到 [MainWindow.xaml] 的 Code Behind 程式碼編輯視窗內,將會看到下面的程式碼

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;

namespace WpfApp2
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
        }

        private void btn_Click(object sender, RoutedEventArgs e)
        {
        }
    }
}

現在,在 btn_Click 按鈕事件常式內,使用 Task.Run 來建立一個非同步工作物件,做到上面所提到的一個迴圈與更新迴圈索引值到畫面上的需求

將底下的程式碼加入到 btn_Click 事件內,雖然這裡使用了 await Task.Delay(1000); 這樣的敘述,但不影響整個實驗結果,當然,可以改寫成為封鎖 Block 等待 Task.Delay(1000).Wait(); 敘述。

private void btn_Click(object sender, RoutedEventArgs e)
{
    var task = Task.Run(async () =>
    {
        for (int i = 0; i < 10; i++)
        {
            await Task.Delay(1000);
            textBlock.Text = i.ToString();
        }
    });
}

現在,可以執行這個 WPF 專案,一旦應用程式啟動成功後,點選視窗中的 OK 按鈕

沒意外的話,應用程式沒有當掉,並且螢幕也沒有任何變化,為什麼會是這樣的情況呢?

若讀者對於 非同步工作 Task 這個類別夠孰悉的話,將會知道,這個工作物件在非同執行所傳入的委派方法的時候,若該委派方法產生了例外異常,這個工作執行個體將會蒐集當時的例外異常資訊,並且不會讓整個應用程式造成崩潰,這也就是為什麼會有這樣的執行結果了。

若你對於 非同步工作 Task 不太孰悉,建議可以來參加我開設的 精準解析 .NET Task 工作 課程

現在,將 btn_Click 這個事件委派方法內,使用 執行緒 集區來建立一個非同步多執行緒作業,看看剛剛用於 Task 物件內的相同委派方法程式碼,在執行緒下會有甚麼結果呢?

請將底下程式碼替換掉 btn_Click 事件內的程式碼

private void btn_Click(object sender, RoutedEventArgs e)
{
    ThreadPool.QueueUserWorkItem(async _ =>
    {
        for (int i = 0; i < 10; i++)
        {
            await Task.Delay(1000);
            textBlock.Text = i.ToString();
        }
    });
}

讓我們來執行這個專案,並且按下 OK 按鈕,看看會有甚麼情況發生。

很不幸的,當按下 OK 按鈕的之後,這個應用程式崩潰了,Visual Studio 2022 出現底下的畫面

Visual Studio 2022

例外異常的訊息文字為

System.InvalidOperationException: '呼叫執行緒無法存取此物件,因為此物件屬於另一個執行緒。'

從這裡的錯誤訊息可以得知,在 GUI 應用程式下,例如 Windows Forms , WPF , Xamarin.Forms 開發框架下, 想要修改 UI 控制項的屬性,該段程式碼必須要在 UI 執行緒下來執行,否則,就會造成上面的錯誤與得到上面的錯誤訊息。

為了要解決這個問題,在這裡將會採用一種做法,這種作法將會適用於不同 UI 開發框架下,皆可使用。

這個方法就是採用 同步內容 SynchronizationContext ,請將底下程式碼替換掉 btn_Click 方法內的程式碼

private void btn_Click(object sender, RoutedEventArgs e)
{
    #region 記錄下當前的 SynchronizationContext
    SynchronizationContext context = SynchronizationContext.Current;
    #endregion
    ThreadPool.QueueUserWorkItem(async _ =>
    {
        for (int i = 0; i < 10; i++)
        {
            await Task.Delay(1000);
            #region 透過同步內容來更新 UI 屬性
            context.Post(_ =>
            {
               textBlock.Text = i.ToString();
            }, null);
            #endregion
        }
    });
}

上面的程式碼會在使用者點選 OK 按鈕之後開始執行,首先,使用 SynchronizationContext.Current 取得當前的 SynchronizationContext 物件,從微軟文件上得知,這個類別提供在各種同步處理模式中散佈同步處理內容的基本功能,而這個物件內有個 SynchronizationContext.Post(SendOrPostCallback, Object) 方法 ,其會將非同步訊息分派至同步處理內容。

了解完成 SynchronizationContext 這個物件用法,可以來執行這個專案,將會看到底下的正確無誤的執行過程。

因此,若想要使用單一做法,在不同 UI 開發框架下做到非同步多執行緒程式碼,可以順利更新 UI 控制項內容,可以使用 SynchronizationContext 來嘗試看看。





2021年6月6日 星期日

Blazor SfGrid SfDataManager 的參數 Parameter 傳遞的異常設計分析 2

 

Blazor SfGrid SfDataManager 的參數 Parameter 傳遞的異常設計分析 2

現在終於可以來嘗試解決使用 Syncfusion SfGrid 元件可以使用 SfDataManager 來指定客製化轉接器,取得 Grid 元件所要顯示的紀錄,卻會有問題的情況。

這篇文章接續 Blazor SfGrid SfDataManager 的參數 Parameter 傳遞的異常設計分析 1

這個說明專案的原始碼位於 bzSfGridRefresh

使用各種方式,讓 Refresh 方法,取得最新的參數變更內容

打開 [Pages] 資料夾內的 Index.razor 檔案

確定該 Blazor 元件如底下內容

@page "/"

<h1>Hello, world!</h1>

@*<Index1View />*@
<Index2View />
@*<Index3View />*@

<SurveyPrompt Title="How is Blazor working for you?" />

在這裡將會使用自行設計的一個 Blazor 元件,這個 Index2View.razor 元件的程式碼如下

@using Syncfusion.Blazor.Grids;
@using Syncfusion.Blazor.Data;
@using Syncfusion.Blazor;

<div>
    <button class="btn btn-primary" @onclick="()=>OnSingleObjectClick(1)">Send A</button>
    <button class="btn btn-primary" @onclick="()=>OnSingleObjectClick(2)">Send B</button>
    <button class="btn btn-primary" @onclick="()=>OnSingleObjectClick(3)">Send C</button>
</div>
<div>
    <button class="btn btn-success" @onclick="()=>OnClassClick(1)">Send 1</button>
    <button class="btn btn-success" @onclick="()=>OnClassClick(2)">Send 2</button>
    <button class="btn btn-success" @onclick="()=>OnClassClick(2)">Send 3</button>
</div>
<div>
    <button class="btn btn-secondary" @onclick="()=>OnClassClick(3)">Send String 1</button>
    <button class="btn btn-secondary" @onclick="()=>OnClassStringClick(1)">Send String 2</button>
    <button class="btn btn-secondary" @onclick="()=>OnClassStringClick(2)">Send String 3</button>
</div>

<SfGrid @ref="Grid" TValue="Order" AllowPaging="true">
    <SfDataManager Adaptor="Adaptors.CustomAdaptor">
        <CustomAdaptorComponent CurrentTypeCondition="@CurrentTypeCondition"
                                FilterClass="@FilterObject" />
    </SfDataManager>
    <GridPageSettings PageSize="12" />
    <GridColumns>
        <GridColumn Field=@nameof(Order.OrderID) HeaderText="Order ID" IsIdentity="true" IsPrimaryKey="true" TextAlign="TextAlign.Right" Width="120">
        </GridColumn>
        <GridColumn Field=@nameof(Order.CustomerID) HeaderText="Customer Name" Width="150"></GridColumn>
    </GridColumns>
</SfGrid>

@code{
    SfGrid<Order> Grid { get; set; }
    public static List<Order> Orders { get; set; }
    public string CurrentTypeCondition { get; set; }
    public FilterClass FilterObject { get; set; } = new FilterClass();

    async Task OnSingleObjectClick(int i)
    {
        Console.WriteLine();
        Console.WriteLine();
        OutputHelper.Output("CurrentTypeCondition 準備要變更了");

        #region 使用同步方式來進行變更 CurrentTypeCondition
        if (i == 1)
        {
            CurrentTypeCondition = "A";
        }
        else if (i == 2)
        {
            CurrentTypeCondition = "B";
        }
        else if (i == 3)
        {
            CurrentTypeCondition = "C";
        }
        OutputHelper.Output("CurrentTypeCondition 已經變更了");
        #endregion

        #region 狀況 1 : 沒做任何事情
        OutputHelper.Output("沒做任何事情");
        #endregion

        #region 狀況 2 : 使用 Task.Yield()
        //OutputHelper.Output("Raise Task.Yield");
        //await Task.Yield();
        #endregion

        #region 狀況 3 : 使用 Task.Delay(100)
        //OutputHelper.Output("Raise Task.Delay");
        //await Task.Delay(100);
        #endregion

        #region 狀況 4 : 使用 StateHasChanged
        OutputHelper.Output("Raise StateHasChanged");
        //StateHasChanged();
        #endregion

        OutputHelper.Output("Raise Grid.Refresh");
        Grid.Refresh();
    }

    async Task OnClassClick(int i)
    {
        Console.WriteLine();
        Console.WriteLine();
        OutputHelper.Output("FilterObject.Id 準備要變更了");

        #region 使用同步方式來進行變更 FilterObject.Id
        FilterObject.Id = i;
        OutputHelper.Output("FilterObject.Id 已經變更了");
        #endregion

        #region 狀況 1 : 沒做任何事情
        OutputHelper.Output("沒做任何事情");
        #endregion

        #region 狀況 2 : 使用 Task.Yield()
        //OutputHelper.Output("Raise Task.Yield");
        //await Task.Yield();
        #endregion

        #region 狀況 3 : 使用 Task.Delay(100)
        //OutputHelper.Output("Raise Task.Delay");
        //await Task.Delay(100);
        #endregion

        #region 狀況 4 : 使用 StateHasChanged
        //OutputHelper.Output("Raise StateHasChanged");
        //StateHasChanged();
        #endregion

        OutputHelper.Output("Raise Grid.Refresh");
        Grid.Refresh();
    }

    async Task OnClassStringClick(int i)
    {
        Console.WriteLine();
        Console.WriteLine();
        OutputHelper.Output("FilterObject.Title 準備要變更了");

        #region 使用同步方式來進行變更 FilterObject.Title
        FilterObject.Title = i.ToString();
        OutputHelper.Output("FilterObject.Title 已經變更了");
        #endregion


        #region 狀況 1 : 沒做任何事情
        OutputHelper.Output("沒做任何事情");
        #endregion

        #region 狀況 2 : 使用 Task.Yield()
        //OutputHelper.Output("Raise Task.Yield");
        //await Task.Yield();
        #endregion

        #region 狀況 3 : 使用 Task.Delay(100)
        //OutputHelper.Output("Raise Task.Delay");
        //await Task.Delay(100);
        #endregion

        #region 狀況 4 : 使用 StateHasChanged
        //OutputHelper.Output("Raise StateHasChanged");
        //StateHasChanged();
        #endregion

        OutputHelper.Output("Raise Grid.Refresh");
        Grid.Refresh();
    }
}

在這個元件內,將會透過 CustomAdaptorComponent 這個轉接器元件來取得集合物件紀錄,其中這個元件 CustomAdaptorComponent 程式碼如下:

@using Syncfusion.Blazor;
@using Syncfusion.Blazor.Data;

@inherits DataAdaptor<Order>

<CascadingValue Value="@this">
    @ChildContent
</CascadingValue>

@code {
    [Parameter]
    public RenderFragment ChildContent { get; set; }

    [Parameter]
    public FilterClass FilterClass { get; set; }

    private string myVar;

    [Parameter]
    public string CurrentTypeCondition
    {
        get { return myVar; }
        set
        {
            myVar = value;
            OutputHelper.Output($"CustomAdaptorComponent CurrentTypeCondition has changed {myVar}");
        }
    }

    public override async Task<object> ReadAsync(DataManagerRequest dataManagerRequest, string key = null)
    {

        #region 發出查詢要求
        List<Order> orders = new List<Order>();
        List<Order> resut = new List<Order>();
        for (int i = 0; i < 3000; i++)
        {
            orders.Add(new Order() { OrderID = i, CustomerID = $"客戶{i}" });
        }

        #region 使用 CurrentTypeCondition
        if (CurrentTypeCondition == "A")
        {
            resut = orders.Where(x => x.OrderID % 7 == 0).ToList();
        }
        else if (CurrentTypeCondition == "B")
        {
            resut = orders.Where(x => x.OrderID % 11 == 0).ToList();
        }
        else if (CurrentTypeCondition == "C")
        {
            resut = orders.Where(x => x.OrderID % 29 == 0).ToList();
        }
        else
        {
            resut = orders;
        }
        #endregion

        #region 使用 FilterClass.Id
        //if (FilterClass.Id == 1)
        //{
        //    resut = orders.Where(x => x.OrderID % 7 == 0).ToList();
        //}
        //else if (FilterClass.Id == 2)
        //{
        //    resut = orders.Where(x => x.OrderID % 11 == 0).ToList();
        //}
        //else if (FilterClass.Id == 3)
        //{
        //    resut = orders.Where(x => x.OrderID % 29 == 0).ToList();
        //}
        //else
        //{
        //    resut = orders;
        //}
        #endregion

        #region 使用 FilterClass.Title
        //if (FilterClass.Title == "1")
        //{
        //    resut = orders.Where(x => x.OrderID % 7 == 0).ToList();
        //}
        //else if (FilterClass.Title == "2")
        //{
        //    resut = orders.Where(x => x.OrderID % 11 == 0).ToList();
        //}
        //else if (FilterClass.Title == "3")
        //{
        //    resut = orders.Where(x => x.OrderID % 29 == 0).ToList();
        //}
        //else
        //{
        //    resut = orders;
        //}
        #endregion

        var myresult = resut.Take(12);
        var item = dataManagerRequest.RequiresCounts
            ? new DataResult() { Result = myresult, Count = resut.Count }
            : (object)orders;
        await Task.Yield();
        return item;
        #endregion
    }
}

在這裡有許多 region C# 程式碼,這裡將會針對不同情況來進行測試。

狀況 1 : 沒做任何事情

這裡有7種模擬狀況,請先將其他 #region 狀況內的程式碼註解起來,將 狀況 1 內的程式碼解除註解

        #region 狀況 1 : 沒做任何事情
        OutputHelper.Output("沒做任何事情");
        #endregion

現在,執行這個專案,執行結果如下圖

接著點選 [Send A] 按鈕,從 Console 視窗內將會看到底下的輸出內容

CurrentTypeCondition 準備要變更了 (Thread:6) [SC:Yes]
CurrentTypeCondition 已經變更了 (Thread:6) [SC:Yes]
沒做任何事情 (Thread:6) [SC:Yes]
Raise Grid.Refresh (Thread:6) [SC:Yes]
CustomAdaptorComponent CurrentTypeCondition has changed  (Thread:6) [SC:Yes]
CustomAdaptorComponent CurrentTypeCondition has changed A (Thread:6) [SC:Yes]
CustomAdaptorComponent CurrentTypeCondition has changed A (Thread:17) [SC:Yes]
CustomAdaptorComponent CurrentTypeCondition has changed A (Thread:17) [SC:Yes]

在 Console 輸出內容可以看出, CurrentTypeCondition 變數值變動之後,再呼叫 Grid.Refesh() 方法之前,這個要傳遞到元件內的參數,是沒有產生,因為可以看到 Raise Grid.Refresh (Thread:6) [SC:Yes] 訊息出現之後,可以看到 CustomAdaptorComponent CurrentTypeCondition has changed A (Thread:6) [SC:Yes] 出現在 Console 視窗內。

可以看看瀏覽器顯示的內容,原則上,當按下 [Send A] 這個按鈕之後, 僅會顯示 OrderId 有 7 的質數的紀錄才會顯示出來,不過,卻沒有看到任何變化,這是因為當呼救 Grid.Refresh 這個方法的時候,所傳遞過去的參數沒有啟用作用。

現在,請再點選一次 [Send A] 這個按鈕

從瀏覽器畫面可以看出,現在的 Order ID 就僅會出現具有 7 這個質數的訂單紀錄

CurrentTypeCondition 準備要變更了 (Thread:6) [SC:Yes]
CurrentTypeCondition 已經變更了 (Thread:6) [SC:Yes]
沒做任何事情 (Thread:6) [SC:Yes]
Raise Grid.Refresh (Thread:6) [SC:Yes]
CustomAdaptorComponent CurrentTypeCondition has changed A (Thread:6) [SC:Yes]
CustomAdaptorComponent CurrentTypeCondition has changed A (Thread:6) [SC:Yes]
CustomAdaptorComponent CurrentTypeCondition has changed A (Thread:10) [SC:Yes]
CustomAdaptorComponent CurrentTypeCondition has changed A (Thread:10) [SC:Yes]

再度觀察 Console 視窗的輸出內容,其實已經明白了,因為在第一次按下按鈕之後,當該按鈕委派事件方法執行完畢之後,在同步內容訊息佇列內還有一個委派方法,當這個方法執行完畢之後,就已經將變更的物件,傳遞到元件內的參數變數內。

而第二次按下同一個按鈕之後,就直接使用這個內容來顯示到瀏覽器上。

可是,當點選 Send B 按鈕,應該要出現 Order ID 具有 11 的質數紀錄,這些紀錄要顯示在網頁上,可是卻沒有,然而,再次點選一次,就會看到預期的內容,如下圖

狀況 2 : 使用 Task.Yield()

這裡有7種模擬狀況,請先將其他 #region 狀況內的程式碼註解起來,將 狀況 2 內的程式碼解除註解

        #region 狀況 2 : 使用 Task.Yield()
        OutputHelper.Output("Raise Task.Yield");
        await Task.Yield();
        #endregion

現在,執行這個專案,執行結果如下圖

接著點選 [Send A] 按鈕,從 Console 視窗內將會看到底下的輸出內容

CurrentTypeCondition 準備要變更了 (Thread:16) [SC:Yes]
CurrentTypeCondition 已經變更了 (Thread:16) [SC:Yes]
Raise Task.Yield (Thread:16) [SC:Yes]
CustomAdaptorComponent CurrentTypeCondition has changed  (Thread:16) [SC:Yes]
CustomAdaptorComponent CurrentTypeCondition has changed A (Thread:16) [SC:Yes]
Raise Grid.Refresh (Thread:11) [SC:Yes]
CustomAdaptorComponent CurrentTypeCondition has changed A (Thread:11) [SC:Yes]
CustomAdaptorComponent CurrentTypeCondition has changed A (Thread:11) [SC:Yes]
CustomAdaptorComponent CurrentTypeCondition has changed A (Thread:11) [SC:Yes]
CustomAdaptorComponent CurrentTypeCondition has changed A (Thread:11) [SC:Yes]

在 Console 輸出內容可以看出, CurrentTypeCondition 變數值變動之後,再呼叫 Grid.Refesh() 方法之前,有看到這個訊息 CustomAdaptorComponent CurrentTypeCondition has changed A (Thread:16) [SC:Yes] ,這表示了元件內的參數綁定已經生效了,此時可以從網頁上看到,這裡出現了正確且與其的結果。