UE代码分析:GConfig的加载

UE4中提供了一套非常成熟的INI文件配置机制,引擎中也使用了ini作为引擎和项目的配置文件。本篇文章来简单分析一下引擎中GConfig的加载。

UE中定义了一堆的全局ini:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Runtime/Core/Private/Misc/CoreGlobals.cpp
FString GEngineIni; /* Engine ini filename */

/** Editor ini file locations - stored per engine version (shared across all projects). Migrated between versions on first run. */
FString GEditorIni; /* Editor ini filename */
FString GEditorKeyBindingsIni; /* Editor Key Bindings ini file */
FString GEditorLayoutIni; /* Editor UI Layout ini filename */
FString GEditorSettingsIni; /* Editor Settings ini filename */

/** Editor per-project ini files - stored per project. */
FString GEditorPerProjectIni; /* Editor User Settings ini filename */

FString GCompatIni;
FString GLightmassIni; /* Lightmass settings ini filename */
FString GScalabilityIni; /* Scalability settings ini filename */
FString GHardwareIni; /* Hardware ini filename */
FString GInputIni; /* Input ini filename */
FString GGameIni; /* Game ini filename */
FString GGameUserSettingsIni; /* User Game Settings ini filename */

但是并没有直接硬编码指定ini是在哪里的,其加载的过程为:在FEngineLoop::AppInit(LaunchEngineLoop.cpp)中通过调用FConfigCacheIni::InitializeConfigSystem来执行加载ini文件,其定义在ConfigCacheIni.cpp

FConfigCacheIni::InitializeConfigSystem

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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
// --------------------------------
// # FConfigCacheIni::InitializeConfigSystem declaration
/**
* Creates GConfig, loads the standard global ini files (Engine, Editor, etc),
* fills out GEngineIni, etc. and marks GConfig as ready for use
*/
static void InitializeConfigSystem();
// --------------------------------
void FConfigCacheIni::InitializeConfigSystem()
{
// Perform any upgrade we need before we load any configuration files
FConfigManifest::UpgradeFromPreviousVersions();

// create GConfig
GConfig = new FConfigCacheIni(EConfigCacheType::DiskBacked);

// load the main .ini files (unless we're running a program or a gameless UE4Editor.exe, DefaultEngine.ini is required).
const bool bIsGamelessExe = !FApp::HasProjectName();
const bool bDefaultEngineIniRequired = !bIsGamelessExe && (GIsGameAgnosticExe || FApp::IsProjectNameEmpty());
bool bEngineConfigCreated = FConfigCacheIni::LoadGlobalIniFile(GEngineIni, TEXT("Engine"), nullptr, bDefaultEngineIniRequired);

if ( !bIsGamelessExe )
{
// Now check and see if our game is correct if this is a game agnostic binary
if (GIsGameAgnosticExe && !bEngineConfigCreated)
{
const FText AbsolutePath = FText::FromString( IFileManager::Get().ConvertToAbsolutePathForExternalAppForRead(*FPaths::GetPath(GEngineIni)) );
//@todo this is too early to localize
const FText Message = FText::Format( NSLOCTEXT("Core", "FirstCmdArgMustBeGameName", "'{0}' must exist and contain a DefaultEngine.ini."), AbsolutePath );
if (!GIsBuildMachine)
{
FMessageDialog::Open(EAppMsgType::Ok, Message);
}
FApp::SetProjectName(TEXT("")); // this disables part of the crash reporter to avoid writing log files to a bogus directory
if (!GIsBuildMachine)
{
exit(1);
}
UE_LOG(LogInit, Fatal,TEXT("%s"), *Message.ToString());
}
}

FConfigCacheIni::LoadGlobalIniFile(GGameIni, TEXT("Game"));
FConfigCacheIni::LoadGlobalIniFile(GInputIni, TEXT("Input"));
#if WITH_EDITOR
// load some editor specific .ini files

FConfigCacheIni::LoadGlobalIniFile(GEditorIni, TEXT("Editor"));

// Upgrade editor user settings before loading the editor per project user settings
FConfigManifest::MigrateEditorUserSettings();
FConfigCacheIni::LoadGlobalIniFile(GEditorPerProjectIni, TEXT("EditorPerProjectUserSettings"));

// Project agnostic editor ini files
static const FString EditorSettingsDir = FPaths::Combine(*FPaths::GameAgnosticSavedDir(), TEXT("Config")) + TEXT("/");
FConfigCacheIni::LoadGlobalIniFile(GEditorSettingsIni, TEXT("EditorSettings"), nullptr, false, false, true, *EditorSettingsDir);
FConfigCacheIni::LoadGlobalIniFile(GEditorLayoutIni, TEXT("EditorLayout"), nullptr, false, false, true, *EditorSettingsDir);
FConfigCacheIni::LoadGlobalIniFile(GEditorKeyBindingsIni, TEXT("EditorKeyBindings"), nullptr, false, false, true, *EditorSettingsDir);

#endif
#if PLATFORM_DESKTOP
// load some desktop only .ini files
FConfigCacheIni::LoadGlobalIniFile(GCompatIni, TEXT("Compat"));
FConfigCacheIni::LoadGlobalIniFile(GLightmassIni, TEXT("Lightmass"));
#endif

// Load scalability settings.
FConfigCacheIni::LoadGlobalIniFile(GScalabilityIni, TEXT("Scalability"));
// Load driver blacklist
FConfigCacheIni::LoadGlobalIniFile(GHardwareIni, TEXT("Hardware"));

// Load user game settings .ini, allowing merging. This also updates the user .ini if necessary.
FConfigCacheIni::LoadGlobalIniFile(GGameUserSettingsIni, TEXT("GameUserSettings"));

// now we can make use of GConfig
GConfig->bIsReadyForUse = true;
FCoreDelegates::ConfigReadyForUse.Broadcast();
}

