Swift Package Manager with a Mixed Swift and Objective-C Project (part 2/2)
You’ll notice from the title that is is part 2/2. If you not read part 1 then I advise going back. It is not essential but it does provide some tips that will help you work with SPM in a more general way.
Update: Dec 18, 2021
The original version of this article describes the steps I took to get a project with mixed ObjC and Swift source files building using Swift Package Manager. In retrospect this was a bad idea. Presumably you are already maintaining an Xcode project file (likely you have a couple other ways of building as well).
Adding the ability to build from a Package file means that you now have an additional source of truth to maintain and this is a bad thing.
For this reason alone I recommend building XCFrameworks and distributing them via binary targets in SPM.
In the original version of this article I described four pain points in building a mixed-source library in SPM:
- Separating targets for Swift and ObjC source code. ie. How to structure the package manifest (Package.swift) to provide a Swift target that depends on an ObjC target.
- Resolving public interfaces so that APIs are available from ObjC projects.
- Passing through an ObjC interface so that it’s available to Swift users
- Issues with diverging module import syntax. ex:
# import <foo/bar.h>
vs#import “bar.h"
Building and distributing XCFrameworks eliminates all of these issues and more. The benefits are:
- Fewer sources of truth to maintain. This is huge. Every way of building your library is a vector for issues. Fewer vector, fewer issues.
- No need to have separate targets for Swift and ObjC source code. Resolving the underlying modules is handled automatically by Xcode and is based on the project file you already maintain.
- No need to restructure your project or use hacky workarounds like symlinks in order to expose your ObjC interfaces to your Swift code.
- No need to re-export nested ObjC modules to end users.
- No need to modify existing imports statements to support diverting syntax.
- No need to have additional CI jobs related to building from a Package file.
- No need to duplicate QA efforts aside from making sure that the XCFramework is available. If the framework can be obtained from the Package, you can safely assume that it will behave the same as if it were vended from other sources.
The solution I would recommend to myself if I were starting all over would be to:
- Generate project files on-demand using Xcodegen or a similar tool.
- Use the project to create XCFrameworks.
- Distribute those frameworks via a repository that contains ONLY a Package.swift file.
I’ve left the original version of this article below in case anyone is not in a position to move to XCFrameworks yet but I highly recommend not reading further.
BACKGROUND
Distributing a library that has a mixture of ObjC and Swift sources provides some interesting challenges. Depending on your audience you may not have the option of distributing a single library. Whether the concern is size, performance, or cost of updating, there are some developers who are not yet ready to adopt Swift into their projects.
This article will assume that you are trying to use SPM to distribute two modules. One module that is exclusively ObjC and one module that is a mixture of ObjC and Swift.
While there are some challenges to implement a solution for SPM, the problem is not unique to SPM.
Imagine you are trying to ship ModuleX. Ideally a consuming project can import ModuleX without worrying if it is importing the Swift or ObjC version of the module. If you’ve distributed a library using CocoaPods you may have already run into this issue and solved it by using sub-specs: CocoaLumberjack. Or maybe you have two separate pod specs where one depends on the the other. Example: Google’s FirebaseFirestoreSwift.podspec depending on FirebaseFirestore.podspec.
The second example mirrors the approach we will need to take to support mixed source files in SPM. ie. providing a Swift module that depends on an ObjC module.
Obviously one article cannot encompass everything about SPM. My goal is to guide you through four major pain points.
- How to structure your package manifest (Package.swift) to provide a Swift target that depends on an ObjC target
- How to resolve your public interface so that your APIs are available from ObjC projects
- How to pass through your ObjC interface so that it’s available to Swift users
- How to avoid the trap of fully qualified module import syntax. ie. issues surrounding
# import <foo/bar.h>
vs#import “bar.h"
PACKAGE MANIFEST
To start, you’ll want to create a Package.swift
file at the root of your project.
Note: To avoid confusion, from here on out I’ll assume that you have your Swift files in a single directory named SwiftSources located at the same level as your ObjC source files.
.target(
name: "ModuleX-ObjC", // 1
dependencies: [], // 2
path: "ModuleX/", // 3
exclude: ["SwiftSources"], // 4
cSettings: [
.headerSearchPath("Internal"), // 5
]
),
.target(
name: "ModuleX", // 6
dependencies: ["ModuleX-ObjC"], // 7
path: "SwiftSources" // 8
),
- First you need to create a target and name it in a way that distinguishes it as your ObjC dependency.
- If you had any dependencies for your ObjC library you would put them here
- This assumes that all of your ObjC files are in a directory named ‘ModuleX/’. If all of your source files are in a directory named ‘src’ or ‘sources’ then this argument can be omitted as SPM will automatically find them.
- This relies on the important assumption that all of your Swift source files are colocated in their own directory(s). In this example we assume that they are in a directory named “SwiftSources”. Currently there is not a way to exclude files based on extension. 😢
- Header search paths are for files within ModuleX to resolve themselves so that we don’t get
cannot find <module-name/module-name.h>
type errors. This is not the same as exposing them as a publicly available interface (we will cover how to do that shortly). This example assumes you have your internal headers in a directory named “Internal”. - This is the name that will be used to import your Swift module into projects.
- This is the dependency on your ObjC module.
- Again, this assumes that your Swift source files are colocated in a directory named “SwiftSources”.
PUBLIC INTERFACES
Exposing public interfaces requires a bit of work. SPM will automatically expose headers that are located in a directory named “include”. There are a couple of ways to address this.
Move all of your header files into the include directory.
Umm. no. This is a huge pain and breaks the convention of colocating header and implementation files.
edit: After poo-pooing this idea I wish I had given it more consideration. It would have been worth the up-front cost to have this structured ‘correctly’. The only downside to having the ‘.h’ files separated from the ‘.m’ files is that Xcode has trouble find them for some keyboard shortcuts. A small price to pay to avoid having to mess with symlinks which cause their own issues in Xcode. Whether or not you can do this will depend on the size and complexity of your codebase.
Have a separate header file that you only use for your Swift package.
Again, no. This is difficult to maintain and you would need to provide relative paths to all of your other headers. It would wind up looking something like this:
// ModuleX.h#import "../MyFeature/SomeClass.h"
Use Symlinks
This is the least disruptive pattern I’ve found. It requires that we provide symlinks to headers we want to make public.
To do this, create and cd into a directory named “include” and run ln -s {projectRoot}**/*.h
or some-such equivalent. The result should be a directory of symlinks to your public headers. You probably want to manually check that you aren’t exposing internal headers.
edit: A few months in I’ve found that this technique works but it required writing a script to recursively check that public header files were exposed via symlink correctly. I have not had issues with this approach since that was added to the CI pipeline.
EXPOSING YOUR OBJC TO SWIFT USERS
Bad news. You will need to do something hacky to expose your ObjC interface to Swift users. In your Swift directory, add a new Swift file. I chose to use the name ‘Exports’. The actual file name is unimportant. Add the following code to your new file:
@_exported import ModuleX-Objc
This will allow users to import your library and have access to its dependencies. For more info see: https://forums.swift.org/t/16648/2
HEADER TRAPS
Some build systems require you to have fully qualified import paths. For example, you might have import statements in the form:
#import <MyModule/SomeHeader.h>
When you go to build your Swift package you’ll get the error:'MyModule/SomeHeader.h' file not found
If this is an internal header or an implementation file then you can use the Xcode-provided SWIFT_PACKAGE
macro.
ex:
#if SWIFT_PACKAGE
#import "SomeHeader.h"
#else
#import <MyModule/SomeHeader.h>
#endif
IMPORTANT
This will only work with non-public files since the macro definition is not exported to consuming projects.
More likely you will get around this by gating on some other macro that you have available based on your build system.
ex:
#if SOME_BUILD_SYSTEM
#import <MyModule/SomeHeader.h>
#else
#import "SomeHeader.h"
#endif
CONCLUSION
Hopefully this helps get you a little closer to distributing your legacy project with SPM. Thanks for reading!