Unity-Xlua热更和AssetBundle详解

从今天开始我们深入Unity的组件,最近的一系列事让我明白学习技术不能只有广度,如走马观花,虽然你可能确实学到了皮毛,但是没有足够深厚的理解,是无法融入自己的东西的,那么你就只是在用别人的工具而不是自己的工具,对于追求技术的人来说,这是无法接受的。

不多废话,我们来到第一个部分

AssetBundle打包加载卸载

在开始具体实现AB包打包的框架之前,我们先要明确一些关于AB包的概念:

AB包的全称是AssetBundle,Asset就是资产而Bundle就是包。如何理解资产呢?在Unity的场景中,所有可以被具象化的资源,都可以成为Asset。换句话说,一张图片,一个音频,都是一个资产,试想这样的资产在一个游戏中有多少,如果我们一个一个地进行保存,光是读取资源并保存这个步骤就得重复成千上万次,所以我们需要AB包:把所有Asset按照功能逻辑分开打包,然后我们以包为单位进行保存即可。

简而言之,AssetBundle就是为了让游戏项目中大量Asset适应实际游戏运行时而被压缩后的一种二进制文件。

关于打包

打包的时候涉及到一些资源的分配问题,最直观地说,如果一个包里东西太多,那么后续我们进行更新时就开销大,且包内东西太多操作不灵活,而如果一个包内东西太少又导致打包次数过多。这个时候就会有几个维度来帮助我们考虑打包的策略了:

资源依赖

首先是资源的依赖:我们的资源往往是嵌套存在的,比如UI的资源里总是有图片,假如我们的A包需要这张图片,B包也需要这张图片,那么我们在A包和B包都分别打包了一次这张图片,这就是一种资源的浪费。这个时候我们可以选择先单独把这个图片进行打包,然后在这个包的基础上把A包的其他内容和B包的其他内容分别打包------但是要基于我们的图片的包,这就是资源依赖的思路:就像把一个数组构建出树的结构一样,我们根据资源的使用情况搭建起包与包之间的关系网,这样可以避免重复打包的情况。

但是这样做就一定好吗?我们写代码的时候也知道,嵌套的代码的好处是可以避免重复,坏处就是我们后期去修改的时候非常难以处理。这也是资源依赖的问题:试想一下,我们可能随便修改了一个作为其他包依赖的包,然后这个时候对这个包有依赖的包是否也要进行更新呢?又比如说我们删除了这个包里的一些资源,那么对这个包的资源有依赖的其他包是否还能正常运作呢?这就是非常麻烦的事。

压缩方式

我们说过了,对于AB包来说压缩是必不可少的一部分,目前主流的压缩方式有两种:

是的,就是我们的LZMA压缩算法和LZ4算法,这两个的区别就在于LZMA算法的压缩率更高(压缩后的包体更小),但是相对的解压速度就更慢且开销更大;不过最大的差异在于:我们的LZMA压缩算法是一次性全部解压的,而LZ4可以基于块来进行解压,那么显然在资源繁多的游戏开发过程中我们选择LZ4压缩算法才是更合理的选择。

Resource

事实上,除了AB包,Unity有自带的保存资源的方法:Resource组件系统,相比起AB包,Resource内部会生成维护一个红黑树来保存各个资源的索引方便我们去查找对应的资源,这个红黑树的内存极大且一旦启动游戏就会一直流在游戏资源的内部,极大地拖慢游戏运行速度。

关于卸载

AB包的卸载有两种方法:

AssetBundle.Unload(bool),其中bool变量为true代表我们会把AB包的内存镜像文件以及所有基于这个镜像文件的实例全部删除,而false我们则只会去卸载这个AB包的内存镜像文件而保留已经被实例化的对象。这里就首先要介绍AB包的加载过程和使用过程内存的变化了:

当我们从服务器上下载AB包之后,整个包会首先存储在硬盘(持久化存储路径)上,当我们的游戏想要读取AB包里的具体资源,Unity会首先去读取AB包的.manifest文件,这个文件记载了AB包中具体的资源的存储分布情况,我们按需去硬盘中读取解压AB包文件,并在内存中生成AB包的镜像文件,之后我们的操作都是基于这个镜像文件来实现的。

综上所述,对于什么时候AssetBundle.Unload(false)和AssetBundle.Unload(true)我们的心里就有数了。因为为true时会把所有实例化的对象也删除,我们当然一般都是达到场景切换的程度才会选择true,否则如果只是一个场景内的资源变换我们一般选择false。需要注意的是一旦选择为true,我们整个场景就不能存在对被删除的AB包资源的引用了,否则一定会报错。

在这里补充一点的是:针对AB包中的资源,我们在具体使用时,遵循"值类型复制,引用类型共享"的思路,当调用 Instantiate(prefab) 时,Unity 会执行 ​浅拷贝(Shallow Copy)

具体代码实例

现在让我们来总结一下AB包从打包到加载到卸载的全过程吧:

首先我们要根据一定的规则对已有游戏的所有资源进行打包,打好包之后我们选择某种压缩方法得到压缩包后把压缩包上传到服务器。这中间其实还涉及热更的一个检查版本信息的过程,涉及到MD5值的比较,我们在热更方面展开介绍。当后续我们从服务器下载包时,我们会先把包放在主机的持久化存储路径下,然后在程序中根据.manifest文件来按需加载AB包,在卸载时根据引用情况来决定是只删除内存镜像文件还是把内存镜像文件和已实例化的所有实例删除。

接下来让我们来分析具体的代码,了解如何实现AB包打包。

Builder

cs 复制代码
namespace AssetBundleFramework
{
    public static class Builder
    {
        ...
    }
}

我们定义命名空间为AB包框架,声明静态类builder不可实例化,实现全局共享的工具类。

cs 复制代码
public static readonly Vector2 collectRuleFileProgress = new Vector2(0, 0.2f);
private static readonly Vector2 ms_GetDependencyProgress = new Vector2(0.2f, 0.4f);
private static readonly Vector2 ms_CollectBundleInfoProgress = new Vector2(0.4f, 0.5f);
private static readonly Vector2 ms_GenerateBuildInfoProgress = new Vector2(0.5f, 0.6f);
private static readonly Vector2 ms_BuildBundleProgress = new Vector2(0.6f, 0.7f);
private static readonly Vector2 ms_ClearBundleProgress = new Vector2(0.7f, 0.9f);
private static readonly Vector2 ms_BuildManifestProgress = new Vector2(0.9f, 1f);

private static readonly Profiler ms_BuildProfiler = new Profiler(nameof(Builder));
private static readonly Profiler ms_LoadBuildSettingProfiler = ms_BuildProfiler.CreateChild(nameof(LoadSetting));
private static readonly Profiler ms_SwitchPlatformProfiler = ms_BuildProfiler.CreateChild(nameof(SwitchPlatform));
private static readonly Profiler ms_CollectProfiler = ms_BuildProfiler.CreateChild(nameof(Collect));
private static readonly Profiler ms_CollectBuildSettingFileProfiler = ms_CollectProfiler.CreateChild("CollectBuildSettingFile");
private static readonly Profiler ms_CollectDependencyProfiler = ms_CollectProfiler.CreateChild(nameof(CollectDependency));
private static readonly Profiler ms_CollectBundleProfiler = ms_CollectProfiler.CreateChild(nameof(CollectBundle));
private static readonly Profiler ms_GenerateManifestProfiler = ms_CollectProfiler.CreateChild(nameof(GenerateManifest));
private static readonly Profiler ms_BuildBundleProfiler = ms_BuildProfiler.CreateChild(nameof(BuildBundle));
private static readonly Profiler ms_ClearBundleProfiler = ms_BuildProfiler.CreateChild(nameof(ClearAssetBundle));
private static readonly Profiler ms_BuildManifestBundleProfiler = ms_BuildProfiler.CreateChild(nameof(BuildManifest));

密密麻麻的变量定义部分,不全部介绍了,前面的一系列Vector2变量表示打包过程中的进度条,后续的profiler则是自定义的一个类型,主要目的就是展示整个打包的过程。

cs 复制代码
#if UNITY_IOS
        private const string PLATFORM = "iOS";
#elif UNITY_ANDROID
        private const string PLATFORM = "Android";
#else
        private const string PLATFORM = "Windows";

根据平台生成平台名。

