UE4源码分析:修改游戏默认的数据存储路径

默认情况下,使用UE打包出游戏的Apk并在手机上安装之后,启动游戏会在/storage/emulated/0/UE4Game/下创建游戏的数据目录(也就是内部存储器的根目录下)。按照Google的规则,每个APP的数据文件最好都是放在自己的私有目录,所以我想要把UE打包出来的游戏的数据全放到/storage/emulated/0/Android/data/PACKAGE_NAME目录中(不管是log、ini、还是crash信息)。
一个看似简单的需求,有几种不同的方法,涉及到了UE4的路径管理/JNI/Android Manifest以及对UBT的代码的分析。

默认的路径:

有两种方法,一种是改动引擎代码实现对GFilePathBase的修改,另一种是不改动引擎只添加项目设置中的manifest就可以,当然不改动引擎是最好的,不过既然是分析,我就两个都来搞一下,顺便从UBT代码分析一下Project Setting-Android-Use ExternalFilesDir for UE4Game Files选项没有作用的原因。

改动引擎代码实现

翻了一下引擎代码,发现路径的这部分代码是写在这里的:AndroidPlatformFile.cpp#L946,它是在GFilePathBase然后组合UE4Game+PROJECT_NAME的路径。

在UE4.22及之前的引擎版本中是在AndroidFile.cpp文件中的,4.23+是在AndroidPlatformFile.cpp中的。
基础路径GFilePathBase的初始化是在Launch\Private\Android\AndroidJNI.cpp中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// Launch\Private\Android\AndroidJNI.cpp
JNIEXPORT jint JNI_OnLoad(JavaVM* InJavaVM, void* InReserved)
{
FPlatformMisc::LowLevelOutputDebugString(TEXT("In the JNI_OnLoad function"));

JNIEnv* Env = NULL;
InJavaVM->GetEnv((void **)&Env, JNI_CURRENT_VERSION);

// if you have problems with stuff being missing especially in distribution builds then it could be because proguard is stripping things from java
// check proguard-project.txt and see if your stuff is included in the exceptions
GJavaVM = InJavaVM;
FAndroidApplication::InitializeJavaEnv(GJavaVM, JNI_CURRENT_VERSION, FJavaWrapper::GameActivityThis);

FJavaWrapper::FindClassesAndMethods(Env);

// hook signals
if (!FPlatformMisc::IsDebuggerPresent() || GAlwaysReportCrash)
{
// disable crash handler.. getting better stack traces from system for now
//FPlatformMisc::SetCrashHandler(EngineCrashHandler);
}

// Cache path to external storage
jclass EnvClass = Env->FindClass("android/os/Environment");
jmethodID getExternalStorageDir = Env->GetStaticMethodID(EnvClass, "getExternalStorageDirectory", "()Ljava/io/File;");
jobject externalStoragePath = Env->CallStaticObjectMethod(EnvClass, getExternalStorageDir, nullptr);
jmethodID getFilePath = Env->GetMethodID(Env->FindClass("java/io/File"), "getPath", "()Ljava/lang/String;");
jstring pathString = (jstring)Env->CallObjectMethod(externalStoragePath, getFilePath, nullptr);
const char *nativePathString = Env->GetStringUTFChars(pathString, 0);
// Copy that somewhere safe
GFilePathBase = FString(nativePathString);
GOBBFilePathBase = GFilePathBase;

// then release...
Env->ReleaseStringUTFChars(pathString, nativePathString);
Env->DeleteLocalRef(pathString);
Env->DeleteLocalRef(externalStoragePath);
Env->DeleteLocalRef(EnvClass);
FPlatformMisc::LowLevelOutputDebugStringf(TEXT("Path found as '%s'\n"), *GFilePathBase);

// Get the system font directory
jstring fontPath = (jstring)Env->CallStaticObjectMethod(FJavaWrapper::GameActivityClassID, FJavaWrapper::AndroidThunkJava_GetFontDirectory);
const char * nativeFontPathString = Env->GetStringUTFChars(fontPath, 0);
GFontPathBase = FString(nativeFontPathString);
Env->ReleaseStringUTFChars(fontPath, nativeFontPathString);
Env->DeleteLocalRef(fontPath);
FPlatformMisc::LowLevelOutputDebugStringf(TEXT("Font Path found as '%s'\n"), *GFontPathBase);

// Wire up to core delegates, so core code can call out to Java
DECLARE_DELEGATE_OneParam(FAndroidLaunchURLDelegate, const FString&);
extern CORE_API FAndroidLaunchURLDelegate OnAndroidLaunchURL;
OnAndroidLaunchURL = FAndroidLaunchURLDelegate::CreateStatic(&AndroidThunkCpp_LaunchURL);

FPlatformMisc::LowLevelOutputDebugString(TEXT("In the JNI_OnLoad function 5"));

char mainThreadName[] = "MainThread-UE4";
AndroidThunkCpp_SetThreadName(mainThreadName);

return JNI_CURRENT_VERSION;
}

