跳到主要内容

Emit

更新日期:2025-04-11

Z# 提供了一种将托管程序集元数据转换为虚幻类型系统元数据(反射信息)的机制,这个机制被称为 Emit。 Emit 机制让开发者能在托管代码中定义 UClass、UFunction、UProperty 等内容,是 Z# 主推的扩展引擎的方式。 正是这个能力,使得 Z# 不需要依托 U++ 或蓝图就能和虚幻引擎最核心的 UObject 系统进行深层次的交互,从而成为绝大多数场景下的首选。

由 Emit 机制生成的反射信息会在程序集绑定的 C++ 模块加载时动态添加到引擎中。 这个时间点恰好在该 C++ 模块的反射信息注册到引擎之后,中间没有其他流程。

信息

Emit 这一名称来源于 System.Reflection.Emit,后者是动态生成 .NET 元数据的机制,而前者是动态生成虚幻元数据。

Emit 生成的元数据与 UHT 生成的元数据之间存在严格的对应关系,这意味着任何 Emit 代码都可以翻译为等价的 U++ 代码,两份代码生成的元数据在运行时无法通过任何引擎原生的机制区分。

ZeroGames.ZSharp.Emit 程序集定义了一套与虚幻反射标识符相匹配的特性,使用这些特性,你可以将一个托管类型标记为虚幻反射类型,并添加各种修饰性的描述。 当引擎加载你的 C++ 模块时,会自动扫描对应的托管程序集,将其中定义的虚幻反射类型加载进内存中。

为了方便,我们将 Emit 生成的虚幻反射类型称为动态类型,动态类型的 共轭类型 称为动态共轭类型。 在不严谨的情况下,我们可以将动态类型和其共轭类型都称为动态类型。 假设你在脚本中定义了一个 MyActor 类,那么:

  • MyActor 对应一个脚本类,这个类严格上来说是动态共轭类型,但大多数时候我们会直接称他为动态类型。
  • MyActor 对应一个 UClass,这个类才是严格意义上的动态类型。

UClass

定义 UClass 需要使用 [UClass] 特性。 这个特性的作用等同于 UCLASS() 宏,将它添加到你的类型上,就定义了一个 UClass。 可以继续向其添加 [Blueprintable] 特性,来将其转变为一个蓝图可继承的类。 如果你只希望这个类型对蓝图可见,但不希望被继承,可以使用 [BlueprintType] 特性。

下面的示例代码定义了一个 Actor 子类:

[UClass, Blueprintable]
public partial class MyActor : Actor;

[UClass] 特性对目标类型有一些要求:

  • 必须是 class。
  • 必须是 partial,因为 Z# 的静态分析器需要为这个类型生成额外的模板代码(相当于 UHT)。
  • 不能是 static,也不能是 abstract,因为 UClass 至少有一个实例 (CDO)。
  • 必须直接或间接继承 ZeroGames.ZSharp.UnrealEngine.CoreUObject.UnrealObject
  • 命名空间必须是 <类型所在的程序集名>.<类型所在的 C++ 模块名>,这决定了类型将会被加载到哪个 Package 中。 例如:你的程序集是 ZeroGames.Game,C++ 模块名是 MyGame,那么命名空间就必须是 ZeroGames.Game.MyGame,对应的 UClass 对象会被加载到 /Script/MyGame 包中。

如果目标类型不满足以上条件,则无法通过编译。 对于最后一个条件,分析器只能判断 C++ 模块名的标识符是否有效,无法判断存在性,这一点需要开发者自己保证。

信息

如果你没有为 UClass 显式指定基类,则目标类型会默认继承 ZeroGames.ZSharp.UnrealEngine.CoreUObject.UnrealObject 而不是 System.Object

构造方法和终结器

你可以在 UClass 中定义两种类型的构造方法——Red ConstructorUClass Constructor

Red Constructor

Red Constructor 是只接受一个 IntPtr 参数的构造方法。 当 Z# 构建红色共轭时,会调用这个构造方法创建共轭对象。

对于动态的 UClass,C++ 的 Class Constructor 内部会显式构建红色共轭,因此 Red Constructor 会在对象实例化时立即调用。

