UE构建提升:优化远程构建IOS的实现

UE build improvement: optimize the implementation of remote build IOS

远程构建是UE中非常方便好用的一种打包IOS方式,能够只在Mac上编译代码,不COOK资源,能够极大地降低对于Mac硬件的需求。

而且,Mac的售价颇高,传统模式下,如果需要部署多个独立的IOS包构建流程,通常就需要采购多台独立的Mac机器分别部署,硬件成本较高。

况且,IOS的开发环境往往需要随着IOS系统迭代进行更新。每年WWDC发布新版本IOS,就会需要新版本的XCode支持,新版本XCode又依赖新版本的MacOS,套娃式的链式依赖。如果Mac的构建环境增多,那么更新这些机器的构建环境也会变为一个繁琐的重复任务。

远程构建IOS也能够比较好的解决这个问题,一台独立的Mac,可以同时给多个IOS构建流程提供支持,如果需要更新环境也只需要处理一台即可。而且编译代码的耗时相对固定,且能够进行增量编译,理论上来说只要不是所有的机器同时执行全量编译,那么对于Mac的性能压力就没有那么大。

博客中也有开启远程构建IOS的文章:UE 开发笔记:Mac/iOS 篇,可以参考基础配置启用。

但UE默认实现的IOS远程构建在实际使用中也存在不少的问题,无法实现从部署到构建全自动化流程,本篇文章会针对远程构建在实际的工程应用中痛点进行优化。

RemoteServer指定端口号

这是一个贯穿所有UE4版本的问题,引擎默认RemoteServer的SSHD端口号为22,大多数情况下也确实是,但是因为各种原因,公司内的网络策略不允许开放22端口,如果想要SSH连接只能将SSHD改为其他端口。

在这种情况下,如果在ProjectSettings中配置了IP:PORT形式的RemoteServerName,就会导致SSHKey的查询失败,因为UE默认是直接把RemoteServerName拼接到路径里的。

所以,需要修改引擎支持,让IP:PORT形式的配置不影响SSHKey的查找。

变动文件为Runtime/IOS/IOSRuntimeSettings/Private/IOSRuntimeSettings.cppPostInitProperties函数:

本问题在UE5中已解决,我提交的PR(EpicGames/UnrealEngine/pull/7737)已被合入UE5,但UE4中仍然存在。

自动化导入证书与Provision

默认情况下,当在Win和Mac上需要打包IOS时,不管是远程构建还是Mac直接出包,都需要在引擎内导入对应bundle id的证书和provision。
并且会弹窗输入证书密码:

整个过程时纯手工操作的,非常繁琐。有多少台机器就要进行多少次导入配置,如果证书过期或者provision更新,所有机器又要手动来一遍,简直折磨。

为了解决这个问题,我排查了引擎中导入证书和provision的实现,寻找可以完全自动化的方法。

引擎中具有一个独立的程序,iPhonePackager,可以导入证书和provision,以及安装IPA到设备上、对IPA重签名等功能。虽然它提供了命令行,但是却不能指定证书密码,执行时会弹窗提示,如上图在编辑器内导入的情况。

所以,我修改了引擎中iPhonePackager的代码,支持通过命令行参数-cerpassword指定证书密码,避免弹窗的问题。

这样就能够通过下列命令进行静默式的证书导入了:

1
2
3
4
# 导入证书
IPhonePackager.exe Install Engine -project G:\Client\Game.uproject -certificate G:\Client\Source\ThirdParty\iOS\com.xxxx.yyyy\Development\com.xxxx.yyyy_Development.p12 -cerpassword cerpassword -bundlename com.xxxx.yyyy
# 导入provision
IPhonePackager.exe Install Engine -project G:\Client\Game.uproject -provision G:\Client\Source\ThirdParty\iOS\com.xxxx.yyyy\Development\com.xxxx.yyyy_Development.mobileprovision -bundlename com.xxxx.yyyy

但默认编译引擎时不会编译这个程序,没法使用最新的变动,为了避免这个情况,可以将预编译好的iPhonePackager放到了工程中,通过这个预编译的版本执行导入。

到目前为止,静默式地更新证书和provision的底层支持已经实现了,但还需要确定导入证书的时机。

我选择的时机时,通过在项目的target.cs中写一段逻辑,检测当前的编译环境、iPhonePackager的路径、证书路径等等,构造出一个导入命令,调用iPhonePackager执行。

