UPL 在 iOS 中的应用

Unreal Plugin Language是 UE 提供的以 XML 语法为基础的语言,用来可以控制构建 Apk 以及 ipa 的过程,如实现修改 AndroidManifest.xml 或者 info.plist 等。

分析 plist 的生成流程

UEDeployIOS.csGeneratePList 函数中通过传入进来的 UPLScripts 来构造出 UPL 对象:

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
public virtual bool GeneratePList(FileReference ProjectFile, UnrealTargetConfiguration Config, string ProjectDirectory, bool bIsUE4Game, string GameName, bool bIsClient, string ProjectName, string InEngineDir, string AppDirectory, List<string> UPLScripts, VersionNumber SdkVersion, string BundleID, bool bBuildAsFramework, out bool bSupportsPortrait, out bool bSupportsLandscape, out bool bSkipIcons)
{
// remember name with -IOS-Shipping, etc
// string ExeName = GameName;

// strip out the markup
GameName = GameName.Split("-".ToCharArray())[0];

List<string> ProjectArches = new List<string>();
ProjectArches.Add("None");

string BundlePath;

// get the receipt
if (bIsUE4Game)
{
// ReceiptFilename = TargetReceipt.GetDefaultPath(UnrealBuildTool.EngineDirectory, "UE4Game", UnrealTargetPlatform.IOS, Config, "");
BundlePath = Path.Combine(UnrealBuildTool.EngineDirectory.ToString(), "Intermediate", "IOS-Deploy", "UE4Game", Config.ToString(), "Payload", "UE4Game.app");
}
else
{
// ReceiptFilename = TargetReceipt.GetDefaultPath(new DirectoryReference(ProjectDirectory), GameName, UnrealTargetPlatform.IOS, Config, "");
BundlePath = AppDirectory;//Path.Combine(ProjectDirectory, "Binaries", "IOS", "Payload", ProjectName + ".app");
}

string RelativeEnginePath = UnrealBuildTool.EngineDirectory.MakeRelativeTo(DirectoryReference.GetCurrentDirectory());

UnrealPluginLanguage UPL = new UnrealPluginLanguage(ProjectFile, UPLScripts, ProjectArches, "", "", UnrealTargetPlatform.IOS);

// Passing in true for distribution is not ideal here but given the way that ios packaging happens and this call chain it seems unavoidable for now, maybe there is a way to correctly pass it in that I can't find?
UPL.Init(ProjectArches, true, RelativeEnginePath, BundlePath, ProjectDirectory, Config.ToString(), false);

return GenerateIOSPList(ProjectFile, Config, ProjectDirectory, bIsUE4Game, GameName, bIsClient, ProjectName, InEngineDir, AppDirectory, SdkVersion, UPL, BundleID, bBuildAsFramework, out bSupportsPortrait, out bSupportsLandscape, out bSkipIcons);
}

在最后调用的 GeneratedIOSList 中,构造出默认的 pliat 内容、从 Additional Plist Data 中读取的内容、以及调用 UPL 来处理 plist 的内容,UPL 的过程是最后处理的。

而且需要注意的是,在 GeneratedPList 函数中,通过 GeneratedIOSList 获取所有模块中添加的 UPL.xml 文件,然后把这些 xml 文件合并成一个,注意合并的顺序是 AdditionalProperties 的顺序,最后添加的 UPL 会放在最后执行,在一个项目中如果有多个使用 UPL 的操作要注意顺序问题。

介入 ipa 生成过程:操作 plist

ios 的 ipa 包中都会有 plist 文件,可以用来配置 app 的一些属性,apple 的开发者文档里对每个支持的 key 有详细的描述:iOS Keys

UE 4.25.1 默认打包会产生下面这样一个 plist 文件:info.plist,在一些特殊的需求中,需要往这个 plist 中添加元素或者修改以及删除。