在允许 Master ALC 热重载的环境下,每次热重载,都会遍历内存中所有已存在的动态 UClass 实例,构建红色共轭。 这意味着 Red Constructor 可能会在同一个 UClass 实例的生命周期中执行多次。

建议只在 Red Constructor 中对托管侧的状态进行初始化。 你当然也可以对非托管侧的状态进行初始化,但这在多数情况下可能与你预期的行为并不一致。

UClass Constructor

UClass Constructor 是一个满足以下所有条件的实例方法:

  • 标记了 [UClassConstructor] 特性。
  • 返回 void,且不接受参数。

在同一个类中,最多只能有一个 UClass Constructor。如果方法不满足上述条件,或者有多个满足条件的方法,则无法通过编译。

C++ 的 Class Constructor 会自动从父类到子类依次调用 UClass Constructor,因此你不需要手动调用父类,把方法声明为 private 比较合理。

UClass Constructor 在同一个 UClass 实例的生命周期中只执行一次,建议在 UClass Constructor 中对非托管侧的状态进行实例化。

PostInitProperties

UnrealObject 类中有一个 PostInitProperties() 虚方法,这个方法只会在动态类型实例化时调用,调用时机在 UObject::PostInitProperties() 之前。 由于 UProperty 的初始化在 C++ 构造函数之后,因此你在构造函数中初始化的 UProperty 会被 CDO 覆盖。 如果你要给 UProperty 指定一个不同于 CDO 的默认值,应该重写这个方法。

终结器

你可以在 UClass 中定义终结器,垃圾回收器会正确调用终结器。 但需要注意的是,终结器并不在 Game Thread 执行,因此不能在这里与引擎进行任何互操作。

字段和自动属性

UClass 可以在托管侧持有状态,也就是可以定义字段和自动属性。 这一点也决定了共轭对象不是单纯的代理对象,而是与原对象协同工作的“另一半”。

需要注意的是,在有热重载的环境下,每当 Master ALC 重启,所有已经存在的共轭对象都会被销毁。 当它们再次创建时,所有托管状态都会被重置。 对于托管状态不可变的对象而言,重新初始化这些状态就可以解决问题;对于那些可变的对象,热重载之后可能无法恢复到正确的状态。 开发者需要谨记这一点,使用托管状态时需要考虑热重载。

实际上,C++ 类型导出的共轭类型也是分部类型,也可以持有状态。开发者完全可以这样做,但这在 Z# 中被认为是一种反模式,因为它属于侵入式扩展。 对于导出的共轭类型,其 C++ 部分已经是一个逻辑完备的整体,不应该再以外挂的方式去强行扩展。

UStruct

定义 UStruct 需要使用 [UStruct] 特性。 这个特性的作用等同于 USTRUCT() 宏,将它添加到你的类型上,就定义了一个 UStruct。 可以继续向其添加 [BlueprintType] 特性,来将其转变为一个对蓝图可见的结构。

下面的示例代码定义了一个 UStruct:

[UStruct, BlueprintType]
public partial class MyStruct;

[UStruct] 特性对目标类型的要求和 [UClass] 特性一样,但直接或间接继承的基类变为了 ZeroGames.ZSharp.UnrealEngine.CoreUObject.UnrealScriptStructBase

信息

如果你没有为 UStruct 显式指定基类,则目标类型会默认继承 ZeroGames.ZSharp.UnrealEngine.CoreUObject.UnrealScriptStructBase 而不是 System.Object

构造方法

静态分析器会为每个 UStruct 生成无参构造方法,你可以定义有参构造方法,但所有有参构造方法都必须调用无参构造方法,因为它确保共轭映射正确建立。 如果自定义的有参构造方法没有调用无参构造方法,则无法通过编译。

注意

如果你希望给 UProperty 指定默认值,应该使用 [UProperty] 特性中的 Default 属性或者 [PropertyDefaultOverride] 特性。 如果你仅通过某个构造方法进行初始化,那么只有在你显式调用它的时候才会有默认值,而其他时候(比如在蓝图中构造)无法正确设置默认值。 这是因为构造 UStruct 实例的时候不会立即构建共轭,而是会等到第一次跨边界时才构建,这就导致托管构造方法不会立即执行。

