部署 Windows 远程构建 iOS

UE 可以在 Windows 可以直接出 Android 和 Win 的包(Android 需要配置 JDK/SDK/NDK/Gradle 等环境)。打包 iOS 则需要一台 Mac,如果是每次打包 iOS 都要在 Mac 上进行操作,其实就是与 Win 上完全相同的操作,但是流程上不能复用就显得很繁琐,尤其是在修改了引擎的情况下,需要先更新引擎代码并编译,然后再更新项目、执行打包。好在 UE 提供了远程构建 iOS,可以把 Win/Android/iOS 三端的包在相同的构建流程里出完。

前置需求:

  1. 内网搭载 MacOS 的电脑一台(白黑都可)
  2. 申请 p12 证书和 mobileprovision
  3. PC 和 Mac 需要在网络内可以相互访问(在相同网段)

IOS 证书申请

打包 iOS 之前需要申请 iOS 的开发者账号来创建证书,可以在 developer.apple.com 申请。需要得到 p12 证书和 mobileprovision,然后在 UE 的项目设置中导入它们。需要注意创建的证书是 Developer 还是 Distribution 证书,在出包的时候要匹配,否则会打包失败。

申请证书的流程网上有很多文章,我这里是简单记录了下我申请证书的流程,步骤不是最详细的,仅供参考。

首先在 Mac 上导出一个证书:
打开软件 钥匙串访问 - 证书助理 - 从证书颁发机构请求证书

选择存储到磁盘,会生成一个 CertificateSigningRequest.certSigningRequest 的文件。
然后登录Apple Developer,进入Account-Certificates

进去之后创建 Apple Development 或者 iOS App Development,创建过程中需要把上面生成的CertificateSigningRequest.certSigningRequest 文件上传。

添加设备:

可以使用 UE 的 IPhonePackager.exe 来查看 ios 设备的 uuid:

生成 Provision:

生成之后要下载 provision 文件:

配置远程构建

UE 打包 iOS 需要在项目设置中导入证书和provision,以及把BundleNameBundle Identifier设置为在 Apple 开发者网站上设置的 Bundle ID,格式为com.xxxxx.yyyyyy

p12 证书和 mobileprovision 导入之后如图:

导入证书之后就可以开始远程打包的配置了。

首先在 MAC 的 系统偏好设置 - 共享 中启用远程登录:

然后在 Windows 上对项目添导入 mobileproversion 和设置 BundleNameBundle Identifier
之后继续往下拉找到 IOS-Build 下的 Remote Build Options:

填入目标 MAC 机器的 IP 地址(如果不指定端口则默认为 22,如果指定端口则使用 xx.xx.xx.xx:2222 这种形式,以冒号分隔)和用户名。

然后点击 Generated SSH Key 会弹出一个窗口:

按任意键继续。
会提示你输入一个密码,按照提示输入,之后会提示你输入 MAC 电脑的密码,输入之后会提示:

1
Enter passphrase (empty for no passphrase):

这是让你输入生成的 ssh Key 的密码,默认情况下可以不输,直接 Enter 就好。
按照提示一直 Enter 会提示你 ssh key 生成成功:

再继续会提示让你输入第一次设置的密码,和目标 MAC 机器的密码,执行完毕之后就会提示没有错误,就 ok 了:

生成的 SSH Key 的存放路径为:

1
C:\Users\imzlp\AppData\Roaming/Unreal Engine/UnrealBuildTool/SSHKeys/192.168.2.89/imzlp/RemoteToolChainPrivate.key

如果要将其共享给组内的其他成员,则把这个 RemoteToolChainPrivate.key 共享,然后让他们把 IOS-Build-RemoteBuildOptions 下的 Override existing SSH Permissions file 设置为 RemoteToolChainPrivate.key 的路径即可。

之后就可以像打包 Windows 或者在 Win 上打包 IOS 一样了:

远程到 Mac 打包分了几个阶段:

  1. 把本机的引擎和工程代码上传至 Mac
  2. 在 Mac 上执行编译
  3. 编译完毕之后在 Mac 上生成 ipa 包(但不包含 Cook 资源)
  4. 把生成的 ipa 包拉回本地,解包,Cook 美术资源,再合并为 ipa

其中第一步,把本机引擎和工程的代码上传至 Mac 是通过 rsync 来实现的,引擎中的 Engine\Build\Rsync 目录下包含了远程构建时需要上传至目标机器的过滤器。
对于项目,可以在工程目录的 <ProjectDir>/Build/Rsync/RsyncProject.txt 创建该文件,添加自己想要上传至 Mac 的文件过滤器,可以解决执行远程打包时有些文件被遗漏掉的问题。
UE 上传时默认使用的 RsyncProject.txt 过滤器有引擎和项目目录的,具体的代码看:UnrealBuildTool/ToolChain/RemoteMac.cs#L927

iOS 打包证书配置报错问题

在 UE 的项目设置中添加 ProvisionCertificateBundle Identifier要和证书能对应上,但是在设置完之后选择打包还是会提示以下错误:

1
2
Provision not found. A provision is required for deploying your app to the device.  
Signing key not found. The app could not be digitally signed, because the signing key is not configured.

配置完证书和 Provision 之后出现这种情况需要检查下证书是 开发 (Development) 还是 发行 (Distribution),默认情况下项目设置中是不勾选 发行 (Distribution) 的,如果导入的证书是发行证书则 只能打包 Shipping并且 ** 需要勾上发行(Distribution)**。

如果使用发行证书不勾选 ** 发行(For Distribution)** 则打包时会有以下错误:

1
2
3
Check dependencies
Code Signing Error: Provisioning profile "com.tencent.tmgp.zyhx_Production_SignProvision" doesn't match the entitlements file's value for the get-task-allow entitlement.
Code Signing Error: Code signing is required for product type 'Application' in SDK 'iOS 13.6'

SSHKey 路径查找的 bug

前面提到了在 Project Settings-Platforms-iOS 中可以在 Override Existing SSH Permissions file 中指定 SSHKey,如果不指定会默认使用引擎查找路径,默认情况下会从以下路径中查找:

1
2
3
4
5
6
7
8
9
10
const FString DefaultKeyFilename = TEXT("RemoteToolChainPrivate.key");
const FString RelativeFilePathLocation = FPaths::Combine(TEXT("SSHKeys"), *RemoteServerName, *RSyncUsername, *DefaultKeyFilename);
TArray<FString> PossibleKeyLocations;
PossibleKeyLocations.Add(FPaths::Combine(*FPaths::ProjectDir(), TEXT("Build"), TEXT("NotForLicensees"), *RelativeFilePathLocation));
PossibleKeyLocations.Add(FPaths::Combine(*FPaths::ProjectDir(), TEXT("Build"), TEXT("NoRedist"), *RelativeFilePathLocation));
PossibleKeyLocations.Add(FPaths::Combine(*FPaths::ProjectDir(), TEXT("Build"), *RelativeFilePathLocation));
PossibleKeyLocations.Add(FPaths::Combine(*FPaths::EngineDir(), TEXT("Build"), TEXT("NotForLicensees"), *RelativeFilePathLocation));
PossibleKeyLocations.Add(FPaths::Combine(*FPaths::EngineDir(), TEXT("Build"), TEXT("NoRedist"), *RelativeFilePathLocation));
PossibleKeyLocations.Add(FPaths::Combine(*FPaths::EngineDir(), TEXT("Build"), *RelativeFilePathLocation));
PossibleKeyLocations.Add(FPaths::Combine(*Path, TEXT("Unreal Engine"), TEXT("UnrealBuildTool"), *RelativeFilePathLocation));

