在開始之前,我先問你幾個(gè)問題,在測(cè)試的時(shí)候,App 一般需要連接測(cè)試服務(wù)器,那么在上架后,還需要連生產(chǎn)服務(wù)器嗎?在發(fā)布前,你的 App 需要通過 Ad-hoc 分發(fā)給內(nèi)部測(cè)試組嗎?在發(fā)布到 App Store 的時(shí)候,你的 App 需要同時(shí)支持免費(fèi)版和收費(fèi)版嗎?
如果你的回答是“是”,那么你的 App 就需要搭建多環(huán)境支持,優(yōu)化開發(fā)的工作流程。多環(huán)境提供很多好處,比如能基于同一套源代碼自動(dòng)構(gòu)建出有差異功能的 App;能支持多個(gè)團(tuán)隊(duì)并行開發(fā),也能分離測(cè)試和生產(chǎn)環(huán)境,提高產(chǎn)品的迭代速度,保證上架的 App 通過嚴(yán)格測(cè)試和功能驗(yàn)證。
在 Moments App 項(xiàng)目中,我們就使用了三個(gè)不同的環(huán)境,分別是開發(fā)環(huán)境,測(cè)試環(huán)境和生產(chǎn)環(huán)境。它們到底有什么區(qū)別呢?
開發(fā)環(huán)境, 用于日常的開發(fā),一般有未完成的功能模塊。編譯時(shí),也不進(jìn)行任何優(yōu)化,可以打印更多的日志,幫助開發(fā)者快速定位問題。
測(cè)試環(huán)境, 主要是用于測(cè)試,以及為產(chǎn)品經(jīng)理進(jìn)行功能驗(yàn)證,包括部分完成的功能模塊,也提供一些隱藏功能,方便我們進(jìn)行開發(fā)和迭代,例如快速切換用戶,清理 Cache,連接到不同后臺(tái)服務(wù)器等等。
生產(chǎn)環(huán)境, 只包含通過了測(cè)試并驗(yàn)證過的功能模塊,它是最終提交到 App Store 供終端用戶使用的版本。
多環(huán)境支持需要用到 Xcode 的構(gòu)建配置,這一講,我就結(jié)合 Moments App 項(xiàng)目來聊聊這個(gè)問題。
Xcode 構(gòu)建基礎(chǔ)概念
一般在構(gòu)建一個(gè) iOS App 的時(shí)候,需要用到 Xcode Project,Xcode Target,Build Settings,Build Configuration 和 Xcode Scheme 等構(gòu)建配置。它們各有什么用呢?
Xcode Project
Xcode Project用于組織源代碼文件和資源文件。一個(gè) Project 可以包含多個(gè) Target,例如當(dāng)我們新建一個(gè) Xcode Project 的時(shí)候,它會(huì)自動(dòng)生成 App 的主 Target,Unit Test Target 和 UI Test Target。
在 Moments App 項(xiàng)目中,主 Target 就是 Moments,Unit Test Target 是 MomentsTests, UI Test Target 就是 MomentsUITests。

Xcode Target
Xcode Target用來定義如何構(gòu)建出一個(gè)產(chǎn)品(例如 App, Extension 或者 Framework),Target 可以指定需要編譯的源代碼文件和需要打包的資源文件,以及構(gòu)建過程中的步驟。
例如在我們的 Moments App 項(xiàng)目中,負(fù)責(zé)單元測(cè)試的MomentsTestsTarget 就指定了 14 個(gè)測(cè)試文件需要構(gòu)建(見下圖的 Compile Sources),并且該 Target 依賴了主 App TargetMoments(見下圖的 Dependencies)。

有了 Target 的定義,構(gòu)建系統(tǒng)就可以讀取相關(guān)的源代碼文件進(jìn)行編譯,然后把相關(guān)的資源文件進(jìn)行打包,并嚴(yán)格按照 Target 所指定的設(shè)置和步驟執(zhí)行。那么 Target 所指定的設(shè)置哪里來的呢?來自 Build Settings。
Build Settings
Build Setting保存了構(gòu)建過程中需要用到的信息,它以一個(gè)變量的形式而存在,例如所支持的設(shè)備平臺(tái),或者支持操作系統(tǒng)的最低版本等。
通常,一條 Build Setting 信息由兩部分組成:名字和值。比如下面是一條 Setting 信息,iOS Development Target是名字,而iOS 14.0是值。

