Understanding the Xcode build system - Part 3 - How can we help the Xcode build system
After comprehending the complex and demanding tasks involved in the build process, we can now shift our focus to the importance of build performance and how to enhance it by adhering to best practices. This will assist the build system in working more efficiently, providing us, as developers, with additional time to implement our cool features. The article is divided into 3 parts:
- Understanding the general build system and some terminologies (part 1)
- Understanding the Xcode build system and Xcode project structure (part 2)
- How can we help the Xcode build system (part 3)
The build performance
In previous parts, we may have observed that build performance can be influenced by various factors. Some of these factors include the dependency order, the complexity of expressions, the build script and the Objective-C/Swift interfaces.
The dependency order
We are all aware that processing tasks in parallel, as opposed to one by one or sequentially, leads to increased speed. This is particularly crucial with the support of modern multicore devices; it would be inefficient in terms of both resources and time not to harness their potential. Fortunately, we have a couple of methods to achieve this.
The first method involves enabling the Parallelize Build option in the Scheme editing menu. This allows Xcode to simultaneously build targets that are independent of each other, ultimately enhancing the overall build speed. However, it's important to note that this option is not activated by default, and its use does not guarantee that there will be no build issues, especially in projects with intricate dependencies. It is advisable to test this option to identify potential errors and carefully weigh the trade-off between improved build time and build stability.
The next method involves examining how we have configured dependencies within Xcode. As discussed in the previous article, dependencies are configured through the Build phase in the target setting by Target Dependencies (explicit) and Link Binary with Libraries. With a deeper understanding of build dependencies and the current Project/Workspace build configurations, we can enhance build performance by implementing parallelization. The talk from WWDC18 provides us some strategies to "break down" some dependencies that hider parallelization during the build process.
We can begin by addressing what they refer to as the "Do Everything" dependency. In this example it's the Test target, which tests numerous components including Game, Shaders, Utilities requiring it to be built last.
We then "break down" the Test target into small targets to test each of the above components separately. By doing so, each test target only needs to have dependency on its corresponding component, enable it to be built at an earlier stage.
The next type of dependency is what they refer to as "Nosy Neighbors". This type of dependency to a target needs to exist but it only requires a small portion of that target. By just simply "move" that thing out of the target and create their own target, we can get more parallelism and even significant impact on the overall build timeline.
And the last one is what they call the "Forgotten Ones". These are dependencies that we simply forgot to remove or clean up, a common situation that occurs frequently. For instance, there might be a scenario where we initially import another target to access some utility methods. Over time, we may replace those methods with others or decide that we no longer need them. However, we might forget to remove the import statement for that target, leading to a redundant dependency.
Build Scripts
Build scripts provide a convenient way to customize the build process, such as generating assets or moving files. It is recommended to execute them only when necessary, as running these scripts incurs a cost. It is crucial to note that the Xcode Build System relies on entities known as input files and output files to determine whether it needs to execute a script. Apple highly recommends declaring these input files and output files, and you can find detailed guidelines in their documentation. Some of the rules the build system employs to trigger a script rerun include: no declared input files or changes in input files, missing output files, and so on.
The complexity of expressions
Type inference is one of the convenient features not only in Swift but also in modern programming languages nowadays. It can help us avoid boilerplate code and have them easy to read as it will be shorter. However, it's not always useful. Take a look at this example:
It seems short enough but not easy to reason about. It takes more efforts from both the compiler and other developers to figure out which type this bigNumber is, as the pow method inside this expression doesn't tell anything about that. We can improve this very easily by just simply put the Double type after the variable name.
We might also face with this situation while coding: we edit our code with some cool ideas and press the build button. Everything seems okay until we found that the compiler takes longer time to compile our code. Finally, it gives up with the error said that it was unable to type-check our expression in reasonable time. This is not the compiler's fault. This is our responsibility to look back our code and there is definitely some unnecessary complexity here.
The provided code should not be accepted in the reviewing process, even if the compiler were somehow strengthened enough to handle it. A clearer and more "Swifty" alternative exists, as demonstrated below:
When coding in Swift, it's crucial to consider the use of AnyObject. While it may be tempting due to its convenience, where we simply utilize methods and properties from the object and let the compiler do the rest, this approach comes with a cost. As Swift processes all source files from the current target and others, it must search for methods and properties to call in this manner. It does so because if there is no matched implementation, it will result in an error. To address this, we can use Protocol instead of AnyObject. This allows the compiler to precisely determine what to call, and it provides opportunities to verify whether all implementing types correctly implement those methods and properties.
The Swift's file dependency and the Objective-C/Swift interfaces
We might already be aware that Caching is a straightforward way to improve performance when handling repetitive tasks, in this case the rebuild process. By reducing the amount of work needed for each rebuild task, we can save time for the compiler. In Swift, the compiler determines which files should be rebuilt based on file dependencies within a module. In simple terms, if a file is changed, files depending on it are more likely to be rebuilt as well, unless the changes are in function bodies and do not affect the file's interface. In the case of Cross-Target Dependencies, the entire dependent target will be rebuilt instead.
However, these considerations apply specifically to Swift dependencies and Swift targets. In real-world scenarios, we often have mixed Swift and Objective-C interfaces, as illustrated by this diagram:
With information from the previous article, from left to right, we can see that Swift files depend on Objective-C headers (.h files) through the Bridging header, and the Objective-C implementations (.m) depend on those Swift files by using the Generated header. If we want to reduce the amount of work on rebuild, we need to shrink the content of Bridging header files and Generated header files as much as possible. For example, by using the private keyword in Swift code, we can prevent it from being unnecessarily exposed in the Generated header. Or we can use categories to break up the interface in the Bridging header, avoiding some internal types in Objective-C code from being exposed to Swift unexpectedly.
And one more thing
Xcode also has a useful feature called Build With Timing Summary. We might notice the build performance decreasing just by a "feeling" or when there is a significant time consumed during building. This build option will display more details about each build task and the duration it takes for its build. Simply triggering the build with timing summary will give us more information about what's going on there.
What can the compiler tell us when something goes wrong?
By default, if Xcode encounters an error during a build, it reports the error and stops immediately. We can force it to keep going with the build by the “Continue building after errors” setting in the General tab of Xcode preferences. It then continues to build and reports errors for the rest of our project's files. However, as an error can be the cause of other errors, and we might only be interested in the error causing the issue (root cause), displaying all of the errors can be unnecessary, making it sometimes hard to debug and fix the issue.
Whenever an error occurs, Xcode tries its best to tell us as much information as it can. We first need to switch to Xcode’s issue navigator or use Cmd-5. Most of the time, we will see the problem here just by reading the error log and navigating to the source code. If it's not, for example, the link issue from the linker, when it fails to look for the symbols needed when linking. In that case, we will need to get more details about the issue by using the report navigator (Cmd-9) and see what symbols are missing (sometimes we might need to click the log icon to see where those symbols are referenced from).
And that's it. In this part, we've walked through some best practices to help the build system to be more efficient by understanding what is does behind the scenes. Those are simple fixes, but they can significantly improve build performance, allowing us to spend less time building and more time coding.