Why Ollie is moving away from SwiftUI to UIKit

Mahyar McDonald
Ollie
Published in
11 min readFeb 15, 2024

--

A few months ago, we made the decision to move away from SwiftUI & Swift Concurrency for our application and to move the core of our app to UIKit and Dispatch. We have migrated all screen and navigation management to UIKit recently in our app and it allowed us to remove several categories of hacks and solved a bunch of performance issues & bugs we had.

We will still use SwiftUI for simple screens like onboarding or settings screens, but complicated things like collection views or anything else we need to not be bug prone, we will use UIKit.

People who are making more typical server client apps with SwiftUI and Swift Concurrency have a good chance of not running into our issues due to the lack of threading & resource stress. They do not need to maintain local state consistently on their device, since they can rely on a backend to properly maintain state for themselves. This kind of app will probably have a much nicer time with SwiftUI.

Multiple people have been interested in the details of why we made this decision, so this article is for you.

Our Adventure

At the beginning we were pretty excited to work on a new app with all the latest Swift libraries, but we have found it a painful, incomplete system to work with once you start making something substantial with it.

When the author joined the company, we were under a stability crisis whose cause was mysterious and we were not in a state to release it to the app store. It took us 2 to 3 months to partly fix the stability issues to be in a releasable state.

Overall we had to spend over a working year of cumulative engineering hours to fix our stability issues, figure out why they were happening and fix them for most of our cases. Since the cause of our stability issues were hard to reproduce deadlocks and infinite spinners, which also blocked reporting and observability of those issues, it was much harder to figure out why things were happening and to get call stacks of what induced these states. Our app also didn’t not rely on a backend at all to function, so we couldn’t inspect server logs to see what network calls were the ‘bad behaving’ ones. All we had is remote logging with datadog and sentry.

After releasing to the app store, several months of production usage and several deep dives of books, docs & WWDC video binges, we finally figured out what combination of bad things were causing our issues. To understand what we did wrong although, you have to understand what our discovered issues were with SwiftUI & Swift Concurrency.

Our Issues

SwiftUI has too much magic & abstraction leaks. AttributeGraph is non-deterministic from a outsider perspective

SwiftUI:

  • Is filled with missing stair steps features that are easy to trip over.
  • Lacks features that have existed since iOS 2.0 in UIKit.
  • Has issues with dealing with debugging, observability & the ability to reason about what is happening in our app.
  • Is hard to easily control the lifecycle of state objects, callbacks and so on.

We have found SwiftUI to have a lot of “magic” within it that makes it hard to reason about. SwiftUI has a lot of implicit hidden assumptions in the system that, if you don’t follow, can cause a lot of surprising painful results. SwiftUI also calls its view body function many times, at a frequency that is difficult to reason about or predict. Performance of large image collection views are also still an issue today, which are essentially the core of our app.

It can be near impossible to break point how a specific binding got updated that way this time, but only occurs %5 of the time for example. To fix such issues, you basically have to deduce what is happening like Sherlock by testing hypothetical inputs and seeing the outputs. SwiftUI itself is a black box wrt to it’s behavior.

SwiftUI is a deceptive system which is easy to get started in, but hard to make it work right without knowing the implicit assumptions unless you’re making something fairly simple. This especially expands once your entire app view tree and navigation is managed by SwiftUI.

In some ways, it reminds me of the trade off you get with React Native due to its abstraction leaks because of the need to seamlessly integrate with UIKit. The need to become an expert in multiple platforms simultaneously to properly debug all issues down the stack vs. working with one native one, and being a declarative vs. imperative view system that uses a diffing algorithm to create view changes.

I fear that SwiftUI is suffering from a second system effect, much like its parent Swift itself did.

The second-system effect or second-system syndrome is the tendency of small, elegant, and successful systems to be succeeded by over-engineered, bloated systems, due to inflated expectations and overconfidence.[1]

The phrase was first used by Fred Brooks in his book The Mythical Man-Month, first published in 1975. Wikipedia

Swift Concurrency deadlocks too easily & it‘s very hard to observe that it deadlocks in iOS apps

Swift Concurrency we have found to be fairly fragile. It’s too easy to deadlock it by surprise due to internal apple libraries such as PhotoKit using standard dispatch_sync calls on unpredictable schedules.

