Unity热更新之AB包上传下载

目录

目录

一、概述

1、前置知识点

2、关于本文章

本文章是唐老狮的AB包课程的学习笔记,经过个人的简单修改和梳理而成,主要内容如下:

  • 上传
    • MD5码。
    • AB包对比文件生成。
    • AB包上传到资源服务器。
    • 最终会制作一个AB包上传工具
  • 下载
    • 从资源服务器下载文件。
    • 解析本地和远端的对比文件,然后进行比对。
    • 根据比对结果,下载、更新或删除。
    • 最终会制作一个客户端的AB包下载管理器

课程主要讲解的是AB包上传下载以及热更新这一套流程,课程的代码可能不是特别完美,还是有一些缺陷在的,但是我们学习的目的就是要理解这个制作的核心思路,具体优化部分可以由我们自己完成。

二、上传

(一)MD5码

1、MD5码概念

MD5(Message-Digest Algorithm 5)即信息摘要算法第五版的简称,它是一种广泛使用的密码散列函数,可以生成出一个128位(16个字节)的散列值,用于确保信息的完整一致性

将数据经过MD5算法计算过后,无论传入的数据有多大,都会生成一个固定长度(16字节)的信息摘要值。相同的数据,每次经过MD5算法计算后的结果都会是一样的,如果数据变化,MD5码将会发生变化。因此我们可以利用MD5码作为文件的唯一标识,判断文件内容是否变化。

2、获取MD5码

获取MD5码需要用到MD5这个类,调用如下两个API:

MD5 md5 = MD5.Create(); // 创建MD5实例
md5.ComputeHash(Stream); // 获取指定Stream的哈希值,返回字节数组

由于MD5返回结果是byte[],我们需要将其转成字符串,可以封装如下方法:

private string GetMD5(string filePath)
{
    using (FileStream fileStream = File.Open(filePath, FileMode.Open))
    {
        // 获取MD5码字节数组
        MD5 md5 = MD5.Create();
        byte[] hash = md5.ComputeHash(fileStream);

        // 将MD5码转成十六进制字符串
        StringBuilder stringBuilder = new StringBuilder();
        for (int i = 0; i < hash.Length; i++)
        {
            stringBuilder.Append(hash[i].ToString("x2")); // 十六进制小写
        }

        return stringBuilder.ToString();
    }
}

(二)AB包上传

1、搭建FTP服务器

在FTP文件传输那篇文章里讲过了,如果是本地的话,可以用Serv-U这类软件进行FTP服务器的搭建。或者也可以自己搭建FTP服务器,当然这里还是推荐直接用别人做好的第三方软件进行搭建。

需要创建两个账户:

  • 用于上传FTP的管理员账号
  • 用于下载FTP的匿名账号(只有读权限)

2、生成资源对比文件

现在让我们制作一个工具,用于生成AB包资源对比文件。

对比文件中,每个AB包之间都用 | 分隔,对于每个AB包,需要记录AB包名,文件大小,MD5码。

在Editor文件夹中,创建如下脚本,对比文件的工具代码如下:

/// <summary>
/// AB包资源对比文件工具
/// </summary>
public class AssetBundleCompareTool
{
    // AB包路径
    private static string ASSET_BUNDLE_PATH = Application.dataPath + "/Arts/AssetBundle/PC/";
    // 对比文件名
    private static string COMPARE_INFO_FILE_NAME = "compareInfo.txt";

    [MenuItem("AB包工具/创建对比文件")]
    public static void CreateAssetBundleCompareFile()
    {
        // 获取所有AB包文件
        DirectoryInfo directoryInfo = Directory.CreateDirectory(ASSET_BUNDLE_PATH);
        FileInfo[] fileInfos = directoryInfo.GetFiles();

        // 获取并拼接AB包的相关信息
        StringBuilder abCompareInfo = new StringBuilder();
        foreach (FileInfo fileInfo in fileInfos)
        {
            // 该文件夹下,没有后缀的文件是AB包
            if (fileInfo.Extension == "")
            {
                abCompareInfo.Append($"{fileInfo.Name} {fileInfo.Length} {GetMD5(fileInfo.FullName)}|");
            }
        }
        if (abCompareInfo.Length > 0)
        {
            abCompareInfo.Remove(abCompareInfo.Length - 1, 1); // 去掉最后一个"|"
        }

        // 将信息存储为对比文件
        File.WriteAllText(ASSET_BUNDLE_PATH + COMPARE_INFO_FILE_NAME, abCompareInfo.ToString());

        AssetDatabase.Refresh();
        Debug.Log("AB包对比文件生成成功");
    }

    /// <summary>
    /// 获取文件MD5码
    /// </summary>
    /// <param name="filePath">文件路径</param>
    /// <returns>MD5码</returns>
    public static string GetMD5(string filePath)
    {
        using (FileStream fileStream = File.Open(filePath, FileMode.Open))
        {
            // 获取MD5码字节数组
            MD5 md5 = MD5.Create();
            byte[] hash = md5.ComputeHash(fileStream);

            // 将MD5码转成十六进制字符串
            StringBuilder stringBuilder = new StringBuilder();
            for (int i = 0; i < hash.Length; i++)
            {
                stringBuilder.Append(hash[i].ToString("x2")); // 十六进制小写
            }

            return stringBuilder.ToString();
        }
    }
}

3、上传AB包

现在我们继续制作一个工具,用于将AB包及其比对文件上传到FTP服务器。

在Editor文件夹中,创建如下脚本,上传AB包的工具代码如下:

/// <summary>
/// AB包上传工具
/// </summary>
public class AssetBundleUploadTool
{
    // AB包路径
    private static string ASSET_BUNDLE_PATH = Application.dataPath + "/Arts/AssetBundle/PC/";
    // 对比文件名
    private static string COMPARE_INFO_FILE_NAME = "compareInfo.txt";
    // FTP相关
    private static string FTP_PATH = "ftp://127.0.0.1/AssetBundle/PC/";
    private static string USER_NAME = "pmm";
    private static string PASSWORD = "123456";

    [MenuItem("AB包工具/上传AB包")]
    public static void UploadAssetBundle()
    {
        DirectoryInfo directoryInfo = Directory.CreateDirectory(ASSET_BUNDLE_PATH);
        FileInfo[] fileInfos = directoryInfo.GetFiles();

        foreach (FileInfo fileInfo in fileInfos)
        {
            // 将比对文件和AB包进行上传
            if (fileInfo.Extension == "" || fileInfo.Name == COMPARE_INFO_FILE_NAME)
            {
                FtpUploadFile(fileInfo.FullName, fileInfo.Name);
            }
        }

    }

