65.9K
CodeProject 正在变化。 阅读更多。
Home

可观察属性模式、内存泄漏和 .NET 的弱委托

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.96/5 (39投票s)

2006年11月1日

24分钟阅读

viewsIcon

226132

downloadIcon

376

本文致力于探讨可观察属性设计模式,这是一个在 Microsoft .NET Framework 中使用的非常好的模式,它可能存在的内存泄漏问题,并提供了一些解决方案。

  1. 警告。
  2. 引言。
  3. .Net 中的可观察属性设计模式。
  4. 内存泄漏。
  5. 部分解决方案 1。
  6. 部分解决方案 2。
  7. 弱委托。
  8. 弱委托装饰器工厂。
  9. 进一步改进。
  10. 示例项目。
    1. UnsubscribeTest 示例项目。
    2. WinFormsIdleTest 示例项目。
    3. ConsoleTest 示例项目。
  11. 经验教训。
  12. 结论。
  13. 许可证。
  14. 链接。
  15. 历史。

警告。

阅读本文时,您应该记住,我相当自由地使用某些术语,并将

  • 事件源;
  • 可观察对象;
  • 模型

作为同义词,以及

  • 事件接收器;
  • 事件监听器;
  • 观察者

也作为同义词。虽然通常情况并非如此,但在本文的上下文中,我假设它们是同义词。

引言。

在 Microsoft .Net Framework 中,最让我兴奋的事情之一就是委托。当涉及到实现各种回调和通知时,它们极大地简化了程序员的工作。

我有一些 Java 经验(虽然有点过时),我记得我对 Java Beans 事件模型的沮丧。要让一个对象通知监听器关于对象状态的变化,你必须声明一个事件回调接口,在监听器中实现它并以某种方式跟踪它们。在设计接口时,你必须决定接口应该声明多少个回调方法,因为如果以后你决定添加更多通知,你将不得不创建另一个回调接口,并可能重写所有监听器。在那种时候,我一直在回忆过去优秀的 C 函数指针。然后 Microsoft 引入了委托。

委托本质上是一种类型安全的面向对象的方法指针。委托可以是单播的,因此它指向单个对象的单个方法(非常适合回调),也可以是多播的,因此它指向不同对象的许多方法,甚至单个对象的许多方法(非常适合事件通知)。

虽然委托是类,但没有编译器的帮助,你几乎无法做到什么。你可以使用特殊语法声明新的委托类型,创建委托实例,赋值、添加和减去它们,并且你可以调用它们,进而调用它们的目标方法。你不能子类化委托(它们都是密封的)。但说实话——你真的需要它吗?

.Net 中的可观察属性设计模式。

委托在通知场景中起着关键作用。例如,你可以通知某个对象属性的值已更改。Microsoft 在 Windows Forms 编程文档中建议控件和组件遵循以下模式:

  1. 声明一个具有 getter 和 setter 的属性;例如,Value 属性
    object _value;
    
    public object Value
    {
        get
        {
            return _value;
        }
        // Setter goes below
    
    }
  2. 声明一个类型为 EventHandler 且名称为 ValueChanged 的事件,当属性值更改时触发该事件
    public event EventHandler ValueChanged;
  3. 编写一个受保护的方法 OnValueChanged 来触发事件
    protected virtual void OnValueChanged(EventArgs e)
    {
        EventHandler handler = ValueChanged;
        if(null != handler)
            handler(this, e);
    }
  4. 编写正确的 setter 代码
    set
    {
        if(_value != value)
        {
            _value = value;
            OnValueChanged(EventArg.Empty);
        }
    }

尽管此代码看起来冗长,但在更复杂的场景中,这种冗长是有回报的,因为该模式非常灵活。Windows Forms 库广泛使用它来通知窗体其子控件的状态变化。例如,Control 类有一个属性 Font 和一个相应的事件 FontChanged。当字体更改时,订阅了该事件的窗体和其他监听器都会知道此更改。