cs 复制代码
//bundle后缀
public const string BUNDLE_SUFFIX = ".ab";
public const string BUNDLE_MANIFEST_SUFFIX = ".manifest";
//bundle描述文件名称
public const string MANIFEST = "manifest";

public static readonly ParallelOptions ParallelOptions = new ParallelOptions()
{
    MaxDegreeOfParallelism = Environment.ProcessorCount * 2
};

//bundle打包Options
public readonly static BuildAssetBundleOptions BuildAssetBundleOptions = BuildAssetBundleOptions.ChunkBasedCompression | BuildAssetBundleOptions.DeterministicAssetBundle | BuildAssetBundleOptions.StrictMode | BuildAssetBundleOptions.DisableLoadAssetByFileName | BuildAssetBundleOptions.DisableLoadAssetByFileNameWithExtension;

这些代码具体来说就是在构建AB包文件的一些格式。

cs 复制代码
/// <summary>
/// 打包设置
/// </summary>
public static BuildSetting buildSetting { get; private set; }

#region Path

/// <summary>
/// 打包配置
/// </summary>
public readonly static string BuildSettingPath = Path.GetFullPath("BuildSetting.xml").Replace("\\", "/");

/// <summary>
/// 临时目录,临时生成的文件都统一放在该目录
/// </summary>
public readonly static string TempPath = Path.GetFullPath(Path.Combine(Application.dataPath, "Temp")).Replace("\\", "/");

/// <summary>
/// 临时目录,临时文件的ab包都放在该文件夹,打包完成后会移除
/// </summary>
public readonly static string TempBuildPath = Path.GetFullPath(Path.Combine(Application.dataPath, "../TempBuild")).Replace("\\", "/");

/// <summary>
/// 资源描述__文本
/// </summary>
public readonly static string ResourcePath_Text = $"{TempPath}/Resource.txt";

/// <summary>
/// 资源描述__二进制
/// </summary>
public static string ResourcePath_Binary = $"{TempPath}/Resource.bytes";

/// <summary>
/// Bundle描述__文本
/// </summary>
public readonly static string BundlePath_Text = $"{TempPath}/Bundle.txt";

/// <summary>
/// Bundle描述__二进制
/// </summary>
public readonly static string BundlePath_Binary = $"{TempPath}/Bundle.bytes";

/// <summary>
/// 资源依赖描述__文本
/// </summary>
public readonly static string DependencyPath_Text = $"{TempPath}/Dependency.txt";

/// <summary>
/// 资源依赖描述__文本
/// </summary>
public readonly static string DependencyPath_Binary = $"{TempPath}/Dependency.bytes";

/// <summary>
/// 打包目录
/// </summary>
public static string buildPath { get; set; }

注释写得都很清楚,AB包在打包流程中需要的一系列设置,主要包含一系列存储的文件路径。

cs 复制代码
        #region Build MenuItem

        [MenuItem("Tools/ResBuild/Windows")]
        public static void BuildWindows()
        {
            Build();
        }

        [MenuItem("Tools/ResBuild/Android")]
        public static void BuildAndroid()
        {
            Build();
        }

        [MenuItem("Tools/ResBuild/iOS")]
        public static void BuildIos()
        {
            Build();
        }

        #endregion

这个部分的代码就是让你可以在菜单直接调用Build函数启动打包的过程。

cs 复制代码
/// <summary>
/// 加载打包配置文件
/// </summary>
/// <param name="settingPath">打包配置路径</param>
private static BuildSetting LoadSetting(string settingPath)
{
    buildSetting = XmlUtility.Read<BuildSetting>(settingPath);
    if (buildSetting == null)
    {
        throw new Exception($"Load buildSetting failed,SettingPath:{settingPath}.");
    }
    (buildSetting as ISupportInitialize)?.EndInit();

    buildPath = Path.GetFullPath(buildSetting.buildRoot).Replace("\\", "/");
    if (buildPath.Length > 0 && buildPath[buildPath.Length - 1] != '/')
    {
        buildPath += "/";
    }
    buildPath += $"{PLATFORM}/";

    return buildSetting;
}

private static void Build()
{
    ms_BuildProfiler.Start();

    ms_SwitchPlatformProfiler.Start();
    SwitchPlatform();
    ms_SwitchPlatformProfiler.Stop();

    ms_LoadBuildSettingProfiler.Start();
    buildSetting = LoadSetting(BuildSettingPath);
    ms_LoadBuildSettingProfiler.Stop();

    //搜集bundle信息
    ms_CollectProfiler.Start();
    Dictionary<string, List<string>> bundleDic = Collect();
    ms_CollectProfiler.Stop();

    //打包assetbundle
    ms_BuildBundleProfiler.Start();
    BuildBundle(bundleDic);
    ms_BuildBundleProfiler.Stop();

    //清空多余文件
    ms_ClearBundleProfiler.Start();
    ClearAssetBundle(buildPath, bundleDic);
    ms_ClearBundleProfiler.Stop();

    //把描述文件打包bundle
    ms_BuildManifestBundleProfiler.Start();
    BuildManifest();
    ms_BuildManifestBundleProfiler.Stop();

    EditorUtility.ClearProgressBar();

    ms_BuildProfiler.Stop();

    Debug.Log($"打包完成{ms_BuildProfiler}");
}

这段代码里包含两个函数:第一个是加载我们的打包设置,我们会去检查是否有打包设置,没有的话就返回异常,有的话就进行加载;第二个则是开始执行打包流程,这个Build函数主要就是负责根据流程调用各个函数的函数。

cs 复制代码
/// <summary>
/// 搜集打包bundle的信息
/// </summary>
/// <returns></returns>

private static Dictionary<string, List<string>> Collect()
{
    //获取所有在打包设置的文件列表
    ms_CollectBuildSettingFileProfiler.Start();
    HashSet<string> files = buildSetting.Collect();
    ms_CollectBuildSettingFileProfiler.Stop();

    //搜集所有文件的依赖关系
    ms_CollectDependencyProfiler.Start();
    Dictionary<string, List<string>> dependencyDic = CollectDependency(files);
    ms_CollectDependencyProfiler.Stop();

    //标记所有资源的信息
    Dictionary<string, EResourceType> assetDic = new Dictionary<string, EResourceType>();

    //被打包配置分析到的直接设置为Direct
    foreach (string url in files)
    {
        assetDic.Add(url, EResourceType.Direct);
    }

    //依赖的资源标记为Dependency,已经存在的说明是Direct的资源
    foreach (string url in dependencyDic.Keys)
    {
        if (!assetDic.ContainsKey(url))
        {
            assetDic.Add(url, EResourceType.Dependency);
        }
    }

    //该字典保存bundle对应的资源集合
    ms_CollectBundleProfiler.Start();
    Dictionary<string, List<string>> bundleDic = CollectBundle(buildSetting, assetDic, dependencyDic);
    ms_CollectBundleProfiler.Stop();

    //生成Manifest文件
    ms_GenerateManifestProfiler.Start();
    GenerateManifest(assetDic, bundleDic, dependencyDic);
    ms_GenerateManifestProfiler.Stop();

    return bundleDic;
}

这个函数负责获取所有AB包的信息。

cs 复制代码
        /// <summary>
        /// 收集指定文件集合所有的依赖信息
        /// </summary>
        /// <param name="files">文件集合</param>
        /// <returns>依赖信息</returns>
        private static Dictionary<string, List<string>> CollectDependency(ICollection<string> files)
        {
            float min = ms_GetDependencyProgress.x;
            float max = ms_GetDependencyProgress.y;

            Dictionary<string, List<string>> dependencyDic = new Dictionary<string, List<string>>();

            //声明fileList后,就不需要递归了
            List<string> fileList = new List<string>(files);

            for (int i = 0; i < fileList.Count; i++)
            {
                string assetUrl = fileList[i];

                if (dependencyDic.ContainsKey(assetUrl))
                    continue;

                if (i % 10 == 0)
                {
                    //只能大概模拟进度
                    float progress = min + (max - min) * ((float)i / (files.Count * 3));
                    EditorUtility.DisplayProgressBar($"{nameof(CollectDependency)}", "搜集依赖信息", progress);
                }

                string[] dependencies = AssetDatabase.GetDependencies(assetUrl, false);
                List<string> dependencyList = new List<string>(dependencies.Length);

                //过滤掉不符合要求的asset
                for (int ii = 0; ii < dependencies.Length; ii++)
                {
                    string tempAssetUrl = dependencies[ii];
                    string extension = Path.GetExtension(tempAssetUrl).ToLower();
                    if (string.IsNullOrEmpty(extension) || extension == ".cs" || extension == ".dll")
                        continue;
                    dependencyList.Add(tempAssetUrl);
                    if (!fileList.Contains(tempAssetUrl))
                        fileList.Add(tempAssetUrl);
                }

                dependencyDic.Add(assetUrl, dependencyList);
            }

            return dependencyDic;
        }

