Unity游戏开发-游戏热更新以及登录流程

本文详述了Unity游戏的热更新和登录流程。首先介绍整体设计,包括解压和热更模块的处理。登录流程采用状态机进行控制,解压和热更模块分别负责资源文件的解压和更新。热更新过程包括资源文件比较、下载和更新,通过状态标记区分不同阶段。整个流程旨在降低模块间的耦合,方便多项目迁移。

本篇主要分享基于热更新的游戏初始化方案。

整体初始化的流程大致为:检查是否需要解压资需要则解压,之后再检查是否存在需要热更新的资源文件需要则更新,更新完成后则初始化结束可进入登录界面。

关于登录这块的处理还涉及到了一个状态机有兴趣可以参考这里《unity游戏开发-简易的状态机》

其中热更新完整的方案本身包含了以下四个部分:

  1. 资源文件打包
  2. 资源文件比较
  3. 资源文件下载
  4. 资源文件更新

其中资源文件打包可以参考这里《Unity游戏开发-Assetbundle打包》

本篇主要会涉及到资源文件比较和更新。

另外关于资源文件的下载会放到这里《Unity游戏开发-客户端Http下载》

整体设计

简单解释下这张泳道流程图

主要的逻辑在就是中间登录流程这张图,一共分三步:解压bundle=>检查并处理热更=>登录就绪

其中关于资源解压的所有逻辑放在了左边流程图对应的模块处理,热更新的所有逻辑放在了右边的流程图对应的模块处理。

另外对于整个游戏内容来说,我们的登录为一个逻辑模块。外部的UI展示界面可以直接实例化一个登录逻辑模块,然后将一个显示用的回调函数传入该模块即可。

逻辑模块内部将解压、更新时的一些关键性数据传入到回调函数中,即可让外部处理进度条等等的展示了。

这样做的好处是,登录的UI界面功能和逻辑模块变成了两个互相独立的模块耦合性更低。

而且将业务相关的UI界面和更接近于框架层面的逻辑分离,有利于多项目迁移。

登录流程模块

根据我们所画的流程图可以看出登录流程并没有直接处理任何具体的事情,只是一个单纯的流程控制模块。

因此我采用了状态机的形式来处理该模块。

内部实际仅仅只初始化了解压和热更两个状态模块,并将之关联起来,最后所有流程结束就调用就绪方法。

利用状态机的形式来处理登录逻辑流程主要为了解压和热更模块之间的衔接关系更清楚的展示再登录流程中也更利于流程的改动。

如果直接模块之间相互调用则每次修改流程会非常的麻烦,每次都需要重新阅读代码找到衔接点修改调用的方法。

如果用事件来衔接各个模块其实也挺麻烦,如果流程状态很多的情况下,事件会零零散散的分布在各个模块,其实也不利于阅读和修改流程。

// 登录流程
public class LoginMachine : IStateMachine
{
    // 资源包解压模块
    private UnPackAssetBundle mUnPackBundle = new UnPackAssetBundle();
    // 热更新模块
    private HotUpdate mHotUpdate = new HotUpdate();

    // 流程结束回调
    private Action mFnOnEnd;

    // 初始化
    public void Init(Action<string, float> fnLoginShow, Action fnOnEnd)
    {
        // 初始化状态
        mUnPackBundle.Init(fnLoginShow, this);
        mHotUpdate.Init(fnLoginShow, this);
        mFnOnEnd = fnOnEnd;

        // 注册流程
        AddTransition(mUnPackBundle, mHotUpdate, "end");
    }

    // 启动流程
    public void Start()
    {
        mUnPackBundle.DoStart(null);
    }

    protected override void DoEnd(string key)
    {
        if ("hotError".Equals(key))
        {
            FDebug.LogError("热更新出错,流程结束!");
        }
        else
        {
            mFnOnEnd?.Invoke();
        }
    }
}

其中有一个fnLoginShow,是留给UI界面展示用的回调。

另外AddTransition(a,b,c)是一个状态迁移的注册方法,其中a代表前一个状态,b代表下一个状态,c代表条件。即a在以c方式结束的情况下迁移到b状态。

在这里即解压模块在以"end"的方式结束的情况下调起热更模块。

最后整个流程结束时若发生了需要处理的错误"hotError"则处理下异常,否则调用mFnOnEnd即我们设计的就绪。

解压模块

设计方案:

中间判定是否需要解压简单的判定一下我们存放资源的路径(Application.persistentDataPath + "/Bundles")下是否存在版本清单文件即可。

因为新用户首次打开游戏的时候还没有做过解压,因此可写路径下不会存在我们的资源文件和版本清单文件。而执行完一次解压之后我们就会把资源文件解压到该路径下,并且生成版本清单文件,因此该判定是有效的。

解压的时候这边是考虑开了一个线程来处理,因为解压是一个比较耗时的过程,玩家肯定不希望在这么长的时间内看到游戏界面是卡主的,因此不能阻塞主线程。

整体处理:

// 资源初始化解压模块
public class UnPackAssetBundle : ILoginState
{
    // 当前正在解压的文件编号
    private int mUnPackIdx = 0;
    // 版本文件
    private AssetVersion version;
    // 进度显示回调
    private Action<string, float> mFnLoginShow;

    #region 流程
    public override void OnStart(string key)
    {
        if (CheckInited())
        {
            DoEnd("end");
        }
        else
        {
            InitAsset();
        }
    }

    public override void DoUpdate()
    {
        string title = "解压文件中:" + mUnPackIdx.ToString() + "/" + version.files.Count.ToString();
        float progress = (float)mUnPackIdx / version.files.Count;
        mFnLoginShow?.Invoke(title, progress);
    }

    public override void Init(Action<string, float> fnLoginShow, IStateMachine machine)
    {
        mFnLoginShow = fnLoginShow;
        Init(machine);
    }
    #endregion

    // 检查资源文件是否初始化过了
    public bool CheckInited()
    {
        FDebug.Log("资源包文件路径:" + PathMgr.instance.AssetBundlePath);
        if (!System.IO.Directory.Exists(PathMgr.instance.AssetBundlePath)
            || !System.IO.File.Exists(PathMgr.instance.AssetBundlePath + "/version"))
        {
            mUnPackIdx = 0;
            // 初始化版本文件
            FileTool.TruncateDirectory(PathMgr.instance.AssetBundlePath);
            version = DataWriteRead.ReadJson<AssetVersion>(PathMgr.instance.StreamingPath + "/version");
            return false;
        }
        return true;
    }

    // 初始化资源文件
    public void InitAsset()
    {
        // 开线程异步执行
        Thread thread = new Thread(UnCompressAsset);
        thread.IsBackground = true;
        thread.Start();
    }

    // 解压所有的资源
    private void UnCompressAsset()
    {
        for(; mUnPackIdx < version.files.Count; mUnPackIdx++)
        {
            string fileName = version.files[mUnPackIdx].fileName;
            FileTool.DecompressFileLZMA(PathMgr.instance.StreamingPath + "/" + fileName, PathMgr.instance.AssetBundlePath + "/" + fileName);
        }
        // 写入版本文件
        DataWriteRead.SaveJson(PathMgr.instance.AssetBundlePath + "/version", version);
    }
}

代码中间又一个块用region包裹起来的"流程",该部分处理的都是状态机中状态的接口实现。

DoUpdate对应了unity的update,里面主要将当前解压文件的进度文字和进度数值传递到mFnLoginShow中供界面展示使用。

其他实现没有什么特别的地方。

热更模块

热更模块相对于解压来说要复杂一些,因为要处理从服务器获取版本清单文件,文件对比校验,下载,解压。

设计方案:

检查是否需要热更:

设计方案:

首先从服务器拉取最新的版本清单文件。

将获取的清单文件逐一和本地做比较,若本地文件md5和服务器不一致则添加到热更列表。

最后若热更列表数量大于0则表示需要热更。