Due to everything being on one concurrent Dispatch thread pool with Swift Concurrency and any downstream call of that thread pool inheriting the peculiar properties of the Swift Concurrency thread pool, it also makes these deadlock states unobservable.

So if you have an async function calling into another module that uses an independent dispatch queue to manage it’s multithreading, Swift Concurrency will ‘infect’ that dispatch queue with the special properties of the main Swift Concurrency dispatch queue, and that queue will start acting like a Swift Concurrency dispatch queue.

So if you call a logging function in your async function, that logging system will also start to deadlock because it happens to be within a Swift Concurrency calling context, even though that logging system does not use any Swift Concurrency primitives, complies with the forward progress contract and so on. If behavior elsewhere deadlocks your swift concurrency queue, it will end up deadlocking your independent logging queue!

If you rely on a logging system to help debug or detect these deadlocks, you have to be extremely careful to not have swift concurrency ever touch those queues. Which can be really difficult to pull off and still be useful, since you often need the data collected by the logging system to diagnose WTF is going on in the first place!

On top of this, the MainActor / main thread of the app gets special privileges from Swift Concurrency’s standpoint. Swift concurrency interactions running on the main thread does not execute in the shared dispatch queue that the rest of Swift Concurrency executes on, and the ‘swift concurrency queue behavior virus’ does not infect the main thread!

So as a result, when swift concurrency deadlocks, it deadlocks everything it touches, except the main thread. This means your UI will still change in response to taps, it’s just anything that calls into an async await call will never return once you’re in this state.

This is actually worse, because your plain old independent logging systems such as Datadog and Sentry who are using plain Dispatch cannot upload the fact that you are in a deadlock from another independent thread pool. Your app will also never crash under a watchdog crash by the system because the UX / main thread stays responsive, making it completely undetectable beyond user reports and internal testing or some really heavy duty and fragile engineering hacks.

On top of this, you can never figure out the prevalence of what is happening with the way iOS apps are set up currently. I would solve this with creating a watcher process that would spawn the actual app process as a child for example, but you are restricted from doing so in iOS unlike macOS.

Tasks, await calls, actors, etc in swift concurrency execute in order most of the time, but sometimes do not, and it is non-obvious on how to force it to execute it in a specific order.

Note / Update: This is the hardest to believe part of our article, but after spending 6 hours with about a dozen iOS engineers on a private slack with 100s of slack messages going back and forth about this, pasting a lot of code and checking a lot of edge cases that didn’t apply in the end, we couldn’t figure out why it happened, just that it did.

It could very well be a bug in Swift Concurrency, or we did use Swift Concurrency wrong in some really edge case way, but in the end it doesn’t matter. The fact that this is possible to do and really hard for a lot of people figure out why it’s happening is the main problem in the first place. It’s too easy to screw it up. Just know the code below is simplification to illustrate our point, the actual code has a lot more checks to prevent double execution and such.

class MyViewModel: ObservedObject {
@Published var showLoadingOverlay = false

/// Significantly simplified from the original code
func refreshPhotos() async {
await photoManager.refresh() // 1 (long running, has await calls & Tasks to other systems inside of it)
await MainActor.run {
showLoadingOverlay = false // 2
}
}
}

%99 of the time when you run code like this, it will execute in order. 1 will finish executing and then 2 will execute. When you write this code, this is what you would assume from all your years of programming. But sometimes, especially when you stress the threading system with a lot of work happening elsewhere, 1 will start executing, and before 1 completes, 2 will execute, causing all sorts of race condition bugs, SOMETIMES!

Actors similarly, also do not guarantee serial execution, but act serially, most of the time!

Once you run into a bug like this and try to fix it by reasonably forcing serial execution, it’s not obvious how to do so with Swift Concurrency. If you try to force these two await calls into their own dispatch queues instead, you start getting a whole bunch of warnings and errors as swift-concurrency complains endlessly about it.

Do searches about it and you see obscure things in swift forum & reddit threads about AsyncStream and SerialExecutors and it’s also not obvious on how to force two tasks to execute serially from each other reading these forum threads. The dispatch API on the other hand made it obvious directly from its documentation, while to do something similar from async await, you need to make a 50 line wrapper to make it as simple as Dispatch did. Most engineers are not going to figure this out easily, or even expect any of this to happen.

