在开发商业游戏时,热更新是一个很重要的模块,这里讲的热更新不是指仅仅修复Bug,而是进行游戏功能的更新。简单来讲,就是启动游戏后,跑个条,下载资源和代码,然后再进入游戏。本篇博客所写的内容并不是最优的解,只是完成了热更新这个事情而已,具体使用还需要使用者根据自己的项目来具体来看。

这里采用的方案是使用 AssetBundle 和 xLua。使用 AssetBundle 是为了资源的完全自主控制。而整个游戏的逻辑部分,则使用 xLua 来实现。当然,C# 的代码不可能一点没有,只是一些核心的功能模块,一般写好后就不会改变的东西,或者对性能要求很高的东西,放在 C# 就可以。

整个功能分为编辑器部分,和运行时部分。编辑器部分就是编 Bundle,生成版本文件等。而运行时部分就是从 CDN 下载版本文件,对比版本号及本地资源是否有要更新的,如果有,则更新,更新完后进入游戏。没有,则直接进入游戏。

编辑器部分

编辑器部分主要就是生成 Bundle 文件,首先,我是按目录来划分 Bundle 的,任何一个目录下的文件(不包括子目录)则会打成一个 Bundle。例如下面的目录结构

Res/
    - ConfigBytes/
    - UI/
    - LuaScripts/
    - Data/
        - ItemsData/
        - CharactersData/

首先 Res 目录是资源的主目录,下面有各种子目录(Res 目录下不会有需要打 Bundle 的文件)。ConfigBytes 目录下的文件,会打成一个 Bundle。UI 目录下的文件会打成一个 Bundle。LuaScripts 目录下的文件会打成一个 Bundle。Data 目录下的 ItemsData 目录中的文件会打成一个 Bundle,Data 目录下的 CharactersData 目录会打成一个Bundle。如果 Data 目录下存在文件(非目录),则这些文件会打成一个 Bundle。简单来讲,就是会按文件夹来决定哪些文件打成一个Bundle,检查的时候只会取一个文件夹下的文件,而不会递归取这个文件夹下的子目录。

LuaScripts 目录在开发时会放在 Assets 目录外面,与其同级,在编 Bundle 时,会拷贝 LuaScripts 目录及下面的所有文件,按原有目录结构,拷贝到 Res 目录中,并且会将每一个 xxx.lua 文件的扩展名改为 xxx.txt。因为 .lua 在 Unity 中识别不了。

打 Bundle 的脚本,会记录每一个资源,所在的 Bundle 名称,最后会生成一个 index.json 文件,这份索引文件记录的,就是每一个资源的加载路径,和所在的 Bundle 名。

Bundle 输出后,会生成一个 version.json 的文件,这个文件,记录了每一个 Bundle 的名字,MD5 和 文件大小。而热更新对比一个文件是否需要更新,就是判断远程文件的 MD5 与本地文件的 MD5 是否相同,如果不想同,则需要更新远程文件。

以上就是编辑器所做的事情,总结一下就是

  1. 拷贝 LuaScripts 到 Assets/Res/ 目录中
  2. 将 Res/ 目录下的文件按目录进行 Bundle 生成
  3. 生成资源索引文件(index.json),并且将这个文件也打成 Bundle
  4. 根据生成的 Bundle,生成 version.json 文件
  5. 拷贝上面的 Bundle 及 version.json 文件到 StreamingAssets 目录
  6. 将上面的 Bundle 及 version.json 文件上传到远程服务器或 CDN

第 5 步,之所以是要拷贝到 StreamingAssets 目录,是为了用户在第一次安装游戏时,运行时逻辑会先判断本地有没有资源,如果没有,或者版本号小于 StreamingAssets 中的 version.json 文件版本号,则需要将 StreamingAssets 目录下的 Bundle 及 version.json 文件拷贝到 Persistent 目录下,这样就不用第一次安装游戏,还需要跑条更新资源了。当然,虽然有了这个过程,拷贝完后,正常的版本检查还是会做。

以上就是编辑器下做的事情,下面为运行时的流程

运行时资源更新部分

版本检查及更新逻辑,可以放在一个独立的场景中去进行,一旦更新完成后,就直接跳转场景到游戏启动场景,这个逻辑比较简单清晰,不容易出错。

在游戏启动时,首先会去远程拉取 version.json 文件,然后根据 version.json 文件中的版本号与本地 version.json 文件中的版本号进行对比。如果不一样,则需要根据 version.json 文件中的 Bundle 信息,看一下哪一些 Bundle 需要更新,找到需要更新的 Bundle 后,依次下载始可。因为 version.json 中包含了每一个 Bundle 的文件大小,所以这里的下载进度条的进度,也是可以计算出来的。

在对比版本号时,需要根据自己游戏的实际进行,分为大版本号和热更新版本号,如果大版本号不一样,则直接不用判断 Bundle 了,让用户进不了游戏,弹窗告诉用户去下载最新的安装包即可。如果大版本一样,则进行热更新的逻辑。这里的大版本判断,不当要根据 version.json 文件里的版本号来判断,最好是根据包里代码中或者包里的某个配置文件中的版本号来判断,因为 version.json 文件是在手机的可读写目录,对于 Android 来说,是很容易随意找到这个文件,然后改掉的,从而绕过热更新。

在 Bundle 都下载完后,需要将远程的 version.json 文件写入本地,覆盖本地的 version.json 文件。

最后,再进行一步本地资源校对,就是计算每一个本地 Bundle MD5,是否与 version.json 中的 MD5 一致,如果不一致,则需要弹窗告诉玩家需要手动修复资源,或者直接自动下载覆盖。手动修复也就是从远程重新下载资源进行覆盖。

最后一步资源校对通过后,则跳到游戏逻辑开始场景。

我是将版本检查和更新的逻辑放在了 C# 实现的,当然,也可以放在 lua 来实现,不过需要在更新完后,重新创建整个 lua 环境,以保证使用了最新的资源。

运行时资源加载部分