    private async static void FtpUploadFile(string filePath, string fileName)
    {
        await Task.Run(() =>
        {
            try
            {
                // 创建FTP请求和相关设置
                FtpWebRequest request = FtpWebRequest.Create(new Uri(FTP_PATH + fileName)) as FtpWebRequest;
                request.Credentials = new NetworkCredential(USER_NAME, PASSWORD);
                request.Proxy = null;
                request.KeepAlive = false;
                request.Method = WebRequestMethods.Ftp.UploadFile;
                request.UseBinary = true;

                // 上传文件
                using (Stream uploadStream = request.GetRequestStream())
                {
                    using (FileStream fileStream = File.OpenRead(filePath))
                    {
                        // 读取本地要上传的文件
                        byte[] bytes = new byte[2048];
                        // 写入到目标文件
                        int length;
                        while ((length = fileStream.Read(bytes, 0, bytes.Length)) > 0)
                        {
                            uploadStream.Write(bytes, 0, length);
                        }
                    }
                }

                // 接收响应
                using (FtpWebResponse response = request.GetResponse() as FtpWebResponse)
                {
                    Debug.Log($"上传完成 [{response.StatusCode}]:{fileName}");
                }

            }
            catch (Exception ex)
            {
                Debug.LogWarning($"{fileName} 上传失败:{ex.Message}");
            }
        });
    }
}

三、下载

接下来实现AB包的下载部分。

这里将实现一个AB包管理器,用于AB包的下载,当然这里并没有实现资源比对的更新,目前只是单纯的下载,更新将在后面章节进行讲解(即管理器完整部分在"更新"章节会讲解)。这里主要实现了这几个功能:

  • DownloadCompareInfoFile:下载AB包资源对比文件。
  • LoadRemoteCompareInfo:用于解析下载的资源对比文件。
  • DownloadAssetBundleFile:下载所有AB包文件(这里暂时没有比对,目前是全部AB包都进行下载)。
  • DownloadFile:用于从FTP服务器下载文件的辅助方法。
/// <summary>
/// AB包管理器
/// </summary>
public class AssetBundleManager : MonoBehaviour
{
    public static AssetBundleManager Instance { get; private set; }
    private void Awake()
    {
        Instance = this;
    }

    // 对比文件名
    private static string COMPARE_INFO_FILE_NAME = "compareInfo.txt";
    // FTP相关
    private static string FTP_PATH = "ftp://127.0.0.1/AssetBundle/PC/";
    private static string USER_NAME = "pmm";
    private static string PASSWORD = "123456";

    /// <summary>
    /// AB包信息类
    /// </summary>
    private class AssetBundleInfo
    {
        public string name; // 包名
        public long size; // 大小
        public string md5; // md5码
        public AssetBundleInfo(string name, long size, string md5)
        {
            this.name = name;
            this.size = size;
            this.md5 = md5;
        }
    }

    // 存储远端下载的AB包信息
    private Dictionary<string, AssetBundleInfo> remoteAssetBundleInfos = new Dictionary<string, AssetBundleInfo>();
    // 待下载AB包列表
    private List<string> downloadList = new List<string>();

    /// <summary>
    /// 下载对比文件
    /// </summary>
    /// <param name="completeCallback">下载完成的回调,若下载成功则为true,否则为false</param>
    public async void DownloadCompareInfoFile(Action<bool> completeCallback = null)
    {
        // 一些变量
        string downloadPath = Application.persistentDataPath + "/" + COMPARE_INFO_FILE_NAME; // 下载路径
        bool isDone = false; // 是否下载成功
        int retryCount = 5; // 重新下载次数

        // 下载对比文件
        while (!isDone && retryCount > 0)
        {
            await Task.Run(() =>
            {
                isDone = DownloadFile(COMPARE_INFO_FILE_NAME, downloadPath);
            });

            retryCount--;
        }

        completeCallback?.Invoke(isDone);
    }

    /// <summary>
    /// 解析远端对比文件
    /// </summary>
    public void LoadRemoteCompareInfo()
    {
        string downloadPath = Application.persistentDataPath + "/" + COMPARE_INFO_FILE_NAME; // 文件路径

        string compareInfo = File.ReadAllText(downloadPath);
        string[] assetBundleInfos = compareInfo.Split("|");
        // 解析每个AB包
        foreach (string assetBundleInfo in assetBundleInfos)
        {
            string[] infos = assetBundleInfo.Split(" ");
            // 记录AB包信息,方便进行对比
            remoteAssetBundleInfos.Add(infos[0], new AssetBundleInfo(infos[0], long.Parse(infos[1]), infos[2]));
        }
    }

    /// <summary>
    /// 下载AB包
    /// </summary>
    /// <param name="completeCallback">下载完成的回调,若全部下载成功则为true,否则为false</param>
    /// <param name="progressCallback">下载进度回调,每次下载AB包完成都会调用,参数分别为当前下载数和最大下载数</param>
    public async void DownloadAssetBundleFile(Action<bool> completeCallback = null, Action<int,int> progressCallback = null)
    {
        // 将需要下载的AB包添加进待下载列表
        foreach (string fileName in remoteAssetBundleInfos.Keys)
        {
            downloadList.Add(fileName); // 在这之前可以进行比对,这里暂时先全部下载
        }

        // 一些变量
        string downloadPath = Application.persistentDataPath + "/"; // 下载路径
        bool isDone = false; // 是否下载成功
        List<string> removeList = new List<string>(); // 记录下载成功的AB包,需要被移除
        int retryCount = 5; // 重新下载次数
        int downloadCount = 0; // 已下载的AB包数量
        int maxDownloadCount = downloadList.Count; // 需要下载的AB包数量

        // 下载文件,下载失败后会重新下载,直至下载完毕 或者 超过最大重新下载次数
        while (downloadList.Count > 0 && retryCount > 0)
        {
            for (int i = 0; i < downloadList.Count; i++)
            {
                // 下载
                isDone = false;
                string fileName = downloadList[i];
                await Task.Run(() =>
                {
                    isDone = DownloadFile(fileName, downloadPath + fileName);
                });

                // 下载成功则添加进待移除列表
                if (isDone)
                {
                    progressCallback?.Invoke(++downloadCount, maxDownloadCount);
                    removeList.Add(fileName);
                }
            }

            // 移除下载成功的AB包
            for (int i = 0; i < removeList.Count; i++)
            {
                downloadList.Remove(removeList[i]);
            }

            retryCount--;
        }

        // 调用回调,全部下载成功则返回true,否则为false
        completeCallback?.Invoke(downloadList.Count == 0);
    }