FConfigCacheIni::LoadGlobalIniFile

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
// --------------------------------
// # FConfigCacheIni::LoadGlobalIniFile declaration
/**
* Loads and generates a destination ini file and adds it to GConfig:
* - Looking on commandline for override source/dest .ini filenames
* - Generating the name for the engine to refer to the ini
* - Loading a source .ini file hierarchy
* - Filling out an FConfigFile
* - Save the generated ini
* - Adds the FConfigFile to GConfig
*
* @param FinalIniFilename The output name of the generated .ini file (in Game\Saved\Config)
* @param BaseIniName The "base" ini name, with no extension (ie, Engine, Game, etc)
* @param Platform The platform to load the .ini for (if NULL, uses current)
* @param bForceReload If true, the destination .in will be regenerated from the source, otherwise this will only process if the dest isn't in GConfig
* @param bRequireDefaultIni If true, the Default*.ini file is required to exist when generating the final ini file.
* @param bAllowGeneratedIniWhenCooked If true, the engine will attempt to load the generated/user INI file when loading cooked games
* @param GeneratedConfigDir The location where generated config files are made.
* @return true if the final ini was created successfully.
*/
static bool LoadGlobalIniFile(FString& FinalIniFilename, const TCHAR* BaseIniName, const TCHAR* Platform=NULL, bool bForceReload=false, bool bRequireDefaultIni=false, bool bAllowGeneratedIniWhenCooked=true, const TCHAR* GeneratedConfigDir = *FPaths::GeneratedConfigDir());
// --------------------------------

