UE4.pak热更新

 

我们在做项目的时候,通常会有这样的需求,比如说美术新出了几个模型,需要替换原有工程中的模型.但是包已经发出去了,用户已经安装.这个时候又不可能因为一点东西让用户重新下载整个软件.或者说包体发出来的时候太大,老板要求在下载好主体的情况下,在进入软件的时候才去下载要用到的资源.这几种情况通常就是我们说的资源热更新了.在Unity里面,我们用AssetBunlde来做资源的热更新.不得不说UNITY对热更新的支持还是不错的.UNITY5.0版本以后,AssetBundle会自己生成Manifest.xml文件.不再需要用户去维护复杂的Dependency关系了.但是用UE4做的话就没那么舒服了...就像UNITY一样...在UE中,我们使用PAK来进行热更新.所以一般来说我们的项目发布的时候是没有那些UASSET资源的.加载uasset其实就是加载的pak文件中的uasset...

那么UE4的资源热更新主要有这么几步

1.打包好.Pak文件并将其放置到服务器上,其中需要一份数据文件Version.txt(json,xml或其他的格式都可以)来表示当前的版本信息.文件的内容我目前设计的很简单.主要就只有两个内容,一个是文件的MD5值,另一个是这次打包.Pak文件一共打包了什么Pak文件进去.

2.本地也需要一份Version.txt,用来比对服务器Version和本地的Version的区别,区别的标识就是文件的MD5值.如果发现MD5值不同的话就把服务器上的Version.txt中所涉及到的所有资源都下载下来并覆盖本地的资源,同时将服务器的Version.txt覆盖本地的Version.避免没必要的重复下载.

一.工具的准备  ----   .pak文件批量打包工具

首先,很蛋疼的是似乎在UE里面没办法通过C++来调用UnrealPak.exe工具,所以我用C#写了一个批量打包pak文件的工具.可以一次将多个uasset文件打包成一个.pak文件.或者将多个uasset文件打包成多个.pak文件.同时,在生成pak文件的时候还会生成版本文件.

页面效果如下:

打包出来的东西:

Version.txt内容:

实现这个工具其实非常的简单.几十行代码就可以了.接下来看一下代码...其中选中引擎根目录,选中要打包的文件等代码就不贴了.篇幅太大.以下是打包的具体代码

        private void Btn_MultipleBuild_Click(object sender, EventArgs e)
        {
            Btn_MultipleBuild.Enabled = false;
            Btn_MultipleBuild.Text = "正在打包...";

            //sb,sw,textWirter均是为了生成Json字符串而使用的
            StringBuilder sb = new StringBuilder();
            StringWriter sw = new StringWriter(sb);
            JsonTextWriter textWriter = new JsonTextWriter(sw);
            textWriter.Formatting = Formatting.Indented;
            DateTime Today = DateTime.UtcNow;
            int second = Today.Second;

            //生成文件的MD5值
            string fileMD5 = StrToMD5(second.ToString());

            textWriter.WriteStartObject();
            textWriter.WritePropertyName("FileVersion");
            textWriter.WriteStartObject();
            textWriter.WritePropertyName("MD5");
            textWriter.WriteValue(fileMD5);
            textWriter.WriteEndObject();


            
            // 检查选中的引擎根目录,其目录下是否包含有UnralPak.exe文件
            if (!File.Exists(TextBox_MultipleEnginePath.Text + @"\Engine\Binaries\Win64\UnrealPak.exe"))
            {
                MessageBox.Show("打包失败,没有找到 UnrealPak.exe,引擎路径不存在!");
                Btn_MultipleBuild.Enabled = true;
                Btn_MultipleBuild.Text = "打包";
                return;
            }

            textWriter.WritePropertyName("Files");
            textWriter.WriteStartArray();

            //根据多选框选中的文件来对文件进行打包
            string[] assetNameArray = TextBox_MultipleUassetPath.Text.Split(' ');
            for (int i = 0; i < assetNameArray.Length; i++)
            {
                string assetFullName = assetNameArray[i].Replace('\\','/');
                string[] assetArray = assetFullName.Split('/');
                string assetName = assetArray[assetArray.Length - 1].Replace(".uasset", "");
                string assetMD5 = StrToMD5(assetName + second.ToString());
                string outPath = TextBox_MultipleOutPath.Text + "\\" + assetName + ".pak";

                //通过Process相关类来多次调用UnrealPak.exe程序来打包
                ProcessStartInfo info = new ProcessStartInfo();
                info.FileName = TextBox_MultipleEnginePath.Text + @"\Engine\Binaries\Win64\UnrealPak.exe";
                info.Arguments = @outPath + @" " + @assetFullName;
                info.WindowStyle = ProcessWindowStyle.Minimized;
                Process process = Process.Start(info);
                process.WaitForExit();

                //将文件的信息写入到Json文件中
                textWriter.WriteStartObject();
                textWriter.WritePropertyName("FileName");
                textWriter.WriteValue(assetName);
                textWriter.WritePropertyName("MD5");
                textWriter.WriteValue(assetMD5);
                textWriter.WriteEndObject();
            }
            MessageBox.Show("生成pak完毕!");
            textWriter.WriteEndArray();
            textWriter.WriteEndObject();
            
            Btn_MultipleBuild.Text = "打包";
            Btn_MultipleBuild.Enabled = true;


            string saveData =
                TextBox_MultipleEnginePath.Text + ";" +
                TextBox_MultipleUassetPath.Text + ";" +
                TextBox_MultipleOutPath.Text;
            File.WriteAllText(Environment.CurrentDirectory + "/save.txt", saveData);

            //生成Version.txt文件
            File.WriteAllText(TextBox_MultipleOutPath.Text + "/Version.txt",sb.ToString());
        }