NetSerialize

你可以在 UStruct 中定义 NetSerialize() 方法来实现自定义的网络序列化。

下面的示例代码定义了一个用于同步普通 .NET 对象的方法:

public class MyData
{
public uint64 Id;
public Dictionary<string, int32> Data;
}

[UStruct]
public partial class MyReplicationProxy
{
[NetSerialize]
private bool NetSerialize(IArchive ar, PackageMap map, out bool success)
{
if (Owner is not { IsExpired: false })
{
success = false;
return false;
}

if (ar.IsSaving)
{
ar.Serialize(JsonSerializer.Serialize(Owner.Data));
}
else if (ar.IsLoading)
{
ar.Serialize(out var data);
Owner.Data = JsonSerializer.Deserialize<MyData>(data);
}
else
{
checkNoEntry();
}

success = true;
return true;
}

[UProperty, NotReplicated]
public partial UnrealObject? Owner { get; set; }
}

[UClass]
public partial class MyObject
{
public MyData Data { get; set;}

protected override void PostInitProperties()
{
RepProxy.Owner = this;
}

[UProperty, Replicated]
private partial MyReplicationProxy RepProxy { get; set; }
}

实际上,支持一般的序列化乃至其他 StructOps 也不麻烦,但 NetSerialize 是最迫切需要的。

字段和自动属性

你可以在 UStruct 中定义字段和自动属性,但你一般不应该这样做,因为 UStruct 的引用非常不稳定,极有可能可能在你预期之外的地方被指向其他实例,从而丢失数据。

UEnum

定义 UEnum 需要使用 [UEnum] 特性。 这个特性的作用等同于 UENUM() 宏,将它添加到你的枚举类上,就定义了一个 UEnum。 可以继续向其添加 [BlueprintType] 特性,来将其转变为一个对蓝图可见的枚举。 可以继续向其添加 [Flags] 特性,来将其转变为一个位域。

下面的示例代码定义了一个 UEnum:

namespace Game.MyGameModule;

[UEnum, BlueprintType]
[UnrealFieldPath("/Script/MyGameModule.EMyEnum")]
public enum EMyEnum : uint8
{
[DisplayName("Value 1")] Value1,
[DisplayName("Value 2")] Value2,
[Hidden] Max,
}

[UEnum] 特性对目标类型有一些要求:

  • 必须是 enum。
  • 命名空间要求和 UClass 一样。
  • 必须显式指定 [UnrealFieldPath] 特性,且参数是 /Script/<类型所在的 C++ 模块名>.<类名>。可以直接使用 Codefix 自动补全。
信息

理论上,可以通过 Rewrite 程序集来自动添加 [UnrealFieldPath] 特性,但这种方案的结果无法体现在源代码上,因此我们最终选择利用静态分析器来做这件事。

Underlying Type

UEnum 的 UnderlyingType 会被正确识别,该类型的 UProperty 会优先使用枚举的 UnderlyingType。

如果你的枚举要对蓝图可见,则 UnderlyingType 必须是 uint8; 如果一个枚举类型的 UProperty 要对蓝图可见,但枚举类型的 UnderlyingType 不是 uint8,则需要在属性上标记 [EnumAsByte] 特性来强行转换为 uint8。 如果不满足以上条件,则无法通过编译。

下面的示例代码将一个 ECollisionChannel : int32 类型的枚举属性转变为蓝图可见:

[UProperty, BlueprintReadWrite, EnumAsByte]
public partial ECollisionChannel CollisionChannel { get; set; }

UDelegate

定义 UDelegate 需要使用 [UDelegate] 特性。 这个特性的作用等同于 UDELEGATE() 宏,将它添加到你的类上,就定义了一个 UDelegate。 实际上,当你在 U++ 中定义 UDelegate 时,无论加不加 UDELEGATE() 宏,UHT 都能正确识别。 但对于 Z# 来说,需要区分导出的委托和定义的委托,因此必须标记 [UDelegate] 特性。