这样就能够在编译的时候就执行导入,而且UE的编译过程也是能够比较方便地往UBT里传递编译参数,进而在Target.cs检测参数并按需执行。

并且,自动导入功能默认最好是关闭的,通过一个UBT的命令行参数开启,这样就能通过参数开关在流水线上自动导入证书了。

在Target.cs中检测参数进行证书导入的代码为:

target.cs
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
bool bExportCer = IsContainInCmd("-importcer");
if (Target.Platform == UnrealTargetPlatform.Win64 && bExportCer)
{
string EngineSourceDirectory = System.IO.Directory.GetCurrentDirectory();
string IPhonePackagerExePath = Path.GetFullPath(Path.Combine(EngineSourceDirectory, "..\\Binaries\\DotNET\\IOS/IPhonePackager.exe").Normalize());
string UProjectFile = Target.ProjectFile.ToString();
Console.WriteLine("uproject file:" + UProjectFile);
Console.WriteLine("IPhonePackagerExePath:" + IPhonePackagerExePath);
if (File.Exists(IPhonePackagerExePath))
{
Dictionary<string, string> BundleIdMap = new Dictionary<string, string>();
BundleIdMap.Add("com.xxx.yyy","password1");
BundleIdMap.Add("com.xxx.yyy.zzz","password2");
string[] Configurations = { "Development" };
string CerRelativePath = "Source/ThirdParty/iOS/";

DirectoryReference ProjectDir = ProjectFile.Directory;
foreach (KeyValuePair<string, string> BundleIdPair in BundleIdMap)
{
string BundleId = BundleIdPair.Key;
for (int ConfigurationIndex = 0; ConfigurationIndex < Configurations.Length; ConfigurationIndex++)
{
string PackageConfiguration = Configurations[ConfigurationIndex];
string mobileprovision_name = BundleId + "_" + PackageConfiguration + ".mobileprovision";
string cer_file_name = BundleId + "_" + PackageConfiguration + ".p12";
string cerPath = Path.Combine(ProjectDir.FullName, CerRelativePath,BundleId,PackageConfiguration,cer_file_name);
string proversionPath = Path.Combine(ProjectDir.FullName, CerRelativePath,BundleId,PackageConfiguration,mobileprovision_name);

if (File.Exists(cerPath) && File.Exists(proversionPath))
{
string cerPassword = BundleIdPair.Value;
string[] Cmds =
{
String.Format(
"Install Engine -project {0} -certificate {1} -cerpassword {2} -bundlename {3}",
UProjectFile, cerPath, cerPassword, BundleId),
String.Format(
"Install Engine -project {0} -provision {1} -bundlename {2}",
UProjectFile, proversionPath, BundleId),
};
for (int CmdIndex = 0;CmdIndex < Cmds.Length;CmdIndex++)
{
ProcessStartInfo startInfo = new ProcessStartInfo();
startInfo.CreateNoWindow = true;
startInfo.UseShellExecute = false;
startInfo.FileName = IPhonePackagerExePath;
startInfo.Arguments = Cmds[CmdIndex];
Console.WriteLine(String.Format("Import Cer&Provision cmd: {0} {1}",startInfo.FileName,startInfo.Arguments));
Process.Start(startInfo);
}
}
}
}
}
}

如果在编译时给UBT传递了-importcer参数:

1
Build\BatchFiles\Build.bat -Target="GameEditor Win64 Development" -Project="G:\Client\Game.uproject" -WaitMutex -importcer  

在编译日志里就能看到证书和provision的导入日志了:

1
2
3
4
5
6
7
2023-08-15 20:13:09:055 : AssemblyDir: G:\UE4_27\Engine
2023-08-15 20:13:09:055 : EngineAssemblyFileName: G:\UE4_27\Engine\Intermediate\Build\BuildRules\UE4Rules.dll
2023-08-15 20:13:09:205 : ProjectDir:G:\Client
2023-08-15 20:13:09:205 : uproject file:G:\Client\FGame.uproject
2023-08-15 20:13:09:205 : IPhonePackagerExePath:G:\Client\Source\ThirdParty\iOS\iPhonePackager\IPhonePackager.exe
2023-08-15 20:13:09:205 : Import Cer&Provision cmd: G:\Client\Source\ThirdParty\iOS\iPhonePackager\IPhonePackager.exe Install Engine -project G:\Client\FGame.uproject -certificate G:\Client\Source\ThirdParty\iOS\com.xxx.yyy\Development\com.xxx.yyy_Development.p12 -cerpassword keystore -bundlename com.xxx.yyy
2023-08-15 20:13:09:256 : Import Cer&Provision cmd: G:\Client\Source\ThirdParty\iOS\iPhonePackager\IPhonePackager.exe Install Engine -project G:\Client\FGame.uproject -provision G:\Client\Source\ThirdParty\iOS\com.xxx.yyy\Development\com.xxx.yyy_Development.mobileprovision -bundlename com.xxx.yyy

