dotNET 中拥有“反射”这一机制,可以在运行时动态加载类库、分析类型、创建对象、调用方法属性等。因此,反射非常适合做插件系统或是动态内容更新。
然而反射拥有一个非常致命的缺点:已被加载的程序集,是无法在程序运行期间卸载的!!!
没错,如果你使用了反射功能,那么所有被加载的程序集,会一直占用你的内存,直到你关闭程序。

那么是否有方法可以在程序运行期间动态加载与卸载程序集呢?
答案就是“AppDomain(程序域)”。

AppDomain 程序域是什么

dotNET 程序在运行期间,会将自己所使用的资源隔离到一个单独的空间内,包括已加载的程序集和其他资源。每一个 dotNET 程序都拥有一个主程序域,而通过主程序域,我们可以创建出一个纯净的子程序域,用于单独加载其他的程序集和资源。

主程序域内的程序集只在程序关闭时才会清理,而子程序域则可以由我们手动决定其生存状态。因此,我们可以将某些动态的程序集加载到子程序域中,再通过主程序域与子程序域的交互,实现动态加载类型、对象等功能。

不过 dotNET 的主程序域和子程序域之间具有隔离机制,我们不能直接调用子程序域所加载的类库,必须要通过一些代理手法来实现对象的共享与同步操作。

声明代理类

因为 DotNET 程序域的隔离机制,我们最好通过一个代理类来与主程序域进行交互。
代理类负责在子域中加载程序集,实现反射、对象生成、对象回收等操作。
而当主程序关闭子程序域时,代理类和反射的对象也会被一并销毁,不会留下内存残留。

因为代理类负责与主程序进行交互,因此需要能够穿透程序域。
先创建一个类库,用于存放代理类,本例中的类库名为 testDLL

代理类的两种原型:
' 可序列化的代理类,使用的是“值传递”
<Serializable()>
Public Class testClass
    Function Add(ByVal i1 As Integer, ByVal i2 As Integer) As Integer
        Return i1 + i2
    End Function
    Function [Sub](ByVal i1 As Integer, ByVal i2 As Integer) As Integer
        Return i1 - i2
    End Function
End Class

' 可引用的代理类,使用的是“引用传递”
Public Class testClass
    Inherits MarshalByRefObject
    
    Function Add(ByVal i1 As Integer, ByVal i2 As Integer) As Integer
        Return i1 + i2
    End Function
    Function [Sub](ByVal i1 As Integer, ByVal i2 As Integer) As Integer
        Return i1 - i2
    End Function
End Class
Serializable 声明的类会被拷贝到主程序域中,当卸载子程序域后,对象依然能够执行,同时对象的回收策略也充满未知,不知道会在何时被回收。

而使用 MarshalByRefObject 声明的类在初始化后会被主程序进行引用传递,当子程序域卸载后,该对象就无法再被使用了。
所以,一般情况下推荐使用 MarshalByRefObject 来声明代理类!

在示例的代理类中,我添加了两个方法AddSub,用于加减法计算。

创建程序域,并与代理类交互

在主程序中,引用我们刚才创建的类库。
当然,不引用也没任何问题,因为我们可以在子程序域中动态加载类库。
只不过引用后,我们可以更方便的对代理类进行拆箱处理,否则就只能使用动态运行时对代理类进行操作,会稍微降低运行效率。

创建 AppDomain

' 代理类库的位置,我们可以将它放到任意位置
Dim dllPath As String = "程序集路径\testDLL.dll"

' 使用 AppDomainSetup 来实例化程序域
Dim ads As New AppDomainSetup()
ads.ApplicationName = Path.GetFileNameWithoutExtension(dllPath) '应用名
ads.ApplicationBase = Path.GetDirectoryName(dllPath) '默认目录
Dim ad As AppDomain = AppDomain.CreateDomain("test", Nothing, ads)

' 直接创建 AppDomain
' 参数1 = 程序域名称
' 参数2 = 证据,Nothing将使用主程序域的证据
' 参数3 = 程序集目录,创建实例时会从此目录寻找类库
' 参数4 = ???
' 参数5 = 影像复制,与热更新相关
Dim ad As AppDomain = AppDomain.CreateDomain("test", Nothing,  Path.GetDirectoryName(dllPath), Nothing, False)
AppDomainSetup 是一个初始化参数类,其中最关键的就是 ApplicationBase 属性,用于指定程序域的默认目录(程序集所在目录),其他的参数都是可选项。
AppDomain.CreateDomain("test", Nothing, ads) 用于创建新的程序域,参数1的名称可以随意指定。

如果你觉得麻烦,也可以直接用 AppDomain.CreateDomain("name", Nothing, "dllDir", Nothing, False) 来构建程序域。

创建代理类实例,调用代理类的方法,以及销毁程序域后的对象生存状态

' 创建代理类的实例
' 未加载程序集的情况下,会尝试自动加载程序集
' 参数1 = 程序集友好名称,一般为不带扩展名的文件名
' 参数2 = 需要实例化的类型,需要完整的命名空间及类型名称
Dim obj As testDLL.testClass = ad.CreateInstanceAndUnwrap("testDLL", "testDLL.testClass")

