原文 WPF 同一視窗内的多線程/多程序 UI(使用 SetParent 嵌入另一個視窗)
WPF 的 UI 邏輯隻在同一個線程中,這是學習 WPF 開發中大家幾乎都會學習到的經驗。如果希望做不同線程的 UI,大家也會想到使用另一個視窗來實作,讓每個視窗擁有自己的 UI 線程。然而,就不能讓同一個視窗内部使用多個 UI 線程嗎?
閱讀本文将收獲一份 Win32 函數
SetParent
及相關函數的使用方法。
WPF 同一個視窗中跨線程通路 UI 有多種方法:
前者使用的是 WPF 原生方式,做出來的跨線程 UI 可以和原來的 UI 互相重疊遮擋。後者使用的是 Win32 的方式,實際效果非常類似
WindowsFormsHost
,新線程中的 UI 在原來的所有 WPF 控件上面遮擋。另外,後者不止可以是跨線程,還可以跨程序。
本文内容
完成基本功能所需的 Win32 函數是非常少的,隻有
SetParent
和
MoveWindow
。
[DllImport("user32.dll")]
public static extern bool SetParent(IntPtr hWnd, IntPtr hWndNewParent);
[DllImport("user32.dll", SetLastError = true)]
public static extern bool MoveWindow(IntPtr hWnd, int X, int Y, int nWidth, int nHeight, bool bRepaint);
SetParent
用于指定傳統的視窗父子關系。有多傳統呢?呃……就是 Windows 自誕生以來的那種傳統。在傳統的 Win32 應用程式中,每一個控件都有自己的視窗句柄,它們之間通過
SetParent
進行連接配接;可以說一個 Button 就是一個視窗。而我們現在使用
SetParent
其實就是在使用傳統 Win32 程式中的控件的機制。
MoveWindow
用于指定視窗相對于其父級的位置,我們使用這個函數來決定新嵌入的視窗在原來界面中的位置。
啟動一個背景的 WPF UI 線程網上有不少線程的方法,但大體思路是一樣的。我之前在
如何實作一個可以用 await 異步等待的 Awaiter一文中寫了一個利用
async
/
await
做的更進階的版本。
為了繼續本文,我将上文中的核心檔案抽出來做成了 GitHubGist,通路
Custom awaiter with background UI thread下載下傳那三個檔案并放入到自己的項目中。
-
為實作 async/await 機制準備的一些接口,雖然事實上可以不需要,不過加上可以防逗比。AwaiterInterfaces.cs
-
這是我自己實作的自定義 awaiter,可以利用 awaiter 的回調函數機制規避線程同步鎖的使用。DispatcherAsyncOperation.cs
-
用于建立背景 UI 線程的類型,這個檔案包含本文需要使用的核心類,使用到了上面兩個檔案。UIDispatcher.cs
在使用了上面的三個檔案的情況下,建立一個背景 UI 線程并獲得用于執行代碼的
Dispatcher
隻需要一句話:
// 傳入的參數是線程的名稱,也可以不用傳。
var dispatcher = await UIDispatcher.RunNewAsync("Background UI");
在得到了背景 UI 線程 Dispatcher 的情況下,無論做什麼背景線程的 UI 操作,隻需要調用
dispatcher.InvokeAsync
即可。
我們使用下面的句子建立一個背景線程的視窗并顯示出來:
var backgroundWindow = await dispatcher.InvokeAsync(() =>
{
var window = new Window();
window.SourceInitialized += OnSourceInitialized;
window.Show();
return window;
});
在代碼中,我們監聽了
SourceInitialized
事件。這是 WPF 視窗剛剛獲得 Windows 視窗句柄的時機,在此事件中,我們可以最早地拿到視窗句柄以便進行 Win32 函數調用。
private void OnSourceInitialized(object sender, EventArgs e)
{
// 在這裡可以擷取到視窗句柄。
}
為了比較容易寫出嵌入視窗的代碼,我将核心部分代碼貼出來:
class ParentWindow : Window
{
public ParentWindow()
{
InitializeComponent();
Loaded += OnLoaded;
}
private async void OnLoaded(object sender, RoutedEventArgs e)
{
// 擷取父視窗的視窗句柄。
var hwnd = (HwndSource) PresentationSource.FromVisual(this);
_parentHwnd = hwnd;
// 在背景線程建立子視窗。
var dispatcher = await UIDispatcher.RunNewAsync("Background UI");
await dispatcher.InvokeAsync(() =>
{
var window = new Window();
window.SourceInitialized += OnSourceInitialized;
window.Show();
});
}
private void OnSourceInitialized(object sender, EventArgs e)
{
var childHandle = new WindowInteropHelper((Window) sender).Handle;
SetParent(childHandle, _parentHwnd.Handle);
MoveWindow(childHandle, 0, 0, 300, 300, true);
}
private HwndSource _parentHwnd;
[DllImport("user32.dll")]
public static extern bool SetParent(IntPtr hWnd, IntPtr hWndNewParent);
[DllImport("user32.dll", SetLastError = true)]
public static extern bool MoveWindow(IntPtr hWnd, int X, int Y, int nWidth, int nHeight, bool bRepaint);
}
具體執行嵌入視窗的是這一段:
private void OnSourceInitialized(object sender, EventArgs e)
{
var childHandle = new WindowInteropHelper((Window) sender).Handle;
SetParent(childHandle, _parentHwnd.Handle);
MoveWindow(childHandle, 0, 0, 300, 300, true);
}
最終顯示時會将背景線程的子視窗顯示到父視窗的 (0, 0, 300, 300) 的位置和大小。可以試試在主線程寫一個
Thread.Sleep(5000)
,在卡頓的事件内,你依然可以拖動子視窗的标題欄進行拖拽。
當然,如果你認為外面那一圈視窗的非客戶區太醜了,使用普通設定視窗屬性的方法去掉即可:
await dispatcher.InvokeAsync(() =>
{
var window = new Window
{
BorderBrush = Brushes.DodgerBlue,
BorderThickness = new Thickness(8),
Background = Brushes.Teal,
WindowStyle = WindowStyle.None,
ResizeMode = ResizeMode.NoResize,
Content = new TextBlock
{
Text = "walterlv.github.io",
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Foreground = Brushes.White,
FontSize = 24,
}
};
window.SourceInitialized += OnSourceInitialized;
window.Show();
});
本文會經常更新,請閱讀原文:
https://walterlv.com/post/embed-win32-window-using-csharp.html,以避免陳舊錯誤知識的誤導,同時有更好的閱讀體驗。