资源的加载,可以使用一个资源管理器脚本来实现。资源管理器在初始化时首先要加载 Bundle 的 AssetBundleManifest 信息,这个资源里记录了各个 Bundle 与其他 Bundle 的依赖关系。然后加载 index 文件,也就是我们一开始生成的资源索引文件,这样才能知道哪一个资源,在哪一个 Bundle 里。当要加载一个资源时,传入资源加载路径,首先会根据 index 文件中的信息,找到这个 Bundle,然后从 Manifest 信息中,读取这个 Bundle 的依赖 Bundle,如果有,则先加载依赖,最后,再加载当前 Bundle。Bundle加载完后,从 Bundle 中加载资源。

具体代码(仅供参考)

AssetBundleBuilder.cs 编辑器下编 Bundle 的代码

using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using UnityEditor.Build;
using System;
using System.IO;
using System.Text;
using System.Linq;
using System.Xml.Linq;
using System.Security.Cryptography;
using UnityEditor.Build.Reporting;

// 注意:BundleCombineConfig.json 中的配置,目录最后!不要!加上 '/'
public class AssetBundleBuilder
{
    private static string RES_TO_BUILD_PATH = "Assets/Res/";
    private static string MANIFEST_FILES_PATH = string.Format("{0}/../BundleManifest/", Application.dataPath);
    private static StringBuilder IndexFileContent = null;
    private static StringBuilder VersionFileContent = null;
    private static MD5 md5 = null;
    private static BuildAssetBundleOptions BuildOption = BuildAssetBundleOptions.ChunkBasedCompression |
                                                        BuildAssetBundleOptions.ForceRebuildAssetBundle;

    private static BundleCombineConfig combineConfig = null;
    private static Dictionary<string, int> combinePathDict = null;

    private static string version = "0.0.0";
    private static bool copyToStreaming = false;

    private static void InitBuilder()
    {
        IndexFileContent = new StringBuilder();
        VersionFileContent = new StringBuilder();
        md5 = new MD5CryptoServiceProvider();
        combineConfig = null;
        combinePathDict = new Dictionary<string, int>();
    }

    private static void WriteIndexFile(string key, string value)
    {
        IndexFileContent.AppendFormat("{0}:{1}", key, value);
        IndexFileContent.AppendLine();
    }

    private static void WriteVersionFile(string key, string value1, long value2)
    {
        VersionFileContent.AppendFormat("{0}:{1}:{2}", key, value1, value2);
        VersionFileContent.AppendLine();
    }

    private static long GetFileSize(string fileName)
    {
        try
        {
            FileInfo fileInfo = new FileInfo(fileName);
            return fileInfo.Length;
        }
        catch (Exception ex)
        {
            throw new Exception("GetFileSize() fail, error:" + ex.Message);
        }
    }