    /// <summary>
    /// 下载文件
    /// </summary>
    /// <param name="fileName">文件名</param>
    /// <param name="savePath">下载保存的路径</param>
    private bool DownloadFile(string fileName, string savePath)
    {
        try
        {
            // 创建FTP请求和相关设置
            FtpWebRequest request = FtpWebRequest.Create(new Uri(FTP_PATH + fileName)) as FtpWebRequest;
            request.Credentials = new NetworkCredential(USER_NAME, PASSWORD); // 有匿名账号可以不设置凭证,这里为了安全还是设置了凭证
            request.Proxy = null;
            request.KeepAlive = false;
            request.Method = WebRequestMethods.Ftp.DownloadFile;
            request.UseBinary = true;

            // 获取响应
            using (FtpWebResponse response = request.GetResponse() as FtpWebResponse)
            {
                // 获取下载流
                using (Stream downloadStream = response.GetResponseStream())
                {
                    // 将资源服务器的文件写入保存路径
                    using (FileStream fileStream = File.Create(savePath))
                    {
                        // 读取本地要上传的文件
                        byte[] bytes = new byte[2048];
                        // 写入到目标文件
                        int length;
                        while ((length = downloadStream.Read(bytes, 0, bytes.Length)) > 0)
                        {
                            fileStream.Write(bytes, 0, length);
                        }
                    }
                }

                Debug.Log($"下载完成 [{response.StatusCode}]:{fileName}");
                return true;
            }
        }
        catch (Exception ex)
        {
            Debug.LogWarning($"{fileName} 下载失败:{ex.Message}");
            return false;
        }
    }

    private void OnDestroy()
    {
        Instance = null;
    }
}

使用如下:

// 先下载资源对比文件
AssetBundleManager.Instance.DownloadCompareInfoFile((isDone) =>
{
    if (isDone)
    {
        // 加载对比文件
        AssetBundleManager.Instance.LoadRemoteCompareInfo();
        // 再下载AB包
        AssetBundleManager.Instance.DownloadAssetBundleFile((isDone) =>
        {
            if (isDone)
                Debug.Log("AB包下载成功");
            else
                Debug.Log("AB包下载失败");
        }, (current, max) => {
            Debug.Log($"下载进度:{current}/{max}");
        });
    }
    else
    {
        Debug.Log("对比文件下载失败");
    }
});

四、更新

(一)设置默认资源

有时我们需要给游戏设置一些默认资源,因此这里制作一个工具,用来移动需要设置为默认资源的AB包到StreamingAssets文件夹当中,同时生成资源对比文件,作为游戏最初的AB包资源。

这里工作方式是,鼠标选中想要移动的AB包(Ctrl或者Shift + 鼠标点击),然后点击工具菜单栏按钮即可移动并生成对比文件。工具编写代码如下:

/// <summary>
/// AB包移动工具
/// </summary>
public class AssetBundleMoveTool
{
    // 对比文件名
    private static string COMPARE_INFO_FILE_NAME = "compareInfo.txt";

    [MenuItem("AB包工具/移动选中的AB包资源到StreamingAssets文件夹")]
    private static void MoveAssetBundleToStreamingAssets()
    {
        // 获取选中的资源
        UnityEngine.Object[] selectedAssets = Selection.GetFiltered(typeof(UnityEngine.Object), SelectionMode.DeepAssets);
        if (selectedAssets.Length == 0)
            return;

        // 处理每个选中的资源,拷贝到StreamingAssets文件夹,同时生成资源对比文件
        StringBuilder abCompareInfo = new StringBuilder();
        foreach (var asset in selectedAssets)
        {
            // 获取文件名
            string assetPath = AssetDatabase.GetAssetPath(asset);
            string fileName = assetPath.Substring(assetPath.LastIndexOf('/') + 1);

            // 获取文件信息
            FileInfo fileInfo = new FileInfo(Application.streamingAssetsPath + "/" + fileName);
            if (fileInfo.Extension != "")
                continue; // 排除非AB包文件

            // 拷贝文件
            AssetDatabase.CopyAsset(assetPath, "Assets/StreamingAssets/" + fileName);

            // 将AB包信息添加进比对文件中
            abCompareInfo.Append($"{fileInfo.Name} {fileInfo.Length} {AssetBundleCompareTool.GetMD5(fileInfo.FullName)}|");
        }
        if (abCompareInfo.Length > 0)
        {
            abCompareInfo.Remove(abCompareInfo.Length - 1, 1); // 去掉最后一个"|"
        }

        // 往StreamingAssets文件夹写入资源对比文件
        File.WriteAllText(Application.streamingAssetsPath + "/" + COMPARE_INFO_FILE_NAME, abCompareInfo.ToString());

        AssetDatabase.Refresh();
    }
}

(二)默认资源的转存问题

1、AB包默认资源转存问题及方案

AB 包存放路径有两种选择

  • streamAssetsPath:流资源目录,只读路径,编辑器中 streamAssetsPath 下的文件可随 Unity 打包一起被打包出去。
  • persistentDataPath:持久化目录,可读可写路径,编辑器状态下的 persisentDataPath 内的文件不会被打包出去。

而我们的AB包默认资源会先放在流资源目录中,热更新过后,资源都是下载到持久化目录中的,因此我们可以先将流资源目录的所有AB包转存到持久化目录中。当然,具体怎么做取决于项目中AB包加载机制,这里有两种方式:

  • 若决定都从persistentDataPath加载,则需要先从 streamingAssetsPath 转存,这个缺点就是若默认资源较多,则会产生两份数据,浪费磁盘空间。
  • 若不想转存,则可以这样:先检查persistentDataPath是否有资源,若没有再从 streamingAssetsPath 中加载。

这里我们采用第二种方式。

2、AB包下载更新流程图

下图展示了游戏客户端热更新从下载到更新文件的流程:

image-20260706110727666

(三)资源更新

1、概述

之前实现的资源对比文件下载时,并没有执行对比操作就直接覆盖了,因此需要进一步实现本地资源与远端资源的对比。由于我们不能下载后就直接覆盖本地文件,因此好的做法是先比对本地与远端的资源对比文件,然后根据两者的不同去更新AB包,确认AB包全部下载完成了,最后才覆盖。

因此,比对资源时一定会在内存或者硬盘上存储两份资源对比数据, 一份远程的一份本地的。这里有两种做法:

  • 保存到临时文件中,待AB包下载完成后,再用该临时文件覆盖本地对比文件
  • 不保存文件,直接通过下载流读取出字节数组数据,转存为字符串,待AB包下载完毕后再保存为本地资源对比文件。

这里我们采用第一种方式。

2、代码实现

(1)将远端对比文件下载为临时文件

我们对原先下载章节的代码进行修改,首先下载远端资源对比文件时,我们不是直接覆盖到本地,而是先保存为临时文件。先声明一个变量,作为临时文件名:

// 远端下载的临时对比文件名
private static string COMPARE_INFO_TEMP_FILE_NAME = "compareInfo_temp.txt";

然后修改两个远端对比文件的方法DownloadCompareInfoFileLoadRemoteCompareInfo,将文件路径替换成临时文件路径,以DownloadCompareInfoFile方法为例,另一个同理:

从这个:

string downloadPath = Application.persistentDataPath + "/" + COMPARE_INFO_FILE_NAME;

替换成这个:

string downloadPath = Application.persistentDataPath + "/" + COMPARE_INFO_TEMP_FILE_NAME;

这一步是为了每次下载文件和加载对比文件时,都从这个临时文件去操作。

(2)获取本地对比文件信息

远端资源对比文件已经可以下载并解析,同样,我们还需要添加一个解析本地对比文件的函数,其内部和LoadRemoteCompareInfo类似。不同的是,这里读取文件需要用UnityWebRequest,这是因为StreamingAssets在安卓等平台上是只读的,无法直接访问(持久化目录也用了是为了保持统一)。

仿照远端对比文件的加载,本地对比文件加载代码如下:

// 存储本地的AB包信息
private Dictionary<string, AssetBundleInfo> localAssetBundleInfos = new Dictionary<string, AssetBundleInfo>();

/// <summary>
/// 解析本地对比文件
/// </summary>
/// <param name="completeCallback">解析本地对比文件完成的回调,若成功则为true,否则为false</param>
public void LoadLocalCompareInfo(Action<bool> completeCallback = null)
{
    string persistentPath = Application.persistentDataPath + "/" + COMPARE_INFO_FILE_NAME;
    string streamingPath = Application.streamingAssetsPath + "/" + COMPARE_INFO_FILE_NAME;
    // 先检查持久化目录是否存在本地对比文件
    if (File.Exists(persistentPath))
    {
        StartCoroutine(LoadLocalCompareInfoCoroutine(persistentPath, completeCallback));
    }
    // 再检查流资源目录是否存在本地对比文件
    else if (File.Exists(streamingPath))
    {
        StartCoroutine(LoadLocalCompareInfoCoroutine(streamingPath, completeCallback));
    }
    // 若两个 if 语句都没有进入,则代表本地没有默认资源
}

// 加载本地对比文件协程
private IEnumerator LoadLocalCompareInfoCoroutine(string filePath, Action<bool> completeCallback = null)
{
    // 使用 UnityWebRequest 读取本地文件
    UnityWebRequest request = UnityWebRequest.Get(filePath);
    yield return request.SendWebRequest();

    if (request.result == UnityWebRequest.Result.Success)
    {
        // 解析本地对比文件信息
        string compareInfo = request.downloadHandler.text;
        string[] assetBundleInfos = compareInfo.Split("|");
        // 解析每个AB包
        foreach (string assetBundleInfo in assetBundleInfos)
        {
            string[] infos = assetBundleInfo.Split(" ");
            // 记录AB包信息,方便进行对比
            localAssetBundleInfos.Add(infos[0], new AssetBundleInfo(infos[0], long.Parse(infos[1]), infos[2]));
        }

        completeCallback?.Invoke(true);
    }
    else
    {
        completeCallback?.Invoke(false);
    }
}

(3)AB包的更新

目前,我们已经完成了远端对比文件的下载和解析,本地对比文件的解析,并将两者的AB包信息都记录到字典当中,是时候将两者进行比对,然后更新对应AB包了。这里我们无非会遇到这几种情况:

  • 更新:远端和本地都有这个AB包,但是两者MD5码不同,则下载并更新本地的AB包。
  • 下载:远端有但本地没有的AB包,进行下载。
  • 删除:远端没有但本地有的AB包,进行删除。
  • 不变:远端和本地都有这个AB包,且两者MD5码相同,则无需变动。

我们开始吧。

首先,我们在“下载”章节中,编写了下载AB包的方法DownloadAssetBundleFile,我们当时是把所有AB包都添加进待列表中,而现在,我们在此之前需要进行比对,所以可以把下面这段代码删掉,让这个方法只需负责下载待下载列表的文件即可,只需待下载列表的添加由外部进行修改。

需要删除的代码块位置如下:

public async void DownloadAssetBundleFile(Action<bool> completeCallback = null, Action<int,int> progressCallback = null)
{
    // 删除,这步由其他模块负责
    foreach (string fileName in remoteAssetBundleInfos.Keys)
    {
        downloadList.Add(fileName);
    }
	
    // 其他不变...
}

之后,我们编写一个方法,用于比对本地和远端的资源,进行更新、下载和删除,将需要下载的AB包添加进待下载列表中,代码如下:

/// <summary>
/// 更新待下载的AB包列表
/// </summary>
public void UpdateDownloadList()
{
    // 资源比对
    foreach (string abName in remoteAssetBundleInfos.Keys)
    {
        // 下载:远端有而本地没有
        if (!localAssetBundleInfos.ContainsKey(abName))
        {
            downloadList.Add(abName);
        }
        else
        {
            // 更新:本地和远端都有,但MD5码不同
            if (localAssetBundleInfos[abName].md5 != remoteAssetBundleInfos[abName].md5)
            {
                downloadList.Add(abName);
            }
            // 每次操作完对本地同名AB包记录进行删除,最后剩余的就是需要被删除的AB包
            localAssetBundleInfos.Remove(abName);
        }
    }

    // 删除:删除本地多余的AB包(只删除持久化目录里的,因为默认资源是只读的)
    foreach (string abName in localAssetBundleInfos.Keys)
    {
        if (File.Exists(Application.persistentDataPath + "/" + abName))
        {
            Debug.Log($"本地多余AB包 {abName} 已删除");
            File.Delete(Application.persistentDataPath + "/" + abName);
        }
    }
}

(4)热更新流程方法

我们前面编写了热更新所需要的各种方法,但是调用步骤比较繁琐,因此可以为外部提供一个方法,用于封装内部的热更新流程,方便外部使用:

这里方法内调用的流程是这样的:

  • 下载远端对比文件
  • 解析远端对比文件
  • 解析本地对比文件
  • 进行本地资源与远端资源的比对,根据比对信息删除本地多余AB包,并记录需要下载的远端AB包
  • 下载AB包
  • 下载完成后用远端的对比文件覆盖本地的对比文件,完成热更新