这个函数则专门负责针对特定的文件去搜索依赖项并返回依赖文件路径。

BundleManager

然后我们来看我们的BundleManager类,这个类专门提供AB包的 同步/异步加载、引用计数、依赖管理、延迟卸载​功能。

成员变量如下:

cpp 复制代码
public readonly static BundleManager instance = new BundleManager();//单例模式

/// <summary>
/// 加载bundle开始的偏移
/// </summary>
internal ulong offset { get; private set; }//只可读

/// <summary>
/// 获取资源真实路径回调
/// </summary>
private Func<string, string> m_GetFileCallback;//Func是委托,接受一个string参数返回一个string参数,允许外部的函数
                                               //来具体实现寻找资源路径功能而BundleManager类内部不关注具体实现

/// <summary>
/// bundle依赖管理信息
/// </summary>
private AssetBundleManifest m_AssetBundleManifest;//AssetBundleManifest是Unity自带的类

/// <summary>
/// 所有已加载的bundle
/// </summary>
private Dictionary<string, ABundle> m_BundleDic = new Dictionary<string, ABundle>();//存储AB包名称和AB包的字
                                                                                    //典

//异步创建的bundle加载时候需要先保存到该列表
private List<ABundleAsync> m_AsyncList = new List<ABundleAsync>();//异步加载的表

/// <summary>
/// 需要释放的bundle
/// </summary>
private LinkedList<ABundle> m_NeedUnloadList = new LinkedList<ABundle>();

在这众多变量之中,除了AssetBundleManifest类是Unity自带的类以外,其他的类要么是C#自带的数据结构要么是我们自己定义的。其中我们可以看到有两个比较显眼的我们自定义的类型:ABundle与ABundleAsync。我们后续再来详细地介绍这两个类,我们先把BundleManager类的方法也全部介绍一遍。

cpp 复制代码
/// <summary>
/// 初始化
/// </summary>
/// <param name="platform">平台</param>
/// <param name="getFileCallback">获取资源真实路径回调</param>
/// <param name="offset">加载bundle偏移</param>
internal void Initialize(string platform, Func<string, string> getFileCallback, ulong offset)
{
    m_GetFileCallback = getFileCallback;
    this.offset = offset;

    string assetBundleManifestFile = getFileCallback.Invoke(platform);//getFileCallback是一个委托实例

    AssetBundle manifestAssetBundle = AssetBundle.LoadFromFile(assetBundleManifestFile);//AssetBundle是封装
                                                                                        //好的类,LoadFromFile
                                                                                        //则是专门用于读取文件
                                                                                        //的方法
    Object[] objs = manifestAssetBundle.LoadAllAssets();//加载所有assets

    if (objs.Length == 0)
    {
        throw new Exception($"{nameof(BundleManager)}.{nameof(Initialize)}() AssetBundleManifest load fail.");
    }

    m_AssetBundleManifest = objs[0] as AssetBundleManifest;//安全地尝试将objs[0]转换成AssetBundleManifest类
}

我们首先来介绍关键字internal :这个关键字修饰的内容只能在这个程序集内被调用,有人要问了,什么是程序集呢?简单地说,我们都知道C#生成可执行文件需要预编译、编译、汇编和链接几个步骤,而经过编译后的中间语言加上元数据以及一些其他的信息就是我们的程序集。

初始化函数中,我们把传入参数中的委托和偏差给到Manager类,然后我们获取AB包的manifest文件名称并读取,这里有几个值得说的点:首先是我们的getFileCallback.Invoke(platform),我们根据平台名调用Invoke函数,调用这个函数的getFileCallback是一个**委托实例,**更准确地说,Func是一个带返回值的泛型委托:

cpp 复制代码
namespace System
{
    public delegate TResult Func<in T, out TResult>(T arg);
}

那么问题来了,Invoke在哪里呢?为什么可以直接调用呢?

简单地说,C#中的所有委托类型都是继承自**MulticastDelegate 类**,所有委托都隐式地包含图中的这三个函数。

至于AssetBundle,就是一个Unity自带的类,专门用来表示AB包的,具体使用方法可以查询手册。

最后的 m_AssetBundleManifest = objs[0] as AssetBundleManifest;中我们可以看到一个as关键字,这个关键字的作用就是安全地尝试转换,如果转换失败的话会返回NULL。

cpp 复制代码
/// <summary>
/// 获取bundle的绝对路径
/// </summary>
/// <param name="url"></param>//url是统一资源定位符的意思,在这里如果是本地文件则url是文件路径,否则是网址
/// <returns>bundle的绝对路径</returns>
internal string GetFileUrl(string url)
{
    if (m_GetFileCallback == null)
    {
        throw new Exception($"{nameof(BundleManager)}.{nameof(GetFileUrl)}() {nameof(m_GetFileCallback)} is null.");
    }

    //交到外部处理
    return m_GetFileCallback.Invoke(url);
}

获取文件的URL方法,内容非常的简单,如果我们的委托实例为空则抛出异常,反之我们执行委托,这里我有添加关于URL的注释。

cpp 复制代码
        /// <summary>
        /// 同步加载bundle
        /// </summary>
        /// <param name="url">asset路径</param>
        internal ABundle Load(string url)
        {
            return LoadInternal(url, false);
        }

        /// <summary>
        /// 异步加载bundle
        /// </summary>
        /// <param name="url">asset路径</param>
        internal ABundle LoadAsync(string url)
        {
            return LoadInternal(url, true);
        }

同步和异步加载AB包,执行的都是LoadInternal()函数,只是修改了某个参数。

cpp 复制代码
/// <summary>
/// 内部加载bundle
/// </summary>
/// <param name="url">asset路径</param>
/// <param name="async">是否异步</param>
/// <returns>bundle对象</returns>
private ABundle LoadInternal(string url, bool async)//加载AB包
{
    ABundle bundle;
    if (m_BundleDic.TryGetValue(url, out bundle))//AB包的字典,尝试根据键获得值,这里用out修饰
                                                 //bundle表示我们可以不初始化bundle但输出的bundle
                                                 //必须是有值的
    {
        if (bundle.reference == 0)//之前的引用为0,但此时我们要去加载这个包,所以将其从需要移除的
                                  //包列表中移除
        {
            m_NeedUnloadList.Remove(bundle);
        }

        //从缓存中取并引用+1
        bundle.AddReference();

        return bundle;
    }

    //创建ab
    if (async)//如果是异步加载
    {
        bundle = new BundleAsync();
        bundle.url = url;
        m_AsyncList.Add(bundle as ABundleAsync);//加入异步加载的AB包表
    }
    else
    {
        bundle = new Bundle();
        bundle.url = url;
    }

    m_BundleDic.Add(url, bundle);//字典加入该包,注意是url作为键

    //加载依赖
    string[] dependencies = m_AssetBundleManifest.GetDirectDependencies(url);//获取直接依赖,输入Url
    if (dependencies.Length > 0)
    {
        bundle.dependencies = new ABundle[dependencies.Length];
        for (int i = 0; i < dependencies.Length; i++)
        {
            string dependencyUrl = dependencies[i];
            ABundle dependencyBundle = LoadInternal(dependencyUrl, async);
            bundle.dependencies[i] = dependencyBundle;
        }
    }

    bundle.AddReference();

    bundle.Load();

    return bundle;
}

具体的作用其实我都写在注释里了,这里反而没有太多好说的,没有陌生的语法也没有陌生的概念,不过我们在这里介绍一下同步加载AB包和异步加载AB包的区别:

最大的区别就在于会不会阻塞主线程。