    private static string GetMD5(byte[] retVal)
    {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < retVal.Length; i++)
        {
            sb.Append(retVal[i].ToString("x2"));
        }
        return sb.ToString();
    }

    private static string GetMD5HashFromFile(string fileName)
    {
        try
        {
            FileStream file = new FileStream(fileName, FileMode.Open);
            byte[] retVal = md5.ComputeHash(file);
            file.Close();

            return GetMD5(retVal);
        }
        catch (Exception ex)
        {
            throw new Exception("GetMD5HashFromFile() fail, error:" + ex.Message);
        }
    }

    static string GetBundleName(string path)
    {
        byte[] md5Byte = md5.ComputeHash(Encoding.Default.GetBytes(path));
        string str = GetMD5(md5Byte) + ".assetbundle";
        return str;
    }
    private class BuildBundleData
    {
        private AssetBundleBuild build = new AssetBundleBuild();
        private List<string> assets = new List<string>();
        private List<string> addresses = new List<string>();

        public BuildBundleData(string bundleName)
        {
            build.assetBundleName = bundleName;
        }

        public void AddAsset(string filePath)
        {
            string addressableName = GetAddressableName(filePath);
            assets.Add(filePath);
            addresses.Add(addressableName);
            WriteIndexFile(addressableName, build.assetBundleName);
        }

        public AssetBundleBuild Gen()
        {
            build.assetNames = assets.ToArray();
            build.addressableNames = addresses.ToArray();
            return build;
        }
    }

    private static string GetAddressableName(string file_path)
    {
        string addressable_name = file_path;
        addressable_name = addressable_name.Replace(RES_TO_BUILD_PATH, "");
        int dot_pos = addressable_name.LastIndexOf('.');
        if (dot_pos != -1)
        {
            int count = addressable_name.Length - dot_pos;
            addressable_name = addressable_name.Remove(dot_pos, count);
        }
        return addressable_name;
    }

    private static string[] GetTopDirs(string rPath)
    {
        return Directory.GetDirectories(rPath, "*", SearchOption.TopDirectoryOnly);
    }

    private static void CopyLuaDir()
    {
        // Copy Lua
        string luaOutPath = Application.dataPath + "/../LuaScripts";
        string luaInPath = Application.dataPath + "/Res/LuaScripts";

        DeleteLuaDir();

        MoeUtils.DirectoryCopy(luaOutPath, luaInPath, true, ".txt");
        AssetDatabase.Refresh();
    }

    private static void DeleteLuaDir()
    {
        string luaInPath = Application.dataPath + "/Res/LuaScripts";

        if (Directory.Exists(luaInPath))
        {
            Directory.Delete(luaInPath, true);
        }
    }

    public static void BuildBundleWithVersion(string v, bool copy)
    {
        version = v;
        copyToStreaming = copy;
        BuildAssetBundle();
    }

    [MenuItem("Tools/Build Bundles")]
    private static void BuildAssetBundle()
    {
        if (version == "0.0.0")
        {
            Debug.LogErrorFormat("请确认版本号");
            return;
        }
        CopyLuaDir();

        InitBuilder();
        LoadBundleCombineConfig();
        Dictionary<string, BuildBundleData> bundleDatas = new Dictionary<string, BuildBundleData>();
        IndexFileContent.Clear();
        VersionFileContent.Clear();

        List<DirBundleInfo> dirList = new List<DirBundleInfo>();

        // ============================
        Queue<DirBundleInfo> dirQueue = new Queue<DirBundleInfo>();
        dirQueue.Enqueue(new DirBundleInfo(RES_TO_BUILD_PATH));
        while (dirQueue.Count > 0)
        {
            DirBundleInfo rootDirInfo = dirQueue.Dequeue();
            if (rootDirInfo.dir != RES_TO_BUILD_PATH)
            {
                if (combinePathDict.ContainsKey(rootDirInfo.dir))
                {
                    rootDirInfo.combine2Dir = rootDirInfo.dir;
                }
                dirList.Add(rootDirInfo);
            }

            foreach (string subDir in GetTopDirs(rootDirInfo.dir))
            {
                DirBundleInfo subDirInfo = new DirBundleInfo(subDir);
                subDirInfo.combine2Dir = rootDirInfo.combine2Dir;
                dirQueue.Enqueue(subDirInfo);

                Debug.LogFormat("Dir: {0}, Combine2Dir: {1}", subDirInfo.dir, subDirInfo.combine2Dir);
            }
        }

        foreach (DirBundleInfo dirInfo in dirList)
        {
            string[] files = GetFiles(dirInfo.dir, SearchOption.TopDirectoryOnly);
            if (files.Length > 0)
            {
                Debug.LogFormat("Dir: {0}, FileCount: {1}", dirInfo.dir, files.Length);
                string bundleDirName = dirInfo.BundleDirName;
                BuildBundleData bbData = null;
                if (bundleDatas.ContainsKey(bundleDirName))
                {
                    bbData = bundleDatas[bundleDirName];
                }
                else
                {
                    bbData = new BuildBundleData(GetBundleName(bundleDirName));
                    bundleDatas.Add(bundleDirName, bbData);
                }

                foreach (string file in files)
                {
                    bbData.AddAsset(file);
                }
            }
        }

        List<AssetBundleBuild> bundleBuildList = new List<AssetBundleBuild>();
        foreach (BuildBundleData data in bundleDatas.Values)
        {
            bundleBuildList.Add(data.Gen());
        }

        string index_file_path = string.Format("{0}{1}.txt", RES_TO_BUILD_PATH, "index");
        File.WriteAllText(index_file_path, IndexFileContent.ToString());
        AssetDatabase.ImportAsset(index_file_path);
        AssetDatabase.SaveAssets();
        AssetDatabase.Refresh();

        AssetBundleBuild indexBuild = new AssetBundleBuild();
        indexBuild.assetBundleName = "index";
        indexBuild.assetNames = new string[] { index_file_path };
        indexBuild.addressableNames = new string[] { "index" };
        bundleBuildList.Add(indexBuild);
        string bundleExportPath = string.Format("{0}/{1}/", Application.dataPath + "/../streaming", "Bundles");
        if (Directory.Exists(bundleExportPath))
        {
            Directory.Delete(bundleExportPath, true);
        }
        Directory.CreateDirectory(bundleExportPath);

        if (Directory.Exists(MANIFEST_FILES_PATH))
        {
            Directory.Delete(MANIFEST_FILES_PATH, true);
        }
        Directory.CreateDirectory(MANIFEST_FILES_PATH);

        BuildPipeline.BuildAssetBundles(bundleExportPath, bundleBuildList.ToArray(), BuildOption, EditorUserBuildSettings.activeBuildTarget);
        AssetDatabase.Refresh();
        DeleteLuaDir();
        AssetDatabase.Refresh();

        // VersionProfile

        List<VersionBundleInfo> versionBundleList = new List<VersionBundleInfo>();
        MoeVersionInfo versionInfo = new MoeVersionInfo();
        versionInfo.version = version;
        versionInfo.asset_date = DateTime.Now.ToString("yyyyMMddHHmm");
        string[] ab_files = Directory.GetFiles(bundleExportPath);
        foreach (string ab_file in ab_files)
        {
            if (Path.GetExtension(ab_file) == ".manifest")
            {
                string new_path = ab_file.Replace(bundleExportPath, MANIFEST_FILES_PATH);
                File.Move(ab_file, new_path);
            }
            else
            {

                Debug.LogFormat("BundleName: {0}", ab_file);
                var data = File.ReadAllBytes(ab_file);
                using (var abStream = new AssetBundleStream(ab_file, FileMode.Create))
                {
                    abStream.Write(data, 0, data.Length);
                }

                string md5 = GetMD5HashFromFile(ab_file);
                long size = GetFileSize(ab_file);
                string bundleName = string.Format("Bundles/{0}", Path.GetFileName(ab_file));
                VersionBundleInfo bInfo = new VersionBundleInfo();
                bInfo.bundle_name = bundleName;
                bInfo.md5 = md5;
                bInfo.size = size;
                versionBundleList.Add(bInfo);
            }
        }

        versionInfo.bundles = versionBundleList.ToArray();
        string versionInfoText = Newtonsoft.Json.JsonConvert.SerializeObject(versionInfo);

        File.WriteAllText(string.Format("{0}/{1}", bundleExportPath, "version.json"), versionInfoText);

        if (copyToStreaming)
        {
            CopyBundleToStreaming(bundleExportPath);
        }
        MoveToVersionDir(bundleExportPath, version);
        AssetDatabase.Refresh();
    }

    private static void MoveToVersionDir(string rootBundlePath, string version)
    {
        string destPath = rootBundlePath + "/" + version;
        Directory.CreateDirectory(destPath);
        destPath += "/Bundles";
        Directory.CreateDirectory(destPath);

        string[] files = GetFiles(rootBundlePath, SearchOption.TopDirectoryOnly);
        foreach (string file in files)
        {
            string fileName = System.IO.Path.GetFileName(file);
            string destFilePath = destPath + "/" + fileName;
            File.Move(file, destFilePath);
        }
    }

    private static void CopyBundleToStreaming(string bundleExportPath)
    {
        string destPath = Application.streamingAssetsPath + "/Bundles";
        if (Directory.Exists(destPath))
        {
            Directory.Delete(destPath, true);
        }

        MoeUtils.DirectoryCopy(bundleExportPath, destPath, true);
    }

    private static string[] GetFiles(string path, SearchOption so)
    {
        string[] files = Directory.GetFiles(path, "*", so);
        List<string> fileList = new List<string>();
        foreach (string file in files)
        {
            string ext = Path.GetExtension(file);
            if (ext == ".meta" || ext == ".DS_Store")
            {
                continue;
            }
            fileList.Add(file);
        }

        return fileList.ToArray();
    }

    class DirBundleInfo
    {
        public string dir;
        public string combine2Dir;

        public bool IsCombine
        {
            get
            {
                return !string.IsNullOrEmpty(combine2Dir);
            }
        }

        public string BundleDirName
        {
            get
            {
                if (IsCombine)
                {
                    return combine2Dir;
                }
                else
                {
                    return dir;
                }
            }
        }

        public DirBundleInfo(string dir, string combine2Dir = null)
        {
            this.dir = dir;
            this.combine2Dir = combine2Dir;
        }

    }

    class BundleCombineConfig
    {
        public string[] combieDirs;
    }

    private static void LoadBundleCombineConfig()
    {
        string path = Application.dataPath + RES_TO_BUILD_PATH.Replace("Assets", "") + "BundleCombineConfig.json";
        if (File.Exists(path))
        {
            string text = File.ReadAllText(path);
            if (!string.IsNullOrEmpty(text))
            {
                combineConfig = Newtonsoft.Json.JsonConvert.DeserializeObject<BundleCombineConfig>(text);
                if (combineConfig != null)
                {
                    Debug.LogFormat("Bundle合并配置成功!");
                    foreach (string cPath in combineConfig.combieDirs)
                    {
                        if (!combinePathDict.ContainsKey(cPath))
                        {
                            combinePathDict.Add(cPath, 0);
                        }
                    }
                }
            }
        }
    }
}

