xLua热更新框架笔记
流程
- 游戏对象上挂C#对接脚本,可以拖对象,C#对接脚本继承一个基类,使用xLua的Global.Get<LuaTable>("table名字")得到lua表,luaTable.Set给表注入成员,xLua还有新建表、设置元表、得到lua函数等操作。C#对接脚本用UnityAction<LuaTable>存从lua得到的生命周期函数,用luaTable.Get<UnityAction<LuaTable>>("Awake")等得到lua函数,在C#生命周期函数里执行;
- luaEnv.AddLoader增加从AB包加载lua脚本的方法,如果是安卓平台必须用UnityWebRequest异步加载则进入游戏先异步加载lua AB包在回调函数里和之后执行任何执行lua代码;
- 程序写好后把所有lua脚本后缀替换成.txt,打AB包。把从项目文件夹加载lua的方法注释掉验证从AB包加载lua成功。在编辑器里使用Android AB包加载lua没问题,只有某些粒子效果会变粉色,到Android平台就会正常;
- 测试时能明显看到初始显示的面板有一个延迟。
传递C#对象给Lua
不能在编辑器拖对象,根对象使用GameObject.Find(),子对象使用transform.Find()寻找。
function PanelMain:Open()--使用Find获得控件的笨方法
if self.obj==nil then
prefab=ABManager:LoadRes(uiAB,"PanelMain",typeof(GameObject))
self.obj=GameObject.Instantiate(prefab)
self.obj.transform:SetParent(Canvas.transform,false)
self.buttonPlay=self.obj.transform:Find("ButtonPlay"):GetComponent(typeof(Button))
self.buttonSettings=self.obj.transform:Find("ButtonSettings"):GetComponent(typeof(Button))
self.buttonRank=self.obj.transform:Find("ButtonRank"):GetComponent(typeof(Button))
self.buttonPlay.onClick:AddListener(function()
self:ClickPlay()
end)
self.buttonSettings.onClick:AddListener(function()
self:ClickSettings()
end)
self.buttonRank.onClick:AddListener(function()
self:ClickRank()
end)
else
self.obj:SetActive(true)
end
end
除此还有什么方法吗?一个C#脚本可以用luaTable.Set()给表写入成员。
luaTable = MyXLuaManager.Instance.Global.Get<LuaTable>(luaTableName);
luaTable.Set("obj",gameObject);
传递面板的控件给Lua
对于一个面板,有Image、Text、Button等组件。如果一个面板有专用的C#脚本,那么每个面板都有一个C#脚本,脚本数量增加了一倍。我尝试使用UGUI控件的共同父类UIBehaviour列表,结果lua得到的都是UIBehaviour对象,根本没有按钮的onClick这些各种控件独有的成员。
那么还想用列表注入,就还需要把具体类型传过去,但是Type不能序列化到检查器,无法在编辑器配置类型。
然后懒得设计,就给每个面板专用一个C#脚本。这些脚本的共同点是获取Lua的生命周期函数,在生命周期函数里执行,所以它们继承这样的基类。
//得到一个lua表,注入一些成员,执行它的生命周期函数
public class LuaSingleton : MonoBehaviour{
public string luaScriptName;
public string luaTableName;
protected LuaTable luaTable;
protected UnityAction awake, start, update, onDestroy;
protected void InitTable()
{
if (luaScriptName != null && luaScriptName != "")
{
MyXLuaManager.Instance.DoLuaFile(luaScriptName);
}
luaTable = MyXLuaManager.Instance.Global.Get<LuaTable>(luaTableName);
luaTable.Set("obj", gameObject);
}
protected virtual void InjectTable()
{
}
protected virtual void Awake()
{
InitTable();
awake = luaTable.Get<UnityAction>("Awake");
start = luaTable.Get<UnityAction>("Start");
update = luaTable.Get<UnityAction>("Update");
onDestroy = luaTable.Get<UnityAction>("OnDestroy");
awake?.Invoke();
}
void Start()
{
start?.Invoke();
}
void Update(){
update?.Invoke();
}
void OnDestroy()
{
onDestroy?.Invoke();
luaTable.Dispose();
}
}
各面板和基类只有注入的成员不同
public class PanelMainLua : LuaSingleton{
public Button buttonPlay,buttonSettings,buttonRank;
protected override void InjectTable()
{
base.InjectTable();
luaTable.Set(nameof(buttonPlay), buttonPlay);
luaTable.Set(nameof(buttonSettings), buttonSettings);
luaTable.Set(nameof(buttonRank), buttonRank);
}
}
然后如果加载面板由Lua完成,就变成Lua加载面板>C#脚本执行Awake()>执行Lua Awake(),里面执行初始化。这种踢皮球的流程,省去了找控件的代码,可以继续用拖拽控件,也可以执行生命周期函数。
function PanelMain:Load()
local prefab=ABManager:LoadRes(uiAB,"PanelMain",typeof(GameObject))
self.obj=GameObject.Instantiate(prefab,Canvas.transform)
end
function PanelMain:Init()
-- self.obj.transform:SetParent(,false)
self.buttonPlay.onClick:AddListener(function()
self:ClickPlay()
end)
self.buttonSettings.onClick:AddListener(function()
self:ClickSettings()
end)
self.buttonRank.onClick:AddListener(function()
self:ClickRank()
end)
end
function PanelMain.Awake()
PanelMain:Init()
end
传递多实例对象给Lua
有一个“类表”,创建一个新表,设置类表为元表。和写面板一样的思想,原来每个C#类现在充当到Lua的桥接器,C#脚本数不变,增加Lua脚本,总脚本数增加一倍。
public class LuaInstance : MonoBehaviour
{
public string luaScriptName;
public string luaClassTableName;
protected LuaTable luaClassTable, luaTable;
UnityAction awake, start, update, onDestroy;
UnityAction<LuaTable,GameObject>onTriggerEnter;
protected virtual void InjectObjects()
{
}
void Awake()
{
if (luaScriptName != null && luaScriptName != "")
{
MyXLuaManager.Instance.DoLuaFile(luaScriptName);
}
luaClassTable = MyXLuaManager.Instance.Global.Get<LuaTable>(luaClassTableName);
luaTable = MyXLuaManager.Instance.luaEnv.NewTable();
luaTable.SetMetaTable(luaClassTable);
luaTable.Set("obj",gameObject);
InjectObjects();
awake = luaClassTable.Get<UnityAction>("Awake");
start = luaClassTable.Get<UnityAction>("Start");
update = luaClassTable.Get<UnityAction>("Update");
onTriggerEnter=luaClassTable.Get<UnityAction<LuaTable,GameObject>>("OnTriggerEnter");
onDestroy = luaClassTable.Get<UnityAction>("OnDestroy");
awake?.Invoke();
}
void Start()
{
start?.Invoke();
}
void Update()
{
update?.Invoke();
}
void OnTriggerEnter(Collider other)
{
onTriggerEnter?.Invoke(luaTable,other.gameObject);
}
void OnDestroy() {
onDestroy?.Invoke();
luaClassTable.Dispose();
luaTable.Dispose();
}
}
一个Lua表对象访问另一个类的Lua表对象
通过C#传递脚本已经能把场景里的对象传给Lua表了,但一个Lua表现在的成员除了简单数据类型、Unity内置类型,就是C#脚本,要访问其他类的Lua表,需要通过其他类的C#传递脚本。
继承关系怎么处理
上面写好了单例和实例的Lua对接脚本。现在如果有一个坦克基类,坦克有很多实例,它使用实例对接脚本,玩家坦克继承坦克基类,玩家坦克是单例。玩家坦克对接脚本如果
- 继承坦克对接脚本基类则新建一个空表,以玩家坦克表为元表,多建了一个表;
- 继承单例对接脚本,则没有了坦克基类所有的成员;
经过权衡还是使用1。然后玩家坦克对接脚本的gameObject被注入给了新建的空表,PlayerTank表的obj还是空的。那么只能玩家坦克对接脚本自己写一个Awake,里面直接获得那个表。即使如此,还是会造成脚本数量爆炸。我打算玩家的坦克、武器不再作为一个子类,而把玩家多出来的数据放在一个成员类,非玩家对象的这个成员类为空。
然后我觉得再把直接使用Lua表和创建新Lua表的C#传递类分2个类是没有意义的,不如直接给类一个公开字段,控制是直接用还是创建新的。
全局初始化和场景初始化
lua同样有全局初始化的(定义符号简写、加载依赖模块)和每个场景初始化的(加载场景的初始面板)。一个场景会多次进入,需要多次执行。require()执行完一个脚本后,再次require()同一个脚本,不会执行,需要package.loaded['XXX']=nil。可以在执行lua脚本的OnDestroy执行这个清除。场景初始化代码要么初始化时require,离开场景时清除,要么不用require,直接执行函数;
--全局初始化
require("Object")
Json=require("JsonUtility")
--取名称简写
UE=CS.UnityEngine
GameObject=UE.GameObject
Transform=UE.Transform
Resources=UE.Resources
RectTransform=UE.RectTransform
AudioSource=UE.AudioSource
Time=UE.Time
Vector3=UE.Vector3
Vector2=UE.Vector2
UI=UE.UI
Image=UI.Image
Text=UI.Text
Button=UI.Button
Slider=UI.Slider
Toggle=UI.Toggle
ABManager=CS.MyABManager.Instance
SceneManager=UE.SceneManagement.SceneManager
File=CS.System.IO.File
dataPath=UE.Application.persistentDataPath
--声明全局量
gameSceneName="GameScene"
require("DataManager")
require("Music")
require("PanelMain")
require("PanelSettings")
require("PanelRank")
require("PanelGame")
Canvas=GameObject.Find("Canvas")
Audio:Init()
PanelMain:Open()
function LeaveBeginScene()
--本场景加载的对象全部置空
PanelSettings.obj=nil
PanelMain.obj=nil
PanelRank.obj=nil
Audio.music=nil
end
让一个Lua表通过调用C#调用另一个Lua表
Lua使用GetComponent、Instantiate获取的是C#类,如果这个类是一个传递脚本,需要通过它的luaTable成员得到处理它逻辑的Lua表。
lua写逻辑的注意
- 主脚本里要require所有用到的脚本,类似编译,还要注意require顺序。然后发现这样每新写一个脚本,都要在主脚本require,能不能把lua文件夹的脚本全执行了?;
- 为了模拟面向对象的写法,声明表后会把成员声明一下,在VSCode里其他地方使用会有提示且没有黄色下划线;
调用Physics的Raycast或Linecast的有RaycastHit的重载
尝试用多返回值
local hit=false
local hitInfo=UE.RaycastHit()
hit,hitInfo=Physics.Linecast(self.obj.transform.position,self.lastFramePos)
尝试把RaycastHit传入
local hit=false
local hitInfo=UE.RaycastHit()
hit,hitInfo=Physics.Linecast(self.obj.transform.position,self.lastFramePos,hitInfo)
只能在C#封装一个方法
public static GameObject CheckHit(Vector3 start, Vector3 end)
{
RaycastHit hit;
if (Physics.Linecast(start, end, out hit))
{
return hit.collider.gameObject;
}
return null;
}
把创建lua脚本加入Create菜单的脚本
脚本名和类名必须相同。
using UnityEditor;
using UnityEngine;
using System.IO;
public class LuaFileCreator : Editor
{
[MenuItem("Assets/Create/Lua Script", false, 80)]
public static void CreateLuaScript()
{
// 获取当前选中的路径
string path = GetCurrentPath();
// 创建唯一的文件名
string fileName = "NewLuaScript.lua";
string fullPath = Path.Combine(path, fileName);
fullPath = AssetDatabase.GenerateUniqueAssetPath(fullPath);
// 创建.lua文件内容
string content = "";
// 写入文件
File.WriteAllText(fullPath, content);
// 刷新AssetDatabase
AssetDatabase.Refresh();
// 选中新创建的文件
Selection.activeObject = AssetDatabase.LoadMainAssetAtPath(fullPath);
}
private static string GetCurrentPath()
{
string path = "Assets";
foreach (Object obj in Selection.GetFiltered(typeof(Object), SelectionMode.Assets))
{
path = AssetDatabase.GetAssetPath(obj);
if (File.Exists(path))
{
path = Path.GetDirectoryName(path);
}
break;
}
return path;
}
}
lua引用变量的判空:==nil和:Equals(nil)
第一种用于变量从未赋值,第二种用于赋值过但是对象被销毁(Unity检查器里的Missing,或者打印的大写开头Null)。为了考虑全面应该两种情况或运算,而且顺序必须先==nil再:Equals(nil)。
只写第一种,引用的对象被销毁后会判断不通过,只写第二种,一开始加载对象时会报错试图索引一个nil。
在编辑器里lua脚本后缀是.lua,require时无需后缀名,打AB包前后缀要变成.txt,如果是.lua后面加.txt,那么代码里所有require后面都要加.lua?
既然如此,那么打AB包前把所有lua脚本后缀改成.txt而不是追加可以吗?
[MenuItem("热更新工具/lua脚本改成txt后缀")]
static void luaToTxt()
{
if(!Directory.Exists(luaPath)){
return;
}
string[] files=Directory.GetFiles(luaPath,"*.lua");
if(!Directory.Exists(targetPath)){
Directory.CreateDirectory(targetPath);
}
else{
string[] oldFiles=Directory.GetFiles(targetPath);
for(int i=0;i<oldFiles.Length;i++){
File.Delete(oldFiles[i]);
}
}
string fileName;
for(int i=0;i<files.Length;i++){
fileName=Path.Combine(targetPath,Path.GetFileNameWithoutExtension(files[i])+".txt");
File.Copy(files[i],fileName);
}
AssetDatabase.Refresh();
}
关于AB包加载
unity ab包里的预制体上挂的脚本是什么状态?有代码逻辑吗?替换ab包能更新里面预制体的脚本吗?
很好,这是一个非常核心且常见的Unity开发问题。我们来分点详细解释。
1. AB包里的预制体上挂的脚本是什么状态?有代码逻辑吗?
简单回答: 脚本在AB包中只保存了引用和序列化数据,没有保存代码逻辑本身。代码逻辑存在于编译后的程序集(DLL)中。
详细解释:
当你在Unity编辑器中创建一个预制体并挂上脚本(例如 MyBehaviour.cs),Unity会做两件事:
· 脚本引用: 预制体资源文件里会记录一个对脚本的引用。这个引用包括脚本的GUID和FileID,这些信息在Unity的meta文件中定义。打包成AssetBundle时,这个引用信息会被一起打包。
· 序列化数据: 你在Inspector窗口中为脚本设置的公开字段(如 public int health;)的值会被序列化,并保存到预制体文件中。这些数据也会被打包进AB包。
代码逻辑在哪里?
你的C#代码逻辑在经过编译后,会变成程序集(通常是 Assembly-CSharp.dll),这个DLL文件不会被自动打包到AssetBundle中。它属于你项目的代码部分,在构建游戏时会被打包到主程序(如.exe、.apk、.ipa)里。
所以,从AB包中加载出来的预制体上的脚本状态是:
· 它知道它应该挂载哪个脚本(通过GUID/FileID引用)。
· 它拥有该脚本所有序列化字段的初始值。
· 当这个预制体被实例化到场景中时,Unity运行时会根据这个引用去当前已加载的程序集(DLL)中查找对应的脚本类。如果找到,就会为游戏对象动态添加这个脚本组件,并将序列化的数据赋给相应的字段。然后,脚本的 Awake(), Start() 等生命周期方法就会按顺序执行。
---
2. 替换AB包能更新里面预制体的脚本吗?
这个问题的答案比较复杂,需要分情况讨论:
情况一:只替换AB包,不更新游戏客户端(不更新DLL)
不能更新脚本逻辑。
· 原因: 正如上面所说,脚本逻辑在DLL里。如果你只从服务器下载并替换了新的AB包,但客户端的应用程序(包含旧的DLL)没有更新,那么:
1. 新的AB包里的预制体依然引用着同名的脚本。
2. 游戏运行时,它会从旧的DLL中加载旧的脚本逻辑来执行。
3. 因此,脚本的行为不会有任何改变。
· 什么会改变?
· 你可以更新序列化的数据。例如,你可以在新AB包中把脚本的 health 字段从100改为200。实例化后,脚本里的 health 初始值就是200。
· 你可以更新模型、纹理、音频等其他资源。
情况二:同时替换AB包和更新包含新脚本的DLL(热更新方案)
可以更新脚本逻辑。这是实现热更新的核心原理。
这需要借助Unity的程序集热更新技术,通常有两种主流方式:
a) 使用ILRuntime/Lua等脚本语言
· 将核心逻辑用Lua或支持热更的脚本语言编写。
· 脚本(如Lua文件)可以作为TextAsset打包到AB包里。
· 替换AB包后,加载新的Lua脚本并执行,就等于更新了逻辑。
· C#层只充当一个“虚拟机”的作用。
b) 使用HybridCLR(原xLua团队的增强型热更新方案)
· 这是目前最主流、最强大的方案。它允许你更新C#的DLL。
· 流程:
1. 你将需要热更的C#代码编译成一个独立的DLL(例如 HotUpdate.dll)。
2. 将这个DLL文件当作一个普通的TextAsset资源,打包到一个AB包中。
3. 游戏启动时,从服务器下载包含新DLL的AB包。
4. 使用 AssetBundle.LoadAsset<TextAsset> 加载DLL的bytes。
5. 使用HybridCLR加载这个DLL到应用程序域中。
6. 此时,再加载包含预制体的AB包。当预制体实例化时,Unity运行时会从新加载的 HotUpdate.dll 中找到新的脚本逻辑并执行。
在这种情况下,替换AB包就实现了脚本逻辑的更新。
---
总结与最佳实践建议
操作 能否更新脚本逻辑? 说明
只替换AB包 否 脚本逻辑存在于主程序DLL中,AB包只含资源和数据。
替换AB包 + 代码热更新框架 是 通过将新DLL作为资源打入AB包,运行时动态加载,实现逻辑热更。
重要提醒:
· 脚本引用断裂: 如果你在更新代码时删除或重命名了一个脚本类,然后只替换了AB包,但没有更新客户端DLL,那么从AB包加载预制体会失败,并出现“Missing Script”的错误。因为运行时在DLL里找不到对应的类了。
· 版本兼容性: 在设计热更新时,要特别注意数据结构的兼容性。如果新脚本删除或修改了一个序列化字段,而旧AB包里的数据还包含这个字段,可能会导致反序列化错误。
· 推荐架构: 为了更好的热更新管理,建议使用 Assembly Definition Files (asmdef) 将需要热更的代码组织到单独的程序集中,与引擎相关、不易热更的代码分离开。
希望这个详细的解释能帮助你彻底理解这个问题!
遇到的问题
打包的时候已经把之前属于的练习包的资源都设置None了,也Remove Unused Names了,原来的包还是打出来了
注意点
路径切换
因为Resources加载是路径/资源名,使用AB包需要给AB包路径和包名,但是不用写路径,直接写资源名。然后要把一大堆加载资源的代码从Resources路径改成资源名。有什么方便的改法吗?
我决定加载资源输入路径/资源名的形式,用AB包时就用Path.GetFileNameWithoutExtension()把资源名提取出来。
xLua使用UnityWebRequest异步加载AB包和资源
public class MyResManagerUWQ : MonoSingletonDontDestroy<MyResManagerUWQ>
{
static string ABFolder = Application.streamingAssetsPath;
Dictionary<string,AssetBundle>abDic=new Dictionary<string,AssetBundle>();
public void LoadRes(string resName, string abName, UnityAction<GameObject> callback = null)
{
if (!abDic.ContainsKey(abName))
{
string abPath = Path.Combine(ABFolder, abName);
StartCoroutine(LoadResIE<AssetBundle>(UwrPathProcessor(abPath), (ab) =>
{
abDic.Add(abName, ab);
GameObject go = ab.LoadAsset<GameObject>(resName);
callback?.Invoke(go);
}));
}
else
{
GameObject go =abDic[abName].LoadAsset<GameObject>(resName);
callback?.Invoke(go);
}
}
IEnumerator LoadResIE<T>(string path, UnityAction<T> callback = null) where T : class
{
UnityWebRequest request = null;
if (typeof(T) == typeof(string))
{
request = UnityWebRequest.Get(path);
}
else if (typeof(T) == typeof(Texture))
{
request = UnityWebRequestTexture.GetTexture(path);
}
else if (typeof(T) == typeof(AssetBundle))
{
request = UnityWebRequestAssetBundle.GetAssetBundle(path);
}
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
if (callback != null)
{
if (typeof(T) == typeof(string))
{
callback(request.downloadHandler.text as T);
}
else if (typeof(T) == typeof(Texture))
{
callback((request.downloadHandler as DownloadHandlerTexture).texture as T);
}
else if (typeof(T) == typeof(AssetBundle))
{
callback((request.downloadHandler as DownloadHandlerAssetBundle).assetBundle as T);
}
}
}
}
public static string UwrPathProcessor(string path)
{
#if UNITY_EDITOR || UNITY_STANDALONE_WIN
return "file://" + path;
#elif UNITY_ANDROID
return "jar:file://"+path;
#endif
return path;
}
}
ResManager=CS.MyResManagerUWQ.Instance
function LoadPanelAsync(panel)
ResManager:LoadRes(panel.resName,uiAB,function(prefab)
panel.obj=GameObject.Instantiate(prefab,Canvas.transform)
end)
end
xLua的CustomLoader和UnityWebRequest的异步加载怎么配合
我写了一个循环等待异步加载完成。但是如果不加最大尝试次数,程序直接卡死,加上程序不卡死了,还是加载不出来。
byte[] ProjectLoaderABAndroid(ref string filePath){
#if UNITY_EDITOR|| UNITY_ANDROID
Debug.Log("在Android AB包里找"+filePath);
#endif
byte[] bytes=null;
int tryNum =0,maxTryNum=50;
MyResManagerUWQ.Instance.LoadText(luaABName,filePath+".lua",(luaScript)=>
{
bytes = luaScript.bytes;
});
while (bytes== null&&tryNum<maxTryNum)
{
tryNum++;
}
return bytes;
}
解决方法:进入游戏第一个场景只有一个初始化脚本,这个脚本先异步加载lua AB包,在异步加载回调里把这个AB包存起来,然后初始化xLua管理器,xLua管理器AddLoader增加从这个AB包加载TextAsset的同步加载方法。
public class LoadLuaThenRun : MonoBehaviour
{
public string luaScript;
public List<string>codes=new List<string>();
void Start()
{
MyResManagerUWQ.Instance.LoadAB(MyXLuaManager.luaABName, (ab) =>
{
MyXLuaManager.Instance.luaAB = ab;
MyXLuaManager.Instance.Init();
if (!string.IsNullOrEmpty(luaScript))
{
MyXLuaManager.Instance.DoLuaFile(luaScript);
}
for (int i = 0; i < codes.Count; i++) {
MyXLuaManager.Instance.RunLua(codes[i]);
}
});
}
}
xLua管理器:
byte[] LoadFromAB(ref string filePath){
#if UNITY_EDITOR|| UNITY_ANDROID
Debug.Log("在AB包里找"+filePath);
#endif
byte[] bytes = luaAB?.LoadAsset<TextAsset>(filePath).bytes;
return bytes;
}
热更新AB包
首先分为服务端和客户端两部分处理。
服务端
- 服务端生成对比文件;
- 服务端上传AB包和比较文件;
客户端
- 客户端开启时下载对比文件;
- 解析服务端对比文件;
- 读取客户端对比文件,解析;
- 对比;
- 如果和本地对比文件不一样,则下载更新的AB包;
- 把服务端对比文件作为新的客户端对比文件;
问题
Sublime Text问题
不识别粘贴来的空格
有时执行代码会报这种有�的错
经检查发现这是被识别的空格,里面有一点:
这是没被识别的空格,里面没有一点:
更多推荐
所有评论(0)