bool FConfigCacheIni::LoadGlobalIniFile(FString& FinalIniFilename, const TCHAR* BaseIniName, const TCHAR* Platform, bool bForceReload, bool bRequireDefaultIni, bool bAllowGeneratedIniWhenCooked, const TCHAR* GeneratedConfigDir)
{
// figure out where the end ini file is
FinalIniFilename = GetDestIniFilename(BaseIniName, Platform, GeneratedConfigDir);

// Start the loading process for the remote config file when appropriate
if (FRemoteConfig::Get()->ShouldReadRemoteFile(*FinalIniFilename))
{
FRemoteConfig::Get()->Read(*FinalIniFilename, BaseIniName);
}

FRemoteConfigAsyncIOInfo* RemoteInfo = FRemoteConfig::Get()->FindConfig(*FinalIniFilename);
if (RemoteInfo && (!RemoteInfo->bWasProcessed || !FRemoteConfig::Get()->IsFinished(*FinalIniFilename)))
{
// Defer processing this remote config file to until it has finish its IO operation
return false;
}

// need to check to see if the file already exists in the GConfigManager's cache
// if it does exist then we are done, nothing else to do
if (!bForceReload && GConfig->FindConfigFile(*FinalIniFilename) != nullptr)
{
//UE_LOG(LogConfig, Log, TEXT( "Request to load a config file that was already loaded: %s" ), GeneratedIniFile );
return true;
}

// make a new entry in GConfig (overwriting what's already there)
FConfigFile& NewConfigFile = GConfig->Add(FinalIniFilename, FConfigFile());

return LoadExternalIniFile(NewConfigFile, BaseIniName, *FPaths::EngineConfigDir(), *FPaths::SourceConfigDir(), true, Platform, bForceReload, true, bAllowGeneratedIniWhenCooked, GeneratedConfigDir);
}

FConfigCacheIni::LoadLocalIniFile

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
// --------------------------------
// # FConfigCacheIni::LoadLocalIniFile declaration
/**
* Load an ini file directly into an FConfigFile, and nothing is written to GConfig or disk.
* The passed in .ini name can be a "base" (Engine, Game) which will be modified by platform and/or commandline override,
* or it can be a full ini filenname (ie WrangleContent) loaded from the Source config directory
*
* @param ConfigFile The output object to fill
* @param IniName Either a Base ini name (Engine) or a full ini name (WrangleContent). NO PATH OR EXTENSION SHOULD BE USED!
* @param bIsBaseIniName true if IniName is a Base name, which can be overridden on commandline, etc.
* @param Platform The platform to use for Base ini names, NULL means to use the current platform
* @param bForceReload force reload the ini file from disk this is required if you make changes to the ini file not using the config system as the hierarchy cache will not be updated in this case
* @return true if the ini file was loaded successfully
*/
static bool LoadLocalIniFile(FConfigFile& ConfigFile, const TCHAR* IniName, bool bIsBaseIniName, const TCHAR* Platform=NULL, bool bForceReload=false);
// --------------------------------
bool FConfigCacheIni::LoadLocalIniFile(FConfigFile& ConfigFile, const TCHAR* IniName, bool bIsBaseIniName, const TCHAR* Platform, bool bForceReload )
{
DECLARE_SCOPE_CYCLE_COUNTER( TEXT( "FConfigCacheIni::LoadLocalIniFile" ), STAT_FConfigCacheIni_LoadLocalIniFile, STATGROUP_LoadTime );

FString EngineConfigDir = FPaths::EngineConfigDir();
FString SourceConfigDir = FPaths::SourceConfigDir();

if (bIsBaseIniName)
{
FConfigFile* BaseConfig = GConfig->FindConfigFileWithBaseName(IniName);
// If base ini, try to use an existing GConfig file to set the config directories instead of assuming defaults

if (BaseConfig)
{
FIniFilename* EngineFilename = BaseConfig->SourceIniHierarchy.Find(EConfigFileHierarchy::EngineDirBase);
if (EngineFilename)
{
EngineConfigDir = FPaths::GetPath(EngineFilename->Filename) + TEXT("/");
}

FIniFilename* GameFilename = BaseConfig->SourceIniHierarchy.Find(EConfigFileHierarchy::GameDirDefault);
if (GameFilename)
{
SourceConfigDir = FPaths::GetPath(GameFilename->Filename) + TEXT("/");
}
}

}

return LoadExternalIniFile(ConfigFile, IniName, *EngineConfigDir, *SourceConfigDir, bIsBaseIniName, Platform, bForceReload, false);
}