MoeVersionManager.cs 资源版本检查及 Bundle 更新逻辑

using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using BestHTTP;
using System;

public class MoeVersionManager : MoeSingleton<MoeVersionManager>
{
    const string REMOTE_URL = "这里改成自己的CDN域名或IP";
    static string VERSION_FILE_DIR;
    static string VERSION_FILE_PATH;
    static string IN_VERSION_FILE_PATH;

    private MoeVersionInfo currVersionInfo = null;
    private MoeVersionInfo remoteVersionInfo = null;
    private UpdateInfo updateInfo = null;

    private static OnVersionStateParam versionStateParam = new OnVersionStateParam();
    private static OnUpdateProgressParam updateProgressParam = new OnUpdateProgressParam();
    private static OnVersionMsgBoxParam msgBoxParam = new OnVersionMsgBoxParam();

    private enum EnProcessType
    {
        Normal,
        Fix,
    }

    private Action<EnProcessType> actionTryUnCompress = null;
    private Action<EnProcessType> actionUpdateVersionFile = null;
    private Action<EnProcessType> actionUpdateBundles = null;
    private Action<EnProcessType> actionCheckAssets = null;
    private Action<EnProcessType> actionForceUpdateVersionFile = null;



    protected override void InitOnCreate()
    {
        VERSION_FILE_DIR = Application.persistentDataPath + "/Bundles/";
        VERSION_FILE_PATH = Application.persistentDataPath + "/Bundles/version.json";
        IN_VERSION_FILE_PATH = Application.streamingAssetsPath + "/Bundles/version.json";
        Debug.LogFormat("{0}", VERSION_FILE_PATH);
        InitProcessChain();
        StartNormalProcess();
    }


