Our DNA is written in Swift
Jump

Adding Swift Package Manager Support – Part 2

In the previous post I looked at some of the history of how we packaged up our library code for use by our fellow developers. We looked at some of the benefits of static libraries versus dynamic frameworks which also come with headers needed by the integrator.

Now let’s dive into the steps that were necessary for me to enable SPM support on the first few libraries DTCoreText, DTFoundation and Kvitto. It took me several days to iron out all the kinks and I’d love to share with you what I learned in the process.

Ad

We are used to using Xcode to describe what goes into a build: Which files to compile, what external libraries to link to, what resources are needed and also general build settings like the range and types of supported platforms. More precisely, these settings are contained in the project.pbxproj file inside your xcodeproj bundle.

With SwiftPM there is no such project file. Rather everything is defined in human-readable form in the Package.swift file.

For some basic terminology: we define certain products (i.e. static library, dynamic framework, app bundle etc, resource bundle, unit test bundle), that relate to a number of targets (a bucket for a bunch of source code files and resources). Here is a distinction from Xcode where target and product is used synonymously.

Package Definition

The first step, and most important one, is to add a package definition file to the root folder of the repository. It needs to be in this place because Swift Packages are referenced by the repository URL and SwiftPM will only look at the top folder for Package.swift.

Here’s the definition for Kvitto, for reference. This has all elements you might encounter, including a dependency on another package, a couple of resources on top of the definition of one product and multiple target.

// swift-tools-version:5.3

import PackageDescription

let package = Package(
    name: "Kvitto",
    platforms: [
        .iOS(.v9),         //.v8 - .v13
        .macOS(.v10_10),    //.v10_10 - .v10_15
        .tvOS(.v9),        //.v9 - .v13
    ],
    products: [
        .library(
            name: "Kvitto",
            targets: ["Kvitto"]),
    ],
    dependencies: [
        .package(url: "https://github.com/Cocoanetics/DTFoundation.git", 
		from: "1.7.15"),
    ],
    targets: [
        .target(
            name: "Kvitto",
            dependencies: [
                .product(name: "DTFoundation", 
				package: "DTFoundation"),
            ],
            path: "Core",
            exclude: ["Info.plist"]),
        .testTarget(
            name: "KvittoTests",
            dependencies: ["Kvitto"],
            path: "Test",
            exclude: ["Info.plist"],
            resources: [.copy("Resources/receipt"),
                        .copy("Resources/sandboxReceipt")]),
    ]
)

The first line might only look like a comment to you, but it is important for the swift tools to determine what syntax elements are supported. Version 5.3 is required if you have resources in any target. If you set that to something lower you get syntax errors regarding the resource definitions. If you set that to 5.3 but don’t specify resource definitions (for non-standard resources) you will get warnings about unknown files that you should either exclude or define as resources.

I found myself conflicted about that, as I had mentioned in the previous article. All code would work on Swift 5.0 and up and only the test target has resources. I could get more green checkmarks on Swift Package Index if I removed the .testTarget definition.

On the other side the swift tools let you run thusly defined unit tests from the command line and functioning unit tests also should count as a sign of good library quality. Finally, everybody should be using Swift 5.3 anyway as that’s the baseline standard since the release of Xcode 12.

That’s why I chose to leave it at that.

The basic setup of the package definition is straightforward. You have the package name, then some minimum platform versions. Note that those minimum OS versions don’t mean that that could restrict the the package to specific platforms.

The products section defines what kind of library comes out of the build process. The default setting (invisible) is to produce a static library, by specifying type: .dynamic you get a dynamic framework instead. The targets array specifies which targets will get merged into the final product.

I thought for a second that that might be good to have the resources be added to the framework instead of a separate resource bundle, like we are used to. But alas the handling of resources stays the same and they get bundled into a Product_Target.bundle. So therefore I’d rather have the static library – which will get merged into the app binary – rather than having yet another separate framework bundle inside the app bundle.

As I explained in the previous article, dynamic frameworks should be avoided if the source code for libraries is public. So we are happy with the static library default.

The dependencies section lists the external reference to other packages. You specify the repository URL and the minimum versions. The shown way with from and a version would accept all 1.x.x versions from and including 1.7.15. There are also other ways to specify an exact number or certain ranges.

Last come the targets. We have a regular target for the package and a test target for all the unit tests. If you don’t specify a path then SwiftPM expects the source code in the Sources folder underneath the target’s folder and resources in a Resources folder. I have a different structure, so I specified a custom path.