在 UE 的项目设置中,可以给 plist 添加元素,在 Project Settings-Platform-iOS-Additional Plist data 中可以填入一个字符串,它会被插入到 plist 文件中:

1
<key>AdditionalElementAAA</key>\n<string>this key is a test element.</string>

中间的 \n 是格式化代码,用于另起一行。

如果想要修改或者删除 plist 的元素,需要通过 UPL 来写逻辑(当然也可以使用 UPL 来添加元素,建议使用这种做法)。

1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0" encoding="utf-8"?>
<root>
<init>
<log text="UPL Exalpme adding element to plist..."/>
</init>
<trace enable="true"/>
<iosPListUpdates>
<addElements tag="dict" once="true">
<key>AdditionalElementAAA</key>
<string>this key is a test element.</string>
</addElements>
</iosPListUpdates>
</root>

上面是用来添加元素的,上面的内容和直接写到 Additional Plist data 是一样的。

遍历 plist 中的 key:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?xml version="1.0" encoding="utf-8"?>
<root>
<init>
<log text="UPL Exalpme..."/>
</init>
<trace enable="true"/>
<iosPListUpdates>
<loopElements tag="dict">
<loopElements tag="$">
<setStringFromTag result="TagName" tag="$"/>
<setBoolIsEqual result="bIsKey" arg1="$S(TagName)" arg2="key"/>
<if condition="bIsKey">
<true>
<log text="$S(TagName):$S(TagValue)"/>
</true>
</if>
</loopElements>
</loopElements>

</iosPListUpdates>
</root>

注意:当前元素以 tag = "$" 方式引用。

编译时就会有以下 log:

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
UPL: /Users/buildmachine/UE4/Builds/lipengzha-PC1/C/BuildAgent/workspace/PackageFGameClient/FGame/Plugins/UPLExample/Source/UPLExample/ThirdParty/IOS/IOS_UPL.xml
UPL Init: None
UPLExalpme adding element to plist...
key : CFBundleURLTypes
key : CFBundleDevelopmentRegion
key : CFBundleDisplayName
key : CFBundleExecutable
key : CFBundleIdentifier
key : CFBundleInfoDictionaryVersion
key : CFBundleName
key : CFBundlePackageType
key : CFBundleSignature
key : CFBundleVersion
key : CFBundleShortVersionString
key : LSRequiresIPhoneOS
key : UIStatusBarHidden
key : UIFileSharingEnabled
key : UIRequiresFullScreen
key : UIViewControllerBasedStatusBarAppearance
key : UIInterfaceOrientation
key : UISupportedInterfaceOrientations
key : UIRequiredDeviceCapabilities
key : CFBundleIcons
key : CFBundleIcons~ipad
key : UILaunchStoryboardName
key : CFBundleSupportedPlatforms
key : MinimumOSVersion
key : ITSAppUsesNonExemptEncryption
key : NSLocationAlwaysAndWhenInUseUsageDescription
key : NSLocationWhenInUseUsageDescription
key : CFBundleURLName
key : CFBundleURLSchemes
key : CFBundlePrimaryIcon
key : CFBundleIconFiles
key : CFBundleIconName
key : UIPrerenderedIcon
key : CFBundlePrimaryIcon
key : CFBundleIconFiles
key : CFBundleIconName
key : UIPrerenderedIcon

对于新增比较简单,但是对于删除和修改就比较麻烦了,需要遍历一遍所有的节点,然后根据匹配来删掉当前的元素(注意 plist 的是键值对的,一个 <key></key> 下面还对应着一个 value 元素,这两个都要删掉,不然会打包不过):

1
2
<key>BuildMachineOSBuild</key>
<string>19C57</string>

我写了个方便删除 plist 中元素的流程,可以方便删除多组元素:

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
<?xml version="1.0" encoding="utf-8"?>
<root>
<init>
<log text="UPLExalpme adding element to plist..."/>
</init>
<trace enable="true"/>
<iosPListUpdates>
<addElements tag="dict" once="true">
<key>AdditionalElementA</key>
<string>this key is a AdditionalElementA element.</string>
<key>AdditionalElementB</key>
<string>this key is a AdditionalElementB element.</string>
</addElements>