cpp 复制代码
/// <summary>
/// 卸载bundle
/// </summary>
/// <param name="bundle">要卸载的bundle</param>
internal void UnLoad(ABundle bundle)
{
    if (bundle == null)
        throw new ArgumentException($"{nameof(BundleManager)}.{nameof(UnLoad)}() bundle is null.");

    //引用-1
    bundle.ReduceReference();

    //引用为0,直接释放
    if (bundle.reference == 0)
    {
        WillUnload(bundle);
    }
}
/// <summary>
/// 即将要释放的资源
/// </summary>
/// <param name="resource"></param>
private void WillUnload(ABundle bundle)
{
    m_NeedUnloadList.AddLast(bundle);//添加到链表的尾节点
}

卸载函数,核心就是减少引用计数并且在引用计数为0时直接启动真正的释放函数。(卸载函数是单独地卸载某个AB包,释放函数则是检测当前场景中没有AB包引用后直接将AB包从内存中释放),释放函数的内容就是把当前包丢进需要释放的列表。

cpp 复制代码
        public void Update()
        {
            for (int i = 0; i < m_AsyncList.Count; i++)
            {
                if (m_AsyncList[i].Update())//如果异步任务列表中某个任务完成则从列表中移除
                {
                    m_AsyncList.RemoveAt(i);
                    i--;
                }
            }
        }

update的生命周期函数中,不断轮询异步任务列表中是否有任务以及完成,完成的话则移除。注意这里有两个update,但是其实这个异步任务(ABundleAsync)的update函数是自定义的,返回bool值表示异步任务是否完成。

cpp 复制代码
public void LateUpdate()
{
    if (m_NeedUnloadList.Count == 0)
        return;

    while (m_NeedUnloadList.Count > 0)
    {
        ABundle bundle = m_NeedUnloadList.First.Value;
        m_NeedUnloadList.RemoveFirst();
        if (bundle == null)
            continue;

        m_BundleDic.Remove(bundle.url);

        if (!bundle.done && bundle is BundleAsync)//is关键字用来判断该对象是否为某个类或者某个类的派生类
        {
            BundleAsync bundleAsync = bundle as BundleAsync;
            if (m_AsyncList.Contains(bundleAsync))
                m_AsyncList.Remove(bundleAsync);
        }

        bundle.UnLoad();

        //依赖引用-1
        if (bundle.dependencies != null)
        {
            for (int i = 0; i < bundle.dependencies.Length; i++)
            {
                ABundle temp = bundle.dependencies[i];
                UnLoad(temp);
            }
        }
    }
}

主要就是通过引用计数和依赖递归机制确保资源安全释放。

现在让我们介绍之前说到的ABundle类和ABundleAsync类:

ABundle类和ABundleAsync类

cpp 复制代码
internal abstract class ABundle
{
    /// <summary>
    /// AssetBundle
    /// </summary>
    internal AssetBundle assetBundle { get; set; }

    /// <summary>
    /// 是否是场景
    /// </summary>
    internal bool isStreamedSceneAssetBundle { get; set; }

    /// <summary>
    /// bundle url
    /// </summary>
    internal string url { get; set; }

    /// <summary>
    /// 引用计数器
    /// </summary>
    internal int reference { get; set; }//引用计数

    //bundle是否加载完成
    internal bool done { get; set; }//是否完成

    /// <summary>
    /// bundle依赖
    /// </summary>
    internal ABundle[] dependencies { get; set; }//AB包依赖

    /// <summary>
    /// 加载bundle
    /// </summary>
    internal abstract void Load();//定义抽象方法Load()以加载

    /// <summary>
    /// 卸载bundle
    /// </summary>
    internal abstract void UnLoad();//定义抽象方法UnLoad()以卸载

    /// <summary>
    /// 异步加载资源
    /// </summary>
    /// <param name="name">资源名称</param>
    /// <param name="type">资源Type</param>
    /// <returns>AssetBundleRequest</returns>
    internal abstract AssetBundleRequest LoadAssetAsync(string name, Type type);//加载异步资源,AssetBundleRequest 是用于
                                                                                //​异步加载 AssetBundle 中特定资源​ 的核心类。它属于
                                                                                //UnityEngine 命名空间,通常配合协程(Coroutine)或
                                                                                //async/await 使用,用于在运行时动态加载资源
                                                                                //(如模型、纹理、场景等),而不会阻塞主线程。

    /// <summary>
    /// 加载资源
    /// </summary>
    /// <param name="name">资源名称</param>
    /// <param name="type">资源Type</param>
    /// <returns>指定名字的资源</returns>
    internal abstract Object LoadAsset(string name, Type type);//加载资产

    /// <summary>
    /// 增加引用
    /// </summary>
    internal void AddReference()//增加引用指数
    {
        //自己引用+1
        ++reference;
    }

    /// <summary>
    /// 减少引用
    /// </summary>
    internal void ReduceReference()//减少引用指数
    {
        //自己引用-1
        --reference;

        if (reference < 0)
        {
            throw new Exception($"{GetType()}.{nameof(ReduceReference)}() less than 0,{nameof(url)}:{url}.");
        }
    }
}

ABundle类作为一个抽象类,主要就是定义一系列抽象方法,作为基类让其他的子类实现。

ABundleAsync的实现则是:

cpp 复制代码
using System;
using UnityEngine;
using Object = UnityEngine.Object;//利用using关键字让Object变成了UnityEngine.Object的别名

namespace AssetBundleFramework
{
    internal abstract class ABundleAsync : ABundle//抽象类
    {
        internal abstract bool Update();//抽象函数
    }
}

是的,注意我们的ABundleAsync已经继承了ABundle类,但是他没有负责实现抽象方法而是继续作为抽象类,这样的话就把问题抛给了下一个非抽象的子类,ABundleAsync类唯一定义的抽象方法就是这个返回bool的Update函数。

Bundle类

Bundle类就是一个继承了ABundle类的类,简单地说,他重写了ABundle类的:

每个函数的内容如下:

cpp 复制代码
        /// <summary>
        /// 加载AssetBundle
        /// </summary>
        internal override void Load()
        {
            if (assetBundle)
            {
                throw new Exception($"{nameof(Bundle)}.{nameof(Load)}() {nameof(assetBundle)} not null , Url:{url}.");
            }

            string file = BundleManager.instance.GetFileUrl(url);

#if UNITY_EDITOR || UNITY_STANDALONE
            if (!File.Exists(file))
            {
                throw new FileNotFoundException($"{nameof(Bundle)}.{nameof(Load)}() {nameof(file)} not exist, file:{file}.");
            }
#endif

            assetBundle = AssetBundle.LoadFromFile(file, 0, BundleManager.instance.offset);

            isStreamedSceneAssetBundle = assetBundle.isStreamedSceneAssetBundle;

            done = true;
        }

存在ab包则获取文件路径,文件路径没错的话调用LoadFromFile()函数,这里我们可以看到有一个参数是isStreamedSceneAssetBundle,这个也是AssetBundle内部自带的一个成员bool变量,表示:

所谓的流式场景

cpp 复制代码
        /// <summary>
        /// 卸载bundle
        /// </summary>
        internal override void UnLoad()
        {
            if (assetBundle)
                assetBundle.Unload(true);

            assetBundle = null;
            done = false;
            reference = 0;
            isStreamedSceneAssetBundle = false;
        }

卸载函数非常简单。

cpp 复制代码
        /// <summary>
        /// 异步加载资源
        /// </summary>
        /// <param name="name">资源名称</param>
        /// <param name="type">资源Type</param>
        /// <returns>AssetBundleRequest</returns>
        internal override AssetBundleRequest LoadAssetAsync(string name, Type type)
        {
            if (string.IsNullOrEmpty(name))
                throw new ArgumentException($"{nameof(Bundle)}.{nameof(LoadAssetAsync)}() name is null.");

            if (assetBundle == null)
                throw new NullReferenceException($"{nameof(Bundle)}.{nameof(LoadAssetAsync)}() Bundle is null.");

            return assetBundle.LoadAssetAsync(name, type);
        }

异步加载函数的内容本质上来说依然是在调用Unity的AssetBundle类内封装的LoadAssetAsync,我们只是加了一些检测,这里可以看到我们返回的是AssetBundleRequest类型,这个类型显然也是Unity自带的类。

cpp 复制代码
        /// <summary>
        /// 加载资源
        /// </summary>
        /// <param name="name">资源名称</param>
        /// <param name="type">资源Type</param>
        /// <returns>指定名字的资源</returns>
        internal override Object LoadAsset(string name, Type type)
        {
            if (string.IsNullOrEmpty(name))
                throw new ArgumentException($"{nameof(Bundle)}.{nameof(LoadAsset)}() name is null.");

            if (assetBundle == null)
                throw new NullReferenceException($"{nameof(Bundle)}.{nameof(LoadAsset)}() Bundle is null.");

            return assetBundle.LoadAsset(name, type);
        }

