⌘ kean.blog

SwiftPM and Xcode

  

I recently had the pleasure of migrating a relatively large project from CocoaPods to SwiftPM, and I would like to share my experience and some thoughts about how it could be improved.

Adding Dependencies #

The existing process for adding dependencies to Xcode target involves multiple steps and is especially convoluted when you have multiple targets, like app extensions. I wanted to make sure that the experience of managing the dependencies is at least as good as in CocoaPods, so I borrowed a page from their playbook. I’m also working with a situation where most of the code is in Xcode targets and can’t be easily extracted to Swift packages.

I created a Modules/Package.swift file and defined a list of products that match my Xcode targets:

let package = Package(
    name: "App",
    products: XcodeSupport.products + [
        .library(name: "MyInternalLibrary", targets: ["MyInternalLibrary"]),
    ],
    // ...
)

enum XcodeSupport {
    static let products: [Product] = [
        .library(name: "XcodeTarget_App", targets: ["XcodeTarget_App"]),
        .library(name: "XcodeTarget_AppTests", targets: ["XcodeTarget_AppTests"]),
        .library(name: "XcodeTarget_ShareExtensions", targets: ["XcodeTarget_ShareExtensions"]),
        // ...
    ]
}

These products are neatly placed at the bottom of the Package.swift and have no source files except for the dummy files stored in Modules/Sources/XcodeSupport/. The libraries produced by these products is what’s get added to “Frameworks, Libraries, and Embedded Content” in Xcode.

The “actual” dependencies are specified in the same Modules/Package.swift file:

enum XcodeSupport {
    static let targets: [Target] = [
        .target(name: "XcodeTarget_App", dependencies: [
            .product(name: "Pulse", package: "Pulse"),
            .product(name: "PulseUI", package: "Pulse"),
            .product(name: "Nuke", package: "Nuke"),
            .product(name: "NukeUI", package: "Nuke"),
            // ...
        ])
        // ...
    ]
}

Now if you need to add a new dependency, you simply add it in Modules/Package.swift, and you are done. You never need to mess with the .pbpxroj file.

The dependencies, including the transient ones, are pinned in the ./MyApp.xcworkspace/xcshareddata/swiftpm/Package.resolved file as usual. I added Modules/Packages.resolved to .gitignore to make sure if someone opens Packages.swift, the Package.resolved file never gets committed into the repo.

Better Developer Experience #

With a few extra steps, I achieved my goals, and I think it was an acceptable solution for SwiftPM’s early days. But it leaves a lot to be desired. The ideal developer experience for me would’ve looked the following way:

You invoke the “Add Package Dependency” action and it shows the existing dialog.

When you select a package, Xcode either creates a new Package.swift file in the root project directory or adds a dependency to the existing file:

let package = Package(
    name: "App",
    dependencies: [
        // ...
        .package(url: "https://github.com/kean/Nuke", from: "12.0.0")
    ],
    // ...
)

The pinned dependencies should be saved in Package.resolved file stored side by side with the Package.swift file so that if you open a package, it uses the same pins as the Xcode project, and there is no confusion where the dependencies are pinned.

To add a dependency to an Xcode target you have two options:

  • Add it to “Frameworks, Libraries, and Embedded Content” manually
  • Add it in code using targets automatically generated by Xcode and/or SwiftPM:
let project = Project(
    // ...
    targets: [
        .target(name: "App", dependencies: [
            "MyInternalLibrary",
             .product(name: "Nuke", package: "Nuke"),
        ])
    ]
)

Yes, borrowing a bit from Tuist.

To delete a dependency, you would Option-Click on the dependency in the Package.swift file and select “Delete Dependency”, which would remove it from the list of dependencies and also from all the targets.

Technical Limitations #

Of course, the Package.swift file is a Swift file, so if it is complicated enough, some of these things won’t work. But I think it’s an acceptable and understadable trade-off because it’s just a bit of a convenience on top of a human-readable source file. If you are unhappy with a diff it creates, you’ll change it. Currently, these features don’t exist at all, even for simple package files.

Other Issues #

In addition to the issues with the developer experience, which I can live with, there are a couple of major issues with no straightforward solutions that really sour the experience of using SwiftPM, especially coming from other package managers and platforms.

  • Bundle Duplication. Xcode is smart enough to link the dependencies used by more than one target dynamically, but, unfortunately, their bundles still end up being duplicated in all of the targets that depend on them.
  • Invalid Bundle.module. If you include a SwiftPM package that has resources in one of your Xcode test targets, the library is not be able to locate its resources. I had to stop using Bundle.module and switched to an updated version that first looks for bundles in ProcessInfo.processInfo.environment["XCTestBundlePath"].
  • Resolve Packages. Xcode constantly restarts package resolution, even as you type in your Package.swift file, which is especially problematic during rebasing or any other Git operations. I’m usually reserved with my comments, but I can’t overstate how horrible the experience is. It’s a good trade-off only if you are one person working on a small project in a single branch. There has to be a way to turn it off and a simple command like swiftpm install to resolve the dependencies.

Final Thoughts #

Despite the existing issues, I feel more confident using SwiftPM for medium- to large-size projects, but I hope to see the improvements that will make it a no-brainer decision to switch to it for projects of any size.