How Xcode Recognizes Module Imports
In iOS development, a module is a self-contained unit of code offering a specific set of functionalities. Modules help break down complex apps into smaller components. Developers can incorporate a module into different parts of an app using the import
keyword. Behind the scenes, the build system performs several tasks to recognize and integrate these imports seamlessly.
A module can be distributed in various formats such as collections of source files, or a pre-compiled binaries or bundles. For a framework bundle itself, the structure may differ depending on whether it’s built purely in Swift, Objective-C, or a mix of both. Xcode’s approach to identifying and processing import declarations varies accordingly.
This blog post delves into how Xcode manages module imports.
Build Tasks of a Target
When looking at Xcode build log, you might see some steps such as SwiftEmitModule, SwiftCompile, CompileC, Libtool, Ld, etc. They exhibits the compiler’s operations under the hood. The diagram below illustrates the compilation process of a target.
In short, the tasks primarily involve:
- Emitting module interfaces
- Compiling source files
- Merging compiled code
While compiling source files takes time, emitting module interfaces is relatively lightweight, as they are implementation-free. Essentially, importing a module only needs its interface. Their implementation, meaning the compiled object files, are later glued together at the very end, in the linking steps.
Thus, importing a module roughly means informing the build system how and where to locate its interface.
Importing a Module
Modules can be imported in several ways:
Via a .swiftmodule
binary
When compiling a Swift target, you should see a .swiftmodule
bundle among the build products. This bundle contains .swiftmodule
binaries for certain slices, such as arm64-apple-ios-simulator
.
The .swiftmodule
file, generated by the Swift compiler, represents interfaces of what symbols can be accessed from the outside. Though cannot be previewed in a trivial way, it’s equivalent to a .swiftinterface
when “building libraries for distribution”.
For a module to be recognized, its .swiftmodule
bundle must be accessible via search paths. This can be achieved in two ways:
- Utilizing the Import Paths (
SWIFT_INCLUDE_PATHS
).
IfA.swiftmodule
is available underpath/to/modules/A.swiftmodule
, addingpath/to/modules
to the import paths allows the compiler to recognize the import of moduleA
. - Internally, values in the import paths are propagated to the Swift compiler (and Clang) through the
-I
argument. Consequently, adding-I path/to/modules
to the Other Swift Flags (OTHER_SWIFT_FLAGS
) achieves the same result.
Via a .modulemap
file
C/C++/Objective-C modules can also be imported into Swift code, albeit differently. Imports in C-family languages are resolved with headers. And module maps define the linkage between modules and their headers.
module Logger {
header "Logger.h"
}
To enable module detection, use the -fmodule-map-file
argument (passed to Clang) to instruct the compiler to process the module map.
OTHER_SWIFT_FLAGS = $(inherited) -Xcc -fmodule-map-file=path/to/module.modulemap
Note: The -Xcc
argument above is used to forward the -fmodule-map-file
argument from the Swift compiler to Clang.
In case of Objective-C code that depends on this module, use the same argument with the OTHER_CFLAGS
setting:
OTHER_CFLAGS = $(inherited) -fmodule-map-file=path/to/module.modulemap
Via a .pcm
(pre-compiled module) binary
When seeing a .modulemap
or a .swiftinterface
, the compiler spawns a thread to compile this textual module into the pre-compiled module (.pcm
) in binary format. The binary is then cached in the module cache directory and reused during compilation.
The .pcm
of modules with a .modulemap
can be found in the module cache directory (default: ~/Library/Developer/Xcode/DerivedData/ModuleCache.noindex
).
In the figure, DebugKit is an Objective-C module with a pre-defined .modulemap
. We can even see the .pcm
for system frameworks such as CoreGraphics, Foundation, etc. because those system frameworks are shipped with .swiftinterface
.
Like a .modulemap
, we can import a module with a given .pcm
by passing the -fmodule-file
argument to Clang:
OTHER_SWIFT_FLAGS = $(inherited) -Xcc -fmodule-file=path/to/module.pcm
OTHER_CFLAGS = $(inherited) -fmodule-file=path/to/module.pcm
Via a .framework
bundle
A framework contains pre-compiled code and related resources. Typically, a framework includes a .swiftmodule
or/and a .modulemap
inside its bundle.
A.framework / Modules / A.swiftmodule / arm64-apple-macos.swiftmodule
|
| module.modulemap
When the compiler detects such a framework, it automatically picks up the corresponding module files. This simplifies integrating 3rd-party code without worrying about handling Swift and Objective-C modules differently. All we need to do is to make sure the framework can be found under the framework search paths.
Given its seamless integration, frameworks are often the go-to choice for distributing modules. However, it is not always the case. Consider SPM packages, for instance. When building a package’s library that is not defined as dynamic, the build process generates both an .o
object together with a .swiftmodule
bundle. Of course when adding such a product to the “Link Binary With Libraries” section, Xcode handles the heavy lifting for you. In case you opt out of approach, you may want to configure build settings discussed in prior sections.
Example
Check out the demo at: trinhngocthuyen/ios-demos/module-import