同步加载和异步加载的最大区别就在于调用的AssetBundle的加载函数不同,以及返回的类型不同,注意这里的Object实际上是我们在头文件引用中:

cpp 复制代码
using System;
using System.IO;
using UnityEngine;
using Object = UnityEngine.Object;

这里就是using的另外一个用法:赋予别名,我们把UnityEngine.Object赋予另一个别名Object,这样我们调用UnityEngine.Object就直接写Object即可。

那么问题来了,为什么我们重写的两个函数中异步加载返回AssetBundleRequest一个返回Object呢?这两个类型有什么区别呢?

简单地说,我们的同步加载就是直接返回加载好的资源,异步加载则返回AssetBundleRequest追踪加载状态。

BundleAsync类

和Bundle类继承自ABundle类似,BundleAsync类也是继承自ABundleAsync类,当然,如果还有人记得的话,我们的ABundleAsync其实也继承自ABundle类,还多加了一个返回Bool变量的Update的抽象函数。

也是同样的重写了Load、UnLoad、LoadAsset、LoadAssetAsync四个函数,当然,还有我们ABundleAsync类新加入的Update函数。

事实上,这个类中的LoadAsset、LoadAssetAsync函数与Bundle类中的同名的两个函数内容一模一样,我就不多赘述了,主要来说说有区别的部分。

后续的还有很多函数,但是大体的逻辑与结构核心就是这几个类。

具体运行时我们在Tools中点击对应的平台进行AB包打包即可,效果如图:

Xlua热更

我先来展示一下使用流程:

既然涉及到热更,我们当然需要自己创建一个简单的服务器,我们使用NetBox来实现一个简单的window服务器。

我们来搭建一个小服务器:

vbscript 复制代码
Dim httpd
Shell.Service.RunService "NBWeb", "NetBox Web Server", "NetBox Http Server Sample"
'---------------------- Service Event ---------------------
Sub OnServiceStart()
Set httpd = NetBox.CreateObject("NetBox.HttpServer")
If httpd.Create("", 5858) = 0 Then
Set host = httpd.AddHost("", "\Web")
host.EnableScript = true
host.AddDefault "index.htm"
host.AddDefault "index.html"
host.AddDefault "index.asp"
host.AddDefault "default.htm"
host.AddDefault "default.asp"
httpd.Start
else
Shell.Quit 0
end if
End Sub
Sub OnServiceStop()
httpd.Close
End Sub
Sub OnServicePause()
httpd.Stop
End Sub
Sub OnServiceResume()
httpd.Start
End Sub

虽然服务器的代码并不是我们的关键(VBScript),但我还是大致解释一下这个服务器代码的意思:

vbscript 复制代码
Dim httpd
Shell.Service.RunService "NBWeb", "NetBox Web Server", "NetBox Http Server Sample"

这两段的意思Dim即定义一个变量httpd,然后我们运行一个变量名为NBWeb的Window服务,显示名为NetBox Web Server,描述为NetBox Http Server Sample。

vbscript 复制代码
Sub OnServiceStart()
Set httpd = NetBox.CreateObject("NetBox.HttpServer")
If httpd.Create("", 5858) = 0 Then
Set host = httpd.AddHost("", "\Web")

OnServiceStart看名字也知道是标记服务开始运行时,然后我们把之前定义的httpd赋值NetBox 提供的 HTTP 服务器对象(NetBox.HttpServer),If httpd.Create("", 5858) = 0表示我们的httpd试图绑定端口5858,如果成功(返回0)则执行Then之后的内容,否则返回报错。而绑定端口成功后我们就要去配置我们的虚拟主机,地址在当前文件路径(这个.box文件路径)下的Web文件中。

vbscript 复制代码
Set host = httpd.AddHost("", "\Web")
host.EnableScript = true
host.AddDefault "index.htm"
host.AddDefault "index.html"
host.AddDefault "index.asp"
host.AddDefault "default.htm"
host.AddDefault "default.asp"
httpd.Start

这一段其实干的事就是启动脚本的支持并配置一系列的默认文件。

vbscript 复制代码
Else
    Shell.Quit 0  ' 退出服务
End If

显然绑定失败就直接退出服务。

vbscript 复制代码
else
Shell.Quit 0
end if
End Sub
Sub OnServiceStop()
httpd.Close
End Sub
Sub OnServicePause()
httpd.Stop
End Sub
Sub OnServiceResume()
httpd.Start
End Sub

这里还多了几个有关服务进行的方法,分别是停止服务,暂停服务以及恢复服务时我们的http主机httpd的操作。

大体上来说,就是写了一个服务器运作的逻辑,对我们来说最重要的有两个东西:绑定的端口号以及我们的虚拟主机的文件路径。

现在让我们先在Unity里写两个测试的Lua脚本:

我们有两个脚本,分别的输出是Hello和Test Old。

我们稍微做一个修改:

然后我们取到Unity界面,Tools->AB包->创建AB包->window。

我们生成了新的AB包,这个AB包的位置在当地的AssetBundlesEncrypt文件夹下:

我们将这些东西复制粘贴到我们的虚拟主机下(其实就是我们的服务器)。

现在让我们来观赏实现这一切的核心代码:我们的热更新管理器。

热更新管理器

cs 复制代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Networking;
using System.IO;
using System.Text;

public class HotUpdateMgr : MonoSingletonBase<HotUpdateMgr>
{
    /// <summary>
    /// _sBaseUrl下载网址
    /// </summary>
#if UNITY_EDITOR || UNITY_STANDALONE_WIN
    public static string _sBaseUrl = "http://127.0.0.1:5858";
#elif UNITY_ANDROID
    public static string _sBaseUrl = "http://192.168.255.10:5858";
#elif UNITY_IPHONE
    public static string _sBaseUrl = "http://192.168.255.10:5858";
#endif

    private string _sABVersionName = "";

    /// <summary>
    /// 本地版本信息缓存路径
    /// </summary>
    private string _sVersionLocalFilePath = "";

    /// <summary>
    /// 同时下载的最大数量
    /// </summary>
    private int _nMaxDownloader = 5;

    /// <summary>
    /// 当前需要下载的AB包数据
    /// </summary>
    List<ABPackInfo> _list_allNeedABPack = new List<ABPackInfo>();

    /// <summary>
    /// 所需下载资源总大小
    /// </summary>
    private float _nDownloadTotalSize = 0;

    /// <summary>
    /// 当前已下载资源的大小
    /// </summary>
    private float _nCurDownloadedSize = 0;

    /// <summary>
    /// AB包下载器
    /// </summary>
    private List<ABDownloader> _list_allABDownloader = new List<ABDownloader>();

    /// <summary>
    /// 客户端的AB版本数据
    /// </summary>
    private Dictionary<string, ABPackInfo> _dict_clientABInfoList = null;

    protected override void Awake()
    {
        string sPlatformStr = ABPackUtils.GetABPackPathPlatformStr();
        _sABVersionName = sPlatformStr + ABPackUtils.sABVersionName;
        _sVersionLocalFilePath = Application.persistentDataPath + _sABVersionName;
        IOUtils.CreateDirectroryOfFile(_sVersionLocalFilePath);
    }

    /// <summary>
    /// 开始热更
    /// </summary>
    public void StartHotUpdate()
    {
        Debug.Log("开始热更 >>>>>> ");
        StartCoroutine(DownloadAllABPackVersion());
    }

    /// <summary>
    /// 解析版本文件,返回一个文件列表
    /// </summary>
    /// <param name="sContent"></param>
    /// <returns></returns>
    public Dictionary<string, ABPackInfo> ConvertToAllABPackDesc(string sContent)
    {
        Dictionary<string, ABPackInfo> dict_allABPackDesc = new Dictionary<string, ABPackInfo>();
        string[] arrLines = sContent.Split('\n');//用回车 字符 \n 分割每一行
        foreach (string item in arrLines)
        {
            string[] arrData = item.Split(' ');//用空格分割每行数据的三个类型
            if (arrData.Length == 3)
            {
                ABPackInfo obj_ABPackData = new ABPackInfo();
                obj_ABPackData.sABName = arrData[0]; // 名称即路径
                obj_ABPackData.sMd5 = arrData[1]; // md5值
                obj_ABPackData.nSize = int.Parse(arrData[2]); // AB包大小

                //Debug.Log(string.Format("解析的路径:{0}\n解析的MD5:{1}\n解析的文件大小KB:{2}", obj_ABPackData.sABName, obj_ABPackData.sMd5, obj_ABPackData.nSize));
                dict_allABPackDesc.Add(obj_ABPackData.sABName, obj_ABPackData);
            }
        }

        return dict_allABPackDesc;
    }


