Managing dependencies using the Swift Package Manager
When Swift was open sourced at the end of 2015, one of the most surprising and interesting new projects that was introduced along with it, was the Swift Package Manager. While it wasn’t the first dependency manager for Swift projects, it was the first that was officially provided and supported by Apple, which many developers saw as really good news.
However, while the server-side Swift community quickly embraced the Swift Package Manager as the go-to tool for managing dependencies when building server applications, it’s taken quite a while for it to become fully integrated into the rest of Apple’s developer toolchains.
But now, starting with Xcode 11, the Swift Package Manager is finally becoming a true first class citizen within Apple’s suite of developer tools — so this week, let’s take a look at how it can be used to manage a project’s various dependencies — both internal and external ones.
The anatomy of a Swift package
A Swift Package is essentially a group of Swift source files that are compiled together to form a Module — which can then be shared and imported into other projects as one unit. Packages can either be public libraries that are shared using services like GitHub, or internal tools and frameworks that are only shared within a small number of projects.
The contents of a package is declared using a
Package.swift manifest file, which is placed in the root directory of each package. Rather than using a data format, like JSON or XML, Swift package manifest files are written using actual Swift code — with a
Package instance representing the declaration of the package.
As an example, let’s say that we’re working on a todo list app, and that we want to create a
TodoKit package for all of our core logic that’s shared across the app — including things like our database layer, our model code, and so on. To get started, we’ll create a new folder (with a name that matches what we want our package to be called), and we’ll then run
swift package init within it to create our package:
$ mkdir TodoKit $ cd TodoKit $ swift package init
In Xcode 11, we can also perform the above setup using the
File > New > Swift Package menu command.
By doing the above, the Swift Package Manager will now have created an initial structure for our new package — which includes a
Package.swift manifest file that looks something like this:
// swift-tools-version:5.1 import PackageDescription let package = Package( name: "TodoKit", products: [ // The external product of our package is an importable // library that has the same name as the package itself: .library( name: "TodoKit", targets: ["TodoKit"] ) ], targets: [ // Our package contains two targets, one for our library // code, and one for our tests: .target(name: "TodoKit"), .testTarget( name: "TodoKitTests", dependencies: ["TodoKit"] ) ] )
swift-tools-version comment at the top of the file isn’t just a comment, it also tells the Swift Package Manager what version of the Swift toolchain to use when building our package.
By default, the Swift Package Manager will match the names of the targets defined within our manifest file with corresponding folders on disk in order to determine what Swift files that belong to each target. That behavior, along with other defaults — such as build settings, target platforms, etc. — can be overridden by passing additional parameters to the APIs used above.
Adding remote dependencies
Besides facilitating the creation of packages, one of the Swift Package Manager’s core use cases is enabling remote dependencies — such as third party libraries — to be added to a project. Any package that can be fetched through Git can be added simply by specifying its URL, as well as the version constraint that we wish to apply to it:
let package = Package( ... dependencies: [ // Here we define our package's external dependencies // and from where they can be fetched: .package( url: "https://github.com/johnsundell/files.git", from: "4.0.0" ) ], targets: [ .target( name: "TodoKit", // Here we add our new dependency to our main target, // which lets us import it within that target's code: dependencies: ["Files"] ), .testTarget( name: "TodoKitTests", dependencies: ["TodoKit"] ) ] )
Above we’re importing any version of the Files package between
5.0.0 — leaving it up to the Swift Package Manager to resolve the most appropriate version that satisfies our overall dependency graph, while defaulting to the latest version within that range.
Using such a broad version constraint can be really powerful, since if we were to add another dependency that requires a specific version of Files, the package manager would be free to pick that version (as long as it’s within our allowed version range) — making it less likely that we’ll end up with an unresolvable dependency graph.
However, sometimes we might want to lock onto a specific version of one of our dependencies — perhaps to avoid a regression that was introduced in a later version, or to be able to keep using an API that was later removed. To do that, we can replace the above
from: parameter with the
.exact version requirement — like this:
.package( url: "https://github.com/johnsundell/files.git", .exact("4.0.0") )
On the other hand, we might instead want to use a dependency revision that’s further ahead than the latest official release — for example to include a bug fix or a new API that hasn’t been properly released yet. To do that, we have two options.
The first option is to point our dependency to a specific Git branch (which can be quite risky if that branch is rapidly changing), or to lock onto a specific commit hash (which is less risky, but also less flexible, since we’ll have to manually change that hash each time we want to update that dependency):
// Depending on a branch (master in this case): .package( url: "https://github.com/johnsundell/files.git", .branch("master") ) // Depending on an exact commit: .package( url: "https://github.com/johnsundell/files.git", .revision("0e0c6aca147add5d5750ecb7810837ef4fd10fc2") )
Being able to specify dependencies not only by version, but by Git revision as well, can also be really useful in order to temporarily fetch a dependency from a forked repository, rather than from its original one.
For example, let’s say that we’ve discovered a bug in one of our external dependencies, and that we’ve implemented a fix for it within our fork of that project. Rather than having to wait for that fix to be merged into the original repository, and then released — we can simply point that dependency to the URL of our fork, and then specify
master as our branch target, to be able to directly use our patched version.
Using local packages
When working on several different packages in parallel, for example when splitting a project up into multiple smaller libraries, using local dependencies can sometimes be really useful — and drastically improve iteration times.
Rather than being downloaded from a URL, local package dependencies are simply added directly from a folder on disk — which both lets us import our own packages without having to worry about versioning, and also enables us to directly edit a dependency’s source files within the project that’s using it.
For example, here’s how we could add a local
CalendarKit package as a dependency to
TodoKit — simply by specifying its relative folder path:
let package = Package( ... dependencies: [ .package( url: "https://github.com/johnsundell/files.git", .exact("4.0.0") ), // Using 'path', we can depend on a local package that's // located at a given path relative to our package's folder: .package(path: "../CalendarKit") ], targets: [ .target( name: "TodoKit", dependencies: ["Files", "CalendarKit"] ), .testTarget( name: "TodoKitTests", dependencies: ["TodoKit"] ) ] )
Besides being able to directly edit dependencies, local package references are also really useful when building custom developer tools. For example, we could use the Swift Package Manager to build a command line tool within an app’s repository, and then use a local dependency to import some of our app code into that tool — like our model or networking code, for example.
Platform and OS version constraints
Apart from the internal packages and third party libraries that we’ve explicitly added to a project, our code will most likely also depend on a specific range of platforms and operating system versions — in order to have access to the right APIs and system frameworks.
While all Swift packages are assumed to be cross-platform (and version agnostic) by default, by adding the
platform parameter when initializing our
Package within our manifest file, we can constrain our code to only support a given set of platforms and OS versions — like this, if we wanted to build a package that contains iOS 13-specific code:
// swift-tools-version:5.1 import PackageDescription let package = Package( name: "TodoSwiftUIComponents", platforms: [.iOS(.v13)], ... )
Just like when picking a minimum deployment target for an app in Xcode, using the
platforms parameter enables us to use APIs that are only available on a subset of platforms or OS versions — such as SwiftUI or Combine. We can of course also specify multiple platforms and version as well — for example, we could append
.macOS(.v10_15) to the above array to also add support for macOS Catalina.
Adding packages to an Xcode project
Starting with Xcode 11, Swift packages can now be directly added and imported into an app project using Xcode’s new
Swift Packages option, which is located within the
File menu. Using this new integration both enables us to easily import third party libraries as Swift packages, and it can also let us leverage the power of the Swift Package Manager to improve the modularity of our code base.
By creating separate packages for different parts of our code base — just like the
TodoSwiftUIComponents examples from before — we can both improve the separation of concerns within our app, and also enable our code to be easily reused across different platforms or extensions.
For example, by defining our UI components in a separate package from our model code, there’s no risk of accidentally mixing view code with model code — which can help us maintain a much more solid architecture over time — and it also enables us to easily share our core UI components between multiple targets.
While the Swift Package Manager isn’t really a brand new tool anymore, the fact that it can now be used for apps on all of Apple’s platforms gives it a much wider appeal — and kind of feels like a “new beginning” for Swift packages as a concept. Being able to use the same package manager to build anything from server-side applications, to command line tools and scripts, to iOS apps, is also incredibly powerful — and could potentially enable parts of our code to be reused in even more contexts.
What do you think? Have you used the Swift Package Manager before, or will you try it out now that it’s integrated into Xcode? Let me know — along with your questions, comments or feedback — either on Twitter or via email.
Thanks for reading! 🚀