前面6篇博客,已经把Unity资源加载的最核心的功能搭建完成了,包括下载,ab加载(ab打包),asset加载,prefab实例化。
这篇文章主要阐述网络游戏开发中必不可少的版本管理。为什么需要版本和热更,这个已经是一个游戏开发者的一个共识,就不需要笔者再做过多解释了吧。
一般游戏在开发中,会遇到多渠道、多平台、多服务器以及多语言的管理难题,而不当的处理会导致更高的理解和管理成本,所以我们需要明确拆分。
多渠道,一般是上线前夕需要部署到正式服上的模块,例如以下
huiwei
xiaomi
oppo
。。。
多平台,一般是3个平台,如下
iOS
Android
PC
多服务器,一般游戏依据需要,通用的如下
内网测试服
(包括私人测试服)外网测试服
(用于接入外部渠道等需要的网络)商务服
(用于外部推广使用)审核服
(用于版号,ios等审核)准正式服
(跟正式服基本一致,一般比正式服提前一个版本)正式服
(对玩家开放的服务器)特殊服
多语言,这个会根据需求,一般只是字符串读取配置不一样和个别图片替换,跟上面的功能目录拆分不同,属于游戏内功能。
多地区,如果全球发行的游戏,一般还要兼顾地区的多渠道(一般一个海外地区就一个渠道),笔者将其作为一个渠道处理
对应上面的逻辑,我们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分支管理内容非常多,不过不是本文的重点,后面有时间再开文再具体讲,只讲几个关键点。
版本管理,管理的是版本号
和版本文件
。
版本号是一个通识的,可被玩家查看的标识,也就是最通俗的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);
}
}
版本文件表一般用于热更资源下载和资源文件加载的路径。
依据上面的规则,会得到如下的目录结构
--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 当前版本号
上面的表示就一目了然了
资源热更,一直是游戏开发的难点和痛点,不稳定和不高效一直是玩家流失的一大因素。
一般游戏的下载流程大同小异,具体实现可以看代码
代码中,使用的下载接口请移步文章多线程断点续传文件下载管理器
下载最难的是错误处理,所以首先要保证代码的正确性,其次要考虑错误的全面性,最后要实现错误后的重启。
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);
}
}