新机器SSH连接指纹验证

因为远程构建IOS的实质上,是在Win上发起构建,在Win上由UBT通过SSH和rsync传递命令和传输引擎和代码文件,再通过rsync拉回编译结果的过程。

关键点:SSH连接

当一台SSH Client去连接一台新的IP时,会触发首次连接到服务器时的安全机制,会给予提示并且等待用户输入yes

当你连接到一个新的服务器时,它询问你是否信任这个新服务器并希望将其添加到known_hosts文件中。如果输入yes,SSH会将服务器的公钥添加到你本地机器的known_hosts文件中。这样,在后续连接到该服务器时,SSH可以比较服务器提供的公钥与known_hosts文件中的公钥,以确保你正在连接到相同的服务器,防止被中间人攻击(Man-in-the-Middle Attack)。

但是,这个问题在我们使用IOS远程构建时,造成了配置噩梦:

  1. 如果修改了项目中的RemoteServerIP,需要对所有参与连接的PC机器,都在手动执行一遍SSH连接,并允许。
  2. 部署了一台新的PC机器,要使用远程构建,同样需要手动执行一遍SSH连接

如果不进行配置,引擎默认SSH连接时使用的是-BatchMode=yes,如果需要用户输入则会直接触发构建失败!我曾分析过这个问题,详见:远程构建时 SSH 连接错误

整个过程异常繁琐,违背了我们完全实现自动化构建的初衷。

同样地,为了解决这个问题,需要对引擎中的实现稍加变动,让引擎在使用SSH与rsync连接时,都忽略指纹验证。

需要变动的代码,在ToolChain/RemoteMac.cs中修改SSH的连接参数:

SSH连接参数

rsync的连接参数

改动完毕,终于,这个世界清静了…

UPL对plist的修改被覆盖

当使用远程构建IOS时,会在Mac上生成stub文件,用于在Windows上合成最终的IPA。

我们在UPL中写的逻辑,会在从Mac上生成的stub中有体现,但Windows上也会在StagedBuilds/IOS下生成一个Info.plist文件,是创建出来的模板信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.Epic.Unreal</string>
<key>CFBundleURLSchemes</key>
<array>
<string>FGame</string>
</array>
</dict>
</array>
<!-- .... -->
</dict>
</plist>

在使用iPhonePackager把Mac上生成的stub与资源打包成最终的IPA时,会把stub中的Info.plist与StagedBuilds/IOS/Info.plist中的进行合并:

iPhonePackager的执行命令:

1
D:\UE4.27\Engine\Binaries\DotNET\IOS\IPhonePackager.exe RepackageFromStage "D:\Client\GWorld.uproject" -config Development -schemename GWorld -schemeconfig "Development" -targetname GWorld -sign -codebased -stagedir "D:\Client\Saved\StagedBuilds\IOS" -project "D:\Client\GWorld.uproject" -provision "com.imzlp.gworld_Development.mobileprovision" -certificate "iPhone Developer: Created via API (XXXX)"

Programs/IOS/iPhonePackager/CookTime.csRepackageIPAFromStub函数中,实现的合并过程:iPhonePackager/CookTime.cs#L226-L258

合并时会有如下LOG:

1
2
3
4
5
6
7
8
9
10
----------
Executing command 'Clean' ...
Cleaning temporary files from PC ...
... cleaning: D:\Client\Intermediate\IOS-Deploy\GWorld\Development\
2023-07-10 14:33:45:817 :
Loaded stub IPA from 'D:\Client\Binaries\IOS\GWorld.stub' ...
... 'D:\Client\Binaries\IOS\GWorld.stub' -> 'D:\Client\Binaries\IOS\GWorld.ipa'
Copy: D:\Client\Binaries\IOS\GWorld.stub -> D:\Client\Binaries\IOS\GWorld.ipa, last modified at 2023/7/10 12:26:52
Found Info.plist (D:\Client\Saved\StagedBuilds\IOS\Info.plist) in stage, which will be merged in with stub plist contents
...

