热更新项目简单模板
示例项目仓库,结构简单流程易懂
https://github.com/Kerzhrua/HybridCLR_Addressable/tree/MyTest
重要流程代码:
using HybridCLR;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using GamePlay;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using Object = UnityEngine.Object;
namespace AOT
{
/// <summary>
/// 用于启动游戏,执行版本检查,版本更新,加载程序集,进入游戏
/// 错误处理是用的打印日志,正式上线的话需要一个错误处理系统来给玩家显示错误信息
/// </summary>
public class GameLauncher : MonoBehaviour
{
public const string META_DATA_DLL_SEPARATOR = "!"; // 分割符号 区分元数据dll
private readonly List<string> _gamePlayDependencyDlls = new List<string>() { }; // GamePlay程序集依赖的热更程序集,这些程序集要先于gameplay程序集加载,需要手动填写
#region 编辑器赋值
public UIVersionUpdate _versionUpdateUI; // 进度条ui
#endregion
#region 缓存
private byte[] _dllBytes; // dll文件的字节数组
private AddressableAssetManager addressableAssetManager = new AddressableAssetManager(); // 资源管理器
private Coroutine _launchCoroutine; // 启动器协程
private Dictionary<string, Assembly> _allHotUpdateAssemblies = new(); // 热更新程序集 程序集名称_程序集
#endregion
private bool enableHybridCLR = !Application.isEditor; // 是否开启HybridCLR,在编辑器下自动关闭
private void Start()
{
_launchCoroutine = StartCoroutine(Launch());
DontDestroyOnLoad(this);
}
private IEnumerator Launch()
{
Debug.Log("检查更新");
yield return CheckUpdate(); // 检查更新
Debug.Log("加载程序集");
yield return LoadAssemblies(); // 加载程序集
Debug.Log("跳转场景");
yield return EnterGame(); // 跳转场景
Debug.Log("创建物体还原脚本");
var GameTestOp = Addressables.LoadAssetAsync<GameObject>("Assets/Prefabs/GameTest.prefab");
yield return new WaitUntil(() => { return GameTestOp.Status == AsyncOperationStatus.Succeeded; });
Instantiate(GameTestOp.Result);
}
/// <summary>
/// 检查版本更新
/// </summary>
/// <returns></returns>
private IEnumerator CheckUpdate()
{
yield return addressableAssetManager.CheckUpdate();
if (addressableAssetManager.IsHasContentToDownload())
{
Debug.Log($"检测到存在内容更新,开始更新内容");
Debug.Log($"展示进度ui追踪下载进程");
yield return OpenVersionUpdateUI();
Debug.Log($"开始下载新内容");
yield return Download();
}
}
//打开版本更新UI
private IEnumerator OpenVersionUpdateUI()
{
_versionUpdateUI = FindObjectOfType<UIVersionUpdate>(true);
if (_versionUpdateUI == null)
{
Debug.LogError("cant find UIVersionUpdate");
return null;
}
_versionUpdateUI.gameObject.SetActive(true);
_versionUpdateUI.GetDownloadProgress = addressableAssetManager.GetDownloadProgress;
return null;
}
//下载资源
private IEnumerator Download()
{
yield return addressableAssetManager.DownloadAssets();
_versionUpdateUI.GetDownloadProgress = null;
}
/// <summary>
/// 加载程序集
/// </summary>
/// <returns></returns>
private IEnumerator LoadAssemblies()
{
Debug.Log("为AOT补充元数据");
yield return LoadMetadataForAOTAssemblies();
Debug.Log("加载游戏程序集依赖的dll");
yield return LoadGamePlayDependencyAssemblies();
Debug.Log("加载游戏程序集");
yield return LoadGamePlayAssemblies();
Debug.Log("重新加载catalog");
yield return addressableAssetManager.ReloadAddressableCatalog();
}
/// <summary>
/// 补充元数据
/// </summary>
/// <returns></returns>
private IEnumerator LoadMetadataForAOTAssemblies()
{
var mataDataDlls = GetMetaDataDllToLoad();
foreach (var aotDllName in mataDataDlls)
{
if(string.IsNullOrEmpty(aotDllName)) continue;
var path = $"Assets/HotUpdateDlls/MetaDataDll/{aotDllName}.bytes";
yield return ReadDllBytes(path);
if (_dllBytes != null)
{
var state = HybridCLR.RuntimeApi.LoadMetadataForAOTAssembly(_dllBytes, HomologousImageMode.SuperSet);
Debug.Log($"加载元数据结果:{aotDllName}. 状态码:{state}");
}
}
}
/// <summary>
/// 获得要加载的元数据dll 从生成的AOTGenericReferences获取
/// 文件从HybridCLRData\AssembliesPostIl2CppStrip\{platform}下获取 注意添加.bytes后缀
/// </summary>
/// <returns></returns>
private string[] GetMetaDataDllToLoad()
{
return new string[]
{
"System.Core.dll",
"System.dll",
"Unity.Addressables.dll",
"Unity.ResourceManager.dll",
"UnityEngine.CoreModule.dll",
"mscorlib.dll",
};
}
//加载GamePlay依赖的第三方程序集
private IEnumerator LoadGamePlayDependencyAssemblies()
{
foreach (var dllName in _gamePlayDependencyDlls)
{
yield return LoadSingleHotUpdateAssembly(dllName);
}
}
/// <summary>
/// 加载GamePlay程序集
/// 文件从HybridCLRData\HotUpdateDlls\{platform}获取 注意添加.bytes后缀
/// </summary>
/// <returns></returns>
private IEnumerator LoadGamePlayAssemblies()
{
yield return LoadSingleHotUpdateAssembly("GamePlay.dll");
}
private IEnumerator EnterGame()
{
yield return addressableAssetManager.ChangeScene("Assets/Scenes/GameScenes/StartScene.unity");
}
#region 工具方法
/// <summary>
/// 读取动态链接库字节
/// </summary>
/// <param name="path"></param>
private IEnumerator ReadDllBytes(string path)
{
Debug.Log("读取dll数据:" + path);
TextAsset dllText = null;
yield return addressableAssetManager.LoadAssetCoroutine<TextAsset>(path, (text) =>
{
dllText = text;
});
if (dllText == null)
{
Debug.LogError($"读取dll数据失败,路径:{path}");
_dllBytes = null;
}
else
{
Debug.Log("读取dll数据成功:" + path);
_dllBytes = dllText.bytes;
}
addressableAssetManager.UnloadAsset(dllText);
}
/// <summary>
/// 加载程序集
/// </summary>
/// <param name="dllName"></param>
/// <returns></returns>
private IEnumerator LoadSingleHotUpdateAssembly(string dllName)
{
var path = $"Assets/HotUpdateDlls/HotUpdateDll/{dllName}.bytes";
yield return ReadDllBytes(path);
if (_dllBytes != null)
{
var assembly = Assembly.Load(_dllBytes);
_allHotUpdateAssemblies.Add(assembly.FullName, assembly);
Debug.Log($"加载程序集成功:{assembly.FullName}");
}
}
/// <summary>
/// 获取程序集
/// </summary>
/// <param name="assemblyName"></param>
/// <returns></returns>
private Assembly GetAssembly(string assemblyName)
{
assemblyName = assemblyName.Replace(".dll", "");
IEnumerable<Assembly> allAssemblies =
enableHybridCLR ? _allHotUpdateAssemblies.Values : AppDomain.CurrentDomain.GetAssemblies();
return allAssemblies.First(assembly => assembly.FullName.Contains(assemblyName));
}
#endregion
}
}
更新资源代码:
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.AddressableAssets.ResourceLocators;
using UnityEngine.ResourceManagement.AsyncOperations;
using UnityEngine.SceneManagement;
using UnityEngine.Serialization;
namespace AOT
{
/// <summary>
/// Addressable的资源管理器(只用于启动游戏时更新)
/// </summary>
public class AddressableAssetManager
{
//记录在playerPres里的需要下载的catalogs的ID 记录这个数据是为了当下载过程被打断,下次登录可以根据这个信息继续下载
const string DOWNLOAD_CATALOGS_ID = "DownloadCatalogIDs";
#region 缓存
private List<object> _KeysNeedToDownload = new(); // 需要下载的文件keys,key是用来做资源定位的,也就是资源的唯一标识
//此对象里保存了需要下载的catalog,每次获取新的catalog会将此对象保存到手机上,如果在下载的过程中关闭了游戏,下次打开还能拿到catalog继续下载
private DownloadContent _downloadContent = new();
private AsyncOperationHandle _downloadOP; // 异步操作处理对象 用于异步下载
#endregion
[Serializable]
private class DownloadContent // 需要下载的内容
{
public List<string> catalogIDs = new(); // 需要更新的catalog的id列表
}
/// <summary>
/// 是否存在需要下载的更新内容
/// </summary>
/// <returns></returns>
public bool IsHasContentToDownload()
{
if (_downloadContent != null && _downloadContent.catalogIDs != null &&
_downloadContent.catalogIDs.Count > 0)
{
return true;
}
return false;
}
/// <summary>
/// 加载资源
/// </summary>
/// <param name="path"></param>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
public IEnumerator LoadAssetCoroutine<T>(string path, System.Action<T> onComplete)
{
var op = Addressables.LoadAssetAsync<T>(path);
if (!op.IsValid())
{
Debug.LogError($"Invalid Addressables path: {path}");
onComplete?.Invoke(default);
yield break;
}
// 等待异步加载完成
yield return op;
if (op.Status == AsyncOperationStatus.Succeeded)
{
onComplete?.Invoke(op.Result);
}
else
{
Debug.LogError($"Failed to load asset at path: {path}, Error: {op.OperationException}");
onComplete?.Invoke(default);
}
}
/// <summary>
/// 释放加载的资源
/// </summary>
/// <param name="asset"></param>
public void UnloadAsset(UnityEngine.Object asset)
{
if (asset != null)
Addressables.Release(asset);
}
/// <summary>
/// 检查addressable更新 更新catalog文件到最新版本,找出所有需要更新的资源唯一id
/// </summary>
/// <returns></returns>
public IEnumerator CheckUpdate()
{
// 检查catalog需要更新的内容
var checkUpdateOP = Addressables.CheckForCatalogUpdates(false);
yield return checkUpdateOP; // 等待检查完成
if (checkUpdateOP.Status == AsyncOperationStatus.Succeeded)
{
#region 确保下载内容对象_downloadContent的下载内容正确性,主要考虑下载中断问题
_downloadContent.catalogIDs = checkUpdateOP.Result; // 赋值到下载内容对象 这是需要更新的catalog的id列表,目前不知道怎么出现多个catalog
if (IsHasContentToDownload()) // 存在需要更新的内容
{
Debug.Log("检测到存在新内容");
//说明服务器上有新的资源,记录要下载的catalog值在playerprefs中,如果下载的过程中被打断,下次打开游戏使用该值还能继续下载
var jsonStr = JsonUtility.ToJson(_downloadContent);
PlayerPrefs.SetString(DOWNLOAD_CATALOGS_ID, jsonStr);
PlayerPrefs.Save();
}
else // 不存在需要更新的内容 但这只是catalog与本地一致,并不代表资源更新完毕
{
if (PlayerPrefs.HasKey(DOWNLOAD_CATALOGS_ID)) // 如果上次下载内容中断
{
//上一次的更新还没下载完
Debug.Log("继续上一次的下载更新");
var jsonStr = PlayerPrefs.GetString(DOWNLOAD_CATALOGS_ID); // 获取未下载完成的内容
JsonUtility.FromJsonOverwrite(jsonStr, _downloadContent); // 覆盖到下载内容对象
}
else
{
//没有需要下载的内容
Debug.Log("不存在需要下载的内容");
}
}
#endregion
if (IsHasContentToDownload()) // 有内容需要下载
{
Debug.Log("资源更新列表:" + JsonUtility.ToJson(_downloadContent));
Debug.Log("根据更新列表进行下载更新");
// 更新catalog,如果不提供catalog的id列表,会检查所有已加载的catalog以获取更新
// 如果只调用Addressables.UpdateCatalogs,它会返回所有资源的索引,但是如果只更新对应的catalogId,就只返回那些需要更新的资源索引,以此避免全部重新下载
AsyncOperationHandle<List<IResourceLocator>> updateCatalogOP = Addressables.UpdateCatalogs(_downloadContent.catalogIDs, false);
// 等待更新完成 这样catalog已经确保是最新的了
yield return updateCatalogOP;
if (updateCatalogOP.Status == AsyncOperationStatus.Succeeded)
{
// 更新所有需要更新的资源定位key
_KeysNeedToDownload.Clear();
foreach (var resourceLocator in updateCatalogOP.Result)
{
_KeysNeedToDownload.AddRange(resourceLocator.Keys);
}
Debug.Log("需要更新的资源key:" + JsonUtility.ToJson(_KeysNeedToDownload));
}
else
{
Debug.LogError($"更新catalog失败:{updateCatalogOP.OperationException.Message}");
}
Addressables.Release(updateCatalogOP);
}
}
else
{
Debug.LogError($"检查catalog更新失败:{checkUpdateOP.OperationException.Message}");
}
Addressables.Release(checkUpdateOP);
//更新完catalog后重新加载一下Addressable的Catalog
yield return ReloadAddressableCatalog();
}
/// <summary>
/// 下载资源 根据需要更新资源的唯一id数组,计算需要下载的大小,
/// </summary>
/// <returns></returns>
public IEnumerator DownloadAssets()
{
var downloadSizeOp = Addressables.GetDownloadSizeAsync((IEnumerable)_KeysNeedToDownload);
yield return downloadSizeOp;
Debug.Log($"download size:{downloadSizeOp.Result / (1024f * 1024f)}MB");
if (downloadSizeOp.Result > 0) // 如果有需要下载的内容
{
Addressables.Release(downloadSizeOp);
/* 开始下载
用于合并请求结果的选项。如果键(A,B)映射到结果([1,2,4],[3,4,5])
UseFirst(或None)获取第一个键的结果。--[1,2,4]
Union获取每个键的结果,并收集与任何键匹配的项。--[1,2,3,4,5]
Intersection获取每个关键字的结果,并收集与每个关键字匹配的项。--[4]
*/
_downloadOP = Addressables.DownloadDependenciesAsync((IEnumerable)_KeysNeedToDownload, Addressables.MergeMode.Union, false);
yield return _downloadOP; // 等待下载完成 全部的
if (_downloadOP.Status == AsyncOperationStatus.Succeeded)
Debug.Log($"download finish!");
else
Debug.LogError(
$"Download Failed! exception:{_downloadOP.OperationException.Message} \r\n {_downloadOP.OperationException.StackTrace}");
Addressables.Release(_downloadOP);
}
//清除需要下载的内容
Debug.Log($"delete key:{DOWNLOAD_CATALOGS_ID}");
PlayerPrefs.DeleteKey(DOWNLOAD_CATALOGS_ID);
}
/// <summary>
/// 获取下载进程
/// </summary>
/// <returns></returns>
public UIVersionUpdate.DownloadInfo GetDownloadProgress()
{
if (!_downloadOP.IsValid())
return default;
var downloadStatus = _downloadOP.GetDownloadStatus();
return new UIVersionUpdate.DownloadInfo(downloadStatus.Percent, downloadStatus.DownloadedBytes, downloadStatus.TotalBytes);
}
public IEnumerator ChangeScene(string sceneName)
{
var op = Addressables.LoadSceneAsync(sceneName, LoadSceneMode.Single);
// 等待完成
yield return op;
if (op.Status != AsyncOperationStatus.Succeeded)
{
Debug.LogError($"加载场景失败:{op.OperationException.Message} \r\n {op.OperationException.StackTrace}");
}
}
#region Other
/// <summary>
/// 重新加载catalog
/// Addressable初始化时热更新代码所对应的ScriptableObject的类型会被识别为System.Object,需要在热更新dll加载完后重新加载一下Addressable的Catalog
/// https://hybridclr.doc.code-philosophy.com/docs/help/commonerrors#%E4%BD%BF%E7%94%A8addressable%E8%BF%9B%E8%A1%8C%E7%83%AD%E6%9B%B4%E6%96%B0%E6%97%B6%E5%8A%A0%E8%BD%BD%E8%B5%84%E6%BA%90%E5%87%BA%E7%8E%B0-unityengineaddressableassetsinvlidkeyexception-exception-of-type-unityengineaddressableassetsinvalidkeyexception-was-thrown-no-asset-found-with-for-key-xxxx-%E5%BC%82%E5%B8%B8
/// </summary>
/// <returns></returns>
public IEnumerator ReloadAddressableCatalog()
{
var op = Addressables.LoadContentCatalogAsync($"{Addressables.RuntimePath}/catalog.json");
// 等待加载完成
yield return op;
if (op.Status != AsyncOperationStatus.Succeeded)
{
Debug.LogError($"加载catalog失败:{op.OperationException.Message} \r\n {op.OperationException.StackTrace}");
}
}
#endregion
}
}
如何改造一个现有项目支持热更?
将游戏的热更逻辑都放在一个程序集中,挂上需要的引用,处理好无报错
创建一个初始Gamelauncher场景,仅包含更新资源和加载dll相关的代码,这些代码在Assembly-CSharp中
当流程处理完即dll文件加载完成后跳转场景到游戏加载场景Load,注意在Load场景中什么都不要放,当场景加载完成后通过实例化预制体的方式来还原脚本,即把项目原本的Load场景的脚本全部放到一个预制体里加载还原。
游戏加载完成后根据需求是否还要切换场景,如还要切到Main场景,切换流程和切换到Load场景一致,Main场景中也什么都没有,在加载完成后再实例化预制体以还原脚本。
当这些处理完成后,可以开始remote打包流程。
热更的Generate All执行流程如下:
// 构建目标平台
BuildTarget target = EditorUserBuildSettings.activeBuildTarget;
// 编译目标平台的dll文件
CompileDllCommand.CompileDll(target);
// 生成必要的 IL2CPP 头文件和定义,扩展 IL2CPP 以支持解释执行
Il2CppDefGeneratorCommand.GenerateIl2CppDef();
// 生成 Link.xml 文件,避免热更新代码因裁剪而无法运行
LinkGeneratorCommand.GenerateLinkXml(target);
// 生成裁剪后的AOT dll 在打包过程中,Unity 会对 AOT 程序集进行裁剪(Strip),移除未使用的代码。AOTDlls 会保存这些裁剪后的 DLL,用于补充元数据(如泛型实例化)
StripAOTDllCommand.GenerateStripedAOTDlls(target);
// 桥接函数生成依赖于AOT dll,必须保证已经build过,生成AOT dll
// 在 AOT 代码和解释执行代码之间建立桥接,使得热更新代码和AOT代码可以互相调用
MethodBridgeGeneratorCommand.GenerateMethodBridge(target);
// 在 AOT 环境下,native 回调 C# 委托需要固定的函数指针,而热更新代码的动态性会导致回调失败。
// 故要为热更新代码中的委托(Delegate)生成反向 P/Invoke 包装器,使得 C# 委托可以被 native 代码(如 Unity 引擎或 C++ 插件)回调
ReversePInvokeWrapperGeneratorCommand.GenerateReversePInvokeWrapper(target);
// 生成 AOT 泛型引用,以解决 AOT 泛型限制问题,让热更新代码可以使用未在 AOT 中实例化的泛型类或方法(如 List<MyHotUpdateType>)
AOTReferenceGeneratorCommand.GenerateAOTGenericReference(target);
在webgl平台下打包有额外的步骤
https://hybridclr.doc.code-philosophy.com/docs/basic/buildwebgl
以管理员权限打开命令行窗口,这个操作不同操作系统版本不一样,请酌情处理。在Win11下为在开始菜单上右键,选中终端管理员菜单项。
运行 cd /d {editor_install_dir}/Editor/Data/il2cpp, 切换目录到安装目录的il2cpp目录
运行ren libil2cpp libil2cpp-origin 将原始libil2cpp改名为libil2cpp-origin
运行 mklink /D libil2cpp "{project}/HybridCLRData/LocalIl2CppData-{platform}/il2cpp/libil2cpp" 建立Editor目录的libil2cpp到本地libil2cpp目录的符号引用
示例:mklink /D libil2cpp "E:\Unity\UnityProjects\DiveVsSlimeLFS/HybridCLRData/LocalIl2CppData-WindowsEditor/il2cpp/libil2cpp"
注意!!!在热更下RuntimeInitializeOnLoadMethod未被支持,故如果想实现类似效果可获取程序集通过反射实现。