PbxprojHelper can help you quickly configure Xcode project files without having to do manual operations. The project is open source and developed by Swift. Please see the instructions for detailed introduction. In addition to Mac App, it also provides the command line tool PBxProj, which integrates the core functions of pbxprojHelper, and is also simple and practical.

Since the README_ZH instructions are very detailed, the focus here is on product solutions and technical implementations.

Product solutions

Why did you build this tool?

When developing a corporate project, the check out code needs to be locally modified to the project file. For example, change certificates and Bundle identifiers, delete some targets that do not compile well, and modify Build Settings. There are many scenarios where these configurations can be manually modified repeatedly:

  1. The first time you check out the new branch, you need to use your own configuration.
  2. Before adding or deleting code files, you can revert Project. Pbxproj and then commit it. In this case, the local project file needs to be reconfigured.
  3. No code files are added or deleted but there is conflict in the project. Pbxproj file. You need to revert and then reconfigure the project file.
  4. Some automated processes, such as CI, require specific compilation options and certificates to package each execution.

The most common scenario I encounter is scenario 1 and scenario 2, because you can’t compile with your company’s certificate configuration, some functionality related to your Apple developer account causes some target compilations, but there are also debug mode compilations that need to be set. So every time you need to manually modify the Xcode project configuration, it is very troublesome.

Demand!

It’s fair to say that the tool was developed entirely to address my personal pain points, with little thought of making it a powerful, universal tool. It’s a niche thing, but it’s satisfying the needs of a handful of Apple developers. I divide requirements into the following points:

  1. Record configuration changes made to the project file by the programmer and save them as JSON files
  2. The next time you use it, import the JSON file, apply the configuration modification to the current project file, and roll back the configuration.
  3. Supports preview and filtering of project file content
  4. Quickly switch between recently used projects
  5. Provides command line tools

It can be said that 1 and 2 are just necessary and common functions. 3 and 4 are ancillary features, and 5 are additional requirements. The most common requirement points I encounter are 2 and 4.

The technical implementation

For an introduction to Xcode project files, please refer to my earlier Let’s Talk About Project.pbxproj. This article is a sequel.

I’ve wrapped all the low-level methods related to the project files in the PropertyListHandler class, which are interface independent. There are also some utility classes and methods written to the Utils file.

Contrastive engineering documents

It is difficult to keep track of changes to the project files, so you can only compare the differences between the two project files. This is not a simple diff like comparing files, but a record of which configuration items were “added, deleted, or changed”.

The contents of a project file can be likened to a multi-fork tree, where the root node is the dictionary and the rest of the middle nodes are the keys of the dictionary. The elements of the array must be strings (leaf nodes), and the key-value pairs of the dictionary may expand into subtrees or leaf nodes. Once you have the data from the two project files, you need to compare each level of the two trees. The difference algorithm for comparing two trees is not difficult to implement. The core idea is: when comparing intermediate nodes, if the content is the same, the next layer is compared recursively, otherwise, it is marked as “add” or “delete”.

The most convenient way to compare intermediate nodes in the same hierarchy is to use Set directly. I saved the difference between the two trees in the dictionary difference, and realized a tail recursion in the embedded method. The intermediate nodes need to be recorded as paths during recursion because the generated paths need to be saved to the comparison results.

/// /// -parameter project1: // -parameter project2: // -parameter project2: Class func compare(project project1: [String: Any], withOtherProject project2: [String: Any]) -> Any { var difference = ["insert": [String: Any](), "remove": [String: Any](), "modify": [String: Any]()] /// Compare the two data objects recursively, save the difference of the deepest node into difference. /// /// -parameters: /// -data1: first data object, array or dictionary /// -data2: second data object, array or dictionary /// -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 }Copy the code

This seemingly long piece of code is actually super simple, just comparing the dictionary and array cases, stupid shit. Note that as a leaf node, there are only two cases of “add” and “delete”.

Each recursion takes parentKeyPath and the current node’s value key with. Spliced together. This means that the final path is in the format A.B.C.

As you can see, the generated comparison result is a dictionary with three key-value pairs, the keys are INSERT, remove, and modify, and the values are dictionaries.

Applying JSON Configuration

Because the generated JSON configuration file has a certain format, format rules must be followed to apply the configuration to the project file. The most critical is that the path generated in the previous step is in A.B.C format, and the path content is unknown and requires real-time processing. So I wrote a method to parse the path and provide closures to modify the value of the path when I get to the bottom of the path. Assuming keyPath is the contents of the path string, the method is implemented as follows:

Let keys = keyPath.components(separatedBy: ".") /// If command is "modify" keyPath to "A.B.C", the purpose is to make value[A][B][C] = data. It is necessary to go deep along the path and use closures to modify the data of leaf nodes. In the recursive process, the modified results are returned step by step to complete the data update on the whole path. /// /// -parameter index: path depth /// -parameter value: the value of the current path /// -parameter complete: the operation to be performed at the end of the path /// /// - 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 }Copy the code

This method takes the node of the current index path as the key and looks up the nextValue of that key in the dictionary. It then recurses through the next layer until it reaches the end of the keypath. The complete closure passed in is executed and the result is returned as the value of the method. In this way, after modifying the value of the node at the end of the path, you can synchronize it layer by layer and finally complete the modification of the entire path.

It would be nice to assign value[A][B][C] directly, but this is not possible. Because the contents of the path are unknown, such code cannot be written to death, but only dynamically recurses into it and returns the changes to the upper layer when called.

As mentioned earlier, the JSON file format contains three commands: insert, remove, and modify. Each of these commands needs to be handled separately when implementing the complete method, and each command needs to distinguish between dictionary and array data types. The logic dealt with here is basically the inverse logic of the previous step and is easy to understand.

/// this method can be used to apply json configuration data to project file data /// /// -parameter json: /// -parameter projectData: project file data, project. Pbxproj content class func apply(json: [String: [String:]) Any]], onProjectData projectData: [String: Any]) -> [String: Any] {var appliedData = projectData // For (command, arguments) in JSON {// For (keyPath, arguments) data) in arguments { let keys = keyPath.components(separatedBy: ".") func walkIn(atIndex index: Int, withCurrentValue value: Any, complete: (Any) -> Any?) -> Any? { ... } // If let result = walkIn(atIndex: 0, withCurrentValue: appliedData, complete: {(value) -> Any? In // value is the data of the leaf node of the path. Switch command {// When adding data, the data and value types must be the same, either array or dictionary, otherwise do not change 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 // The data to be removed is an array containing data or keys. Otherwise, no change is made. 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 case "modify": return data default: return value } }) as? [String: Any] { appliedData = result } } } return appliedData }Copy the code

Because the JSON file is very hierarchical, you need to traverse the outermost dictionary first. There are three key-value pairs corresponding to insert, remove, and modify commands and arguments respectively. The arguments to each command consist of key-value pairs in the format (path: dictionary or array). The value type of the path must be the same as that in the JSON file.

Modify the contents of the project file data while traversing, using Swift’s nested method and trailing closure syntax. While this general syntax is nice to use, it also makes the code less readable.

Operation engineering document

Can use PropertyListSerialization to (DE) serialization project. Pbxproj file content:

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)
Copy the code

Writing project file data to disk may seem like a no-brainer, but there are coding issues and backup mechanisms involved.

Coding problem

Chinese characters will be garbled after the project file data is directly written to the file. What needs to be done is to extract the scalar value of Unicode from the Chinese content and convert it to numeric Character Reference (NCR).” The sequence of &# DDDD characters is the escape sequence of HTML, XML and other SGML-like languages. They are not “encoded”.

The following method can replace the Chinese content of the generated project file with 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: "&#%04d;" , 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)") } }Copy the code

Backup mechanism

Since it is necessary to generate a new project file to replace the original project file, the backup mechanism must be sufficient. The current backup mechanism only backs up the files that were changed last time. This is because backing up historical files takes up a lot of disks. For example, large project files may take up 10M or more space, and frequent operations will produce a lot of backups.

You need to obtain the URL of the backup file when generating the backup file or using the backup file for restoration. The real protagonist, project.pbxproj, is contained within the project file (folder), so it is up to you to decide what to do with it based on the file suffix. The following private method changes the URL reference parameter passed to the real project. Pbxproj file URL and returns the URL of the backup file:

/// /// -parameter Url: specifies the url of the file. If the file is a project file, it will be changed to project. Pbxproj file /// /// - returns: Backup file path 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 }Copy the code

One method only does one thing, this method is badly designed, do two things, don’t do it like me. I did this to save code. (Excuse, escape)

Preview and filter project file content

The main interface is as follows. When displaying all data, you can enter keywords in the Filter text box to Filter data:

