问题
新项目,在学习FairyGUI,看到官方Demo里的Window示例有这么一段代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| private Window _winA; private Window _winB; private void PlayWindow() { GComponent obj = _demoObjects["Window"]; obj.GetChild("n0").onClick.Add(() => { if (_winA == null) _winA = new Window1(); _winA.Show(); });
obj.GetChild("n1").onClick.Add(() => { if (_winB == null) _winB = new Window2(); _winB.Show(); }); }
public void Add(EventCallback0 callback) { _bridge.Add(callback); }
EventCallback0 _callback0; public void Add(EventCallback0 callback) { _callback0 -= callback; _callback0 += callback; }
|
这个PlayWindow函数是每次点击对应按钮都会调用的,但是里面的注册回调却用的匿名函数,虽然内部会先对委托进行解绑再绑定,但是匿名函数,怎敢断定两次调用传入的是同一个函数的?我立马嗅到一丝内存泄漏的恶臭味😡,但是实际并没有发生。
验证
噢,我知道了一定是编译器又在暗中帮忙,我立马打开的ILSpy打开对应程序集文件(Project/Library/ScriptAssemblis/YourAssembly.dll)”,然后看到了编译器暗中做的事情:(记得取消勾选ILSpy视图/选项里的“反编译匿名方法或lambda”,不然ILSpy会将内部细节优化掉,你只能看到传入的是lambda {})
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| private void PlayWindow() { GComponent obj = _demoObjects["Window"]; obj.GetChild("n0").onClick.Add(<PlayWindow>b__18_0); obj.GetChild("n1").onClick.Add(<PlayWindow>b__18_1); }
[CompilerGenerated] private void <PlayWindow>b__18_0() { if (_winA == null) { _winA = new Window1(); } _winA.Show(); }
[CompilerGenerated] private void <PlayWindow>b__18_1() { if (_winB == null) { _winB = new Window2(); } _winB.Show(); }
|
可以看到,对于这两个匿名函数的注册,编译器直接优化成了类的实例函数(因为匿名函数只捕获了实例变量),有了确定的地址!因此取消注册也能顺利完成。
脆弱
实际使用过程中,这种方式需要被杜绝掉,因为假如,匿名函数捕获的不是当前上下文的实例变量,而是局部变量呢,我改了下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| private Window _winA; private Window _winB; private void PlayWindow() { int a = 1; GComponent obj = _demoObjects["Window"]; obj.GetChild("n0").onClick.Add(() => { Debug.Log(a); if (_winA == null) _winA = new Window1(); _winA.Show(); });
obj.GetChild("n1").onClick.Add(() => { if (_winB == null) _winB = new Window2(); _winB.Show(); }); }
|
刷新下ILSpy:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| private void PlayWindow() { <>c__DisplayClass18_0 <>c__DisplayClass18_ = new <>c__DisplayClass18_0(); <>c__DisplayClass18_.<>4__this = this; <>c__DisplayClass18_.a = 1; GComponent obj = _demoObjects["Window"]; obj.GetChild("n0").onClick.Add(<>c__DisplayClass18_.<PlayWindow>b__0); obj.GetChild("n1").onClick.Add(<>c__DisplayClass18_.<PlayWindow>b__1); }
[CompilerGenerated] private sealed class <>c__DisplayClass18_0 { public int a;
public BasicsMain <>4__this;
internal void <PlayWindow>b__0() { Debug.Log((object)a); if (<>4__this._winA == null) { <>4__this._winA = new Window1(); } <>4__this._winA.Show(); }
internal void <PlayWindow>b__1() { if (<>4__this._winB == null) { <>4__this._winB = new Window2(); } <>4__this._winB.Show(); } }
|
这次编译器采用的策略是生成一个匿名类,将捕获的变量作为自身的实例变量,每次执行PlayWindow,都重新创建一个匿名类,这样传入的实例函数的target属于不同对象(委托的相等需满足target相等 + method相等),自然无法正确解绑,实测也是如此,发生了内存泄漏。
延展
那如果,匿名函数内部不捕获任何变量呢?调用的改动如下,只在匿名函数内输出一条日志:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| private Window _winA; private Window _winB; private void PlayWindow() { GComponent obj = _demoObjects["Window"]; obj.GetChild("n0").onClick.Add(() => { Debug.Log("a"); });
obj.GetChild("n1").onClick.Add(() => { if (_winB == null) _winB = new Window2(); _winB.Show(); }); }
|
刷新下ILSpy:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| private void PlayWindow() { GComponent obj = _demoObjects["Window"]; obj.GetChild("n0").onClick.Add(<>c.<>9__18_0 ?? (<>c.<>9__18_0 = <>c.<>9.<PlayWindow>b__18_0)); obj.GetChild("n1").onClick.Add(<PlayWindow>b__18_1); }
[Serializable] [CompilerGenerated] private sealed class <>c { public static readonly <>c <>9 = new <>c(); public static GTweenCallback1 <>9__11_0; public static EventCallback0 <>9__12_0; public static EventCallback1 <>9__13_0;
public static EventCallback0 <>9__18_0;
internal void <PlayGraph>b__11_0(GTweener t) { ((NGraphics)t.target).GetMeshFactory<LineMesh>().fillEnd = t.value.x; ((NGraphics)t.target).SetMeshDirty(); }
internal void <PlayButton>b__12_0() { Debug.Log((object)"click button"); }
internal void <PlayText>b__13_0(EventContext context) { GRichTextField t = context.sender as GRichTextField; t.text = "[img]ui://Basics/pet[/img][color=#FF0000]You click the link[/color]:" + context.data; }
internal void <PlayWindow>b__18_0() { Debug.Log((object)"a"); } }
|
可以看到,这时编译器依旧会生成一个匿名类+静态字段,并且还是一个单例类,匿名方法是该匿名单例类的实例方法,因此绑定和解绑的也都会是同一个委托,不会发生内存泄漏。
SharpLab
与ILSpy反编译不同,SharpLab可以直接用Roslyn生成编译后的C#,或许会看到更详细的信息,针对类似情况的匿名函数,做了如下模拟:
1. 委托是静态方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| class B { private A a = new();
public void Test() { a.Bind(Del); a.Fire(); } static void Del() { Console.WriteLine(1); } }
class A { private Action act;
public void Bind(Action ac) { act -= ac; act += ac; }
public void Fire() { act?.Invoke(); } }
|
生成的结果和之前反编译的有所不同,这次生成的是静态匿名类+静态字段来指向我的静态方法(上文ILSpy是单例类+静态字段),虽然形式上不一样,但是结果没什么区别,反正不捕获。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| internal class B { [CompilerGenerated] private static class <>O { public static Action <0>__Del; }
[Nullable(1)] private A a = new A();
public void Test() { a.Bind(<>O.<0>__Del ?? (<>O.<0>__Del = new Action(Del))); a.Fire(); }
private static void Del() { Console.WriteLine(1); } }
[NullableContext(1)] [Nullable(0)] internal class A { private Action act;
public void Bind(Action ac) { act = (Action)Delegate.Remove(act, ac); act = (Action)Delegate.Combine(act, ac); }
public void Fire() { Action action = act; if (action != null) { action(); } } }
|
2. 委托捕获局部变量
将Test作如下修改:
1 2 3 4 5 6
| public void Test() { int aa = 1; a.Bind(() => { aa = 2;}); a.Fire(); }
|
对应的B类发生了变化,和之前ILSpy结果是一致的,生成了一个匿名实例类和它的匿名实例方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| internal class B { [CompilerGenerated] private sealed class <>c__DisplayClass1_0 { public int aa;
internal void <Test>b__0() { aa = 2; } }
[Nullable(1)] private A a = new A();
public void Test() { <>c__DisplayClass1_0 <>c__DisplayClass1_ = new <>c__DisplayClass1_0(); <>c__DisplayClass1_.aa = 1; a.Bind(new Action(<>c__DisplayClass1_.<Test>b__0)); a.Fire(); } }
|
3. 委托捕获实例变量
将类B作如下修改:
1 2 3 4 5 6 7 8 9 10
| class B { private A a = new(); int aa = 1; public void Test() { a.Bind(() => { aa = 2; }); a.Fire(); } }
|
对应的B类发生了变化,和之前ILSpy结果也是一致的,直接嵌入当前上下文实力类,作为实例匿名方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| internal class B { [Nullable(1)] private A a = new A();
private int aa = 1;
public void Test() { a.Bind(new Action(<Test>b__2_0)); a.Fire(); }
[CompilerGenerated] private void <Test>b__2_0() { aa = 2; } }
|
4. 委托是实例方法
将类B作如下修改:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| class B { private A a = new();
public void Test() { a.Bind(Del); a.Fire(); }
void Del() { Console.WriteLine(1); } }
|
生成的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| internal class B { [Nullable(1)] private A a = new A();
public void Test() { a.Bind(new Action(Del)); a.Fire(); }
private void Del() { Console.WriteLine(1); } }
|
刚才,委托捕获实例变量我就发现了,每次调用都会new个Action,所以这里也是这样,为了GC,我们或许可以将这个new优化掉。对类B进一步修改(用一个实例变量委托在 [构造函数/初始化阶段/使用前] 赋值成目标函数,然后传入委托时,传入该实例变量):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| class B { private A a = new();
private readonly Action _p;
public B() { _p = Del; }
public void Test() { a.Bind(_p); a.Fire(); }
void Del() { Console.WriteLine(1); } }
|
结果如下,看起来就跟没进行任何处理一样,这样一来,就不会每次调用都new一个Action了。为什么编译器不这样做呢,因为会隐式引入额外内存大小?委托如果只传递一次确实是负优化了哈哈。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| [NullableContext(1)] [Nullable(0)] internal class B { private A a = new A();
private readonly Action _p;
public B() { _p = new Action(Del); }
public void Test() { a.Bind(_p); a.Fire(); }
private void Del() { Console.WriteLine(1); } }
|
结论
- 不要用匿名方法绑定事件,很容易内存泄漏
- 委托是静态方法,直接传递
- 委托是实例方法,如果可能多次绑定,可以考虑存一个实例委托变量而不是直接传入实例方法,用以优化GC。