C# snack 001

问题

新项目,在学习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
// 业务层BasicsMain
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();
});
}

// FairyGUI.EventListener.cs
public void Add(EventCallback0 callback)
{
_bridge.Add(callback);
}

// FairyGUI.EventBridge.cs
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;

// here
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;
}

// here
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);
}
}

结论

  1. 不要用匿名方法绑定事件,很容易内存泄漏
  2. 委托是静态方法,直接传递
  3. 委托是实例方法,如果可能多次绑定,可以考虑存一个实例委托变量而不是直接传入实例方法,用以优化GC。