代码的话实在是太简单了..以至于没什么好说的.其中生成MD5值的代码是这样的:

<span style="white-space:pre">	</span>public string StrToMD5(string str)
        {
            byte[] data = Encoding.GetEncoding("GB2312").GetBytes(str);
            MD5 md5 = new MD5CryptoServiceProvider();
            byte[] OutBytes = md5.ComputeHash(data);

            string OutString = "";
            for (int i = 0; i < OutBytes.Length; i++)
            {
                OutString += OutBytes[i].ToString("x2");
            }
            return OutString.ToLower();
        }

那么既然已经能批量打包pak文件并生成Version.txt了.那么我们就把生成出来的这堆东西都丢到服务器上去就可以了.第一步完成.下面在UE4中完成第二部.资源的更新

二. 资源更新

1.首先我们在代码里面增加一个状态叫做GameUpdateResourcesState,资源更新状态,用来处理资源的更新

GameUpdateResourcesState.h:

// Fill out your copyright notice in the Description page of Project Settings.

#include "GameBaseState.h"
#include "IDHManagers/HttpLoader.h"
#pragma once

/**
 *
 */

 //class AHttpLoader;
class IDHOME_API GameUpdateResourcesState :public GameBaseState
{
private:
	struct FileMessage
	{
		FString FileName;
		FString FileMD5;
	};

public:
	GameUpdateResourcesState();
	~GameUpdateResourcesState();

	void OnEnter(TArray<void*> Params) override;
	void OnExit() override;

private:
	void GetServerResoucesVersionFile();

	void CompareServerAndLocalVersion(bool bSuccess, FHttpResponsePtr Response);

	const FString LocalVersionFileLocation = FPaths::GameContentDir() + TEXT("Data/Version.txt");
	const FString ServerPakDirectory = TEXT("http://localhost:80/icons/Data/");
	const FString SavePakDirectory = FPaths::GameContentDir() + TEXT("DownLoadPaks/");

	bool GetVersionMessageFromString(FString JsonString, FString& VersionFileMD5, TArray<FileMessage>& FileMessages);

	void UpdateResources(const TArray<FileMessage>& Files);

	void DownloadFileComplete(FHttpResponsePtr Response, FString SavePath, FString FileName);

	AHttpLoader* HttpLoader = nullptr;

private:
	int DownloadCompleteNumber = 0;
	int NeedDownloadNumber = 0;

};

GameUpdateResourcesState.cpp:

// Fill out your copyright notice in the Description page of Project Settings.

