7 months ago

Between Simplicity and Limitations: A Developer's Take on Apple's Tooling Strategy

Yesterday, I wrote about the evolution of iOS development and the role of Tuist in it. I kept reflecting on some of the ideas that I touched on in it. One of them, in particular, is the tradeoff that Apple found itself having to make between developers’ convenience, which is a key tool to make it easy for developers to get started, and flexibility, which is what medium-to-large apps need to be able to scale.

You might think you won’t ever have the need of thinking about scale, but let me tell you that you will. Scalability is not only about the size of a project but also about the breadth of it. The moment you decide to support multiple platforms, for example, iOS and watchOS, you’ll most likely want to reuse code across targets. This is achievable through shared targets, which introduce you to the world of dependencies that I discussed yesterday.

Configuring a modular Xcode project, including its external dependencies, is a tedious and error-prone task. If Apple’s platform were dynamic like NodeJS, we wouldn’t even have to think about the problems I’m going to talk about because modules are dynamically resolved and loaded. Unless you make some node_modules-like design decision, and you find yourself in a spot that’s difficult to move away from. What that means is that the build process is more involved, and the build system and the mental models it works with are more intricate.

If you have built for the Apple ecosystem for a while, you’ve most likely faced the issue of “duplicated symbols” or apps crashing at runtime because there’s a dynamic module that hasn’t been copied into the final product (i.e., “framework not found”). Those are hard to debug, aren’t they? I became so weirdly obsessed with understanding them that I decided to build Tuist so that no one would have to do it themselves manually. The problem is that when Apple recognized that it’s not trivial, and the number of use cases that we need to handle in Tuist’s codebase is a good proof of that, they decided to go down the path of convenience and enabled some implicit settings. What does it mean in practice? There are several places where one can experience that, but the most obvious one is Apple being able to detect target dependencies by looking at who outputs a .framework into a directory that’s exposed to our target through the framework search path. Isn’t it cool? It is, until it’s not. What works for the developer that’s getting started, whose dependency graph is small, doesn’t work in slightly larger projects where there’s a mix of static and dynamic frameworks and libraries. And the matter keeps getting worse because Apple doesn’t cease to add new target types. For example, there’s now Swift Macros and Build Tools.

So, in order to solve the problem, we had to start by making the implicit explicit. And the core-most element that required that explicitness was the dependency graph. One might think that the dependency graph refers only to external dependencies, but with it, I’m also referring to the targets that are part of your project - some dependencies are local, and others are remote. They are all dependencies. So when you look at Tuist’s DSL, you’ll notice that it made dependencies front and center. The build settings and phases that are required are an implementation detail. If a dynamic framework needs to be copied, we know and configure things properly. The same is true for when the right binary of an .xcframework needs to be selected for the target that’s linking against it. As mentioned earlier, the scenarios are endless, and you really don’t want to be doing that yourself.

The problem is that Apple doesn’t reconsider the implicitness path, which in my opinion is a terrible design idea. At least if we think of a future where apps are multi-platform and very modular, which I think is realistic to think about. A good example of that is that the integration between Xcode and the Swift Package Manager is also very implicit. Xcode’s build system and the Swift Package Manager are both communicating and making decisions at build time to keep things convenient. Just flag Swift Packages to be automatically linked, and Xcode will do it for you. Until it doesn’t, or the experience is laggy and slow, and you can’t do little about it.

I met with a developer over a month ago, and we chatted a bit about their transition from Tuist to Swift Package Manager, and they found themselves very limited by the optimization opportunities that the Swift Package Manager offers. I’d be surprised if they can change that without going back to first principles because they should even start with questioning whether a compiled language, Swift, is the right tool for the job. When we look at more advanced systems, we see dynamic, fast, and functional DSLs being the common denominator. Xcode’s build system should probably be closer to what Gradle is for Android. From all the things compiling Swift could offer, like sharing code across manifest files or coming up with with abstractions, they only use type-related capabilities.

Apple appears to be hindering their own progress. While they’ve streamlined basic tasks, they’ve concurrently made more intricate ones seem unattainable. This puts developers in a quandary: how can they discern these limitations when Apple doesn’t offer comprehensive insights from its tools? Often, developers only recognize these constraints when faced with them directly. Consequently, many organizations either pivot towards alternatives like React Native or undertake the daunting task of overhauling the entire build system. I encountered this firsthand at Shopify. Despite my persistent efforts to highlight the unsustainability of their unwavering reliance on Xcode, I was met with staunch opposition, culminating in a top-down directive to transition to React Native.

It’s not an easy problem to solve, but I believe it’s a problem worth solving. They should consider layering their tooling such that there’s a very low extensible layer without convenience or implicitness that developers can build upon and extend. And then another layer on top of it that’s the one that provides the convenience and says “we are trying to be smart to help you stay focused.” Right now, there’s a single layer that stretches too broadly and is more of a hindrance than a help. Xcode would know how to contract with those layers, and alternative build systems like Bazel or Gradle would have the possibility to swap pieces as needed.

As someone trying to help solve this problem, I find the whole situation very frustrating. It’s frustrating because everyone gets eclipsed by what Apple proposes, and they are unable to objectively decide whether that’s a good idea or not. Hence why I’m writing blog posts like this one. Hopefully, one day Apple goes back to the root and rethinks the build system. I personally believe the Swift Package Manager path is not the way.

About Pedro Piñera

I created XcodeProj and Tuist, and co-founded Tuist Cloud. My work is trusted by companies like Adidas, American Express, and Etsy. I enjoy building delightful tools for developers and open-source communities.