The interaction of all 3 of the above issues together led to our stability crisis.

How we held SwiftUI and Swift Concurrency Wrong

  1. We used MVVM which we translated into @StateObject business logic heavy ViewModels that was ok to do in UIKit, but fundamentally a bad idea with SwiftUI due to it’s unpredictable initialization behavior.
  2. Most SwiftUI tutorials and documentation do not make this obvious unless you read a few key paragraphs in documentation or deep dive into WWDC videos such as ‘demystifying swift ui’ & ‘swift ui performance’ that partly explain the internals of how SwiftUI work and implicit assumptions. Most will not learn about these fragile gotchas when using SwiftUI.
  3. If I was to do the data model again, I would avoid usage of State management within SwiftUI and inject everything in as an observable from the top. This lets you manage the lifecycle of all data, business logic and multithreading code completely independent of SwiftUI.
  4. SwiftUI’s internal C++ AttributeGraph library that determines the state lifecycle of state objects attached to views is hard to reason about, especially in the way we used it. Multiple instances occurring of our StateObject ViewModels when we were only expecting one induced a lot of further issues when it started interacting with Swift Concurrency and combine, because it caused us to stress the threading system more than it needed to with repeated actions.
  5. We call a lot of heavyweight apple libraries, like Vision, CoreML, PhotoKit, etc. We also use a complicated Google Photos library as part of an optional feature. These codebases internally do not conform %100 to the Swift Concurrency contract, sometimes leading to our deadlocking issues under times of threading stress, which was made even more intermittent by SwiftUI’s AttributeGraph non-deterministic object lifecycle behavior.
  6. Combine can spawn 100 threads unexpectedly when you use it wrong. Dispatch stops itself after a certain point.

How we solved some of the above issues

  1. We migrated away from Combine. This started before I joined, but some vestiges remained causing bugs.
  2. We moved heavyweight operations into explicit dispatch queues so we don’t stress the shared swift concurrency ‘cooperative’ thread pools.
  3. We made some ViewModels ObservedObjects that parent StateObject vended into views, but this wasn’t easy in some spots due to our reliance on StateObject. We are still using StateObject improperly in a bunch of spots, but have improved our usage of it as we started to understand it’s implicit requirements better.

How we are going to solve the rest of the issues: UIKit & Dispatch

To solve the rest of our issues, we were facing down needing to significantly refactor our view models and business logic to be more SwiftUI friendly, so we could reason about the lifecycle of our data models and move away from StateObject usage. We are also facing down moving a lot of async await code to dispatch so we avoid unobservable deadlocks and predictably allocate threading resources for our various subsystems. Overall it is a huge refactor of our code base with an already tricky multithreading system.

After talking a while about it, we realized that moving the views from SwiftUI to UIKit would actually be the smaller lift. We would free ourselves of SwiftUI’s indeterministic state management, our ViewModel lifecycles will work predictably and well with UIKit and overcomplicated things like SwiftUI navigation would be made easy. On top of that, we wouldn’t have to spend weeks in the future to implement things that are 5 liners in UIKit that are multi-day projects to implement equivalents in SwiftUI.

After we made that decision, one of our engineers, made a UIKitCollectionView prototype in a few hours of a core screen that performed literally 10x faster than our SwiftUI version with way less headache, and he did it out of out excitement with nobody asking him.

Several months after we made this decision, we finished the migration of all navigation management, app management, etc into UIKit. We don’t have a top level App struct anymore, use Application & Scene delegate instead. Each SwiftUI screen is wrapped with a hosting UIViewController and we use UIKit’s UINavigation to manage screen management, modal displays, etc. Everything works much better now. We partly estimate that this works better because each SwiftUI attribute graph tree is not part of one App mega tree, but instead many separated smaller trees that AttributeGraph is probably having an easier time dealing with performantly.

Next Article

The next article will be a bit of a rant about how we held these tools wrong, but asking why are they so hard to hold in the first place, and us imagining a world where apple made some more pareto optimal choices in their devtools, saving literal billions of dollars of human time & life.

This is part of a series of blog posts where we go into:

--

--