ET
简介
ET是一个开源的游戏客户端(基于unity3d)服务端双端框架,服务端是使用C# .net core开发的分布式游戏服务端,其特点是开发效率高,性能强,双端共享逻辑代码,客户端服务端热更机制完善,同时支持可靠udp tcp websocket协议,支持服务端3D recast寻路等等
我们项目后端用的Go,这里只使用ET Client部分
改造内容
打包方式
ET 本身是通过AssetBundle对资源进行管理,用ILRuntime对代码进行热更新。但是随着近期Unity 推出的Addressable插件,官方也表明后期要逐步淘汰AssetBundle,所以自己打算对这个部分做一个适配
Addressable 用起来非常简单,上手也非常容易,具体会在下面介绍
配表流程
ET 提供了导出配置的工具,Json的格式见下图,但是在对象嵌套的情况下不是很适合
我们在配表的流程上采用的是xlsx2json的解决方案,具体也会在下面介绍
Addressable
简介
Addressable 提供了一套非常简单的资源更新方案,根据用户给的Key,加载在本地或者在服务器的资源,同时可以占用更小的内存,管理起来十分方便
上面视频是8月份的,Addressable这段时间也更新了很多内容,有些API也换掉了,不过可以当做入门的参考及功能介绍
Addressable Import 工具
Addressable 本身是GUI的形式对每个Group进行操作,经常会出现忘记添加一些资源导致运行错误,上面这个工具将这个过程根据开发人员的配置实现自动化添加,很好理解,看官方示例即可
目前我自己使用下来有一个Bug,如果你项目中存在Addressable 的配置, 需要把Addressable插件和Setting全部删除,并优先导入这个插件
注意事项
- 在实际测试中,Addresable提供的本地Host服务,在Mac 10.15环境下无法正常使用
- Addressable 无法搭配Unity Cloud Build正常使用,即使将打包文件名设置为NoHash,或者加入Pre-Build Script 逻辑也依然无法正常使用,必须本地Build
- 不要在极短的时间内同时使用
Addressables.LoadAssetAsync
去加载同一份资源,否则这个资源的引用计数会出错,导致资源无法正常Release
ILRuntime
简介
ILRuntime 提供了客户端C#撸到底的热更解决方案,并且在ET中猫大也进行了支持
注意事项
在官方说明中IL2CPP打包注意事项中,作者有提到如果A和B不是Hotfilx.dll项目中的类型,那么List<A>
和List<B>
是两个类型
因为没仔细看手册,多打了好几遍包
xlsx2json
简介
这个配表库是用JS写的,不需要像之前博客中的配表解决方案还需要写reg文件,而且同样支持对象嵌套
这个工具导出来的json长下面这个样子,会默认创建一个用ID作为Key的值,方便我们用Dictionary来保存这个数据
注意事项
在我们正常使用中发现,如果在数据表中插入一列新的数据,解析成json时有时值会为null,代码中读取json时需要做一下空值校验
代码部分
导入顺序
如果要使用Addressable Import 插件,务必优先导入这个插件,然后再升级Addressable到最新Release版本,否则Import会无法正常工作
Addressable 基础配置及工具
Addressables Profile
Addressable 可以使用Unity中PackageManager 直接导入,当前我使用的版本为
1.3.8
打开Addressables Profiles
配置打包位置和服务器位置,可以创建多个打包逻辑,用于不同环境的部署
Addressable Assets Settings
- 需要远程加载记得勾选
Build Remote Catalog
,并选好Catalog的Build Path - 如果需要分析Addressable的内存占用,需要勾选
Send Profiler Event
- 个人使用喜欢提前写好一些Assets Group的Templates,这样创建Group时在Import插件中可以直接引用
Addressable Import
在Project
中创建Import的配置文件,下面以我自己项目中UI来举例
UI这里的规则是,只放UI的Prefab文件,所以路径可以直接写
*
,代表所有(更多的规则可以参考上面git仓库中的README)
- Group Name 代表在
Addressable Groups
中出现的名字 Label Refs
的配置是为了方便对资源进行归类,比如出现需要全部下载的逻辑时,就可以添加一个名为All
的Label- 加载UI时,UI脚本的命名与Prefab的名字一致,所以要勾选
Address Simplified
设置完成后,当这个目录下有内容更新时,Import插件会自动将其添加到对应的Group中
Group Setting
下面是UI对应的Group
设置
这个部分的设置都比较好理解,
Bundle Naming
可以选择其他的,我是为了测试Unity Cloud Build
所以一直用的No Hash
一键热更同步工具
为了方便和服务器同步资源,我们还需要使用Rsync配合Addressable 打热更包,方法也很简单,猫大已经在Editor里面帮我们写好了Rsync的基本逻辑
下面出现的
.bin
文件必须Build一次Addressable才会出现,如果没有,需要自己手动Build
Rsync 的配置可以使用猫大写好的Rsync工具自行配置
下面的代码是针对MacOS下开发使用的,Win下的小伙伴还需要自己改一改
下面出现的#F1
的意思是Shift + F1 是这个工具的快捷键,更多内容可以查看Unity的官方手册
[MenuItem("Tools/Rsync同步 #F1")] public static void AsyncFile() { #if UNITY_IOS var path =
quot;{Application.dataPath}/Res/AddressableAssetsData/iOS/addressables_content_state.bin";
#else
var path =quot;{Application.dataPath}/Res/AddressableAssetsData/Android/addressables_content_state.bin";
#endifif(!string.IsNullOrEmpty(path)){
ContentUpdateScript.BuildContentUpdate(AddressableAssetSettingsDefaultObject.Settings, path);string arguments =
quot;-vzrtopg --password-file=./Tools/cwRsync/Config/rsync.secrets --delete ./Unity/ServerData/ 用户名@地址";
ProcessHelper.Run(
@"/usr/bin/rsync",
arguments,
@"../",
true
);Log.Info("同步完成!");
}
}
}
Addressable使用
资源文件加载
下面是
AssetBundle
和Addressable
加载Hotfix.dll文件的对比相比较传统的
AssetBundle
加载,Addressable
不需要写过多的Bundle管理,加载、卸载、服务器文件对比等逻辑,一行代码就完成了对文件的本地与服务端对比更新和加载TextAsset assText = await Addressables.LoadAssetAsync<TextAsset>("Hotfix.dll").Task; TextAsset pdbText = await Addressables.LoadAssetAsync<TextAsset>("Hotfix.pdb").Task; byte[] assBytes = assText.bytes; byte[] pdbBytes = pdbText.bytes; // Game.Scene.GetComponent<ResourcesComponent>().LoadBundle(
quot;code.unity3d");
// GameObject code = (GameObject)Game.Scene.GetComponent<ResourcesComponent>().GetAsset("code.unity3d", "Code");
//
// byte[] assBytes = code.Get<TextAsset>("Hotfix.dll").bytes;
// byte[] pdbBytes = code.Get<TextAsset>("Hotfix.pdb").bytes;
检查更新
下面是对所有我们标记为
Preload
的资源进行异步加载,并提供加载进度UI 部分的代码就不贴了,大概可以看懂什么意思,Popup工具可以查看之前的博客
UILoadingComponent loadingComponent = await UIComponent.Instance.Reveal<UILoadingComponent>(); long size = await Addressables.GetDownloadSizeAsync("Preload").Task; if(size > 0){ if(!await UIUtils.Popup(CancellationToken.None, "更新",
quot;检查到更新,大小为{SizeHelper.GetSize(size)},是否更新?")){
// 如果没有点确定,则退出游戏
Application.Quit();
}
}AsyncOperationHandle operationHandle = Addressables.DownloadDependenciesAsync("Preload");
while(!operationHandle.IsDone){
loadingComponent.Progress = operationHandle.PercentComplete;
await ETModel.Game.Scene.GetComponent<TimerComponent>().WaitAsync(100);
}
异步实例化GameObject
url为当前资源在
Group
中的NameGameObject obj = await Addressables.InstantiateAsync(url, transform).Task;
释放GameObject资源
Addressables.ReleaseInstance(gameObject);
释放Object资源
Addressables.Release(obj);
Addressable Analyze
这里目前还没用到,暂时先不写
其他
到这里
Addressable
的介绍差不多就结束了,针对UI的Load我们可以将Addressable的逻辑放到自己的UI管理器中我的规则是保持
UI Prefab
名与ET Component
名一致,这样就不需要每次都输入Prefab在Addressable中对应的Group Name
ConfigComponent改造
加载逻辑
我们可以将所有的Config文件按照上面Addressable规则配置到对应的
Group
中修改ConfigHelper文件中的加载逻辑
public static async ETTask<string> GetText(string key) { try{ TextAsset configStr = await Addressables.LoadAssetAsync<TextAsset>(key).Task; return configStr.text; } catch(Exception e){ throw new Exception(
quot;load config file fail, key: {key}", e); }
}
反序列化逻辑
修改
ACategory
中的Json读取逻辑为了方便使用Dictionary对ID进行查询,这里要先反序列化为JsonData,然后再对每一行数据进行重新解析
public override async ETTask BeginInit() { this.dict = new Dictionary<string, IConfig>(); string configStr = await ConfigHelper.GetText(typeof (T).Name); JsonData data = JsonMapper.ToObject(configStr); foreach(string key in data.Keys){ try{ string singleLine = data[key].ToJson(); T t = ConfigHelper.ToObject<T>(singleLine); if(t.ID == null) continue; this.dict.Add(t.ID, t); } catch(Exception e){ Log.Error(e.ToString()); throw new Exception(
quot;parser json fail: {key}", e);
}
}
}
需要注意的是,这里我将加载Config流程改成异步了,只有在Hotfix层所有Config加载结束时才会触发Game.EventSystem.Run(EventIdType.InitSceneStart)
逻辑资源释放
下面提供一个资源释放的思路,以UI举例
父级子级定义
现在我们有一个UI资源名为Loading,在加载时,我们定义为Loading父级资源,用Addressable直接实例化
Loading组件下面需要使用Addressable加载A.png和B.png资源,我们定义A.png和B.png为子级资源
父级资源
除了上面这些,还需要对Addressable资源的生命周期做手动的管理
以UI举例,创建下面的
MonoBehaviour
脚本在每次HideUI时,检查是否需要释放,如果需要释放,直接调用上面的Release即可public class UIConfig : MonoBehaviour { public string LayerName; public bool DestoryOnHide; }
子级资源
子级资源加载时,要提供父级的Type,这样我们在释放父级资源时,所有子级资源随着父级的Release,一同从内存中释放掉,这里记得不要在极短的事件同时Load多次a.png,否则资源不会得到正确的释放
在Loading UI 中需要加载a.png
this._aImage.sprite = await AddressableComponent.Instance.LoadSublevelAsset<Sprite, UILoadingComponent>("Path to a.png");
在Loading UI 中需要实例化a.prefab
GameObject obj = await AddressableComponent.Instance.InstantiateSublevelAsync<UILoadingComponent>("Path to a.prefab", transform);
Release Loading UI时,name 为 “UILoadingComponent”
这样我们在调用Hide Loading UI时就可以自动将所有和Loading有关的资源全部释放
AddressableComponent.Instance.ReleaseSublevel(name);
代码
using System; using System.Collections.Generic; using System.Linq; using ETModel; using UnityEngine; using UnityEngine.AddressableAssets; namespace ETHotfix { [ObjectSystem] public class AddressableAwakeSystem: AwakeSystem<AddressableComponent> { public override async ETTask Awake(AddressableComponent self) { self.Awake(); } } public class AddressableComponent: Component { public static AddressableComponent Instance; private Dictionary<string, Dictionary<string, List<UnityEngine.Object>>> _sublevelDic = new Dictionary<string, Dictionary<string, List<UnityEngine.Object>>>(); public void Awake() { Instance = this; } /// <summary> /// 仅用在Load子级资源 /// </summary> /// <param name="type">父级类型</param> /// <param name="url">url</param> /// <typeparam name="T">类型</typeparam> /// <returns></returns> public async ETTask<T> LoadSublevelAsset<T>(string type, string url) where T : UnityEngine.Object { Dictionary<string, List<UnityEngine.Object>> dic; List<UnityEngine.Object> objects = new List<UnityEngine.Object>(); UnityEngine.Object obj; try{ if(this._sublevelDic.ContainsKey(type)){ dic = this._sublevelDic[type]; if(dic.ContainsKey(url)){ objects = dic[url]; if(objects.Count > 0){ obj = objects[0]; } else{ obj = await Addressables.LoadAssetAsync<T>(url).Task; objects.Add(obj); } dic[url] = objects; } else{ obj = await Addressables.LoadAssetAsync<T>(url).Task; objects.Add(obj); if(!dic.ContainsKey(url)){ dic.Add(url, objects); } } } else{ dic = new Dictionary<string, List<UnityEngine.Object>>(); obj = await Addressables.LoadAssetAsync<T>(url).Task; objects.Add(obj); dic.Add(url, objects); if(!this._sublevelDic.ContainsKey(type)){ this._sublevelDic.Add(type, dic); } } } catch(Exception e){ Log.Error(e.ToString()); throw; } return(T) obj; } /// <summary> /// 仅用在Load子级资源 /// </summary> /// <param name="url">url</param> /// <typeparam name="T">对象类型</typeparam> /// <typeparam name="R">父级类型</typeparam> /// <returns></returns> public async ETTask<T> LoadSublevelAsset<T, R>(string url) where T : UnityEngine.Object where R : Component { return await this.LoadSublevelAsset<T>(typeof (R).Name, url); } /// <summary> /// 仅用在实例化子级资源 /// </summary> /// <param name="type">父级类型</param> /// <param name="url">url</param> /// <param name="transform">父级transform</param> /// <returns></returns> public async ETTask<GameObject> InstantiateSublevelAsync(string type, string url, Transform transform) { Dictionary<string, List<UnityEngine.Object>> dic; List<UnityEngine.Object> objects = new List<UnityEngine.Object>(); UnityEngine.Object obj; try{ if(this._sublevelDic.ContainsKey(type)){ dic = this._sublevelDic[type]; if(dic.ContainsKey(url)){ objects = dic[url]; obj = await Addressables.InstantiateAsync(url, transform).Task; objects.Add(obj); } else{ obj = await Addressables.InstantiateAsync(url, transform).Task; objects.Add(obj); dic.Add(url, objects); } } else{ dic = new Dictionary<string, List<UnityEngine.Object>>(); obj = await Addressables.InstantiateAsync(url, transform).Task; objects.Add(obj); dic.Add(url, objects); if(!this._sublevelDic.ContainsKey(type)){ this._sublevelDic.Add(type, dic); } } } catch(Exception e){ Log.Error(e.ToString()); throw; } return(GameObject) obj; } /// <summary> /// 仅用在实例化子级资源 /// </summary> /// <param name="url">url</param> /// <param name="transform">父级transform</param> /// <typeparam name="T">父级类型</typeparam> /// <returns></returns> public async ETTask<GameObject> InstantiateSublevelAsync<T>(string url, Transform transform) where T : Component { return await this.InstantiateSublevelAsync(typeof (T).Name, url, transform); } public void ReleaseSublevel(string type) { Dictionary<string, List<UnityEngine.Object>> dic; this._sublevelDic.TryGetValue(type, out dic); if(dic == null){ return; } foreach(List<UnityEngine.Object> objects in dic.Values){ foreach(UnityEngine.Object obj in objects){ if(obj is GameObject gameObject){ Addressables.ReleaseInstance(gameObject); Log.Debug(
quot;Release GameObject = {gameObject.name}");
continue;
}Addressables.Release(obj);
Log.Debug(quot;Release obj = {obj.name}, from {type}");
}
}dic.Clear();
this._sublevelDic.Remove(type);
}/// <summary>
/// 随父级释放所有资源
/// </summary>
/// <typeparam name="T"></typeparam>
public void ReleaseSublevel<T>() where T : Component { this.ReleaseSublevel(typeof (T).Name); }
}
}