UnLua 实现原理解析
UnLua 是一个把 Lua 虚拟机与 UE 反射系统对接的脚本方案,让 Lua 能直接读写 UObject 属性、调用/覆写 C++ 与蓝图函数,从而实现热更新。
零、前言:为什么需要 UnLua?
- ✅ 替代蓝图编写业务逻辑(无需重新编译)
- ✅ 直接操作 UE 的 UObject 对象
- ✅ 调用/覆写 C++ 函数和蓝图事件
- ✅ 游戏打包后可以实现热更
- ✅ 开发的时候可以热重载
一、实现概述
UnLua 模块会向 UE 注册 一个UObject创建的监听器,每当 UObject 被创建就触发回调。
若该类实现了 IUnLuaInterface(静态绑定)或动态指定了模块名,unlua就会 加载 对应 Lua 文件并完成绑定。
然后它用三层元表链 (实例→Lua模块→UClass 反射表)做方法/属性查找;属性和函数懒加载并缓存 ;对蓝图事件通过新建 ULuaFunction 并注入原函数字节码的方式实现覆写;C++ 调 Lua 靠 Registry 引用,Delegate 用 ULuaDelegateHandler 代理,Latent 函数用 Lua 协程的 yield/resume 实现异步。最终做到"Lua 写业务 + C++ 做底层 + 运行时热更新"。
- 自动绑定:监听 UObject 创建
UnLua 模块会向 UE 注册 一个UObject创建的监听器,每当 UObject 被创建就触发回调。
若该类实现了 IUnLuaInterface(静态绑定)或动态指定了模块名,就会 require 对应 Lua 文件并完成绑定。
2.绑定的本质:三层元表链
INSTANCE(实例表,存 .Object 指向 UObject)
└─metatable→ REQUIRED_MODULE(你写的 Lua 逻辑)
└─metatable→ UCLASS Metatable(UE 反射信息)
查方法:先看你写的 Lua,找不到再走 UE 反射。
查属性:最终落到 __index / __newindex,通过反射读和写 C++ 成员。
- 函数覆写(最核心黑科技)
针对 BlueprintImplementableEvent / BlueprintNativeEvent 这类"可覆写"函数。
UnLua 新建一个 ULuaFunction,把原 UFunction 的 Script 字节码注入一条跳转指令,让 NativeFunc 指向 execCallLua。
引擎调原函数 → 跳字节码 → execCallLua → 调到 Lua 函数。无需改 C++ 源码,运行时劫持。
UnLua 的架构总览
┌─────────────────────────────────────────────────────────────┐
│ Lua 脚本层 │
│ local M = UnLua.Class() │
│ function M:ReceiveBeginPlay() ... end │
│ return M │
├─────────────────────────────────────────────────────────────┤
│ UnLua 绑定层 │
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────────┐ │
│ │ 静态绑定 │ │ 动态绑定 │ │ 函数覆写机制 │ │
│ │IUnLuaInterface│ │FLuaDynamicBnd│ │ULuaFunction+字节码│ │
│ └─────────────┘ └──────────────┘ └───────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ UE 反射系统 │
│ UClass / UFunction / UProperty / FPropertyDesc │
└─────────────────────────────────────────────────────────────┘
下面具体讲怎么实现的
二、核心概念通俗解释
想要实现热更,就需要用到虚拟机和lua,lua是嵌入式语言。
C++ 编译后会变成硬件能直接执行的机器码,一旦打包就改不了;
而CPU只会和虚拟机打交道,虚拟机本身是写死的,但是虚拟机读取的lua代码是可以随时变更的,所以这样就可以实现热更。
2.1 虚拟机(FLuaEnv)--- Lua 的"执行环境"
想象一下:Lua虚拟机就像一个独立的"小世界"。
在这个"小世界"里:
- 有自己的变量、函数、表(table)
- 可以加载和执行 Lua 脚本文件
- 与 UE 的 C++ 世界通过"桥梁"连接
2.2 绑定(Binding)--- 建立 C++ ↔ Lua 的连接
lua想要和C++交互,就需要建立绑定关系
绑定 就是让一个** /C++ 的 UObject 在 Lua 中有一个"替身"/ 。**
C++ 世界 Lua 世界
┌──────────────┐ ┌──────────────┐
│ ABattlePlayer│ ←── 绑定 ──→ │ INSTANCE表 │
│ (UObject) │ │ .Object │
└──────────────┘ └──────────────┘
当你访问 Lua 中的这个"替身"时,实际上是在操作对应的 C++ 对象。
2.3 元表(Metatable)--- Lua 的"面向对象魔法"
lua本身内部有自己的变量,想要实现lua可以操控自己的变量,又可以操控它所绑定的C++对象的变量,就要通过元表。
先介绍下如果通过元表实现继承:
Lua 本身不是面向对象语言,但它通过**元表(Metatable)**实现了类似 OOP 的行为:
- 当你访问
obj.Name时,如果obj里没有Name - Lua 会自动去它的"元表"里找,如果书写__index函数
这就是 UnLua 实现"继承"和"方法调用"的核心机制!
而另一方面,-** 元表还可以有元表,形成**查找链****
有了这种机制,就可以通过lua去调用它所绑定的C++物体的变量了,
通过__newIndex还可以实现写入
四、两种绑定方式详解
绑定,核心就是在于为C++类,声明好它要绑定哪个lua文件,首先需要为其实现IUnLuaInterface 接口,然后让其实现接口函数:GetModuleName,用来告诉UnLua这个类对应哪个Lua模块。
4.1 静态绑定 --- "预先约定"
适用场景:你明确知道哪些 C++ 类需要在 Lua 中使用。
实现方式 :让这些类继承 IUnLuaInterface 接口
在 NRC 项目中,很多 UI 类都使用了这种方式:
cpp
// UIIcon.h - NRC项目实际代码
#include "UnLuaInterface.h"
UCLASS()
class NRC_API UUIIcon : public UNRCUserWidget, public IUnLuaInterface
{
GENERATED_BODY()
public:
// 必须实现的接口函数 --- 告诉UnLua这个类对应哪个Lua模块
virtual FString GetModuleName_Implementation() const override;
UFUNCTION(BlueprintImplementableEvent)
void OnIconLoaded(const FString& assetPath, UObject* content);
};
工作流程:
- C++ 类声明实现
IUnLuaInterface接口 - 重写
GetModuleName_Implementation()返回 Lua 模块名(如"UIIcon") - UnLua 自动监听对象创建 → 加载对应的 Lua 文件 → 建立绑定
项目案例:
| 类名 | 用途 | 所在文件 |
|---|---|---|
UUIIcon |
图标控件 | UI/Widgets/UIIcon.h |
UNRCWidgetLoader |
Widget加载器 | UI/Widgets/NRCWidgetLoader.h |
UNRCGridViewEx |
网格列表扩展 | UI/Widgets/NRCGridViewEx.h |
ABattlePlayer |
战斗玩家角色 | Battle/BattlePlayer.h |
4.2 动态绑定 --- "按需创建"
适用场景:在 Lua 中动态创建对象并绑定。
lua
-- Lua 中动态创建 Widget 并立即绑定
local IconWidget = NewObject(WidgetClass, self, nil, "Tutorials.IconWidget")
-- 此时 UnLua 会自动完成绑定流程
这种方式不需要 C++ 类事先实现接口,更加灵活。
上面为C++的对象声明了其要绑定的lua文件,接下来讲讲这个文件的创建过程。
五、对象绑定的内部机制
5.1 全局监听器监听UObject的创建,然后去绑定
UnLua 通过注册 UE 的全局对象监听器来实现自动绑定:
cpp
class FUnLuaModule : public IUnLuaModule,
public FUObjectArray::FUObjectCreateListener, // 创建监听
public FUObjectArray::FUObjectDeleteListener // 销毁监听
{
// 当任何UObject被创建时触发
virtual void NotifyUObjectCreated(UObject *Object, int32 Index) override
{
// 检查是否需要绑定
if (ShouldBind(Object))
{
Bind(Object); // 执行绑定
}
}
};
这就像是给 UE 的"出生登记处"装了一个监控摄像头,每当新对象"出生",UnLua 就会检查是否需要给它安排一个 Lua 替身。
5.2 创建绑定文件
首先检查这个对象有没有实现是否实现了IUnLuaInterface,如果有实现,则加载其对应的lua文件,
然后导出类描述,也就是创建一个METATABLE_UOBJECT,它记录了记录了这个 UE 类(UClass)的所有反射信息------属性、函数、__index和__newIndex的方法。
然后为其创建一个实例表
这个实例表(Instance)里存的是一个userdata,这个userdata里装着一个指向C++对象的指针。
然后这个实例表接下来还会存你自己在lua里新建的变量。
接下来就会为其设定元表链结构了。
5.3 元表链结构(关键设计)
这是 UnLua 最精妙的设计之一!每个绑定的对象在 Lua 中形成一个三层元表链:
lua
第 1 层:INSTANCE (实例表)
│
│ .Object ── 指向 ABattlePlayer 的 C++ 指针
│ (其他字段暂时没有)
│
│ 设置元表 → 指向下面
▼
第 2 层:REQUIRED_MODULE (开发者写的 Lua 模块)
│
│ function M:OnHurt(Damage) ... end ← 你在 Lua 里写的方法
│ function M:ReceiveBeginPlay() ... end
│
│ 设置元表 → 指向下面
▼
第 3 层:METATABLE_UOBJECT (UE 反射信息表)
│
│ __index ── 通过 UE 反射读取 C++ 属性(比如 Health)
│ __newindex ── 通过 UE 反射写入 C++ 属性
│ (包含所有 UProperty/UFunction)
│
└─ 没有下一层了
这三层的职责分工:
| 层级 | 存放什么 | 举例 |
|---|---|---|
| 第1层 实例表 | 这个对象自己的临时数据 | self.SomeTempFlag = true |
| 第2层 Lua模块 | 你写的业务逻辑 | function M:OnHurt() |
| 第3层 UClass反射 | UE 端定义的原生能力 | C++ 的 Health、PlayAnim() |
调用链示例 :
当你在 Lua 中调用 self:ReceiveBeginPlay() 时:
- 先在
INSTANCE表中找 → 没有 - 去
REQUIRED_MODULE(父类)找 → 找到了!(你定义的那个函数)→ 直接调用
当你写 self.Health 时:
- 先在
INSTANCE表中找 → 没有 - 去
REQUIRED_MODULE找 → 没有 - 触发
METATABLE.__index→ 通过 UE 反射找到Health属性 → 返回值
六、属性与方法的懒加载机制
6.1 为什么需要懒加载?
一个 UE 类可能有成百上千个属性和方法,如果在绑定时全部导出,会非常慢且浪费内存。
UnLua 的解决方案 :用到才导出,导出一次后缓存起来。
七、函数覆写机制
7.1 什么是函数覆写?
覆写 就是用 Lua 函数替代 C++ 函数的实现。比如你想自定义 ReceiveBeginPlay() 的逻辑,就可以在 Lua 中写一个新的版本。
7.2 哪些函数可以被覆写?
其次,希望lua可以重写的C++的函数,需要将其声明为蓝图可覆盖:
| 宏 | C++实现 | 蓝图/Lua能力 |
|---|---|---|
UFUNCTION() |
✅ 有实现 | ❌ 不能覆写 |
UFUNCTION(BlueprintNativeEvent) |
✅ 有默认实现 | ✅ 可覆写(C++ 版本为 _Implementation()) |
UFUNCTION(BlueprintImplementableEvent) |
❌ 无实现! | ✅ 必须在蓝图/Lua 中实现 |
具体如下:
UFUNCTION(BlueprintImplementableEvent)
void OnIconLoaded(const FString& assetPath, UObject* content);
7.3 覆写的实现原理(字节码注入)
这是 UnLua 整个方案中最精妙、也最"黑科技"的一部分。要讲清楚它的思路,得先回到一个最根本的难题。
7.3.1 核心难题:已编译的 C++ 函数根本改不了
当我们在 Lua 里写了一个 ReceiveBeginPlay 函数,希望它"替代"掉 C++ 原版的实现时,有一个绕不开的事实:C++ 函数在编译之后就变成了一段固定的机器码,被刻在 exe 里的某个地址上。既然是刻在那里的二进制指令,就不可能在游戏运行时把它挖出来、换成别的。
那是不是就没办法了?不是------虽然函数体本身改不了,但我们可以改变"引擎怎么找到它"这件事。UnLua 正是在"调用入口"这一层下手,让引擎看起来调用的是原函数,实际却跳到了 Lua 逻辑上去。
7.3.2 整体思路:偷梁换柱的两步走
UnLua 的覆写机制本质上可以概括成两句话:
- 造一个假的
UFunction(叫ULuaFunction),它的 Native 入口直接连到一个叫execCallLua的 C 函数,这个函数的工作就是"转调到 Lua"。 - 改造原来的
UFunction,让引擎在调用它时,第一时间就跳到我们造的这个假UFunction上去。
打个比方:原本引擎有一张"电话簿",上面记着"ReceiveBeginPlay 的号码是 C++ 版本"。UnLua 做的事情不是去删这条记录,而是在这个号码前面插了一个呼叫转移------你还是拨那个号,但电话响在 Lua 那边。这样整个 UE 引擎完全无感知,它以为自己还是在正常调用 UFunction。
整个替换关系如下:
原始情况:
AGamePlayer::ReceiveBeginPlay() { /* 原始C++代码 */ }
覆写后:
┌─────────────────────┐
│ ULuaFunction (新建) │ ← 假的UFunction,Native入口指向execCallLua
│ - NativeFunc: execCallLua │
│ - 引用: Lua函数地址 │
└─────────────────────┘
↑
│ 劫持
│
┌─────────────────────┐
│ 原始UFunction │ ← 头部被注入一条"跳转指令"
│ - 注入: 跳到ULuaFunction │
└─────────────────────┘
7.3.3 第一步:建立"Lua 入口"------SetNativeFunc
UE 的 UFunction 里有一个关键字段叫 NativeFunc,它是一个 C 函数指针,决定了这个 UFunction 被调用时真正执行的那段 C++ 代码 。UnLua 创建 ULuaFunction 时,会把这个字段设置成自己写的 execCallLua。
这样一来,只要引擎把调用路由到了这个 ULuaFunction,控制流就会自动进入 execCallLua------在那里 UnLua 会做三件事:从 Lua 虚拟机取出对应的 Lua 函数、把 UE 侧的参数压到 Lua 栈上、调用 Lua 函数并把返回值写回。
然后 UnLua 还得把这个假 UFunction 注册进 UClass 的函数映射表里,这样 UE 反射系统才认得它。对应的核心代码是:
cpp
// 把入口函数设置为 execCallLua ------ Lua 的中转站
SetNativeFunc(execCallLua);
// 注册到所属 UClass 的函数表里,让 UE 反射能查找到它
Class->AddFunctionToFunctionMap(this, *GetName());
做完这一步,一个"长得像 UFunction、实际调用会跳去 Lua"的新函数就造好了。但光有它还不够------因为引擎依然会去调原来那个 UFunction,根本不知道这个新东西存在。
7.3.4 第二步:劫持原函数------字节码注入
这里就要用到 UE UFunction 的另一个特性:每个 UFunction 都带一段字节码(Script 数组) ,引擎在执行蓝图/脚本函数时会先过这段字节码。UnLua 正是利用这一点,在原函数的字节码头部插入一条特殊指令------"立刻跳转到某个指定的 ULuaFunction 去执行"。
具体做法可以分解成三个动作:
- 扩容 :在原
UFunction->Script数组里预留一块空间,大小是指令头 + 一个 ULuaFunction 指针。 - 定位:算出这块新空间的起始位置。
- 写入:把指令头和我们那个假 UFunction 的地址写进去。
对应代码是:
cpp
// 在原 UFunction 的字节码里扩出空间,用来装"跳转指令 + 目标地址"
Function->Script.AddUninitialized(
ScriptMagicHeaderSize + sizeof(ULuaFunction*)
);
// 定位到新增空间的起始位置
uint8* Data = &Function->Script[Function->Script.Num() -
(ScriptMagicHeaderSize + sizeof(ULuaFunction*))];
// 把我们的 ULuaFunction 指针写进去,作为跳转目标
FPlatformMemory::WriteUnaligned<ULuaFunction*>(Data + ScriptMagicHeaderSize, this);
这样一来,原 UFunction 的"开场白"就变成了一句"请改道去找 ULuaFunction"。从此刻起,任何一次对原函数的调用都会被这条指令截胡。
7.3.5 整体调用流程串起来
当上面两步做完后,整个覆写机制就形成了一条完整的重定向链。我们可以用一次"蓝图调用 PlayAnimByType"的过程来体会整个流程:
① 引擎/蓝图发起调用 PlayAnimByType(...)
↓
② 进入原始的 UFunction,开始执行它的字节码
↓
③ 字节码第一条就是 UnLua 注入的"跳转指令"
↓
④ 控制流跳到我们造的 ULuaFunction 上
↓
⑤ ULuaFunction 的 NativeFunc = execCallLua
↓
⑥ execCallLua 查找对应的 Lua 函数,压参、调用
↓
⑦ Lua 中的 PlayAnimByType 执行业务逻辑
↓
⑧ 返回值写回 UE 栈,调用方拿到结果 ✓
全程 UE 引擎"以为"自己在走正常的 UFunction 调用链路 ,完全不知道中间被插了一刀。这种做法的高明之处在于:UnLua 既没有改 C++ 源码、没有重新编译引擎,也没有动原 UFunction 的任何逻辑结构,只是在字节码入口处悄悄地放了一条指引。
7.3.6 为什么只有蓝图可覆写函数能走这条路?
这也是为什么第 7.2 节强调"只有 BlueprintImplementableEvent / BlueprintNativeEvent / 虚事件函数才能被覆写"------这些函数的调用路径本身就要经过字节码执行器 ,UnLua 的注入才有效。而普通的 UFUNCTION() 调用是直接 C++ 函数调用,根本不走字节码,也就没地方"注入跳转指令"了。
换句话说,UnLua 不是能覆写"所有 C++ 函数",而是能覆写"所有走字节码调用路径的 UFunction"。这是它的能力边界。
7.4 实际应用示例
cpp
// GamePlayer.h
UCLASS()
class GAME_API AGamePlayer : public ACharacter
{
GENERATED_BODY()
public:
// BlueprintImplementableEvent = 可被Lua覆写!
UFUNCTION(BlueprintImplementableEvent, BlueprintCallable)
void PlayAnimByType(EPlayerAnimType Type);
};
对应的 Lua 代码可能是这样:
lua
-- GamePlayer.lua (假设)
local M = UnLua.Class()
function M:PlayAnimByType(Type)
-- 自定义动画播放逻辑,完全替代C++的实现
if Type == EPlayerAnimType.Idle then
self:PlayAnimation(IdleAnim)
elseif Type == EPlayerAnimType.Attack then
self:PlayAnimation(AttackAnim)
end
end
return M
八、C++ 主动调用 Lua --- 回调机制
可以通过lua去覆写c++同名函数 然后c++调用这个函数即可
或者通过虚拟机的方式,具体可以看C++调用lua的方法总结。
简单来说就是,它可以直接调用全局函数,或者调用全局变量,将参数压入栈,然后就可以调用,函数返回值就在调用后的栈。
除此之外,还可以调用某个模块的指定函数,因为模块在lua里是唯一的。
还可以判断这个c++对象有没有绑定的lua对象,有的话也可以调用它的。