委托支持单播、多播、稀疏三种,根据基类的不同,Z# 会将其识别为对应类型的委托:

  • 如果基类是 ZeroGames.ZSharp.UnrealEngine.CoreUObject.UnrealDelegateBase,则生成单播委托。
  • 如果基类是 ZeroGames.ZSharp.UnrealEngine.CoreUObject.UnrealMulticastInlineDelegateBase,则生成多播委托。
  • 如果基类是 ZeroGames.ZSharp.UnrealEngine.CoreUObject.UnrealMulticastSparseDelegateBase,则生成稀疏委托。
  • 如果基类不是以上三者中的任何一个,则无法通过编译。

定义 UDelegate 的方式与定义 .NET 委托的方式有区别,下面的示例代码定义了一个单播 UDelegate:

[UDelegate]
public partial class GetNameDelegate : UnrealDelegateBase
{
public delegate UnrealString Signature();
}

可以看到,UDelegate 并不直接对应委托类本身,而是它外层的 class。 这是因为 UDelegate 本身与 .NET 委托并不等价,实际上,我们是用一个 .NET 对象来表示 UDelegate 的共轭对象。 这个对象像其他共轭对象一样,负责与虚幻引擎的委托对象进行互操作。 定义一个 UDelegate,本质是在定义它的共轭类型,内部的 Signature 委托则是该委托的签名。

如果你定义的是一个多播委托或者稀疏委托,则返回值类型必须是 void,否则无法通过编译。

内部委托类

UDelegate 支持嵌套在 UClass 内部,以避免污染全局命名空间,Z# 同样支持这个特性。 下面的示例代码在 UClass 内部定义了一个多播 UDelegate(事件)。

[UClass]
public partial class MyActor : Actor
{
[UDelegate]
public partial class OnDamaged : UnrealMulticastInlineDelegateBase
{
public delegate void Signature(float damage);
}
}

UInterface

目前版本尚未支持在托管侧定义和实现 UInterface。

UFunction

可以在 UClass 中定义 UFunction,具体有以下几种情形。

普通函数

[UFunction] 特性添加到一个方法上,就定义了一个普通的 UFunction。 大多数情况下,普通的 UFunction 是作为暴露给蓝图的接口使用。 可以继续向其添加 [BlueprintCallable][BlueprintPure] 特性,来将其转变为一个对蓝图可见的函数。

以下示例代码定义了一个蓝图可以调用的函数:

[UFunction, BlueprintCallable]
public void Say(UnrealString? content)
{
UE_LOG(LogTemp, $"{GetName()} says: {content}");
}

蓝图事件函数 (Blueprint Event)

可以给 UFunction 添加 [BlueprintNativeEvent][BlueprintImplementableEvent] 特性来将其转变为一个蓝图事件函数(虚函数)。 大多数情况下,蓝图事件函数是作为暴露给蓝图的框架使用。蓝图为其提供实现,基类在某个流程中调用这个函数。

如果你使用 [BlueprintNativeEvent],就必须在托管代码中为其提供默认实现,同时托管代码中的子类也可以重写这个函数; 如果你使用 [BlueprintImplementableEvent],就只有蓝图子类可以为其提供实现,你和你的托管子类都不可以。

以下示例代码展示了 [BlueprintNativeEvent][BlueprintImplementableEvent] 的用法:

[UFunction, BlueprintNativeEvent]
public partial void PreAttributeChange(int32 attributeId, double oldValue, ref double newValue);
protected virtual partial void PreAttributeChange_Implementation(int32 attributeId, double oldValue, ref double newValue)
{
newValue = Math.Clamp(newValue, 0, 10000);
}

[UFunction, BlueprintImplementableEvent]
public partial void PostAttributeChange(int32 attributeId, double oldValue, double newValue);

蓝图事件函数必须是没有方法体的 partial 方法,因为 Z# 的静态分析器需要为其生成一些模板代码。 和 C++ 一样,你不应该为蓝图事件函数本体提供实现,因此它也不应该是 virtual 的。 如果你使用 [BlueprintNativeEvent],你应该定义一个与其签名完全一致并以 _Implementation 为后缀的 protected virtual 方法,并在这里提供默认实现。

