Download
♪ 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!
♪