    /// <summary>
    /// 获取服务端的AB包版本信息
    /// </summary>
    /// <returns></returns>
    IEnumerator DownloadAllABPackVersion()
    {
        string sVersionUrl = _sBaseUrl + @"/" + _sABVersionName;
        //Debug.Log("下载版本数据路径:" + sVersionUrl);

        using (UnityWebRequest uObj_versionWeb = UnityWebRequest.Get(sVersionUrl))
        {
            yield return uObj_versionWeb.SendWebRequest(); // 等待资源下载
            if (uObj_versionWeb.isNetworkError || uObj_versionWeb.isHttpError)
            {
                Debug.LogError("获取版本AB包数据错误: " + uObj_versionWeb.error);
                yield break;
            }
            else
            {
                string sVersionData = uObj_versionWeb.downloadHandler.text;
                //Debug.Log("成功获取到版本相关数据 >>>> \n" + sVersionData);
                CheckNeedDownloadABPack(sVersionData);
            }
        }
    }

    /// <summary>
    /// 检测需要下载
    /// </summary>
    /// <param name="sServerVersionData"></param>
    void CheckNeedDownloadABPack(string sServerVersionData)
    {
        //Debug.Log("运行平台:" + Application.platform);
        //Debug.Log("本地版本文件路径是:" + _sVersionLocalFilePath);

        Dictionary<string, ABPackInfo> dict_serverDownList = ConvertToAllABPackDesc(sServerVersionData); // 服务端获取的资源下载列表

        if (File.Exists(_sVersionLocalFilePath))
        {
            //Debug.Log("存在本地,对比服务器版本信息");
            string sClientVersionData = File.ReadAllText(_sVersionLocalFilePath); // 本地版本信息
            _dict_clientABInfoList = ConvertToAllABPackDesc(sClientVersionData); // 客户端本地缓存的资源下载列表

            //遍历服务器文件
            foreach (ABPackInfo obj_itemData in dict_serverDownList.Values)
            {
                // 存在对应已下载文件,对比Md5值是否一致
                if (_dict_clientABInfoList.ContainsKey(obj_itemData.sABName))
                {
                    // md5值不一致,则更新文件
                    if (_dict_clientABInfoList[obj_itemData.sABName].sMd5 != obj_itemData.sMd5)
                    {
                        _list_allNeedABPack.Add(obj_itemData);
                        _nDownloadTotalSize = _nDownloadTotalSize + obj_itemData.nSize;

                        //Debug.Log("MD5 值不一样,资源存在变更,增加文件 >>>>> " + obj_itemData.sABName);
                    }
                }
                else
                {
                    _list_allNeedABPack.Add(obj_itemData);
                    _nDownloadTotalSize = _nDownloadTotalSize + obj_itemData.nSize;
                }
            }
        }
        else // 如果说不存在本地缓存,那就直接下载所有的AB包
        {
            foreach (ABPackInfo obj_itemData in dict_serverDownList.Values)
            {
                _list_allNeedABPack.Add(obj_itemData);
                _nDownloadTotalSize = _nDownloadTotalSize + obj_itemData.nSize;
                //Debug.Log("所需下载文件 >>>>> " + obj_itemData.sABName);
            }
        }
        StartDownloadAllABPack();
    }

    /// <summary>
    /// 开始下载所有所需下载的AB包资源
    /// </summary>
    /// <param name="list_allABPack"></param>
    void StartDownloadAllABPack()
    {
        int nMaxCount = _list_allNeedABPack.Count;
        if (nMaxCount <= 0) 
        {
            HotUpdateEnd();
            return;
        }

        int nNeedCount = Mathf.Min(nMaxCount, _nMaxDownloader);
        for (int i = 0; i < nNeedCount; i++)
        {
            ABPackInfo obj_ABPackDesc = _list_allNeedABPack[0];
            ABDownloader obj_downloader = new ABDownloader();
            _list_allABDownloader.Add(obj_downloader);
            StartCoroutine(obj_downloader.DownloadABPack(obj_ABPackDesc));
            _list_allNeedABPack.RemoveAt(0);
        }
    }

    /// <summary>
    /// 切换下载下一个AB包
    /// </summary>
    /// <param name="obj_ABDownloader">需要切换的下载器</param>
    public void ChangeDownloadNextABPack(ABDownloader obj_ABDownloader)
    {
        //Debug.Log("切换下载下一个 AB 包");
        _nCurDownloadedSize += obj_ABDownloader.GetDownloadResSize();

        if (_list_allNeedABPack.Count > 0) // 还存在需要下载的资源,下载器切换资源,继续下载
        {
            StartCoroutine(obj_ABDownloader.DownloadABPack(_list_allNeedABPack[0]));
            _list_allNeedABPack.RemoveAt(0);
        }
        else
        {
            bool bIsDownloadSuc = true; // 资源是否全部下载完成
            foreach(ABDownloader obj_downloader in _list_allABDownloader)
            {
                if(obj_downloader.bIsDownloading) // 存在一个下载中,即表示当前还有未下载完成的部分
                {
                    bIsDownloadSuc = false;
                    break;
                }
            }

            if (bIsDownloadSuc) // 已完成全部下载
            {
                HotUpdateEnd();
            }
        }
    }

    /// <summary>
    /// 更新本地缓存的AB包版本数据
    /// </summary>
    /// <param name="obj_ABPackDecs"></param>
    public void UpdateClientABInfo(ABPackInfo obj_ABPackDecs)
    {
        if (_dict_clientABInfoList == null)
        {
            _dict_clientABInfoList = new Dictionary<string, ABPackInfo>();
        }

        _dict_clientABInfoList[obj_ABPackDecs.sABName] = obj_ABPackDecs;

        StringBuilder obj_sb = new StringBuilder();
        foreach (ABPackInfo obj_temp in _dict_clientABInfoList.Values)
        {
            obj_sb.AppendLine(ABPackUtils.GetABPackVersionStr(obj_temp.sABName, obj_temp.sMd5, obj_temp.nSize.ToString()));
        }

        IOUtils.CreatTextFile(_sVersionLocalFilePath, obj_sb.ToString());
    }

    /// <summary>
    /// 热更新结束,进入下一个阶段
    /// </summary>
    private void HotUpdateEnd()
    {
        // TODO 进入下一个阶段
        Debug.Log("热更新: 已完成所有的AB包下载, 进入下一个阶段 TODO");
        HotUpdateTest.GetInstance().RunLua();
        HotUpdateTest.GetInstance().InitShow();
    }
}

可以看到非常冗长的代码啊,我们来一点一点介绍:

首先我们要注意的是这个MonoSingletonBase<HotUpdateMgr>,这个是泛型单例基类的意思,也就是同时包含了泛型和单例模式的一个基类,这两个概念就不用我多做介绍了吧。

cs 复制代码
    /// <summary>
    /// _sBaseUrl下载网址
    /// </summary>
#if UNITY_EDITOR || UNITY_STANDALONE_WIN
    public static string _sBaseUrl = "http://127.0.0.1:5858";
#elif UNITY_ANDROID
    public static string _sBaseUrl = "http://192.168.255.10:5858";
#elif UNITY_IPHONE
    public static string _sBaseUrl = "http://192.168.255.10:5858";
#endif

这个就是根据我们的平台来确定服务器的网址,这样我们才能去服务器下载AB包。

cs 复制代码
private string _sABVersionName = "";

/// <summary>
/// 本地版本信息缓存路径
/// </summary>
private string _sVersionLocalFilePath = "";

/// <summary>
/// 同时下载的最大数量
/// </summary>
private int _nMaxDownloader = 5;

/// <summary>
/// 当前需要下载的AB包数据
/// </summary>
List<ABPackInfo> _list_allNeedABPack = new List<ABPackInfo>();