信息

和 C++ 一样,默认情况下,蓝图事件函数只能被蓝图重写,不能被蓝图调用。 你需要使用 [BlueprintCallable][BlueprintPure] 特性来将其转变为蓝图可调用。

远程函数 (RPC)

可以给 UFunction 添加 [Server][Client][NetMulticast] 特性来将其转变为一个远程函数 (RPC)。 使用 RPC 时,你必须显式指定 [Reliable][Unreliable] 其中之一,否则无法通过编译。 可以继续向其添加 [WithValidation] 特性,来将其转变为一个需要校验的函数。 如果你不希望子类重写这个方法,可以向其添加 [SealedEvent] 特性,这会将 Implementation 方法转变为 private,从而阻止子类重写。

以下实例代码定义了一个需要校验的、不可重写的 Server RPC:

[UFunction, Server, Reliable, WithValidation, SealedEvent]
public partial void ServerInteractWith(Actor? target);
private partial void ServerInteractWith_Implementation(Actor? target)
{
if (target is not null)
{
UE_LOG(LogTemp, $"{GetName()} interact with {target.GetName()}.");
}
}
private partial bool ServerInteractWith_Validate(Actor? target)
{
if (target is null)
{
return true;
}

if (KismetMathLibrary.Vector_DistanceSquared(this.K2_GetActorLocation(), target.K2_GetActorLocation()) > 100 * 100)
{
return false;
}

return true;
}

引用参数和出参

refout 修饰符会影响 UFunction 参数的性质,和 U++ 的具体对应关系如下:

  • 输入:Vector -> Vector
  • 输出:out Vector -> Vector&
  • 输入&输出:ref -> UPARAM(ref) Vector&

UProperty

可以在 UClass 和 UStruct 中定义 UProperty。 将 [UProperty] 特性添加到一个属性上,就定义了一个 UProperty。 目标属性必须是没有方法体的 partial 属性,因为 Z# 的静态分析器需要为其生成一些模板代码。 可以继续向其添加 [BlueprintReadWrite][BlueprintReadOnly] 特性,来将其转变为一个对蓝图可见的属性。 如果你希望在属性编辑器中查看/编辑这个属性,可以使用 [EditAnywhere][VisibleAnywhere] 系列特性。

以下示例代码定义了一个蓝图只读、可编辑的 UProperty:

[UProperty(Default = 1000), BlueprintReadOnly, EditAnywhere]
public partial int32 Money { get; set; }

partial 属性不是自动属性,因此无法指定默认值。 [UProperty] 特性提供了一个参数,可以在 UObject 对象实例化时设置该属性的默认值。 这是一个 Object 类型的参数,Z# 最终会将这个参数 ToString() 后的值传递给非托管侧,并使用 FProperty::ImportText_Direct() 函数解析默认值。 对于数字、布尔、字符串等简单类型,开发者直接填托管代码对应的形式即可;对于复杂类型,需要开发者了解它们各自的文本格式,这属于虚幻引擎的基础知识,这里不进一步展开。 如果默认值解析失败,将触发断言。这可以帮助开发者尽早发现错误。

属性复制 (Replication)

可以向 UProperty 添加 [Replicated] 特性,来将其转变为一个可以被网络复制的属性。 托管类无法重写 GetLifetimeReplicatedProps() 函数,但这个函数的职责有限,Z# 将开发者可能在这个函数中做的事都集成到了 [Replicated] 特性中。 复制回调也被集成到了 [Replicated] 特性中,因此托管代码中不存在 [ReplicatedUsing] 特性。

虽然 [Replicated] 特性能收集到足够多的有关属性复制的元数据,但运行时总归还是需要从 GetLifetimeReplicatedProps() 函数注册到网络模块中。 因此,想在托管类中使用属性复制,它的 C++ 基类必须满足一些要求,满足要求的父类会自动收集并注册托管子类中定义的可复制属性:

  1. 实现 IZSharpReplicatedObject 接口。
  2. 重写 GetLifetimeReplicatedProps() 函数,并在重写版本中调用 IZSharpReplicatedObject::ZSharpReplicatedObject_GetLifetimeReplicatedProps() 函数。