    private void InitProcessChain()
    {
        this.actionTryUnCompress = (EnProcessType param) =>
        {
            Debug.LogFormat("Action>>> 解压: {0}", param);
            this.currVersionInfo = LoadVersionInfo(VERSION_FILE_PATH);
            // if (!CheckBundleCorrect())
            if (currVersionInfo == null)
            {
                UpdateUIState("正在解压资源");
                UnCompressBundle();
                this.currVersionInfo = LoadVersionInfo(VERSION_FILE_PATH);
            }
            else
            {
                // 判断是不是更新包,也就是StreamingAssets里的版本是否比Persistent版本高,如果高的话,再次解压Bundle
                MoeVersionInfo inVersionInfo = LoadVersionInfo(IN_VERSION_FILE_PATH);
                if (inVersionInfo != null)
                {
                    int[] inVersionDigit = inVersionInfo.GetVersionDigitArray();
                    int[] currVersionDigit = this.currVersionInfo.GetVersionDigitArray();
                    // if (inVersionInfo.GetVersionLong() > this.currVersionInfo.GetVersionLong())
                    if (inVersionDigit[0] > currVersionDigit[0] ||
                       inVersionDigit[1] > currVersionDigit[1] ||
                       inVersionDigit[2] > currVersionDigit[2])
                    {
                        // 包里的版本比Persistent的版本高,可能玩家进行了大版本更新,重新解压
                        Debug.LogFormat("包里的Bundle版本 > Persistent Bundle 版本,重新解压");
                        UpdateUIState("正在解压资源");
                        UnCompressBundle();
                        this.currVersionInfo = LoadVersionInfo(VERSION_FILE_PATH);
                    }
                    else
                    {
                        Debug.LogFormat("包里Bundle版本 <= Persistent Bundle版本,无需解压~");
                    }
                }
                else
                {
                    Debug.LogErrorFormat("逻辑错误,从StreamingAssets 中加载VersionInfo文件失败");
                }
            }
        };

        this.actionUpdateVersionFile = (EnProcessType param) =>
        {
            Debug.LogFormat("Action>>> 获取远程版本文件: {0}", param);
            StartCoroutine(TryUpdateVersion((bool ok, bool majorUpdate) =>
            {
                if (ok)
                {
                    if (majorUpdate)
                    {
                        // 调用商店
                        OnMsgBox("新的大版本已更新,请下载最新安装包!", "确定", () =>
                        {
                            JumpToDownloadMarket();
                        });
                    }
                    else
                    {
                        // 成功了,接下来更新Bundle
                        this.actionUpdateBundles?.Invoke(param);
                    }
                }
                else
                {
                    // 版本文件更新失败,弹窗询问
                    OnMsgBox("版本信息获取失败,请检查网络连接!", "重试", () =>
                    {
                        this.actionUpdateVersionFile?.Invoke(param);
                    });
                }
            }));
        };

        this.actionForceUpdateVersionFile = (EnProcessType param) =>
        {
            Debug.LogFormat("Action>>> 强制获取远程版本文件: {0}", param);
            TryDeleteBundleDir();
            TryCreateBundleDir();
            StartCoroutine(TryUpdateVersion((bool ok, bool majorUpdate) =>
            {
                if (ok)
                {
                    if (majorUpdate)
                    {
                        // 调用商店
                        OnMsgBox("新的大版本已更新,请下载最新安装包!", "确定", () =>
                        {
                            JumpToDownloadMarket();
                        });
                    }
                    else
                    {
                        // 成功了,接下来更新Bundle
                        this.actionUpdateBundles?.Invoke(param);
                    }
                }
                else
                {
                    // 版本文件更新失败,弹窗询问
                    OnMsgBox("版本信息获取失败,请检查网络连接!", "重试", () =>
                    {
                        this.actionForceUpdateVersionFile?.Invoke(param);
                    });
                }
            }, true));
        };

        this.actionUpdateBundles = (EnProcessType param) =>
        {
            Debug.LogFormat("Action>>> 更新Bundle: {0}", param);
            StartCoroutine(TryUpdateBundle((bool ok) =>
            {
                if (ok)
                {
                    // 成功了,接下来检查资源,
                    this.actionCheckAssets?.Invoke(param);
                }
                else
                {
                    OnMsgBox("资源下载失败,请检查网络连接!", "重试", () =>
                    {
                        this.actionUpdateBundles(param);
                    });
                }
            }));
        };

        this.actionCheckAssets = (EnProcessType param) =>
        {
            Debug.LogFormat("Action>>> 校对资源: {0}", param);
            if (!CheckBundleCorrect())
            {
                // 更新完了,本地Bundle还是不对
                Debug.LogFormat("更新完Bundle后,发现文件不对");
                if (param == EnProcessType.Normal)
                {
                    OnMsgBox("资源有错误,请修复客户端!", "修复", () =>
                    {
                        this.actionForceUpdateVersionFile?.Invoke(EnProcessType.Fix);
                    });
                }
                else
                {
                    OnMsgBox("客户端修复失败,请重新下载安装包!", "确定", () =>
                    {
                        JumpToDownloadMarket();
                    });
                }
            }
            else
            {
                UpdateUIState("进入游戏");
                MoeEventManager.Inst.SendEvent(EventID.Event_OnUpdateEnd);
            }
        };
    }



    // 跳转到下载商店
    private void JumpToDownloadMarket()
    {
        Application.OpenURL("https://taptap.com");
    }

    private void StartNormalProcess()
    {
        TryCreateBundleDir();
        this.actionTryUnCompress?.Invoke(EnProcessType.Normal);
        this.actionUpdateVersionFile?.Invoke(EnProcessType.Normal);
    }

    private void StartFixProcess()
    {
        this.actionForceUpdateVersionFile(EnProcessType.Fix);
    }


    private MoeVersionInfo LoadVersionInfo(string path)
    {
        Debug.LogFormat("加载 Version 文件: {0}", path);
        try
        {
            if (System.IO.File.Exists(path))
            {
                string text = System.IO.File.ReadAllText(path);
                if (!string.IsNullOrEmpty(text))
                {
                    MoeVersionInfo vInfo = Newtonsoft.Json.JsonConvert.DeserializeObject<MoeVersionInfo>(text);
                    if (vInfo != null)
                    {
                        Debug.LogFormat("Version 信息加载成功: {0}", vInfo.version);
                        return vInfo;
                    }
                }
                else
                {
                    Debug.LogFormat("Version 文件内容为空");
                }
            }
            else
            {
                Debug.LogFormat("Version 文件不存在");
            }
        }
        catch (System.Exception e)
        {
            Debug.LogErrorFormat("读取Version文件出错: {0}", e.ToString());
        }

        return null;
    }

    /// <summary>
    /// 从 StreamingAssets 里将Bundle拷贝到 Persistent 目录里 
    /// </summary>
    private void UnCompressBundle()
    {
        TryDeleteBundleDir();
        TryCreateBundleDir();
        Debug.LogFormat("尝试从 Steaming 拷贝Bundle 到 Persistent");
        try
        {
            if (System.IO.File.Exists(IN_VERSION_FILE_PATH))
            {
                string text = System.IO.File.ReadAllText(IN_VERSION_FILE_PATH);
                Debug.LogFormat("Text: {0}", text);
                MoeVersionInfo inVersionInfo = Newtonsoft.Json.JsonConvert.DeserializeObject<MoeVersionInfo>(text);
                if (inVersionInfo != null)
                {
                    // 拷贝 Bundle
                    foreach (VersionBundleInfo bundleInfo in inVersionInfo.bundles)
                    {
                        string srcFilePath = string.Format("{0}/{1}", Application.streamingAssetsPath, bundleInfo.bundle_name);
                        string destFilePath = string.Format("{0}/{1}", Application.persistentDataPath, bundleInfo.bundle_name);
                        Debug.LogFormat("拷贝Bundle, {0} -> {1}", srcFilePath, destFilePath);
                        System.IO.File.Copy(srcFilePath, destFilePath, true);
                    }

                    // 拷贝 Version文件
                    System.IO.File.Copy(IN_VERSION_FILE_PATH, VERSION_FILE_PATH, true);
                }
            }
            else
            {
                Debug.LogErrorFormat("解压失败,StreamingAssets 中没有 Version 文件");
            }
        }
        catch (System.Exception e)
        {
            Debug.LogErrorFormat("Bundle拷贝出错! {0}", e.ToString());
        }
    }

