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

简介

达哥B站视频

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来保存这个数据

-w348

注意事项

在我们正常使用中发现,如果在数据表中插入一列新的数据,解析成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 = $"{Application.dataPath}/Res/AddressableAssetsData/iOS/addressables_content_state.bin";
#else
            var path = $"{Application.dataPath}/Res/AddressableAssetsData/Android/addressables_content_state.bin";
#endif

            if(!string.IsNullOrEmpty(path)){
                ContentUpdateScript.BuildContentUpdate(AddressableAssetSettingsDefaultObject.Settings, path);

                string arguments =
                        $"-vzrtopg --password-file=./Tools/cwRsync/Config/rsync.secrets --delete ./Unity/ServerData/ 用户名@地址";

                ProcessHelper.Run(
                    @"/usr/bin/rsync",
                    arguments,
                    @"../",
                    true
                );

                Log.Info("同步完成!");
            }
        }
    }

Addressable使用

资源文件加载

下面是AssetBundleAddressable 加载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($"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, "更新", $"检查到更新,大小为{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中的Name

GameObject 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($"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($"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($"Release GameObject = {gameObject.name}");
                        continue;
                    }

                    Addressables.Release(obj);
                    Log.Debug($"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); }
    }
}

What doesn’t kill you makes you stronger.