' 未引用类库的情况下,可直接用 Object 来装载实例化的对象
Dim obj As Object = ad.CreateInstanceAndUnwrap("testDLL", "testDLL.testClass")


' 未引用代理类的话,VB也可以直接调用对象的方法,C#应该也有类似操作
MsgBox(obj.add(5, 6))
MsgBox(obj.sub(10, 9))

' 卸载程序域
AppDomain.Unload(ad)

' 在程序域卸载后调用代理类
' 如果代理类为 Serializable,则不会引发错误,说明类型已被拷贝到了本地
' 如果代理类为 MarshalByRefObject,就会引发错误
Try
    MsgBox(obj.add(6, 6))
Catch ex As Exception
    MsgBox(ex.Message)
    obj = Nothing '最好清理掉无效的对象
End Try
当创建完程序域后,我们即可通过其 CreateInstanceAndUnwrap 方法来动态加载程序集、创建类型实例。
其参数1为程序集的友好名称,只有指定了默认目录后才可以正常加载;参数2为需要实例化的类型的完整名称,我们的代理类完整名称为testDLL.testClass

如果你未指定默认目录,或者程序集并未存放在默认目录,则可以使用 CreateInstanceFromAndUnwrap 方法来从指定路径上加载程序集,又或者使用 Load 方法直接加载程序集的bytes。

通过 CreateInstanceAndUnwrap 创建的实例对象,你可以用 testDLL.testClass 来装载,亦或直接用 Object 来装载,之后我们就可以直接对这个对象进行操作了。

需要注意的是:当我们调用 AppDomain.Unload(ad) 时,代表立刻销毁这个子程序域。
虽然这个子程序域被销毁,但并不代表我们所创建的对象也被销毁了,这是根据代理类的声明方式所决定的。只有声明继承为 MarshalByRefObject 的类型会被立刻销毁,而 Serializable 属性的类型则不会被立刻销毁。

直接读取类库的几种方法

' 在程序域中使用数据集加载类库
Dim dllPath As String = "程序集路径\testDLL.dll"
Dim bs As Byte() = IO.File.ReadAllBytes(dllPath)

' 创建一个程序域
Dim ad As AppDomain = AppDomain.CreateDomain("test")
ad.Load(bs) '直接将程序集RAW加载进程序域

' 创建代理类的实例
Dim obj As testDLL.testClass = ad.CreateInstanceAndUnwrap("testdll", "testDLL.testClass")

Dim obj As Object = ad.CreateInstanceAndUnwrap("testdll", "testDLL.testClass")

' 另一种创建实例的方法,适合不确定的程序集路径
' 参数1 = 程序集完整路径
' 参数2 = 需要实例化的类型
Dim obj As testDLL.testClass = ad.CreateInstanceFromAndUnwrap("程序集完整路径", "testDLL.testClass")
此示例中,我们直接创建了一个程序域,并且未声明默认目录。
之后,我们可以通过 Load(bytes) 直接将程序集的原始数据加载到程序域中。

此外,我们还可以通过 CreateInstanceFromAndUnwrap(程序集完整路径,类型全名) 的方法直接读取指定位置的程序集,并通过它来创建实例对象。

一些实用方法,以及需要注意的地方

Dim ad As AppDomain = AppDomain.CreateDomain("test", Nothing, "程序集目录", Nothing, False)

' 指定过程序集目录后,就可以通过友好名称手动加载程序集
ad.Load("testDLL")

' 直接加载程序集数据
ad.Load( IO.File.ReadAllBytes("dllPath") )


' 获取域中已加载的所有程序集
ad.GetAssemblies()

' 获取指定名称的程序集
' 程序集的完整名称为 “name, version=版本, Culture=区域化, PublicKeyToken=签名”
' 想要只获取程序集名称,就需要先获 AssemblyName 属性,再获取简化名称
Dim ass As Assembly = ad.GetAssemblies().First(Function(a) a.GetName.Name = "testDLL")
MsgBox(ass.FullName)


' 通过程序集创建对象
' 注意:通过程序集创建的对象是本地对象,关闭程序域时不会清理本地对象
' 永远只用 AppDomain 的 CreateInstanceAndUnwrap 和 CreateInstanceFromAndUnwrap 来创建对象
Dim obj As Object = ass.CreateInstance("testDLL.testClass")
MsgBox(obj.add(5, 6))
AppDomain.Unload(ad)
MsgBox(obj.Add(6, 6)) '对象仍活着


' 获取指定的类型
Dim proxyType As Type = ass.GetType("testDLL.testClass")
MsgBox(proxyType.ToString)

' linq 查询类型
Dim proxyType As Type = ass.GetTypes().First(Function(t) t.Name = "testClass")
MsgBox(proxyType.ToString)
需要注意的是:通过程序集创建的对象是本地对象,关闭程序域时不会清理本地对象!
永远只用 AppDomain 的 CreateInstanceAndUnwrapCreateInstanceFromAndUnwrap 来创建对象!

总结

使用子程序域,我们可以将部分程序集和对象隔离在主程序域之外,这对于需要经常更新类库的程序非常有用。

然而在使用过程中,我们一定要合理的初始化代理类,尽量将所有操作都留在代理类中,与主程序域隔离开。