Windows上源码编译并使用Unreal Engine 5.1

起因

接手一个使用Unreal Engine 5.1 进行3D渲染的直播项目。开发过程中发现,用于3D渲染的程序,在生成时使用了Development配置,导致最终产物在性能表现和分发处理时均存在一些问题,需要切换Shipping配置重新生成。
原程序使用Unreal Engine官方提供的SDK生成,切换到Shipping配置后重新生成,发现了下列问题:

  1. 无日志输出。
  2. Shipping配置中加载场景的Actor的Display Name,与Debug/Developmen模式不一致。

一番调研后确认,上述问题无法通过修改配置文件解决,官方文档建议修改源码后重新编译SDK。

过程

获取源码

参考官网文档

  1. 关联Epic账号和GitHub账号
    Unreal Engine相关源码托管在GitHub上,需要加入@EpicGames组织才能下载。
    在GitHub上访问虚幻引擎源代码
  2. 克隆源码,切换分支
    1
    2
    3
    git clone https://github.com/EpicGames/UnrealEngine.git
    cd UnrealEngine
    git checkout 5.1.1-release # 使用5.1.1分支对应代码进行编译

注意:

因为服务商问题,仓库中部分二进制文件的下载链接失效,导致checkout时会出现报错:

1
Failed to download 'http://cdn.unrealengine.com/dependencies/UnrealEngine-16624087/135d2dfcfa4bbe7dd725f03f844323624525ed4a': The remote server returned an error: (403) Forbidden. (WebException)

官方给出的解决方案是,下载并更新仓库中的Engine/Build/Commit.gitdeps.xml 文件后,再重新执行checkout。
TAG对应的Commit.gitdeps.xml文件可以在GitHub上对应TAG的Release页面下载。
https://github.com/EpicGames/UnrealEngine/releases/tag/5.1.1-release

编译

  1. 生成工程
    1. 执行根目录下的Setup.bat
    2. 执行根目录下的GenerateProjectFiles.bat
  2. 编译工程
    打开根目录下的UE5.sln,设置配置为Development,平台为Win64,选择编译工程/Build Solution。

错误处理

因为工具链及版本的差异,编译时会出现一些错误,需要单独处理。

  • Error NU1904 Warning As Error: Package ‘System.Drawing.Common’ 4.7.0 has a known critical severity vulnerability
    部分三方库被发现了一些安全问题。Unreal Engine工程通过Nuget引入这些三方库时会触发警告。而工程本身又配置了将Nuget的警告视为报错,停止编译。
    考虑到之前用的官方SDK也存在这些安全问题,继续使用不会带来更坏的结果,但升级三方库可能会导致其他问题,重新编译时选择忽略这些警告。
    在VS中双击Error List对应错误,跳转到出错的.csproj文件。将
    1
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    修改为
    1
    <TreatWarningsAsErrors>false</TreatWarningsAsErrors>
  • SteamVRInputDeviceFunctionLibrary.cpp(513): error C4834: discarding return value of function with ‘nodiscard’ attribute
    参考https://forums.unrealengine.com/t/build-from-source-fails-with-errors-c4834-and-msb3073/1266696,修改```Engine/Plugins/Runtime/Steam/SteamVR/Source/SteamVRInputDevice/Private/SteamVRInputDeviceFunctionLibrary.cpp```
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    diff --git a/Engine/Plugins/Runtime/Steam/SteamVR/Source/SteamVRInputDevice/Private/SteamVRInputDeviceFunctionLibrary.cpp b/Engine/Plugins/Runtime/Steam/SteamVR/Source/SteamVRInputDevice/Private/SteamVRInputDeviceFunctionLibrary.cpp
    index 9277039ae4c589e0e741536b030dff40e8dbb3bd..ab43dd0fbf2bc595afa975d9a140391343cc4b29 100644
    --- a/Engine/Plugins/Runtime/Steam/SteamVR/Source/SteamVRInputDevice/Private/SteamVRInputDeviceFunctionLibrary.cpp
    +++ b/Engine/Plugins/Runtime/Steam/SteamVR/Source/SteamVRInputDevice/Private/SteamVRInputDeviceFunctionLibrary.cpp
    @@ -510,7 +510,7 @@ bool UDEPRECATED_USteamVRInputDeviceFunctionLibrary::FindSteamVR_ActionOrigin(FN
    return true;
    }

    - GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Red, (TEXT("Unable to find Action [%s] for Action Set [%s]"), *ActionName.ToString(), *ActionSet.ToString()));
    + GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Red, FString::Printf(TEXT("Unable to find Action [%s] for Action Set [%s]"), *ActionName.ToString(), *ActionSet.ToString()));
    return false;
    }
  • ChaosUserDataPT.h(105): error C4458: declaration of ‘Solver’ hides class member
    修改Engine/Plugins/Experimental/ChaosUserDataPT/Source/ChaosUserDataPT/Public/ChaosUserDataPT.h
