基于MVVM框架的XUUI Tutorial_App — 多模块应用框架教程 Tutorial_App — 多模块应用框架与之前教程最大的不同模块化架构Helloworld 和 MoreComplex 都是单个 ViewModel 的简单示例。App 模式引入了真正的模块化架构将 UI 逻辑拆分为多个模块module各自独立每个模块有自己的data/computed/commands互不干扰模块之间通过exports可控交互支持模块热刷新reload不影响其他模块1. 场景搭建1.1 创建 GameObject 层级App (GameObject) ├── Canvas │ ├── Module1InfoText (Text) ← 显示 module1.info │ ├── Module1ClickBtn (Button) ← 触发 module1.click │ ├── Module2InfoText (Text) ← 显示 module2.info │ ├── Module2ClickBtn (Button) ← 触发 module2.click │ ├── SayHelloBtn (Button) ← 触发 module2.say_hello_to_csharp │ ├── ReloadModule1Btn (Button) ← 重载 module1 │ └── ReloadModule2Btn (Button) ← 重载 module21.2 挂载适配器GameObject适配器BindTo说明Module1InfoTextTextAdaptermodule1.info显示 module1 的 computed.infoModule1ClickBtnButtonAdaptermodule1.click触发 module1 的 click 命令Module2InfoTextTextAdaptermodule2.info显示 module2 的 computed.infoModule2ClickBtnButtonAdaptermodule2.click触发 module2 的 click 命令SayHelloBtnButtonAdaptermodule2.say_hello_to_csharp调用 C# 的 HelloCSharpReloadModule1BtnButtonAdapterReloadModule1App.cs 的 Command重载 module1ReloadModule2BtnButtonAdapterReloadModule2App.cs 的 Command重载 module2关键区别BindTo 的格式变为模块名.路径因为 App 模式下有多个模块UI 需要显式指定数据来自哪个模块。2. 代码解析2.1 App.cs —— C# 入口usingUnityEngine;usingXUUI;publicclassApp:MonoBehaviour{Contextcontextnull;voidStart(){contextnewContext( return { name myapp, modules {module1, module2}, } );context.AddCSharpModule(ModuleManager,this);context.Attach(gameObject);}voidOnDestroy(){context.Dispose();}[Export]publicvoidHelloCSharp(Interface2data,intp){Debug.Log(data.selectdata.select, pp);}[Command]publicvoidReloadModule1(){context.ReloadModule(module1);}[Command]publicvoidReloadModule2(){context.ReloadModule(module2);}}逐行解释代码作用name myapp定义应用名称模块加载时会按myapp.module1、myapp.module2的路径 requiremodules {module1, module2}声明两个模块框架自动加载并为每个创建独立沙盒context.AddCSharpModule(ModuleManager, this)将当前 C# 对象注册为名为 “ModuleManager” 的模块其[Export]方法可在 Lua 中调用context.Attach(gameObject)绑定 UI开始数据监听[Export] public void HelloCSharp(...)标记为导出函数Lua 代码可以调用ModuleManager.HelloCSharp(data, 1024)[Command] public void ReloadModule1()标记为命令UI 可通过 BindTo“ReloadModule1” 绑定按钮2.2 module1.lua.txtreturn{data{namehaha,select0,},commands{clickfunction(data)module2.set_select(data.select)-- 跨模块调用调用 module2 的 exportsdata.selectdata.select0and1or0end,},computed{infofunction(data)returnstring.format(i am %s, my select is %d,data.name,data.select)end,},exports{hellofunction(p)print(hello, p ..p)end,},}2.3 module2.lua.txtlocaldata{messagehehe,select1,}return{datadata,commands{clickfunction(data)module1.hello(1)-- 跨模块调用调用 module1 的 exportsdata.selectdata.select0and1or0end,say_hello_to_csharpfunction(data)ModuleManager.HelloCSharp(data,1024)-- 调用 C# 的 Export 方法end,},computed{infofunction(data)returnstring.format(message is %s, select is %d,data.message,data.select)end,},exports{set_selectfunction(p)data.selectpend,},}3. 核心概念详解3.1 模块沙盒与数据隔离每个模块运行在独立的沙盒中沙盒 module1: data { name haha, select 0 } commands.click 只能读写 module1 的 data computed.info 只能读取 module1 的 data 沙盒 module2: data { message hehe, select 1 } commands.click 只能读写 module2 的 data computed.info 只能读取 module2 的 data这意味着即使两个模块定义了同名变量如select也不会冲突command 函数收到的是自己模块的 data不能直接修改其他模块的数据不同开发团队可以独立开发各自模块不用担心全局命名冲突3.2 模块间通信exports模块通过exports暴露接口供其他模块调用module2.commands.click 调用 module1.hello(1) ↓ 调用 module1.exports.hello(p) ↓ 打印 hello, p 1 module1.commands.click 调用 module2.set_select(data.select) ↓ 调用 module2.exports.set_select(p) ↓ 设置 module2 的 data.select p注意module2.exports.set_select使用了闭包引用外部的local datalocaldata{...}-- 闭包变量return{datadata,exports{set_selectfunction(p)data.selectp-- 直接操作闭包引用的 dataend,},}这种方式绕过了 command 的只能看到自己 data的限制——因为set_select不是 command不接收data参数而是通过 Lua 闭包直接访问原始data表。这在需要允许其他模块修改本模块数据时非常有用。3.3 C# 桥接Export 与 AddCSharpModuleApp.cs 中AddCSharpModule(ModuleManager, this)注册后Lua 侧调用: ModuleManager.HelloCSharp(data, 1024) ↓ XLua 桥接: data → XLua 自动包装为 Interface2 代理 ↓ C# 侧: HelloCSharp(Interface2 data, int p) → Debug.Log(data.select data.select , p 1024)这里的Interface2是一个 C# 接口XLua 自动将 Lua table 代理为实现了该接口的对象。所以data.select读取的就是 Lua 模块数据表中的select字段。3.4 模块热刷新Reload[Command]publicvoidReloadModule1(){context.ReloadModule(module1);}调用context.ReloadModule(module1)时重新 requiremyapp.module1加载最新的 Lua 脚本保留旧的data状态数据不丢失用新的commands替换旧的——UI 上已绑定的按钮自动关联新命令重新计算所有computed——UI 自动更新module2完全不受影响这非常适合需要热更新的场景修改 Lua 脚本后点击 Reload 按钮立即生效无需重新运行 Unity。4. 完整数据流初始化流程App.Start() └─ new Context(luaScript) ├─ 解析 namemyapp, modules{module1,module2} ├─ require myapp.module1 → 创建沙盒1包装响应式数据 ├─ require myapp.module2 → 创建沙盒2包装响应式数据 └─ 注册模块间引用module1 中可访问 module2反之亦然 └─ context.AddCSharpModule(ModuleManager, this) └─ 注册 [Export] 方法到 Lua 环境 └─ context.Attach(gameObject) └─ 扫描适配器按 模块名.路径 绑定点击 module1.click 时用户点击 Module1ClickBtn → ButtonAdapter.OnAction() → commands[module1.click](module1.data) → module2.set_select(module1.data.select) -- 跨模块调用 → module1.data.select 0 → module2 的 data 变为 0 → module2 的 UI 自动更新 → module1.data.select 1 -- 修改自己的数据 → module1 的 UI 自动更新点击 SayHelloBtn 时用户点击 SayHelloBtn → ButtonAdapter.OnAction() → commands[module2.say_hello_to_csharp](module2.data) → ModuleManager.HelloCSharp(data, 1024) → XLua 接口代理 → C# HelloCSharp(Interface2 data, int p) → Debug.Log(data.select0, p1024)5. 绑定关系总结GameObject适配器BindToViewModel 映射方向Module1InfoTextTextAdaptermodule1.infomodule1 的 computed.infoVM → ViewModule1ClickBtnButtonAdaptermodule1.clickmodule1 的 commands.clickView → VMModule2InfoTextTextAdaptermodule2.infomodule2 的 computed.infoVM → ViewModule2ClickBtnButtonAdaptermodule2.clickmodule2 的 commands.clickView → VMSayHelloBtnButtonAdaptermodule2.say_hello_to_csharpmodule2 的 commands.say_hello_to_csharpView → VMReloadModule1BtnButtonAdapterReloadModule1App.cs 的 [Command] ReloadModule1View → C#ReloadModule2BtnButtonAdapterReloadModule2App.cs 的 [Command] ReloadModule2View → C#6. 跟之前教程的对比特性HelloworldMoreComplexApp 模式模块数1 个隐含模块1 个隐含模块多个独立模块BindTo 格式infooptionsmodule1.info数据隔离无无各模块沙盒隔离模块通信N/AN/A通过 exportsC# 集成无无AddCSharpModule Export热刷新不支持不支持ReloadModule 支持Lua 文件位置内联在 C# 中内联在 C# 中独立的 .lua.txt 文件7. 动手实验7.1 观察模块隔离点击 module1 的 Click 按钮观察两个 InfoText 的变化。你会看到Module1InfoText 里的select在 0 和 1 之间切换Module2InfoText 里的select可能也被 module1 的跨模块调用改变了7.2 尝试跨模块调用链修改module1.commands.click让它调用module2.hello()如果 module2 也有 exports.hello 的话观察调用链如何串联两个模块。7.3 测试热刷新运行场景观察 UI 显示修改module1.lua.txt中的data.name haha改为data.name xixi点击ReloadModule1按钮观察 Module1InfoText 的显示更新为 “i am xixi, …” 而 Module2InfoText 不受影响7.4 新增一个 module3在Resources/myapp/下创建module3.lua.txt在 App.cs 的 modules 列表中加入module3添加对应的 UI 和适配器绑定8. 完整文件清单Assets/ ├── Scenes/ │ └── App.unity ← 本示例的场景 ├── Scripts/ │ └── App.cs ← C# 入口App 模式启动器 ├── Resources/ │ └── myapp/ │ ├── module1.lua.txt ← 模块1data/commands/computed/exports │ └── module2.lua.txt ← 模块2data/commands/computed/exports C# 桥接 └── XUUI/ ├── Scripts/ │ ├── ViewModel.cs ← Context 类包含 AddCSharpModule / ReloadModule │ ├── AdapterBase.cs │ └── UGUIAdapter/ │ ├── TextAdapter.cs │ ├── ButtonAdapter.cs │ └── ... └── Resources/ ├── xuui.lua.txt ← XUUI 主模块包含 app 模式的初始化逻辑 ├── binding.lua.txt ← 绑定引擎 ├── observeable.lua.txt ← 响应式数据系统 └── ...9. 常见问题Q: module1 和 module2 是全局变量吗是的但它们是沙盒内的全局变量。框架在加载每个模块时会把其他模块名注册到当前沙盒的全局环境中所以module1中可以直接用module2.set_select(...)。这本质上是框架管理的命名空间不是真正的 Lua 全局变量不会污染全局环境。Q: 为什么 exports 里的函数能修改 data 而 command 不行command 的函数签名是function(data)这里的data是框架传入的当前模块的 data 副本引用command 只能读写本模块的 data。而 exports 里的函数通过 Lua闭包捕获了外部的data变量可以自由操作。这是设计上的有意区别command 是模块对外的行为接口exports 是模块间的编程接口。Q: Reload 后 data 会重置吗不会。ReloadModule会保留当前的 data 状态只替换 commands、computed 和 exports。所以你在 Reload 前修改的数据会保持不变。Q: 模块脚本必须放在 Resources/myapp 目录下吗不是必须的。默认按 Lua 的 require 规则查找但可以通过CustomLoader自定义加载路径和文件后缀。为了演示方便本示例放在Resources/myapp目录下。