<setString result="NeedDeleteKey_1" value="AdditionalElementA"/>
<setString result="NeedDeleteKey_2" value="AdditionalElementB"/>
<setInt result="loopSumNum" value="2"/>

<setInt result="loopCount" value="1"/>
<setBoolIsLessEqual result="loopRun" arg1="$I(loopCount)" arg2="$I(loopSumNum)"/>
<while condition="loopRun">
<log text="count:$I(loopCount) SearchKey:$S(NeedDeleteKey_$I(loopCount))"/>

<setBool result="bIsDeleteElement" value="false"/>
<loopElements tag="dict">
<loopElements tag="$">
<!-- delete value -->
<if condition="bIsDeleteElement">
<true>
<setBool result="bIsDeleteElement" value="false"/>
<log text="bIsDeleteElement is true!!!"/>
<setStringFromTag result="TagName" tag="$"/>
<setStringFromTagText result="TagValue" tag="$"/>
<log text="Delete element value,tagname:$S(TagName) value:$S(TagValue)"/>
<removeElement tag="$" once="true"/>
</true>
</if>

<!-- delete key -->
<setStringFromTag result="TagName" tag="$"/>
<setBoolIsEqual result="bIsKey" arg1="$S(TagName)" arg2="key"/>
<if condition="bIsKey">
<true>
<setStringFromTagText result="TagValue" tag="$"/>
<log text="tagname:$S(TagName) tagvalue:$S(TagValue)"/>
<setBoolIsEqual result="bIs_NeedDeleteKey_$I(loopCount)" arg1="$S(TagValue)" arg2="$S(NeedDeleteKey_$I(loopCount))"/>
<if condition="bIs_NeedDeleteKey_$I(loopCount)">
<true>
<log text="Match key $S(NeedDeleteKey_$I(loopCount))."/>
<log text="Delete element key,tagname:$S(TagName) value:$S(TagValue)."/>
<removeElement tag="$" once="true"/>
<setBool result="bIsDeleteElement" value="true"/>
</true>
</if>
</true>
</if>
</loopElements>
</loopElements>

<!--control loop end-->
<setIntAdd result="loopCount" arg1="$I(loopCount)" arg2="1"/>
<setBoolIsLessEqual result="loopRun" arg1="$I(loopCount)" arg2="$I(loopSumNum)"/>
<if condition="loopRun">
<true>
<log text="add loopCount to $I(loopCount)"/>
</true>
<false>
<log text="the loop is finished!"/>
</false>
</if>
</while>

</iosPListUpdates>
</root>

脚本最开始 Add 了两个元素对,后面则是删除的代码,使用时需要关注的是下面三行:

1
2
3
<setString result="NeedDeleteKey_1" value="AdditionalElementA"/>
<setString result="NeedDeleteKey_2" value="AdditionalElementB"/>
<setInt result="loopSumNum" value="2"/>

头两行是要删除的元素的变量,值是要删除的 key 的字符串,注意命名规则都是以 NeedDeleteKey_ 开头,要遵守这个命名规则。
第三行是创建了一个 loopSumNum 的变量,用于记录有多少个需要删除的元素对,这里我测试删除两个,它的值就是 2。

