31.%d Xcode — Flag、Scheme、Target

春麗 S.T.E.M.
18 min readDec 3, 2021

--

目錄

⦿ 重點回顧
⦿ Maintain
⦿ 程式碼
⦿ Flag

重點回顧

前篇文章中,我們講到 TableView 的 Architecture 從簡單的 MVC 變成 MVVM,而設計原理是將 ViewController 的權責拆分,在 MVVM 中,以 Model 為基底,View Model 為讓 View 呈現的資料,透過 View Model 實例,去 Call Fetch Function 來取回需要的資料,而在 TableView 中的 View 就是 TableViewCell,Cell 就要透過 setupCell with viewModel 去呈現,所以 ViewController 最後只需要 Call Function,其他該做的事 View Model 跟 View 都幫它做了。

另外,由於建構 Cell 的資料有許多筆,當我們透過 View Model 取回資料後,還需要的是傳入 IndexPath.row,也就是 Cell 在建構時透過這兩筆資料,組成一個個 Cell 欄位的 Model。

而透過 Data Binding,我們可以讓 View Model 取回不同的資料,由於這個資料是存在專案裡的不同 json 檔案,我們希望在讀取/切換這個不同的檔案之間,讓 TableView 可以 reloadData,就會用到之前說過的 Boxing,即是 Observer Pattern 的實作。

這個切換,我們可以看成是內部,在使用者需要不同的資料下,透過按鈕做不同的資料取回

但今天我們得來講講如果既是同一個專案,又是不同一個專案底下應該怎麼去進行,先看到如下:

這其實就是一個專案底下,兩個不同 Target(Scheme)的 build,如下:

在 TheSixth 與 TheSixth Dev 中,其實是一個專案從兩個不同的需求發展出來的 Scheme,其中一個取回的資料是星座名及日期尾,另一個是出席 WWDC 的開發者名及他們的年齡。

在第一個 Scheme,這頁 Table 的底下有一個 Button,方便去取回不同的資料,而另一個 Scheme 沒有這個需求,Button 是 isEnabled = false 的,並且讓它 isHidden = true,由於 TableView 是建構在 ViewController 底下的,所以我們看看程式碼:

override func viewDidLoad() {
super.viewDidLoad()

#if Origin
listViewModel.fetchData(useAlternate: false)
listViewModel.cellItems.bind { _ in
self.listTableView.reloadData()
}
#elseif Dev
switchDataBtn.isEnabled = false
switchDataBtn.isHidden = true
listViewModel.fetchData()
#endif

}

其中 #if#elseif#endif 這種使用方式叫做 flag,可以看到兩個不同的 Scheme(Origin、Dev)下,分別採用不同的建構方式,第一個是用 listViewModel 有參數的 function 取回資料,當資料改變時更新 TableView,而資料改變是透過 Button 做切換;第二個則是將這個 Button 隱藏,接著取回資料。

要如何做到呢?我們一步步往下看。

繼續閱讀|回目錄

Maintain

為什麼需要不同的建置環境呢?其實初衷都是方便與方便 Maintain,如果你想在已經建置好的 APP 下,發展出一個不同版本,這個不同版本不只是更換語系那麼簡單而已,比方台灣版跟印度版,印度版希望拿掉某些可能會觸怒他們信仰的遊戲,這就可以透過不同的 Scheme 來達成不同的 build,而 Scheme 可以搭配 flag 來做到細粒度(granularity)的操作。

如果你只是要換圖,那我們倒不需要這麼麻煩,就好比 localized 的 string 檔案,你可以為不同地區設置不同的語言,但不同地區在顯示時,有的地區使用的語言對於單數多數不同的變體,我們本來是用原來的去對照後來的,如下:

/* Class = "UILabel"; text = "Name*"; ObjectID = "6WN-Ax-pFF"; */
"6WN-Ax-pFF.text" = "姓名*";

這個是 Storyboard 中有一個 UILabel,它原先的顯示名稱為 Name*,當你創建一個 UIElement,在 Storyboard 中就有一個 ObjectID,當你用 Source Code 去檢視它時,也可以在 XML 形式的呈現裡找到這個 ObjectID。

關於用 Source Code 檢視,可以參考另篇文章。

而 Text 在單數多數的變體則可以使用 Dictionary 的方式去處理,而不是在程式碼中去變換。

但今天更麻煩了,我想要先包一個測試的版本給 QA 的話,不應該包一個還沒完全建成的頁面也在裡面的 APP,比方我們可以增加一個 Target 叫做 Dev 來包給他們測試,這個 Dev 可以是不透過網路溝通取回資料,而是採用放在程式碼裡面的假資料⋯⋯。