FConfigCacheIni::LoadExternalIniFile

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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// --------------------------------
// # FConfigCacheIni::LoadExternalIniFile declaration
/**
* Load an ini file directly into an FConfigFile from the specified config folders, optionally writing to disk.
* The passed in .ini name can be a "base" (Engine, Game) which will be modified by platform and/or commandline override,
* or it can be a full ini filenname (ie WrangleContent) loaded from the Source config directory
*
* @param ConfigFile The output object to fill
* @param IniName Either a Base ini name (Engine) or a full ini name (WrangleContent). NO PATH OR EXTENSION SHOULD BE USED!
* @param EngineConfigDir Engine config directory.
* @param SourceConfigDir Game config directory.
* @param bIsBaseIniName true if IniName is a Base name, which can be overridden on commandline, etc.
* @param Platform The platform to use for Base ini names
* @param bForceReload force reload the ini file from disk this is required if you make changes to the ini file not using the config system as the hierarchy cache will not be updated in this case
* @param bWriteDestIni write out a destination ini file to the Saved folder, only valid if bIsBaseIniName is true
* @param bAllowGeneratedIniWhenCooked If true, the engine will attempt to load the generated/user INI file when loading cooked games
* @param GeneratedConfigDir The location where generated config files are made.
* @return true if the ini file was loaded successfully
*/
static bool LoadExternalIniFile(FConfigFile& ConfigFile, const TCHAR* IniName, const TCHAR* EngineConfigDir, const TCHAR* SourceConfigDir, bool bIsBaseIniName, const TCHAR* Platform=NULL, bool bForceReload=false, bool bWriteDestIni=false, bool bAllowGeneratedIniWhenCooked = true, const TCHAR* GeneratedConfigDir = *FPaths::GeneratedConfigDir());
// --------------------------------
bool FConfigCacheIni::LoadExternalIniFile(FConfigFile& ConfigFile, const TCHAR* IniName, const TCHAR* EngineConfigDir, const TCHAR* SourceConfigDir, bool bIsBaseIniName, const TCHAR* Platform, bool bForceReload, bool bWriteDestIni, bool bAllowGeneratedIniWhenCooked, const TCHAR* GeneratedConfigDir)
{
// if bIsBaseIniName is false, that means the .ini is a ready-to-go .ini file, and just needs to be loaded into the FConfigFile
if (!bIsBaseIniName)
{
// generate path to the .ini file (not a Default ini, IniName is the complete name of the file, without path)
FString SourceIniFilename = FString::Printf(TEXT("%s/%s.ini"), SourceConfigDir, IniName);

// load the .ini file straight up
LoadAnIniFile(*SourceIniFilename, ConfigFile);

ConfigFile.Name = IniName;
}
else
{
FString DestIniFilename = GetDestIniFilename(IniName, Platform, GeneratedConfigDir);

GetSourceIniHierarchyFilenames( IniName, Platform, EngineConfigDir, SourceConfigDir, ConfigFile.SourceIniHierarchy, false );

if ( bForceReload )
{
ClearHierarchyCache( IniName );
}

// Keep a record of the original settings
ConfigFile.SourceConfigFile = new FConfigFile();

// now generate and make sure it's up to date (using IniName as a Base for an ini filename)
const bool bAllowGeneratedINIs = true;
bool bNeedsWrite = GenerateDestIniFile(ConfigFile, DestIniFilename, ConfigFile.SourceIniHierarchy, bAllowGeneratedIniWhenCooked, true);

ConfigFile.Name = IniName;

// don't write anything to disk in cooked builds - we will always use re-generated INI files anyway.
if (bWriteDestIni && (!FPlatformProperties::RequiresCookedData() || bAllowGeneratedIniWhenCooked)
// We shouldn't save config files when in multiprocess mode,
// otherwise we get file contention in XGE shader builds.
&& !FParse::Param(FCommandLine::Get(), TEXT("Multiprocess")))
{
// Check the config system for any changes made to defaults and propagate through to the saved.
ConfigFile.ProcessSourceAndCheckAgainstBackup();

if (bNeedsWrite)
{
// if it was dirtied during the above function, save it out now
ConfigFile.Write(DestIniFilename);
}
}
}

// GenerateDestIniFile returns true if nothing is loaded, so check if we actually loaded something
return ConfigFile.Num() > 0;
}

