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

马上过年了,近期武汉肺炎闹得人心惶惶,各位朋友们春节期间注意安全。 今天的文章是年前的最后一篇文章了,是对UE打包Android的数据存储目录的分析,有谬误请指正。 本篇文章我的博客链接为: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组合的路径。

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

// 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,:

// 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;
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的值为:

/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中有以下代码:

//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的值,也就是下列路径:

/data/user/PACKAGE_NAME/files

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

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

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

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

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

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

<?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安装后的权限要求、属性配置等等,可以看到其中有一条:

<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下的,所以需要在这个选项下添加。

添加内容为:

<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:

// 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选项。

// 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:

// 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,一个选项没作用,还把它在设置里暴露出来干嘛...

发布于 01-22

文章被以下专栏收录