但是在 RemoveServerName 包含端口的情况下,希望使用引擎查找路径时,UE 的实现有 Bug。

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
void UIOSRuntimeSettings::PostInitProperties()
{
Super::PostInitProperties();

// We can have a look for potential keys
if (!RemoteServerName.IsEmpty() && !RSyncUsername.IsEmpty())
{
SSHPrivateKeyLocation = TEXT("");

const FString DefaultKeyFilename = TEXT("RemoteToolChainPrivate.key");
const FString RelativeFilePathLocation = FPaths::Combine(TEXT("SSHKeys"), *RemoteServerName, *RSyncUsername, *DefaultKeyFilename);

FString Path = FPlatformMisc::GetEnvironmentVariable(TEXT("APPDATA"));

TArray<FString> PossibleKeyLocations;
PossibleKeyLocations.Add(FPaths::Combine(*FPaths::ProjectDir(), TEXT("Build"), TEXT("NotForLicensees"), *RelativeFilePathLocation));
PossibleKeyLocations.Add(FPaths::Combine(*FPaths::ProjectDir(), TEXT("Build"), TEXT("NoRedist"), *RelativeFilePathLocation));
PossibleKeyLocations.Add(FPaths::Combine(*FPaths::ProjectDir(), TEXT("Build"), *RelativeFilePathLocation));
PossibleKeyLocations.Add(FPaths::Combine(*FPaths::EngineDir(), TEXT("Build"), TEXT("NotForLicensees"), *RelativeFilePathLocation));
PossibleKeyLocations.Add(FPaths::Combine(*FPaths::EngineDir(), TEXT("Build"), TEXT("NoRedist"), *RelativeFilePathLocation));
PossibleKeyLocations.Add(FPaths::Combine(*FPaths::EngineDir(), TEXT("Build"), *RelativeFilePathLocation));
PossibleKeyLocations.Add(FPaths::Combine(*Path, TEXT("Unreal Engine"), TEXT("UnrealBuildTool"), *RelativeFilePathLocation));

// Find a potential path that we will use if the user hasn't overridden.
// For information purposes only
for (const FString& NextLocation : PossibleKeyLocations)
{
if (IFileManager::Get().FileSize(*NextLocation) > 0)
{
SSHPrivateKeyLocation = NextLocation;
break;
}
}
}
// ...
}

这个代码在 Windows 上有 bug,因为当 RemoteServerName 具有指定端口时,在 Windows 上就会找不到 SSHKey,因为 Windows 上路径中不能包含冒号,所以在查找 Key 路径的时候会有问题,这个问题需要修改引擎才能解决。
修改上面的代码:

1
2
3
4
5
6
7
8
9
SSHPrivateKeyLocation = TEXT("");
FString RealRemoteServerName = RemoteServerName;
if(RemoteServerName.Contains(TEXT(":")))
{
FString RemoteServerPort;
RemoteServerName.Split(TEXT(":"),&RealRemoteServerName,&RemoteServerPort);
}
const FString DefaultKeyFilename = TEXT("RemoteToolChainPrivate.key");
const FString RelativeFilePathLocation = FPaths::Combine(TEXT("SSHKeys"), *RealRemoteServerName, *RSyncUsername, *DefaultKeyFilename);

重新编译引擎即可。

远程编译 Shader 的 Key 查找 bug

注意:在 4.26 及之后的引擎版本支持在 Windows 上编译 metal 的 Shader 了,详情见文档:Using the Windows Metal Shader Compiler for iOS

Project Settings-Platforms-IOS 中开启 Enable Remote Shader Compile 后,如果填入的构建机地址具有指定端口,在查找 SSHkey 时会有问题:

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
// Developer/Apple/MetalShaderFormat/Private/MetalShaderCompiler.cpp
bool IsRemoteBuildingConfigured(const FShaderCompilerEnvironment* InEnvironment)
{
// ...
GRemoteBuildServerSSHKey = "";
if (InEnvironment != nullptr && InEnvironment->RemoteServerData.Contains(TEXT("SSHPrivateKeyOverridePath")))
{
GRemoteBuildServerSSHKey = InEnvironment->RemoteServerData[TEXT("SSHPrivateKeyOverridePath")];
}
if (GRemoteBuildServerSSHKey.Len() == 0)
{
GConfig->GetString(TEXT("/Script/IOSRuntimeSettings.IOSRuntimeSettings"), TEXT("SSHPrivateKeyOverridePath"), GRemoteBuildServerSSHKey, GEngineIni);

GConfig->GetString(TEXT("/Script/IOSRuntimeSettings.IOSRuntimeSettings"), TEXT("SSHPrivateKeyOverridePath"), GRemoteBuildServerSSHKey, GEngineIni);
if (GRemoteBuildServerSSHKey.Len() == 0)
{
if (!FParse::Value(FCommandLine::Get(), TEXT("serverkey"), GRemoteBuildServerSSHKey) && GRemoteBuildServerSSHKey.Len() == 0)
{
if (GRemoteBuildServerSSHKey.Len() == 0)
{
// RemoteToolChain.cs in UBT looks in a few more places but the code in FIOSTargetSettingsCustomization::OnGenerateSSHKey() only puts the key in this location so just going with that to keep things simple
FString Path = FPlatformMisc::GetEnvironmentVariable(TEXT("APPDATA"));
GRemoteBuildServerSSHKey = FString::Printf(TEXT("%s\\Unreal Engine\\UnrealBuildTool\\SSHKeys\\%s\\%s\\RemoteToolChainPrivate.key"), *Path, *GRemoteBuildServerHost, *GRemoteBuildServerUser);
}
}
}
}
// ...
}

可以看到这里查找的 Key 路径时直接通过 GRemoteBuildServerHost 拼接的,但是如果在配置中指定了端口,那么 GRemoteBuildServerHost 的值为这种格式xxx.xx.xx.xx:1234,但是 Win 上目录名不能带:,就会导致 Key 查找失败。

还有在同文件的 ExecRemoteProcess 函数中,没有针对具有指定端口的情况做处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool ExecRemoteProcess(const TCHAR* Command, const TCHAR* Params, int32* OutReturnCode, FString* OutStdOut, FString* OutStdErr)
{
#if PLATFORM_MAC && !UNIXLIKE_TO_MAC_REMOTE_BUILDING
return FPlatformProcess::ExecProcess(Command, Params, OutReturnCode, OutStdOut, OutStdErr);
#else
if (GRemoteBuildServerHost.IsEmpty())
{
return false;
}
FString CmdLine = FString(TEXT("-i \"")) + GRemoteBuildServerSSHKey + TEXT("\" \"") + GRemoteBuildServerUser + '@' + GRemoteBuildServerHost + TEXT("\" ") + Command + TEXT(" ") + (Params != nullptr ? Params : TEXT(""));
return ExecProcess(*GSSHPath, *CmdLine, OutReturnCode, OutStdOut, OutStdErr);

#endif
}

需要做一些处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
bool ExecRemoteProcess(const TCHAR* Command, const TCHAR* Params, int32* OutReturnCode, FString* OutStdOut, FString* OutStdErr)
{
#if PLATFORM_MAC && !UNIXLIKE_TO_MAC_REMOTE_BUILDING
return FPlatformProcess::ExecProcess(Command, Params, OutReturnCode, OutStdOut, OutStdErr);
#else
if (GRemoteBuildServerHost.IsEmpty())
{
return false;
}

FString RemoteBuildServerIP = GRemoteBuildServerHost;
FString RemoteBuildServerPort = TEXT("22");

if(GRemoteBuildServerHost.Contains(TEXT(":")))
{
GRemoteBuildServerHost.Split(TEXT(":"),&RemoteBuildServerIP,&RemoteBuildServerPort);
}

FString CmdLine = FString(TEXT("-i \"")) + GRemoteBuildServerSSHKey + TEXT("\" \"") + GRemoteBuildServerUser + '@' + RemoteBuildServerIP + TEXT("\" ") TEXT("-p ") + RemoteBuildServerPort +TEXT(" ")+ Command + TEXT(" ") + (Params != nullptr ? Params : TEXT(""));
return ExecProcess(*GSSHPath, *CmdLine, OutReturnCode, OutStdOut, OutStdErr);

#endif
}