Unity开发(七)多渠道、多平台、多服务器的版本管理和资源热更

文章目录

    • 前言
    • 分支管理
      • 功能拆分
      • CDN目录结构
      • Git管理
    • 版本管理
      • 版本号
      • 版本文件单元
      • 版本文件表
      • CDN文件目录
    • 资源热更
      • 热更流程
      • 错误处理
    • 代码整合

前言

前面6篇博客,已经把Unity资源加载的最核心的功能搭建完成了,包括下载,ab加载(ab打包),asset加载,prefab实例化。

这篇文章主要阐述网络游戏开发中必不可少的版本管理。为什么需要版本和热更,这个已经是一个游戏开发者的一个共识,就不需要笔者再做过多解释了吧。

分支管理

一般游戏在开发中,会遇到多渠道、多平台、多服务器以及多语言的管理难题,而不当的处理会导致更高的理解和管理成本,所以我们需要明确拆分。

功能拆分

多渠道,一般是上线前夕需要部署到正式服上的模块,例如以下

  1. huiwei
  2. xiaomi
  3. oppo
  4. 。。。

多平台,一般是3个平台,如下

  1. iOS
  2. Android
  3. PC

多服务器,一般游戏依据需要,通用的如下

  1. 内网测试服(包括私人测试服)
  2. 外网测试服(用于接入外部渠道等需要的网络)
  3. 商务服(用于外部推广使用)
  4. 审核服(用于版号,ios等审核)
  5. 准正式服(跟正式服基本一致,一般比正式服提前一个版本)
  6. 正式服(对玩家开放的服务器)
  7. 特殊服

多语言,这个会根据需求,一般只是字符串读取配置不一样和个别图片替换,跟上面的功能目录拆分不同,属于游戏内功能。

多地区,如果全球发行的游戏,一般还要兼顾地区的多渠道(一般一个海外地区就一个渠道),笔者将其作为一个渠道处理

CDN目录结构

对应上面的逻辑,我们CDN目录结构设计如下

--InTest
  --Default			
    --IOS
    --Android
    --PC
  --Test
--OutTest
  --Default
  --Test
--Ready
--Release
  --Default
  	--IOS		一般只有一个IOS平台
  	--PC		
  --huawei		一般渠道都只有Android
    --Android	
  --xiaomi
    --Android
  --Japanese	一般多地区,是跟渠道并行一个层级
  	--IOS
  	--Android
  	--PC  

上述只是资源管理部分,有很多情况下,服务器很可能是同一台服务器,比如常见的iOS和Android数据互通,这样是根据游戏启动,游戏包体对应配置到服务端获取对应服务器地址,再连接对应服务器。

Git管理

应对上述的复杂条件,Git管理目前已经成为一个主流。
git分支管理内容非常多,不过不是本文的重点,后面有时间再开文再具体讲,只讲几个关键点。

  1. 项目中最好只有一条主开发分支线,多渠道,多语言,多平台的功能都在上面,关键节点拉出分支固定节点内容
  2. 要发布一个渠道,从开发主分支拉出分支作为xx渠道分支,导出package
  3. 热更xx渠道,从xx渠道分支拉出x.x.1版本,提交内容打patch,再从x.x.1 拉出x.x.2提交内容打patch
  4. xx渠道换包,重复第二步

版本管理

版本管理,管理的是版本号版本文件
版本号是一个通识的,可被玩家查看的标识,也就是最通俗的1.1.1,1.1.2,1.2.1这样的版本号。
而版本文件,一般是玩家不可见的,代表游戏资源文件。一般每个资源文件都有一个版本号,表示该版本文件需要使用哪个版本。

版本号

版本号一般有三个段组成,
maxVer表示第几代游戏,表示完全跨度的版本,比如龙之谷,龙之谷2,龙之谷3这样的差异
midVer表示换包版本号,表示包体更新,也就是需要更包,换包才需要提升一位
minVer表示资源版本号,表示可以直接可以通过下载热更的,也就是在游戏开始自己提示下载资源的

版本文件单元

public class FileVersionData
{
    public string name;
    public int size;
    public string md5;
    public int version;
    public override string ToString()
    {
        return name + "\t" + size + "\t" + md5 + "\t" + version;
    }
    public void InitData(string str)
    {
        var sps = str.Split('\t');

        name = sps[0];
        size = int.Parse(sps[1]);
        md5 = sps[2];
        version = int.Parse(sps[3]);
    }
}

