WinForms 基於單執行緒 UI 模型 (STA),背景執行緒直接操作控制項會導致例外或未定義行為。本文詳細說明 ISynchronizeInvoke 模式、現代 Task/async-await 配合 SynchronizationContext 的最佳實務,並補充 HTML 格式剪貼板處理技術。
Windows 視窗訊息迴圈綁定在建立視窗的執行緒 (UI 執行緒)。控制項的事件處理、屬性存取、繪圖都必須在該執行緒執行。跨執行緒存取會破壞訊息順序,導致死結、繪圖錯誤或存取違規。
public interface ISynchronizeInvoke
{
object Invoke(Delegate method, object[] args); // 同步封送
IAsyncResult BeginInvoke(Delegate method, object[] args); // 非同步封送
object EndInvoke(IAsyncResult result);
bool InvokeRequired { get; } // 是否需要封送
}
Control 實作此介面,InvokeRequired 判斷呼叫執行緒是否為 UI 執行緒。
// 背景執行緒更新 Label 文字
private void UpdateLabelSafe(string text)
{
if (label1.InvokeRequired)
{
label1.Invoke(new Action(UpdateLabelSafe), text); // 阻塞等待
}
else
{
label1.Text = text; // UI 執行緒直接執行
}
}
private void UpdateLabelAsync(string text)
{
if (label1.InvokeRequired)
{
label1.BeginInvoke(new Action(UpdateLabelAsync), text);
}
else
{
label1.Text = text;
}
}
SynchronizationContext.Current 在 UI 執行緒捕獲為 WindowsFormsSynchronizationContextTaskScheduler.FromCurrentSynchronizationContext() 取得 UI 執行緒排程器await 自動捕獲上下文,續作在原執行緒執行
private async Task LoadDataAsync()
{
// 1. UI 執行緒:顯示載入中
btnLoad.Enabled = false;
lblStatus.Text = "載入中...";
try
{
// 2. 背景執行緒:耗時操作 (不阻塞 UI)
var data = await Task.Run(() => FetchDataFromDatabase());
// 3. 自動回到 UI 執行緒 (因為 await 捕獲了 SynchronizationContext)
dataGridView1.DataSource = data;
lblStatus.Text = $"完成,共 {data.Count} 筆";
}
catch (Exception ex)
{
MessageBox.Show($"錯誤:{ex.Message}");
}
finally
{
btnLoad.Enabled = true;
}
}
private List FetchDataFromDatabase()
{
Thread.Sleep(2000); // 模擬耗時
return new List { new Order { Id = 1, Name = "訂單A" } };
}
private async Task ProcessWithProgressAsync()
{
var progress = new Progress(p =>
{
// 這裡自動在 UI 執行緒執行
progressBar1.Value = p;
lblProgress.Text = $"{p}%";
});
await Task.Run(() =>
{
for (int i = 0; i <= 100; i++)
{
Thread.Sleep(50);
progress.Report(i); // 執行緒安全回報
}
});
MessageBox.Show("處理完成");
}
// 類別庫/非 UI 程式碼:不需要回到 UI 執行緒
public async Task GetDataAsync()
{
var json = await httpClient.GetStringAsync(url).ConfigureAwait(false);
return JsonSerializer.Deserialize(json); // 在執行緒池執行續作
}
剪貼板資料結構:
Version:1.0
StartHTML:0000000105
EndHTML:0000000452
StartFragment:0000000140
EndFragment:0000000417
SourceURL:https://example.com
這是 粗體 文字
/
public static void SetHtmlToClipboard(string htmlFragment, string sourceUrl = null)
{
// 1. 建立完整 HTML
string html = $@"
{htmlFragment}
";
// 2. 計算位移 (UTF-8 位元組數)
byte[] htmlBytes = Encoding.UTF8.GetBytes(html);
byte[] headerBytes = Encoding.UTF8.GetBytes(
"Version:1.0\r\n" +
$"StartHTML:0000000000\r\n" + // 佔位
$"EndHTML:0000000000\r\n" +
$"StartFragment:0000000000\r\n" +
$"EndFragment:0000000000\r\n" +
(sourceUrl != null ? $"SourceURL:{sourceUrl}\r\n" : "") +
"\r\n");
// 3. 計算實際位移
int startHtml = headerBytes.Length;
int startFragment = startHtml + Encoding.UTF8.GetBytes(html.Substring(0, html.IndexOf("") + 20)).Length;
int endFragment = startFragment + Encoding.UTF8.GetBytes(htmlFragment).Length;
int endHtml = headerBytes.Length + htmlBytes.Length;
// 4. 組裝最終資料
string header =
"Version:1.0\r\n" +
$"{startHtml:D10}\r\n" +
$"{endHtml:D10}\r\n" +
$"{startFragment:D10}\r\n" +
$"{endFragment:D10}\r\n" +
(sourceUrl != null ? $"SourceURL:{sourceUrl}\r\n" : "") +
"\r\n";
byte[] finalData = Encoding.UTF8.GetBytes(header + html);
// 5. 寫入剪貼板
var dataObj = new DataObject();
dataObj.SetData(DataFormats.Html, new MemoryStream(finalData));
dataObj.SetData(DataFormats.UnicodeText, StripHtml(htmlFragment)); // 純文字備援
Clipboard.SetDataObject(dataObj, true);
}
private static string StripHtml(string html) =>
System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", "");
public static string GetHtmlFromClipboard()
{
if (!Clipboard.ContainsData(DataFormats.Html)) return null;
var stream = Clipboard.GetData(DataFormats.Html) as MemoryStream;
if (stream == null) return null;
using var reader = new StreamReader(stream, Encoding.UTF8);
string fullData = reader.ReadToEnd();
// 解析 Header 找到 Fragment 範圍
var lines = fullData.Split('\n');
int startFragment = -1, endFragment = -1;
int bodyStart = -1;
for (int i = 0; i < lines.Length; i++)
{
if (lines[i].StartsWith("StartFragment:"))
startFragment = int.Parse(lines[i].Split(':')[1]);
else if (lines[i].StartsWith("EndFragment:"))
endFragment = int.Parse(lines[i].Split(':')[1]);
else if (string.IsNullOrWhiteSpace(lines[i])) // Header 結束
{
bodyStart = i + 1;
break;
}
}
if (startFragment >= 0 && endFragment > startFragment)
{
// 計算 body 在全字串中的位移
int headerLen = fullData.Substring(0, fullData.IndexOf(lines[bodyStart])).Length;
int fragmentStart = startFragment - headerLen;
int fragmentLen = endFragment - startFragment;
return fullData.Substring(headerLen + fragmentStart, fragmentLen);
}
return null;
}
| 場景 | 推薦做法 |
|---|---|
| 簡單 UI 更新 | control.Invoke(Action) 或 BeginInvoke |
| 非同步作業 + UI 回饋 | async/await + Task.Run (自動捕獲 UI Context) |
| 長時間處理 + 進度回報 | IProgress + Progress |
| 類別庫/可重用元件 | ConfigureAwait(false) 避免死結 |
| 複製富文本到 Word/Excel | 同時寫入 CF_HTML 與 UnicodeText |
核心原則:所有 UI 控制項存取必須在 UI 執行緒。現代 .NET 使用 async/await 讓編譯器自動處理執行緒切換,比手動 Invoke 更安全、可讀性更高。