跳到主要内容

互操作

更新日期:2025-03-10

Z# 作为一个脚本引擎,需要提供的核心能力就是互操作。 所谓互操作,就是脚本引擎与宿主引擎进行信息交换的过程。 因此,互操作包含两部分:脚本调用宿主宿主调用脚本。 宿主在这里既包括虚幻引擎本体,也包括项目的 C++ 部分。

宿主和脚本互操作的方式有 ZCall函数指针 两种,前者是 Z# 提供的机制,后者是 .NET 原生的机制。 这两种方式都不是类型安全的,但 Z# 在更上层为 ZCall 提供了类型安全的封装,大部分开发者都无需接触不安全的部分。 函数指针一般在对性能比较敏感的场景使用,Z# 底层大量使用了这种方式来获得极致的性能。 开发者也可以使用函数指针进行互操作,并且不需要 Z# 方面的知识,只要对 .NET 足够了解即可。

这里主要介绍如何通过 ZCall 实现互操作。 这是 Z# 主推的方式,开发者无需手写复杂的胶水代码,安全系数高,易用性强,性能在绝大多数场景下也不会构成问题。

脚本调用宿主

使用 ufup 协议

ufup 协议导出接口到托管侧最便捷的方式,因为它内部直接使用反射动态调用,不需要手写任何胶水代码。

使用 uf 协议时,你只需要在某个 UClass 里定义一个 UFunction(不需要 BlueprintCallable/BlueprintPure)。 函数在托管侧的可见性与 C++ 中定义的相同,如果这是暴露给外部的接口,使用 public,否则使用 protected 或 private。 使用 up 协议的方式和 uf 类似,只不过换成定义 UProperty(不需要 BlueprintReadWrite/BlueprintReadOnly)。

例如,你可以在你的 C++ 项目中封装一个静态函数库供脚本调用:

// MyGameplayStatics.h
UCLASS()
class UMyGameplayStatics : public UBlueprintFunctionLibrary
{
GENERATED_BODY()

public:
UFUNCTION()
static int32 GetTagStackCount(APlayerPawn* player, FName tag);
}

编译 C++,执行控制台命令 z#.geng assemblies=<你的程序集名> 同步胶水代码,你就可以在托管代码中调用新增的接口了:

// MyGameplayStatics.d.cs
public partial class MyGameplayStatics : BlueprintFunctionLibrary
{
public static partial int32 GetTagStackCount(PlayerPawn? player, UnrealName tag);
}

// Client
PlayerPawn? player = ...;
int32 tagCount = MyGameplayStatics.GetTagStackCount(player, "Bleeding");
UE_LOG(LogTemp, $"Player {player.GetName()} has {tagCount} stacks of bleeding.");

如果你想调用没有胶水代码的接口,如未导出的 C++ UClass 和蓝图类中的函数,可以使用 Dynamic ZCall。 例如,以下代码动态地调用了控件蓝图中定义的 HasTitle() 函数,进而访问了控件蓝图中定义的 TitleWidget 子控件:

UserWidget myWidget = ...;
if (myWidget.CallUnrealFunction<bool>("HasTitle"))
{
var title = myWidget.ReadUnrealProperty<TextBlock>("TitleWidget");
UE_LOG(LogTemp, $"My title is {title.GetText()}");
}
注意

Dynamic ZCall 是非类型安全的操作,开发者必须保证参数和返回值的类型正确,否则将产生未定义行为。

关于 uf! 协议

uf! 协议是没有多态行为的精确调用。预期上,这个协议应该仅在内部使用。

当动态共轭类型重写了一个 BlueprintEvent 时,可能需要调用父类的实现。这是 uf! 协议在设计上唯一的存在意义。 当子类重写并调用父类函数时,这个调用应该是一个精确调用。 Z# 在生成 BlueprintEvent 的胶水代码时,会生成两个胶水方法。 一个对应于本体,是提供给外界调用的版本;另一个对应于 Implementation,是提供实现的地方。 在 Implementation 的胶水代码中,就会使用 uf! 协议进行精确调用。

子类重写 BlueprintEvent 时,实际上是重写了 Implementation。 如果这是一个 BlueprintImplementableEvent,子类直接提供重写版本即可,因为父类的实现是空的; 如果这是一个 BlueprintNativeEvent,子类调用父类的 Implementation,就能实现对父类实现的调用。

使用 ex 协议(不完善)

ex 协议通常用来导出一些虚幻反射系统无法支持的接口,比如传递 UStruct 引用的。 目前 ex 协议虽然可用,但尚未支持自动生成胶水代码,易用性比较差,当前版本暂不推荐使用。

宿主调用脚本

使用 nm 协议

nm 协议可以让非托管代码调用托管方法,推荐配合 Emit 来以安全的方式使用。

下面这段示例代码创建了一个 Actor 子类,并重写了 ReceiveBeginPlay() 函数和 ReceiveEndPlay() 函数:

[UClass]
public partial class MyActor : Actor
{
protected override void ReceiveBeginPlay_Implementation() => UE_LOG(LogTemp, $"{GetName()} Begin Play!");
protected override void ReceiveEndPlay_Implementation(EEndPlayReason endPlayReason) => UE_LOG(LogTemp, $"{GetName()} End Play!");
}

当你在场景中实例化一个 MyActor 对象时,引擎就会在对应的时机通过 nm 协议调用到 ReceiveBeginPlay_Implementation() 方法和 ReceiveEndPlay_Implementation() 方法。

使用 nd 协议

nd 协议可以让非托管代码调用托管委托,推荐配合虚幻动态委托来以安全的方式使用。

下面这段实例代码监听了一个 Button 控件的点击事件,并在按钮点击时输出一条日志:

Button button = ...;
UnrealObject handle = button.OnClicked.Add(() => { UE_LOG(LogTemp, $"Button {button.GetName()} is clicked.") });
...
button.OnClicked.RemoveAll(handle);

委托的 Add() 方法返回了一个类型为 UnrealObject 的不透明的 Handle。 Handle 持有托管委托对象的强引用,因此 Handle 回收之前,托管委托对象不会被回收; 当 Handle 被回收时,绑定的委托也将失效,但托管委托对象的生命周期仍然由托管 GC 决定。 此时如果你在托管代码中有对托管委托对象的引用,就仍然可以正常使用它。 你可以将 Handle 赋值给一个 UPropertyStrongObjectPtr 来保持对它的引用。 在 Handle 被自动回收之前,你也可以使用 RemoveAll() 方法来显式解除绑定。

信息

如果你深入研究 Z# 的实现细节就会发现,使用 Remove(handle, "__Stub") 这种写法也可以解除 Handle 的绑定。 虽然理论上它的性能比 RemoveAll() 好一点点(微乎其微),但这仍然是一种不推荐的做法。 因为 Remove 不是一个高频操作,并且 __Stub 这个标识符在设计上不是 接口 而是 实现细节,我们不承诺未来不会无预警地改变这个名字。