每个文件都会记录名字,长度,MD5校验值和版本号。

版本文件表

而所有文件的版本信息会保存到一个版本文件表中

public void InitVersionFile(string path)
{
    _fileList.Clear();
    if (!File.Exists(path)) return;

    var lines = File.ReadAllLines(path);
    foreach(var line in lines)
    {
        if (string.IsNullOrEmpty(line)) continue;

        var fileVersionData = new FileVersionData();
        fileVersionData.InitData(line);

        _fileList.Add(fileVersionData.name, fileVersionData);
    }
}

版本文件表一般用于热更资源下载和资源文件加载的路径。

CDN文件目录

依据上面的规则,会得到如下的目录结构

--PC         		平台
--IOS
--Android
  --1001000			当前版本的资源目录,包含所有资源
    --Assets		ab导出的目录
      --Prefab
      	--xxx1.ab	ab文件
      	--xxx2.ab
      --Texture
        --xxx1.ab
        --xxx2.ab
      --Assets		ab的manifast文件
    --Bytes			二进制配置文件目录
      --xxx1.bytes	二进制文件(加密)
  --1001001			只导出比1001001增加和修改的文件
    --Assets
      --Prefab
      	--xxx1.ab	修改的保留
      	...xxx2.ab	相同的不放人目录
      	--xxx3.ab	增加的保留
      --Assets
  --1001002
  --1001003
  --1002000			换包需要新启midVer,会全部重新导出一遍资源
  --1002001
  --1001000.txt		版本文件表,包含所有文件信息
  --1001001.txt
  --1001002.txt
  --1002000.txt
  --1002001.txt
  --version.txt		当前版本号

上面的表示就一目了然了

资源热更

资源热更,一直是游戏开发的难点和痛点,不稳定和不高效一直是玩家流失的一大因素。

热更流程

清理
UpdateLoad
比对小版本小
提取热更文件
玩家同意
全部下载完成
成功
比对中版本小
比对版本一致
开始
读取本地版本
获取CDN服务器版本
下载版本文件
请求下载文件
下载热更文件
保存本地版本号
结束
请求换包

一般游戏的下载流程大同小异,具体实现可以看代码
代码中,使用的下载接口请移步文章多线程断点续传文件下载管理器

错误处理

下载最难的是错误处理,所以首先要保证代码的正确性,其次要考虑错误的全面性,最后要实现错误后的重启。

  1. 网络断开导致的下载打断,这个本文没有贴出(放到另一个模块做了)
  2. 玩家不同意下载,退回开始页面
  3. 下载关键文件失败,重新启动下载
  4. 下载普通文件失败,循环尝试下载(非本文内容)

代码整合

using System.Collections.Generic;
using System.IO;
using System.Text;

public class FileVersionData
{
    public string name;
    public int size;
    public string md5;
    public int version;
    public bool initPackage; //是否在初始包内 

    public override string ToString()
    {
        return name + "\t" + size + "\t" + md5 + "\t" + version;
    }

    public void InitData(string str)
    {
        var sps = str.Split('\t');

        name = sps[0];
        size = int.Parse(sps[1]);
        md5 = sps[2];
        version = int.Parse(sps[3]);

#if BIG_PACKAGE && !UNITY_ANDROID
        initPackage = version % 1000 == 0;
#endif
    }

}
public class FileVersionMgr
{

    private static FileVersionMgr _Instance = null;

    public static FileVersionMgr I
    {
        get
        {
            if (_Instance == null) _Instance = new FileVersionMgr();
            return _Instance;
        }
    }

    private Dictionary<string, FileVersionData> _fileList;

    private FileVersionMgr()
    {
        _fileList = new Dictionary<string, FileVersionData>();
    }

    public void Clear()
    {
        _fileList.Clear();
    }

    public void InitVersionFile(string path)
    {
        _fileList.Clear();
        if (!File.Exists(path)) return;

        var lines = File.ReadAllLines(path);
        foreach(var line in lines)
        {
            if (string.IsNullOrEmpty(line)) continue;

            var fileVersionData = new FileVersionData();
            fileVersionData.InitData(line);

            _fileList.Add(fileVersionData.name, fileVersionData);
        }

    }

    public void SaveVersionFile(string path)
    {
        if (File.Exists(path)) File.Delete(path);

        StringBuilder sb = new StringBuilder();
        foreach(var fileData in _fileList.Values)
        {
            sb.Append(fileData.ToString() + "\n");
        }

        File.WriteAllText(path, sb.ToString());
    }

    public FileVersionData GetFileVersionData(string name)
    {
        if (_fileList.ContainsKey(name))
            return _fileList[name];
        return null;
    }

    //提取大于当前版本文件
    public List<FileVersionData> FindUpdateFiles(int version)
    {
        List<FileVersionData> updateList = new List<FileVersionData>();

        foreach(var fileData in _fileList.Values)
        {
            //大包初始版本,不提取包内资源
            if (fileData.initPackage) continue;

            if (fileData.version >= version)
                updateList.Add(fileData);
        }

        return updateList;
    }

    //根据版本获取路径
    public string GetFilePath(string name)
    {
        if(_fileList.ContainsKey(name) && _fileList[name].initPackage)
        {
            return GPath.StreamingAssetsPath + name;
        }

        return GPath.StreamingAssetsPath + name;
    }

    //根据文件是否存在,返回路径,没有返回null
    public string GetFilePathByExist(string name)
    {
        if (_fileList.ContainsKey(name) && _fileList[name].initPackage)
        {
            return GPath.StreamingAssetsPath + name;
        }

        string path = GPath.StreamingAssetsPath + name;
        if (File.Exists(path)) return path;

        path = GPath.StreamingAssetsPath + name;
        if (File.Exists(path)) return path;

        return null;
    }


    //新文件或有更改,替换
    public bool ReplaceFileVersionData(FileVersionData newData)
    {
        if (!_fileList.ContainsKey(newData.name))
        {
            _fileList.Add(newData.name, newData);
            return true;
        }

        var oldData = _fileList[newData.name];
        if (newData.size != oldData.size || newData.md5 != oldData.md5)
        {
            _fileList[newData.name] = newData;
            return true;
        }
        return false;
    }

    public void DeleteFileVersionData(string name)
    {
        if (_fileList.ContainsKey(name))
            _fileList.Remove(name);
    }



}


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

public struct VersionCode
{
    private int _version;

    public int version
    {
        get { return _version; }
        set { _version = value; }
    }

    public int maxVer
    {
        get { return _version / 1000000; }
    }
    public int midVer
    {
        get { return _version / 1000 % 1000; }
    }
    public int minVer
    {
        get { return _version % 1000; }
    }

    public override string ToString()
    {
        return maxVer + "." + midVer + "." + minVer;
    }
}

public class HotfixMgr
{
    public delegate void HotfixCallback(HotfixState state, string msg, Action<bool> callback);

    public enum HotfixState
    {
        None,  //正在为您初始化资源包
        ReadLocalVersion, //读取本地版本
        CheckCDNVersion,  //获取资源cdn版本
        DownloadVersionFile, //下载版本文件
        CompareAssetVersion, //比对所有版本文件
        DownloadFiles, //下载需要的资源文件
        SaveVersion, //保存版本
        Finished,   //更新完毕,祝你游戏愉快

        RequsetUpdatePackage,  //请求换包
        RequestDownloadFiles, //请求开始下载文件
        RequestErrorRestart, // error重新开始下载
    }

    private static HotfixMgr _Instance = null;

    public static HotfixMgr I
    {
        get
        {
            if (_Instance == null) _Instance = new HotfixMgr();
            return _Instance;
        }
    }

    private HotfixState _currentState;
    private HotfixCallback _hotfixCallback;
    private VersionCode _clientVersion;
    private VersionCode _serverVersion;

    private HotfixMgr()
    {
        _currentState = HotfixState.None;
        _hotfixCallback = null;
        _clientVersion = new VersionCode();
        _serverVersion = new VersionCode();

    }

    public HotfixState currentState
    {
        get { return _currentState; }
    }

    public VersionCode currentVersion
    {
        get { return _clientVersion; }
    }

    public void RegisterRequsetCallback(HotfixCallback hotfixCallback)
    {
        _hotfixCallback = hotfixCallback;
    }