合并plist在引擎里的实现,是将StagedBuilds中的plist合并到stub的plist(注意这个顺序)。

如果StagedBuilds的plist中的元素在stub的plist中已存在,会先移除stub的plist中的元素,再将StagedBuilds的plist中的元素添加到stub的plist结果中。

MergePlistIn的实现在:iPhonePackager/Utilities.cs#L399

如果我们通过UPL操作了生成StagedBuilds/IOS/Info.plist中已存在的元素值,在这样的逻辑中,我们修改的值将永远被默认值覆盖,导致最终打出来的IPA与stub中的plist的值不同。

这应该是引擎中的BUG,修复的方案就是让StagedBuilds的plist去合并stub的plist即可,就是把上述的过程反过来。

CookTime.cs
1
2
3
4
5
6
7
8
9
10
11
12
// Merge the two plists, using the staged one as the authority when they conflict
byte[] StagePListBytes = File.ReadAllBytes(PossiblePList);
string StageInfoString = Encoding.UTF8.GetString(StagePListBytes);

byte[] StubPListBytes = FileSystem.ReadAllBytes("Info.plist");

//++[lipengzha] Fix the problem that the modification of plist in UPL is overwritten
Utilities.PListHelper StageInfo = new Utilities.PListHelper(StageInfoString);
StageInfo.MergePlistIn(Encoding.UTF8.GetString(StubPListBytes));
// Write it back to the cloned stub, where it will be used for all subsequent actions
byte[] MergedPListBytes = Encoding.UTF8.GetBytes(StageInfo.SaveToString());
//--[lipengzha]

而且,使用UBT单独编译引擎,并不会把iPhonePackager这个程序也编译了,所以需要对它单独执行一次编译,或者封装脚本,在每次编译引擎时,把引擎的tools都编译一遍,比较保险。实在不行也能把预编译好的iPhonePackager提交到引擎中。

远程构建上传路径修改

在UE4.26及之前的引擎版本中,默认会上传到~目录下,这是在RemoteMac.cs中写死的逻辑,无法配置:

1
2
3
4
5
6
7
8
// 4.27 or before
// Get the remote base directory
StringBuilder Output;
if(ExecuteAndCaptureOutput("'echo ~'", out Output) != 0)
{
throw new BuildException("Unable to determine home directory for remote user. SSH output:\n{0}", StringUtils.Indent(Output.ToString(), " "));
}
RemoteBaseDir = String.Format("{0}/UE4/Builds/{1}", Output.ToString().Trim().TrimEnd('/'), Environment.MachineName);

echo ~输出的就是当前~的绝对路径。

在UE4.27中,引擎增加了配置项,可以在IOSRuntimeSettings中配置RemoteServerOverrideBuildPath

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 4.27 or later
// Get the remote base directory
string RemoteServerOverrideBuildPath;
if (Ini.GetString("/Script/IOSRuntimeSettings.IOSRuntimeSettings", "RemoteServerOverrideBuildPath", out RemoteServerOverrideBuildPath) && !String.IsNullOrEmpty(RemoteServerOverrideBuildPath))
{
RemoteBaseDir = String.Format("{0}/{1}", RemoteServerOverrideBuildPath.Trim().TrimEnd('/'), Environment.MachineName);
}
else
{
StringBuilder Output;
if (ExecuteAndCaptureOutput("'echo ~'", out Output) != 0)
{
throw new BuildException("Unable to determine home directory for remote user. SSH output:\n{0}", StringUtils.Indent(Output.ToString(), " "));
}
RemoteBaseDir = String.Format("{0}/UE4/Builds/{1}", Output.ToString().Trim().TrimEnd('/'), Environment.MachineName);
}

如果配置了会优先获取,如果不存在才会通过echo ~获取。

如果默认上传路径的磁盘不足,可以使用这种方式将远程构建的上传路径修改到其他的磁盘中。

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

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

本文标题:UE构建提升:优化远程构建IOS的实现
文章作者:查利鹏
发布时间:2023年08月16日 10时43分
更新时间:2023年09月05日 20时11分
本文字数:本文一共有3.6k字
原始链接:https://imzlp.com/posts/50293/
许可协议: CC BY-NC-SA 4.0
文章禁止全文转载,摘要转发请保留原文链接及作者信息,谢谢!
您的捐赠将鼓励我继续创作!