我们的目的就是要改动GFilePathBase的值,因为默认引擎里是通过调用getExternalStorageDirectory得到的,其是外部存储的目录即/storage/emulated/0/,再拼接上UE4Game就是默认平时我们看到的路径。

因为getExternalStorageDirectory这些都是Environment的静态成员,没有我们想要获取的路径的方法,但是Context中有,UE的代码中并没有获取到,所以我们要像一个办法得到App的Context。

可以通过下列方法从JNI获取Context,:

1
2
3
4
5
6
7
8
9
10
// get context
jobject JniEnvContext;
{
jclass activityThreadClass = Env->FindClass("android/app/ActivityThread");
jmethodID currentActivityThread = FJavaWrapper::FindStaticMethod(Env, activityThreadClass, "currentActivityThread", "()Landroid/app/ActivityThread;", false);
jobject at = Env->CallStaticObjectMethod(activityThreadClass, currentActivityThread);
jmethodID getApplication = FJavaWrapper::FindMethod(Env, activityThreadClass, "getApplication", "()Landroid/app/Application;", false);

JniEnvContext = FJavaWrapper::CallObjectMethod(Env, at, getApplication);
}

之后可以使用Context下的函数getExternalFilesDir获取到我们想要的路径:

注意getExternalFilesDir的原型是:File getExternalFilesDir(String),在使用JNI获取jmehodID时一定注意签名要传对,不然会Crash,其签名是(Ljava/lang/String;)Ljava/io/File;

1
2
3
4
5
6
7
jmethodID getExternalFilesDir = Env->GetMethodID(Env->GetObjectClass(JniEnvContext), "getExternalFilesDir", "(Ljava/lang/String;)Ljava/io/File;");
// get File
jobject ExternalFileDir = Env->CallObjectMethod(JniEnvContext, getExternalFilesDir,nullptr);
// getPath method in File class
jmethodID getFilePath = Env->GetMethodID(Env->FindClass("java/io/File"), "getPath", "()Ljava/lang/String;");
jstring pathString = (jstring)Env->CallObjectMethod(ExternalFileDir, getFilePath, nullptr);
const char *nativePathString = Env->GetStringUTFChars(pathString, 0);

得到的nativePathString的值为:

1
/storage/emulated/0/Android/data/com.imzlp.GWorld/files

其中的com.imzlp.GWorld是你的App的包名。

然后将其赋值给GFilePathBase即可,打开编辑器重新打包Apk,安装上之后该APP所有的数据就会在/storage/emulated/0/Android/data/PACKAGE_NAME/files下了。

在UE中调用和操作JNI以及Android存储路径相关的链接:

使用Manifest控制

OK,关于分析引擎中修改GFilePathBase的大致写完了,其实有个不改动引擎的办法,就是在项目设置中添加minifest