这个函数中最重要的部分就是调用了GetSourceIniHierarchyFilenames,它把当前传入的baseName的ini在Engine和项目下的所有ini文件都收集了起来。

这就是为什么我们看到其实GConfig中很多都是Saved/Config下的ini,里面内容是空的,但是我们怎么查到项目里的设置的呢?这是因为UE把这么多个ini合并了:

这些ini的的内容都会加载进来。

这部分操作是在FConfigFile::AddStaticLayersToHierarchy中实现的,通过遍历GConfigLayers中所有规则的ini文件:

ConfigCacheIni.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
struct FConfigLayer
{
// Used by the editor to display in the ini-editor
const TCHAR* EditorName;
// Path to the ini file (with variables)
const TCHAR* Path;
// Special flag
EConfigLayerFlags Flag;

} GConfigLayers[] =
{
/**************************************************
**** CRITICAL NOTES
**** If you change this array, you need to also change EnumerateConfigFileLocations() in ConfigHierarchy.cs!!!
**** And maybe UObject::GetDefaultConfigFilename(), UObject::GetGlobalUserConfigFilename()
**************************************************/

// Engine/Base.ini
{ TEXT("AbsoluteBase"), TEXT("{ENGINE}/Config/Base.ini"), EConfigLayerFlags::Required | EConfigLayerFlags::NoExpand},

// Engine/Base*.ini
{ TEXT("Base"), TEXT("{ENGINE}/Config/Base{TYPE}.ini") },
// Engine/Platform/BasePlatform*.ini
{ TEXT("BasePlatform"), TEXT("{ENGINE}/Config/{PLATFORM}/Base{PLATFORM}{TYPE}.ini") },
// Project/Default*.ini
{ TEXT("ProjectDefault"), TEXT("{PROJECT}/Config/Default{TYPE}.ini"), EConfigLayerFlags::AllowCommandLineOverride | EConfigLayerFlags::GenerateCacheKey },
// Engine/Platform/Platform*.ini
{ TEXT("EnginePlatform"), TEXT("{ENGINE}/Config/{PLATFORM}/{PLATFORM}{TYPE}.ini") },
// Project/Platform/Platform*.ini
{ TEXT("ProjectPlatform"), TEXT("{PROJECT}/Config/{PLATFORM}/{PLATFORM}{TYPE}.ini") },

// UserSettings/.../User*.ini
{ TEXT("UserSettingsDir"), TEXT("{USERSETTINGS}Unreal Engine/Engine/Config/User{TYPE}.ini"), EConfigLayerFlags::NoExpand },
// UserDir/.../User*.ini
{ TEXT("UserDir"), TEXT("{USER}Unreal Engine/Engine/Config/User{TYPE}.ini"), EConfigLayerFlags::NoExpand },
// Project/User*.ini
{ TEXT("GameDirUser"), TEXT("{PROJECT}/Config/User{TYPE}.ini"), EConfigLayerFlags::GenerateCacheKey | EConfigLayerFlags::NoExpand },
};

在使用时会进行替换:

ConfigCacheIni.cpp
1
2
3
4
5
6
7
8
static FString PerformBasicReplacements(const FString& InString, const TCHAR* BaseIniName)
{
FString OutString = InString.Replace(TEXT("{TYPE}"), BaseIniName, ESearchCase::CaseSensitive);
OutString = OutString.Replace(TEXT("{USERSETTINGS}"), FPlatformProcess::UserSettingsDir(), ESearchCase::CaseSensitive);
OutString = OutString.Replace(TEXT("{USER}"), FPlatformProcess::UserDir(), ESearchCase::CaseSensitive);

return OutString;
}

比如我要加载Windows平台Game的ini,它实际上会包含:

1
2
3
4
5
6
7
8
9
../../../Engine/Config/Base.ini
../../../Engine/Config/BaseGame.ini
../../../Engine/Config/Windows/BaseWindowsGame.ini
../../../ProjectName/Config/DefaultGame.ini
../../../Engine/Config/Windows/WindowsGame.ini
../../../ProjectName/Config/Windows/WindowsGame.ini
C:/Users/lipengzha/AppData/Local/Unreal Engine/Engine/Config/UserGame.ini
C:/Users/lipengzha/Documents/Unreal Engine/Engine/Config/UserGame.ini
../../../ProjectName/Config/UserGame.ini