打開你的專案,最上方左邊的 TheSixth 就叫做 Target(Scheme),〉iPhone 11 指的是你要建置成什麼形式,比方說模擬器是 iPhone 11 這個機型,如下:

如果是要包版了,就會選擇如下:

意思是 arm64 架構下的 iOS Device 都能用的 archive。

接著,我們對著 Target 按下 option + 左鍵,會看到如下:

這是原來 APP 的 Scheme,我們從左邊找到 Run 這個欄位,從 Info 的地方可以選擇建置 TheSixth 時是釋出版本,還是只用來 Debug 等等。

不過,我們現在需要創出另個 Target,對 Target 按下左鍵 Duplicate,接著更改名稱,如下:

完成後,因為還沒 build 過,所以左邊 Products 是這樣的:

TheSixth Debug 呈現紅色的。

此時也幫你配置了 info.plist,通常我們會希望不同的 info.plist 對應到不同的 Target(Scheme),如下:

而其實我們可以這樣看,TARGETS 底下的 Target 都是不同 Scheme,因為當你 Manage Schemes 時,如下:

現階段會為你每個 Target 自動產生 Scheme。

現在,你有兩個 Target(Scheme) 了,我們希望第一個是專用來做 Release 版本,如下:

而第二個是專用來做 Debug 版本的,如下:

好的,這即是說你的不同 Target 可以用來做不同的事,比方釋出版本我就 build 第一個,要測試的版本我就 build 第二個。

又比方說,我同一個 APP 要包成兩個不同的版本,所以 Scheme 就都會是 Release 的,並在程式碼中做其他相關處理。

如果不只是版本的問題,譬如你要大改版,給不同地區有不一樣的 APP 體驗時,比方說七龍珠的卡牌手遊,它的內裡做的更動,例如日本已經在 Version 2.0.1 了,台灣推出的時間整整晚了八個月,Version 才在 1.1.7,一個已經在打破壞神,一個還在打賽魯,但我們總不會分成兩個專案來寫吧?

就好比動畫的劇場版不會特別出來宣告說,我們的劇場版已經跟原來的 IP 是完全不同的東西一樣,因為這是在 IP 底下延伸的版本,為因應不同需求

所以把 Target 視為衍生商品,Scheme 則是在不同商品下,再細分出來不同的建置環境,比方說 Release、Debug 後,你還想要有 SIT(System Integration Testing)、UAT(User Acceptance Testing)的不同測試版本。

好了!有基本概念後,我們再來看看程式裡會怎麼做。

繼續閱讀|回目錄

程式碼

接著,回到前段說的如果資料來源不同要如何建置,先看到如下:

這個頁面是延續了早先那篇 MVVM 與 Data Binding。

在不同版本中,TableView 要顯示成另外一筆資料,它是 WWDC 的出席人員名單跟年齡。

因為是 MVVM,所以資料的取回就需在 ViewModel 裡動手腳,最簡單的方式是給不同的 Target 用相同名稱,不同內涵的 ViewModel,看到如下:

這邊由於資料來源都是專案中的 json file,所以就是透過 Decoder 去 decode Data 為這個 json file 的 Name 轉為的 url 後,再裝到 cellItems。

所以,我們也會有兩種不同的 Model 如下:

import Foundation

// for Origin
struct ZodiacData: Decodable {
let name: String
let fromDate: String
let tillDate: String
}

// for Dev
struct AttendeeData: Decodable {
let age: Int
let badgeNumber: Int
let isFirstTimeAttending: Bool
let name: String
let nationality: String
}

因為 Model 的數量少,所以這邊其實並不需要兩個檔案,我們只有 ViewModel 是分兩個。

並且在 VC 中不需要太大的變動,所以也沒拆成兩個,看到如下:

override func viewDidLoad() {
super.viewDidLoad()

#if Origin
listViewModel.fetchData(useAlternate: false)
listViewModel.cellItems.bind { _ in
self.listTableView.reloadData()
}
#elseif Dev
switchDataBtn.isEnabled = false
switchDataBtn.isHidden = true
listViewModel.fetchData()
#endif
}

extension ViewController: UITableViewDataSource {

func tableView(_ tableView: UITableView,
numberOfRowsInSection section: Int) -> Int {
listViewModel.numberOfItems()
}

func tableView(_ tableView: UITableView,
cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: "\(ListTableViewCell.self)", for: indexPath) as? ListTableViewCell else {
return UITableViewCell()
}
cell.setupCell(with: listViewModel, at: indexPath.row)
return cell
}
}