其实原理也在AndoidJNI.cpp里了,AndroidJNI.cpp中有以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
//This function is declared in the Java-defined class, GameActivity.java: "public native void nativeSetGlobalActivity();"
JNI_METHOD void Java_com_epicgames_ue4_GameActivity_nativeSetGlobalActivity(JNIEnv* jenv, jobject thiz, jboolean bUseExternalFilesDir, jstring internalFilePath, jstring externalFilePath, jboolean bOBBinAPK, jstring APKFilename /*, jobject googleServices*/)
{
if (!FJavaWrapper::GameActivityThis)
{
GGameActivityThis = FJavaWrapper::GameActivityThis = jenv->NewGlobalRef(thiz);
if (!FJavaWrapper::GameActivityThis)
{
FPlatformMisc::LowLevelOutputDebugString(TEXT("Error setting the global GameActivity activity"));
check(false);
}

// This call is only to set the correct GameActivityThis
FAndroidApplication::InitializeJavaEnv(GJavaVM, JNI_CURRENT_VERSION, FJavaWrapper::GameActivityThis);

// @todo split GooglePlay, this needs to be passed in to this function
FJavaWrapper::GoogleServicesThis = FJavaWrapper::GameActivityThis;
// FJavaWrapper::GoogleServicesThis = jenv->NewGlobalRef(googleServices);

// Next we check to see if the OBB file is in the APK
//jmethodID isOBBInAPKMethod = jenv->GetStaticMethodID(FJavaWrapper::GameActivityClassID, "isOBBInAPK", "()Z");
//GOBBinAPK = (bool)jenv->CallStaticBooleanMethod(FJavaWrapper::GameActivityClassID, isOBBInAPKMethod, nullptr);
GOBBinAPK = bOBBinAPK;

const char *nativeAPKFilenameString = jenv->GetStringUTFChars(APKFilename, 0);
GAPKFilename = FString(nativeAPKFilenameString);
jenv->ReleaseStringUTFChars(APKFilename, nativeAPKFilenameString);

const char *nativeInternalPath = jenv->GetStringUTFChars(internalFilePath, 0);
GInternalFilePath = FString(nativeInternalPath);
jenv->ReleaseStringUTFChars(internalFilePath, nativeInternalPath);

const char *nativeExternalPath = jenv->GetStringUTFChars(externalFilePath, 0);
GExternalFilePath = FString(nativeExternalPath);
jenv->ReleaseStringUTFChars(externalFilePath, nativeExternalPath);

if (bUseExternalFilesDir)
{
#if UE_BUILD_SHIPPING
GFilePathBase = GInternalFilePath;
#else
GFilePathBase = GExternalFilePath;
#endif
FPlatformMisc::LowLevelOutputDebugStringf(TEXT("GFilePathBase Path override to'%s'\n"), *GFilePathBase);
}

FPlatformMisc::LowLevelOutputDebugStringf(TEXT("InternalFilePath found as '%s'\n"), *GInternalFilePath);
FPlatformMisc::LowLevelOutputDebugStringf(TEXT("ExternalFilePath found as '%s'\n"), *GExternalFilePath);
}
}

在引擎启动的时候会从JNI调过来,其中有一个参数bUseExternalFilesDir用来控制修改GFilePathBase的值,如果它为ture,在Shipping打包的模式下就会把GFilePathBase设置为GInternalFilePath的值,也就是下列路径:

1
/data/user/PACKAGE_NAME/files

在非Shipping打包模式下会设置为GExternalFilePath的值:

1
/storage/emulated/0/Android/data/PACKAGE_NAME/files

但是,问题的关键是bUseExternalFilesDir这个从JNI调过来的参数我们又如何控制呢?

问题的答案是添加manifest信息!本来以为是ProjectSettings-Android-UseExternalFilesDirForUE4GameFiles这个选项,但是选中没有任何效果,原因后面会分析。

在详细解释怎么通过manifest控制bUseExternalFilesDir这个变量之前,需要先知道,UE4打包出来的APK的Manifest中默认有什么。