1
2
3
4
5
6
7
8
9
10
11
12
13
diff --git a/Engine/Plugins/Experimental/ChaosUserDataPT/Source/ChaosUserDataPT/Public/ChaosUserDataPT.h b/Engine/Plugins/Experimental/ChaosUserDataPT/Source/ChaosUserDataPT/Public/ChaosUserDataPT.h
index b66e2b93fa70d894c1eb45d5084b8056ec8a5886..20b0a11a141e72cc05dc43ac386d09756b489817 100644
--- a/Engine/Plugins/Experimental/ChaosUserDataPT/Source/ChaosUserDataPT/Public/ChaosUserDataPT.h
+++ b/Engine/Plugins/Experimental/ChaosUserDataPT/Source/ChaosUserDataPT/Public/ChaosUserDataPT.h
@@ -102,7 +102,7 @@ namespace Chaos
{
SCOPE_CYCLE_COUNTER(STAT_UserDataPT_SetData_GT);

- if (const FPhysicsSolverBase* Solver = this->GetSolver())
+ if (this->GetSolver())
{
if (TInput* Input = this->GetProducerInputData_External())
{

代码修改

  • 分发模式下启用日志输出
    修改Engine/Source/Programs/UnrealBuildTool/Configuration/UEBuildTarget.cs,始终添加USE_LOGGING_IN_SHIPPING宏。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
diff --git a/Engine/Source/Programs/UnrealBuildTool/Configuration/UEBuildTarget.cs b/Engine/Source/Programs/UnrealBuildTool/Configuration/UEBuildTarget.cs
index ecfa98e2c746dd15fb195261806b2a05e5c72a74..45b2632995a45deef609e3877f57b0a1b4468aa9 100644
--- a/Engine/Source/Programs/UnrealBuildTool/Configuration/UEBuildTarget.cs
+++ b/Engine/Source/Programs/UnrealBuildTool/Configuration/UEBuildTarget.cs
@@ -4099,14 +4099,7 @@ namespace UnrealBuildTool
GlobalCompileEnvironment.Definitions.Add("WITH_PERFCOUNTERS=0");
}

- if (Rules.bUseLoggingInShipping)
- {
- GlobalCompileEnvironment.Definitions.Add("USE_LOGGING_IN_SHIPPING=1");
- }
- else
- {
- GlobalCompileEnvironment.Definitions.Add("USE_LOGGING_IN_SHIPPING=0");
- }
+ GlobalCompileEnvironment.Definitions.Add("USE_LOGGING_IN_SHIPPING=0");

if (Rules.bLoggingToMemoryEnabled)
{
  • 分发模式下启用Actor的Display Name
    修改Engine/Source/Runtime/Engine/Classes/GameFramework/Actor.h,定义ACTOR_HAS_LABELS始终为1
1
2
3
4
5
6
7
8
9
10
11
12
13
diff --git a/Engine/Source/Runtime/Engine/Classes/GameFramework/Actor.h b/Engine/Source/Runtime/Engine/Classes/GameFramework/Actor.h
index 1632662a7825b5d6abf5ea7d95117e325454921f..830853e7bb3bd1a4bdf477e6bd470c5b0daadb89 100644
--- a/Engine/Source/Runtime/Engine/Classes/GameFramework/Actor.h
+++ b/Engine/Source/Runtime/Engine/Classes/GameFramework/Actor.h
@@ -53,7 +53,7 @@ class UActorFolder;

// By default, debug and development builds (even cooked) will keep actor labels. Manually define this if you want to make a local build
// that keep actor labels for Test or Shipping builds.
-#define ACTOR_HAS_LABELS (UE_BUILD_DEBUG || UE_BUILD_DEVELOPMENT || WITH_PROFILEGPU)
+#define ACTOR_HAS_LABELS 1

/** Chooses a method for actors to update overlap state (objects it is touching) on initialization, currently only used during level streaming. */
UENUM(BlueprintType)

分发

参考制作安装构建版本,在CMD中执行下列命令:

1
2
3
4
5
6
Engine\Build\BatchFiles\RunUAT.bat BuildGraph ^
-target="Make Installed Build Win64" ^
-script="Engine/Build/InstalledEngineBuild.xml" ^
-set:HostPlatformOnly=true ^
-set:WithDDC=false ^
-clean

之后在LocalBuilds目录下可以找到编译完成的Unreal Engine SDK。可以将目录打成7z包,分发给团队成员。
使用者解压后,运行一次Engine\Windows\Engine\Binaries\Win64\UnrealEditor.exe,会自动在系统中注册该版本的SDK。之后就可以正常使用切换版本/生成工程文件/打开编辑器等功能。

lldb调试时设置Qt代码映射

在Windows平台上使用CLion调试Qt工程时,每次调试启动后都需要手动设置Qt源代码所在路径。平时因为调试Qt源码的次数不多,也不在乎多点几次。结果某次排查程序初始化问题时,需要反复重启程序调试Qt初始化相关逻辑,每次重启后都需要手动操作一次,着实被恶心到了。排查完问题之后,决定花点时间调研一下自动设置Qt源代码路径的可行性。

我本机的Qt工具链及源代码均安装在C:\Qt\目录下。而Qt官方编译产物的符号文件,对应的代码文件均指向C:\Users\qt\work\qt目录下。因为路径不匹配,导致调试器无法自动加载代码文件。Qt Creator对此的解决方法是:给个选项,手动设置Qt源代码目录😂猜测运行时会设置源代码映射,我们的解决方案应该也是类似的。

半自动

CLion使用lldb进行调试。搜索得知,可以通过如下命令,设置lldb在DEST中查找SRC下的源代码文件。

1
settings append target.source-map SRC DEST

Remap source file pathnames for the debug session

以Qt 6.5.3版本为例,假设Qt源代码存放在本地的C:\Qt\6.5.3\Src目录下,则可以在启动后通过下列lldb命令,设置Qt代码映射。

1
2
settings append target.source-map C:\Users\qt\work\qt C:\Qt\6.5.3\Src
settings append target.source-map C:\Users\qt\work\install C:\Qt\6.5.3\Src

全自动

上述方法,依然需要每次启动时手动输入一次命令,有没有更方便一点的方法?
再次搜索可知,可以通过.lldbinit文件设置lldb启动后自动执行的命令。
configuration-files
lldb启动后默认会加载~/.lldbinit并执行内部命令。
可以创建该文件文件,填充如下内容:

1
2
settings append target.source-map C:\Users\qt\work\qt C:\Qt\6.5.3\Src
settings append target.source-map C:\Users\qt\work\install C:\Qt\6.5.3\Src

这样每次启动lldb,都会自动设置Qt的代码映射。

Windows平台编译Skia

安装依赖

参考How to download Skia

编译器

官网推荐使用clang,性能更好。如果使用Visual Studio的话,建议使用2017或2019。
我实际编译时使用了2022,目前没有遇到问题。
后续步骤中也以Visual Studio 2022作为编译器。

depot_tools

  1. https://chromium.googlesource.com/chromium/tools/depot_tools.git Clone到本地。
  2. 将depot_tools目录加入环境变量PATH的最前面。
  3. 执行一次gclient。

手册上建议Windows平台上手动下载zip包再复制,实测不如直接git clone。

其他

python和ninja可以使用depot_tools内置的版本。
单纯编译时不需要安装bazelisk。

获取代码

1
2
3
4
git clone https://skia.googlesource.com/skia.git
cd skia
git checkout chrome/m116 # 使用chrome/m116分支
python3 tools/git-sync-deps

编译

参考How to build Skia 以及 JetBrains的skia-pack项目中的编译参数。
因为参数中存在空格和引号,建议通过Python运行。

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
from subprocess import check_call
SKIA_SOURCE_PATH = "where_you_clone_skia"

# 根据项目需要调整编译参数
args = [
'is_official_build=true', # Release
'is_component_build=true', # 编译动态库
'target_cpu="x64"', # 64bit

'skia_use_direct3d=true',
'skia_use_sfntly=false',
'skia_use_system_expat=false',
'skia_use_system_harfbuzz=false',
'skia_pdf_subset_harfbuzz=true',
'skia_use_system_icu=false',
'skia_use_system_libjpeg_turbo=false',
'skia_use_system_libpng=false',
'skia_use_system_libwebp=false',
'skia_use_system_zlib=false',

'extra_cflags=["-DSK_FONT_HOST_USE_SYSTEM_SETTINGS"]',
]
check_call(
['gn', 'gen', 'out/Release', '--args=' + ' '.join(args)],
shell=True,
cwd=SKIA_SOURCE_PATH,
)

check_call(
['ninja', '-C', join(SKIA_SOURCE_PATH, 'out/Release')],
shell=True,
)

GammaRay在mac上的编译及分发

最近在网上瞎逛时,发现了一个调试Qt应用程序的好东西:GammaRay。
按照官方介绍:
GammaRay is a software introspection tool for Qt applications developed by KDAB. Leveraging the QObject introspection mechanism it allows you to observe and manipulate your application at runtime. This works both locally on your workstation and remotely on an embedded target.
使用GammaRay可以在运行时直接查看Qt应用内的对象树,修改对象属性,查看信号槽连接和发射情况,查看界面布局等等。
对于我来说,GammaRay最大的作用有两个:

  • 简单直观的查看信号和事件的触发时机
  • 运行时动态设置对象属性和界面样式

对于团队新人来说,还可以借助这个工具,快速了解应用的架构以及界面布局,加快入门速度。

这么好的工具,当然要在团队内推广开来。但目前GammaRay并不直接提供编译好的二进制文件,只开源了源代码。
让每个人自己下载源码编译一次,显然是个很蠢的做法。于是大家达成共识,编译一份团队特供版的GammaRay。而我就来负责mac版本的编译。

编译

GammaRay的源码可以从 https://github.com/KDAB/GammaRay.git 上获取。稳定起见,选择最近release版本(v2.11.3)对应的tag。
GammaRay本身使用CMake进行工程管理,在*nix系统上编译很方便。按照INSTALL.md中的步骤,添加Qt库所在路径,执行cmake && cmake install即可完成编译。
此处需要注意:

  1. 建议直接使用编译应用程序的Qt库编译GammaRay
    工程会编译GammaRay本体和Probe。编译Probe需要使用和被调试的Qt应用程序一致的Qt动态库,GammaRay加载Probe之后才能正常工作。
    编译参数中可以看到,GammaRay支持单Launcher加载多Probe的方式。但没有具体的文档说明。在我们的项目中,目前统一使用一个Qt库,没有类似的需求。这里就没有深入研究。

  2. 使用cmake install生成的二进制产物
    mac上存在rpath的概念。mac上的动态库、可执行文件本身也会记录要其依赖的其他动态库的路径。
    默认情况下,cmake build的产物,记录了依赖动态库在本地的绝对路径。cmake install时,则会更新这些内容,变为包含rpath的相对路径。
    如果直接使用cmake build的产物,最终生成的可执行程序,在分发给其他电脑之后,会因为依赖的动态库无法加载,导致程序无法运行。

分发

执行完cmake install之后,默认会在/Applications/下生成一个名为GammaRay.app的应用程序。但直接运行时大概率会无法启动,或者无法正常工作。因为此时GammaRay.app内部没有包含依赖的Qt库,启动时会尝试加载系统目录下的Qt库。
Qt官方提供了macdelpoyqt来解决这个问题,将应用依赖的Qt库复制到app内,并会修改相关动态库的rpath等。但如果只是执行

1
${QTDIR}/bin/macdelpoyqt /Applications/GammaRay.app

GammaRay.app可以正常启动,依然无法正常工作。为什么呢?

一顿尝试之后发现,macdelpoyqt只能检查应用程序的主程序直接或者间接显式依赖的Qt动态库。
举个例子,主程序显式依赖了QtWidget和三方动态库X,三方动态库X显式依赖了QtOpenglWidget,运行时会动态加载Qt3DRender。
在这种场景下,macdelpoyqt只能分析出应用程序需要QtWidget和QtOpenglWidget,以及它俩依赖的其他Qt组件,并不能分析出对Qt3DRender的依赖。运行时,就会因为缺少Qt3DRender导致的错误。
回到GammaRay这边,install完成之后,Probe会作为插件被复制到GammaRay.app/Contents/Plugins/gammaray目录下,在GammaRay运行时动态加载。macdelpoyqt只会分析GammaRay.app/Contents/MacOS下几个可执行文件的依赖,丢掉了对Probe的依赖分析。

针对这个问题,可以利用macdelpoyqt的-executable选项,将需要分析依赖的动态库文件也一并传入。
因为Probe下的动态库个数比较多,手写容易出错,我就写了个python脚本。

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
from os import walk
from os.path import join
from subprocess import check_call

APP_PATH = '/Applications/GammaRay.app' # GammaRay.app路径
QT_DIR = '/Users/yoooooo/Qt/5.9.9/clang_64' # Qt库路径

executable_file_list = []
for parent, _, files in walk(join(APP_PATH, 'Contents/MacOS')):
for file in files:
executable_file_list.append(join(parent, file))

for parent, _, files in walk(join(APP_PATH, 'Contents/PlugIns/gammaray')):
for file in files:
if file.endswith('.so') or file.endswith('.dylib'):
executable_file_list.append(join(parent, file))

check_call([
join(QT_DIR, 'bin/macdeployqt'),
APP_PATH,
'-verbose=3',
'-dmg', # 生成dmg文件
*['-executable=' + executable_file for executable_file in executable_file_list]
],
cwd='/Applications', # 最终生成的dmg文件所在目录
)

其他

可以看到脚本中调用macdeployqt时,除了增加-executable选项之外,还增加了-dmg选项,最后直接生成了dmg文件。
期间因为偷懒,直接生成zip包进行分发。结果因为app中存在软链接文件,而标准格式的zip是不支持软链接的。导致解压zip得到的app无法正常工作,排查了很久才意识到,踩中这个老坑了。

此外,如果编译GammaRay的macOS版本比较新时,建议在GammaRay根目录下的CMakeList.txt中增加

1
2
# 此处设置最低支持的macOS版本为10.13
set(CMAKE_OSX_DEPLOYMENT_TARGET "10.13" CACHE STRING "Minimum OS X deployment version")

确保编译出来的app可以在低版本macOS上运行。

Windows 10下CMake无法找到Visual Studio 2013工具链的问题排查

迫于生计,紧急支援了一个客户端项目。这个项目使用CMake进行工程管理,在Windows下,为了支持XP及一些特殊原因,需要先生成Visual Studio 2013工程,再进行编译。
按照README上说的,在开发机上安装完Visual Studio 2013和CMake,再运行自动化脚本,相关工程就会自动创建并编译,我就可以点个咖啡,享受C++程序员的福利时间。
然而现实是残酷的,咖啡还没下单,自动化脚本就报错了。
执行

1
cmake SOURCE_PATH -G "Visual Studio 12 2013"

时报错:
The CXX compiler identification is unknown
看着像是没找到Visual Studio 2013的工具链。

印象中之前也解决过类似的问题,删除CMakeCache.txt、升级CMake、重装Visual Studio、手动设置CMAKE_CXX_COMPILER。结果一轮操作下来,还是相同的报错:
The CXX compiler identification is unknown
一个上午过去了,连开发环境都没部署完,这就很尴尬了。难道要放重装系统的大招?

正好点的咖啡到了。喝了一杯咖啡,冷静了一些,终于决定先看一下CMake自己的报错信息。
打开CMakeError.log,发现里面有大量类似的报错

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Build started 11/30/2021 11:10:35 PM.
Project "SecretPath\CMakeFiles\3.21.1\CompilerIdC\CompilerIdC.vcxproj" on node 1 (default targets).
PrepareForBuild:
Creating directory "Debug\".
Creating directory "Debug\CompilerIdC.tlog\".
InitializeBuildStatus:
Creating "Debug\CompilerIdC.tlog\unsuccessfulbuild" because "AlwaysCreate" was specified.
ClCompile:
C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\bin\CL.exe /c /nologo /W0 /WX- /Od /Oy- /D _USING_V110_SDK71_ /D _MBCS /Gm- /EHsc /RTC1 /MDd /GS /fp:precise /Zc:wchar_t /Zc:forScope /Fo"Debug\\" /Fd"Debug\vc120.pdb" /Gd /TC /analyze- /errorReport:queue CMakeCCompilerId.c
CMakeCCompilerId.c
CMakeCCompilerId.c : fatal error C1001: An internal error has occurred in the compiler. [SecretPath\CMakeFiles\3.21.1\CompilerIdC\CompilerIdC.vcxproj]
(compiler file 'f:\dd\vctools\compiler\cxxfe\sl\p1\c\p0io.c', line 2807)
To work around this problem, try simplifying or changing the program near the locations listed above.
Please choose the Technical Support command on the Visual C++
Help menu, or open the Technical Support help file for more information
Done Building Project "SecretPath\CMakeFiles\3.21.1\CompilerIdC\CompilerIdC.vcxproj" (default targets) -- FAILED.

是因为无法编译这些测试文件,才导致CMake无法识别么?
拿着fatal error C1001f:\dd\vctools\compiler\cxxfe\sl\p1\c\p0io.c作为关键字搜了一波,还真搜到了相关的信息。
fatal error C1001: An internal error has occurred in the compiler. ‘f:\dd\vctools\compiler\cxxfe\sl\p1\c\p0io.c
在这个问题下,有人提到操作系统的locale设置会影响编译器编译p0io.c这个文件。关闭Windows 10区域设置中的Beta版: 使用Unicode UTF-8提供全球语言支持 / Beta: Use Unicode UTF-8 for worldwide language support就可以解决这个问题。(更详细的讨论)
碰巧我的开发机之前为了验证该设置对程序的影响,手动打开过。之后就再也没管过。关闭设置之后,再重新执行CMake,终于成功识别出了Visual Studio 2013的工具链。
总结一下,遇到问题先看日志,别急着套用之前的解决方案。CMake这种成熟的开源软件,给出的错误日志都很详细,直接对着日志排查/上网搜索关键字,往往比无脑重装软件更快。

CLion加载Qt工程的错误处理

今天心血来潮,想优化一下之前写的日志分析工具。打开CLion,加载工程,准备开干,结果CMake报了这样的错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- Could NOT find Qt6CoreTools (missing: Qt6CoreTools_DIR)
CMake Warning at /Users/***/Qt/6.2.1/macos/lib/cmake/Qt6/Qt6Config.cmake:176 (find_package):
Found package configuration file:

/Users/***/Qt/6.2.1/macos/lib/cmake/Qt6Core/Qt6CoreConfig.cmake

but it set Qt6Core_FOUND to FALSE so package "Qt6Core" is considered to be NOT FOUND.
Call Stack (most recent call first):
src/CMakeLists.txt:1 (find_package)

-- Could NOT find Qt6GuiTools (missing: Qt6GuiTools_DIR)
CMake Warning at /Users/***/Qt/6.2.1/macos/lib/cmake/Qt6/Qt6Config.cmake:176 (find_package):
Found package configuration file:

/Users/***/Qt/6.2.1/macos/lib/cmake/Qt6Gui/Qt6GuiConfig.cmake

but it set Qt6Gui_FOUND to FALSE so package "Qt6Gui" is considered to be NOT FOUND.
Call Stack (most recent call first):
src/CMakeLists.txt:1 (find_package)

因为很长一段时间都没有动过这个工程,期间本地的CLion还升级了几个版本,本地Qt版本也升级过好几次,一时间无法确定是什么原因导致的加载失败。好在使用Qt Creator加载工程不会出现这个问题。趁着灵感还在,在Qt Creator里把代码写了。
完事之后上网搜了一波,没有找到靠谱的答案,但是找到了jerbrains官方的Qt指导
照着指导修改了一些工程配置,发现在设置CMAKE_PREFIX_PATH后,上述报错就消失了。
有遇到相同报错的同学可以先排查一下,检查CMAKE_PREFIX_PATH是否包含${QTDIR}/lib/cmake

USN日志处理移动事件时的补充

在写完通过读取USN日志监控文件变动之后,又陆陆续续踩了一些坑,修复了一些客户反馈的bug,主要是移动相关的问题。在这做一下记录,希望能帮到其他的人(应该也没人会去踩这种坑吧。。。)

等待移动队列

通过读取USN日志监控文件变动中提到:

  1. 文件id为X的文件从A移动到B
  2. 文件id为Y的文件从C移动到A

这种场景,在部分情况下会上报

  1. file id为X,USN_REASON包含RENAME_OLD_NAME,名称为A的记录
  2. file id为Y,USN_REASON包含RENAME_OLD_NAME,名称为C的记录
  3. file id为Y,USN_REASON包含RENAME_NEW_NAME和CLOSE,名称为A的记录
  4. file id为X,USN_REASON包含RENAME_NEW_NAME和CLOSE,名称为B的记录

原本认为是使用Transactional NTFS (TxF)技术的软件造成的,但无法复现出来,具体原因仍然有待调查。
如果直接依靠USN_REASON_RENAME_NEW_NAME的到达顺序来判断文件实际的移动顺序,则就会将该场景误判为C移动到B再移动到A。
为了解决这个问题,我们实现了一个等待移动队列,里面的单个元素,包含发生移动的文件ID,以及对应的包含USN_REASON_RENAME_NEW_NAME | CLOSE的记录

  1. USN_REASON_RENAME_OLD_NAME抵达后,向队列尾部添加对应文件ID,记录为空的元素。
  2. 在USN_REASON_RENAME_NEW_NAME | CLOSE记录抵达后,根据记录中的文件ID,查找队列中该ID对应的元素位置并存储记录。并将队列起始位置开始取元素的记录,直到遇到记录为空的情况。

最终输出的事件顺序即为真实的移动顺序。

对移动后仍然保持打开状态文件的处理

在上线等待移动队列后,我们发现部分客户出现了监控事件延迟的问题。经过排查后发现,部分文件(常见于系统日志)在移动后不发送包含USN_REASON_RENAME_NEW_NAME | CLOSE的记录,或发送间隔极长,导致移动事件都积压在等待移动队列中。
为了适配这种场景,我们又在等待队列中的元素中记录了入队时间。并做了如下修改:

  1. USN_REASON_RENAME_OLD_NAME抵达后,除了添加文件ID,还会删除队列中同文件ID的元素。
  2. USN_REASON_RENAME_NEW_NAME | CLOSE记录抵达后,除了存储记录,还会将队列前记录为空但入队时间超过指定间隔的元素移到最后并重置入队时间,之后再从队列起始位置开始取元素的记录。

两种操作的目的都是为了将相关记录移动到队列尾部,确保不阻塞其他文件的移动事件。

在MySQL上以text类型字段为查找结果排序时踩到的坑

公司的同步功能后端接口,提供了一系列和文件操作相关的接口。其中一个接口的作用是,返回云端指定节点下所有子节点id的列表,且保证父节点id在列表中一定比子节点id先出现。
客户端会按顺序遍历这个列表,获取id对应的信息,插入一个树结构,就可以得到云端当前的文件结构。如果列表中的id顺序不对,或者只有子节点id没有父节点id,那么客户端就会通知云端数据出现错误,并重新调用这个接口,获取修复后的文件结构。
最近在回归测试同步功能时发现,当云端某个目录层级过深时,使用上面提到的那个接口获取id列表时,返回的id顺序会出现错乱。比较奇怪的是,数据库中这个目录及下列节点的数据均没有出现错乱的情况。使用其他接口来获取文件结构时也不会出现错误。难道是这个接口的实现有问题?
后端数据库表在设计时只记录了节点当前的直属父节点的id。后续迭代开发时,为了满足一系列的业务需求,增加了一个text类型的字段path_ids,表示从根节点到当前节点的完整路径。实际内容为以/分割的节点id。在新建、移动、删除节点时维护这个字段。看了一下这个接口的实现:

  1. 查找db中节点对应的path_ids,记为p
  2. 查找db中所有path_ids以p+/开头的节点,并以path_ids升序排序返回id

看着也没什么问题。但实际输出的列表中,层级比较深的几个目录节点,和下面的子节点,顺序始终是错的。直接连接mysql,查找db中所有path_ids以p+/开头的节点,并以path_ids升序排序返回id和path_ids,得到的结果也是错误的。难道是MySQL的错?
一顿Google之后,发现问题关键点了。按照The BLOB and TEXT Types中提到的。BLOB/TEXT类型的字段只有前max_sort_length个字节被用于排序。max_sort_length默认值为1024。在我们这个场景中,因为路径比较深,节点和下面的子节点的path_ids字段长度超过了1024。MySQL在排序时,无法正确处理这些节点的先后顺序。
找到问题原因后,解决方案就比较多了。

  1. 接口本身不对节点id做排序。客户端在拿到子节点id和对应的节点信息后,根据节点信息中的父子关系,去重新构建文件树。
  2. 业务层从MySQL中获取不排序的数据,然后在业务代码中排序。
  3. 增大max_sort_length值,继续让MySQL执行排序。

综合性能及兼容性的考虑,最终我们选择了

  1. 被线上老版客户端调用的原有接口,使用增大max_sort_length值的方案。
  2. 新开一个新版客户端调用的接口,使用方案1。

在macOS 10.15.4之后的系统上编译Qt 5.14.2 WebEngine时遇到的问题

背景

公司目前还在维护一个基于Qt WebEngine开发的V2客户端。在这个客户端上,依赖浏览器内核来实现对音视频文件的预览。为了避免许可问题,Qt官方给出的二进制文件中,没有启用对特定格式音视频解码器的支持,导致部分音视频文件无法被解码。因此每次升级Qt版本时,都需要重新编译Qt WebEngine,启用对这些音视频解码器的支持。
几个月前,公司选择接入WPS预览方案作为新的在线文档预览方案。因为方案用到了浏览器的Service Worker API,而当时V2客户端使用的Qt 5.9.3内置的WebEngine,对Service Worker API的支持存在问题。导致在V2客户端中打开预览界面时,会出现页面完全空白等问题。这些bug同时影响了Windows和macOS两个平台上的V2客户端。因为公司的下一代产品,暂时没有macOS平台的开发计划,只能将macOS平台上V2客户端使用的Qt升级到当时最新的5.14.2版本,并重新发布。因此,需要在macOS上重新编译Qt 5.14.2的WebEngine。

编译

编译本身还是很简单的。下载源代码并解压源代码,再在源代码的上级目录下新建build目录,在build目录下执行

script
1
2
3
4
5
6
7
8
9
../qt-everywhere-src-5.14.2/configure -opensource \
-confirm-license \
-verbose \
-release \
-nomake tests \
-nomake examples \
-webengine-proprietary-codecs
make
make install

这样执行,编译结果和中间文件都在build目录中生成。如果需要修改配置重新编译,直接清空build目录再重新配置生成就可以了。
因为编译过之前之前的版本,都是一次通过,以为这次也会一样顺利。周五下班前开始编译,周一上班时直接就可以用了。结果周末远程回公司电脑看编译结果,看到了一堆错误。根源是src/qtwebengine/src/3rdparty/chromium/build/mac/find_sdk.py抛了Exception: No 10.15.4+ SDK found的异常。
一顿搜索后,在Qt BugReports系统里找到了别人提的bug(QTBUG-83318)。报告人和我一样,在编译Qt 5.14.2版本的时候遇到了这个问题。开发给出的解释是10.15.4开始,xcrun命令输出的SDK版本信息格式为Major.Minor.Patch,而不是原来的Major.Minor,而原有的编译脚本没有适配这个改动。5.12.9以及5.15的正式版上会修复这个问题。而5.14版本不是LTS版本,5.14.2之后不会再有新的小版本更新,需要我们自行修复。
研究了一下5.15上修复bug的commit,基本就是修改一下src/buildtools/config/mac_osx.pri里获取mac_sdk_min的方式,适配一下新的格式。在5.14.2源代码的对应位置中修改一下,就可以修复这个报错,让编译继续。
之后又遇到了一个异常:src/qtwebengine/src/3rdparty/chromium/build/toolchain/mac/filter_libtool.py在执行IsBlacklistedLine时抛了TypeError: cannot use a string pattern on a bytes-like object异常。这个是因为我系统中的python默认为python3。filter_libtool.py中执行libtoolout.communicate()得到的_err类型为bytes,但IsBlacklistedLine期待的入参类型为str。这个需要在得到err后,执行

1
err = err.decode()

将err解码为utf8编码的字符串,再执行后面的操作。

完成上述修改后,即可正常编译。

替换

上述命令执行完成后,会在/usr/local/Qt-5.14.2/目录下生成最终编译产物。这套SDK的Qt WebEngine已经启用对上述音视频解码器的支持。使用这套SDK重新编译客户端即可。
如果你的项目和我们的V2客户端一样,使用了PyQt5,则需要复制/usr/local/Qt-5.14.2/lib下的QtWebEngineCore.framework目录,替换掉site-packages/PyQt5/Qt/lib下的同名目录。

通过读取USN日志监控文件变动

之前我曾在借助计划任务执行需要管理员权限运行的程序里提过,要用读取USN日志来实现对文件变动的监控。之后实现了初版的监控,并断断续续迭代了几个月,修了不少bug。现在功能算是稳定了,在这记录一下,也分享一下遇到的坑,希望能帮到其他人。

USN日志

简介

官方文档戳这里
简单说就是NTFS格式的磁盘上可以开启USN日志功能,记录所有文件/文件夹的文件变动,包括增加删除移动重命名、属性变化、内容变化等。
USN日志中包含一条一条的USN记录,这些记录可以通过 update sequence number (USN) 进行查找。
不同版本的USN日志中存储的USN记录结构体并不相同(USN_RECORD_V2/USN_RECORD_V3/USN_RECORD_V4)。但都有下列属性:

  • FileReferenceNumber: 文件ID
  • ParentFileReferenceNumber: 父文件ID
  • Usn: USN
  • TimeStamp: 时间戳
  • Reason: 变动原因
  • FileAttributes: 文件属性
  • FileNameLength、FileNameOffset、FileName: 文件名

基础实现

Windows提供了两种方式来读取USN日志记录

  1. FSCTL_ENUM_USN_DATA 枚举两个USN之间的所有USN记录。
  2. FSCTL_READ_USN_JOURNAL 读取指定USN之后满足条件的USN记录

可以参考官方例子walking-a-buffer-of-change-journal-records

监控实现

整体方案

  1. 使用CreateFile获得一个磁盘卷的句柄(该操作需要UAC权限)
  2. FSCTL_CREATE_USN_JOURNAL创建/更新USN日志,得到执行FSCTL_CREATE_USN_JOURNAL时刻T0,当前USN日志第一条记录的USN FirstUsn和下一次记录插入时的USN NextUsn。在已经有USN日志的磁盘卷上执行该操作,会按照传入的参数,重新剪裁日志。
  3. FSCTL_ENUM_USN_DATA枚举FirstUsnNextUsn之间的所有USN记录。这些记录对应了T0时,磁盘卷上存在的文件/文件夹。可以通过USN记录中的文件ID、父文件ID、文件名来构建文件树结构FileTree
  4. 反复执行FSCTL_READ_USN_JOURNAL 获取NextUsn之后的所有USN记录。对每一条记录,根据文件ID/父文件ID,在FileTree中获取完整路径。并根据Reason更新FileTree,并生成对应的文件变动事件。

需要注意的坑

  • 只处理Reason包含USN_REASON_CLOSE时的USN记录。

  • 单条USN记录的Reason可能同时包含USN_REASON_FILE_DELETE / USN_REASON_RENAME_NEW_NAME / USN_REASON_FILE_CREATE。目前我们的实现是优先判断USN_REASON_FILE_DELETE,然后判断USN_REASON_RENAME_NEW_NAME, 最后判断USN_REASON_FILE_CREATE。

  • 理论上可以在执行FSCTL_READ_USN_JOURNAL时带上过滤条件,只获取包含USN_REASON_CLOSE时的USN记录。
    但我们的客户反馈,部分使用Transactional NTFS (TxF)的软件(比如Word的某几个版本)在执行删除A -> B移动到A操作时,FSCTL_READ_USN_JOURNAL获取到记录的顺序为B移动到A -> 删除A。导致上层逻辑认为实际操作为B文件移动到A位置之后被删除了。而对应的Reason为USN_REASON_RENAME_OLD_NAME的记录到达顺序则是准确的。最终我们选择记录Reason为USN_REASON_RENAME_OLD_NAME的记录对应的文件ID,并在更新FileTree和生成文件变动事件时,严格按照USN_REASON_RENAME_OLD_NAME到达顺序来处理USN_REASON_RENAME_NEW_NAME记录。

  • FSCTL_ENUM_USN_DATA在文件量较多、磁盘速度较慢的情况下,需要花费较长的时间执行。这会导致:

    1. 如果每次启动监控都执行一次FSCTL_ENUM_USN_DATA,则需要等待很长的时间才能真正开始工作。

    2. FSCTL_READ_USN_JOURNAL开始读取的一部分USN记录延迟较大。

      目前我们的做法是:

    • 将文件树存入sqlite3 db中缓存起来,并记录对应的USN日志ID和最后一次执行FSCTL_READ_USN_JOURNAL时返回的NextUsn
    • 监控启动时判断USN日志ID是否和db中记录的一致。NextUsn是否大于等于db中记录的值。如果一致,则跳过执行FSCTL_ENUM_USN_DATA,以db中的NextUsn作为执行FSCTL_READ_USN_JOURNAL时传入的初始值。否则清空DB,按照正常流程执行FSCTL_ENUM_USN_DATA。
    • FSCTL_ENUM_USN_DATA执行完成后,先执行FSCTL_READ_USN_JOURNAL,只获取包含USN_REASON_CLOSE时的USN记录,并更新FileTree。直到读取不到USN记录,或者USN记录的触发时间与当前时间的间隔已经小于1秒。之后再执行正常的FSCTL_READ_USN_JOURNAL流程,开始生成文件变动事件。
  • USN_REASON_OBJECT_ID_CHANGE的解释为The object identifier of a file or directory is changed.,但似乎不能作为文件ID变化的依据。

  • NTFS的树状结构中,磁盘卷根目录并不是文件树的根节点。存在部分节点,他们的父文件ID并不在FSCTL_ENUM_USN_DATA构造的文件树中。遇到这种case,认为这些节点在磁盘卷的根目录下即可。

  • 执行FSCTL_READ_USN_JOURNAL过程中如果删除USN日志,则FSCTL_READ_USN_JOURNAL会返回错误ERROR_INVALID_PARAMETERERROR_JOURNAL_ENTRY_DELETED,需要根据对应的业务逻辑,重新执行FSCTL_CREATE_USN_JOURNAL生成新的USN日志,或者结束监控。