当打包构建的时候,这个脚本执行起来就会有下面的输出:

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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
UPL: /Users/buildmachine/UE4/Builds/lipengzha-PC1/C/BuildAgent/workspace/PackageFGameClient/FGame/Plugins/UPLExample/Source/UPLExample/ThirdParty/IOS/IOS_UPL.xml
UPL Init: None
UPLExalpme adding element to plist...
count:1 SearchKey:AdditionalElementA
tagname:key tagvalue:CFBundleURLTypes
tagname:key tagvalue:CFBundleDevelopmentRegion
tagname:key tagvalue:CFBundleDisplayName
tagname:key tagvalue:CFBundleExecutable
tagname:key tagvalue:CFBundleIdentifier
tagname:key tagvalue:CFBundleInfoDictionaryVersion
tagname:key tagvalue:CFBundleName
tagname:key tagvalue:CFBundlePackageType
tagname:key tagvalue:CFBundleSignature
tagname:key tagvalue:CFBundleVersion
tagname:key tagvalue:CFBundleShortVersionString
tagname:key tagvalue:LSRequiresIPhoneOS
tagname:key tagvalue:UIStatusBarHidden
tagname:key tagvalue:UIFileSharingEnabled
tagname:key tagvalue:UIRequiresFullScreen
tagname:key tagvalue:UIViewControllerBasedStatusBarAppearance
tagname:key tagvalue:UIInterfaceOrientation
tagname:key tagvalue:UISupportedInterfaceOrientations
tagname:key tagvalue:UIRequiredDeviceCapabilities
tagname:key tagvalue:CFBundleIcons
tagname:key tagvalue:CFBundleIcons~ipad
tagname:key tagvalue:UILaunchStoryboardName
tagname:key tagvalue:CFBundleSupportedPlatforms
tagname:key tagvalue:MinimumOSVersion
tagname:key tagvalue:ITSAppUsesNonExemptEncryption
tagname:key tagvalue:NSLocationAlwaysAndWhenInUseUsageDescription
tagname:key tagvalue:NSLocationWhenInUseUsageDescription
tagname:key tagvalue:AdditionalElementA
Match key AdditionalElementA.
Delete element key,tagname:key value:AdditionalElementA.
bIsDeleteElement is true!!!
Delete element value,tagname:string value:this key is a AdditionalElementA element.
tagname:key tagvalue:AdditionalElementB
tagname:key tagvalue:CFBundleURLName
tagname:key tagvalue:CFBundleURLSchemes
tagname:key tagvalue:CFBundlePrimaryIcon
tagname:key tagvalue:CFBundleIconFiles
tagname:key tagvalue:CFBundleIconName
tagname:key tagvalue:UIPrerenderedIcon
tagname:key tagvalue:CFBundlePrimaryIcon
tagname:key tagvalue:CFBundleIconFiles
tagname:key tagvalue:CFBundleIconName
tagname:key tagvalue:UIPrerenderedIcon
add loopCount to 2
count:2 SearchKey:AdditionalElementB
tagname:key tagvalue:CFBundleURLTypes
tagname:key tagvalue:CFBundleDevelopmentRegion
tagname:key tagvalue:CFBundleDisplayName
tagname:key tagvalue:CFBundleExecutable
tagname:key tagvalue:CFBundleIdentifier
tagname:key tagvalue:CFBundleInfoDictionaryVersion
tagname:key tagvalue:CFBundleName
tagname:key tagvalue:CFBundlePackageType
tagname:key tagvalue:CFBundleSignature
tagname:key tagvalue:CFBundleVersion
tagname:key tagvalue:CFBundleShortVersionString
tagname:key tagvalue:LSRequiresIPhoneOS
tagname:key tagvalue:UIStatusBarHidden
tagname:key tagvalue:UIFileSharingEnabled
tagname:key tagvalue:UIRequiresFullScreen
tagname:key tagvalue:UIViewControllerBasedStatusBarAppearance
tagname:key tagvalue:UIInterfaceOrientation
tagname:key tagvalue:UISupportedInterfaceOrientations
tagname:key tagvalue:UIRequiredDeviceCapabilities
tagname:key tagvalue:CFBundleIcons
tagname:key tagvalue:CFBundleIcons~ipad
tagname:key tagvalue:UILaunchStoryboardName
tagname:key tagvalue:CFBundleSupportedPlatforms
tagname:key tagvalue:MinimumOSVersion
tagname:key tagvalue:ITSAppUsesNonExemptEncryption
tagname:key tagvalue:NSLocationAlwaysAndWhenInUseUsageDescription
tagname:key tagvalue:NSLocationWhenInUseUsageDescription
tagname:key tagvalue:AdditionalElementAAA
tagname:key tagvalue:AdditionalElementB
Match key AdditionalElementB.
Delete element key,tagname:key value:AdditionalElementB.
bIsDeleteElement is true!!!
Delete element value,tagname:string value:this key is a AdditionalElementB element.
tagname:key tagvalue:CFBundleURLName
tagname:key tagvalue:CFBundleURLSchemes
tagname:key tagvalue:CFBundlePrimaryIcon
tagname:key tagvalue:CFBundleIconFiles
tagname:key tagvalue:CFBundleIconName
tagname:key tagvalue:UIPrerenderedIcon
tagname:key tagvalue:CFBundlePrimaryIcon
tagname:key tagvalue:CFBundleIconFiles
tagname:key tagvalue:CFBundleIconName
tagname:key tagvalue:UIPrerenderedIcon
the loop is finished!