/// <summary>
/// 所需下载资源总大小
/// </summary>
private float _nDownloadTotalSize = 0;

/// <summary>
/// 当前已下载资源的大小
/// </summary>
private float _nCurDownloadedSize = 0;

/// <summary>
/// AB包下载器
/// </summary>
private List<ABDownloader> _list_allABDownloader = new List<ABDownloader>();
 
/// <summary>
/// 客户端的AB版本数据
/// </summary>
private Dictionary<string, ABPackInfo> _dict_clientABInfoList = null;

我们在这里自定义了一系列变量,包括AB包版本信息,本地版本信息缓存路径,同时下载AB包的最大数量,当前需要下载的AB包数据,所需资源的总大小与已下载的总大小,AB包下载器与客户端的AB包版本信息。

cs 复制代码
    protected override void Awake()
    {
        string sPlatformStr = ABPackUtils.GetABPackPathPlatformStr();
        _sABVersionName = sPlatformStr + ABPackUtils.sABVersionName;
        _sVersionLocalFilePath = Application.persistentDataPath + _sABVersionName;
        IOUtils.CreateDirectroryOfFile(_sVersionLocalFilePath);
    }

Awake函数中,我们定义了AB包版本信息的构成方式:由平台名加上固定的版本信息拼接而成,获得平台名的函数如下所示,根据不同的平台(如Windows)来生成不同的平台名。然后我们将Unity的持久化数据目录与版本文件名拼接,生成版本文件的完整本地路径,最后根据文件路径自动创建所有必要的父目录(即使路径中部分目录不存在)。

cs 复制代码
    public static string GetABPackPathPlatformStr()
    {
        RuntimePlatform obj_platform = Application.platform;
        string sPlatformStr = "/AssetBundles/";
        if (obj_platform == RuntimePlatform.WindowsEditor || obj_platform == RuntimePlatform.WindowsPlayer)
        {
            sPlatformStr += "StandaloneWindows/";
        }
        else if (obj_platform == RuntimePlatform.Android)
        {
            sPlatformStr += "Android/";
        }
        else if (obj_platform == RuntimePlatform.IPhonePlayer)
        {
            sPlatformStr += "iOS/";
        }

        return sPlatformStr;
    }
cs 复制代码
    /// <summary>
    /// 开始热更
    /// </summary>
    public void StartHotUpdate()
    {
        Debug.Log("开始热更 >>>>>> ");
        StartCoroutine(DownloadAllABPackVersion());
    }

没啥好说,开始热更,启动协程执行DownloadAllABPackVersion()函数。

cs 复制代码
/// <summary>
/// 解析版本文件,返回一个文件列表
/// </summary>
/// <param name="sContent"></param>
/// <returns></returns>
public Dictionary<string, ABPackInfo> ConvertToAllABPackDesc(string sContent)
{
    Dictionary<string, ABPackInfo> dict_allABPackDesc = new Dictionary<string, ABPackInfo>();
    string[] arrLines = sContent.Split('\n');//用回车 字符 \n 分割每一行
    foreach (string item in arrLines)
    {
        string[] arrData = item.Split(' ');//用空格分割每行数据的三个类型
        if (arrData.Length == 3)
        {
            ABPackInfo obj_ABPackData = new ABPackInfo();
            obj_ABPackData.sABName = arrData[0]; // 名称即路径
            obj_ABPackData.sMd5 = arrData[1]; // md5值
            obj_ABPackData.nSize = int.Parse(arrData[2]); // AB包大小

            //Debug.Log(string.Format("解析的路径:{0}\n解析的MD5:{1}\n解析的文件大小KB:{2}", obj_ABPackData.sABName, obj_ABPackData.sMd5, obj_ABPackData.nSize));
            dict_allABPackDesc.Add(obj_ABPackData.sABName, obj_ABPackData);
        }
    }

    return dict_allABPackDesc;
}

这一段主要是用来解析我们读取到的AB包版本文件,按换行符来份行,每一行分为名字,MD5值以及AB包的尺寸大小。我们的函数会把这些信息全部塞进字典中,字典的键是AB包的名称,而值则是AB包的所有信息。

cs 复制代码
    /// <summary>
    /// 获取服务端的AB包版本信息
    /// </summary>
    /// <returns></returns>
    IEnumerator DownloadAllABPackVersion()
    {
        string sVersionUrl = _sBaseUrl + @"/" + _sABVersionName;
        //Debug.Log("下载版本数据路径:" + sVersionUrl);

        using (UnityWebRequest uObj_versionWeb = UnityWebRequest.Get(sVersionUrl))
        {
            yield return uObj_versionWeb.SendWebRequest(); // 等待资源下载
            if (uObj_versionWeb.isNetworkError || uObj_versionWeb.isHttpError)
            {
                Debug.LogError("获取版本AB包数据错误: " + uObj_versionWeb.error);
                yield break;
            }
            else
            {
                string sVersionData = uObj_versionWeb.downloadHandler.text;
                //Debug.Log("成功获取到版本相关数据 >>>> \n" + sVersionData);
                CheckNeedDownloadABPack(sVersionData);
            }
        }
    }

这个函数负责读取服务器的AB包版本信息,先用服务器的网址和AB包版本信息拼接出一个新的版本网址,然后我们调用http中的GET请求从服务器处获取数据,注意这里我们在一开始我们使用了一个using。

这是using的一个用法之一,我们下载过程中如果报错就break,否则我们将下载到的版本数据进行比对判断是否需要下载新的AB包。

cs 复制代码
/// <summary>
/// 检测需要下载
/// </summary>
/// <param name="sServerVersionData"></param>
void CheckNeedDownloadABPack(string sServerVersionData)
{
    //Debug.Log("运行平台:" + Application.platform);
    //Debug.Log("本地版本文件路径是:" + _sVersionLocalFilePath);

    Dictionary<string, ABPackInfo> dict_serverDownList = ConvertToAllABPackDesc(sServerVersionData); // 服务端获取的资源下载列表

    if (File.Exists(_sVersionLocalFilePath))
    {
        //Debug.Log("存在本地,对比服务器版本信息");
        string sClientVersionData = File.ReadAllText(_sVersionLocalFilePath); // 本地版本信息
        _dict_clientABInfoList = ConvertToAllABPackDesc(sClientVersionData); // 客户端本地缓存的资源下载列表

        //遍历服务器文件
        foreach (ABPackInfo obj_itemData in dict_serverDownList.Values)
        {
            // 存在对应已下载文件,对比Md5值是否一致
            if (_dict_clientABInfoList.ContainsKey(obj_itemData.sABName))
            {
                // md5值不一致,则更新文件
                if (_dict_clientABInfoList[obj_itemData.sABName].sMd5 != obj_itemData.sMd5)
                {
                    _list_allNeedABPack.Add(obj_itemData);
                    _nDownloadTotalSize = _nDownloadTotalSize + obj_itemData.nSize;

                    //Debug.Log("MD5 值不一样,资源存在变更,增加文件 >>>>> " + obj_itemData.sABName);
                }
            }
            else
            {
                _list_allNeedABPack.Add(obj_itemData);
                _nDownloadTotalSize = _nDownloadTotalSize + obj_itemData.nSize;
            }
        }
    }
    else // 如果说不存在本地缓存,那就直接下载所有的AB包
    {
        foreach (ABPackInfo obj_itemData in dict_serverDownList.Values)
        {
            _list_allNeedABPack.Add(obj_itemData);
            _nDownloadTotalSize = _nDownloadTotalSize + obj_itemData.nSize;
            //Debug.Log("所需下载文件 >>>>> " + obj_itemData.sABName);
        }
    }
    StartDownloadAllABPack();
}

检查AB包是否需要下载的代码非常冗长,我们首先把上一个函数中从服务器获得的AB包版本信息转换成字典,然后检查本地是否已经有AB包缓存地址,有的话说明我们已经从服务器处下载过AB包了,这时候我们就去比对本地的AB包的版本信息与服务器的AB包的版本信息,尤其是其中的MD5的值,如果发现不同说明我们的服务器的AB包有更改过,我们需要重新下载。对于本地缓存中不包含的AB包,我们需要下载,当然,如果我们连本地的缓存都没有,那当然需要下载所有的AB包。