    public void TryCreateBundleDir()
    {
        if (!System.IO.Directory.Exists(VERSION_FILE_DIR))
        {
            Debug.LogFormat("创建 Persistent Bundle 目录");
            System.IO.Directory.CreateDirectory(VERSION_FILE_DIR);
        }
        else
        {
            Debug.LogFormat("Persistent Bundle 目录已存在,不需要创建");
        }
    }

    public void TryDeleteBundleDir()
    {
        if (System.IO.Directory.Exists(VERSION_FILE_DIR))
        {
            System.IO.Directory.Delete(VERSION_FILE_DIR, true);
        }
    }

    private string GetLocalBundleMD5(string bundle_name)
    {
        string bundleFilePath = string.Format("{0}/{1}", Application.persistentDataPath, bundle_name);
        if (System.IO.File.Exists(bundleFilePath))
        {
            string md5 = MoeUtils.GetMD5HashFromFile(bundleFilePath);
            return md5;
        }

        return null;
    }

    /// <summary>
    /// 检查当前的Bundle是否正确 
    /// </summary>
    /// <returns></returns>
    public bool CheckBundleCorrect()
    {
        if (currVersionInfo != null)
        {
            foreach (VersionBundleInfo bundleInfo in currVersionInfo.bundles)
            {
                string bundleFilePath = string.Format("{0}/{1}", Application.persistentDataPath, bundleInfo.bundle_name);
                bool matched = false;

                if (GetLocalBundleMD5(bundleInfo.bundle_name) == bundleInfo.md5)
                {
                    matched = true;
                }
                else
                {
                    Debug.LogErrorFormat("MD5 不匹配: {0}, FileMD5: {1}, bInfoMD5: {2}", bundleInfo.bundle_name, GetLocalBundleMD5(bundleInfo.bundle_name), bundleInfo.md5);
                }

                if (!matched)
                {
                    return false;
                }
            }

            Debug.LogFormat("本地Bundle文件检完全正确");
            return true;
        }
        else
        {
            return false;
        }
    }

    /// <summary>
    /// 
    /// </summary>
    /// <param name="callback"><是否成功,是否是强更></param>
    /// <param name="force"></param>
    /// <returns></returns>
    private IEnumerator TryUpdateVersion(System.Action<bool, bool> callback, bool force = false)
    {
        UpdateUIState("正在检查更新");
        this.remoteVersionInfo = null;
        this.updateInfo = null;
        string remoteVersionUrl = REMOTE_URL + "/fishing/version.json";
        Debug.LogFormat("开始下载远程 Version 文件: {0}", remoteVersionUrl);
        HTTPRequest request = new HTTPRequest(new System.Uri(remoteVersionUrl), false, true, null).Send();

        while (request.State < HTTPRequestStates.Finished)
        {
            yield return new WaitForSeconds(0.1f);
        }

        if (request.State == HTTPRequestStates.Finished &&
         request.Response.IsSuccess)
        {
            string remoteVersionText = request.Response.DataAsText;
            if (!string.IsNullOrEmpty(remoteVersionText))
            {
                MoeVersionInfo remoteVersionInfo = Newtonsoft.Json.JsonConvert.DeserializeObject<MoeVersionInfo>(remoteVersionText);
                if (remoteVersionInfo != null)
                {
                    Debug.LogFormat("远程 Version 文件解析成功, Version: {0}", remoteVersionInfo.version);
                    // 判断是否要更新

                    int appMajorVersion = AppConfig.Inst.GetMajorVersion();
                    // 判断是否要强更
                    int remoteMajor = remoteVersionInfo.GetMajorVersion();
                    if (remoteMajor > appMajorVersion)
                    {
                        // 这是一个需要强更的版本,需要提示用户去商店下载
                        Debug.LogFormat("发现强更版本,需要重新下包,进行大版本更新!");
                        callback?.Invoke(true, true);
                        callback = null;

                        UpdateUIState("新的大版本已更新,请下载最新安装包!");
                    }
                    else
                    {
                        // 强制修复
                        if (force)
                        {
                            this.remoteVersionInfo = remoteVersionInfo;
                            List<VersionBundleInfo> updateBundleList = new List<VersionBundleInfo>();
                            updateBundleList.AddRange(remoteVersionInfo.bundles);
                            // 有需要更新的包
                            this.updateInfo = new UpdateInfo();
                            this.updateInfo.remoteVersionInfo = remoteVersionInfo;
                            this.updateInfo.updateBundleList = updateBundleList;
                            Debug.LogFormat("强制更新,有需要更新的Bundle");
                            callback?.Invoke(true, false);
                            callback = null;
                        }
                        else
                        {
                            // 正常更新
                            int[] remoteVersionDigit = remoteVersionInfo.GetVersionDigitArray();
                            int[] currVersionDigit = this.currVersionInfo == null ? new int[] { 0, 0, 0 } : this.currVersionInfo.GetVersionDigitArray();
                            // if (this.currVersionInfo == null || remoteVersionInfo.GetVersionLong() > this.currVersionInfo.GetVersionLong())
                            if (remoteVersionDigit[0] > currVersionDigit[0] ||
                               remoteVersionDigit[1] > currVersionDigit[1] ||
                               remoteVersionDigit[2] > currVersionDigit[2])
                            {
                                Debug.LogFormat("这次需要热更新");
                                this.remoteVersionInfo = remoteVersionInfo;
                                List<VersionBundleInfo> updateBundleList = new List<VersionBundleInfo>();

                                foreach (VersionBundleInfo rBInfo in remoteVersionInfo.bundles)
                                {
                                    if (GetLocalBundleMD5(rBInfo.bundle_name) != rBInfo.md5)
                                    {
                                        updateBundleList.Add(rBInfo);
                                    }
                                }

                                // 有需要更新的包
                                this.updateInfo = new UpdateInfo();
                                this.updateInfo.remoteVersionInfo = remoteVersionInfo;
                                this.updateInfo.updateBundleList = updateBundleList;
                                Debug.LogFormat("有需要更新的Bundle");
                                callback?.Invoke(true, false);
                                callback = null;
                            }
                            else
                            {
                                Debug.LogFormat("远程版本号 {0} <= 本地版本号 {1},无需更新!", remoteVersionInfo.version, this.currVersionInfo.version);
                                callback?.Invoke(true, false);
                                callback = null;
                            }
                        }
                    }
                }
                else
                {
                    Debug.LogErrorFormat("远程 Version 文件反序列化失败: {0}", remoteVersionText);
                }
            }
            else
            {
                Debug.LogErrorFormat("远程 Version 文件内容为空");
            }
        }
        else
        {
            Debug.LogErrorFormat("远程 Version 文件下载失败: {0}, {1}", request.State, request.Response.StatusCode);
        }

        BestHTTP.PlatformSupport.Memory.BufferPool.Release(request.Response.Data);
        callback?.Invoke(false, false);
    }