有了這些基礎(chǔ)知識(shí)以后,接下來我就結(jié)合 Moments App 來和你介紹下如何進(jìn)行多環(huán)境配置,從而生成不同環(huán)境版本的 App。
Moments App 構(gòu)建配置
一般用 Xcode 編譯出不同環(huán)境版本的 App 有多種辦法,例如拷貝復(fù)制所有源代碼,建立多個(gè) Target 來包含不同的源碼文件等等。不過,在這里我推薦使用 Build Configuration 和 Xcode Scheme 來管理多環(huán)境,進(jìn)而構(gòu)建出不同環(huán)境版本的 App。為什么?因?yàn)檫@兩個(gè)是目前管理成本最低的辦法。接下來我一一介紹下。
Build Configuration
當(dāng)我們?cè)?Xcode 上新建一個(gè)項(xiàng)目的時(shí)候,Xcode 會(huì)自動(dòng)生成兩個(gè) Configuration:Debug和Release。Debug 用于日常的本地開發(fā),Release 用于構(gòu)建和分發(fā) App。而在我們的 Moments App 項(xiàng)目中,有三個(gè) configuration:Debug,Internal 和 AppStore。它們分別用于構(gòu)建開發(fā)環(huán)境、測(cè)試環(huán)境和生產(chǎn)環(huán)境。 其中 Internal 和 AppStore 是從自動(dòng)生成的 Release 拷貝而來的。

那什么是 Build Configuration 呢?
Build Configuration就是一組 Build Setting。 我們可以通過 Build Configuration 來分組和管理不同組合的 Build Setting 集合,然后傳遞給 Xcode 構(gòu)建系統(tǒng)進(jìn)行編譯。
有了 Build Configuration 以后,我們就能為同一個(gè) Build Setting 設(shè)置不同的值。例如Build Active Architecture Only在 Debug configuration 是Yes,而在 Internal 和 AppStore configuration 則是No。這樣就能做到同一份源代碼通過使用不同的 Build Configuration 來構(gòu)建出功能不一樣的 App 了。

那么,在構(gòu)建過程中怎樣才能選擇不同的 Build Configuration 呢?答案是使用 Xcode Scheme。
Xcode Scheme
Xcode Scheme用于定義一個(gè)完整的構(gòu)建過程,其包括指定哪些 Target 需要進(jìn)行構(gòu)建,構(gòu)建過程中使用了哪個(gè) Build Configuration ,以及需要執(zhí)行哪些測(cè)試案例等等。在項(xiàng)目新建的時(shí)候只有一個(gè) Scheme,但可以為同一個(gè)項(xiàng)目建立多個(gè) Scheme。不過這么多 Scheme 中,同一時(shí)刻只能有一個(gè) Scheme 生效。
我們一起看一下 Moments App 項(xiàng)目的 Scheme 吧。 Moments App 項(xiàng)目有三個(gè) Scheme 來分別代表三個(gè)環(huán)境,Moments Scheme 用于開發(fā)環(huán)境,Moments-Internal Scheme 用于測(cè)試環(huán)境,而 Moments-AppStore Scheme 用于生產(chǎn)環(huán)境。

下面是MomentsScheme 的配置。

左邊是該 Scheme 的各個(gè)操作,如當(dāng)前選擇了 Build 操作;右邊是對(duì)應(yīng)該操作的配置,比如 Build 對(duì)應(yīng)的 Scheme 可以構(gòu)建三個(gè)不同的 Targets。不同的 Scheme 所構(gòu)建的 Target 數(shù)量可以不一樣,例如下面是Moments-InternalScheme 的配置。

該 Scheme 只構(gòu)建主 App TargetMoments,而不能構(gòu)建其他兩個(gè)測(cè)試 Target。
當(dāng)我們選擇 Run、Test、Profile、 Analyze 和 Archive 等操作時(shí),在右欄有一個(gè)很關(guān)鍵的配置是叫作 Build Configuration,我們可以通過下拉框來選擇 Moments App 項(xiàng)目里面三個(gè) Configuration (Debug,Internal 和 AppStore) 中的其中一個(gè)。

為了方便管理,我們通常的做法是,一個(gè) Scheme 對(duì)應(yīng)一個(gè) Configuration。有了這三個(gè) Scheme 以后,我們就可以很方便地構(gòu)建出 Moments α(開發(fā)環(huán)境),Moments β(測(cè)試環(huán)境)和 Moments(生產(chǎn)環(huán)境)三個(gè)功能差異的 App。

