Working with more complex Xcode projects? You've come to the right place. Discover how you can configure your project to build for multiple Apple platforms, filter content per-platform, create custom build rules and file dependencies, and more. We'll take you through multi-platform framework targets, detail how to optimize your project and scheme configuration, and show you how to make effective use of configuration settings files.
We'll explore configuring schemes for parallel builds and implicit dependencies, script phases, custom build rules, setting up input and output file dependencies, build phase file lists, and deduplicating work via aggregate targets. Lastly, find out more about the build settings editor, how levels work, and configuration settings file syntax.
♪ Bass music playing ♪ ♪ Jake Petroules: Hi, and welcome to "Explore advanced project configuration in Xcode". I'm Jake, and together with my colleague Prachi, I'll discuss strategies and techniques for making the most of your Xcode project's build configuration. We're going to cover three major topic areas. First, Prachi will discuss multiplatform projects and Xcode 13's new support for multiplatform framework targets. Next, I'll cover best practices for modeling and configuring your project through schemes, target setup and dependency management, and build phases and rules. And finally, Prachi will take you on a deep dive into build settings, where we'll cover their structure and behavior, the project editor UI, configuration settings files and their syntax, and a whole lot more! Throughout this talk, we'll be using a multiplatform app project called Fruta to show how these techniques apply to a real project. And now I'm going to hand it over to Prachi, who's going to talk about multiplatform frameworks. Prachi Pai Asnodkar: Thanks, Jake. One of the new features in Xcode 13 is support for multiplatform frameworks. Multiplatform frameworks allow us to consolidate multiple frameworks into one, providing us simplified target management, one set of build phases to manage, and one set of build settings to manage. Let's take a look at the Fruta app and update our project to take advantage of this feature. This is the Fruta app. It is a multiplatform app that builds for macOS, iOS, and watchOS. It also has three framework targets -- one for each platform that contains a set of shared code used by the apps. Maintaining three separate frameworks can come with challenges, such as keeping build settings in sync, and ensuring all of your source files are properly added to your compile sources build phases. To tackle these challenges, we'll start by converting one of our frameworks to a multiplatform framework. Here we have the three frameworks -- one for each platform. All of these targets are identical except that one has a file which only builds on macOS. First, let's navigate to the Build Settings tab in the project navigator for the macOS framework target. Next, we will configure the framework to build for all platforms by going to the Supported Platforms build setting and choosing Any Platform. You can also see that Allow Multiplatform Builds has been set to "Yes" automatically. This informs the build system to build this target once for each of its supported platforms, as necessary. Now that this is a multiplatform target, recall that the original macOS framework had one additional file that should only build when building for macOS. In order to configure our framework to do this, we can add a platform filter to specify that this file should only build for macOS. To do this, we will first go to the Build Phases tab. Next, expand the Compile Sources build phase. Finally, configure Ingredient+macOS.swift to build only for macOS by clicking on the Filters item and unchecking everything but macOS. Now that we have our new multiplatform target configured, we can delete the other two variants of our framework, as they are no longer needed. Additionally, because we have only one framework target, we will have to configure all of our apps to link and embed that new target. The macOS app is already configured because we have set up our multiplatform target starting from the macOS one. We can add the new framework to the iOS and watchOS apps by going to the General tab for each of the app targets, and adding the framework to the Frameworks and Libraries build phase. To summarize: we took our macOS framework target and enabled it to build for iOS and watchOS. We customized that framework with a platform filter for our macOS-only source file. And finally, we configured our app targets to link and embed the new single multiplatform framework target. And that's multiplatform targets in Xcode. Now back to Jake, who's going to dive deeper into project configuration. Jake: Thanks, Prachi. I'm going to discuss best practices for modeling and configuring your Xcode project, and show a few things you can do to improve the performance and correctness of your builds. First, let's have a look at build options for the scheme. I'll click the scheme picker, Edit Scheme, then go to the Build section. There's a few simple things I can configure here. For Build Order, we recommend selecting Dependency Order, which will cause targets in your project to build in parallel according to the dependency graph. This can greatly improve multicore build performance and will also get you faster results from continuous integration. In contrast, choosing Manual Order is deprecated and is not recommended. Using this option will slow down your build and can cause cycle errors when the target order listed in the scheme is inconsistent with your project's dependencies. Another important setting in the scheme build options is Find Implicit Dependencies. Checking this option allows Xcode to automatically add dependencies between targets based on the information in your project, such as linker flags in build settings and the names of linked libraries in build phases. This can be especially useful when the related targets are in different projects where you can't normally add an explicit target dependency. If you are using manual dependency order to build targets in a specific order due to the inability to add explicit target dependencies across different projects, enabling Find Implicit Dependencies in conjunction with choosing Dependency Order is often a better solution. Now I'm going to talk about script phases and build rules. I'll select the SmoothieKit target from the project's target list, and then select the Build Phases tab. Here we have a Process Recipes script phase that contains some custom build logic. One of its responsibilities is generating code from a number of recipe files with one output per input, which we process in sequence. Now, you may realize that these computations are completely independent of each other. This presents a performance optimization opportunity that we can take advantage of by running them in parallel. Build rules allow us to do just that. Let's take a look at how we can extract this work into a build rule. I'll go to the Build Rules tab in the project editor for our framework and click the plus button to add a new build rule. Then enter the file pattern "*.recipe", which corresponds to the file extension of the file type I want this rule to process. Next, I'll add dependencies to this rule. I don't need to add any additional inputs to the build rule, because it will automatically get each input file it processes as an input. However, I do need to tell the build system the path of the output file that the rule will produce for each file it processes. I'll click the plus button to add a new output file and enter in $(DERIVED_ FILE_ DIR)/$ (INPUT_ FILE _BASE) .compiledrecipe. It's best practice to write generated files under DERIVED_FILE_DIR since this will point to an appropriate location managed by the build system. You should avoid generating output files under the source root. This can interfere with source control and lead to conflicts when running multiple builds simultaneously. Now we, of course, have to copy our script phase code over to the rule. I'll go back to the script phase, and copy out the code where we processed each of the files. Then I'll go back to the rule and paste that in. Remember that rules run once for each input they process. So I'll remove the for loop, replace $RECIPE with $SCRIPT_INPUT_FILE -- which corresponds to the absolute file path of the current input file being processed -- and replace $DERIVED_FILE_DIR/ $RECIPE.compiledrecipe with $SCRIPT_OUTPUT_FILE_0, which refers to the output file path I've entered in the Output Files section below. Don't forget to quote variables to make sure spaces and other special characters in file paths are handled correctly. Great. Now there's one more thing to configure in the rule. I mentioned that rules run once for each input they process. By default, they also run once for each architecture that the target is compiling for. For example, a rule in a Mac app target might run once for arm64 and once for x86_64 times each of its inputs. So if there are four inputs times two architectures, the rule would be invoked eight times. This is useful when the output of the rule is architecture-dependent, such as object code. However, in this case, my rule produces output which is independent of the underlying CPU architecture, so I'm going to uncheck “Run once per architecture”. Lastly, in order for the build system to propagate the input files into the build rule, I'll need to add all of the .recipe files into the Compile Sources build phase of my framework target. I'll go back to Build Phases, expand Compile Sources, and use the plus button to add the recipe files.
Now let's go back to the script phase. The remaining piece of work this does is merge the contents of multiple text files into a single file we can load at runtime in our app more efficiently. And in order to have better source control experience, I'm keeping scripts external to the project file and calling them from the inline script editor here. So let's follow the reference to package.sh to see the code. A build rule wouldn't be appropriate in this case, since we need to process all the inputs at once to combine them into one. So there's no way to break it up into isolated units which can be run in parallel and therefore it makes sense to keep this work in the script phase. But this brings us to one of the most important takeaways: the script has no input and output dependencies specified. This might cause build tasks to run in the wrong order and slows down the build because Xcode has to be more conservative with respect to running other tasks in parallel, as it doesn't know what files the script phase may be using. So it's important to add input and output dependencies to ensure the work performed by script phases is done in the correct order relative to other tasks in the build. For this particular script, I have a large number of inputs. Instead of entering these in the project file one by one, I can use an xcfilelist to manage this list of inputs via an external file. I'll go ahead and add one to the project now. I'll go to File > New File, and choose Build Phase File List under the Other section.
I'll paste the list of input files that will be processed by this script phase, one per line. If you want, you can even write comments by beginning a line with the pound sign, which is great for adding additional context. Now I'll reference this xcfilelist from the script phase. I'll go back to the script phase and specify the path to the xcfilelist in the Input File Lists. Lastly, I'll specify an output dependency by providing the file path at which the output contents will be written, just like I did for the build rule. There's one more thing to mention. Similar to the build rule, there are some crucial environment variables provided for you by the script phase. Let's navigate back to package.sh to have a closer look. In the source, I reference SCRIPT_INPUT_FILE_LIST_COUNT; which refers to the total number of input file lists passed to our script phase, SCRIPT_INPUT_FILE_LIST_n; which refers to the resolved absolute file path of the input file list at the nth index, and SCRIPT_OUTPUT_FILE_0; which refers to the resolved absolute file path of the first -- and in this case, only -- output file. Here is an overview of some of the key environment variables provided to script phases. The build settings of the target are also made available to the script phase environment. Here is an overview of some environment variables specific to build rules, as well as some less common ones. Like script phases, the build settings of the target are also made available to the build rule environment. OK. Now, if I try to build the project, I'll run into an issue.
Let's go to the build log to take a closer look. Because SmoothieKit is a multiplatform target, it's building twice: once for iOS and once for watchOS, and this means each of these builds are trying to produce the output of the script phase at the same path. This is not allowed because the build system requires that only one task in the entire build may produce the output at a given path. There are a couple different ways I could solve this. One simple solution would be to change the output path of the script phase so that it's unique each time the target is built. In this case, I could consider using a different build setting like DERIVED_FILE_DIR, which is platform-specific, and would make the path sufficiently unique and solve the conflict. However, if the actual work that the script phase is doing would be identical within the context of each target, that would simply cause the same work to be done twice. In that case, it can be a better option to move the script phase to a new aggregate target which the shared framework target depends on. That's what I'm going to do for my project. To get started, I'll click the plus button at the bottom of the target list, select the Other tab, and choose Aggregate target. I'll call it Resources. Then I'll add a new script phase, and copy the name, script source, inputs, and outputs from the framework target.
Finally, I'll delete the original script phase from the framework target and then add a target dependency on the new aggregate target.
This way, the work will only be done once, there will be no output file conflict, and both the iOS and watchOS variants of the framework will build in the correct order relative to that script phase. Build successful. And now, back to Prachi, who's going to tell you all about build settings. Prachi: Thank you, Jake! So what is a build setting? It is a property you can apply to Xcode targets to configure aspects of how they are built. Xcode provides two main mechanisms for configuring build settings. The first is through the build settings editor. The second is through a configuration settings file or an .xcconfig file. Let's start by seeing how the build settings editor can be used to manage the settings within our project. In order to bring up the build settings editor, first you need to select your project in the Project navigator. Next, make sure to select the target you wish to configure. And finally, click the Build Settings tab across the tab bar. From here, you can add new build settings or modify existing ones. You can also find out additional information for the selected build setting by opening the Quick Help inspector. Build settings are defined at multiple levels. You can think of this as a stack of definitions. In fact, these levels can be visualized by clicking on the Levels filter. Each column represents a different level a build setting can be defined in, and they are evaluated from the right to the left. Starting from the lowest level there is the default value, which is defined by the currently selected SDK, the project level configuration settings file, the project level settings from the Xcode project file, target settings defined in a configuration settings file, the target level settings defined in your Xcode project file, and finally, the resolved value of the build setting. Note that if you see a bold setting that denotes that the level has an explicit value for the build setting. The other mechanism Xcode provides for managing build settings are configuration setting files or .xcconfig files. Some of the benefits of an xcconfig file include: better source control management, sharing settings across targets or configurations, advanced composition of build settings, and the ability to include additional xcconfig files based on your development or test environment. Let's take a look at how you can author build settings in an xcconfig file. At its most basic level, a build setting is made up of a name, an assignment operator, and a value. You can narrow the value of a build setting using the conditional syntax. Conditional settings are defined using square brackets. Some of the supported conditions include configuration, architecture, and SDK. As shown with the SDK condition, wildcards can be used for matching purposes. Comments can be added as well using the familiar double-slash syntax. A build setting can be set to the value of another build setting by using the dollar-parens syntax. In the example here, MY_OTHER_BUILD_SETTING has been set to YES. The value of MY_BUILD_SETTING_NAME uses the dollar-parens syntax to evaluate MY_OTHER_BUILD_SETTING. Multiple values can be evaluated here as well, like we see with MORE_SETTINGS. And finally, existing values for a build setting can be used with the $(inherited) value. This allows you to append additional values to a build setting while retaining all of its existing values. This is a convenience form as you could also use the build setting name, APPEND_TO_EXISTING_SETTINGS. Another use of the build setting evaluation syntax is to compose build settings together from a set of other build settings. First, we start with a control setting: IS_BUILD_SETTING_ENABLED. We will use the value of this setting as a suffix for two additional build settings, MY_BUILD_SETTING_NO and MY_BUILD_SETTING_YES. Lastly, we define MY_BUILD_SETTING to have a value that is composed of both MY_BUILD_SETTING and IS_BUILD_SETTING_ENABLED. Because build setting evaluation happens inside-out, the inner-most setting is evaluated and returns NO, which is the value of IS_BUILD_SETTING_ENABLED. Finally, the composed BUILD_SETTING_NO is evaluated to a value of -use_this_one. When evaluating a build setting, there are a set of operators you can use to provide some basic transformations of your value. The three classifications of operators are: string operators, path operators, and replacement operators. The supported string operators are quote, which escapes the characters within the string; lower and upper, which convert cases of characters; and identifiers, which convert strings to valid identifiers in various formats. We provide a set of path operators to get the directory, filename, base name, suffix, and standardized path. For each path operator, there is a replacement counterpart that allows you to replace part of a value. There's also a default operator which provides the replacement value if the build setting is empty, otherwise it uses the existing value of the build setting. The last item to look at is the ability to include xcconfig files within other xcconfig files. There are two mechanisms available to you. The first are required includes, which requires the xcconfig file to exist on disk. A compiler error will be produced if the file cannot be found. The second are optional includes, which allow for including an xconfig file if present on disk. This will not fail if the file does not exist. Note that the path is relative from the location of your Xcode project file. So let's take a look at how you might put all of this information together in a real-world scenario. In this example, we'll be taking a look at how to solve the following problem. On our development machines, the compiler should aggressively warn for expressions that take too long to type check. However, the CI machines are slower, so the time for expression checking should be increased. For our solution, there are three configuration setting files: debug, common, and ci.xcconfig. The debug xcconfig file is used for our debug builds, and passes some additional flags to the Swift compiler via the OTHER_SWIFT_FLAGS build setting. The common xcconfig file optionally includes the ci.xcconfig file. It also defines the OTHER_SWIFT_FLAGS setting to control the type expression warning. It makes use of $(inherited) to ensure that any of the other flag settings are included, such as from the debug.xcconfig file and a build setting evaluation for MAX _EXPRESSION_TIME that has a default value of 200. The ci xcconfig file defines an override value for MAX_EXPRESSION _TIME. Finally, Xcode needs to be told how to apply these xcconfig files to one of the supported configuration levels. This is done through the project editor, which is what we see here. From the Configuration section, you can apply any of the config files from your project at either the project or target levels, for any defined build configuration. Here, you can see that the debug.xcconfig file is being applied at the project level for the debug configuration of Fruta. Also, common.xcconfig file is set for each of the targets within the project. To recap the solution, the default operator was used to define a default value for MAX_EXPRESSION_TIME. The ci.xcconfig file was optionally included because it will only exist on the CI system. And an override of the default value for MAX_EXPRESSION_TIME was used in the ci xcconfig file. This wraps up our practical example. Now let's go back to Jake to review everything that we have covered. Jake: Thanks, Prachi. Let's recap. You learned about multiplatform frameworks and how they provide an easier way to manage build settings and build phases in multiplatform projects. You saw how you can improve your project configuration and build performance by building targets in parallel according to dependency order, how to properly use build rules and build phases, and the importance of specifying dependencies. Finally, you took a deep dive into build settings, how you can use configuration settings files to manage them more easily, and dived into their syntax and all of the constructs it provides. We hope these lessons provide you with a set of useful tools to help you make the most of your development experience. Thank you for watching! ♪
Looking for something specific? Enter a topic above and jump straight to the good stuff.
An error occurred when submitting your query. Please check your Internet connection and try again.