How SwiftUI & Concurrency Could Forward Fix Their Issues

Mahyar McDonald
Ollie
Published in
6 min readFeb 15, 2024

After our series of blog articles, what could apple do to forward fix the issues we had?

I also wrote a tweet thread about this partly.

Swift Concurrency Deadlocks

  • This is a great presentation about Swift Concurrency issues: Matthew Massicotte — The Bleeding Edge of Swift Concurrency. I would strongly suggest addressing all the scary aspects he raises.
  • A ‘watchdog’ extension that lets you pause and inspect all call stacks of the app, and upload it to an endpoint. The extension would live in a different process, and also be called when the app crashes or gets killed by the system watchdog. MetricKit has this partly, but being able to put in our own logic (such as a heartbeat checker to check if specific subsystems are deadlocked) would help us catch the cases that MetricKit does not catch.
  • A watchdog extension would’ve helped us with our production threading heisenbug issues, because we would be able to guarantee that we are observing all deadlock states and know the actual rates for them. Also there are some watchdog terminations that only have full call stacks on the phone, while sentry only has one thread’s call stack. This watchdog extension should also be able to recognize if Swift Concurrency is specifically deadlocked while the main thread is still working.
  • An OS logging system that uploads to a URL endpoint reliably, that doesn’t rely on the app to implement it’s own logger properly or worry about deadlocks.
  • Make in order execution of Tasks the default, out of order execution be a special mode that you have to wrap in a scope identifier. Sometimes out of order execution is a huge surprise and most do not need it’s benefits.
Async.outOfOrder {
await Task { /* ... */ }
await Task { /* ... */ }
}
  • Debugging / pausing: You can only see the thread call stack, you can’t see the task count or the task tree that a debugger breakpoint is currently in. You have threads & queues on the debugger pane, add a third ‘tasks’ versions that lets you see this dependency tree so we can understand what is going on.

SwiftUI

State Lifecycle Control and Inspectability

  • We should be able to answer questions like: Why did this object get created 5 times or 1 time? How do I control the state lifecycle of this object and understand how it is working?
  • Linting: Warnings for not making a @State or @StateObject private, or setting it directly can help people in using it properly. This should be a hard warning from the compiler but this very bad behavior has no warnings whatsoever.
  • SwiftUI presents itself as a mutable system of sorts with @State or @StateObject, when it actually is not and is acting like a pure stateless function under the hood. Exposing the AttributeGraph underneath and how it works can help reduce the magic.
  • Consider exposing the source of AttributeGraph and SwiftUI so we can dive into it with breakpoints. It doesn’t have to be actual OSS.

Debuggability

Make it easy to answer: “Why did this change happen in this order?”

  • With UIKit, you usually could determine with a breakpoint why this view got updated with a new value by putting a breakpoint on the property set call of the view, and that set call would only hit once in normal circumstances and go to the root modifier up the call stack. If a dispatch call on another thread caused it on your thread, you would see that call stack in the debugger too with all of it’s jumps.
  • In SwiftUI, there is no real reliable way to do this, and you often do not see the piece of code that edited a binding value get modified in the breakpoint for the creation of your state view, and often that gets called 5 times for no discernible reason, breaking the chain of causality in the debugger.

What I estimate what is causing the disjoint behavior:

  • Main Thread Event Loop Cycle 1: Modify variable in ObservedObject
  • Cycle 2: AttributeGraph checks it’s dependency graph for any ‘dirty’ nodes, marks all downstream nodes as dirty
  • Cycle 3: AttributeGraph remakes View structs down a tree with new values (your breakpoint hits here, with no connection to what happened in cycle 1, you don’t know what caused your view to change anymore, and which upstream dependency it came from)
  • SwiftUI encourages something I call the ‘side effect rube goldberg machine’. In UIKit, you directly modify your thing you want to change. In SwiftUI, you modify a state variable, and then the black box SwiftUI rube goldberg machine finds your View you want to change via AttributeGraph and then modifies it on your behalf as a side effect. I estimate this creates a lot issues in debugging because of it’s lack of inspectability and how it disjoints a lot of actions.

(Non)Determinism

  • How can I reliably control when certain objects get created, this is especially a problem with things like NavigationLinks.

Navigation View Mutability / Feature Backporting

  • You can’t edit the navigation view, so if you want to remove previous items in a navigation stack, ex: “login → push onboarding (and remove login so you cant go back)” you have to do a subview swap instead and then animate a side transition to make it look like a navigation push currently.
  • This is fixed in iOS 16 with NavigationStack, but we can’t use it due to being at minimum version of iOS 15, and ~%5 of users being on iOS 15 consistently. Backporting would solve this for us.

Collection View Performance

  • It’s much easier to make a performant collection view in UIKit vs. SwiftUI due to easily inducing non-lazy behavior by mistake in SwiftUI compared to UIKit.

View Debugger Inspectability

Why does SwiftUI create a huge deep view tree for 3 labels in a list with 3 toggles?!
  • This is a lot better in Xcode 15, now your SwiftUI view names show up in the graph, but SwiftUI still creates significantly more complicated view debugger states than UIKit does. There is a lot of empty container states that create a lot of noise, a way to compress and hide them would help in understanding, kind of like the debugger hiding system call stack frames as a button option.
  • A lot of sentry and InstaBug view profiling is messed up because of this, the names are not meaningful or helpful

Hidden Magic / Unlabeled Functionality

  • Fundamentals of SwiftUI such as identity are hidden by design. StateObject & State honestly should be separated systems idependent of SwiftUI vs. things associated with a view via AttributeGraph.
  • What objects are created lazy or not is not obvious. @StateObject should be force tagged as lazy so people know it’s not eager initialized for example.

Defaults & Examples encourage bad practices

  • SwiftUI via @StateObject encourages embedding business logic heavy objects into a View hierarchy. Examples that encouraged fully separating business state & logic in a separate class based state hierarchy and then injecting it from the top into a SwiftUI View tree as the default example would significantly reduce people doing it the wrong way by default and using a lot of SwiftUI foot guns.

Last Article

This is the last article of a series of blog posts where we go into:

--

--