代码实现:

    // 获取服务器版本文件
    HttpMgr.instance.HttpPost(HttpEventId.HTTP_POST_HOT_VERSION);

    // 收到服务器版本消息回调
    private void OnRecvVersion(int resCode, string jsonData)
    {
        if (resCode == 200)
        {
            mServerVersion = LitJson.JsonMapper.ToObject<AssetVersion>(jsonData);
            if (CheckHotUpdate(mServerVersion))
            {
                StartHotUpdate();
            }
            else
            {
                DoEnd();
            }
        }
        else
        {
            FDebug.LogError("服务器版本文件获取失败");
            DoEnd("hotError");
        }
    }
    
    // 检查是否需要热更新
    public bool CheckHotUpdate(AssetVersion serverVersion)
    {
        if (serverVersion == null)
        {
            return false;
        }

        AssetVersion version = DataWriteRead.ReadJson<AssetVersion>(PathMgr.instance.AssetBundlePath + "/version");
        // 本地版本不存在则更新所有文件
        if (version == null)
        {
            foreach (AssetFile serverFile in serverVersion.files)
            {
                HotFile hotFile = GetHotFile(serverFile, serverVersion.downPath);
                mHotFiles.Add(hotFile);
            }
            FDebug.Log("本地热更新文件不存在");
        }
        // 本地版本存在,则更新本地没有的文件或者md5不一致的文件
        else
        {
            foreach (AssetFile serverFile in serverVersion.files)
            {
                if (version.TryGetAssetFile(serverFile.fileName, out var localFile))
                {
                    if (!localFile.md5.Equals(serverFile.md5))
                    {
                        HotFile hotFile = GetHotFile(serverFile, serverVersion.downPath);
                        mHotFiles.Add(hotFile);
                        FDebug.Log(serverFile.fileName + "md5不一致=>本地md5:" + localFile.md5 + "。服务器md5:" + serverFile.md5);
                    }
                    else
                    {
                        FDebug.Log(serverFile.fileName + "md5一致");
                    }
                }
                else
                {
                    HotFile hotFile = GetHotFile(serverFile, serverVersion.downPath);
                    mHotFiles.Add(hotFile);
                    FDebug.Log(serverFile.fileName + "本地文件不存在");
                }
            }
        }

        return mHotFiles.Count > 0;
    }

public class HotFile
{
    public string serverFile;
    public string md5;
    public int size;
    public string localFile;
    public string fileName;
    public HttpDownPack downPack;
}

代码实现比较简单没啥好说的。。。

中间有一个HotFile hotFile = GetHotFile(serverFile, serverVersion.downPath);

是将资源文件信息的数据结构转换为热更新需要的数据结构。

HotFile结构中的HttpDownPack为下载文件需要的数据结构,该结构会在调用下载的时候由下载模块返回回来。我们就可以通过读取HttpDownPack中的各项数据来显示下载进度了。

serverFile为服务器的下载路径,localFile为本地需要保存的路径。

执行热更新:

执行热更简单来讲就是将热更文件列表中的文件逐一拿去调用下载模块。

中间如果出错就直接以hotError的形式结束热更模块。

中途下载中的时候则不停的回调mFnLoginShow来展示下载的进度。

设计方案:

代码实现:

// 开始下载流程
    private void StartHotUpdate()
    {
        // 设置下载总大小
        foreach (var oneHotFile in mHotFiles)
        {
            mTotalSize += oneHotFile.size;
        }
        // 下载第一个文件
        mDownIdx = 0;
        var hotFile = mHotFiles[mDownIdx];
        hotFile.downPack = HttpMgr.instance.HttpDownLoad(hotFile);
        // 修改状态为下载状态
        mState = EHotState.HotUpdate;
    }

    // 下载状态时定时回调
    private void DoHotUpdate()
    {
        var curHotFile = mHotFiles[mDownIdx];
        HttpDownState state = curHotFile.downPack.state;
        if (state == HttpDownState.Complete)
        {
            // 记录已下载的大小
            mNowDownSize += curHotFile.size;
            // 下载下一个
            mDownIdx++;
            if (mDownIdx < mHotFiles.Count)
            {
                var hotFile = mHotFiles[mDownIdx];
                hotFile.downPack = HttpMgr.instance.HttpDownLoad(hotFile);
            }
            // 结束下载
            else
            {
                StartUnPack();
            }
        }
        else if (state == HttpDownState.Error ||
            state == HttpDownState.Md5Failed)
        {
            DoEnd("hotError");
        }
        else
        {
            int curSize = mNowDownSize + curHotFile.downPack.downSize;
            string title = "热更新中:" + CommonTool.GetMBByteSize(curSize) + "/" + CommonTool.GetMBByteSize(mTotalSize);
            float progress = (float)curSize / mTotalSize;
            mFnLoginShow?.Invoke(title, progress);
        }
    }

代码实现中有一块地方是设置mState = EHotState.HotUpdate;

原因是整个热更模块中有两个小的子模块:下载、解压。

这两个模块中间某些地方都需要被Update调用,因此用了一个状态标记。

再热更模块的Update中根据不同的状态调用不同的子模块逻辑。

执行解压:

热更的解压部分和解压模块非常的相似,都是启动一个线程然后一个一个的解压文件。

update里面不停的获取解压的进度反馈到界面即可。

代码实现:

    // 开始解压
    private void StartUnPack()
    {
        mState = EHotState.UnPack;
        mUnPackIdx = 0;
        // 开启线程解压
        System.Threading.Thread unPack = new System.Threading.Thread(UnPackTask);
        unPack.IsBackground = true;
        unPack.Start();
    }

    // 解压任务执行
    private void UnPackTask()
    {
        for (; mUnPackIdx < mHotFiles.Count; )
        {
            var hotFile = mHotFiles[mUnPackIdx];
            FileTool.DecompressFileLZMA(hotFile.localFile, PathMgr.instance.AssetBundlePath + "/" + hotFile.fileName);
            FileTool.DeleteFile(hotFile.localFile);
            mUnPackIdx++;
        }
    }

    // 解压时定时器获取进度
    private void DoUnPack()
    {
        // 回调显示
        string title = "热更文件解压中:" + mUnPackIdx.ToString() + "/" + mHotFiles.Count.ToString();
        mFnLoginShow?.Invoke(title, (float)mUnPackIdx / mHotFiles.Count);

        if (mUnPackIdx >= mHotFiles.Count)
        {
            // 将服务器版本文件写入本地
            DataWriteRead.SaveJson<AssetVersion>(PathMgr.instance.AssetBundlePath + "/" + "version", mServerVersion);
            DoEnd();
        }
    }

完整代码

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

public class HotFile
{
    public string serverFile;
    public string md5;
    public int size;
    public string localFile;
    public string fileName;
    public HttpDownPack downPack;
}

// 热更新
public class HotUpdate : ILoginState
{
    // 热更状态
    private enum EHotState
    { 
        None,
        HotUpdate,
        UnPack,
    }
    private EHotState mState;
    // 服务器版本文件
    private AssetVersion mServerVersion;
    // 需要热更的文件
    private List<HotFile> mHotFiles = new List<HotFile>();
    // 总大小
    private int mTotalSize;
    // 当前已下载完成大小
    private int mNowDownSize;
    // 正在下载的文件编号
    private int mDownIdx;
    // 正在解压的文件编号
    private int mUnPackIdx;
    // 显示回调
    private Action<string, float> mFnLoginShow;

    #region 流程
    public override void OnStart(string key)
    {
        mState = EHotState.None;
        // 注册回调
        HttpEvent.instance.AddEvent<int, string>(HttpEventId.HTTP_POST_HOT_VERSION, OnRecvVersion);
        // 获取服务器版本文件
        HttpMgr.instance.HttpPost(HttpEventId.HTTP_POST_HOT_VERSION);
    }

    public override void DoUpdate()
    {
        if (mState == EHotState.HotUpdate)
        {
            DoHotUpdate();
        }
        else if (mState == EHotState.UnPack)
        {
            DoUnPack();
        }
    }

    protected override void OnEnd()
    {

    }

    public override void Init(Action<string, float> fnLoginShow, IStateMachine machine)
    {
        mFnLoginShow = fnLoginShow;
        Init(machine);
    }
    #endregion

    #region 逻辑
    // 收到服务器版本消息
    private void OnRecvVersion(int resCode, string jsonData)
    {
        if (resCode == 200)
        {
            mServerVersion = LitJson.JsonMapper.ToObject<AssetVersion>(jsonData);
            if (CheckHotUpdate(mServerVersion))
            {
                StartHotUpdate();
            }
            else
            {
                DoEnd();
            }
        }
        else
        {
            FDebug.LogError("服务器版本文件获取失败");
            DoEnd("hotError");
        }
    }