    //启动前,请先调用RegisterRequsetCallback注册回调
    public void Start()
    {
        if(_hotfixCallback == null)
        {
            Utils.LogError("Null Error is _currentState");
            return ;
        }

#if UNIRY_EDITOR && !TEST_AB
        //编辑器状态,不热更
        Finished();
#else
        ReadLocalVersion();
#endif
    }

    private void UpdateError(string msg="")
    {
        _hotfixCallback(HotfixState.RequestErrorRestart, msg, (run) =>
        {
            if (run) Start();
        });
    }

    private void ReadLocalVersion()
    {
        _currentState = HotfixState.ReadLocalVersion;
        string versionPath = GPath.PersistentAssetsPath + GPath.VersionFileName;

        int version;
        if (File.Exists(versionPath))
        {
            version = int.Parse(File.ReadAllText(versionPath));
        }
        else
        {
            version = int.Parse(Resources.Load<TextAsset>(GPath.VersionFileName).text);
        }

        _clientVersion.version = version;
    }

    private void CheckCDNVersion()
    {
        DownloadMgr.I.ClearAllDownloads();

        _currentState = HotfixState.CheckCDNVersion;

        string versionServerFile = GPath.PersistentAssetsPath + "versionServer.txt";
        if (File.Exists(versionServerFile)) File.Delete(versionServerFile);

        DownloadUnit versionUnit = new DownloadUnit();
        versionUnit.name = "version.txt";
        versionUnit.downUrl = GPath.CDNUrl + GPath.VersionFileName;
        versionUnit.savePath = versionServerFile;
        versionUnit.errorFun = (DownloadUnit downUnit, string msg) =>
        {
            Utils.LogWarning("CheckAssetVersion Download Error " + msg + "\n" + downUnit.downUrl);
            DownloadMgr.I.DeleteDownload(versionUnit);
            UpdateError();
        };
        versionUnit.completeFun = (DownloadUnit downUnit) =>
        {
            if (!File.Exists(versionServerFile))
            {//文件不存在,重新下载
                UpdateError();
                return;
            }

            string versionStr = File.ReadAllText(versionServerFile);
            int curVersion = 0;
            if (!int.TryParse(versionStr, out curVersion))
            {
                Utils.LogError("CheckAssetVersion version Error " + versionStr);
                UpdateError();
                return;
            }

            _serverVersion.version = curVersion;
            Utils.Log("本地版本:" + _clientVersion + " 服务器版本:" + curVersion);

            if(_serverVersion.midVer < _clientVersion.midVer)
            {
                UpdateError();
            }
            else if(_serverVersion.midVer > _clientVersion.midVer)
            {//换包
                _hotfixCallback(HotfixState.RequestErrorRestart, "", (run) =>
                {

                });
            }
            else if (_serverVersion.minVer < _clientVersion.minVer)
            {
                Finished();
            }
            else DownloadVersionFile();
        };

        DownloadMgr.I.DownloadAsync(versionUnit);
    }

    private void DownloadVersionFile()
    {
        _currentState = HotfixState.DownloadVersionFile;

        string versionListServerFile = GPath.PersistentAssetsPath + _serverVersion.ToString() + ".txt";
        if (File.Exists(versionListServerFile)) File.Delete(versionListServerFile);

        DownloadUnit versionListUnit = new DownloadUnit();
        versionListUnit.name = _serverVersion.ToString() + ".txt";
        versionListUnit.downUrl = GPath.CDNUrl + versionListUnit.name;
        versionListUnit.savePath = versionListServerFile;
        versionListUnit.errorFun = (DownloadUnit downUnit, string msg) =>
        {
            Utils.LogWarning("CompareVersion Download Error " + msg + "\n" + downUnit.downUrl);
            DownloadMgr.I.DeleteDownload(versionListUnit);
            UpdateError();
        };
        versionListUnit.completeFun = (DownloadUnit downUnit) =>
        {
            if (!File.Exists(versionListServerFile))
            {//文件不存在,重新下载
                UpdateError();
                return;
            }

            FileVersionMgr.I.InitVersionFile(versionListServerFile);
            var updateList = FileVersionMgr.I.FindUpdateFiles(_clientVersion.version);

            Utils.Log("版本文件数量:" + updateList.Count);
            if (updateList.Count > 0) StartDownloadList(updateList);
            else SaveVersion();
        };

        DownloadMgr.I.DownloadAsync(versionListUnit);
        Utils.Log("版本文件url:" + versionListUnit.downUrl);
    }

