远程构建是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.cpp
的PostInitProperties
函数:
本问题在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 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远程构建时,造成了配置噩梦:
如果修改了项目中的RemoteServerIP,需要对所有参与连接的PC机器,都在手动执行一遍SSH连接,并允许。
部署了一台新的PC机器,要使用远程构建,同样需要手动执行一遍SSH连接
如果不进行配置,引擎默认SSH连接时使用的是-BatchMode=yes
,如果需要用户输入则会直接触发构建失败!我曾分析过这个问题,详见:远程构建时 SSH 连接错误
整个过程异常繁琐,违背了我们完全实现自动化构建的初衷。
同样地,为了解决这个问题,需要对引擎中的实现稍加变动,让引擎在使用SSH与rsync连接时,都忽略指纹验证。
需要变动的代码,在ToolChain/RemoteMac.cs
中修改SSH的连接参数:
改动完毕,终于,这个世界清静了…
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.cs
的RepackageIPAFromStub
函数中,实现的合并过程: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 byte [] StagePListBytes = File.ReadAllBytes(PossiblePList);string StageInfoString = Encoding.UTF8.GetString(StagePListBytes);byte [] StubPListBytes = FileSystem.ReadAllBytes("Info.plist" );Utilities.PListHelper StageInfo = new Utilities.PListHelper(StageInfoString); StageInfo.MergePlistIn(Encoding.UTF8.GetString(StubPListBytes)); byte [] MergedPListBytes = Encoding.UTF8.GetBytes(StageInfo.SaveToString());
而且,使用UBT单独编译引擎,并不会把iPhonePackager这个程序也编译了,所以需要对它单独执行一次编译,或者封装脚本,在每次编译引擎时,把引擎的tools都编译一遍,比较保险。实在不行也能把预编译好的iPhonePackager提交到引擎中。
远程构建上传路径修改 在UE4.26及之前的引擎版本中,默认会上传到~
目录下,这是在RemoteMac.cs 中写死的逻辑,无法配置:
1 2 3 4 5 6 7 8 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 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 ~
获取。
如果默认上传路径的磁盘不足,可以使用这种方式将远程构建的上传路径修改到其他的磁盘中。