cs 复制代码
/// <summary>
/// 开始下载所有所需下载的AB包资源
/// </summary>
/// <param name="list_allABPack"></param>
void StartDownloadAllABPack()
{
    int nMaxCount = _list_allNeedABPack.Count;
    if (nMaxCount <= 0) 
    {
        HotUpdateEnd();
        return;
    }

    int nNeedCount = Mathf.Min(nMaxCount, _nMaxDownloader);
    for (int i = 0; i < nNeedCount; i++)
    {
        ABPackInfo obj_ABPackDesc = _list_allNeedABPack[0];
        ABDownloader obj_downloader = new ABDownloader();
        _list_allABDownloader.Add(obj_downloader);
        StartCoroutine(obj_downloader.DownloadABPack(obj_ABPackDesc));
        _list_allNeedABPack.RemoveAt(0);
    }
}

下载AB包的代码并不复杂,我们有一个AB包数量的上限,在数量达到上限之前,我们实现了一个异步下载AB包的流程。

cs 复制代码
    /// <summary>
    /// 切换下载下一个AB包
    /// </summary>
    /// <param name="obj_ABDownloader">需要切换的下载器</param>
    public void ChangeDownloadNextABPack(ABDownloader obj_ABDownloader)
    {
        //Debug.Log("切换下载下一个 AB 包");
        _nCurDownloadedSize += obj_ABDownloader.GetDownloadResSize();

        if (_list_allNeedABPack.Count > 0) // 还存在需要下载的资源,下载器切换资源,继续下载
        {
            StartCoroutine(obj_ABDownloader.DownloadABPack(_list_allNeedABPack[0]));
            _list_allNeedABPack.RemoveAt(0);
        }
        else
        {
            bool bIsDownloadSuc = true; // 资源是否全部下载完成
            foreach(ABDownloader obj_downloader in _list_allABDownloader)
            {
                if(obj_downloader.bIsDownloading) // 存在一个下载中,即表示当前还有未下载完成的部分
                {
                    bIsDownloadSuc = false;
                    break;
                }
            }

            if (bIsDownloadSuc) // 已完成全部下载
            {
                HotUpdateEnd();
            }
        }
    }

这段代码负责更新是否还存在需要下载的资源,如果存在则启动下载的协程,否则更新资源下载情况,资源全部下载之后我们停止热更。

cs 复制代码
/// <summary>
/// 更新本地缓存的AB包版本数据
/// </summary>
/// <param name="obj_ABPackDecs"></param>
public void UpdateClientABInfo(ABPackInfo obj_ABPackDecs)
{
    if (_dict_clientABInfoList == null)
    {
        _dict_clientABInfoList = new Dictionary<string, ABPackInfo>();
    }

    _dict_clientABInfoList[obj_ABPackDecs.sABName] = obj_ABPackDecs;

    StringBuilder obj_sb = new StringBuilder();
    foreach (ABPackInfo obj_temp in _dict_clientABInfoList.Values)
    {
        obj_sb.AppendLine(ABPackUtils.GetABPackVersionStr(obj_temp.sABName, obj_temp.sMd5, obj_temp.nSize.ToString()));
    }

    IOUtils.CreatTextFile(_sVersionLocalFilePath, obj_sb.ToString());
}

/// <summary>
/// 热更新结束,进入下一个阶段
/// </summary>
private void HotUpdateEnd()
{
    // TODO 进入下一个阶段
    Debug.Log("热更新: 已完成所有的AB包下载, 进入下一个阶段 TODO");
    HotUpdateTest.GetInstance().RunLua();
    HotUpdateTest.GetInstance().InitShow();
}v

更新本地缓存和执行下一个代码,都没啥可说的。

看似很长的代码,具体来说就干了几件事:获取服务器和客户端的AB包版本信息,发现不同后就开始下载,更新本地缓存。

热更新测试

热更新测试是用来干嘛的呢?其实就是用来检查我们的热更是否正常运行,也就是我们实现了热更这个过程吗。

cs 复制代码
using UnityEngine;

public class HotUpdateTest : MonoSingletonBase<HotUpdateTest>
{
    void Start()
    {
        HotUpdateMgr.GetInstance().StartHotUpdate();
    }

    public void RunLua()
    {
        LuaInterpreter.GetInstance().RequireLua("HelloWorld");
        LuaInterpreter.GetInstance().RequireLua("Test");
    }

    public void InitShow()
    {
        GameObject obj_cube = AssetBundleMgr.GetInstance().LoadABPackRes<GameObject>("mode.ab", "Cube");
        Debug.Log("实例化 Cube");
    }
}

在start函数中直接调用热更新管理器的开始热更函数,之后的RunLua函数中则是从Lua解释器中声明需要的Lua脚本,最后还实例化了一个立方体。

关于热更新的逻辑在这里就差不多结束了,我们现在运行代码之后可以看到:

可以看到我们的整个热更新过程已经执行成功了,该有的打印信息也都有。

热更新原理

本身热更新这个过程并不难理解,难的是其中的原理,因为这里涉及到C#,Lua互相转换。

我们都知道C#是编译型语言而Lua是解释性语言,Lua的轻量化让他可以被替换后不再重新编译就生效,简单奔放的语法让他易于更改,但是世上没有免费的午餐。

我们先来聊聊C#和Lua脚本互相转换的过程吧:

C#调用Lua的过程中,C#会生成一个Bridge文件(中间层),这个文件可以去调用Lua的.dll文件(其实就是一些C的API)来使用Lua语言(Lua的虚拟机使用C/C++实现),而反过来Lua调用C#时会首先生成一个wrap文件,这个文件会把C#的各种字段方法注册到Lua的虚拟机中变成Lua可以识别的类型,这样Lua就可以在虚拟机中调用C#的方法了。

这里会涉及到一个经典的问题就是:为什么Lua调用C#这么慢?

大体上来说,C#调用Lua本质上就是去调用Lua的C接口,直接操作Lua的虚拟机,还有一部分开销来自于C#和Lua的类型转换;而Lua调用C#还涉及到一个C#的字段方法反射到Lua虚拟机的过程,而这个过程是非常耗时的。至于优化的方法,比如我们的XLua就可以为Lua生成可识别的C#类和方法,大体思路就是去减少反射的次数与减少数据传递的频率。

让我们来聊聊Unity,我们都知道我们通过C#控制Unity引擎,但其实Unity引擎本身是用C++写成的,我们的C#本身只是去调用Unity底层的各种C++代码,而如果我们想要实现热更的效果,那么我们就避免不了C#和Lua脚本的转换。

更新时,我们的C++写成的Unity底层代码并不能更改,我们更改的只是Lua脚本写成的这一部分代码,这些Lua脚本在C#脚本中通过某些手段(如XLua)能快速地实现转换。在真正的热更流程中,我们会在服务器发布新的Lua脚本,然后客户端会检查Lua脚本的版本信息(MD5值或shader64值)决定是否需要更新,需要的话就会下载新的Lua脚本替换原脚本。

有了AB包和Lua脚本,我们就可以大致上实现了热更的整体逻辑:针对简单的脚本交互逻辑,我们用lua脚本实现,针对场景内的资源,我们用AB包进行打包,二者都可以实现热更。

相关推荐
魔士于安39 分钟前
Unity太空战舰完整工程,包含战损,实时战损
游戏·unity·游戏引擎·贴图·模型
Nuopiane1 小时前
MyPal3(10)视锥体剔除
unity
爱搞虚幻的阿恺3 小时前
RPG游戏开发【加餐】实现游戏小地图的简单方法
游戏·ue5·游戏引擎·虚幻
海海不瞌睡(捏捏王子)3 小时前
Unity知识点概要
unity·1024程序员节
学不完的4 小时前
Zrlog面试问答及问题解决方案
linux·运维·nginx·unity·游戏引擎
小清兔4 小时前
unity游戏制作中问题汇总(持续更新)
游戏·unity·游戏引擎
WiChP6 小时前
【V0.1B4】从零开始的2D游戏引擎开发之路
前端·javascript·游戏引擎
mxwin18 小时前
Unity Shader SRP深入理解内置渲染管线与 URP/HDRP 的底层架构差异
unity·游戏引擎·单一职责原则
mxwin1 天前
Unity Shader 渲染管线深度解析 — Shader 三阶段
unity·游戏引擎·shader·uv
mxwin1 天前
Unity Shader 数学与几何变换 深入理解渲染管线中的坐标系转换:从模型空间到屏幕空间的完整变换链
unity·游戏引擎·shader