您可以将属性及其更改事件视为一个单一实体,与 Smalltalk 的 ValueModel 类非常相似,但更有效,因为一个类可以有许多独立的可观察属性。如果您在自己的代码中利用此模式,它将帮助您分离数据及其呈现。如果您的数据对象有一个类型为 decimal 的公共属性 Salary 和一个类型为 EventHandler 的事件 SalaryChanged,您可以手动将该属性“绑定”到窗体上的文本框:窗体本身订阅数据对象的 SalaryChanged 事件和文本框的 TextChanged 事件,并将值从 UI 传递/转换到数据对象,反之亦然。因此,如果数据对象更改了 Salary 属性的值,文本框中的值也会相应更改;如果用户输入了新的工资,Salary 属性的值也会更改。我谈论的是一个相当旧但非常强大且仍然可用的模式,名为 Model-View-Controller,简称 MVC。这里的模型是我们的带有事件的属性,视图是文本框,控制器是窗体,或者更确切地说,是它的两个事件处理程序。

好消息是,您可以通过标准数据绑定(注意:它更侧重于基于 IListDataSet 的数据对象)或 Marc Clifton 的“简化数据绑定”(参见 https://codeproject.org.cn/csharp/simpledatabinding.asp [^])来绑定属性/事件对和 UI 元素。我一直在使用一组类似于 Marc 的 BindHelper 的类,但更简单,并支持可选的设计器。此外,我通常为我的 Windows Forms 程序声明一个全局单例对象 Options,它保存所有程序选项(谁能想到呢)。Options 对象的所有或大部分属性都实现了该模式。它使我能够打开一个选项窗体,该窗体将自身“绑定”到属性,并允许用户即时编辑它们。此外,由于程序的其他窗体也绑定到 Options 对象,界面中的许多内容会立即更改,因此用户可以“玩”应用程序设置并立即看到更改。此类选项窗体不需要确定取消应用按钮,只需一个还原按钮,用于放弃所有更改并将所有内容恢复到以前的状态(这是通过使用 GoF [^] 设计模式 Memento [^] 实现的)。利用这种方法的应用程序感觉不像 Windows 应用程序,但它们无疑是方便的。

另请注意,在 System.ComponentModel 命名空间中,您可以找到一个非常有用的委托类型 CancelEventHandler,以及伴随的 CancelEventArgs 参数类。使用它,您可以创建受限属性,即在某些情况下其值无法更改的属性。要使 Value 属性受限,您需要声明另一个事件 ValueChanging,类型为 CancelEventHandler,并在实际赋值之前触发它。如果任何事件监听器更改了参数对象的 Cancel 属性的值,则 setter 应该保留该值并返回。

内存泄漏。

但是你必须为一切买单,尤其是舒适。虽然这种“实时更新”的场景非常美妙和方便,但有一个令人烦恼的问题——你必须跟踪所有的事件订阅。如果你的表单注册了一个 ValueChanged 事件的处理程序,你必须在某个时候取消注册它,因为委托是对事件接收器的强引用,如果事件源的生命周期比事件接收器长,后者将留在内存中。即使在中等大小的应用程序中,订阅也很难追踪,你可能只是忘记取消订阅事件(这就是所谓的“分裂清洁器”错误模式)。

令人遗憾的是,你可能真的习惯了 CLR 管理内存的事实。UI 设计器生成的代码从不取消注册事件处理程序。所有这些都可能让你认为委托以“即发即弃”的方式工作。

部分解决方案 1。

假设您有一个事件源对象,它公开了许多事件,并且有许多事件接收器对象订阅了其中的许多事件。当事件源超出范围时,最好一次性取消订阅所有事件接收器。这很容易做到。如果您的事件声明如下:

public event EventHandler PropertyChanged;

您应该将此声明更改为

EventHandler propertyChanged;

public event EventHandler PropertyChanged
{
    add
    {
        propertyChanged += value;
    }

    remove
    {
        propertyChanged -= value;
    }
}

你为什么要这么做?因为如果某些对象订阅了 PropertyChanged 事件,你可以通过将 null 赋值给委托存储字段 propertyChanged 来一次性取消订阅它们。

public void Unsubscribe()
{
    propertyChanged = null;
}

简单,不是吗?

如果您在 Unsubscribe() 方法中使用了所谓的优化事件实现与 EventHandlerList 类,您可以直接重新创建事件处理程序列表,一次性取消订阅所有事件。这甚至更简单。

部分解决方案 2。

假设上述场景不符合您的需求,您必须只从可观察对象暴露的所有事件中取消订阅一个观察者。让我们让事件源管理移除。

首先,我们需要一个简单的实用类

public sealed class DelegateUtil
{
    . . . . .

    public static Delegate Unsubscribe(Delegate del, object target)
    {
        if(null == del)
            return null;
        if(null == target)
            return del;
        Delegate[] list = del.GetInvocationList();
        for(int i = 0; i < list.Length; i++)
        {
            if(target == list[i].Target)
                list[i] = null;
        }
        return Delegate.Combine(list);
    }
    . . . . .
}

Unsubscribe() 方法从多播委托 del 中移除所有指向 target 对象任何方法的委托。也就是说,当特定的事件接收器 target 超出范围时,事件源会从事件委托中移除所有对它的引用。让我演示如何使用这个代码片段,使用一个声明了我们的 Value 属性的类:

public void Unsubscribe(object target)
{
    Value = (EventHandler)DelegateUtil.Unsubscribe(Value, target);
}

如果您的类公开了其他事件,请对每个事件重复 DelegateUtil.Unsubscribe。请记住,实际代码取决于事件实现(有关详细信息,请参阅 .Net Framework SDK 文档中的 EventHandlerList 类描述),但它确实有效。我们用一个简单的调用替换了一堆 -= 运算符。

弱委托。

如果事件接收器对象不知道它何时超出作用域,即它既不是控件也不是组件,也没有实现 IDisposable 接口,那么上述技巧将不起作用,并且接收器对象将保留在内存中。如果委托实际上是弱引用,就不会出现这种情况。您根本不需要取消订阅事件。但是,如果 Microsoft 真的将委托设置为弱引用,我们将在调试各种非托管 API 的托管回调时遇到很多麻烦。如果有一天 Microsoft 为 .Net 添加弱委托支持,但仅作为附加功能,那将会很好。

但是,是否有可能以某种方式完全模拟弱委托呢?当然!

Greg Schechter 在他的博客中提供了一个非常接近弱委托的类实现: http://blogs.msdn.com/greg_schechter/archive/2004/05/27/143605.aspx [^]。实际上,我强烈建议阅读他的文章,他非常彻底地描述了事件委托的问题,并配有清晰的图表,使情况更加明朗。

他引入了一个对象,它装饰一个委托,使其表现得好像是一个弱委托(Greg 称之为 WeakContainer)。装饰器代表观察者接收来自可观察对象的通知,并持有对观察者的弱引用。当观察者超出作用域并被垃圾回收时,装饰器会取消订阅通知,并自行符合垃圾回收的条件。可观察对象对此一无所知,也无法知道它将通知发送给一个中间对象而不是观察者。

Ian Griffit 在他的博客中回应了 Greg 的号召,并尝试实现一个通用的弱委托: http://www.interact-sw.co.uk/iangblog/2004/06/06/weakeventhandler [^]。他的代码问题在于他持有一个对普通委托的弱引用,因此委托和观察者对象很快就被垃圾回收了。

Xavier Musy 提供了一个有效的解决方案: http://www.seedindustries.com/blog/x/2004_06_01_archive.html [^]。他基本上重新实现了 MulticastDelegate 类,对 Target 属性使用弱引用而不是普通引用。唯一不好的是他使用了 MethodInfo.Invoke 方法,该方法速度极慢。虽然对于一般的 Windows Forms 应用程序来说这可能不是什么大问题,但在某些情况下,例如多个视图从同一模型接收通知,这可能不合适。

弱委托装饰器工厂。

在我看来,Greg Schechter 的解决方案几乎是完美的。唯一的问题是装饰器必须了解太多关于可观察对象和观察者的信息。它必须至少了解它们的类型、事件和事件处理程序。因此,即使在 .Net 2.0 中,您也无法使其通用,并且必须为每个事件源类的每个事件重新实现它。但是,如果您仔细查看 Greg 的代码,您会注意到装饰器的实现很简单,如果不是微不足道的话。那里的终结器只是一个示例,因此您需要一个构造函数和一个与目标方法签名相同的方法。如果它如此简单,那么在运行时创建这样的对象就足够容易了。您还记得“thunk”这个词吗?

进入 System.Reflection.EmitWeakDelegateDecorator。后者的类的方法使用前者的命名空间执行实际的代码生成。但在我们开始实际编写 IL 发射代码之前,让我们考虑可能的事件连接场景:

  1. 实例事件连接到实例事件处理程序;
  2. 实例事件连接到静态事件处理程序;
  3. 静态事件连接到实例事件处理程序,以及
  4. 静态事件连接到静态事件处理程序。

在场景 2 和 4 中,我们使用哪种委托无关紧要,委托目标与应用程序域的生命周期相同。所以我们基本上需要考虑场景 1 和 3。场景 1 与 MVC 模式和数据绑定相关,场景 3 对于任何曾经(错误地)使用过 Application.Idle 事件的人来说都非常熟悉。

让我们想象一下,我们有一个事件源对象,它有一个属性 Name 和一个事件 NameChanged,还有一个事件接收器对象,它有一个具有适当签名的 OnNameChanged 方法,当 NameChanged 事件触发时,该方法当然会被调用。让我们为这种场景编写一个弱事件装饰器类:

public class WeakInstanceToInstance: WeakReference
{
    public WeakInstanceToInstance(EventSink observer):
        base(observer)
    {}

    public void Handler(object sender, EventArgs e)
    {
        EventSink observer = (EventSink)Target;
        if(null != observer)
            observer.OnEvent(sender, e);
        else
        {
            EventSource observable = sender as EventSource;
            if(null != observable)
                observable.Event -= new EventHandler(Handler);
        }
    }
}

这个类,或者更具体地说,它到 IL 指令的转换,将成为我们场景 1 的模板。

让我们想象一个对象,可能是一个窗体,如何连接到 Application.Idle 事件。该对象将有一个类似 OnIdle 的方法,而弱装饰器将如下所示:

public class WeakStaticToInstance: WeakReference
{
    public WeakStaticToInstance(EventSink observer):
        base(observer)
    {}

    public void Handler(object sender, EventArgs e)
    {
        EventSink observer = (EventSink)Target;
        if(null != observer)
            observer.OnIdle(sender, e);
        else
            EventSource.StaticEvent -= new EventHandler(Handler);
    }
}

场景3的代码稍微简单一些,但它们有很多共同之处。

尽管 Microsoft 使用并建议为事件委托签名使用两个参数,第一个是 object 类型,第二个是派生自 EventArgs 类的类型,但事件委托的参数数量没有限制,并且存在例外。请查看 SharpDevelop IDE 源代码中的 AbstractMargin.cs 文件 (src\Libraries\ICSharpCode.TextEditor\src\Gui\AbstractMargin.cs)。我必须考虑到这一点。我还应该记住,事件委托可能返回结果。如果这些结果是引用类型,一切都很容易,我们应该返回 null;对于 MSIL 中内置的一些值类型,我可以使用内置指令;对于一般的值类型,我应该使用创建未初始化结构的默认构造函数。

给定正确的信息,重载的 Decorate 方法为指定事件生成适当的弱装饰器,并返回一个应该连接到源事件的委托。

public static Delegate Decorate(Type eventSourceType, string eventName, 
           Delegate del)
public static Delegate Decorate(object eventSource, string eventName, 
           Delegate del)
public static Delegate Decorate(object eventSource, string eventName, 
           object eventSink, string eventHandlerName)
public static Delegate Decorate(Type eventSourceType, string eventName, 
           object eventSink, string eventHandlerName)
public static Delegate Decorate(EventInfo eventInfo, Delegate del)
public static Delegate Decorate(EventInfo eventInfo, object eventSink, 
           string eventHandlerName)

您通常通过替换以下代码来装饰事件委托:

eventSourceObject.Event += 
               new SomeEventHandler(eventSinkObject.EventHandlerMethod);

eventSourceObject.Event += 
       (SomeEventHandler)WeakDelegateDecorator.Decorate(eventSourceObject, 
                           "Event", eventSinkObject, "EventHandlerMethod");

或使用更类型安全(且更耗内存)的版本

eventSourceObject.Event += 
       (SomeEventHandler)WeakDelegateDecorator.Decorate(eventSourceObject, 
         "Event", new SomeEventHandler(eventSinkObject.EventHandlerMethod));

我希望为 .Net 2.0 将 WeakDelegateDecorator 类设为泛型,以便某些 Decorate 重载可以返回类型化的委托,但不幸的是,C# 2.0 不允许将 Delegate 指定为泛型约束的基类。因此,我们不得不使用这些类型转换。

这些 Decorate 方法检查参数并调用私有方法 InternalDecorate。该方法首先尝试在其内部缓存中查找已注册的弱装饰器类型,只有当其参数组合是新的时,它才创建一个新的弱装饰器类并保留其类型以供以后使用。

请记住,事件处理程序方法必须是公共的。这是因为在 .Net 中,位于另一个程序集中的类无法访问您的类的非公共成员。这是弱委托的一个限制,我不知道有任何解决方法。

每个装饰器类都有两个方法 - 一个构造函数和一个事件处理程序。

构造方法很简单,它只是将事件接收器对象的引用传递给基类(WeakReference)的构造函数。但是对于处理程序方法,我使用了各种优化技术来处理非平凡的委托。例如,MSIL 有一个特殊指令,可以引用方法的前四个参数。因为我们的处理程序是实例方法,所以索引为 0 的第一个参数是 this 指针,我使用 ldarg 0 指令将其压入堆栈。对于最常见的情况,发送者和事件参数对象的索引分别为 1 和 2。如果处理程序有超过 3 个参数,我使用 ldarg.s {index} 指令。还有 ldarg {index} 指令,但它是用于参数超过 256 个的方法。我认为试图编写这么多参数的方法的人不应该被允许使用计算机。

我将 tailcall 前缀添加到一些 call/callvirt 指令中:如果方法不返回任何值,则添加到事件目标方法的调用和 remove_{Event}() 调用中。在这两种情况下,原始代码都会立即返回;尾调用优化有效地“重用”了我们处理程序方法的堆栈。对于 x86 处理器,JIT 应该将 call/leave 指令对替换为简单的 jmp。这无疑使得装饰器在大多数常见情况下更快。在我的一个测试计算机(Intel CPU 和 Windows 2000)上,它提供了良好的代码性能提升,而在另一个(AMD 64 和 Windows XP)上则没有改变。然而,根据 Shri Borde 的这篇博文: http://blogs.msdn.com/shrib/archive/2005/01/25/360370.aspx [^],.Net 中的尾调用可能会导致问题,因此从长远来看,我可能不得不移除此优化。

另一个优化是使用预定义的 MSIL 指令来处理基本值类型。对于通用值类型,甚至对于低级的 IntPtrUIntPtr 类型(它们分别映射为 MSIL 中的 native intnative uint),我使用默认构造函数。

我修改了 Builder 类,以放宽装饰器的安全限制——本质上,我将处理程序设为公共。现在,您可以在 LocalIntranet 安全区域(即从 UNC 共享运行)的应用程序中使用 Pvax.WeakDelegates.dll 程序集,而不仅仅是在 FullTrust 区域。

我实现的弱装饰器与 Greg 的不同。Greg 的容器/观察者持有对“弱容器”的引用,并将许多可观察对象的事件附加到单个中介,该中介将事件中继给观察者(我重新绘制了 Greg 的图表)。

Sample image

在我的实现中,观察者和可观察对象根本不知道中介,因此我必须为每个新的事件/处理程序组合创建一个新的装饰器类。

Sample image

Greg 的方法在内存消耗方面更好。

我曾考虑使用 .Net 2.0 动态方法作为装饰器的轻量级替代品,但在我看来,它们不太符合情景。请证明我错了,如果能摆脱这种笨重的内存中类型缓存,那就太好了。如果你问我为什么不使用泛型哈希表,我有两个答案。首先,代码是为 .Net 1.1 编写的。其次,想象一下带有泛型的缓存声明。

我引入了一组新的测试,证明了弱委托装饰器适用于 .Net 2.0 泛型委托。我曾有点担心需要以某种方式更改或调整泛型的 IL 代码生成。令我惊讶的是,为 .Net 1.1 编写的代码在没有进行任何更改的情况下与泛型一起工作。这意味着 Microsoft 的开发人员在将泛型无缝集成到 CTS 和 CLR 中方面做得非常出色。太棒了!

顺便说一句,在为泛型委托编写测试之后,我不再认为基于反射的工厂方法的声明是丑陋的:对于或多或少复杂的泛型委托,声明和转换更丑陋。

进一步改进。

我的代码生成了很多类型,一个好的做法是为每个装饰器生成尽可能多的事件处理程序,以减少类型数量和装饰器总数。

我的代码本质上不是线程安全的;如果能使其线程安全就好了。

如果 IL 代码生成器抛出 InvalidProgramException,这意味着我在 IL 代码中搞砸了一些东西(不是说我认为我做了,但它可能会发生)。这是一个不可恢复的异常,因为收集所有装饰器类型的内存中程序集会损坏。我不会捕获此异常,而是让它传播到全局异常处理程序。如果发生这种情况,最合理的方法是让应用程序终止并通知我。如果您可以访问应用程序的源代码,请至少将导致错误的委托声明发送给我。

示例项目。

像往常一样,我的示例项目是在 SharpDevelop IDE 中创建的。该项目包含五个子项目:Pvax.WeakDelegates、Pvax.WeakDelegates.Tests、WinFormsIdleTest、ConsoleTest 和 UnsubscribeTest。我还使用 #D 2.0 将它们转换为 VS 兼容项目。

前两个是弱装饰器库本身和该库的测试程序集。其余的在下面描述。

示例项目文件夹还包含 FxCop 和 NUnit 项目文件。FxCop 公平地说 Pvax.WeakDelegates 命名空间包含的类太少,但请记住,所有装饰器也都在此命名空间中发出。它也不喜欢不指定区域信息的静态构造函数和 ToString() 调用,但所有这些都是“实现细节”,所以忽略它们即可。我创建了一个单独的 NUnit 项目,因为我不知何故搞砸了我的 #D 安装,并且单元测试插件不再工作。如果编译器抱怨缺少对 NUnit.Framework 程序集的引用,请移除该引用,重新添加它并重新编译测试项目。

UnsubscribeTest 示例项目。

此控制台应用程序示例演示了 DelegateUtil 类的用法。它创建了一个可观察实例和一些观察者,更改了可观察对象的一个属性,使用 DelegateUtil 无条件地取消订阅了一个观察者,并再次更改了属性。所有通知都记录到控制台,以确保观察者确实已取消订阅。

WinFormsIdleTest 示例项目。

WinFormsIdleTest 是一个在 Windows Forms 应用程序中使用弱委托的示例。我将子窗体挂接到 Application.Idle 事件。尽管窗体在 WM_CLOSE 消息处理程序中被释放,但它们占用的内存仍然是可访问的(即不符合垃圾回收的条件),因为事件未取消订阅。如果您使用弱装饰器装饰事件委托,内存将被回收。

该示例应用程序包含两个窗体。主窗体

有三个按钮。通过点击 New sticky form... 按钮,您可以创建一个小窗体:

它使用 Application.OnIdle 事件执行简单的动画(注意:在实际应用程序中切勿将此事件用于此目的,效率极低)。此按钮的 Click 事件处理程序使用普通的 += 运算符将子窗体订阅到空闲事件。您可以使用标准 Close 按钮(窗口标题右侧带有对角叉的小按钮)关闭子窗体。

如果您查看代码,您会发现子窗体构造函数分配了一个字节数组来模拟真实应用程序的数据。我从大堆中分配了 100 KB,因为内存管理器将如此大的对象视为“大”对象。使用 性能监视器 应用程序查看这些分配非常方便。

Sample image

例如,添加以下计数器

  • 所有堆中的字节数,比例 0.00001
  • 大对象堆大小,比例 0.00001

如果运行程序时,您应该使用 New sticky form... 按钮创建几十个子窗体,您应该会看到类似以下的图片:

Sample image

现在关闭所有子窗体。它们各自的 Dispose() 方法将释放与窗体关联的窗口句柄,但由于所有这些窗体都从静态事件中对它们有强引用,它们会留在内存中。当然,它们拥有的字节块也留在内存中。单击 Force GC! 按钮以使 GC 回收所有未使用的内存(注意:在实际程序中切勿执行此类操作)。您将在 性能监视器 中看到类似以下的图片:

Sample image

我用浅绿色线标记了我点击按钮的时刻。如您所见,大堆的大小没有改变,我们这里显然存在内存泄漏。

重新启动示例应用程序并重复所有步骤,但这次点击 New weal sticky form... 按钮。此按钮的事件处理程序几乎相同,但它使用了我的 WeakDelegateDecorator 类。分配了相当数量的子窗体后,性能监视器 图表应如下所示:

Sample image

现在关闭所有子窗体并点击 Force GC! 按钮。之后图表应如下所示:

Sample image

同样,我用浅绿色线标记了我点击按钮的时刻。如您所见,使用弱事件装饰器,内存会按预期回收。

ConsoleTest 项目。

这个示例项目演示了属性观察者和受限属性模式。它测试了一种一次性从事件中取消所有观察者的方法。之所以有效,是因为 Observable 类实例的生命周期比 Observer 类实例长。

它还测试了弱委托装饰器。一个有趣的细节是:在弱装饰器测试中,您会发现当事件接收器对象被垃圾回收时,装饰器本身会保留在内存中,直到代码再次触发事件。只有在那一刻,装饰器才会意识到它们的目标不再可访问,并自行取消订阅源事件。这些测试显示了装饰过的委托与未装饰过的委托相比速度慢了多少。

您可以修改各种常量,如 HogSizeMemoryNoiseObjectCountObserversCountPropertyChangeCount。例如,MemoryNoiseObjectCount 常量定义了 GenerateMemoryNoise() 方法应该分配多少个“内存噪声”对象。此方法模拟了真实世界的内存管理器负载。通过更改这些常量,您将获得不同的结果,但有一点不变——弱装饰器会消耗额外的内存并使委托调用变慢,因此请明智地使用它们。

CLR Profiler [^] 下运行 ConsoleTest 很有趣。为了最大程度地减少内存压力,将 MemoryNoiseObjectCount 设置为 0。如果这样做,您会看到应用程序生成了大量的委托。这是因为 +=-= 运算符在大规模事件订阅场景中效率相当低,因此如果您要一次性订阅大量对象到一个事件,请使用 Delegate.Combine() 方法的重载,该重载将委托数组作为其参数。另一方面,它显示了 .Net 垃圾收集器的效率,因为尽管订阅会生成大量小对象,但订阅所需的时间几乎与订阅者数量呈线性关系。

经验教训。

由于委托是不可变的,反复使用 +=-= 操作会生成大量小对象(单播委托),这些对象很有可能被提升到堆的第 1 代甚至第 2 代。另一方面,null 是一个有效的委托对象。这就是我的 DelegateUtil.Unsubscribe() 方法只是将原始委托的调用列表中的槽位设为 null 并使用 Delegate.Combine() 方法的原因:这个技巧在创建新委托的过程中最大限度地减少了中间对象的数量。

MSIL 和 System.Reflection.Emit 初看起来很吓人,但实际上甚至比 System.CodeDom 更简单。执行 IL 代码的虚拟机是一个简单的堆栈机,而中间语言本身让我想起了 Forth,所以我在那里感觉很自在。我不确定大型项目,但在这个特定的项目中,我只用了两天时间就编写了可用的 IL 发射代码。无论如何,感谢 Microsoft 为普通程序员提供了这个强大的功能。

你知道的,但我还是要重复一遍——单元测试很有用。它们帮助我找到了并修复了 IL 代码生成器中的两个讨厌的错误。这两个错误都与装饰器事件处理程序必须返回的值对象有关。

结论。

委托很棒,毋庸置疑。它们的使用使源代码更清晰,应用程序的对象模型更灵活。然而,这枚硬币也有另一面。我们无法摆脱它,但我们可以使用 DelegateUtil 类和弱委托装饰器来简化我们的任务。

许可证。

DelegateUtils 类受 BSD 许可证保护(请参阅源代码)。由于弱委托装饰器工厂代码基于 Greg Shechter 的源代码和思想,并且我从 MSDN 借鉴了一些,因此我将 Pvax.WeakDelegates 程序集及其源代码置于 MIT 许可证之下。然而,请自行承担风险使用它,我尚未在所有可能的情况下测试过它。

链接。

历史。

  • 2006年11月1日 - 初始版本;
  • 2006年11月13日 - 添加了尾调用优化;修改了 Builder 类代码以放宽权限限制 - 现在装饰器工厂可在 LocalIntranet 安全区域运行。
  • 2006年4月12日 - 修复了 .Net 2.0 项目选项;消除了 #D 版本之间资源处理的差异;确保了 WeakDelegateDecoratorINotifyPropertyChangesEventHandler<T> 兼容性;对内部方法和对象进行了一些重构;为 Decorate 方法添加了两个新的重载。
  • 2007年1月14日 - 确保了泛型委托的兼容性;项目布局略有更改;添加了一个新的子项目以测试 bxb 错误报告。
© . All rights reserved.