    private IEnumerator TryUpdateBundle(System.Action<bool> callback)
    {
        if (this.remoteVersionInfo != null && this.updateInfo != null)
        {
            long totalSize = 0;
            foreach (VersionBundleInfo bInfo in this.updateInfo.updateBundleList)
            {
                totalSize += bInfo.size;
            }

            UpdateUIDownload(totalSize, 0);
            long downloadedSize = 0;
            bool hasError = false;

            foreach (VersionBundleInfo bInfo in this.updateInfo.updateBundleList)
            {
                Debug.LogFormat("Bundle信息 {0} | {1}", GetLocalBundleMD5(bInfo.bundle_name), bInfo.md5);
                if (GetLocalBundleMD5(bInfo.bundle_name) != bInfo.md5)
                {
                    string remoteBundleUrl = string.Format("{0}/fishing/{1}/{2}", REMOTE_URL, this.updateInfo.remoteVersionInfo.version, bInfo.bundle_name);
                    Debug.LogFormat("开始更新Bundle: {0}", remoteBundleUrl);
                    HTTPRequest request = new HTTPRequest(new System.Uri(remoteBundleUrl), false, true, null).Send();
                    while (request.State < HTTPRequestStates.Finished)
                    {

                        yield return new WaitForSeconds(0.1f);
                    }

                    if (request.State == HTTPRequestStates.Finished && request.Response.IsSuccess)
                    {
                        downloadedSize += bInfo.size;
                        string bundleWritePath = Application.persistentDataPath + "/" + bInfo.bundle_name;
                        // 写入Bundle文件
                        System.IO.File.WriteAllBytes(bundleWritePath, request.Response.Data);
                        Debug.LogFormat("{0} 更新完成", bInfo.bundle_name);
                        UpdateUIDownload(totalSize, downloadedSize);
                    }
                    else
                    {
                        Debug.LogErrorFormat("{0} 下载出错: {1}, {2}", bInfo.bundle_name, request.State, request.Response.IsSuccess);
                        callback?.Invoke(false);
                        callback = null;
                        hasError = true;
                        break;
                    }
                    yield return null;
                    BestHTTP.PlatformSupport.Memory.BufferPool.Release(request.Response.Data);
                }
                else
                {
                    Debug.LogFormat("!!!!!!!!!!! 本地已存在需要更新的 {0},跳过下载", bInfo.bundle_name);
                    downloadedSize += bInfo.size;
                    UpdateUIDownload(totalSize, downloadedSize);
                }
            }

            if (!hasError)
            {
                Debug.LogFormat("写入远程 Version 文件");
                // 最后写入Version文件
                string versionText = Newtonsoft.Json.JsonConvert.SerializeObject(this.updateInfo.remoteVersionInfo);
                System.IO.File.WriteAllText(VERSION_FILE_PATH, versionText);
                yield return null;

                // 重新加载一遍本地文件
                this.currVersionInfo = LoadVersionInfo(VERSION_FILE_PATH);
                UpdateUIState("更新完成");
            }
        }
        else
        {
            Debug.LogFormat("无需要更新,前置数据不足: remoteVersionInfo is Null: {0}, updateInfo is Null: {1}", this.remoteVersionInfo == null, this.updateInfo == null);
        }
        callback?.Invoke(true);
    }

    private class UpdateInfo
    {
        public MoeVersionInfo remoteVersionInfo;
        public List<VersionBundleInfo> updateBundleList;
    }

    private void UpdateUIState(string msg)
    {
        versionStateParam.state = msg;
        MoeEventManager.Inst.SendEvent(EventID.Event_OnVersionState, versionStateParam);
    }

    private void UpdateUIDownload(long total, long downloaded)
    {
        updateProgressParam.totalUpdateSize = total;
        updateProgressParam.nowUpdatedSize = downloaded;
        MoeEventManager.Inst.SendEvent(EventID.Event_OnUpdateProgress, updateProgressParam);
    }

    private void OnMsgBox(string msg, string btnText, System.Action callback)
    {
        msgBoxParam.msg = msg;
        msgBoxParam.btnText = btnText;
        msgBoxParam.callback = callback;
        MoeEventManager.Inst.SendEvent(EventID.Event_OnVersionMsgBox, msgBoxParam);
    }
}

MoeReleaseAssetBundleManager.cs 运行时资源管理器

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;

public class MoeReleaseAssetBundleManager : IMoeResAgent
{
    const string INDEX_FILE = "index";
    private Dictionary<int, Object> _resources = new Dictionary<int, Object>();
    private Dictionary<int, AssetBundle> _bundles = new Dictionary<int, AssetBundle>();
    private Dictionary<int, string> _bundles_index = new Dictionary<int, string>();

    private AssetBundleManifest _manifest = null;

    public void Init()
    {
        InitAndLoadManifestFile();
        InitAndLoadIndexFile();
    }

