iOS 多环境打包,一个工程同时出 Ad Hoc 测试包和正式包
测试同学要装连测试环境的包,上架要连正式环境的包,两个包最好能同时装在一台手机上,方便对比。这篇用一个 SwiftUI 工程 MyApp 演示,用 xcconfig 把两套环境分开,准备 Ad Hoc 和 App Store 两份描述文件,最后用一个 PowerShell 脚本一次打出两个 ipa。
文中的命令在 Windows 上执行,工程已经能用 KXBuild 编译并装到真机上。还没走到这一步的,先看第一个 SwiftUI App。
两个包有什么不同
| 项目 | 测试包 | 正式包 |
|---|---|---|
| Bundle ID | com.example.myapp.test | com.example.myapp |
| 桌面名称 | MyApp测试 | MyApp |
| 图标 | AppIcon-Test | AppIcon |
| 接口地址 | https://test-api.example.com | https://api.example.com |
| 描述文件 | Ad Hoc,含测试机 UDID | App Store |
| 安装方式 | 数据线或分发平台直接装 | TestFlight、App Store |
Bundle ID 不同,手机才会把它们当成两个 App,两个包可以同时安装。名称和图标不同,是为了测试同学一眼分清手里拿的是哪个。
方案选型
Xcode 里常见的做法是复制 Release 新建一个 Staging 构建配置,工程里已经有这类配置的,构建时写 -c Staging 即可。没有的,用下面的做法,不用改工程结构。
- 工程设置里放默认值。接口地址作为一个自定义构建设置
API_BASE_URL写进工程,Debug 指向测试环境,Release 指向正式环境。平时在 VS Code 里调试和出正式包都用它。 - 测试包套一份 xcconfig。
Config\AdHoc.xcconfig里写测试包要改的几项,构建时用--xcconfig套上。xcconfig 是纯文本,和代码一起提交,谁来打包结果都一样。 - 临时改一项用 -D。例如这次测试包要连开发同学自己的服务器,构建时加
-D API_BASE_URL=...,不用改文件。
有一条规则要记住。--xcconfig 文件里的设置优先级最高,比 -D 还高,这和 xcodebuild 的 -xcconfig 一样。所以同一个键不要既写进 xcconfig 又用 -D 传,想临时覆盖的键就别放进 xcconfig。
加一个接口地址设置
以下两种做法任选其一。
- VS Code 插件。命令面板运行
KXBuild: 工程设置…,左栏选 MyApp 这个 target,在 Build Settings 页的值单元格上右键「添加用户自定义设置」,名字填API_BASE_URL。打开「按配置展开」,Debug 填https://test-api.example.com,Release 填https://api.example.com,Ctrl+S 保存。 - 命令行。在工程目录执行下面两条,
-c指定只改哪个配置。
1kxbuild project add-setting -k API_BASE_URL -v https://test-api.example.com -t MyApp -c Debug -P .\MyApp.xcodeproj
2kxbuild project add-setting -k API_BASE_URL -v https://api.example.com -t MyApp -c Release -P .\MyApp.xcodeproj
编辑器的用法见工程设置编辑器,命令参数见 kxbuild 命令参考。
让代码读到它
构建设置只在构建时存在,要写进 Info.plist,App 运行时才能读到。
工程本来就有 Info.plist 文件的,用 plist 编辑器打开它,添加一行 String 类型的 API_BASE_URL,值写 $(API_BASE_URL)。
Xcode 13 之后新建的工程通常没有 Info.plist 文件,由构建设置自动生成。这种工程新建一个 Config\Info.plist,只写自定义的键,构建时会和自动生成的内容合并。
1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict>
5 <key>API_BASE_URL</key>
6 <string>$(API_BASE_URL)</string>
7</dict>
8</plist>
再把 target 的 Info.plist 文件指向它,在工程设置编辑器 General 页的「Info.plist 文件」填 Config/Info.plist,或者执行下面这条。
1kxbuild project add-setting -k INFOPLIST_FILE -v Config/Info.plist -t MyApp -P .\MyApp.xcodeproj
代码里这样读取。STAGING 是下一节在测试包里加的编译条件,只在测试包里需要的代码(例如显示当前环境的调试入口)用它包起来,正式包里不会编译进去。
1import Foundation
2
3enum AppEnv {
4 static let apiBaseURL = Bundle.main.object(forInfoDictionaryKey: "API_BASE_URL") as? String ?? ""
5
6 #if STAGING
7 static let isTestBuild = true
8 #else
9 static let isTestBuild = false
10 #endif
11}
写两份 xcconfig
在工程目录建 Config 文件夹,放两份 xcconfig。测试包的这份写所有和正式包不同的地方。
1// Config/AdHoc.xcconfig
2PRODUCT_BUNDLE_IDENTIFIER = com.example.myapp.test
3INFOPLIST_KEY_CFBundleDisplayName = MyApp测试
4ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon-Test
5API_BASE_URL = https:/$()/test-api.example.com
6SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) STAGING
7CODE_SIGN_IDENTITY = Apple Distribution
正式包的这份只指定用发布证书。
1// Config/AppStore.xcconfig
2CODE_SIGN_IDENTITY = Apple Distribution
几处要注意。
- xcconfig 里
//是注释,网址要写成https:/$()/,$()展开为空,结果仍是https://。 INFOPLIST_KEY_CFBundleDisplayName只对自动生成 Info.plist 的工程生效。工程用自己的 Info.plist 的,在 plist 编辑器里把CFBundleDisplayName的值改成$(APP_DISPLAY_NAME),再在工程里和 xcconfig 里分别设置APP_DISPLAY_NAME。AppIcon-Test要在资源目录里先建好。用资源目录编辑器打开 Assets.xcassets,右键「新建 App 图标」,命名为 AppIcon-Test,放一张加了「测试」角标的图标。做图标的方法见图标、启动屏和多语言。
SWIFT_ACTIVE_COMPILATION_CONDITIONS 里的 $(inherited) 保留工程原有的编译条件,只追加 STAGING。
准备两套描述文件
两个包用同一张 Apple Distribution 证书,描述文件各一份。证书和描述文件的获取方法见证书与签名,完整申请步骤见在 Windows 上申请证书。
登记测试机
Ad Hoc 包只能装在描述文件里登记过的手机上。测试机接到电脑上,执行下面的命令,第一列就是 UDID。
1kxdevice device list
在 developer.apple.com 的 Certificates, Identifiers & Profiles 里打开 Devices 页,点「+」逐台登记。每种设备每个会员年最多登记 100 台,删除设备要到下一个会员年才能腾出名额,别把临时借用的手机随手加进去。
注册 App ID 并生成描述文件
- 在 Identifiers 页注册两个 App ID,
com.example.myapp和com.example.myapp.test。App 用到推送、App Groups、Sign in with Apple 这类能力的,两个 App ID 都要勾上。 - 在 Profiles 页新建一个 Ad Hoc 类型的描述文件,App ID 选
com.example.myapp.test,证书选 Apple Distribution,设备勾上所有测试机,名字填 MyApp AdHoc。 - 再新建一个 App Store 类型的描述文件,App ID 选
com.example.myapp,名字填 MyApp AppStore。
下载后和证书一起放进工程根目录的 keychain 文件夹。调试用的开发证书和描述文件可以留在里面,互不影响。
1keychain\
2├─ development.p12
3├─ distribution.p12
4├─ MyApp_Dev.mobileprovision
5├─ MyApp_AdHoc.mobileprovision
6├─ MyApp_AppStore.mobileprovision
7└─ password.properties
构建时怎么选
KXBuild 按 Bundle ID 从证书目录里挑描述文件,两个包的 Bundle ID 不同,各自会匹配到自己的那一份。xcconfig 里的 CODE_SIGN_IDENTITY = Apple Distribution 决定用发布证书签。
同一个 Bundle ID 同时有 Ad Hoc 和 App Store 两份描述文件时,自动挑选分不出该用哪份,这时在对应的 xcconfig 里加一行,按名字指定。名字是在开发者网站创建描述文件时填的那个,不是文件名。
1PROVISIONING_PROFILE_SPECIFIER = MyApp AdHoc
一次打出两个包
把下面的脚本存成工程目录下的 build-all.ps1,每次出包执行它,参数是这次的构建号。
1param([int]$Build = 1)
2
3$proj = '.\MyApp.xcodeproj'
4$packages = @(
5 @{ Name = 'MyApp-Test'; Config = '.\Config\AdHoc.xcconfig' },
6 @{ Name = 'MyApp'; Config = '.\Config\AppStore.xcconfig' }
7)
8
9foreach ($p in $packages) {
10 kxbuild build $proj -c Release --xcconfig $p.Config -D CURRENT_PROJECT_VERSION=$Build --derivedDataDir ".\out\dd-$($p.Name)" --ipa ".\out\$($p.Name).ipa"
11 if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
12}
1powershell -ExecutionPolicy Bypass -File .\build-all.ps1 -Build 46
完成后 out 目录里有 MyApp-Test.ipa 和 MyApp.ipa,旁边各有一个上传用的 AppStoreInfo.plist。几点说明。
- 两个包都用 Release 配置构建,测试包和正式包的优化、行为一致,测试发现的问题就是正式包的问题。
--derivedDataDir给两个包各用一个输出目录。共用一个目录时,先打的包留下的图标文件会混进后打的包里。-D CURRENT_PROJECT_VERSION让两个包的构建号一致,测试反馈的问题能对上是哪次构建。构建号不在 xcconfig 里,所以-D能生效。- 脚本里不要写中文注释。Windows 自带的 PowerShell 5.1 按系统编码读取不带 BOM 的脚本,中文会被读乱而报语法错误。
--xcconfig、--ipa、--derivedDataDir 这些参数的说明见构建与配置。要在 Jenkins、GitLab CI 里自动出这两个包,把这个循环放进流水线即可,证书的处理方式见 Jenkins 和 GitLab CI 自动打包。
分发测试包
用数据线安装
测试机接到电脑上,执行下面的命令。正式版 App 已经在手机上的,测试包会作为另一个 App 装在旁边。
1kxdevice install .\out\MyApp-Test.ipa
多台手机同时接着时用 --udid 指定装到哪一台,UDID 从 kxdevice device list 查看。命令用法见 kxdevice 命令参考。
上传到分发平台
测试同学不在身边的,把 MyApp-Test.ipa 上传到蒲公英这类分发平台,把下载链接或二维码发给测试同学,用 Safari 打开安装。只有描述文件里登记过的手机能装上,新来的测试机要先登记 UDID,重新生成 Ad Hoc 描述文件,替换 keychain 里的旧文件后重新打包。
正式包走 TestFlight
MyApp.ipa 用的是 App Store 描述文件,不能用数据线直接安装,这是正常的。它用 Appuploader 上传到 App Store Connect,再通过 TestFlight 装到手机上验证,步骤见在 Windows 上上架 App Store 的上传和 TestFlight 两节。
常见问题
测试包装不上
多数是这台手机的 UDID 不在 Ad Hoc 描述文件里。在开发者网站登记设备后,要编辑描述文件把这台设备勾上,重新下载替换,再重新打包,已经打好的包不会自动包含新设备。其他安装报错对照 iOS 签名报错与 ipa 安装失败。
两个包装上去只剩一个
两个包的 Bundle ID 相同,后装的覆盖了先装的。用 kxdevice apps 看手机上装了哪些 Bundle ID,再检查构建测试包时是否套上了 --xcconfig .\Config\AdHoc.xcconfig。
测试包收不到推送
先确认 com.example.myapp.test 这个 App ID 也开了推送。另外 Ad Hoc 包和正式包一样走 APNs 的生产环境,后台给测试包发推送要用生产环境的地址,推送主题(topic)填 com.example.myapp.test,不能沿用正式包的 Bundle ID。推送的完整配置见推送通知。
App Groups、钥匙串共享在测试包里不生效
这类能力的标识要注册在 App ID 上。测试包的 App ID 是另一个,要在它的 App Groups 里也勾上同一个组,或者给测试包单独建一个组。改完能力后描述文件要重新生成下载,否则签名时会报描述文件不含这项能力。
-D 传的值没有生效
这个键多半已经写在 --xcconfig 指定的文件里了,xcconfig 的优先级更高。把它从 xcconfig 里删掉,改成在工程里设默认值,需要时再用 -D 覆盖。
上传提示构建号重复
App Store Connect 要求同一版本每次上传的构建号比上一次大,每次执行脚本都要换一个更大的 -Build。这要求 Info.plist 的 CFBundleVersion 写成 $(CURRENT_PROJECT_VERSION),Xcode 13 之后新建的工程默认如此,老工程的改法见Jenkins 和 GitLab CI 自动打包的构建号一节。