本文章已于2021.7.9更新,修复FGUI释放UI包时,ab释放所有权问题。感谢网友 小太阳 的提醒。
本文章已于2021.3.29更新,FGUI编辑器升级到2021.1.0,FGUI SDK升级到4.2.0,新增FGUI资源加载全异步支持,简化大量代码。感谢网友ˇ℉un . Shīne
的提醒。
前言
技能系统暂时告一段落,现在要花点时间规范一下客户端这边的资源管理以及一些流程优化,这里选择轻量高效资源管理框架xasset:https://github.com/xasset/xasset
版本为:https://github.com/xasset/xasset/commit/3d4983cd24ff92a63156c8078caf34b20d2d4c02
代码量很少,一天就能看个差不多,但是质量很高,如果只追求使用的话,是可以开箱即用的。
另外我对xasset底层源码做了一些修改,主要是为了迎合我们ET传统艺能await/async样式代码,所以推荐大家直接使用我项目(下文的Moba项目)中的xasset源码
想要入门此资源管理框架的可以查看:https://www.lfzxb.top/xasset-base/
以及视频教程:https://www.bilibili.com/video/BV15A411v7nT
为了方便大家参考,可以去我对Moba项目:https://gitee.com/NKG_admin/NKGMobaBasedOnET ,来查看更加具体的接入代码,目前全部功能测试通过(资源加载,普通热更新,VFS热更新,FGUI适配)
流程预演
为了流畅接入一个框架,可以先把大体流程先构思一下
xasset使用主要分为3块
打包工具配置(直接拿过来改几个路径即可使用(因为要打包到我们资源服务器指定的目录下面))
本地资源服务器(使用ET自带的Web资源服务器即可)
运行时类库(非常简单的接口使用,完全不需要关心资源管理)
打包工具
xasset打包流程分ApplyRule(配置打包规则),BuildRule(自动分析依赖关系,优化资源冗余,解决资源冲突),BuildBundle(出包)三步走,具体内容可参照上文链接
本地资源服务器
这一块是ET的内容,事实上我们只需要修改代码,把资源打到文件资源服务器指定的目录就行了
运行时类库
xasset运行时接入相对于前面两块内容较为复杂,主要包括资源热更新模块接入,API封装,FGUI资源加载适配
正式开始
xasset导入
首先导入xasset到ET,主要有Editor和Runtime这两部分内容
首先是Editor部分,把Assets/XAsset/Editor文件夹放到我们ET中的Editor文件夹
Assets/XAsset/Runtime文件夹放到我们ET中的ThirdParty,注意移除UI文件夹,因为他是和xasset的官方Demo耦合的
会有一些Updater脚本的报错,但是不要怕,我们接下来解决他
它里面的报错主要是引用的Message Mono类(一个用于显示对话框的类)找不到导致的,所以我们把这部分内容改成用Debug输出或者直接删掉就行了
这里提供一个我的修改版本的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 using System;using System.Collections;using System.Collections.Generic;using System.IO;using UnityEngine;using UnityEngine.Networking;namespace libx { public enum Step { Wait, Copy, Coping, Versions, Prepared, Download, Completed, } [RequireComponent(typeof (Downloader)) ] public class Updater : MonoBehaviour { public Step Step; public Action ResPreparedCompleted; public float UpdateProgress; public bool DevelopmentMode; public bool EnableVFS = true ; [SerializeField ] private string baseURL = "http://127.0.0.1:7888/DLC/" ; private Downloader _downloader; private string _platform; private string _savePath; private List <VFile > _versions = new List<VFile>(); public void OnMessage (string msg ) { Debug.Log(msg); } public void OnProgress (float progress ) { UpdateProgress = progress; } private void Awake ( ) { _downloader = gameObject.GetComponent<Downloader>(); _downloader.onUpdate = OnUpdate; _downloader.onFinished = OnComplete; _savePath = string .Format("{0}/DLC/" , Application.persistentDataPath); _platform = GetPlatformForAssetBundles(Application.platform); this .Step = Step.Wait; Assets.updatePath = _savePath; } private void OnUpdate (long progress, long size, float speed ) { OnMessage(string .Format("下载中...{0}/{1}, 速度:{2}" , Downloader.GetDisplaySize(progress), Downloader.GetDisplaySize(size), Downloader.GetDisplaySpeed(speed))); OnProgress(progress * 1f / size); } private IEnumerator _checking; public void StartUpdate ( ) { Debug.Log("StartUpdate.Development:" + this .DevelopmentMode); #if UNITY_EDITOR if (this .DevelopmentMode) { Assets.runtimeMode = false ; StartCoroutine(LoadGameScene()); return ; } #endif if (_checking != null ) { StopCoroutine(_checking); } _checking = Checking(); StartCoroutine(_checking); } private void AddDownload (VFile item ) { _downloader.AddDownload(GetDownloadURL(item.name), item.name, _savePath + item.name, item.hash, item.len); } private void PrepareDownloads ( ) { if (this .EnableVFS) { var path = string .Format("{0}{1}" , _savePath, Versions.Dataname); if (!File.Exists(path)) { AddDownload(_versions[0 ]); return ; } Versions.LoadDisk(path); } for (var i = 1 ; i < _versions.Count; i++) { var item = _versions[i]; if (Versions.IsNew(string .Format("{0}{1}" , _savePath, item.name), item.len, item.hash)) { AddDownload(item); } } } private static string GetPlatformForAssetBundles (RuntimePlatform target ) { switch (target) { case RuntimePlatform.Android: return "Android" ; case RuntimePlatform.IPhonePlayer: return "iOS" ; case RuntimePlatform.WebGLPlayer: return "WebGL" ; case RuntimePlatform.WindowsPlayer: case RuntimePlatform.WindowsEditor: return "Windows" ; case RuntimePlatform.OSXEditor: case RuntimePlatform.OSXPlayer: return "iOS" ; default : return null ; } } private string GetDownloadURL (string filename ) { return string .Format("{0}{1}/{2}" , baseURL, _platform, filename); } private IEnumerator Checking ( ) { if (!Directory.Exists(_savePath)) { Directory.CreateDirectory(_savePath); } this .Step = Step.Copy; if (this .Step == Step.Copy) { yield return RequestCopy ( ) ; } if (this .Step == Step.Coping) { var path = _savePath + Versions.Filename + ".tmp" ; var versions = Versions.LoadVersions(path); var basePath = GetStreamingAssetsPath() + "/" ; yield return UpdateCopy (versions, basePath ) ; this .Step = Step.Versions; } if (this .Step == Step.Versions) { yield return RequestVersions ( ) ; } if (this .Step == Step.Prepared) { OnMessage("正在检查版本信息..." ); var totalSize = _downloader.size; if (totalSize > 0 ) { Debug.Log($"发现内容更新,总计需要下载 {Downloader.GetDisplaySize(totalSize)} 内容" ); _downloader.StartDownload(); this .Step = Step.Download; } else { OnComplete(); } } } private IEnumerator RequestVersions ( ) { OnMessage("正在获取版本信息..." ); if (Application.internetReachability == NetworkReachability.NotReachable) { Debug.LogError("请检查网络连接状态" ); yield break ; } var request = UnityWebRequest.Get(GetDownloadURL(Versions.Filename)); request.downloadHandler = new DownloadHandlerFile(_savePath + Versions.Filename); yield return request.SendWebRequest(); var error = request.error; request.Dispose(); if (!string .IsNullOrEmpty(error)) { Debug.LogError($"获取服务器版本失败:{error} " ); yield break ; } try { _versions = Versions.LoadVersions(_savePath + Versions.Filename, true ); if (_versions.Count > 0 ) { PrepareDownloads(); this .Step = Step.Prepared; } else { OnComplete(); } } catch (Exception e) { Debug.LogException(e); Debug.LogError("版本文件加载失败" ); } } private static string GetStreamingAssetsPath ( ) { if (Application.platform == RuntimePlatform.Android) { return Application.streamingAssetsPath; } if (Application.platform == RuntimePlatform.WindowsPlayer || Application.platform == RuntimePlatform.WindowsEditor) { return "file:///" + Application.streamingAssetsPath; } return "file://" + Application.streamingAssetsPath; } private IEnumerator RequestCopy ( ) { var v1 = Versions.LoadVersion(_savePath + Versions.Filename); var basePath = GetStreamingAssetsPath() + "/" ; var request = UnityWebRequest.Get(basePath + Versions.Filename); var path = _savePath + Versions.Filename + ".tmp" ; request.downloadHandler = new DownloadHandlerFile(path); yield return request.SendWebRequest(); if (string .IsNullOrEmpty(request.error)) { var v2 = Versions.LoadVersion(path); if (v2 > v1) { Debug.Log("将资源解压到本地" ); this .Step = Step.Coping; } else { Versions.LoadVersions(path); this .Step = Step.Versions; } } else { this .Step = Step.Versions; } request.Dispose(); } private IEnumerator UpdateCopy (IList<VFile> versions, string basePath ) { var version = versions[0 ]; if (version.name.Equals(Versions.Dataname)) { var request = UnityWebRequest.Get(basePath + version.name); request.downloadHandler = new DownloadHandlerFile(_savePath + version.name); var req = request.SendWebRequest(); while (!req.isDone) { OnMessage("正在复制文件" ); OnProgress(req.progress); yield return null ; } request.Dispose(); } else { for (var index = 0 ; index < versions.Count; index++) { var item = versions[index]; var request = UnityWebRequest.Get(basePath + item.name); request.downloadHandler = new DownloadHandlerFile(_savePath + item.name); yield return request.SendWebRequest(); request.Dispose(); OnMessage(string .Format("正在复制文件:{0}/{1}" , index, versions.Count)); OnProgress(index * 1f / versions.Count); } } } private void OnComplete ( ) { if (this .EnableVFS) { var dataPath = _savePath + Versions.Dataname; var downloads = _downloader.downloads; if (downloads.Count > 0 && File.Exists(dataPath)) { OnMessage("更新本地版本信息" ); var files = new List<VFile>(downloads.Count); foreach (var download in downloads) { files.Add(new VFile { name = download.name, hash = download.hash, len = download.len, }); } var file = files[0 ]; if (!file.name.Equals(Versions.Dataname)) { Versions.UpdateDisk(dataPath, files); } } Versions.LoadDisk(dataPath); } OnProgress(1 ); OnMessage($"更新完成,版本号:{Versions.LoadVersion(_savePath + Versions.Filename)} " ); StartCoroutine(LoadGameScene()); } private IEnumerator LoadGameScene ( ) { OnMessage("正在初始化" ); var init = Assets.Initialize(); yield return init ; this .Step = Step.Completed; if (string .IsNullOrEmpty(init .error)) { init .Release(); OnProgress(0 ); OnMessage("加载游戏场景" ); ResPreparedCompleted?.Invoke(); } else { init .Release(); Debug.LogError($"初始化异常错误:{init .error} ,请联系技术支持" ); } } } }
最后因为我们Model层会用到xasset,所以引用asmdef文件, Hotfix同理
替换ET资源管理模块
因为我们使用xasset全盘托管资源管理(资源加载,热更新),所以我们只需要对其进行封装即可
移除所有打包模块
Editor下的打包模块相关代码都可以删除
ResourceComponent
其中有一些api需要对xasset源码进行拓展,可以参考Moba项目的xasset源码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 using libx;using UnityEngine;namespace ETModel { public class ResourcesComponent : Component { #region Assets public T LoadAsset <T >(string path ) where T : UnityEngine.Object { AssetRequest assetRequest = Assets.LoadAsset(path, typeof (T)); return (T) assetRequest.asset; } public ETTask <T > LoadAssetAsync <T >(string path ) where T : UnityEngine.Object { ETTaskCompletionSource<T> tcs = new ETTaskCompletionSource<T>(); AssetRequest assetRequest = Assets.LoadAssetAsync(path, typeof (T)); if (assetRequest.isDone) { tcs.SetResult((T) assetRequest.asset); return tcs.Task; } assetRequest.completed += (arq) => { tcs.SetResult((T) arq.asset); }; return tcs.Task; } public void UnLoadAsset (string path ) { Assets.UnloadAsset(path); } #endregion #region Scenes public ETTask<SceneAssetRequest> LoadSceneAsync (string path ) { ETTaskCompletionSource<SceneAssetRequest> tcs = new ETTaskCompletionSource<SceneAssetRequest>(); SceneAssetRequest sceneAssetRequest = Assets.LoadSceneAsync(path, false ); sceneAssetRequest.completed = (arq) => { tcs.SetResult(sceneAssetRequest); }; return tcs.Task; } public void UnLoadScene (string path ) { Assets.UnloadScene(path); } #endregion } }
BundleDownloaderComponent
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 using System;using System.Collections.Generic;using System.IO;using System.Threading.Tasks;using libx;using UnityEngine;namespace ETModel { [ObjectSystem ] public class UiBundleDownloaderComponentAwakeSystem : AwakeSystem <BundleDownloaderComponent > { public override void Awake (BundleDownloaderComponent self ) { self.Updater = GameObject.FindObjectOfType<Updater>(); } } [ObjectSystem ] public class UiBundleDownloaderComponentSystem : UpdateSystem <BundleDownloaderComponent > { public override void Update (BundleDownloaderComponent self ) { if (self.Updater.Step == Step.Completed) { self.Tcs.SetResult(); } } } public class BundleDownloaderComponent : Component { public Updater Updater; public ETTaskCompletionSource Tcs; public ETTask StartUpdate ( ) { Tcs = new ETTaskCompletionSource(); Updater.ResPreparedCompleted = () => { Tcs.SetResult(); }; Updater.StartUpdate(); return Tcs.Task; } } }
ABPathUtilities
因为xasset使用全路径对资源进行加载,所以我们要提供路径拓展
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 namespace ETModel { public class ABPathUtilities { public static string GetTexturePath (string fileName ) { return $"Assets/Bundles/Altas/{fileName} .prefab" ; } public static string GetFGUIDesPath (string fileName ) { return $"Assets/Bundles/FUI/{fileName} .bytes" ; } public static string GetFGUIResPath (string fileName,string extension ) { return $"Assets/Bundles/FUI/{fileName} {extension} " ; } public static string GetNormalConfigPath (string fileName ) { return $"Assets/Bundles/Independent/{fileName} .prefab" ; } public static string GetSoundPath (string fileName ) { return $"Assets/Bundles/Sounds/{fileName} .prefab" ; } public static string GetSkillConfigPath (string fileName ) { return $"Assets/Bundles/SkillConfigs/{fileName} .prefab" ; } public static string GetUnitPath (string fileName ) { return $"Assets/Bundles/Unit/{fileName} .prefab" ; } public static string GetScenePath (string fileName ) { return $"Assets/Scenes/{fileName} .unity" ; } } }
打包配置
BuildlScript
把脚本中对应路径进行修改即可
1 2 3 4 5 6 7 8 9 10 public static class BuildScript { public static string ABOutPutPath = c_RelativeDirPrefix + GetPlatformName(); private const string c_RelativeDirPrefix = "../Release/" ; private const string c_RulesDir = "Assets/Res/XAsset/Rules.asset" ; .... }
Assets
按需求修改Manifest保存路径即可
1 2 3 4 5 public sealed class Assets : MonoBehaviour { public static readonly string ManifestAsset = "Assets/Res/XAsset/Manifest.asset" ; ... }
适配FGUI
因为FGUI提供的API是对于AssetBundle而言的,而xasset设计理念是不关心AssetBundle,所以我们要使用FGUI提供的自定义包加载 功能
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 using System.Collections.Generic;using FairyGUI;using UnityEngine;namespace ETModel { public class FUIPackageComponent : Component { private readonly Dictionary <string , UIPackage > packages = new Dictionary<string , UIPackage>(); public async ETTask AddPackageAsync (string type ) { if (this .packages.ContainsKey(type)) { return ; } TextAsset desTextAsset = await ResourcesComponent.Instance.LoadAssetAsync<TextAsset>(ABPathUtilities.GetFGUIDesPath($"{type} _fui" )); packages.Add(type, UIPackage.AddPackage(desTextAsset.bytes, type, LoadPackageInternalAsync)); } private static async void LoadPackageInternalAsync (string name, string extension, System.Type type, PackageItem item ) { Texture texture = await ResourcesComponent.Instance.LoadAssetAsync<Texture>(ABPathUtilities.GetFGUIResPath(name, extension)); item.owner.SetItemAsset(item, texture, DestroyMethod.Unload); } public void RemovePackage (string type ) { UIPackage package; if (packages.TryGetValue(type, out package)) { var p = UIPackage.GetByName(package.name); if (p != null ) { ResourcesComponent.Instance.UnLoadAsset(ABPathUtilities.GetFGUIResPath($"{type} _atlas0" , ".png" )); ResourcesComponent.Instance.UnLoadAsset(ABPathUtilities.GetFGUIDesPath($"{type} _fui" )); UIPackage.RemovePackage(package.name); } packages.Remove(package.name); } } } }
对于FGUI来说,其内部在执行 UIPackage.RemovePackage
时会进行ab.Unload(true)
操作,应该是个很贴心的设计,但我们xasset需要管理资源的引用计数,所以不需要这个贴心的功能,故:
1 UIPackage.unloadBundleByFGUI = false ;
热更新流程演示
打包
xasset出包流程为
Apply Rule
Build Rule
Build Bundle
Build Player
根据我们ET的传统艺能,资源形式大多都是一个个prefab(但是这种做法不提倡嗷,要按正规项目那样分布)
这里以Unit为例,对Unit文件夹应用Prefab规则(各个规则代表的含义可以去前面链接里的文章查看)
对于FUI(我们的FGUI编辑器导出的文件)需要应用两次规则,因为有png和bytes两种文件
然后我们会得到一个Rule.asset文件
其中的Scene In Build选项中需要包含我们随包发布的Scene(ET中的Init.scene)
然后我们Build Bundle,就可以出包了
运行
为Global添加Update Mono脚本
其中各个内容含义为:
Step:当前热更新阶段
Update Progess:当前热更新阶段进度
Development Mode:是否开启编辑器资源模式,如果开启会使用AssetDatabase.load进行资源加载原始资源,如果关闭会模拟出包环境下的资源加载
Enable VFS:是否开启VFS(对于VFS更加详细的内容,可以去上文链接中查看)
Base URL:资源下载地址,这里我填写的是HFS的资源地址,如果我们使用ET资源文件服务器就是http://127.0.0.1:8080/
然后在脚本调用,即可进行热更新,其中对于热更新各个阶段的进度,都可对Updater的Step和UpdateProgress来取得
1 await bundleDownloaderComponent.StartUpdate();
资源加载
同步资源加载
以我们加载Hotfix.dll.bytes为例
1 2 3 4 GameObject code = Game.Scene.GetComponent<ResourcesComponent>().LoadAsset<GameObject>(ABPathUtilities.GetNormalConfigPath("Code" )); byte [] assBytes = code.GetTargetObjectFromRC<TextAsset>("Hotfix.dll" ).bytes;byte [] pdbBytes = code.GetTargetObjectFromRC<TextAsset>("Hotfix.pdb" ).bytes;
异步资源加载
这里加载一在路径Assets/Textures/TargetTextureName.png!webp 中的贴图示例
1 await Game.Scene.GetComponent<ResourcesComponent>().LoadAssetAsync<Sprite>("Assets/Textures/TargetTextureName.png!webp" );
资源卸载
1 Game.Scene.GetComponent<ResourcesComponent>().UnLoadAsset("Assets/Textures/TargetTextureName.png!webp" );
资源内存释放
xasset采用惰性GC的资源内存管理方案,老版本是每帧都会检查和清理未使用的资源(称为灵敏GC),这个版本底层只会在切换场景或者主动调用Assets.RemoveUnusedAssets();的时候才会清理未使用的资源,这样用户可以按需调整资源回收的频率,在没有内存压力的时候,不回收可以获得更好的性能。