其实只要可以删除,就可以在删除之后自己通过 addElements 来再把已删除的元素添加一遍了,从而实现要修改的目的。

为 IOS 添加 Framawork

IOS 上的 Framework 有点类似于静态链接库的意思,相当于把.a+.h+ 资源打包到一块的集合体。更具体的区别描述请看:iOS 库 .a 与.framework 区别

在 UE 中以集成 IOS 上操作 Keycahin 的 SSKeychain 为例,在 Module 的 build.cs 中使用 PublicAdditionalFrameworks 来添加:

1
2
3
4
5
6
7
PublicAdditionalFrameworks.Add(
new Framework(
"SSKeychain",
"ThirdParty/IOS/SSKeychain.embeddedframework.zip",
"SSKeychain.framework/SSKeychain.bundle"
)
);

构造 Framework 的第一个参数是名字,第二个是 framework 的路径(相对于 Module),第三个则是解压之后的 Framework 的 bundle 路径(如果 framework 没有 bundle 则可以忽略这个参数,而且就算有 bundle,但是不写这第三个参数貌似也没什么问题)。

这个可以打开 SSKeychain.embeddedframework.zip 文件看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
SSKeychain.embeddedframework
└─SSKeychain.framework
│ Info.plist
│ SSKeychain

├─Headers
│ SSKeychain.h
│ SSKeychainQuery.h

├─Modules
module.modulemap

├─SSKeychain.bundle
│ └─en.lproj
│ SSKeychain.strings

└─_CodeSignature
CodeDirectory
CodeRequirements
CodeRequirements-1
CodeResources
CodeSignature

相对于 .framework 的路径,这个路径一定要填正确,不然是不能用的,因为打包时会把这个 zip 解压出来,然后拷贝到包体中,路径指定错了就无法拷贝了。

1
[2020.05.14-11.04.48:324][988]UATHelper: Packaging (iOS):     [2/183] sh Unzipping : /Users/zyhmac/UE4/Builds/ZHALIPENG/C/Users/imzlp/Documents/UnrealProjectSSD/MicroEnd_423/Plugins/PlatformUtils/Source/PlatformUtils/ThirdParty/IOS/SSKeychain.embeddedframework.zip -> /Users/zyhmac/UE4/Builds/ZHALIPENG/D/UnrealEngine/Epic/UE_4.23/Engine/Intermediate/UnzippedFrameworks/SSKeychain/SSKeychain.embeddedframework

注意:不要在两个不同的模块里同时引入一个相同的第三方 framework 文件,不然会有以下错误(如我在插件 A 中引入了 SSKeychain.embeddedframework.zip 然后在相同工程的另一个插件 B 中也引入了它)。

1
Unable to merge actions producing SSKeychain.embeddedframework.extracted: prerequisites are different.