#include "IDHome.h"
#include "GameUpdateResoucesState.h"
#include "IDHManagers/AssetManager.h"
#include "IDHGameState.h"

GameUpdateResourcesState::GameUpdateResourcesState()
{
}

GameUpdateResourcesState::~GameUpdateResourcesState()
{
}

void GameUpdateResourcesState::OnEnter(TArray<void*> Params)
{
	//获取服务器上的资源版本文件
	GetServerResoucesVersionFile();
}

void GameUpdateResourcesState::OnExit()
{

}

void GameUpdateResourcesState::GetServerResoucesVersionFile()
{
	if (HttpLoader == nullptr)
	{
		TArray<AActor*> Actors;
		UGameplayStatics::GetAllActorsOfClass(World, AHttpLoader::StaticClass(), Actors);
		HttpLoader = Cast<AHttpLoader>(Actors[0]);
	}

	FString Url = TEXT("http://localhost:80/icons/Data/Version.txt");
	FString SendDataString;
	FDownloadDelegate DownloadServerFileDelegate;
	DownloadServerFileDelegate.BindRaw(this, &GameUpdateResourcesState::CompareServerAndLocalVersion);
	
	HttpLoader->OnHttpRequest(Url, SendDataString, DownloadServerFileDelegate);
}

void GameUpdateResourcesState::CompareServerAndLocalVersion(bool bSuccess, FHttpResponsePtr Response)
{
	//访问不到服务器或者服务器上没有该文件等...
	if (!bSuccess)
	{
		UE_LOG(LogClass, Log, TEXT("服务器上没有Version.txt文件,无需更新..."));
		AIDHGameState::Singleton()->ChangeState(GameStateEnum::LoginState);
		return;
	}


	FString LocalFile;
	FString LocalMD5;
	TArray<FileMessage> LocalFileMessages;

	FString ServerFile = Response.Get()->GetContentAsString();
	FString ServerMD5;
	TArray<FileMessage> ServerFileMessages;

	//获取服务器版本信息
	GetVersionMessageFromString(ServerFile, ServerMD5, ServerFileMessages);

	//解析出来的MD5值非法或者没有需要下载的文件
	if(ServerMD5.IsEmpty() || ServerFileMessages.Num() == 0)
	{
		AIDHGameState::Singleton()->ChangeState(GameStateEnum::LoginState);
		return;
	}

	//加载本地的资源版本文件
	if (FFileHelper::LoadFileToString(LocalFile, *LocalVersionFileLocation))
	{
		//获取本地版本信息
		GetVersionMessageFromString(LocalFile, LocalMD5, LocalFileMessages);
		if (LocalMD5.Equals(ServerMD5))
		{
			UE_LOG(LogClass, Log, TEXT("版本文件MD5值相同.无需更新"));
			AIDHGameState::Singleton()->ChangeState(GameStateEnum::LoginState);
			return;
		}
	}
	//覆盖本地的资源版本文件
	FFileHelper::SaveStringToFile(ServerFile, *LocalVersionFileLocation);
	//开始更新资源
	UpdateResources(ServerFileMessages);
}

bool GameUpdateResourcesState::GetVersionMessageFromString(FString JsonString, FString& VersionFileMD5, TArray<FileMessage>& FileMessages)
{
	TSharedPtr<FJsonObject> JsonObject;
	TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(JsonString);
	//将文件中的内容变成你需要的数据格式
	if (FJsonSerializer::Deserialize(Reader, JsonObject))
	{
		TSharedPtr<FJsonObject> FileObject = JsonObject->GetObjectField("FileVersion");
		VersionFileMD5 = FileObject->GetStringField("MD5");
		FileMessages.Empty();
		const TArray<TSharedPtr<FJsonValue>> Files = JsonObject->GetArrayField("Files");
		for (int i = 0; i < Files.Num(); i++)
		{
			const TSharedPtr<FJsonObject>* FileMessageObject;
			if (Files[i].Get()->TryGetObject(FileMessageObject))
			{
				FileMessage* FileMes = new FileMessage();
				FileMes->FileName = FileMessageObject->Get()->GetStringField("FileName");
				FileMes->FileMD5 = FileMessageObject->Get()->GetStringField("MD5");
				FileMessages.Add(*FileMes);
			}
		}
		return true;
	}
	else
	{
		UE_LOG(LogClass, Error, TEXT("无法解析json数据,Json数据可能有误..."));
		return false;
	}
}