下列是我解包出来的APK中的Manifest文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<?xml version="1.0" encoding="utf-8" standalone="no"?><manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="internalOnly" package="com.imzlp.TEST" platformBuildVersionCode="29" platformBuildVersionName="10">
<application android:debuggable="true" android:hardwareAccelerated="true" android:hasCode="true" android:icon="@drawable/icon" android:label="@string/app_name">
<activity android:debuggable="true" android:label="@string/app_name" android:launchMode="singleTask" android:name="com.epicgames.ue4.SplashActivity" android:screenOrientation="landscape" android:theme="@style/UE4SplashTheme">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity android:configChanges="density|keyboard|keyboardHidden|mcc|mnc|orientation|screenSize|uiMode" android:debuggable="true" android:label="@string/app_name" android:launchMode="singleTask" android:name="com.epicgames.ue4.GameActivity" android:screenOrientation="landscape" android:theme="@style/UE4SplashTheme">
<meta-data android:name="android.app.lib_name" android:value="UE4"/>
</activity>
<activity android:configChanges="density|keyboard|keyboardHidden|mcc|mnc|orientation|screenSize|uiMode" android:name=".DownloaderActivity" android:screenOrientation="landscape" android:theme="@style/UE4SplashTheme"/>
<meta-data android:name="com.epicgames.ue4.GameActivity.EngineVersion" android:value="4.22.3"/>
<meta-data android:name="com.epicgames.ue4.GameActivity.EngineBranch" android:value="++UE4+Release-4.22"/>
<meta-data android:name="com.epicgames.ue4.GameActivity.ProjectVersion" android:value="1.0.0.0"/>
<meta-data android:name="com.epicgames.ue4.GameActivity.DepthBufferPreference" android:value="0"/>
<meta-data android:name="com.epicgames.ue4.GameActivity.bPackageDataInsideApk" android:value="true"/>
<meta-data android:name="com.epicgames.ue4.GameActivity.bVerifyOBBOnStartUp" android:value="false"/>
<meta-data android:name="com.epicgames.ue4.GameActivity.bShouldHideUI" android:value="false"/>
<meta-data android:name="com.epicgames.ue4.GameActivity.ProjectName" android:value="Mobile422"/>
<meta-data android:name="com.epicgames.ue4.GameActivity.AppType" android:value=""/>
<meta-data android:name="com.epicgames.ue4.GameActivity.bHasOBBFiles" android:value="true"/>
<meta-data android:name="com.epicgames.ue4.GameActivity.BuildConfiguration" android:value="Development"/>
<meta-data android:name="com.epicgames.ue4.GameActivity.CookedFlavors" android:value="ETC2"/>
<meta-data android:name="com.epicgames.ue4.GameActivity.bValidateTextureFormats" android:value="true"/>
<meta-data android:name="com.epicgames.ue4.GameActivity.bUseExternalFilesDir" android:value="false"/>
<meta-data android:name="com.epicgames.ue4.GameActivity.bUseDisplayCutout" android:value="false"/>
<meta-data android:name="com.epicgames.ue4.GameActivity.bAllowIMU" android:value="true"/>
<meta-data android:name="com.epicgames.ue4.GameActivity.bSupportsVulkan" android:value="false"/>
<meta-data android:name="com.google.android.gms.games.APP_ID" android:value="@string/app_id"/>
<meta-data android:name="com.google.android.gms.version" android:value="@integer/google_play_services_version"/>
<activity android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode" android:name="com.google.android.gms.ads.AdActivity"/>
<service android:name="OBBDownloaderService"/>
<receiver android:name="AlarmReceiver"/>
<receiver android:name="com.epicgames.ue4.LocalNotificationReceiver"/>
<receiver android:exported="true" android:name="com.epicgames.ue4.MulticastBroadcastReceiver">
<intent-filter>
<action android:name="com.android.vending.INSTALL_REFERRER"/>
</intent-filter>
</receiver>
<meta-data android:name="android.max_aspect" android:value="2.1"/>
</application>
<uses-feature android:glEsVersion="0x00030000" android:required="true"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="com.android.vending.CHECK_LICENSE"/>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.VIBRATE"/>
</manifest>