只是加入 flag,由 ViewModel 取回不同的資料,再依照資料是否需與 View 綁定選擇加入與否。

那麼,在 Table 建構時其實就沒有差異了。

而我們只需要注意何時要加入不同的 ViewModel,讓它留在不同的 Target Membership 中,當然,兩個 Target 都會使用到的檔案就都勾選,如下:

發現問題了嗎?

你會說不對呀,明明用來建構 Cell 的 Items 是不同的,怎麼會用兩個相同的 setupCell,我們看到如下:

    func setupCell(with viewModel: ListCellViewModel, at index: Int) {
let item = viewModel.itemAt(index)
#if Origin
leadingLabel.text = item.name
trailingLabel.text = item.tillDate
#elseif Dev
leadingLabel.text = item.name
trailingLabel.text = "\(item.age)"
#endif
}

因為 setupCell 裡也用了 flag,但為了方便 maintain,在使用 flag 要非常地小心,一不小心你的程式碼就會亂灑,並且難以尋蹤。

不過為了不使用 flag,我們也可以寫兩個 setupCell,使用不同的 Model 來建構,如下:

    // for OriginCell
func setupCell(with item: ZodiacData) {
leadingLabel.text = item.name
trailingLabel.text = item.tillDate
}

// for DevCell
func setupCell(with item: AttendeeData) {
leadingLabel.text = item.name
trailingLabel.text = "\(item.age)"
}

太棒了!這樣就不需使用 flag 了,因為 ViewModel 取回的資料 ViewController 並不需要知道是哪種型別(ZodiacData / WwdcData),而由於參數不同,所以其實就是不同的 function。

在這邊需要非常小心的是 Swift 中調用 function 是以 vtable dispatch,所以編譯時就會糾錯,當你想要用轉型來做判斷的時候,比方回到 setupCell with viewModel 時,要用轉型判斷兩個不同的建構時:

func setupCell(with viewModel: ListCellViewModel, at index: Int) {
let item = viewModel.itemAt(index)

if let item = item as? ZodiacData {
leadingLabel.text = item.name
trailingLabel.text = item.tillDate
} else if let item = item as? AttendeeData {
leadingLabel.text = item.name
trailingLabel.text = "\(item.age)"
}
}

這裡必須要注意,這表示你切換 Target 時,用的是不同 ViewModel,也就是取回來的資料也跟著不同,但是在一種 Target 下取得的只有一種型別的 Data,所以沒辦法用轉型方式去判斷我要用什麼 Data 去建構 Cell,因為在 Compile 時就已經確定 function 的調用,所以這種轉型處理在 Compile 就不會過了(只會滿足其中一種)。

關於 vtable dispatch 可以參考下面。

接著,我們要來看看 flag 怎麼使用。

繼續閱讀|回目錄

Flag

雖然專案中,在前面的地方,你看到了 #if #endif 的用法,但實際上並不是可以直接這樣用,第一步,你必須為 Target 設置一個可用來辨識的名稱,比方在前面,我們就可知道 Origin 跟 Dev 分別是兩個 Target 的辨識名稱,我們要怎麼去設置這個名稱呢?

回到專案最初的地方,切換到 TARGETS 的 Build Settings 頁籤,我們選擇 AllCombined,這樣一來所有的設定都集中在裡面了,先看到如下:

從右方搜尋欄鍵入 flag,利用關鍵字去找到 Other Swift Flags,假設我們兩個 Target 都只會用 Debug 這個 Scheme,看到右邊的 -DOrigin-DDev,這就是我們用來辨識不同的 Target 的名稱了!

回到 AppDelegate,我們可以在這裡做點簡單的測試:

分開 build 後,結果如下:

Flag,旗標,通常指的是 Bool 或一種指示器,所以當你看到 #if Dev,就是在說某個 Target 的某個 Scheme 成立下(即 Dev 成立),就進到該設定,這在 Xcode 中可說是 Compile 的指示器。

而 Flag 也可用在 Runtime,比方 MAC 的 CLI 指令 ls -l 後方的 -l 也是一種旗標,意思是以長格式列出文件,在 Arduino 中,Flag 則用於硬體 State 的變化。

這次就分享到這,感謝您的閱讀。

繼續閱讀|回目錄

Reference:

附上 GitHub:

--

--

春麗 S.T.E.M.
春麗 S.T.E.M.

Written by 春麗 S.T.E.M.

Do not go gentle into that good night, Old age should burn and rave at close of day; Rage, rage, against the dying of the light.

No responses yet