以下示例代码定义了一个禁用 Push Model 的(默认启用)、只同步给主控端的、有复制回调且无论属性是否变化都会触发的 UProperty:

[UFunction]
public void OnRep_MyName()
{
UE_LOG(LogTemp, $"MyName changed to {MyName}");
}

[UProperty, Replicated(Notify = nameof(OnRep_MyName), Condition = ERepCondition.OwnerOnly, NotifyCondition = ERepNotifyCondition.Always, IsPushBased = false)]
public partial UnrealString MyName { get; set; }

和 C++ 一样,复制回调必须是一个 UFunction。 在给可复制属性赋值时,你无需显式调用 MarkPropertyDirty() 系列接口(无论是否启用 Push Model),因为 Z# 的静态分析器生成的 set 方法会自动为你做这件事。 正因如此,Z# 才将 Push Model 默认启动,因为开发者对它完全没有任何感知,不会像 C++ 一样影响上层编码方式。除非有特殊原因,否则没有理由关闭 Push Model。

需要注意的是,如果你直接修改某个属性本身的值而不是重新赋值,则需要显式调用 MarkPropertyDirty():

MyVector.X = 100;
this.MarkPropertyDirty(nameof(MyVector));

属性通知 (Field Notify)

可以向 UProperty 添加 [FieldNotify] 特性,来将其转变为一个带有属性通知的属性。 和 C++ 一样,支持属性通知的类型需要实现 INotifyFieldValueChanged 接口。 这个接口只能从 C++ 中实现,因此想在托管类中支持属性通知,就必须继承正确实现了 INotifyFieldValueChanged 接口的 C++ 基类。 正确实现意味着,C++ 基类能正确收集到托管子类中定义的属性通知。 具体可以参考 UMVVMViewModelBase 收集蓝图属性通知的方式。

以下示例代码定义了一个带有属性通知的 UProperty:

[UProperty, FieldNotify]
public partial int32 Health { get; set; }

在给带有属性通知的属性赋值时,你无需显式调用 BroadcastFieldValueChanged() 系列接口,因为 Z# 的静态分析器生成的 set 方法会自动为你做这件事。 因此,使用属性通知时,只将 [FieldNotify] 特性添加到 UProperty 上即可,无需关注其他事情。

需要注意的是,如果你直接修改某个属性本身的值而不是重新赋值,则需要显式调用 BroadcastFieldValueChanged():

MyVector.X = 100;
BroadcastFieldValueChanged(nameof(MyVector));

C++ 模块的加载顺序

在动态共轭类型中依赖其他共轭类型(包括继承、实现、定义 UProperty)需要开发者自己保证被依赖类型的 C++ 模块优先加载。 这是因为动态共轭类型的 C++ 模块加载时会构建反射信息,这里假定被依赖的类型已经在内存中。 而 Z# 无法通过分析托管代码来发现 C++ 项目中的错误,只能人为保证。

如果 C++ 模块的加载顺序错误,构建反射信息时会因为找不到依赖项而触发断言。 虽然这个断言只能在运行时触发,但构建反射信息的时机非常早,以此作为帮助开发者发现错误的手段也足够了。

当你在托管代码中依赖了其他共轭类型,只需要让你的类型所在的 C++ 模块引用被依赖类型所在的 C++ 模块,就可以保证被依赖类型优先加载。

虚拟模块

你可以选择将动态类型生成到一个虚拟模块中。 虚拟模块不对应任何实际 C++ 模块,是一个仅通过配置生成的虚拟容器。

你可以在 Project Settings > ZSharp Runtime Settings > Emit Virtual Modules 中配置虚拟模块的名字和加载时机。 如果虚拟模块与某个实际 C++ 模块重名,则会被忽略。

使用虚拟模块可以提高代码的可复用性。你可以将你的组件放到虚拟模块中编译成托管程序集,并发布给用户。 用户只需要引用你的托管程序集并正确配置虚拟模块即可使用你的组件,而不需要和你完全相同的 C++ 项目环境。