该文件在UnrealBuildTool\Platform\Android\UEDeployAdnroid.cs中的GenerateManifest函数中生成。

其中控制了APK安装后的权限要求、属性配置等等,可以看到其中有一条:

1
<meta-data android:name="com.epicgames.ue4.GameActivity.bUseExternalFilesDir" android:value="false"/>

bUseExternalFilesDir的值为false!,那么怎么把它设置为true呢?

需要打开Project Settings-Android-Advanced APK Packaging,找到Extra Tags for<application> node,因为<meta-data />是在Application下的,所以需要在这个选项下添加。

添加内容为:

1
<meta-data android:name="com.epicgames.ue4.GameActivity.bUseExternalFilesDir" android:value="true"/>

没错!直接把meta-data这一行直接粘贴过来改一下值就可以了,UE打包时会自动把这里的内容追加到ManifestApplication项尾部,这样就覆盖了默认的false的值。

然后再打包就可以看到bUseExternalFilesDir这个选项起作用了。

项目设置bUseExternalFilesDir选项无效分析

下面来分析一下Project Settings-Android-Use ExternalFilesDir for UE4Game Files这个选项不生效。
其实这个选项确实是控制manifest中的bUseExternalFilesDir的值的,在UBT中操作的,上面已经提到manifest文件就是在UBT中生成的。
但是,虽然UE提供了这个参数,但是目前的引擎中(4.22.3)这个选项是没有作用的,因为它被默认禁用了。
首先,UBT的构建调用栈为:

  1. AndroidPlatform(UEBuildAndroid.cs)的Deploy
  2. UEDeployAndroid(UEDeployAndroid.cs)中的PrepTargetForDeployment
  3. UEDeployAndroid(UEDeployAndroid.cs)中的MakeApk(最关键的函数)

MakeApk这个函数接收了一个特殊的控制参数bDisallowExternalFilesDir:

1
2
// UEDeployAndroid.cs
private void MakeApk(AndroidToolChain ToolChain, string ProjectName, TargetType InTargetType, string ProjectDirectory, string OutputPath, string EngineDirectory, bool bForDistribution, string CookFlavor, bool bMakeSeparateApks, bool bIncrementalPackage, bool bDisallowPackagingDataInApk, bool bDisallowExternalFilesDir);

它用来控制是否启用项目设置中的Use ExternalFilesDir for UE4Game Files选项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// UEDeployAndroid.cs
private void MakeApk(AndroidToolChain ToolChain, string ProjectName, TargetType InTargetType, string ProjectDirectory, string OutputPath, string EngineDirectory, bool bForDistribution, string CookFlavor, bool bMakeSeparateApks, bool bIncrementalPackage, bool bDisallowPackagingDataInApk, bool bDisallowExternalFilesDir)
{
// ...
bool bUseExternalFilesDir = UseExternalFilesDir(bDisallowExternalFilesDir);
// ...
}

// func UseExternalFilesDir
public bool UseExternalFilesDir(bool bDisallowExternalFilesDir, ConfigHierarchy Ini = null)
{
if (bDisallowExternalFilesDir)
{
return false;
}

// make a new one if one wasn't passed in
if (Ini == null)
{
Ini = GetConfigCacheIni(ConfigHierarchyType.Engine);
}

// we check this a lot, so make it easy
bool bUseExternalFilesDir;
Ini.GetBool("/Script/AndroidRuntimeSettings.AndroidRuntimeSettings", "bUseExternalFilesDir", out bUseExternalFilesDir);

return bUseExternalFilesDir;
}

可以看到,如果bDisallowExternalFilesDir为true的话,就完全不会去读项目设置里的配置。