    private void StartDownloadList(List<FileVersionData> updateList)
    {
        _currentState = HotfixState.CompareAssetVersion;

        string saveRootPath = GPath.PersistentAssetsPath;
        string urlRootPath = GPath.CDNUrl;

        List<DownloadUnit> downloadList = new List<DownloadUnit>();
        Dictionary<string, int> downloadSizeList = new Dictionary<string, int>();
        int downloadCount = updateList.Count;
        int downloadMaxCount = downloadCount;

        int existAllSize = 0;
        int totalAllSize = 0;

        int downloadedFileSizes = 0;
        int downloadAllFileSize = 0;

        foreach (var fileData in updateList)
        {
            string savePath = saveRootPath + fileData.name;
            if (File.Exists(savePath))
            {
                FileInfo fi = new FileInfo(savePath);
                if (fileData.size == (int)fi.Length)
                {//长度相等,可能是已经下载的
                    downloadSizeList.Add(fileData.name, fileData.size);
                    existAllSize += fileData.size;
                }
                else
                {//长度不相等,需要重新下载
                    //Utils.Log("StartDownloadList Delete fileData.size="+ fileData.size + " fi.Length="+ fi.Length);
                    fi.Delete();
                    downloadSizeList.Add(fileData.name, 0);
                    totalAllSize += fileData.size;
                }
            }
            else
            {
                downloadSizeList.Add(fileData.name, 0);
                totalAllSize += fileData.size;
            }

            DownloadUnit downloadUnit = new DownloadUnit();
            downloadUnit.name = fileData.name;
            downloadUnit.downUrl = urlRootPath + fileData.version + "/Assets/" + fileData.name;
            downloadUnit.savePath = savePath;
            downloadUnit.size = fileData.size;
            downloadUnit.md5 = fileData.md5;

            downloadUnit.errorFun = (DownloadUnit downUnit, string msg) =>
            {
                string errorMgs = "StartDownloadList Error " + downUnit.downUrl + " " + msg + "\n";
                Utils.LogWarning(errorMgs);
            };

            downloadUnit.progressFun = (DownloadUnit downUnit, int curSize, int allSize) =>
            {
                downloadedFileSizes += curSize - downloadSizeList[downUnit.name];
                downloadSizeList[downUnit.name] = curSize;
            };

            downloadUnit.completeFun = (DownloadUnit downUnit) =>
            {
                downloadCount--;

                int percent = (downloadMaxCount - downloadCount) * 10 / downloadMaxCount;

                if (downloadCount == 0)
                {//下载完成
                    SaveVersion();
                }
            };

            downloadList.Add(downloadUnit);
        }

        downloadedFileSizes = 0;
        downloadAllFileSize = totalAllSize;

        //如果文件都存在,用已下载的作为长度,概率极低,为了表现进度特殊处理
        if (totalAllSize == 0)
        {
            downloadedFileSizes = 1;
            downloadAllFileSize = 1;
        }


        Utils.Log("下载文件总大小:" + totalAllSize);

        Action downloadFun = () =>
        {
            _currentState = HotfixState.DownloadFiles;
            foreach (var downUnit in downloadList)
            {
                DownloadMgr.I.DownloadAsync(downUnit);
            }
        };

        if (totalAllSize < 1024 * 1024) //<1MB
        {
            downloadFun();
        }
        else
        {
            _hotfixCallback(HotfixState.RequestDownloadFiles, "游戏需要更新部分资源("+
                (totalAllSize/1024/1024) + "M),建议您在无线局域网环境下更新", (run) =>
            {
                if(run == true) downloadFun();

            });
        }

    }

    private void SaveVersion()
    {
        _currentState = HotfixState.SaveVersion;

        _clientVersion.version = _serverVersion.version + 1;
        string versionPath = GPath.PersistentAssetsPath + GPath.VersionFileName;
        File.WriteAllText(versionPath, _clientVersion.version.ToString());
    }

    public void Finished()
    {
        _currentState = HotfixState.Finished;

        _hotfixCallback(HotfixState.Finished, "", null);

    }

}

你可能感兴趣的:(Unity)