你可能已經(jīng)注意到這三個(gè) App 的名字都不一樣,怎么做到的呢?實(shí)際上是我們?yōu)椴煌?Configuration 設(shè)置了不一樣的 Build Setting。其中決定 App 名字的 Build Setting 叫作PRODUCT_BUNDLE_NAME,然后在 Info.plist 文件里面為 Bundle name 賦值,就能構(gòu)建出名字不一樣的 App。

為了構(gòu)建出不同環(huán)境版本的 App,我們需要經(jīng)常為各個(gè) Build Configuration 下的 Build Setting 設(shè)置不一樣的值。 在這其中,使用好 xcconfig 配置文件就顯得非常重要。
xcconfig 配置文件
xcconfig 會(huì)起到什么作用呢?
一般修改 Build Setting 的辦法是在 Xcode 的 Build Settings 界面上進(jìn)行。 例如下面的例子中修改 Suppress Warnings。

這樣做有一些不好的地方,首先是手工修改很容易出錯(cuò),例如有時(shí)候很難看出來修改的 Setting 到底是 Project 級(jí)別的還是 Target 級(jí)別的。其次,最關(guān)鍵的是每次修改完畢以后都會(huì)修改了 xcodeproj 項(xiàng)目文檔 (如下圖所示),導(dǎo)致 Git 歷史很難查看和對(duì)比。

幸運(yùn)的是,Xcode 為我們提供了一個(gè)統(tǒng)一管理這些 Build Setting 的便利方法,那就是使用 xcconfig 配置文件來管理。
xcconfig 概念及其作用
xcconfig也叫作 Build configuration file(構(gòu)建配置文件),我們可以使用它來為 Project 或 Target 定義一組 Build Setting。由于它是一個(gè)純文本文件,我們可以使用 Xcode 以外的其他文本編輯器來修改,而且可以保存到 Git 進(jìn)行統(tǒng)一管理。 這樣遠(yuǎn)比我們?cè)?Xcode 的 Build Settings 界面上手工修改要方便很多,而且還不容易出錯(cuò)。
在 xcconfig 文件里面的每一條 Setting 都是下面的格式:
BUILD_SETTING_NAME = value
其中,BUILD_SETTING_NAME表示 Build Setting 的名字,而value是該 Setting 的值。下面是一個(gè)例子。
SWIFT_VERSION = 5.0
SWIFT_VERSION是用于定義 Swift 語言版本的 Build Setting,其值是5.0。Setting 的名字都是由大寫字母,數(shù)值和下劃線組成。這種命名法我們一般成為蛇型命名法,例如SNAKE_CASE_NAME。
當(dāng)我們使用 xcconfig 時(shí),Xcode 構(gòu)建系統(tǒng)會(huì)按照下面的優(yōu)先級(jí)來計(jì)算出 Build Setting 的最后生效值
Platform Defaults (平臺(tái)默認(rèn)值)
Xcode Project xcconfig File(Project 級(jí)別的 xcconfig 文件)
Xcode Project File Build Settings(Project 級(jí)別的手工配置的 Build Setting)
Target xcconfig File (Target 級(jí)別的 xcconfig 文件)
Target Build Settings(Target 級(jí)別的手工配置的 Build Setting)
Xcode 構(gòu)建系統(tǒng)會(huì)按照上述列表從上而下讀取 Build Setting,如果發(fā)現(xiàn)同樣的 Setting ,就會(huì)把下面的 Setting 覆蓋掉上面的,越往下優(yōu)先級(jí)別越高。
例如我們?cè)?Project 級(jí)別的 xcconfig 文件配置了SWIFT_VERSION = 5.0而在Target 級(jí)別的 xcconfig 文件配置了SWIFT_VERSION = 5.1,那么Target 級(jí)別的 Build Setting 會(huì)覆蓋 Project 級(jí)別的SWIFT_VERSION設(shè)置,最終SWIFT_VERSION生效的值是5.1。
那么,要怎樣做才能做到不覆蓋原有的 Build Setting 呢?我們可以使用下面例子中的$(inherited)來實(shí)現(xiàn)。
BUILD_SETTING_NAME = $(inherited) additional value
可以保留原先的 Setting,然后把新的值添加到后面去。比如:
FRAMEWORK_SEARCH_PATHS = $(inherited) ./Moments/Pods
其中的FRAMEWORK_SEARCH_PATHS會(huì)保留原有的值,然后加上./Moments/Pods作為新值。
在配置 Build Setting 時(shí),還可以引用其他已定義的 Build Setting。
例如下面的例子中,FRAMEWORK_SEARCH_PATHS使用了另外一個(gè) Build SettingPROJECT_DIR。
FRAMEWORK_SEARCH_PATHS = $(inherited) $(PROJECT_DIR)
為了重用,我們可以通過#include引入其他 xcconfig 文件。
#include "path/to/OtherFile.xcconfig"
Moments App xcconfig 配置文件
下面我們一起來看看 Moments App 項(xiàng)目是怎樣管理 xcconfig 配置文件吧。