I have to exclude the Info.plist for both targets because this is used by two targets defined inside the Xcode project. And for the test target I specify two resources to be copied with the path relative to the target custom path. These copy instructions are necessary because the contained resources don’t have a type that Xcode knows how to handle. For things like strings files or XIBs you don’t have to specify anything.

Compare the dependencies key of both targets. On the one hand you see that I am referencing the external dependency of the main target. On the other hand the test target requires the main target to work. That’s also a difference to Xcode where the tested code resides inside a host application, where’s here it is compiled into the unit test bundle.

Target Considerations

You might be wondering why there is a distinction between products and targets in SPM. One reason for that you have already seen: there is no reason for the test target to be represented in a product. Simple packages will generally only have one product that might only consist of one target.

Although I already found two more reasons, to separate code out into more individual targets and then also products.

You might assume that Swift Package Manager would only all you to have code written in Swift. But you would be wrong, Any language goes, also Objective-C and other C dialects. But SPM doesn’t allow you to mix C-based languages with Swift in a single target.

In one project I had some Objective-C code for a function with a lot of ifs. I rewrote that in Swift only to find that compiling this would take more than a minute, compared to a few seconds in Objective-C. So I chose to leave the function as it was. The solution was to put it into a separate Objective-C target and refer that to an internal dependency from the main Swift target.

The other good reason for a separate target and product was to have some common data model code that would be used by internal targets and also via import in an app consuming my library. In places where the client would only need the shared definitions he would import the specific module for that. Elsewhere he would import other targets which in turn could also make use of those definitions internally.

Each product becomes its own module.

Resourcefulness

I mentioned above that you can let SPM do its own thing when it comes to standard resource types, like localised strings, XIBs, storyboards and asset catalogs. If you use string localisation though, you have to specify the project’s default language.

Other types you have to either specifically exclude or specify what should be done for it. You can either specify a .copy for each individual resource or also for the entire Resources folder. Since I have only two test files and that’s not going to change, it wasn’t too much work to add those individually.

SPM expects resources in the same folder that a target’s source files reside in (or a sub-folder thereof). The reason for that is again that there is no Xcode project file where you could specify membership of certain files to specific targets. You specify what belongs where by how it is laid out in the file system in combination of the package definition.

Say you have a single place where you have localised strings files downloaded from a translation site like POEditor but you want them to be included in different targets. A technique to achieve that is to create soft-links inside the target’s resource folders to the files. I wrote this shell script to create the lproj folders for all languages and then create the links.

#!/bin/sh

