在 WinForms 设计器中公开动态事件






4.69/5 (17投票s)
在设计时为控件数组声明动态事件的解决方案

引言
对于包含动态加载子控件的容器控件,存在一个常见问题:当我们在编译时不知道这些子控件时,如何为单个子控件公开事件?演示项目中使用的 ButtonContainer
控件就是这个问题的例证;它为 Enum
类型定义的每个值创建一个按钮,该类型由“EnumType
”属性指定。一个通用的“ButtonClick
”事件在按钮被点击时发出信号。通过将按钮指定为发送者,消费者知道按钮实例。
public class ButtonContainer : UserControl
{
public event EventHandler ButtonClick;
private Type enumType = typeof(AnchorStyles);
public Type EnumType
{
get { return enumType; }
set
{
enumType = value;
createButtons();
}
}
private void createButtons()
{
foreach (string name in Enum.GetNames(enumType))
{
Button btn = new Button();
btn.Name = name;
btn.Text = name;
...
btn.Click += new EventHandler(btn_Click);
Controls.Add(btn);
}
}
void btn_Click(object sender, EventArgs e)
{
Button btn = (Button) sender;
if (ButtonClick != null)
{
ButtonClick(btn, EventArgs.Empty);
}
}
}
现在,为了公开与单个按钮关联的单独“Click
”事件,我们不能声明一个事件数组:它无法编译。
public event EventHandler[] SingleClick;
已知的替代方法是提供“AddEventHandler
”和“RemoveEventHandler
”方法,这些方法允许在运行时为单个事件(此处由一个 enum
值标识)附加/分离侦听器。
[NonSerialized]
private Dictionary<object,> eventDictionary;
public void AddEventHandler(object value, EventHandler listener)
{
// all sanity omitted
eventDictionary = eventDictionary ?? new Dictionary<object,>();
if (eventDictionary.ContainsKey(value))
{
EventHandler newDel = (EventHandler)Delegate.Combine
(eventDictionary[value], listener);
eventDictionary[value] = newDel;
}
else
{
eventDictionary.Add(value, listener);
}
}
public void RemoveEventHandler(object value, EventHandler listener)
{
if (eventDictionary.ContainsKey(value))
{
EventHandler newDel = (EventHandler)Delegate.Remove
(eventDictionary[value], listener);
if (newDel != null)
{
eventDictionary[value] = newDel;
}
else
{
eventDictionary.Remove(value);
}
}
}
private void OnButtonClick(Button sender, object value)
{
if (eventDictionary == null) return;
if (eventDictionary.ContainsKey(value))
{
EventHandler listener = eventDictionary[value];
listener.Invoke(sender, EventArgs.Empty);
}
}
当您在网上搜索时,这是目前关于动态事件的主题。本文通过提供设计时支持来扩展此解决方法,该支持允许像往常一样从属性网格管理动态事件。
背景:设计时的事件
事件由 EventDescriptor
实例描述,该实例由 TypeDescriptor.CreateEvent
方法创建,并且可以通过 TypeDescriptor.GetEvents()
获取类型。IEventBindingService
哈希描述符并创建一个关联的 PropertyDescriptor
(一个 private EventBindingService+EventPropertyDescriptor
实例),该实例将其附加的侦听器方法名存储为 string
值。
当组件序列化为设计器代码时,带有附加侦听器的事件在 CodeDom 中表示为 CodeAttachEventStatement
。在反序列化中,CodeDomSerializerBase
从 IEventBindingService
获取关联的 EventPropertyDescriptor
并通过方法名持久化侦听器。在运行时保存附加侦听器的组件的 EventHandlerList
未使用。
VS 属性网格查询 TypeDescriptor
以获取组件的所有 EventDescriptor
,并将其关联的 EventPropertyDescriptor
封装在其自己的内部网格条目中,这些条目显示在事件选项卡上。与属性和特性一样,事件可以通过 IDesignerFilter
和 ITypeDescriptorFilterService
实现对类型或特定组件进行过滤。
我们不能做的事情
- 为不存在的事件创建 EventDescriptor
TypeDescriptor.CreateEvent()
实际上返回一个描述符,但只要反射发现没有定义的事件,它就会抛出异常。 - 使用自定义 EventPropertyDescriptor
IEventBindingService
仅识别其自己的private EventPropertyDescriptor
。 - 使用自定义 EventBindingService
该服务不是特定于组件Site
的,它负责DesignSurface
上的所有组件。从我们的组件类型交换表面服务很棘手,因为我们必须确保它随第一个加载的组件一起添加,并随最后一个释放的组件一起删除。此外,当项目重新编译时,控件会频繁重新加载。
虽然我们可以解决上述同步问题,但我们仍然需要获取初始EventPropertyDescriptor
值,因为默认的IEventBindingService
总是由设计器加载器首先加载,远早于我们的组件加载。
只有当我们“借用”Microsoft 的内部VSCodeDomDesignerLoader+VSCodeDomEventBindingService
代码时,服务的实现才是微不足道的。然后,我们的组件将需要引用一些“Microsoft.VisualStudio.*
”程序集,这是不可取的。
解决方案
该解决方案由一个 ControlDesigner
、一个 CodeDomSerializer
、一个 EventDescriptor
、一个 TypeDelegator
和一个 Attribute
类组成。
- EnumMemberAttribute
此属性特定于您的控件,并且必须通过其属性标识每个生成的事件。由于演示控件使用
Enum
名称和值来标识所包含的按钮,因此EnumMemberAttribute
公开适当的属性。
[AttributeUsage(AttributeTargets.Class , AllowMultiple= false, Inherited= true)]
internal sealed class EnumMemberAttribute : Attribute
{
public EnumMemberAttribute(Type type, object value, string name);
public object EnumValue { get;}
public string EnumName { get;}
public Type EnumType { get;}
}
TypeDelegator
提供了一种非常方便的方式来从 System.Type
类继承,因为它将所有方法委托给构造函数中传入的类型。我们的 TypeAlias
包装事件类型(演示:System.EventHandler
)。通过覆盖 Equals()
和 GetHashCode()
,我们可以为每个 EnumMemberAttribute
创建单独的事件类型,这些事件类型的行为与事件类型相同,但在比较中是独立的。TypeAlias
是该解决方案的小英雄,在两个场合拯救了我们,我们稍后将看到。我必须感谢 Schalk van Wyk 给予的启发。
internal sealed class TypeAlias : TypeDelegator
{
private readonly EnumMemberAttribute enumMember;
public TypeAlias(Type type, EnumMemberAttribute enumMember) : base(type)
{
this.enumMember = enumMember;
}
public EnumMemberAttribute EnumMember
{
get { return enumMember; }
}
public override bool Equals(object obj)
{
TypeAlias that = obj as TypeAlias;
if (that != null)
{
return (that.typeImpl == typeImpl) && (that.enumMember == enumMember);
}
return base.Equals(obj);
}
public override int GetHashCode()
{
return base.GetHashCode() ^ enumMember.GetHashCode();
}
}
从进一步阅读中可以看出,我们使用继承的 EventDescriptor
来表示每个动态事件。CustomEventDescriptor
返回 TypeAlias
委托器而不是事件类型。
internal sealed class CustomEventDescriptor : EventDescriptor
{
private readonly TypeAlias eventTypeAlias;
private readonly Type componentType;
public CustomEventDescriptor(EventDescriptor descr,
EnumMemberAttribute enumMember)
: base(descr, null)
{
componentType = descr.ComponentType;
eventTypeAlias = new TypeAlias(descr.EventType, enumMember);
}
/// Gets the type of component this event is bound to.
public override Type ComponentType
{
get { return componentType; }
}
/// Gets the type of delegate for the event.
/// Returned type is a TypeAlias delegator !
public override Type EventType
{
get { return eventTypeAlias; }
}
irrelevant implemented members (EventDescriptor is an abstract class) ...
}
在设计器上,我们声明一个仅在设计时可用的虚拟事件。
public event EventHandler ButtonClick_;
设计器创建必要的属性,以识别控件的每个动态事件。
private IEnumerable<EnumMemberAttribute> createAttributes()
{
ButtonContainer control = (ButtonContainer)Control;
List<EnumMemberAttribute> attributes = new List<EnumMemberAttribute>();
foreach (string name in Enum.GetNames(control.EnumType))
{
EnumMemberAttribute attr = new EnumMemberAttribute
(control.EnumType, Enum.Parse(control.EnumType, name), name);
attributes.Add(attr);
}
return attributes;
}
对于所有动态事件,我们创建 EventDescriptor
实例,都指向虚拟设计事件并共享其名称,但它们在属性网格中以其各自的名称作为单独的事件显示。事实证明,属性网格对所有生成的描述符使用一个相同的 EventPropertyDescriptor
,尽管我可以验证 IEventBindingService
已正确哈希并为每个 EventDescriptor
实例提供了不同的 EventPropertyDescriptor
。添加 CustomEventDescriptor
实例而不是,规避了这种不必要的合并行为。在我们 TypeAlias
的帮助下,属性网格认为生成的事件具有不同的事件类型,并且每个条目都使用正确的关联 EventPropertyDescriptor
。
protected override void PreFilterEvents(IDictionary events)
{
Type componentType = GetType();
foreach (EnumMemberAttribute enumMemberAttribute in createAttributes())
{
string enumName = enumMemberAttribute.EnumName;
EventDescriptor ed = TypeDescriptor.CreateEvent
(componentType, "ButtonClick_", typeof(EventHandler),
new DesignOnlyAttribute(true),
new DisplayNameAttribute("ButtonClick_" + enumName),
new MergablePropertyAttribute(false),
enumMemberAttribute);
CustomEventDescriptor ced =
new CustomEventDescriptor(ed, enumMemberAttribute);
events.Add("ButtonClick_" + enumName, ced);
}
}
当属性网格中的空条目被双击时,其底层 EventPropertyDescriptor
在表单文件中创建一个新的侦听器方法,在设计器文件中生成一个 CodeAttachEventStatement
并跳转到侦听器方法。该操作封装在 DesignerTransaction
中,并通过 IComponentChangeService
发出信号。
我们通过监听 IComponentChangeService.ComponentChanging
并抛出 CheckoutException
来默默地阻止它来拦截它。附加到 Application.Idle
事件,允许我们在 DesignerTransaction
被取消后处理拦截的描述符。两个单独的 bool
变量用于重新进入代码,一次是我们取消原始值,一次是在 Application.Idle
上设置我们的值。
private CustomEventDescriptor interceptedEventDescriptor;
private bool reEntrantCodeCancel;
private bool reEntrantCodeSet;
void IComponentChangeService_ComponentChanging
(object sender, ComponentChangingEventArgs e)
{
if (e.Component != Component || e.Member == null) return;
if (reEntrantCodeSet)
{
// e.Member is either interceptedEventDescriptor or
// its associated EventPropertyDescriptor
return;
}
if (reEntrantCodeCancel)
{
// e.Member is never interceptedEventDescriptor or
// its associated EventPropertyDescriptor
// Cancel triggered Undo operation for a previous intercepted
// EventPropertyDescriptor or other descriptor
throw CheckoutException.Canceled;
}
CustomEventDescriptor ced = e.Member as CustomEventDescriptor;
if (ced == null)
{
return;
}
//-- EventPropertyDescriptor is setting value
interceptedEventDescriptor = ced;
reEntrantCodeCancel = true;
Application.Idle += new EventHandler(Application_Idle);
throw CheckoutException.Canceled;
}
现在我们可以使用单独显示的事件名称而不是共享的虚拟名称来设置值。我们不知道最初输入的值(可能由用户输入),我们总是使用我们的算法来生成侦听器名称。如果一个具有适当名称的侦听器方法已经存在,IEventBindingService
将会附加它,否则该服务将为我们创建它。如果 EventPropertyDescriptor
已经附加了一个侦听器,我们将其重置为 null
,IEventBindingService
将从文件中删除侦听器方法。
void Application_Idle(object sender, EventArgs e)
{
Application.Idle -= Application_Idle;
reEntrantCodeCancel = false;
reEntrantCodeSet = true;
IEventBindingService svc =
(IEventBindingService)GetService(typeof(IEventBindingService));
PropertyDescriptor eventProperty =
svc.GetEventProperty(interceptedEventDescriptor);
EnumMemberAttribute enumMemberAttribute =
(EnumMemberAttribute)interceptedEventDescriptor.Attributes
[typeof(EnumMemberAttribute)];
object curValue = eventProperty.GetValue(Component);
if (curValue == null)
{
// -- create new listener or attach existing having identical name
string methodName = svc.CreateUniqueMethodName
(Component, interceptedEventDescriptor);
methodName += enumMemberAttribute.EnumName;
eventProperty.SetValue(Component, methodName);
}
else
{
// -- always reset existing listener
eventProperty.SetValue(Component, null);
}
interceptedEventDescriptor = null;
reEntrantCodeSet = false;
}
此时,已创建正确的侦听器方法,但生成的 CodeAttachEventStatement
将无法编译,因为虚拟事件仅在设计时存在。
// listener for the button, identified by System.Windows.Forms.AnchorStyles.Left:
// in Form1.cs
private void buttonContainer1_ButtonClick_Left(object sender, EventArgs e)
{
}
// in Form1.Designer.cs
this.buttonContainer1.ButtonClick_ +=
new System.EventHandler(this.buttonContainer1_ButtonClick_Left);
序列化器负责将错误的 CodeAttachEventStatement
替换为有效的 CodeMethodInvokeExpression
,该表达式针对控件上存在的“AddEventHandler
”方法。上面的语句被转换为
this.buttonContainer1.AddEventHandler(System.Windows.Forms.AnchorStyles.Left,
new System.EventHandler(this.buttonContainer1_ButtonClick_Left));
我们让默认的 ControlCodeDomSerializer
将控件序列化到 CodeStatementCollection
中,并根据需要交换语句。
public override object Serialize
(IDesignerSerializationManager manager, object value)
{
CodeDomSerializer defaultSerializer =
(CodeDomSerializer)manager.GetSerializer
(typeof(System.Windows.Forms.Control), typeof(CodeDomSerializer));
CodeStatementCollection statements =
(CodeStatementCollection)defaultSerializer.Serialize(manager, value);
foreach (CodeAttachEventStatement cas in
findAttachEventStatements(statements, "ButtonClick_"))
{
CodeMethodInvokeExpression cmie =
createMethodInvokeExpression(manager, "AddEventHandler", cas);
statements.Remove(cas);
statements.Add(cmie);
}
return statements;
}
我们可以从原始 CodeAttachEventStatement
获取构建 CodeMethodInvokeExpression
所需的数据。表示侦听器方法的 CodeDelegateCreateExpression
和表示我们控件的 CodeFieldReferenceExpression
可直接使用和重用。糟糕!!!我们需要什么枚举值才能正确识别事件?CodeDom
语句没有用属性装饰,无法获取我们到目前为止使用的 EnumMemberAttribute
。我们可以从侦听器名称推断出枚举名称,这不是一种安全做法。设计器和序列化器在设计和意图上是真正分离的实体,我们是否应该在这里提供一些同步?糟糕!!!
private static CodeMethodInvokeExpression createMethodInvokeExpression
(IDesignerSerializationManager manager, string methodName,
CodeAttachEventStatement cas)
{
CodeDelegateCreateExpression listener =
(CodeDelegateCreateExpression) cas.Listener;
CodeFieldReferenceExpression targetObject = cas.Event.TargetObject;
CodeExpression enumValue = ???;
CodeMethodInvokeExpression cmie =
new CodeMethodInvokeExpression
(targetObject, methodName, enumValue == ??? , listener);
return cmie;
}
嗯,每个 CodeDom CodeObject
都公开了一个“UserData
” IDictionary
属性,内部设计器序列化基础设施在此存储辅助名称/值对,以帮助相关的序列化器进行各自的操作。在 CodeAttachEventStatement
的情况下,只在此存储事件类型。由于我们的 CustomEventDescriptor
返回了 TypeAlias
作为事件类型,因此它存储在字典中并为我们提供了缺失的属性。所以小小的 TypeDelegator
两次帮助了我们,并在庞大的序列化基础设施中走私了所需的数据。
private static CodeMethodInvokeExpression createMethodInvokeExpression
(IDesignerSerializationManager manager, string methodName,
CodeAttachEventStatement cas)
{
CodeDelegateCreateExpression listener =
(CodeDelegateCreateExpression) cas.Listener;
CodeFieldReferenceExpression targetObject = cas.Event.TargetObject;
Debug.Assert(!(cas.UserData[typeof(Delegate)] is Delegate));
TypeAlias typeAlias = (TypeAlias)cas.UserData[typeof(Delegate)];
// {System.ComponentModel.Design.Serialization.EnumCodeDomSerializer}
CodeDomSerializer serializer = (CodeDomSerializer)manager.GetSerializer
(typeAlias.EnumMember.EnumType, typeof(CodeDomSerializer));
CodeFieldReferenceExpression enumValue =
(CodeFieldReferenceExpression)serializer.Serialize
(manager, typeAlias.EnumMember.EnumValue);
return new CodeMethodInvokeExpression
(targetObject, methodName, enumValue, listener);
}
序列化器的第二个职责是在反序列化控件时获取 EventPropertyDescriptor
的初始值。在我们让默认的 ControlCodeDomSerializer
从 CodeStatementCollection
反序列化控件之前,我们剥离所有“AddEventHandler
”方法调用。默认序列化将任何事件侦听器 CodeDom
表达式反序列化为 null
,它会用 null
值填充控件的 eventDictionary
字段。
相反,我们从每个 CodeMethodInvokeExpression
推断出侦听器名称和标识正确事件的 enum
值,找到相应的 EventPropertyDescriptor
并将其侦听器名称设置为其值。
private void setEventProperties
(IDesignerSerializationManager manager,
object instance, IEnumerable<CodeMethodInvokeExpression> list)
{
// Designer has added CustomEventDescriptors
EventDescriptorCollection eventDescriptors =
TypeDescriptor.GetEvents(instance, new Attribute[]
{ new DesignOnlyAttribute(true) });
IEventBindingService service =
(IEventBindingService)manager.GetService(typeof(IEventBindingService));
foreach (CodeMethodInvokeExpression cmie in list)
{
// void AddEventHandler(object enumValue /*0*/, Delegate listener /*1*/)
object enumValue = DeserializeExpression(manager, null, cmie.Parameters[0]);
string listenerName = getListenerName(manager, cmie.Parameters[1]);
CustomEventDescriptor ced =
findEventDescriptor(eventDescriptors, "ButtonClick_", enumValue);
PropertyDescriptor eventProperty = service.GetEventProperty(ced);
eventProperty.SetValue(instance, listenerName);
}
}
带有许多 ForEach
循环的序列化器代码无疑会受益于 Linq,可惜我不得不针对 NET 2.0。我为CodeDom 可视化工具而自豪,它有助于序列化器的开发。
局限性
当我们在 WinForms 设计器中附加一个普通事件时,属性网格会向我们显示一个现有的兼容方法列表,我们可以从中选择或通过双击创建一个新方法。对于我们的动态事件,我们总是为每个事件生成一个相同的侦听器名称,无论用户输入了什么。因此,兼容方法的概念被替换为相同方法的概念。如果一个具有我们预期名称的方法已经存在,我们就附加它,否则我们就创建它。不允许不同的方法名称,句号。但是用户可以自由地从代码编辑器中重命名侦听器。
这种行为正是我想要的,它避免了因相似的侦听器名称而产生的虚假信息,并且事件不应该跨不同控件合并。因此,我使用了一个自定义的 TypeConverter
,它只返回一个现有侦听器而不是兼容方法作为标准值。该转换器通过反射设置相应 EventPropertyDescriptor
上的 private
字段进行交换。
如果您想要兼容方法,则必须找到一种直接从属性网格读取输入值的方法。
使用相同方法概念时不太可能出现名称冲突,我完全省略了名称验证。您可能希望研究 Microsoft 在
Microsoft.VisualStudio.Shell.Design.Serialization.CodeDom.<br />CodeDomEventBindingService
中的实现。所需的 CodeTypeDeclaration
可作为设计器中的服务使用
CodeTypeDeclaration declaration =
(CodeTypeDeclaration) GetService(typeof (CodeTypeDeclaration));
关于 Visual Studio 程序集地狱的一课
以下方法是 DesignEventSerializer
的一个摘录,它为给定的 enum
值查找相应的描述符。只要描述符包含在传递的集合中,我们总是应该找到它,对吗?
private static CustomEventDescriptor findEventDescriptor
(EventDescriptorCollection eventDescriptors, string eventName, object enumValue)
{
foreach (EventDescriptor ed in eventDescriptors)
{
if (ed.Name != eventName)
{
continue;
}
EnumMemberAttribute attr =
(EnumMemberAttribute)ed.Attributes[typeof(EnumMemberAttribute)];
if (attr == null)
{
continue;
}
if (enumValue.Equals(attr.EnumValue))
{
return ed as CustomEventDescriptor;
}
}
Debug.Fail("We should always find the EventDescriptor!");
return null;
}
如果 enum
类型属于 .NET Framework 和/或已 GAC 化,则为真。如果 enum
类型定义在项目本身中并且项目被重新编译,则完全错误。原因是 VS 在重新编译时可能会创建并加载项目程序集的新影子副本。当我们比较 enum
类型时,我们很可能最终会比较从不同 Assembly.Location
加载的类型。对我们来说,它们可能看起来是具有相同程序集限定名称的相同类型,但 NET Framework 是固执的:不相等,永远不相等!这就是为什么我在这里额外通过哈希码进行比较,并且在采用我的解决方案时,您通常应该注意程序集地狱。
if (enumValue.Equals(attr.EnumValue))
{
return ed as CustomEventDescriptor;
}
if (enumValue.GetHashCode() == attr.EnumValue.GetHashCode())
{
if (enumValue.GetType().AssemblyQualifiedName ==
attr.EnumValue.GetType().AssemblyQualifiedName)
{
// Equality failed due to VS Assembly hell !!!
Debug.Assert(enumValue.GetType().Assembly.Location
!= attr.EnumValue.GetType().Assembly.Location);
return ed as CustomEventDescriptor;
}
}
Using the Code
要采用我的解决方案,请将属性属性更改为您的控件用于标识动态事件的任何内容。查找我的原始属性的使用情况并根据需要进行更改。
重命名设计器中的虚拟事件,我建议在控件上定义的通用事件名称后附加一个下划线。我使用了一个共享虚拟事件和“AddEventHandler
”方法名称的 static
类,简化了更改。TypeAlias
、CustomEventDescriptor
和 90% 的 DesignEventSerializer
都是可重用的。
我提出的解决方案只考虑了每个控件一个动态“事件系列”,如果您需要更多,改进我的代码应该不难。
要在设计时调试演示解决方案,请使用控件库“DynamicEvents
”作为启动项目。在其项目属性页上,指向您的 devenv.exe 安装并指定“Test”DynamicEvents”项目的路径作为命令行参数。
历史
- 2010 年 1 月 19 日:首次发布