我們把所有 xcconfig 文件分成三大類:Shared、 Project 和 Targets。
其中 Shared 文件夾用于保存分享到整個(gè) App 的 Build Setting,例如 Swift 的版本號(hào)、App 所支持的 iOS 版本號(hào)等各種共享的基礎(chǔ)信息。 下面是 SDKAndDeviceSupport.xcconfig 文件里面所包含的信息:
TARGETED_DEVICE_FAMILY = 1
IPHONEOS_DEPLOYMENT_TARGET = 14.0
TARGETED_DEVICE_FAMILY表示支持的設(shè)備,1表示 iPhone。而IPHONEOS_DEPLOYMENT_TARGET表示支持 iOS 的最低版本,我們的 Moments App 所支持的最低版本是 iOS 14.0。
Project 文件夾用于保存 Xcode Project 級(jí)別的 Build Setting,其中 BaseProject.xcconfig 會(huì)引入 Shared 文件夾下所有的 xcconfig 配置文件,如下所示:
#include "CompilerAndLanguage.xcconfig"
#include "SDKAndDeviceSupport.xcconfig"
#include "BaseConfigurations.xcconfig"
然后我們會(huì)根據(jù)三個(gè)不同的環(huán)境分別建了三個(gè)xcconfig 配置文件,如下:
- DebugProject.xcconfig 文件
#include "BaseProject.xcconfig"
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) DEBUG
- InternalProject.xcconfig 文件
#include "BaseProject.xcconfig"
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) INTERNAL
- AppStoreProject.xcconfig 文件
#include "BaseProject.xcconfig"
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) PRODUCTION
它們的共同點(diǎn)是都引入了用于共享的 BaseProject.xcconfig 文件,然后分別定義了 Swift 編譯條件配置SWIFT_ACTIVE_COMPILATION_CONDITIONS。其中$(inherited)表示繼承原有的配置,$(inherited)后面的DEBUG或者INTERNAL表示在原有配置的基礎(chǔ)上后面添加了一個(gè)新條件。有了這些編譯條件,我們就可以在代碼中這樣使用:
#if DEBUG
print("Debug Environment")
#endif
該段代碼只在開發(fā)環(huán)境執(zhí)行,因?yàn)橹挥虚_發(fā)環(huán)境的SWIFT_ACTIVE_COMPILATION_CONDITIONS才有DEBUG的定義。這樣做能有效分離各個(gè)環(huán)境,保證同一份代碼構(gòu)建出對(duì)應(yīng)不同環(huán)境的 App。
Targets 文件夾用于保存 Xcode Target 級(jí)別的 Build Setting,也是由一個(gè) BaseTarget.xcconfig 文件來共享所有 Target 都需要使用的信息。
PRODUCT_BUNDLE_NAME = Moments
這里的PRODUCT_BUNDLE_NAME是 App 的名字。
下面是三個(gè)不同環(huán)境的 Target xcconfig 文件。
- DebugTarget.xcconfig
#include "../Pods/Target Support Files/Pods-Moments/Pods-Moments.debug.xcconfig"
#include "BaseTarget.xcconfig"
PRODUCT_BUNDLE_NAME = $(inherited) α
PRODUCT_BUNDLE_IDENTIFIER = com.ibanimatable.moments.development
- InternalTarget.xcconfig
#include "../Pods/Target Support Files/Pods-Moments/Pods-Moments.internal.xcconfig"
#include "BaseTarget.xcconfig"
PRODUCT_BUNDLE_NAME = $(inherited) β
PRODUCT_BUNDLE_IDENTIFIER = com.ibanimatable.moments.internal
- AppStoreTarget.xcconfig
#include "../Pods/Target Support Files/Pods-Moments/Pods-Moments.appstore.xcconfig"
#include "BaseTarget.xcconfig"
PRODUCT_BUNDLE_NAME = $(inherited)
PRODUCT_BUNDLE_IDENTIFIER = com.ibanimatable.moments
它們都需要引入 CocoaPods 所生成的 xcconfig 和共享的 BaseTarget.xcconfig 文件,然后根據(jù)需要改寫 App 的名字。例如DebugTarget 覆蓋了PRODUCT_BUNDLE_NAME的值為Moments α*, 其所構(gòu)建的 App 叫Moments α*。
一般在 App Store 上所有 App 的標(biāo)識(shí)符都必須是唯一的。如果你的項(xiàng)目通過 Configuration 和 Scheme 來生成免費(fèi)版和收費(fèi)版的 App,那么,你必須在兩個(gè) Configuration 中分別為PRODUCT_BUNDLE_IDENTIFIER配置對(duì)應(yīng)的標(biāo)識(shí)符,例如com.lagou.free和com.lagou.paid。
在 Moments App 中,我們也為各個(gè)環(huán)境下的 App 使用了不同的標(biāo)識(shí)符,以方便我們通過 CI 自動(dòng)構(gòu)建,并分發(fā)到內(nèi)部測(cè)試組或者 App Store。同時(shí),這也能為各個(gè)環(huán)境版本的 App 分離用戶行為數(shù)據(jù),方便統(tǒng)計(jì)分析。
一旦有了這些 xcconfig 配置文件,今后我們就可以在 Xcode 的 Project Info 頁面里的 Configurations 上引用它們。