注意:GConfigLayers的最后一个元素具有EConfigLayerFlags::GenerateCacheKey的FLAG,在处理时会将所有的ini路径拼接起来作为Key:

FConfigFile::AddStaticLayersToHierarchy
1
2
3
4
5
6
// add this to the list!
SourceIniHierarchy.AddStaticLayer(
FIniFilename(PlatformPath, bIsRequired, bGenerateCacheKey ?
GenerateHierarchyCacheKey(SourceIniHierarchy, PlatformPath, InBaseIniName) :
FString(TEXT(""))),
LayerIndex, ExpansionIndex, PlatformIndex);

最终所有能够被加载的Ini文件的路径列表,在GenerateDestIniFile函数中执行实际的加载行为。(以Engine为例):

加载ini文件的栈:

读取时会按照Ini路径列表的顺序依次读取并Combine到FConfigFile,这也表明了Ini文件的优先级也是如列表加载顺序。

GetDestIniFilename

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
// Runtime/Source/Core/Private/Misc/ConfigCacheIni.cpp

/**
* Calculates the name of a dest (generated) .ini file for a given base (ie Engine, Game, etc)
*
* @param IniBaseName Base name of the .ini (Engine, Game)
* @param PlatformName Name of the platform to get the .ini path for (nullptr means to use the current platform)
* @param GeneratedConfigDir The base folder that will contain the generated config files.
*
* @return Standardized .ini filename
*/
static FString GetDestIniFilename(const TCHAR* BaseIniName, const TCHAR* PlatformName, const TCHAR* GeneratedConfigDir)
{
// figure out what to look for on the commandline for an override
FString CommandLineSwitch = FString::Printf(TEXT("%sINI="), BaseIniName);

// if it's not found on the commandline, then generate it
FString IniFilename;
if (FParse::Value(FCommandLine::Get(), *CommandLineSwitch, IniFilename) == false)
{
FString Name(PlatformName ? PlatformName : ANSI_TO_TCHAR(FPlatformProperties::PlatformName()));

FString BaseIniNameString = BaseIniName;
if (BaseIniNameString.Contains(GeneratedConfigDir))
{
IniFilename = BaseIniNameString;
}
else
{
// put it all together
IniFilename = FString::Printf(TEXT("%s%s/%s.ini"), GeneratedConfigDir, *Name, BaseIniName);
}
}

// standardize it!
FPaths::MakeStandardFilename(IniFilename);
return IniFilename;
}

FPaths::GeneratedConfigDir获取的路径是当前项目的Saved下的Config:

1
2
3
4
5
6
7
8
9
// Paths.cpp
FString FPaths::GeneratedConfigDir()
{
#if PLATFORM_MAC
return FPlatformProcess::UserPreferencesDir();
#else
return FPaths::ProjectSavedDir() + TEXT("Config/");
#endif
}

即在Windows平台上项目启动时创建的全局G*Ini文件读取的都是Saved/Config/Windows下的.ini
而且在GetDestIniFilename的实现中也可以看到,G*Ini的配置也是可以从CommandLine传入的,可以替换掉默认的Saved/Config/Platform下的ini:

1
2
// 使用指定的Engine.ini
UE4Editor.exe uprojectPath -EngineINI="D:\\CustomEngine.ini"


扩展阅读

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

微信扫描二维码,关注我的公众号。

本文标题:UE代码分析:GConfig的加载
文章作者:查利鹏
发布时间:2019/05/27 22:57
更新时间:2020/05/28 18:02
本文字数:1.3k 字
原始链接:https://imzlp.com/posts/2386/
许可协议: CC BY-NC-SA 4.0
文章禁止全文转载,摘要转发请保留原文链接及作者信息,谢谢!
您的捐赠将鼓励我继续创作!