echo "Removing existing strings"
rm -rf ../TFMViews/Resources/*.lproj
rm -rf ../TFMExtension/Resources/*.lproj

PWD=`pwd`

for entry in *.lproj
do
  echo "Linking $entry..."

  mkdir ../TFMViews/Resources/$entry
  ln -s ../../../Strings/$entry/TFMViews.stringsdict \
     ../TFMViews/Resources/$entry
  ln -s ../../../Strings/$entry/TFMViews.strings \
     ../TFMViews/Resources/$entry

  mkdir ../TFMExtension/Resources/$entry
  ln -s ../../../Strings/$entry/TFMExtension.stringsdict \
     ../TFMExtension/Resources/$entry
  ln -s ../../../Strings/$entry/TFMExtension.strings \
     ../TFMExtension/Resources/$entry

done

The same technique of soft-links can also be employed for Objective-C based packages where you can link to all relevant public headers in an include folder.

Platform-specific Code

Since the package has no facility for limiting specific source code to specific platforms or OS versions, you will face the situation that certain code won’t compile for other platforms. A workaround for this limitation is the use of conditional compilation directives.

For example, everything that references UIKit cannot be compiled for macOS or watchOS, so I have a few places in DTCoreText or DTFoundation (both written in Objective-C) where the entire implementation is enclosed in:

#import <TargetConditionals.h>

#if TARGET_OS_IPHONE && !TARGET_OS_WATCH
...
#endif

I also found that sometimes I had to also import the TargetConditionals header for the defines to work. In particular certain Objective-C category extensions in DTCoreText would not be visible in the public interface if I didn’t import this header. I have no explanation as to why, but adding the import for the header fixed it.

Inside the Xcode Project

The changes for conditional compilation aside, there is nothing you need to change in your Xcode project – unless you want to. The principal setup for the package happens in Package.swift. You can build the package with issuing swift build.

I found it convenient to add a reference to the package inside the Xcode project because this allows you to debug your code in the context of being compiled for a package. If you drag any folder (containing a package definition) into the project navigator pane, Xcode will add a local package reference for you, with a symbol of a cute little box.

In Xcode 12 there is a bug that if you do that for the project folder itself, it seems to work, but once you close the project and reopen it again, the reference becomes defunct. The way to fix it is to change the reference to “Relative to Project” and open the folder selector via the folder button and re-select the project root folder.

This also creates a scheme for building the package and the package’s products become available to link/embed to your app. Package products have an icon of a greek temple. If they are static libraries then they will get merged into the app binary, dynamic frameworks will be added to the app’s Frameworks folder.

Xcode also creates a scheme for the package, placing it in .swiftpm/xcode/xcshareddata/xcschemes/. I moved it into the shared schemes folder of the xcodeproj and renamed it to Kvitto-Package.xcscheme.

I had the watchOS platform builds on Swift Package Index fail because xcodebuild insists on building all targets, including the test target. This fails because unit tests require XCTest which does not excite for watchOS.

By providing an aptly named shared scheme it will only build the main target and I achieved green checkmarks for watchOS on SPI.

Library Unit Tests

To run the unit tests contained in the test target, all you need to do is to run swift test on the command line, from the repository root folder.

Result of running the Kvitto unit tests from the command line

Some magic was required to get that to work because test files required by the unit tests are not bundled in the .xctest bundle. For regular packages a resource bundle accessor is being automatically generated, which you can use with Bundle.module.

The accessor works by determining the path of the executable and constructing a bundle name from names of package and target. In the case of unit tests the executable is xcrun contained in the Xcode.app bundle where it has no chance of finding the Kvitto_KittoTests.bundle.

My ugly, but functional, workaround for this is as follows:

func urlForTestResource(name: String, ofType ext: String?) -> URL?
{
	let bundle = Bundle(for: type(of: self))
		
	#if SWIFT_PACKAGE
		
	// there is a bug where Bundle.module points to the path of xcrun inside the Xcode.app bundle, instead of the test bundle
	// that aborts unit tests with message:
	//   Fatal error: could not load resource bundle: /Applications/Xcode.app/Contents/Developer/usr/bin/Kvitto_KvittoTests.bundle: file KvittoTests/resource_bundle_accessor.swift, line 7
		
	// workaround: try to find the resource bundle at the build path
	let buildPathURL = bundle.bundleURL.deletingLastPathComponent()
		
	guard let resourceBundle = Bundle(url: buildPathURL.appendingPathComponent("Kvitto_KvittoTests.bundle")),
	   let path = resourceBundle.path(forResource: name, ofType: ext) else
	{
		return nil
	}
		
	return URL(fileURLWithPath: path)
		
	#else
		
	guard let path = bundle.path(forResource: name, ofType: ext) else
	{
		return nil
	}
		
	return URL(fileURLWithPath: path)
		
	#endif
}

This relies on the fact that the resource bundle will be created parallel to the xctest bundle, in the same build folder. The #if SWIFT_PACKAGE conditional compilation will only be added if this code is built as part of a swift package. With this workaround, the previous mechanisms of running the unit test scheme via Xcode continues to work.

The great thing about Swift being open source, is that we can also inspect the code for the resource accessor on GitHub. It turns out that the mentioned bug has already been addressed there. The fix was made too late to make it into Swift 5.3 in Xcode 12 but has been confirmed to be present in Xcode 12.2.

Conclusion

I find that the evolution of Swift Package Manager as progressed sufficiently to start adding support for it to my libraries. It is possible and advisable to do so in addition to other ways of integration, like Xcode subproject, Cocoapods or Carthage.

The most annoying limitation remaining is that you cannot limit targets to certain platforms or specify a range of supported OS versions per target. But those can easily be worked around with conditional compilation directives.

The quality criteria partially enforced by the Swift Package Index coupled with the discoverability of components also make it attractive for library vendors to consider supporting Swift Package Manager. Having the dependency management taken care of by Xcode is the greatest feature of all.


Also published on Medium.


Categories: Administrative

1 Comment »

Trackbacks

  1. Adding Swift Package Manager Support – Part 1 | Cocoanetics

Leave a Comment

%d bloggers like this: