概述
本教程将带你从零搭建 XUUI 框架的第一个示例 ------ HelloWorld。你将学会:
- 在 Unity 场景中创建 UI 并挂载适配器组件
- 编写 C# 入口脚本创建 XUUI Context
- 理解 Lua ViewModel 的 data / computed / commands 结构
- 理解整个 MVVM 数据绑定流程
1. XUUI 是什么
XUUI 是一个轻量级的 Unity MVVM 框架,使用 XLua 作为 ViewModel 层,Unity uGUI 作为 View 层。
核心思想:
View (uGUI) ←→ Adapter (C#) ←→ Binding Engine (Lua) ←→ ViewModel (Lua Table)
- View:Unity 场景中的 Text、Button 等 uGUI 组件
- Adapter :挂在 uGUI 组件上的 C# 脚本,提供
Value/OnAction等统一接口 - Binding Engine:Lua 侧的绑定引擎,负责将 Lua ViewModel 数据推送到 Adapter,或将 UI 事件传递回 ViewModel
- ViewModel :纯 Lua table,包含
data(数据)、computed(计算属性)、commands(命令)
2. 场景搭建
2.1 创建 GameObject 层级
在 Hierarchy 窗口中创建如下结构:
HelloWorld (GameObject)
├── Canvas
│ ├── MessageText (Text)
│ └── ClickButton (Button)
具体操作:
- 创建空 GameObject,命名为
HelloWorld------这就是入口 GameObject - 在
HelloWorld下创建 Canvas - 在 Canvas 下创建 Text,命名为
MessageText,调整好位置和字体大小 - 在 Canvas 下创建 Button,命名为
ClickButton,修改按钮上的 Text 为 "Click Me"
2.2 挂载适配器组件
XUUI 有两种绑定方式。本教程使用 AdapterBase 方式(MonoBehaviour 组件方式),更直观易懂。
给 MessageText 挂载 TextAdapter
- 选中
MessageTextGameObject - 在 Inspector 中点击 Add Component
- 搜索并添加 XUUI > Text 组件(或直接搜索
TextAdapter) - 在 TextAdapter 的
BindTo字段中填入:message - 确认 TextAdapter 的
Target已自动指向自身身上的Text (UnityEngine.UI.Text)组件
给 ClickButton 挂载 ButtonAdapter
- 选中
ClickButtonGameObject - 点击 Add Component → XUUI > Button (或搜索
ButtonAdapter) - 在 ButtonAdapter 的
BindTo字段中填入:click
2.3 挂载 Helloworld 脚本
- 选中
HelloWorld(根 GameObject) - 点击 Add Component → 搜索并添加
Helloworld脚本
2.4 场景结构总览
最终 Inspector 结构如下:
HelloWorld (GameObject)
└─ Helloworld (Script)
Canvas
├── MessageText (GameObject)
│ ├── Text (UI.Text)
│ └── TextAdapter (Script)
│ ├── BindTo: "message"
│ └── Target: Text (auto-assigned)
└── ClickButton (GameObject)
├── Button (UI.Button)
├── Text (UI.Text) --- 按钮文字
└── ButtonAdapter (Script)
├── BindTo: "click"
└── Target: Button (auto-assigned)
3. 代码解析
3.1 Helloworld.cs
csharp
using UnityEngine;
using XUUI;
public class Helloworld : MonoBehaviour
{
Context context = null;
void Start()
{
context = new Context(@"
return {
data = {
info = {
name = 'John',
},
},
computed = {
message = function(data)
return 'Hello ' .. data.info.name .. '!'
end
},
commands = {
click = function(data)
print(data.info.name)
end,
},
}
");
context.Attach(gameObject);
}
void OnDestroy()
{
context.Dispose();
}
}
逐行解释
| 行 | 作用 |
|---|---|
Context context = null; |
声明一个 Context 引用,它是 XUUI 的核心管理器 |
context = new Context(...) |
传入一段 Lua 脚本字符串,Context 的构造函数会:1. 初始化 XLua 环境 2. 编译执行脚本返回 Lua table 3. 调用 xuui.new(options) 创建 ViewModel |
context.Attach(gameObject) |
将当前 GameObject(及其子物体)绑定到 ViewModel。这一步触发 Binding Engine 扫描适配器、注册数据监听和命令绑定 |
context.Dispose() |
在对象销毁时清理所有 Lua 引用和事件监听 |
3.2 Lua ViewModel 结构
Lua 脚本返回的 table 是 ViewModel 的定义,包含三个核心部分:
data ------ 数据模型(Model)
lua
data = {
info = {
name = 'John',
},
}
- 这是 MVVM 中的 Model 层
- 数据被
observeable.lua用 metatable 包装成响应式数据 - 当
data.info.name的值改变时,所有依赖它的 UI 会自动更新
computed ------ 计算属性
lua
computed = {
message = function(data)
return 'Hello ' .. data.info.name .. '!'
end,
}
- 这是一个纯函数 ,接收
data作为参数,返回计算后的值 TextAdapter.BindTo = "message"告诉 Binding Engine:这个 Text 显示computed.message的结果- 自动依赖追踪 :引擎在执行
message(data)时,会记录它访问了data.info.name,并自动注册监听。当name变化时,message重新计算,UI 自动刷新
commands ------ 命令
lua
commands = {
click = function(data)
print(data.info.name)
end,
}
ButtonAdapter.BindTo = "click"告诉引擎:这个按钮触发commands.click- 点击按钮时,Binding Engine 调用
click(data),打印 "John"
4. 运行流程
程序启动时的完整调用链:
Helloworld.Start()
└─ new Context(luaScript)
├─ LuaEnv.LoadString(script) → 编译 Lua 返回 table
└─ xuui.new(options) → 包装响应式数据,返回 attach 函数
└─ context.Attach(gameObject)
└─ binding.bind(data, observe, computed, commands, root)
├─ Collector.Collect(root) → 扫描子物体,收集所有 Adapter
├─ listen_to() → 为每个 DataConsumer(TextAdapter)注册数据监听
│ ├─ 发现 BindTo="message" 在 computed 中
│ ├─ 执行 message(data) → 依赖追踪 → 注册 watcher
│ └─ 设置 TextAdapter.Value = "Hello John!"
├─ watch_to() → 为每个 DataProducer 绑定"UI → data"监听
└─ bind_action() → 为每个 EventEmitter(ButtonAdapter)绑定命令
└─ button.OnAction = function() commands.click(data) end
运行后效果:
- 屏幕上显示 "Hello John!"
- 点击按钮,Console 打印 "John"
上面的流程图就很好的解释了,我们Content参数里的computed 和 Commands是如何被框架执行的,主要就是这个Attach的方法,跟对应的节点绑定,在Attach的时候做了Lua初始化的工作,而在初始化的时候加载了xuui.lua.txt
local function new(options)
options = options or {}
options.data = options.data or {}
options.computed = options.computed or {}
options.computed[0] = {}
options.commands = options.commands or {}
options.exports = {}
if options.modules then
app_init(options)
end
local observe = observeable.new(options.data)
local function attach(el)
return binding.bind(options.data, observe, options.computed, options.commands, el)
end
local function reload(module_name, reload_data)
loadmodule(options, options.exports, options.name, module_name, not reload_data)
end
return attach, reload
end
里面调用了binding.bind的方法,而_M.bind方法扫描所有的子物体,搜集适配器,其中包含EventEmitters对象然后处理了ButtonAdapter,collector.collect 在 C# 侧 (Collector.cs:12) 通过 GetComponentsInChildren 找到所有 ButtonAdapter,按接口类型分到三个数组里。bindings3 是所有实现 EventEmitter 接口的适配器(即 ButtonAdapter)。
5. 绑定关系总结
| GameObject | 适配器 | BindTo | ViewModel 映射 | 方向 |
|---|---|---|---|---|
| MessageText | TextAdapter | "message" |
computed.message(data) |
VM → View |
| ClickButton | ButtonAdapter | "click" |
commands.click(data) |
View → VM |
6. 动手实验
理解了基本流程后,可以尝试以下修改来加深理解:
6.1 修改显示的数据
将 name = 'John' 改为 name = 'World',运行后 Text 变为 "Hello World!"
6.2 新增一个显示字段
在 data 中添加 age = 25:
lua
data = {
info = {
name = 'John',
age = 25, -- 新增
},
},
添加一个 computed:
lua
ageInfo = function(data)
return data.info.name .. ' is ' .. data.info.age .. ' years old'
end,
在场景中新建一个 Text,挂载 TextAdapter,BindTo 设为 "ageInfo"。
6.3 修改命令行为
把 click 命令改为修改数据:
lua
click = function(data)
data.info.name = 'World' -- 改数据
end,
点击按钮后,Text 会自动更新为 "Hello World!" ------ 这就是 MVVM 的威力,修改 Model 自动驱动 View 更新。
6.4 使用 InputField 实现双向绑定
在 Canvas 下创建一个 InputField,挂载 InputFieldAdapter,BindTo 设为 "info.name"。运行时在输入框中打字,Text 的文字会随之实时变化。因为:
- InputFieldAdapter 是
DataProducer<string>--- 用户输入触发OnValueChange - 引擎将输入值写回
data.info.name - 响应式系统检测到
name变化,触发computed.message重新计算 - TextAdapter 的
Value被更新,UI 刷新
7. 常见问题
Q: BindTo 的值如何对应到 Lua data?
BindTo 使用点号分隔路径。例如:
| BindTo | Lua 路径 |
|---|---|
"message" |
computed.message(先从 computed 查找) |
"click" |
commands.click |
"info.name" |
data.info.name(直接数据路径) |
"module1.click" |
commands["module1.click"](模块化命令) |
Q: 为什么 TextAdapter 的 BindTo 是 "message" 却显示 computed 的结果?
Binding Engine 的 listen_to 函数会先检查 BindTo 是否在 computed 表中。如果在,就执行计算属性函数而不是直接读取 data。如果在 computed 和 commands 中都找不到,则直接从 data 中按路径读取。
Q: 适配器只有 Text 和 Button 吗?
XUUI 还内置了 InputFieldAdapter(双向绑定)、DropdownAdapter 等。你也可以自己继承 AdapterBase<T> 创建自定义适配器。
8. 完整文件清单
Assets/
├── Scenes/
│ └── Helloworld.unity ← 本示例的场景
├── Scripts/
│ └── Helloworld.cs ← 入口 C# 脚本
├── XUUI/
│ ├── Scripts/
│ │ ├── ViewModel.cs ← Context 类
│ │ ├── AdapterBase.cs ← 适配器基类
│ │ ├── RawAdapterBase.cs ← 非 MonoBehaviour 适配器基类
│ │ ├── DataConsumer.cs ← VM→View 接口
│ │ ├── DataProducer.cs ← View→VM 接口
│ │ ├── EventEmitter.cs ← 事件接口
│ │ └── UGUIAdapter/
│ │ ├── TextAdapter.cs ← Text 适配器
│ │ ├── ButtonAdapter.cs ← Button 适配器
│ │ ├── InputFieldAdapter.cs ← InputField 适配器
│ │ ├── DropdownAdapter.cs ← Dropdown 适配器
│ │ ├── ViewBinding.cs ← 序列化绑定组件(另一种方式)
│ │ └── Collector.cs ← 扫描收集所有适配器
│ └── Resources/
│ ├── xuui.lua.txt ← XUUI 主模块,new() 工厂
│ ├── binding.lua.txt ← 绑定引擎
│ ├── observeable.lua.txt ← 响应式数据系统
│ ├── collector.lua.txt ← Lua 端适配器收集器
│ └── xuui_utils.lua.txt ← 工具函数
9.补充
DataProvider接口,生产者
DataProducer ------ 生产者,View → VM
数据从 UI 流回 ViewModel。定义在 DataProducer.cs:
public interface DataProducer<T> : DataProducer
{
Action<T> OnValueChange { get; set; } // UI 变化时通知谁
}
只有 OnValueChange,UI 变化时触发它,把值传出去。
使用例子 --- InputFieldAdapter (InputFieldAdapter.cs):
public class InputFieldAdapter : AdapterBase<InputField>, DataConsumer<string>, DataProducer<string>
{
// 作为 DataConsumer:VM → View
public string Value
{
set { Target.text = value; }
}
// 作为 DataProducer:View → VM
public Action<string> OnValueChange { get; set; }
void Start()
{
Target.onValueChanged.AddListener((text) =>
{
if (OnValueChange != null)
OnValueChange(text); // 用户输入 → Lua
});
}
}
InputFieldAdapter 同时实现了两个接口,所以支持双向绑定。
DataConsumer接口,消费者
DataConsumer ------ 消费者,VM → View
数据从 ViewModel 流向 UI。定义在 DataConsumer.cs:
public interface DataConsumer<T> : DataConsumer
{
T Value { set; } // 只能设置值推到 UI,没有 get
}
只有 set 没有 get,意味着这是个单向写入通道。
使用例子 --- TextAdapter (TextAdapter.cs):
public class TextAdapter : AdapterBase<Text>, DataConsumer<string>
{
public string Value
{
set
{
Target.text = value; // Lua 算好的值 → 写到 UI
}
}
}
Binding 引擎调用 el.Value = "Hello John!" → Text 的文字就变了。