/// <summary>
/// 检查并进行热更新
/// </summary>
/// <param name="completeCallback">完成后的回调,若操作成功则为true,否则为false</param>
/// <param name="updateInfoCallback">热更新时的步骤信息,可由外部进行调用,如UI展示</param>
public void CheckUpdate(UnityAction<bool> completeCallback = null, UnityAction<string> updateInfoCallback = null)
{
    // 清理上次残留信息
    remoteAssetBundleInfos.Clear();
    localAssetBundleInfos.Clear();
    downloadList.Clear();

    string persistentPath = Application.persistentDataPath + "/" + COMPARE_INFO_FILE_NAME; // 对比文件保存路径
    string tempPath = Application.persistentDataPath + "/" + COMPARE_INFO_TEMP_FILE_NAME; // 临时对比文件保存路径

    //加载远端资源对比文件
    updateInfoCallback?.Invoke("开始更新资源");
    DownloadCompareInfoFile((isDone) =>
    {
        if (isDone)
        {
            updateInfoCallback?.Invoke("对比文件下载结束,开始解析远端对比文件");
            LoadRemoteCompareInfo();
            updateInfoCallback?.Invoke("远端对比文件解析完成,开始解析本地对比文件");
            LoadLocalCompareInfo((isDone) =>
            {
                if (isDone)
                {
                    updateInfoCallback?.Invoke("本地对比文件解析完成,开始资源比对");
                    UpdateDownloadList();
                    updateInfoCallback?.Invoke("资源对比完成,开始下载新的AB包");
                    DownloadAssetBundleFile((isDone) =>
                    {
                        if (isDone)
                        {
                            // 写入并覆盖本地对比文件
                            File.WriteAllText(persistentPath, File.ReadAllText(tempPath));
                        }
                        completeCallback?.Invoke(isDone);
                    },
                    (current, max) =>
                    {
                        updateInfoCallback?.Invoke($"AB包下载进度:{current}/{max}");
                    });
                }
                else
                {
                    completeCallback.Invoke(false);
                }
            });
        }
        else
        {
            completeCallback.Invoke(false);
        }
    });
}

外部调用也非常简单:

AssetBundleManager.Instance.CheckUpdate((isDone) =>
{
    if (isDone)
    {
        Debug.Log("资源热更新完成");
    }
    else
    {
        Debug.Log("资源热更新失败");
    }
}, (updateInfo) =>
{
    Debug.Log(updateInfo);
});

(四)完整的下载和更新流程代码

在完成下载和更新的代码后,就完成了客户端的AB包管理器,这里将其整个代码列出来,并且为了方便阅读,这里简单讲解一下里面的各个内容:

  • AssetBundleInfo类:AB包的信息,主要用于存储和资源比对。
  • CheckUpdate方法:用于检查和进行资源热更新,封装了热更新的整套流程,方便外部调用。
  • DownloadCompareInfoFile方法:下载远端资源对比文件(暂时作为临时文件,不直接覆盖本地文件)。
  • LoadRemoteCompareInfo方法:解析远端对比文件。
  • LoadLocalCompareInfo方法:解析本地对比文件。
  • UpdateDownloadList方法:进行本地和远端资源的对比,内部会对本地多余的AB包进行删除,并更新待下载的AB包列表。
  • DownloadAssetBundleFile方法:下载待下载列表中的所有AB包。
  • DownloadFile方法:辅助方法,用于从资源服务器下载文件。
/// <summary>
/// AB包管理器
/// </summary>
public class AssetBundleManager : MonoBehaviour
{
    public static AssetBundleManager Instance { get; private set; }
    private void Awake()
    {
        Instance = this;
    }

    // 对比文件名
    private static string COMPARE_INFO_FILE_NAME = "compareInfo.txt";
    // 远端下载的临时对比文件名
    private static string COMPARE_INFO_TEMP_FILE_NAME = "compareInfo_temp.txt";
    // FTP相关
    private static string FTP_PATH = "ftp://127.0.0.1/AssetBundle/PC/";
    private static string USER_NAME = "pmm";
    private static string PASSWORD = "123456";

    /// <summary>
    /// AB包信息类
    /// </summary>
    private class AssetBundleInfo
    {
        public string name; // 包名
        public long size; // 大小
        public string md5; // md5码
        public AssetBundleInfo(string name, long size, string md5)
        {
            this.name = name;
            this.size = size;
            this.md5 = md5;
        }
    }

    // 存储远端下载的AB包信息
    private Dictionary<string, AssetBundleInfo> remoteAssetBundleInfos = new Dictionary<string, AssetBundleInfo>();
    // 存储本地的AB包信息
    private Dictionary<string, AssetBundleInfo> localAssetBundleInfos = new Dictionary<string, AssetBundleInfo>();
    // 待下载AB包列表
    private List<string> downloadList = new List<string>();

    /// <summary>
    /// 检查并进行热更新
    /// </summary>
    /// <param name="completeCallback">完成后的回调,若操作成功则为true,否则为false</param>
    /// <param name="updateInfoCallback">热更新时的步骤信息,可由外部进行调用,如UI展示</param>
    public void CheckUpdate(UnityAction<bool> completeCallback = null, UnityAction<string> updateInfoCallback = null)
    {
        // 清理上次残留信息
        remoteAssetBundleInfos.Clear();
        localAssetBundleInfos.Clear();
        downloadList.Clear();

        string persistentPath = Application.persistentDataPath + "/" + COMPARE_INFO_FILE_NAME; // 对比文件保存路径
        string tempPath = Application.persistentDataPath + "/" + COMPARE_INFO_TEMP_FILE_NAME; // 临时对比文件保存路径

        //加载远端资源对比文件
        updateInfoCallback?.Invoke("开始更新资源");
        DownloadCompareInfoFile((isDone) =>
        {
            if (isDone)
            {
                updateInfoCallback?.Invoke("对比文件下载结束,开始解析远端对比文件");
                LoadRemoteCompareInfo();
                updateInfoCallback?.Invoke("远端对比文件解析完成,开始解析本地对比文件");
                LoadLocalCompareInfo((isDone) =>
                {
                    if (isDone)
                    {
                        updateInfoCallback?.Invoke("本地对比文件解析完成,开始资源比对");
                        UpdateDownloadList();
                        updateInfoCallback?.Invoke("资源对比完成,开始下载新的AB包");
                        DownloadAssetBundleFile((isDone) =>
                        {
                            if (isDone)
                            {
                                // 写入并覆盖本地对比文件
                                File.WriteAllText(persistentPath, File.ReadAllText(tempPath));
                            }
                            completeCallback?.Invoke(isDone);
                        },
                        (current, max) =>
                        {
                            updateInfoCallback?.Invoke($"AB包下载进度:{current}/{max}");
                        });
                    }
                    else
                    {
                        completeCallback.Invoke(false);
                    }
                });
            }
            else
            {
                completeCallback.Invoke(false);
            }
        });
    }

    /// <summary>
    /// 下载对比文件
    /// </summary>
    /// <param name="completeCallback">下载完成的回调,若下载成功则为true,否则为false</param>
    public async void DownloadCompareInfoFile(Action<bool> completeCallback = null)
    {
        // 一些变量
        string downloadPath = Application.persistentDataPath + "/" + COMPARE_INFO_TEMP_FILE_NAME; // 下载路径
        bool isDone = false; // 是否下载成功
        int retryCount = 5; // 重新下载次数

        // 下载对比文件
        while (!isDone && retryCount > 0)
        {
            await Task.Run(() =>
            {
                isDone = DownloadFile(COMPARE_INFO_FILE_NAME, downloadPath);
            });

            retryCount--;
        }

        completeCallback?.Invoke(isDone);
    }

    /// <summary>
    /// 解析远端对比文件
    /// </summary>
    public void LoadRemoteCompareInfo()
    {
        string downloadPath = Application.persistentDataPath + "/" + COMPARE_INFO_TEMP_FILE_NAME; // 文件路径

        string compareInfo = File.ReadAllText(downloadPath);
        string[] assetBundleInfos = compareInfo.Split("|");
        // 解析每个AB包
        foreach (string assetBundleInfo in assetBundleInfos)
        {
            string[] infos = assetBundleInfo.Split(" ");
            // 记录AB包信息,方便进行对比
            remoteAssetBundleInfos.Add(infos[0], new AssetBundleInfo(infos[0], long.Parse(infos[1]), infos[2]));
        }
    }

    /// <summary>
    /// 解析本地对比文件
    /// </summary>
    /// <param name="completeCallback">解析本地对比文件完成的回调,若成功则为true,否则为false</param>
    public void LoadLocalCompareInfo(Action<bool> completeCallback = null)
    {
        string persistentPath = Application.persistentDataPath + "/" + COMPARE_INFO_FILE_NAME;
        string streamingPath = Application.streamingAssetsPath + "/" + COMPARE_INFO_FILE_NAME;
        // 先检查持久化目录是否存在本地对比文件
        if (File.Exists(persistentPath))
        {
            StartCoroutine(LoadLocalCompareInfoCoroutine(persistentPath, completeCallback));
        }
        // 再检查流资源目录是否存在本地对比文件
        else if (File.Exists(streamingPath))
        {
            StartCoroutine(LoadLocalCompareInfoCoroutine(streamingPath, completeCallback));
        }
        // 若两个 if 语句都没有进入,则代表本地没有默认资源
    }

    // 加载本地对比文件协程
    private IEnumerator LoadLocalCompareInfoCoroutine(string filePath, Action<bool> completeCallback = null)
    {
        // 使用 UnityWebRequest 读取本地文件
        UnityWebRequest request = UnityWebRequest.Get(filePath);
        yield return request.SendWebRequest();

        if (request.result == UnityWebRequest.Result.Success)
        {
            // 解析本地对比文件信息
            string compareInfo = request.downloadHandler.text;
            string[] assetBundleInfos = compareInfo.Split("|");
            // 解析每个AB包
            foreach (string assetBundleInfo in assetBundleInfos)
            {
                string[] infos = assetBundleInfo.Split(" ");
                // 记录AB包信息,方便进行对比
                localAssetBundleInfos.Add(infos[0], new AssetBundleInfo(infos[0], long.Parse(infos[1]), infos[2]));
            }

            completeCallback?.Invoke(true);
        }
        else
        {
            completeCallback?.Invoke(false);
        }
    }

    /// <summary>
    /// 更新待下载的AB包列表
    /// </summary>
    public void UpdateDownloadList()
    {
        // 资源比对
        foreach (string abName in remoteAssetBundleInfos.Keys)
        {
            // 下载:远端有而本地没有
            if (!localAssetBundleInfos.ContainsKey(abName))
            {
                downloadList.Add(abName);
            }
            else
            {
                // 更新:本地和远端都有,但MD5码不同
                if (localAssetBundleInfos[abName].md5 != remoteAssetBundleInfos[abName].md5)
                {
                    downloadList.Add(abName);
                }
                // 每次操作完对本地同名AB包记录进行删除,最后剩余的就是需要被删除的AB包
                localAssetBundleInfos.Remove(abName);
            }
        }

        // 删除:删除本地多余的AB包(只删除持久化目录里的,因为默认资源是只读的)
        foreach (string abName in localAssetBundleInfos.Keys)
        {
            if (File.Exists(Application.persistentDataPath + "/" + abName))
            {
                Debug.Log($"本地多余AB包 {abName} 已删除");
                File.Delete(Application.persistentDataPath + "/" + abName);
            }
        }
    }

    /// <summary>
    /// 下载AB包
    /// </summary>
    /// <param name="completeCallback">下载完成的回调,若全部下载成功则为true,否则为false</param>
    /// <param name="progressCallback">下载进度回调,每次下载AB包完成都会调用,参数分别为当前下载数和最大下载数</param>
    public async void DownloadAssetBundleFile(Action<bool> completeCallback = null, Action<int,int> progressCallback = null)
    {
        // 一些变量
        string downloadPath = Application.persistentDataPath + "/"; // 下载路径
        bool isDone = false; // 是否下载成功
        List<string> removeList = new List<string>(); // 记录下载成功的AB包,需要被移除
        int retryCount = 5; // 重新下载次数
        int downloadCount = 0; // 已下载的AB包数量
        int maxDownloadCount = downloadList.Count; // 需要下载的AB包数量

        // 下载文件,下载失败后会重新下载,直至下载完毕 或者 超过最大重新下载次数
        while (downloadList.Count > 0 && retryCount > 0)
        {
            for (int i = 0; i < downloadList.Count; i++)
            {
                // 下载
                isDone = false;
                string fileName = downloadList[i];
                await Task.Run(() =>
                {
                    isDone = DownloadFile(fileName, downloadPath + fileName);
                });

                // 下载成功则添加进待移除列表
                if (isDone)
                {
                    progressCallback?.Invoke(++downloadCount, maxDownloadCount);
                    removeList.Add(fileName);
                }
            }

            // 移除下载成功的AB包
            for (int i = 0; i < removeList.Count; i++)
            {
                downloadList.Remove(removeList[i]);
            }

            retryCount--;
        }

        // 调用回调,全部下载成功则返回true,否则为false
        completeCallback?.Invoke(downloadList.Count == 0);
    }

    /// <summary>
    /// 下载文件
    /// </summary>
    /// <param name="fileName">文件名</param>
    /// <param name="savePath">下载保存的路径</param>
    private bool DownloadFile(string fileName, string savePath)
    {
        try
        {
            // 创建FTP请求和相关设置
            FtpWebRequest request = FtpWebRequest.Create(new Uri(FTP_PATH + fileName)) as FtpWebRequest;
            request.Credentials = new NetworkCredential(USER_NAME, PASSWORD); // 有匿名账号可以不设置凭证,这里为了安全还是设置了凭证
            request.Proxy = null;
            request.KeepAlive = false;
            request.Method = WebRequestMethods.Ftp.DownloadFile;
            request.UseBinary = true;

            // 获取响应
            using (FtpWebResponse response = request.GetResponse() as FtpWebResponse)
            {
                // 获取下载流
                using (Stream downloadStream = response.GetResponseStream())
                {
                    // 将资源服务器的文件写入保存路径
                    using (FileStream fileStream = File.Create(savePath))
                    {
                        // 读取本地要上传的文件
                        byte[] bytes = new byte[2048];
                        // 写入到目标文件
                        int length;
                        while ((length = downloadStream.Read(bytes, 0, bytes.Length)) > 0)
                        {
                            fileStream.Write(bytes, 0, length);
                        }
                    }
                }

                Debug.Log($"下载完成 [{response.StatusCode}]:{fileName}");
                return true;
            }
        }
        catch (Exception ex)
        {
            Debug.LogWarning($"{fileName} 下载失败:{ex.Message}");
            return false;
        }
    }

    private void OnDestroy()
    {
        Instance = null;
    }
}

五、AB包上传工具制作

我们之前使用编辑器制作了一些用于AB包上传的工具:

  • AssetBundleCompareTool:用于生成AB包对比文件。
  • AssetBundleUploadTool:用于将AB包及其对比文件上传到资源服务器。
  • AssetBundleMoveTool:用于将指定AB包移动到StreamingAssets文件夹中,作为默认资源。

现在,我们将制作一个相对完整的AB包上传工具,用于整合上述的这些功能。

我们需要完成这两步:

  • 创建一个可视化界面,方便操作。
  • 移植之前的这些工具到同一个工具类中,略微修改一下即可,然后这些方法由界面的按钮进行调用。

我们需要编写的工具代码如下:

/// <summary>
/// AB包上传工具
/// </summary>
public class AssetBundleTool : EditorWindow
{
    #region 窗口

    // 平台选择
    private int currentSelectIndex = 0;
    private string[] targetPlatform = new string[] { "PC", "IOS", "Android" };

    // 资源服务器IP
    private string serverIP = "ftp://127.0.0.1";

    [MenuItem("AB包工具/打开工具窗口")]
    private static void OpenWindow()
    {
        AssetBundleTool window = EditorWindow.GetWindowWithRect(typeof(AssetBundleTool), new Rect(0, 0, 350, 220)) as AssetBundleTool;
        window.Show();
    }

    private void OnGUI()
    {
        // 平台页签显示
        GUI.Label(new Rect(10, 10, 150, 15), "平台选择");
        currentSelectIndex = GUI.Toolbar(new Rect(10, 30, 250, 20), currentSelectIndex, targetPlatform);
        // 资源服务器IP地址设置
        GUI.Label(new Rect(10, 60, 150, 15), "资源服务器地址");
        serverIP = GUI.TextField(new Rect(10, 80, 150, 20), serverIP);
        // 一些按钮
        if (GUI.Button(new Rect(10, 110, 120, 40), "创建资源对比文件"))
        {
            CreateAssetBundleCompareFile();
        }
        if (GUI.Button(new Rect(140, 110, 200, 40), "保存默认资源到StreamingAssets"))
        {
            MoveAssetBundleToStreamingAssets();
        }
        if (GUI.Button(new Rect(10, 160, 330, 40), "上传AB包和对比文件"))
        {
            UploadAssetBundle();
        }
    }

    #endregion

    #region 工具方法

    // AB包路径,用于上传
    private static string ASSET_BUNDLE_PATH = Application.dataPath + "/Arts/AssetBundle/";
    // 对比文件名
    private static string COMPARE_INFO_FILE_NAME = "compareInfo.txt";
    // FTP上传相关账号密码
    private static string USER_NAME = "pmm";
    private static string PASSWORD = "123456";

    /// <summary>
    /// 创建AB包对比文件
    /// </summary>
    private void CreateAssetBundleCompareFile()
    {
        // 根据窗口选择的平台决定路径
        string directoryPath = ASSET_BUNDLE_PATH + targetPlatform[currentSelectIndex] + "/";
        // 获取所有AB包文件
        DirectoryInfo directoryInfo = Directory.CreateDirectory(directoryPath);
        FileInfo[] fileInfos = directoryInfo.GetFiles();

        // 获取并拼接AB包的相关信息
        StringBuilder abCompareInfo = new StringBuilder();
        foreach (FileInfo fileInfo in fileInfos)
        {
            // 该文件夹下,没有后缀的文件是AB包
            if (fileInfo.Extension == "")
            {
                abCompareInfo.Append($"{fileInfo.Name} {fileInfo.Length} {GetMD5(fileInfo.FullName)}|");
            }
        }
        if (abCompareInfo.Length > 0)
        {
            abCompareInfo.Remove(abCompareInfo.Length - 1, 1); // 去掉最后一个"|"
        }

        // 将信息存储为对比文件
        File.WriteAllText(directoryPath + COMPARE_INFO_FILE_NAME, abCompareInfo.ToString());

        AssetDatabase.Refresh();
        Debug.Log("AB包对比文件生成成功");
    }

    /// <summary>
    /// 获取文件MD5码
    /// </summary>
    /// <param name="filePath">文件路径</param>
    /// <returns>MD5码</returns>
    private string GetMD5(string filePath)
    {
        using (FileStream fileStream = File.Open(filePath, FileMode.Open))
        {
            // 获取MD5码字节数组
            MD5 md5 = MD5.Create();
            byte[] hash = md5.ComputeHash(fileStream);

            // 将MD5码转成十六进制字符串
            StringBuilder stringBuilder = new StringBuilder();
            for (int i = 0; i < hash.Length; i++)
            {
                stringBuilder.Append(hash[i].ToString("x2")); // 十六进制小写
            }

            return stringBuilder.ToString();
        }
    }
    
    /// <summary>
    /// 移动指定AB包到StreamingAssets文件夹
    /// </summary>
    private void MoveAssetBundleToStreamingAssets()
    {
        // 获取选中的资源
        UnityEngine.Object[] selectedAssets = Selection.GetFiltered(typeof(UnityEngine.Object), SelectionMode.DeepAssets);
        if (selectedAssets.Length == 0)
            return;

        // 处理每个选中的资源,拷贝到StreamingAssets文件夹,同时生成资源对比文件
        StringBuilder abCompareInfo = new StringBuilder();
        foreach (var asset in selectedAssets)
        {
            // 获取文件名
            string assetPath = AssetDatabase.GetAssetPath(asset);
            string fileName = assetPath.Substring(assetPath.LastIndexOf('/') + 1);

            // 获取文件信息
            FileInfo fileInfo = new FileInfo(Application.streamingAssetsPath + "/" + fileName);
            if (fileInfo.Extension != "")
                continue; // 排除非AB包文件

            // 拷贝文件
            AssetDatabase.CopyAsset(assetPath, "Assets/StreamingAssets/" + fileName);

            // 将AB包信息添加进比对文件中
            abCompareInfo.Append($"{fileInfo.Name} {fileInfo.Length} {AssetBundleCompareTool.GetMD5(fileInfo.FullName)}|");
        }
        if (abCompareInfo.Length > 0)
        {
            abCompareInfo.Remove(abCompareInfo.Length - 1, 1); // 去掉最后一个"|"
        }

        // 往StreamingAssets文件夹写入资源对比文件
        File.WriteAllText(Application.streamingAssetsPath + "/" + COMPARE_INFO_FILE_NAME, abCompareInfo.ToString());

        AssetDatabase.Refresh();
    }


    /// <summary>
    /// 上传AB包和对比文件到资源服务器
    /// </summary>
    private void UploadAssetBundle()
    {
        // 根据窗口选择的平台决定路径
        string directoryPath = ASSET_BUNDLE_PATH + targetPlatform[currentSelectIndex] + "/";
        // 获取里面的文件信息
        DirectoryInfo directoryInfo = Directory.CreateDirectory(directoryPath);
        FileInfo[] fileInfos = directoryInfo.GetFiles();

        foreach (FileInfo fileInfo in fileInfos)
        {
            // 将比对文件和AB包进行上传
            if (fileInfo.Extension == "" || fileInfo.Name == COMPARE_INFO_FILE_NAME)
            {
                FtpUploadFile(fileInfo.FullName, fileInfo.Name);
            }
        }

    }

    /// <summary>
    /// 上传文件到资源服务器的辅助方法
    /// </summary>
    /// <param name="filePath">文件路径</param>
    /// <param name="fileName">上传后的文件名</param>
    private async void FtpUploadFile(string filePath, string fileName)
    {
        await Task.Run(() =>
        {
            try
            {
                // 获取需要上传到服务器的路径
                string serverPath = serverIP + "/AssetBundle/" + targetPlatform[currentSelectIndex] + "/" + fileName;
                // 创建FTP请求和相关设置
                FtpWebRequest request = FtpWebRequest.Create(new Uri(serverPath)) as FtpWebRequest;
                request.Credentials = new NetworkCredential(USER_NAME, PASSWORD);
                request.Proxy = null;
                request.KeepAlive = false;
                request.Method = WebRequestMethods.Ftp.UploadFile;
                request.UseBinary = true;

                // 上传文件
                using (Stream uploadStream = request.GetRequestStream())
                {
                    using (FileStream fileStream = File.OpenRead(filePath))
                    {
                        // 读取本地要上传的文件
                        byte[] bytes = new byte[2048];
                        // 写入到目标文件
                        int length;
                        while ((length = fileStream.Read(bytes, 0, bytes.Length)) > 0)
                        {
                            uploadStream.Write(bytes, 0, length);
                        }
                    }
                }

                // 接收响应
                using (FtpWebResponse response = request.GetResponse() as FtpWebResponse)
                {
                    Debug.Log($"上传完成 [{response.StatusCode}]:{fileName}");
                }

            }
            catch (Exception ex)
            {
                Debug.LogWarning($"{fileName} 上传失败:{ex.Message}");
            }
        });
    }
    #endregion
}

最终效果:

image-20260707142040113

六、AB包下载管理器路径优化

1、概述

上一节我们完善了AB包上传工具,现在我们来优化客户端的AB包下载管理器。

这里主要是进行路径优化,因为不同平台的特殊路径,读取表现是不同的,因此需要针对多平台进行修改。这里会用到这几个知识点:

  • 不同平台的特殊路径及读取表现
路径分类 & 读取方式 PC Android IOS
persistentDataPath
└─ UnityWebRequest/WWW读取 file:/// file:/// file:///
└─ C#文件流读取 无需处理 无需处理 无需处理
streamingAssetsPath
└─ UnityWebRequest/WWW读取 file:/// 直接用 file:///
└─ C#文件流读取 无需处理 不可用 无需处理
  • 多路测试

预处理器指令#if ... #elif ... #else ... #endif可以选择性的编译C#部分代码。

  • 平台测试符号

Unity 支持对脚本使用的平台 #define 指令如下,这里只展示项目会用到的,具体可以在Unity官网查看:Unity - 手册: Unity 脚本符号参考

Define 功能
UNITY_IOS 用于为 iOS 平台编译/执行代码的 #define 指令。
UNITY_ANDROID 用于 Android 平台的 #define 指令。

2、代码修改

(1)首先修改DownloadFile方法,不同平台的资源服务器下载路径不一样:

private static string FTP_PATH = "ftp://127.0.0.1";

private bool DownloadFile(string fileName, string savePath)
{
    try
    {
#if UNITY_IOS
        string serverPath = FTP_PATH + "/AssetBundle/IOS/" + fileName;
#elif UNITY_ANDROID
        string serverPath = FTP_PATH + "/AssetBundle/Android/" + fileName;
#else
        string serverPath = FTP_PATH + "/AssetBundle/PC/" + fileName;
#endif
        // 创建FTP请求和相关设置
        FtpWebRequest request = FtpWebRequest.Create(new Uri(serverPath)) as FtpWebRequest;
        // 其他不变...
    }
    catch (Exception ex)
    {
        Debug.LogWarning($"{fileName} 下载失败:{ex.Message}");
        return false;
    }
}

(2)LoadRemoteCompareInfo不用修改,因为这是读取persistentDataPath且用C#流文件读取的。

(3)之后修改LoadLocalCompareInfo方法,这里用到了UnityWebRequest,且用到了流文件目录和持久化目录。因此读取StreamingAssets目录时需要注意,因为Android平台读取该目录时默认会加一个前缀(好像是jar:file:///),不需要我们手动加,所以需要分两种情况,修改如下:

public void LoadLocalCompareInfo(Action<bool> completeCallback = null)
{
    // 持久化路径
    string persistentPath = Application.persistentDataPath + "/" + COMPARE_INFO_FILE_NAME;
    // 流资源路径
    string streamingPath = Application.streamingAssetsPath + "/" + COMPARE_INFO_FILE_NAME;

    // 先检查持久化目录是否存在本地对比文件
    if (File.Exists(persistentPath))
    {
        StartCoroutine(LoadLocalCompareInfoCoroutine("file:///" + persistentPath, completeCallback));
    }
    // 再检查流资源目录是否存在本地对比文件
    else if (File.Exists(streamingPath))
    {
#if UNITY_ANDROID
        StartCoroutine(LoadLocalCompareInfoCoroutine(streamingPath, completeCallback));
#else
        StartCoroutine(LoadLocalCompareInfoCoroutine("file:///" + streamingPath, completeCallback));
#endif
    }
    // 若两个 if 语句都没有进入,则代表本地没有默认资源
}

自此,AB包下载管理器的路径部分就修改完了,当然这个管理器代码应该还是有点问题的,这里只测试了PC端的,其他端并不能保证正常运行。这里主要是要掌握不同平台路径优化的思路,具体正确且完整的代码可以交由大家自行完成。