无模式 WinForm,带来自非托管代码的回调
如何在非托管应用程序中使用无模式 WinForm,包括回调功能
引言
我工作的公司维护的两个主要产品是用“C”(是的,我说的是“C”)和 VB6 分别编写的遗留应用程序。多年来,它们都通过 C++、MFC、COM、ActiveX 以及最近的 .NET 框架进行了大量扩展。该“C”应用程序的 GUI 使用了一套至少有 20 年历史的跨平台工具,这些工具的学习曲线非常陡峭,因此新的 UI 组件的开发尽可能使用 .NET 框架和 WinForms 来完成。
直到最近,我们的库中包含了大量的 .NET 窗体,这些窗体显示为模态对话框。我们需要创建的一个新屏幕的要求是,它应该是无模式的,并且行为像主应用程序窗口的子窗口。这意味着它需要包含在主窗口内,并且如果被最大化,它只会填充窗口的可视部分。作为额外的“福利”,新窗体上的一些按钮需要回调到应用程序中,以执行复杂任务,例如使用现有代码中的方法进行打印或报表生成。
初次尝试
我首先需要一个要显示的窗体。我创建了一个带有 3 个按钮的简单窗体,并将其放入一个名为 ModelessForm
的类库中。

第二个需要的是一个 DLL 中的入口点,供“C”代码调用以显示窗体。我们有一个用于此目的的现有包装器 DLL,它使用 CLR 选项编译,因此它可以包含托管和非托管代码。我添加了一个包含以下代码的文件,以提供我的入口点并显示 ModelessForm
类库中的窗体(ModelessForm::DemoForm
)。
#include "stdafx.h"
#include <windows.h>
using namespace System;
using namespace System::Windows::Forms;
using namespace ModelessForm;
extern "C" __declspec(dllexport) int ShowModeless(HWND parent)
{
DemoForm^ form = gcnew DemoForm();
form->Show( NativeWindow::FromHandle((IntPtr)parent));
return 0;
}
我创建了一个非常简单的 MFC SDI 应用程序,添加了一个显示对话框的菜单项,并在视图类中添加了一个命令处理程序来调用导出的 DLL 函数。
extern "C" __declspec(dllimport) int ShowModeless(HWND parent);
void CChildView::OnWinformShow()
{
ShowModeless( GetSafeHwnd() );
}
太简单了!我运行了应用程序,调用了我的新入口点,我的无模式窗体就出现了。正当我准备庆祝时,我发现我的实现存在一些问题——窗体没有限制在调用应用程序的视图区域内,并且似乎没有处理键盘输入。显然,事情并没有我想象的那么容易。
我在网上搜索了一下,看看我错过了什么,找到了 Elango Ramanathan 的文章“如何使无模式 Winforms 中的 TAB 键生效”,其中提供了一个关于如何实现使用 Win32 函数 IsDialogMessage
的消息钩子的好例子。为了能够处理窗体中的键盘输入,这似乎需要大量的代码,但我需要让它工作,所以我将代码添加到了我的窗体中。我再次运行了测试,当我看到我的窗体确实在处理键盘输入时,我感到非常惊喜。然而,我很快注意到了一些关于 Tab 顺序的奇怪行为——它与在 WinForms 设计器中使用控件的 TabIndex
属性设置的 Tab 顺序不匹配。它是基于控件的 Z 顺序,这在键盘输入是在窗口消息级别处理的情况下是可以理解的。这个解决方案也行不通。我需要一种方法,在允许 .NET 框架和工具处理窗体的细节(如使用设计器进行编辑和预期的运行时行为)的情况下,从非托管代码中使用窗体。
更好
在网上搜索有关处理键盘输入的信息时,我记得看到过一些关于从自己的线程运行无模式窗体的说法。这让我想到了 WinForms 应用程序是如何启动以及它们的生命周期是如何管理的,即 Application
对象的 Run
方法。我在包装器 DLL 中使用 C++/CLI 编写了一个非常简单的类作为概念验证,发现通过启动一个线程,该线程又使用对 Application.Run
的调用来显示窗体,键盘就能被正确处理。
当然,虽然这奏效了,但它并没有真正可重用,除了复制代码粘贴这种方式。我想要一些可以放入我们共享控件库中的东西,它易于使用,并且需要消费者进行最少的编码。我决定最好的方法是创建一个实现所有所需行为的基类。
public partial class ModelessFormBase : Form
{
public ModelessFormBase()
{
InitializeComponent();
}
public void Show(IntPtr parent)
{
Thread thread = new Thread(ModelessLifetimeProc);
thread.Start(parent);
}
protected void ModelessLifetimeProc(object parent)
{
SetParent(this.Handle, (IntPtr)parent);
this.Show();
Application.Run( this );
}
[DllImport("user32.dll", SetLastError = true)]
static extern IntPtr SetParent(IntPtr hWndChild, IntPtr hWndNewParent);
}
ModelessFormBase
扩展了 Form
类,并实现了新的 Show
方法,该方法接受父窗口的 Win32 句柄作为 IntPtr
。此方法创建线程,使用 Form.Show
将窗体显示为无模式,然后使用 Win32
函数 SetParent
设置父窗口。万一您在想,这是我能将无模式窗体约束到父窗口的唯一方法。从父窗口的句柄创建一个 NativeWindow
并将其传递给 Form.Show
无效,设置 Parent
属性也无效。然后,通过调用 Application.Run
来管理窗体的生命周期,直到窗体关闭,Application.Run
才返回。
我修改了我的窗体,使其派生自 ModelessFormBase
而不是 Form
,现在我拥有了所有我想要的行为……除了一个用于向应用程序提供通知的回调机制。
回调
我决定使用事件可能是向调用应用程序发送来自无模式窗体的通知的最佳方式。为此,我在无模式窗体基类中添加了 NotifyClient
事件和一个用于触发事件的方法。我还创建了一个派生自 System.EventArgs
的自定义类,用于将整数通知代码传递给订阅了该事件的客户端。
在非托管包装器代码中,我更新了入口点以接受来自应用程序的回调函数,并将其分配给一个全局指针。我为新事件创建了一个事件处理程序,该处理程序将通过全局函数指针调用回调函数,并将自定义 EventArgs
类中指定的整数通知代码传递给它。
虽然这奏效了,但我真的希望有些更简单的方式。我希望无模式窗体能够直接调用应用程序中的回调函数,而无需事件的额外开销。幸运的是,我的同事 Steve 有一个解决方案。他建议我查看 System.Runtime.InteropServices.Marshal
类的 GetDelegateForFunctionPointer
方法。
这正是我所期望的!我现在可以将回调函数传递给无模式窗体,并让它直接通知应用程序。这是最终的实现。
public partial class ModelessFormBase : Form
{
public delegate void NotifyClientCallback(int code);
protected NotifyClientCallback _notifyClient = null;
protected void NotifyClient(int code)
{
if (null != _notifyClient)
{
_notifyClient(code);
}
}
public ModelessFormBase()
{
InitializeComponent();
}
public void Show(IntPtr parent)
{
Thread thread = new Thread(ModelessLifetimeProc);
thread.Start(parent);
}
public void Show(IntPtr parent, IntPtr notifyClientFunction)
{
_notifyClient = Marshal.GetDelegateForFunctionPointer(
notifyClientFunction,
typeof(NotifyClientCallback))
as NotifyClientCallback;
Thread thread = new Thread(ModelessLifetimeProc);
thread.Start(parent);
}
protected void ModelessLifetimeProc(object parent)
{
SetParent(this.Handle, (IntPtr)parent);
this.Show();
Application.Run( this );
}
[DllImport("user32.dll", SetLastError = true)]
static extern IntPtr SetParent(IntPtr hWndChild, IntPtr hWndNewParent);
}
我向窗体基类添加了一个委托,其签名与应用程序中的回调函数相同。我添加了另一个 Show
方法的实现,该方法接受父窗口的句柄和回调函数指针,并从函数指针创建委托。
我为派生自 ModelessFormBase
的测试窗体添加了按钮单击事件,以测试回调函数。
public partial class DemoForm : ModelessFormBase
{
public DemoForm()
{
InitializeComponent();
}
private void button1_Click(object sender, EventArgs e)
{
base.NotifyClient(1);
}
private void button2_Click(object sender, EventArgs e)
{
base.NotifyClient(2);
}
private void button3_Click(object sender, EventArgs e)
{
base.NotifyClient(3);
}
}
我更新了包装器函数以接受回调函数的指针,并将其包含在对窗体 Show
方法的调用中。
#include "stdafx.h"
#include <windows.h>
using namespace System;
using namespace System::Windows::Forms;
using namespace ModelessForm;
typedef void (*LPF_NOTIFY_CALLBACK)(int);
extern "C" __declspec(dllexport) int ShowModeless
(HWND parent, LPF_NOTIFY_CALLBACK lpfNotifyCallback)
{
DemoForm^ form = gcnew DemoForm();
form->Show( NativeWindow::FromHandle
((IntPtr)parent), (IntPtr)lpfNotifyCallback );
return 0;
}
最后,我更新了应用程序以包含一个回调,并将回调的地址传递给包装器 DLL 中的入口点。
extern "C" __declspec(dllimport) int ShowModeless
(HWND parent, void (*LPF_NOTIFY_HANDLER)(int));
void OnModelessDialogNotify(int code)
{
TCHAR message[256];
_stprintf( message, L"The code is: %d", code );
MessageBox( NULL, message, L"Modeless Dialog Callback", MB_OK );
}
void CChildView::OnWinformShow()
{
ShowModeless( GetSafeHwnd(), &OnModelessDialogNotify );
}
演示
ModelessForm
一个 C# 类库项目,其中包含基窗体 ModelessFormBase
和派生窗体 DemoForm
。请注意,我将它们放在同一个程序集中纯粹是为了简化演示。在“实际”世界中,基窗体可能会放在包含共享 UI 组件的程序集中。
ModelessFormWrapper
一个 Win32 DLL 项目,其中包含一个“C”样式的入口点,允许非托管应用程序显示窗体。它使用 /CLR 选项编译,因此支持托管和非托管代码。
ModelessFormDemo
一个非常简单的 MFC SDI 应用程序,它调用包装器 DLL 来显示无模式窗体,并将视图类作为父窗口。窗体被限制在视图区域内,包括最小化或最大化时。窗体上的每个按钮单击时都会显示一条消息,演示回调功能。
结论
希望这能让那些在遗留代码库中工作的人能够利用 .NET 框架的强大功能来实现新的 UI 屏幕。
历史
- 2011/05/03 - 初始发布