pbxprojHelper 可以幫你快速配置 Xcode 工程文件,省去麻煩的人工手動操作。項目開源,使用 Swift 開發,詳細介紹請見使用說明。除了 Mac App 外還提供了命令行工具 pbxproj,它集成了 pbxprojHelper 的核心功能,同樣簡易實用。
因為 README_ZH 中對使用方法已經講得很詳細了,這裡重點說的是產品方案和技術實現。
產品方案
為什麼造這個工具?
在開發公司的項目時,check out 代碼到本地後需要修改工程文件。比如更改證書和 Bundle Identifier、刪除一些編譯不過的 Target,修改 Build Settings 等配置。重復手動修改這些配置的場景很多:
第一次 check out 新的分支,需要使用自己的配置。
增刪代碼文件前會先 revert project.pbxproj 文件,修改完成後再 commit。此時本地工程文件需要重新配置。
沒有增刪代碼文件但 project.pbxproj 文件有沖突(conflict),需要先 revert 後重新配置工程文件。
一些自動化流程(比如 CI)每次執行都需要特定的編譯選項和證書來編包。
而我本人最常遇到的場景是 1 和 2,因為不能用公司的證書配置來編譯,一些跟蘋果開發者賬號相關的功能導致一些 target 編譯不過,還有些 debug 模式下需要設置的編譯選項。所以每次都需要手動修改 Xcode 工程配置,很是麻煩。
需求!
可以說開發這個工具一開始完全就是為了解決我個人的痛點的,基本沒考慮做成功能強大的通用工具。雖然做的事情比較小眾,但也能滿足一批蘋果開發者的需求了。我把需求分為以下幾點:
將程序員對工程文件做出的配置修改記錄下來,並保存成 JSON 文件
下次使用時直接導入 JSON 文件,將配置修改應用到當前的工程文件上
支持回滾操作
支持工程文件內容的預覽、過濾
快速切換最近使用的工程
提供命令行工具
可以說 1 和 2 是剛需,也是常用功能。3、4 和 5 是輔助功能,6 是附加需求。我平時最常碰到的需求點就是 2 和 5 了。
技術實現
關於 Xcode 工程文件的介紹,請參考我之前寫的 Let’s Talk About project.pbxproj。本篇文章可以算作是它的續集。
我把工程文件相關的底層方法都封裝在 PropertyListHandler 類中,它們跟界面無關。還有一些工具類和方法寫到 Utils 文件中。
對比工程文件
想要記錄工程文件的修改是很難的,所以只能是比較下兩個工程文件的差異。這裡不是對比文件那種簡單的 diff 操作,而是要記錄具體針對哪個配置項做了『增刪改』。
工程文件的內容可以比作一顆多叉樹,的根節點是字典,其余中間節點都是字典的鍵。數組的元素肯定是字符串(葉子節點),字典的鍵值對則可能繼續拓展出子樹,也可能是葉子節點。在拿到兩個工程文件的數據後,就需要對兩棵樹的每個層級進行對比。對比兩顆樹的差異算法不難實現,核心思想是:在對比中間節點時,如果內容相同那就遞歸比較下一層,否則就記為『增』或『刪』。
而比較同一層級中間節點的差異,直接用 Set 是最方便的了。我將兩棵樹的差異保存在字典 difference 中,在內嵌方法中又實現了個尾遞歸。遞歸過程中需要記錄中間節點作為路徑,因為生成的路徑需要保存到對比結果中。
/// 將 project 與 other project 做比較 /// /// - parameter project1: 作為比較的 project /// - parameter project2: 被參照的 project /// /// - returns: project1 相對於 project2 的變化 class func compare(project project1: [String: Any], withOtherProject project2: [String: Any]) -> Any { var difference = ["insert": [String: Any](), "remove": [String: Any](), "modify": [String: Any]()] /// 將兩個數據對象作遞歸比較,將最深層次節點的差異保存到 difference 中。 /// /// - Parameters: /// - data1: 第一個數據對象,數組或字典 /// - data2: 第二個數據對象,數組或字典 /// - parentKeyPath: 父路徑 func compare(data data1: Any?, withOtherData data2: Any?, parentKeyPath: String) { if let dictionary1 = data1 as? [String: Any], let dictionary2 = data2 as? [String: Any] { let set1 = Set(dictionary1.keys) let set2 = Set(dictionary2.keys) for key in set1.subtracting(set2) { if let value = dictionary1[key], difference["insert"]?[parentKeyPath] == nil { difference["insert"]?[parentKeyPath] = [key: value] } else if let value = dictionary1[key], var insertDictionary = difference["insert"]?[parentKeyPath] as? [String: Any] { insertDictionary[key] = value difference["insert"]?[parentKeyPath] = insertDictionary } } for key in set2.subtracting(set1) { if difference["remove"]?[parentKeyPath] == nil { difference["remove"]?[parentKeyPath] = [key] } else if var removeArray = difference["remove"]?[parentKeyPath] as? [Any] { removeArray.append(key) difference["remove"]?[parentKeyPath] = removeArray } } for key in set1.intersection(set2) { let keyPath = parentKeyPath == "" ? key : "\(parentKeyPath).\(key)" // values are both String, leaf node if let str1 = dictionary1[key] as? String, let str2 = dictionary2[key] as? String { if str1 != str2 { difference["modify"]?[keyPath] = str1 } } else { // continue compare subtrees compare(data: dictionary1[key], withOtherData: dictionary2[key], parentKeyPath: keyPath) } } } if let array1 = data1 as? [String], let array2 = data2 as? [String] { let set1 = Set(array1) let set2 = Set(array2) for element in set1.subtracting(set2) { if difference["insert"]?[parentKeyPath] == nil { difference["insert"]?[parentKeyPath] = [element] } else if var insertArray = difference["insert"]?[parentKeyPath] as? [Any] { insertArray.append(element) difference["insert"]?[parentKeyPath] = insertArray } } for element in set2.subtracting(set1) { if difference["remove"]?[parentKeyPath] == nil { difference["remove"]?[parentKeyPath] = [element] } else if var removeArray = difference["remove"]?[parentKeyPath] as? [Any] { removeArray.append(element) difference["remove"]?[parentKeyPath] = removeArray } } } } compare(data: project1, withOtherData: project2, parentKeyPath: "") return difference }
這段看似很長的代碼其實邏輯超級簡單,就是分別針對字典和數組兩種情況進行比較而已,弱智的一逼。需要注意的是數組內容作為葉子節點,只存在『增』和『刪』兩種情況。
每次遞歸都將 parentKeyPath 與當前節點的值 key 用 . 拼接在一起。也就是說最後得到的路徑是 A.B.C 這種格式。
可以看出生成的對比結果是個字典,包含三個鍵值對,鍵分別是 insert、remove 和 modify,值為字典。
應用 JSON 配置
因為生成的 JSON 配置文件具有一定格式,所以必須按照格式規則來應用這些配置到工程文件中。最關鍵的是在上一步中生成的路徑格式為 A.B.C,且路徑內容是未知的,需要實時處理。所以我寫了個方法來解析路徑,步入到路徑最底層後提供閉包來對路徑的值進行修改。假設 keyPath 為路徑字符串內容,方法實現如下:
let keys = keyPath.components(separatedBy: ".") /// 假如 command 為 "modify" keyPath 為 "A.B.C",目的是讓 value[A][B][C] = data。需要沿著路徑深入,使用閉包修改葉子節點的數據,遞歸過程中逐級向上返回修改後的結果,完成整個路徑上數據的更新。 /// /// - parameter index: 路徑深度 /// - parameter value: 當前路徑對應的值 /// - parameter complete: 路徑終點所要做的操作 /// /// - returns: 當前路徑層級修改後的值 func walkIn(atIndex index: Int, withCurrentValue value: Any, complete: (Any) -> Any?) -> Any? { if index < keys.count { let key = keys[index] if let dicValue = value as? [String: Any], let nextValue = dicValue[key] { var resultValue = dicValue resultValue[key] = walkIn(atIndex: index + 1, withCurrentValue: nextValue, complete: complete) return resultValue } else { print("Wrong KeyPath") } } else { return complete(value) } return value }
這個方法會將當前層級(index)路徑的節點作為鍵(key),並查找字典中該鍵對應的值(nextValue)。然後遞歸遍歷下一層,直至步入到路徑(keypath)最末端。此時會執行傳入的 complete 閉包,並將結果作為該方法的返回值。這樣在對路徑最末端的節點值做出修改後就可以逐層同步上去,最後完成對整條路徑的修改。
如果能直接給 value[A][B][C] 賦值就好了,但是這是不可能的。因為路徑內容是未知的,這樣的代碼不可能寫死的,只能動態地遞歸進去,並在調用後將修改內容返回上層。
之前提到過 JSON 文件格式中包含三種命令:insert、remove 和 modify。所以在實現 complete 方法的時候需要針對這三種命令分別處理,每種命令還要區分字典和數組兩種數據類型。這裡處理的邏輯基本是上一步的逆邏輯,很容易理解。
/// 這個方法可厲(dan)害(teng)咯,把 json 配置數據應用到工程文件數據上 /// /// - parameter json: 配置文件數據,用於對工程文件的增刪改操作 /// - parameter projectData: 工程文件數據,project.pbxproj 的內容 class func apply(json: [String: [String: Any]], onProjectData projectData: [String: Any]) -> [String: Any] { var appliedData = projectData // 遍歷 JSON 中的三個命令 for (command, arguments) in json { // 遍歷每個命令中的路徑 for (keyPath, data) in arguments { let keys = keyPath.components(separatedBy: ".") func walkIn(atIndex index: Int, withCurrentValue value: Any, complete: (Any) -> Any?) -> Any? { ... 此處省略 } // 調用 `walkIn` 方法, if let result = walkIn(atIndex: 0, withCurrentValue: appliedData, complete: { (value) -> Any? in // value 為路徑葉子節點的數據。根據 command 的不同,處理的規則也不一樣: switch command { // 添加數據時 data 和 value 類型要統一,要麼都是數組,要麼都是字典,否則不做變更 case "insert": if var dictionary = value as? [String: Any], let dicData = data as? [String: Any] { for (dataKey, dataValue) in dicData { dictionary[dataKey] = dataValue } return dictionary } if var array = value as? [Any], let arrayData = data as? [Any] { array.append(contentsOf: arrayData) return array } return value // 移除數據時被移除的 data 為包含數據或鍵的數組,否則不做變更 case "remove": if var dictionary = value as? [String: Any], let arrayData = data as? [Any] { for removeData in arrayData { if let removeKey = removeData as? String { dictionary[removeKey] = nil } } return dictionary } if var array = value as? [String], let arrayData = data as? [Any] { for removeData in arrayData { if let removeIndex = removeData as? Int { if (0 ..< array.count).contains(removeIndex) { array.remove(at: removeIndex) } } if let removeElement = removeData as? String, let removeIndex = array.index(of: removeElement) { array.remove(at: removeIndex) } } return array } return value // 直接用 data 替換 value case "modify": return data default: return value } }) as? [String: Any] { appliedData = result } } } return appliedData }
因為 JSON 文件內容層級較深,所以需要先遍歷最外面的字典。一共有三個鍵值對,分別對應 insert、remove 和 modify 三個命令(command)及其參數(arguments)。每種命令的參數都是由『(路徑:字典或數組)』這樣格式的鍵值對組成。路徑對應的值的類型需要與 JSON 文件中一樣。
在遍歷的同時修改工程文件數據的內容,這裡使用了 Swift 的嵌套方法和尾隨閉包語法。這總語法雖然用著爽,但是對代碼的可讀性也有所降低。
操作工程文件
可以用 PropertyListSerialization 來(反)序列化 project.pbxproj 文件的內容:
let fileData = try Data(contentsOf: url) let plist = try PropertyListSerialization.propertyList(from: fileData, options: .mutableContainersAndLeaves, format: nil) let data = try PropertyListSerialization.data(fromPropertyList: list, format: .xml, options: 0) try data.write(to: url, options: .atomic)
將工程文件數據寫入磁盤表面上看起來是一件再簡單不過的事情,但其實這裡面包含編碼問題和備份機制。
編碼問題
直接把工程文件數據寫入文件後,中文會有亂碼。需要做的是把中文內容的 Unicode 的標量值提取出並轉成 numeric character reference(NCR)。”dddd” 的一串字符是 HTML、XML 等 SGML 類語言的轉義序列(escape sequence),它們不是『編碼』。
下面的方法可以將生成的工程文件中文內容替換成 NCR:
func handleEncode(fileURL: URL) { func encodeString(_ str: String) -> String { var result = "" for scalar in str.unicodeScalars { if scalar.value > 0x4e00 && scalar.value < 0x9fff { result += String(format: "d;", scalar.value) } else { result += scalar.description } } return result } do { var txt = try String(contentsOf: fileURL, encoding: .utf8) txt = encodeString(txt) try txt.write(to: fileURL, atomically: true, encoding: .utf8) } catch let error { print("translate chinese characters to mathematical symbols error: \(error.localizedDescription)") } }
備份機制
既然是要生成新的工程文件來替換原來的工程文件,備份機制肯定不能少。當前的備份機制僅僅備份上次修改的文件,這是考慮到備份歷史文件會占用大量磁盤的問題。比如大一些的工程文件可能占用10M 甚至更多的空間,頻繁操作產生的備份會很多。
在生成備份文件和使用備份文件還原時,都需要獲取到當前工程文件對應的備份文件 URL。真正的主角 project.pbxproj 被包含在工程文件(夾)內部,所以要根據文件後綴名來決定如何處理。下面的私有方法會將傳入的 URL 引用參數修改為真正的 project.pbxproj 文件 URL,並返回備份文件的 URL:
/// 返回指定文件對應的備份文件路徑 /// /// - parameter url: 文件 URL,如果是工程文件,會被修改為 project.pbxproj 文件 /// /// - returns: 備份文件路徑 fileprivate class func backupURLOf(projectURL url: inout URL) -> URL { var backupURL = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Documents") if url.pathExtension == "xcodeproj" { backupURL.appendPathComponent(url.lastPathComponent) backupURL.appendPathExtension("project.pbxproj") url.appendPathComponent("project.pbxproj") } else { let count = url.pathComponents.count if count > 1 { backupURL.appendPathComponent(url.pathComponents[count-2]) backupURL.appendPathExtension(url.pathComponents[count-1]) } } backupURL.appendPathExtension("backup") return backupURL }
一個方法只干一件事,這個方法設計的很不好,干了兩件事,別學我這麼做。我這麼做是為了省代碼量。(狡辯,逃)
預覽和過濾工程文件內容
主界面如下,在展示所有數據的同時,可以在 Filter 文本框中輸入關鍵詞來過濾數據:
MainWindow
預覽
關於如何使用 NSOutlineView 展示數據,不想多說,查文檔寫 UI 誰都會。
我定義了一個數據結構 Item 來表示 NSOutlineView 中每行節點的數據:
typealias Item = (key: String, value: Any, parent: Any?)
因為有了 parent 指向父節點,可以遞歸搜尋到某個 Item 對象所處的路徑(keypath):
func keyPath(forItem item: Any?) -> String { let key: String let parent: Any? if let tupleItem = item as? Item { key = tupleItem.key parent = tupleItem.parent } else { key = "" parent = nil } if let parentItem = parent { return "\(keyPath(forItem: parentItem)).\(key)" } return "\(key)" }
這樣就可以實現雙擊某行數據時,自動將當前數據的路徑寫入 Pasteboard 中。
過濾
過濾關鍵字的重點就是判斷一個 Item 及其子節點中是否包含此關鍵字,此時需要依然是需要 DFS 遞歸查找關鍵字。
查找關鍵字需要忽略大小寫:
func checkAny(value: Any, containsString string: String) -> Bool { return ((value is String) && (value as! String).lowercased().contains(string.lowercased())) }
遞歸查找很容易實現,只不過區分下數組和字典罷了:
func dfs(propertyList list: Any) -> Bool { if let dictionary = list as? [String: Any] { for (key, value) in dictionary { if checkAny(value: key, containsString: word) || checkAny(value: value, containsString: word) { return true } else if dfs(propertyList: value) { return true } } } if let array = list as? [Any] { for value in array { if checkAny(value: value, containsString: word) { return true } else if dfs(propertyList: value) { return true } } } return false }
最後經過方法嵌套拼裝成如下:
func isItem(_ item: Any, containsKeyWord word: String) -> Bool { if let tupleItem = item as? Item { if checkAny(value: tupleItem.key, containsString: word) || checkAny(value: tupleItem.value, containsString: word) { return true } func dfs(propertyList list: Any) -> Bool { /// 此處省略 } return dfs(propertyList: tupleItem.value) } return false }
快速切換工程文件
下拉列表的 UI 實現很簡單,就是一個 NSView 裡面放幾個 NSTextField。維護常用工程文件列表需要在每次用戶選擇工程文件後將其加入列表,實現 LRU 算法。
這裡對 LRU 緩存的需求跟 自制一款 Mac 平台 URL 輔助工具 這篇文章中的 TFSHelper 的是一樣的。我直接把代碼搬過來了。我將其放到 Github Gist 上了,可能需要科學上網:LRUCache。
下拉列表的點擊操作交由 NSClickGestureRecognizer 捕獲處理。
構造命令行工具
為了盡可能精簡命令行的使用復雜度,我只把最核心的功能封裝進去,一共只有這幾個命令:
Usage: pbxproj [command_option] file Command options are (-convert is the default): -compare modified_file -o path compare modified property list file with property list file and generate a json result at the given path -apply json_file apply a json file on property list file -revert revert property list file to latest backup -convert rewrite property list files in xml format
輸入的這些參數都需要自己去處理,由此會產生大量條件判斷,好在我的不算復雜。需要注意的是參數列表第一個是程序名稱(路徑)。
在 terminal 中執行 Swift 文件時獲取參數內容的方式變了好多次,一開始是 C_ARGC 和 C_ARGV,到了 Swift 1.2 只能使用 Process.arguments,到了 Swift 3 又變了,必須用 CommandLine.arguments。
拿到了參數後,我所做的事情只是調用 PropertyListHandler 中已經封裝好的工具方法罷了。
不是所有的人都會把 Swift 文件當做腳本去執行,所以還需要創建個 target,打包成可執行程序,這樣就不依賴 Swift 命令了。
結果
我使用 pbxprojHelper 的頻率十分高,因為開發同一項目的人很多,svn 的分支也多。第一次生成好我的 JSON 配置文件後以後就幾乎不用再生成了,不同分支的工程都可以共用這一個 JSON 配置。每次因為種種原因 revert 了 project.pbxproj 文件後,我都可以用它一鍵配置好我的工程文件,節省了至少 90% 的時間!即便換了個其他分支的工程,也可以在常用列表中迅速切換,不用再次 select 文件。
也正是在一次次的使用中發現了若干 bug 和體驗問題,然後不斷改進和完善。
感悟
這個項目從開始構思需求到完成基本功能花費了我大概一周的業余時間。
前期調研做了些准備工作後覺得還是有可行性的,並對部分功能需求做了妥協。比如記錄工程文件修改內容需要對比新舊兩個文件,這就要求使用者先把工程文件保存一份,然後再修改,最後使用 pbxprojHelper 對比兩個工程文件的差異。最後生成工程文件的環節也做了妥協,因為無法將數據以 OpenStep 格式寫入文件,除非調用 Xcode 私有框架 touch 下工程文件。所以需要用戶用 Xcode 打開工程後隨意修改下工程再復原即可。就是在這樣一次次對功能的妥協下,使得方案的看似不可行變得可行。
這個項目的需求一開始並不明確,是在摸索中一點點確立的。比如一開始根本沒有想到過要把修改保存成 JSON 文件,之後想的是讓用戶手動創建和編寫 JSON 配置文件,再之後想的是自動生成 JSON 配置文件。在制定 JSON 配置的內容規則上也是調整了一陣子,幾經修改最後定稿。所以說,產品經理下次改需求的時候可以適當理解下,畢竟產品成型的確需要個過程。
摸著石頭過河的感覺雖然忐忑,但是我更享受攻城略地般的快感。當時在開發的過程中遇到了一個個難題,當時連自己也不知道能否搞定,很有可能半途而廢。但最終還是通過制定策略和實現算法實現了,雖然算法都挺簡單並不難,但是能有針對性地給出一些解決方案還是比較有成就感的。
作為一款給自己量身打造的玩票工具,使用 Swift 來開發看起來是當今標配,理所當然。也是趁著玩票的機會溫(chong)習(xue)下 Swift,畢竟平時一直用 OC 寫 MRC 代碼,生怕落後於這個時代。