void GameUpdateResourcesState::UpdateResources(const TArray<FileMessage>& Files)
{
	NeedDownloadNumber = Files.Num();
	DownloadCompleteNumber = 0;

	//一个个文件进行下载
	for (int i = 0; i < Files.Num(); i++)
	{
		FString FileURL = ServerPakDirectory + Files[i].FileName + TEXT(".pak");
		FString SaveURL = SavePakDirectory + Files[i].FileName + TEXT(".pak");
		FRequestDelegate DownloadCompleteDelegate;
		DownloadCompleteDelegate.BindRaw(this, &GameUpdateResourcesState::DownloadFileComplete, SaveURL, Files[i].FileName);
		AAssetManager::Singleton()->DownloadPakFile(FileURL, DownloadCompleteDelegate);
	}
}

//文件下载完成回调
void GameUpdateResourcesState::DownloadFileComplete(FHttpResponsePtr Response, FString SavePath, FString FileName)
{
	FFileHelper::SaveArrayToFile(Response->GetContent(), *SavePath);
	UE_LOG(LogClass, Log, TEXT("文件:%s 已经下载完成,保存在%s"), *FileName, *SavePath);
	DownloadCompleteNumber++;
	//如果所有文件都下载完了.那么就进行下一个游戏状态.
	if (DownloadCompleteNumber == NeedDownloadNumber)
	{
		UE_LOG(LogClass, Log, TEXT("资源已全部更新完毕,跳转到登录状态"));
		AIDHGameState::Singleton()->ChangeState(GameStateEnum::LoginState);
	}
}

这里面,AHttpLoader是我写的一个发起HttpRequest的一个单例类,主要用于和服务器交互,请求数据或者是下载文件等..还有几个DownLoadDelegate之类的委托.整体思路很简单

1.进入该状态以后,去下载服务器上的版本信息文件

2.如果连接不上服务器或者没有服务器上没有这个文件,那么就直接执行下一个游戏状态的逻辑.如果能连接上并下载到了版本信息文件.进行下一步

3.获取服务器版本文件信息,如果数据合法,那么开始加载本地信息文件,对两个文件的MD5值进行比较.如果相同,那么就进行下一游戏状态的逻辑,否则执行下一步

4.如果MD5值不同,那么就先覆盖本地的版本信息文件,然后开始更新资源

5.一个一个资源的下载,下载完最后一个文件的时候,资源更新状态的逻辑就已经都做完了,那么可以执行接下来的逻辑啦.

整个资源的热更新思路就是这么简单.

文章里面涉及到的Pak文件和下载部分的教程我之前都已经写过了.串在一起就是一份完整的热更新思路了.至于你拿到了pak文件以后要怎么使用,那是你的事,不在这次的讨论范围内.

但是有一点值得注意的,经过我测试,目前UE4的pak文件是没有依赖关系的.也就是说,如果你打包了一个UBlueprint,里面包含了一个UStaticMesh的话,那么当你直接加载这个UBlueprint到世界中的时候,他的Mesh是会丢失的.在Unity里面我们通过AssetBundle,可以先将Mesh加载进内存,这种情况下就能正常加载出来Prefab了..但是在UE4里面不行.所以可能我们需要自己去维护这一个依赖关系.甚至可能对于UBlueprint这些要生成一个文件来记录他包含了什么依赖,就像UNITY中做的那样...处理依赖这个问题在UE4里面估计会是一个比较复杂的问题...这一块就先不讨论了..mmmm...有可能以后也不会讨论...

整篇文章写下来发现好像没有什么特别的东西...最难搞的Pak加载都写在上一篇文章了...但是主要是提供一个思路给那些没有做过热更新的小伙伴吧.或者是不知道怎么批量打包pak的小伙伴...

这次的热更新到这里应该就差不多全部解决了...

下次见...

Author: 90cg