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

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:

  1. 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.
  2. Resolving public interfaces so that APIs are available from ObjC projects.
  3. Passing through an ObjC interface so that it’s available to Swift users
  4. 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:

  1. Fewer sources of truth to maintain. This is huge. Every way of building your library is a vector for issues. Fewer vector, fewer issues.
  2. 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.
  3. No need to restructure your project or use hacky workarounds like symlinks in order to expose your ObjC interfaces to your Swift code.
  4. No need to re-export nested ObjC modules to end users.
  5. No need to modify existing imports statements to support diverting syntax.
  6. No need to have additional CI jobs related to building from a Package file.
  7. 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

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

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
),
  1. First you need to create a target and name it in a way that distinguishes it as your ObjC dependency.
  2. If you had any dependencies for your ObjC library you would put them here
  3. 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.
  4. 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. 😢
  5. 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”.
  6. This is the name that will be used to import your Swift module into projects.
  7. This is the dependency on your ObjC module.
  8. Again, this assumes that your Swift source files are colocated in a directory named “SwiftSources”.

PUBLIC INTERFACES

Move all of your header files into the include directory.

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.

// ModuleX.h#import "../MyFeature/SomeClass.h"

Use Symlinks

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

@_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

#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

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store