    private void InitAndLoadIndexFile()
    {
        _bundles_index.Clear();
        AssetBundle indexBundle = LoadBundleSync(INDEX_FILE);
        TextAsset ta = indexBundle.LoadAsset<TextAsset>(INDEX_FILE);
        if (ta == null)
        {
            Debug.LogErrorFormat("Index 文件加载失败!");
            return;
        }

        string[] lines = ta.text.Split('\n');
        char[] trim = new char[] { '\r', '\n' };

        if (lines != null && lines.Length > 0)
        {
            for (int i = 0; i < lines.Length; ++i)
            {
                string line = lines[i].Trim(trim);
                if (string.IsNullOrEmpty(line))
                {
                    continue;
                }

                string[] pair = line.Split(':');
                if (pair.Length != 2)
                {
                    Debug.LogErrorFormat("Index 行数据有问题: {0}", line);
                    continue;
                }

                int hash = pair[0].GetHashCode();
                if (_bundles_index.ContainsKey(hash))
                {
                    Debug.LogErrorFormat("Index 文件中存在相同的路径: {0}", pair[0]);
                }
                else
                {
                    _bundles_index.Add(hash, pair[1]);
                }
            }
        }

        if (_bundles_index.Count != 0)
        {
            Debug.LogFormat("Bundle Index 初始化完成");
        }
        else
        {
            Debug.LogErrorFormat("Index 文件数据为空");
        }

        indexBundle.Unload(true);
        indexBundle = null;
    }

    private void InitAndLoadManifestFile()
    {
        AssetBundle manifestBundle = LoadBundleSync("Bundles");
        _manifest = manifestBundle.LoadAsset<AssetBundleManifest>("AssetBundleManifest");
        manifestBundle.Unload(false);
        manifestBundle = null;
    }

    public T LoadAsset<T>(string path) where T : UnityEngine.Object
    {
        UnityEngine.Object obj = Load(path);
        if (obj != null)
        {
            return obj as T;
        }

        return null;
    }

    public byte[] LoadLuaCode(string path)
    {
        string assetPath = string.Format("LuaScripts/{0}", path);
        TextAsset ta = LoadAsset<TextAsset>(assetPath);
        if (ta != null)
        {
            return ta.bytes;
        }
        return null;
    }



    private UnityEngine.Object Load(string assetPath)
    {
        if (string.IsNullOrEmpty(assetPath))
        {
            return null;
        }

        int pathHash = assetPath.GetHashCode();
        Object obj = null;
        if (_resources.TryGetValue(pathHash, out obj))
        {
            if (obj == null)
            {
                _resources.Remove(pathHash);
            }
            else
            {
                return obj;
            }
        }

        AssetLoadInfo loadInfo = GetAssetLoadInfo(assetPath);
        // 加载依赖Bundle

        for (int i = 0; i < loadInfo.dependencies.Length; ++i)
        {
            if (LoadBundleSync(loadInfo.dependencies[i]) == null)
            {
                Debug.LogErrorFormat("加载依赖Bundle出错,资源 {0}, 主Bundle:{1}, 依赖:{2}", assetPath, loadInfo.mainBundle, loadInfo.dependencies[i]);
                return null;
            }
        }

        AssetBundle mainBundle = LoadBundleSync(loadInfo.mainBundle);
        if (mainBundle == null)
        {
            Debug.LogErrorFormat("加载主Bundle出错,资源:{0},主Bundle:{1}", assetPath, loadInfo.mainBundle);
            return null;
        }

        obj = mainBundle.LoadAsset(assetPath);

        if (obj == null)
        {
            Debug.LogErrorFormat("从Bundle加载资源失败,资源:{0},主Bundle:{1}", assetPath, loadInfo.mainBundle);
            return null;
        }

        _resources.Add(pathHash, obj);
        return obj;
    }

    private AssetBundle LoadBundleSync(string bundleName)
    {
        int bundleHash = bundleName.GetHashCode();
        AssetBundle bundle = null;

        if (!_bundles.TryGetValue(bundleHash, out bundle))
        {
#if UNITY_EDITOR
            string rootPath = Application.dataPath + "/../streaming";
#else
            string rootPath = Application.persistentDataPath;
#endif
            string bundleLoadPath = System.IO.Path.Combine(rootPath, string.Format("Bundles/{0}", bundleName));
            Debug.LogFormat(">>>> 加载Bundle: {0}", bundleLoadPath);

            using (var fileStream = new AssetBundleStream(bundleLoadPath, FileMode.Open, FileAccess.Read, FileShare.None, 1024 * 4, false))
            {
                bundle = AssetBundle.LoadFromStream(fileStream);
            }

            // bundle = AssetBundle.LoadFromFile(bundleLoadPath);

            if (bundle != null)
            {
                _bundles.Add(bundleHash, bundle);
            }
            else
            {
                Debug.LogErrorFormat("Bundle 加载失败 {0}, LoadPath: {1}", bundleName, bundleLoadPath);
            }
        }
        else
        {
            // Debug.LogFormat("Bundle {0} 已加载,直接返回", bundleName);
        }

        return bundle;
    }

    private string GetAssetOfBundleFileName(string assetPath)
    {
        int assetHash = assetPath.GetHashCode();
        string bundleName;
        if (_bundles_index.TryGetValue(assetHash, out bundleName))
        {
            return bundleName;
        }

        return string.Empty;
    }

    private AssetLoadInfo GetAssetLoadInfo(string assetPath)
    {
        AssetLoadInfo loadInfo = new AssetLoadInfo();
        loadInfo.assetPath = assetPath;
        loadInfo.mainBundle = GetAssetOfBundleFileName(assetPath);
        loadInfo.dependencies = _manifest.GetAllDependencies(loadInfo.mainBundle);
        return loadInfo;
    }


    private class AssetLoadInfo
    {
        public string assetPath;
        public string mainBundle;
        public string[] dependencies;
    }
}

萌一小栈

欢迎关注微信公众号 萌一小栈,博客文章同步推送