MainWindow

preview

I don’t want to talk too much about how to present data using NSOutlineView, but anyone can look up documentation and write UI.

I define a data structure Item to represent the data for each row of nodes in NSOutlineView:

typealias Item = (key: String, value: Any, parent: Any?)
Copy the code

With parent pointing to the parent, you can recursively search to the path (keypath) of an Item object:

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)"
}
Copy the code

This allows you to automatically write the path of the current data to the Pasteboard when you double-click a row of data.

filter

The key point of keyword filtering is to determine whether an Item and its child nodes contain this keyword. In this case, DFS recursive keyword search is still needed.

Finding keywords requires ignoring case:

func checkAny(value: Any, containsString string: String) -> Bool {
    return ((value is String) && (value as! String).lowercased().contains(string.lowercased()))
}
Copy the code

Recursive lookups are easy to implement, just to distinguish between arrays and dictionaries:

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
}
Copy the code

Finally, the method is nested and assembled as follows:

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 {/// omitted} return DFS (propertyList: Any) tupleItem.value) } return false }Copy the code

Quickly switch project files

The UI implementation of a drop-down list is pretty simple, just an NSView with a couple of NS Extfields in it. To maintain the list of common engineering files, you need to add them to the list after each user selects a project file to implement the LRU algorithm.

The requirements for LRU cache are the same as those for TFSHelper in this article. I just moved the code over here. I put it on Github Gist, maybe science online: LRUCache.

The click action of the drop-down list is captured by NSClickGestureRecognizer.

Construct command line tools

In order to minimize the complexity of using the command line, I have encapsulated only the core functions, and there are only a few commands:

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 formatCopy the code

The input parameters need to be processed by themselves, resulting in a lot of conditional judgment, fortunately mine is not complicated. Note that the first parameter list is the program name (path).

The way to get parameter contents in Swift files in Terminal has changed several times, starting with C_ARGC and C_ARGV, in Swift 1.2 only process.arguments, in Swift 3 it changed again, Commandline.arguments must be used.

Once THE parameters are in hand, all I do is call the wrapped utility methods in PropertyListHandler.

Not everyone will execute a Swift file as a script, so you need to create a target and package it as an executable that doesn’t rely on Swift commands.

The results of

I use pbxprojHelper a lot because there are so many people working on the same project and so many branches of SVN. After generating my JSON configuration file for the first time, there is almost no need to regenerate it, and projects of different branches can share the same JSON configuration. Every time I revert a project. Pbxproj file for some reason, I can use it to configure my project files with one click, saving at least 90% of the time! Even if you switch to another branch of the project, you can quickly switch through the commonly used list without having to select the file again.

It is also in the use of a number of bugs and experience problems, and then continue to improve and improve.

feeling

It took me about a week’s spare time to complete the basic functions of this project from the beginning of the conception of requirements.

After some preliminary research and preparation work, I think it is feasible and compromise some functional requirements. For example, to record the changes in the project file, you need to compare the old and new files. This requires the user to save the project file first, then modify it, and finally use pbxprojHelper to compare the differences between the two project files. The last part of generating the project file was compromised because it was impossible to write the data to the file in OpenStep format without calling the Xcode private framework Touch project file. Therefore, users need to open the project with Xcode, modify the project at will, and then restore it. It is in this time and again to the function of the compromise, making the seemingly infeasible scheme becomes feasible.

The requirements of the project were not clear at the beginning, but were established bit by bit through trial and error. For example, you don’t think about saving your changes to a JSON file at first, then you think about letting users manually create and write JSON configuration files, and then you think about automatically generating JSON configuration files. I also adjusted the content rules of JSON configuration for a while, and finally finalized it after several modifications. Therefore, the product manager can properly understand when changing the requirements next time, after all, product molding does need a process.

Feeling the stone across the river feeling although uneasy, but I enjoy the pleasure of conquering the city. At that time, I encountered many problems in the process of development. At that time, I didn’t know if I could solve them, so I might give up halfway. However, it was finally realized by formulating strategies and implementing algorithms. Although the algorithms were quite simple and not difficult, it was quite rewarding to provide some targeted solutions.

As a tailor-made gaming tool, using Swift for development seems to be the norm these days. Is also taking the opportunity to play tickets wen (Chong) learn (xue) Swift, after all, usually has been using OC to write MRC code, for fear of falling behind this era.