I have a bad habit of under-investing in my project’s organization. I use Xcode groups a lot, but when it comes to local code, that’s usually as far as I go. I’ve been having great luck with using Swift packages for project-external dependencies, so I decided to try using them for local modules as well. I ended up going on quite a journey. Come along, let’s talk about organizing local code in Xcode!
TL;DR: Local packages can work. But static/dynamic libraries give you nearly all the benefits with more control and should be seriously considered.
We’re going to be talking about Xcode targets a lot. They represent the artifacts we produce, and are the most important parts. You might have a straight-forward setup with just one single app target. But, things can quickly get much more complex with extensions and services. And all of these can have complex relationships at both build- and link-time.
You cannot really get around Xcode targets. Apps, extensions, and many other components can only be created with them. But, pretty much everything else is up to you. You can even share source files across targets, so you can definitely get by with a very flat project structure. This might seem like the simplest approach, but will result in larger binaries, slower build times, and low modularization. You can definitely do better, and you’ll probably want to.
I think everyone can get behind smaller binaries and faster build times. But, modularization may not be something you’ve used a lot in your project. I think it’s very worthwhile, and probably one of the biggest benefits of more intentional project organization. Converting your app project into distinct modules really helps to think about and define the boundaries of your subsystems. Swift’s access control features give the tools to enforce those boundaries.
As I began modularizing my app, I immediately started finding surprising dependencies. I found a number of systems that were tangled up in ways that made no sense. It wasn’t always straightforward to fix, but I always found it incredibly satisfying to do. In all cases, I found the systems much better factored afterwards.
I had always thought about Swift packages as exclusively external to a project. But, they actually can be used inside a project as well. For the most part, they behave exactly like an external package. You can add them as dependencies to your Xcode targets, and can link against them. All that really changes is their contents become editable.
One really interesting thing about working with local packages is the Xcode project file itself references just the top-level package directory. This means any file operations within the package do not produce modifications to the containing
.xcodeproj file. This makes for less chance for merge conflicts. Xcode also gives them a cool icon in the project navigator 📦.
Local packages can define dependencies to both external and other local packages. And, Xcode targets can depend on packages. However, it is really important to note that the reverse is not true. A package cannot depend on an Xcode target. This might be a non-issue for you, especially if you are exclusively using local packages. But, if you have any existing static/dynamic library targets, this could be a show-stopper.
By default, SPM packages do not specify what kind of library they should build. Without any configuration, this is left up to the build system. In many cases, this works just fine. However, this can result in some really surprising behavior. When you link more than one Xcode target against the same package, local or remote, Xcode will automatically produce a dynamic framework.
While this works, it isn’t always what you actually want. Dynamic libraries have a non-trivial impact on application launch time. One or two isn’t too much to worry about. But as you start adding more packages, you can easily end up with a very serious launch time penalty. Worse, because it’s totally automatic, you really have to go look in the final .app bundle to get an idea of what is going on.
I really like to avoid implicit behavior like this. It is possible to explicitly set the library type in a Package.swift, and I thought this would help me control for this situation. While it can help, it can also cause more problems. Xcode does not allow you to link the same static library into multiple targets. Despite being done intentionally, it won’t just produce a warning - it is a build error. I get that it is a potentially sub-optimal configuration, but disallowing it entirely seems too severe.
At this point, I was getting deeply frustrated with local packages. They offered me the modularization I was looking for, but they took away too much control over the build process. I really didn’t want to go back to a flat project, so I decided to try Xcode library targets.
In many respects, library targets and local packages are equivalent. But libraries, being first-class Xcode targets, have many benefits. They can participate in the Xcode dependency system. They support all of Xcode’s build settings, and those settings can be defined, and shared, in xcconfig files. And, by their very nature, they offer explicit control over the final artifact generation. No more static/dynamic ambiguity.
I was really loving using static libraries to define modules! There are, as far as I can tell, only three downsides to explicit targets. They are marginally more difficult to open source as packages down the road. Their structure lives entirely within the xcodeproj file. But the third issue was a heartbreaker: they have a critical interoperability issue with certain SPM modules.
Here’s the situation: you have an Xcode target that depends on a package, but does not link against it. If that package itself has non-Swift package dependencies, it will fail to build in Xcode. This bug will only happen with all three of these conditions. But, if you are unlucky enough to be in this situation, you are stuck.
Or, so I thought!
For any target that happens to be hit by this issue, it can be fixed with this
OTHER_SWIFT_FLAGS build setting:
This is really, really annoying. But, it’s also probably a relatively rare situation. And, in my case, getting back the ability to control the linking and framework generation behavior was absolutely essential. (Filed as FB10032666)
Ok, so let’s get right down to it. Should you go with local packages? If you have a simple project, with just one single app target, it can definitely work. But, if you are embedding other binaries in your app, like extensions or services, I think you may want to avoid them. Bug notwithstanding, static libraries offer nearly all of the benefits, without giving up control that can become essential for more complex projects.
As I discovered during my journey, going back and forth actually isn’t too painful. So, you can always start with packages if that feels best, and then change when/if needed. Even a hybrid approach is possible. Regardless which option you decide on, project modularization is really worth the effort. The faster build times are just icing on the cake. Having APIs with clear and intentional boundaries is such an enormous win, even for small projects. This is definitely something you should look at if you aren’t doing it today.
Thu, Jun 16, 2022 - Matt Massicotte