    // 检查是否需要热更新
    public bool CheckHotUpdate(AssetVersion serverVersion)
    {
        if (serverVersion == null)
        {
            return false;
        }

        AssetVersion version = DataWriteRead.ReadJson<AssetVersion>(PathMgr.instance.AssetBundlePath + "/version");
        // 本地版本不存在则更新所有文件
        if (version == null)
        {
            foreach (AssetFile serverFile in serverVersion.files)
            {
                HotFile hotFile = GetHotFile(serverFile, serverVersion.downPath);
                mHotFiles.Add(hotFile);
            }
            FDebug.Log("本地热更新文件不存在");
        }
        // 本地版本存在,则更新本地没有的文件或者md5不一致的文件
        else
        {
            foreach (AssetFile serverFile in serverVersion.files)
            {
                if (version.TryGetAssetFile(serverFile.fileName, out var localFile))
                {
                    if (!localFile.md5.Equals(serverFile.md5))
                    {
                        HotFile hotFile = GetHotFile(serverFile, serverVersion.downPath);
                        mHotFiles.Add(hotFile);
                        FDebug.Log(serverFile.fileName + "md5不一致=>本地md5:" + localFile.md5 + "。服务器md5:" + serverFile.md5);
                    }
                    else
                    {
                        FDebug.Log(serverFile.fileName + "md5一致");
                    }
                }
                else
                {
                    HotFile hotFile = GetHotFile(serverFile, serverVersion.downPath);
                    mHotFiles.Add(hotFile);
                    FDebug.Log(serverFile.fileName + "本地文件不存在");
                }
            }
        }

        return mHotFiles.Count > 0;
    }

    // 开始下载流程
    private void StartHotUpdate()
    {
        // 设置下载总大小
        foreach (var oneHotFile in mHotFiles)
        {
            mTotalSize += oneHotFile.size;
        }
        // 下载第一个文件
        mDownIdx = 0;
        var hotFile = mHotFiles[mDownIdx];
        hotFile.downPack = HttpMgr.instance.HttpDownLoad(hotFile);
        // 修改状态为下载状态
        mState = EHotState.HotUpdate;
    }

    // 下载状态时定时回调
    private void DoHotUpdate()
    {
        var curHotFile = mHotFiles[mDownIdx];
        HttpDownState state = curHotFile.downPack.state;
        if (state == HttpDownState.Complete)
        {
            // 记录已下载的大小
            mNowDownSize += curHotFile.size;
            // 下载下一个
            mDownIdx++;
            if (mDownIdx < mHotFiles.Count)
            {
                var hotFile = mHotFiles[mDownIdx];
                hotFile.downPack = HttpMgr.instance.HttpDownLoad(hotFile);
            }
            // 结束下载
            else
            {
                StartUnPack();
            }
        }
        else if (state == HttpDownState.Error ||
            state == HttpDownState.Md5Failed)
        {
            DoEnd("hotError");
        }
        else
        {
            int curSize = mNowDownSize + curHotFile.downPack.downSize;
            string title = "热更新中:" + CommonTool.GetMBByteSize(curSize) + "/" + CommonTool.GetMBByteSize(mTotalSize);
            float progress = (float)curSize / mTotalSize;
            mFnLoginShow?.Invoke(title, progress);
        }
    }

    // 开始解压
    private void StartUnPack()
    {
        mState = EHotState.UnPack;
        mUnPackIdx = 0;
        // 开启线程解压
        System.Threading.Thread unPack = new System.Threading.Thread(UnPackTask);
        unPack.IsBackground = true;
        unPack.Start();
    }

    // 解压任务执行
    private void UnPackTask()
    {
        for (; mUnPackIdx < mHotFiles.Count; )
        {
            var hotFile = mHotFiles[mUnPackIdx];
            FileTool.DecompressFileLZMA(hotFile.localFile, PathMgr.instance.AssetBundlePath + "/" + hotFile.fileName);
            FileTool.DeleteFile(hotFile.localFile);
            mUnPackIdx++;
        }
    }

    // 解压时定时器获取进度
    private void DoUnPack()
    {
        // 回调显示
        string title = "热更文件解压中:" + mUnPackIdx.ToString() + "/" + mHotFiles.Count.ToString();
        mFnLoginShow?.Invoke(title, (float)mUnPackIdx / mHotFiles.Count);

        if (mUnPackIdx >= mHotFiles.Count)
        {
            // 将服务器版本文件写入本地
            DataWriteRead.SaveJson<AssetVersion>(PathMgr.instance.AssetBundlePath + "/" + "version", mServerVersion);
            DoEnd();
        }
    }

    private HotFile GetHotFile(AssetFile assetFile, string downPath)
    {
        HotFile hotFile = new HotFile();
        hotFile.serverFile = downPath + assetFile.fileName;
        hotFile.md5 = assetFile.md5;
        hotFile.size = assetFile.fileSize;
        hotFile.fileName = assetFile.fileName;
        hotFile.localFile = PathMgr.instance.DownTmpPath + "/" + assetFile.fileName;
        return hotFile;
    }
    #endregion
}

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值