而关键的地方就在于,在PrepTargetForDeployment中调用MakeApk的时候,给了默认参数true:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// UEDeployAndroid.cs
public override bool PrepTargetForDeployment(UEBuildDeployTarget InTarget)
{
//Log.TraceInformation("$$$$$$$$$$$$$$ PrepTargetForDeployment $$$$$$$$$$$$$$$$$ {0}", InTarget.TargetName);
AndroidToolChain ToolChain = new AndroidToolChain(InTarget.ProjectFile, false, InTarget.AndroidArchitectures, InTarget.AndroidGPUArchitectures);

// we need to strip architecture from any of the output paths
string BaseSoName = ToolChain.RemoveArchName(InTarget.OutputPaths[0].FullName);

// get the receipt
UnrealTargetPlatform Platform = InTarget.Platform;
UnrealTargetConfiguration Configuration = InTarget.Configuration;
string ProjectBaseName = Path.GetFileName(BaseSoName).Replace("-" + Platform, "").Replace("-" + Configuration, "").Replace(".so", "");
FileReference ReceiptFilename = TargetReceipt.GetDefaultPath(InTarget.ProjectDirectory, ProjectBaseName, Platform, Configuration, "");
Log.TraceInformation("Receipt Filename: {0}", ReceiptFilename);
SetAndroidPluginData(ToolChain.GetAllArchitectures(), CollectPluginDataPaths(TargetReceipt.Read(ReceiptFilename, UnrealBuildTool.EngineDirectory, InTarget.ProjectDirectory)));

// make an apk at the end of compiling, so that we can run without packaging (debugger, cook on the fly, etc)
string RelativeEnginePath = UnrealBuildTool.EngineDirectory.MakeRelativeTo(DirectoryReference.GetCurrentDirectory());
MakeApk(ToolChain, InTarget.TargetName, InTarget.ProjectDirectory.FullName, BaseSoName, RelativeEnginePath, bForDistribution: false, CookFlavor: "",
bMakeSeparateApks: ShouldMakeSeparateApks(), bIncrementalPackage: true, bDisallowPackagingDataInApk: false, bDisallowExternalFilesDir: true);

// if we made any non-standard .apk files, the generated debugger settings may be wrong
if (ShouldMakeSeparateApks() && (InTarget.OutputPaths.Count > 1 || !InTarget.OutputPaths[0].FullName.Contains("-armv7-es2")))
{
Console.WriteLine("================================================================================================================================");
Console.WriteLine("Non-default apk(s) have been made: If you are debugging, you will need to manually select one to run in the debugger properties!");
Console.WriteLine("================================================================================================================================");
}
return true;
}

这真是好坑的一个点啊...我看UE4.18 UBT的源码中是一样的,都是默认关闭的。明明有这个选项,却默认给关闭了,但是还没有任何的提示,这真是比较蛋疼的事情。

总结

其实改动引擎代码和使用manifest各有好处:

  • 改动代码的好处是可以任意指定路径(当然不一定合理),但缺点是需要源码版引擎;
  • 使用Manifest的好处是不需要源码版引擎,但是只能使用InternalFilesDir(Shipping)或者ExternalFilesDir(not-shipping);

顺道吐槽一下UE,一个选项没作用,还把它在设置里暴露出来干嘛...

全文完,若有不足之处请评论指正。

扫描二维码,分享此文章

本文标题:UE4源码分析:修改游戏默认的数据存储路径
文章作者:ZhaLiPeng
发布时间:2020年01月22日 09时10分
本文字数:本文一共有3.1k字
原始链接:https://imzlp.me/posts/20367/
专栏链接:https://zhuanlan.zhihu.com/p/102998101/
许可协议: CC BY-NC-SA 4.0
捐赠BTC:1CbUgUDkMdy6YRmjPJyq1hzfcpf2n36avm
转载请保留原文链接及作者信息,谢谢!
您的捐赠将鼓励我继续创作!