在Unity中,结合
Addressable Asset System
(简称:AA)和HybridCLR
来实现热更新资源和脚本的控制。AA
是Unity的一个强大的资源管理系统,可以用于动态加载和更新资源。HybridCLR是一个用于在Unity中运行C#脚本的工具,可以实现热更新脚本的功能。
使用版本:
发布WebGL平台:从Unity 2021.3.4+、2022.3.0+版本起,不再需要全局安装,也就是webgl平台的构建过程与其他平台完全相同。
实现思路:
原理解析:
通过使用AA
,你可以将游戏中的资源打包成独立的AssetBundle,并在运行时根据需要加载和卸载这些资源。这样,你就可以实现资源的热更新,不需要重新构建和发布整个应用程序。
而HybridCLR
可以帮助你实现热更新脚本的功能。它允许你在Unity中加载和运行C#脚本代码,而无需重新编译整个项目。通过将脚本代码打包成DLL文件,并使用HybridCLR加载和执行这些DLL文件,你可以在游戏运行时动态更新脚本逻辑,实现脚本的热更新。
结合AA和HybridCLR,可以实现资源和脚本的全部热更新控制。我们将资源和脚本分别打包成独立的AssetBundle和DLL文件,然后在游戏运行时根据需要下载和加载这些文件。这样,你可以实现资源和脚本的全部热更新流程。
主菜单中点击Windows/Package Manager
打开包管理器。如下图所示点击Add package from git URL...
,填入https://gitee.com/focus-creative-games/hybridclr_unity.git
或https://github.com/focus-creative-games/hybridclr_unity.git
。
没看过官方文档的,推荐去看官方文档的快速上手
按照官方快速上手的文档将实现:
设计思路: 推荐将所有需要热更的脚本都放到一个程序集中。这样方便会减少后面游戏热更限制。
创建热更程序集
实现步骤:
将热更程序集添加到热更新Assembly Defintions
中:
配置PlayerSettings:
首先安装HybridCLR,点击HybridCLR/Installer
弹出面板,再次点击”Install“,等待安装完成即可。
然后HybridCLR/Generate/ALL
,此步骤会进行:
将需要进行补充的dll添加到Steamingassets
,并在HybridCLR Settings/补充元数据AOT dlls
将文件名填进去。
需要补充的dll文件生成在,dll编译根目录:HybridCLRData/HotUpdateDlls
:
设置后再代码中补充元数据部分的逻辑,就可以顺利通过了:
同样将需要补充元数据的dll名称放到AOTMetaAssemblyFiles
注意:当读取StreamingAssets
文件夹下资源时,Android平台是和其他平台不一致的,需要单独处理,处理方法在完整代码中注释里面写了。
【逻辑思路简介,详细看后面代码讲解】
在资源更新之后补充元数据,然后读取热更程序集,启动游戏:
这个桥接,可以是从热更加载场景跳转到游戏场景或者加载游戏主预制体都可以。(反正是要启动热更程序集中的代码执行流程)
点击HybridCLR/CompileDll/ActiveBuildTarget
,编译目标平台热更程序集代码:
按照需求创建资源组,并将HybridCLR打包的程序集当做资源包托管的AA:
设置远程资源包下载地址:
将需要热更的资源包设置为远程资源包:
不支持热更
要做的事:检测热更,资源下载,加载热更程序集,启动热更代码(桥接)
启动代码挂载如下:
内容如下:
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using HybridCLR;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.AddressableAssets.ResourceLocators;
using UnityEngine.ResourceManagement.AsyncOperations;
using UnityEngine.UI;
/// <summary>
/// Loading页检测更新并下载资源
/// </summary>
public class AADownloadManager : MonoBehaviour
{
/// <summary>
/// 显示下载状态和进度
/// </summary>
public Text updateText;
public Image progressImage;
public Button retryButton;
private AsyncOperationHandle downloadDependencies;
// 当前下载文件索引
private int downLoadIndex = 0;
// 下载完成文件个数
private int downLoadCompleteCount = 0;
// 下载每组资源大小
List<long> downLoadSizeList;
/// <summary>
/// 下载多个文件列表
/// </summary>
List<string> downLoadKeyList;
List<IResourceLocator> resourceLocators;
// 总大小
long totalSize = 0;
// 下载进度
float curProgressSize = 0;
// 下载到那个资源
int progressIndex = 0;
// 下载资源总大小
private long downLoadTotalSize = 0;
// 当前下载大小
private float curDownLoadSize = 0;
void Start()
{
Screen.sleepTimeout = SleepTimeout.NeverSleep;
Application.targetFrameRate = 60;
downLoadIndex = 0;
retryButton.gameObject.SetActive(false);
InitAddressable();
}
/// <summary>
/// 初始化 --> 加载远端的配置文件
/// </summary>
private async void InitAddressable()
{
ShowHintText(0, "正在初始化配置...");
var initAddress = Addressables.InitializeAsync(false);
await initAddress.Task;
if (initAddress.Status == AsyncOperationStatus.Failed)
{
Debug.LogError("初始化失败");
ShowHintText(0, "初始化失败");
StartGame();
return;
}
CheckUpdateAsset();
Addressables.Release(initAddress);
}
/// <summary>
/// 检查是否有更新
/// </summary>
private async void CheckUpdateAsset()
{
ShowHintText(0, "正在检测更新配置...");
retryButton.gameObject.SetActive(false);
var checkCatLogUpdate = Addressables.CheckForCatalogUpdates(false);
await checkCatLogUpdate.Task;
if (checkCatLogUpdate.Status != AsyncOperationStatus.Succeeded)
{
Debug.LogError("检测更新失败");
ShowHintText(0, "检测更新失败");
// 展示重试按钮
retryButton.gameObject.SetActive(true);
retryButton.onClick.RemoveAllListeners();
retryButton.onClick.AddListener(CheckUpdateAsset);
}
downLoadKeyList = checkCatLogUpdate.Result;
if (downLoadKeyList.Count <= 0)
{
Debug.Log("无可更新内容,直接进入游戏...");
ShowHintText(1, "无可更新内容");
StartGame();
return;
}
else
{
Debug.Log($"有{downLoadKeyList.Count}个资源需要更新");
CheckUpdateAssetSize();
}
Addressables.Release(checkCatLogUpdate);
}
private async void CheckUpdateAssetSize()
{
ShowHintText(0, "正在校验更新资源大小...");
retryButton.gameObject.SetActive(false);
// true:自动清除缓存 ,更新资源列表,是否自动释放
var updateCatLog = Addressables.UpdateCatalogs(true, downLoadKeyList, false);
await updateCatLog.Task;
if (updateCatLog.Status != AsyncOperationStatus.Succeeded)
{
Debug.LogError("更新资源列表失败");
ShowHintText(0, "更新资源列表失败");
// 展示重试按钮
retryButton.gameObject.SetActive(true);
retryButton.onClick.RemoveAllListeners();
retryButton.onClick.AddListener(CheckUpdateAssetSize);
return;
}
resourceLocators = updateCatLog.Result;
Addressables.Release(updateCatLog);
AsyncOperationHandle<long> operationHandle = default;
foreach (var item in resourceLocators)
{
operationHandle = Addressables.GetDownloadSizeAsync(item.Keys);
await operationHandle.Task;
downLoadSizeList.Add(operationHandle.Result);
totalSize += operationHandle.Result;
}
Debug.Log($"获取到的下载大小:{totalSize / 1048579f} M");
Addressables.Release(operationHandle);
if (totalSize <= 0)
{
Debug.Log("无可更新内容");
ShowHintText(1, "无可更新内容");
StartGame();
return;
}
Debug.Log($"有{downLoadKeyList.Count}个资源需要更新");
ShowHintText(0, $"有{downLoadKeyList.Count}个资源需要更新");
progressIndex = 0;
DownloadAsset();
}
private async void DownloadAsset()
{
ShowHintText(0, "正在更新资源...");
retryButton.gameObject.SetActive(false);
for (int i = progressIndex; i < resourceLocators.Count; i++)
{
var item = resourceLocators[i];
AsyncOperationHandle asyncOperationHandle = Addressables.DownloadDependenciesAsync(item.Keys);
//await asyncOperationHandle.Task;
while (asyncOperationHandle.IsDone)
{
if (asyncOperationHandle.Status == AsyncOperationStatus.Succeeded)
{
Debug.Log($"下载成功:{item}...");
}
else
{
Debug.LogError($"下载失败:{item},显示重试按钮,下载到第{progressIndex}个资源...");
progressIndex = i;
retryButton.gameObject.SetActive(true);
retryButton.onClick.RemoveAllListeners();
retryButton.onClick.AddListener(DownloadAsset);
}
float progress = asyncOperationHandle.PercentComplete;
curProgressSize += downLoadSizeList[i] * progress;
Debug.Log($"{item} ;progress:{progress}; downLoadSizeList:{downLoadSizeList[i]}...");
ShowHintText(curProgressSize / (totalSize * 1.0f), "正在更新资源...");
await Task.Yield();
}
}
Debug.Log("下载完成");
StartGame();
}
private void Update()
{
if (Input.GetKeyDown(KeyCode.A))
{
Debug.Log("清理缓存...");
// 清理缓存
Caching.ClearCache();
}
}
void ShowHintText(float progress, string text)
{
if (updateText != null)
{
updateText.text = text;
}
if (progressImage != null)
{
progressImage.fillAmount = progress;
}
}
#region CLR -- 进入游戏
private Assembly _hotUpdateAss;
async void StartGame()
{
LoadMetadataForAOTAssemblies();
#if UNITY_EDITOR
string hotUpdateName = "HotUpdate";
_hotUpdateAss = System.AppDomain.CurrentDomain.GetAssemblies().First(a => a.GetName().Name == hotUpdateName);
#else
//_hotUpdateAss = Assembly.Load(ReadBytesFromStreamingAssets(hotUpdateName));
string hotUpdateName = "HotUpdate.dll.bytes";
Debug.Log($"异步加载资源Key路径:{hotUpdateName}");
AsyncOperationHandle<TextAsset> handle = Addressables.LoadAssetAsync<TextAsset>(hotUpdateName);
await handle.Task;
Debug.Log($"异步加载资源Key状态:{handle.Status}");
if (handle.Status != AsyncOperationStatus.Succeeded)
{
Debug.LogError($"异步加载资源失败,资源Key路径:{hotUpdateName},\n异常 {handle.OperationException}");
//throw new Exception($"异步加载资源失败,资源Key路径:{hotUpdateName},\n异常 {handle.OperationException}");
}
Debug.Log($"异步加载资源大小:{handle.Result.dataSize}");
_hotUpdateAss = Assembly.Load(handle.Result.bytes);
#endif
await Task.Yield();
Type entryType = _hotUpdateAss.GetType("GameEntry");
entryType.GetMethod("Start").Invoke(null, null);
}
private void OnHotUpdateLoaded(AsyncOperationHandle<TextAsset> handle)
{
Debug.LogError("handle.Status: " + handle.Status);
if (handle.Status == AsyncOperationStatus.Succeeded)
{
TextAsset hotUpdateAsset = handle.Result;
byte[] assemblyBytes = hotUpdateAsset.bytes;
// 将程序集读取到内存中
Assembly hotUpdateAssembly = Assembly.Load(assemblyBytes);
Type entryType = hotUpdateAssembly.GetType("GameEntry");
entryType.GetMethod("Start").Invoke(null, null);
}
else
{
Debug.LogError("Failed to load HotUpdate.dll.bytes: " + handle.OperationException);
}
}
private static List<string> AOTMetaAssemblyFiles { get; } = new List<string>()
{
"mscorlib.dll.bytes",
"System.dll.bytes",
"System.Core.dll.bytes",
"Unity.ResourceManager.dll.bytes",
};
/// <summary>
/// 为aot assembly加载原始metadata, 这个代码放aot或者热更新都行。
/// 一旦加载后,如果AOT泛型函数对应native实现不存在,则自动替换为解释模式执行
/// </summary>
private void LoadMetadataForAOTAssemblies()
{
// 注意,补充元数据是给AOT dll补充元数据,而不是给热更新dll补充元数据。
// 热更新dll不缺元数据,不需要补充,如果调用LoadMetadataForAOTAssembly会返回错误
HomologousImageMode mode = HomologousImageMode.SuperSet;
foreach (var aotDllName in AOTMetaAssemblyFiles)
{
byte[] dllBytes = ReadBytesFromStreamingAssets(aotDllName);
// 加载assembly对应的dll,会自动为它hook。一旦aot泛型函数的native函数不存在,用解释器版本代码
LoadImageErrorCode err = RuntimeApi.LoadMetadataForAOTAssembly(dllBytes, mode);
Debug.Log($"LoadMetadataForAOTAssembly:{aotDllName}. mode:{mode} ret:{err}");
}
}
private byte[] ReadBytesFromStreamingAssets(string abName)
{
Debug.Log($"ReadAllBytes name: {abName}");
#if UNITY_ANDROID
AndroidJavaClass jc = new AndroidJavaClass("com.unity3d.player.PrivacyActivity");
//jc.CallStatic<byte[]>("getFromAssets", name);
AndroidJavaObject jo = jc.GetStatic<AndroidJavaObject>("currentActivity");
byte[] oldBytes = jo.Call<byte[]>("getFromAssets", abName);
return oldBytes;
#region 在Android工程中添加读取方法
// import java.io.File;
// import java.io.FileInputStream;
// import java.io.FileNotFoundException;
// import java.io.FileOutputStream;
// import java.io.IOException;
// import java.io.InputStream;
// public byte[] getFromAssets(String fileName) {
// Log.e("****", "getFromAssets:" + fileName);
// try {
// //得到资源中的Raw数据流
// InputStream in = getResources().getAssets().open(fileName);
// //得到数据的大小
// int length = in.available();
//
// byte[] buffer = new byte[length];
// //读取数据
// in.read(buffer);
// //依test.txt的编码类型选择合适的编码,如果不调整会乱码
// //res = EncodingUtils.getString(buffer, "BIG5");
// //关闭
// in.close();
//
// return buffer;
// } catch (Exception e) {
// e.printStackTrace();
// return null;
// }
// }
#endregion
#else
byte[] oldBytes = File.ReadAllBytes(Application.streamingAssetsPath + "/" + abName);
return oldBytes;
#endif
}
#endregion
}
支持热更
要做的事入:桥接热更进入,游戏逻辑
桥接热更入口:切换到新场景后,自动启动游戏逻辑
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.SceneManagement;
/// <summary>
/// 游戏入口 -- 热更桥接
/// </summary>
public static class GameEntry
{
public static void Start()
{
Debug.Log("[GameEntry::Start] 热更完成进入游戏场景");
//SceneManager.LoadScene("Scenes/MainScene");
Addressables.LoadSceneAsync("MainScene");
}
}
拓展编辑器脚本,方便后续打包逻辑:
注意:修改为自己的资源目标路径和远程资源路径
执行后可导出apk或者进行xCode打包
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using HybridCLR.Editor;
using UnityEditor;
using UnityEditor.AddressableAssets;
using UnityEditor.AddressableAssets.Build;
using UnityEditor.AddressableAssets.Settings;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEditor.AddressableAssets.Settings;
using UnityEditor.AddressableAssets.Settings.GroupSchemas;
namespace Editor
{
public class BuidlEditorTools
{
[MenuItem("Build/1. 编译目标平台热更脚本", false, 301)]
public static void CompileDllActiveBuildTargetCopy()
{
HybridCLR.Editor.Commands.CompileDllCommand.CompileDll(EditorUserBuildSettings.activeBuildTarget);
Debug.Log($"Compile Dll Active Build Target Copy Finished!");
CopyDllToAssets();
}
// 复制热更的DLL到资源目录,以备用AB包导出
//[MenuItem("Build/2. 复制热更的DLL到资源目录", false, 301)]
public static void CopyDllToAssets()
{
BuildTarget target = EditorUserBuildSettings.activeBuildTarget;
string buildDir = SettingsUtil.GetHotUpdateDllsOutputDirByTarget(target);
// 项目配置的热更dll
for (int i = 0; i < HybridCLRSettings.Instance.hotUpdateAssemblyDefinitions.Length; i++)
{
string fileName = HybridCLRSettings.Instance.hotUpdateAssemblyDefinitions[i].name + ".dll";
string sourcePath = Directory.GetFiles(buildDir).ToList().Find(hotPath => hotPath.Contains(fileName));
if (string.IsNullOrEmpty(sourcePath))
{
Debug.Log($"热更程序集不存在: {buildDir} / {fileName}");
Debug.LogError($"热更程序集不存在: {buildDir} / {fileName}");
continue;
}
// 将程序集添加后缀 .bytes 并复制到AB包路径下
string newFileName = fileName + ".bytes";
//todo... 你的工程:目标目录路径 //Assets/Res/AOTAssembly/HotUpdate.dll.bytes
string targetDirectory = Application.dataPath + "/Res/AOTAssembly";
Debug.Log($"目标目录路径:{targetDirectory} ");
// 检查源文件是否存在
if (File.Exists(sourcePath))
{
// 构建目标文件的完整路径
string destinationPath = Path.Combine(targetDirectory, newFileName);
// 检查目标目录是否存在,如果不存在则创建
if (!Directory.Exists(targetDirectory))
{
Directory.CreateDirectory(targetDirectory);
}
// 如果目标文件已经存在,则删除
if (File.Exists(destinationPath))
{
File.Delete(destinationPath);
}
// 将源文件复制到目标目录下,并修改名称
File.Copy(sourcePath, destinationPath);
// 刷新资源,使其在 Unity 编辑器中可见
AssetDatabase.Refresh();
Debug.Log("File copied successfully!");
}
else
{
Debug.LogError("Source file does not exist!");
}
}
Debug.Log("复制热更的DLL到资源目录 完成!!!");
}
[MenuItem("Build/2. 打包AA包资源", false, 302)]
public static void BuildPackageAB()
{
// AddressableAssetSettings.BuildPlayerContent();
Debug.LogError("去用Addressable Group Build 进行打包AB或者热更AB...");
}
[MenuItem("Build/3. 上传资源包到OSS", false, 303)]
public static async void UpLoadABOSS()
{
#region 注释:通过指定的名称获取组别
// AddressableAssetSettings addressableSettings = AssetDatabase.LoadAssetAtPath<AddressableAssetSettings>("Assets/AddressableAssetsData/AddressableAssetSettings.asset");
//
// if (addressableSettings == null)
// {
// Debug.LogError("Addressable Asset Settings not found.");
// return;
// }
//
// List<AddressableAssetGroup> groups = addressableSettings.groups;
// foreach (AddressableAssetGroup group in groups)
// {
// string groupName = group.Name;
// string profileName = addressableSettings.activeProfileId;
// BundledAssetGroupSchema schema = group.GetSchema<BundledAssetGroupSchema>();
//
// if (schema != null)
// {
// // Access the configuration data from the profile
// Debug.Log($"Group: {groupName}, Profile: {profileName}");
// Debug.Log($"Remote Build Path: {schema.BuildPath.GetValue(addressableSettings) as string}");
// Debug.Log($"Remote Build Path: {schema.LoadPath.GetValue(addressableSettings) as string}");
//
// // Access other properties as needed
// }
// }
// // 获取Profile设置
// AddressableAssetProfileSettings profileSettings = addressableSettings.profileSettings;
//
// if (profileSettings == null)
// {
// Debug.LogError("Addressable Profile Settings not found.");
// return;
// }
//
// AddressableAssetGroup remoteGroup = addressableSettings.FindGroup("Prefabs");
// BundledAssetGroupSchema bundledAssetGroupSchema = remoteGroup.GetSchema<BundledAssetGroupSchema>();
//
// if (bundledAssetGroupSchema != null)
// {
// string remoteBuildPath = bundledAssetGroupSchema.BuildPath.GetValue(addressableSettings) as string;
// string remoteLoadPath = bundledAssetGroupSchema.LoadPath.GetValue(addressableSettings) as string;
// Debug.Log($"Remote Build Path 111 : {remoteBuildPath}");
// Debug.Log($"Remote Build Path 111 : {remoteLoadPath}");
// }
#endregion
#region 注释:微信小游戏资源地址
// var config = UnityUtil.GetEditorConf();
// var uploadResCDN = config.ProjectConf.CDN;
// if (string.IsNullOrEmpty(config.ProjectConf.DST) || string.IsNullOrEmpty(config.ProjectConf.CDN))
// {
// Debug.LogError("请先在设置项目CDN地址");
// return;
// }
// var fullResPath = config.ProjectConf.DST + "/webgl";
#endregion
// todo... 修改:打包的资源路径
var fullResPath = Application.dataPath.Replace("Assets","")
+ "ServerData/" + EditorUserBuildSettings.activeBuildTarget;
// todo... 修改:上传CDN的资源路径
var uploadResCDN = "wx/Test/"+ EditorUserBuildSettings.activeBuildTarget;
Debug.Log($"开始上传 {BuildTarget.WebGL.ToString()} 平台的资源,上传目录:{fullResPath}");
await UploadResToOSS.StartUploadOssClient(fullResPath, uploadResCDN);
Debug.Log($"上传OSS:{EditorUserBuildSettings.activeBuildTarget.ToString()}平台的完整资源上传成功");
}
}
}
工程目录作用:
源码文件在文章开头链接 或 点击下方卡片,回复“华佗”或“热更”获取资源包