下面是所有 Configurations 所引用的 xcconfig 文件。

在配置好所有 xcconfig 文件的引用以后,可以在 Build Settings 頁面查看某個(gè) Build Setting 的生效值。我們以IPHONEOS_DEPLOYMENT_TARGET為例,一起看看。

當(dāng)我們選擇All和Levels時(shí),可以看到所有配置信息分成了不同的列。這些列分別代表前面的 Build Settng 優(yōu)先級(jí):
平臺(tái)默認(rèn)值
Project 級(jí)別的 xcconfig 文件
Xcode 項(xiàng)目文件中的 Project 級(jí)別配置
Target 級(jí)別的 xcconfig 文件
Xcode 項(xiàng)目文件中的 Target 級(jí)別配置
Build Settng 的優(yōu)先級(jí)是從左到右排序的。越是左邊優(yōu)先級(jí)就越高。例如,我們?cè)?Project 級(jí)別的 xcconfig 文件里面定義了IPHONEOS_DEPLOYMENT_TARGET的值為14.0,那么Project 級(jí)別的 xcconfig 文件(Project Config File) 一列上就會(huì)顯示iOS 14.0,它覆蓋了系統(tǒng)的默認(rèn)值 (iOS Default)iOS 14.2。這就是因?yàn)?Project 級(jí)別的 xcconfig 文件,它的優(yōu)先級(jí)高于系統(tǒng)默認(rèn)值,因此最后生效的值是iOS 14.0。
總結(jié)
介紹了如何通過 Build Configuration、 Xcode Scheme 以及 xcconfig 配置文件來統(tǒng)一項(xiàng)目的構(gòu)建配置,從而搭建出多個(gè)不同環(huán)境,為后期構(gòu)建出對(duì)應(yīng)環(huán)境的 App 做準(zhǔn)備。

在使用 xcconfig 配置時(shí),還是需要注意以下兩點(diǎn):
首先,我們必須把所有 Build Setting 都配置在 xcconfig 文件里面,并通過 Git 進(jìn)行統(tǒng)一管理;
其次,我們千萬不要在 Xcode 的 Build Settings 頁面修改任何 Setting,否則該配置會(huì)覆蓋 xcconfig 文件里面的配置。如果你不小心修改了,可以通過點(diǎn)擊刪除鍵把頁面是的配置刪掉。
思考題:
我們 Moments App 項(xiàng)目的主 App 為什么只使用了一個(gè) Target 嗎?如果使用多個(gè) Target,例如 Debug Target,Internal Target 和 Release Target 會(huì)有什么問題?