SwiftUI performance tips

Optimizing performance is definitely one of the most interesting topics, not only on iOS, but software development in general. There are many thought provoking challenges, accompanied with a detective work to find the places that stand between your product and the optimal and performant user experience.

SwiftUI has been around for almost 3 years now, and during this period working with it, I’ve noticed few groups of developer mistakes (both mine and from others) that can impact its performance. In this post, we will look at these pitfalls, and their potential solutions.

We don’t want our apps to behave like sloths.

Animation hitches

First, let’s see what makes a smooth user experience from a performance perspective. On iOS, we use animation hitches as a metric for measuring the performance of an app. A hitch is basically a frame that’s displayed on the screen later than it was supposed to be shown. The longer the hitch time, the more noticeable are the glitches and hangs that make a poor user experience. For example, if you have a hitch of 1 second, it means that this frame is shown 1 second later than expected, thus making the hang easily visible by the users. The hitches can appear in the commit phase or in the render phase.

Great starting point for learning more about animation hitches are the following Apple Talks:

In order to improve our app’s performance, we need to reduce these animation hitches to a minimum (or even better, get rid of them altogether).

Heavy work on the main thread

As a rule of thumb, no matter whether we are using SwiftUI or UIKit, we should avoid doing heavy work on the main thread. This is one of the most common sources of app sloweness. It’s interesting that these mistakes are subtle and sometimes hard to spot, especially if the method we are using is synchronous (without a callback, future or an async function). SwiftUI frequently redraws its views, so having a slow operation referenced somewhere in the view definition will definitely have an impact on performance.

Computed variables

Let’s say we have a list that presents some data, and an optional attachment icon (indicating that there’s an attachment that can be opened on tap).

struct CustomModel: Identifiable {
    let id: String
    let attachmentURL: URL?

    var attachment: Attachment? {
        if let attachmentURL = attachmentURL {
            do {
                let attachmentData = try Data(contentsOf: attachmentURL)
                let attachment = try JSONDecoder().decode(Attachment.self, from: attachmentData)
                return attachment
            } catch {
                return nil
            }
        }

        return nil
    }

    var hasAttachment: Bool {
        attachment != nil
    }
}

There are two computed variables here, one that loads the attachment and another one that checks if there’s an attachment. We use the second one in our view to determine if we should show the attachment icon.

Can you spot the bottleneck here? The problem is that the hasAttachment method will be called many times by SwiftUI. It returns just a boolean, but under the hood, it uses the whole attachment loading logic, which when applied on many rows has a significant performance impact.

In this case, we don’t need to load the whole attachment to check if it has an attachment. We can just check if the attachmentURL is nil. If we want to validate that it’s also a valid attachment before showing the icon, we can add a @Published dictionary with all the attachments in our view display logic (e.g. a ViewModel), do the loading work in a background queue, and update the dictionary on the main queue when we’re done. This will update the view accordingly, and it will avoid unecessary additional loads of the attachment.

Database access

The same underlying issue can appear in many other different scenarios, like database access on the main queue. Imagine that you are building an app that displays stats data in a graph or something similar. This data can be available weekly, monthly, yearly etc. Not only this data needs to be loaded from the database, but it also needs to be grouped daily, monthly or in any other way, and this requires further computations. If you are using the ViewContext in CoreData, which means you will load them on the main thread, you might notice some performance implications. If you load them in a background context, and present the loaded objects in the views, your app will crash since CoreData expects to be run on a single thread.

There are two solutions here – either you can use NSManagedObjects objectId to pass a managed object from one thread to another, or even better, map the object to a different view display model (simple struct with the data you need and no CoreData dependency). With the second approach, you get the bonus of hiding the underlying CoreData implementation and potentially changing the implementation in the future, without modifying the view layer.

To sum up, be aware of the places that can take more time, since they are not always straightforward to find. What really helps here is the Instruments app in Xcode. First, run the Animation Hitches option in Instruments, detect the hitches and then open the Time Profiler to see which parts of the code took the most time to complete in that time frame.

Caching

Add a caching layer where possible, especially for the more static parts. For example, if you have a chat experience and you don’t expect many changes of the sender’s name or avatar, you can cache this data for certain amount of time. But more dynamic parts, such as reactions or replies, are critical for a responsive chat experience and those shouldn’t be cached. It’s a trade-off that needs to be properly analysed, depending on the app’s use-case.

Frequent redraws

Views structure

Another common source of lagginess in SwiftUI are frequent (and unnecessary) redraws, especially in the bigger, container like components. For example, consider the following code:

class ViewModel: ObservableObject {
    @Published var elements = [Element]()
    @Published var images = [String: UIImage]()

    func image(for id: String) -> UIImage {
        if let image = images[id] {
            return image
        }

        loadImage(for: id) { [weak self] image in
            self?.images[id] = image
        }

        return UIImage.placeholder
    }
}

struct ListView: View {

    @StateObject var viewModel = ViewModel()

    var body: some View {
        LazyVStack(spacing: 0) {
            ForEach(viewModel.elements) { element in
                ListItem(
                    element: element,
                    image: viewModel.image(for: element.id)
                )        
            }
        }
    }

}

It all seems pretty standard here. We are using an in-memory image cache and whenever we receive new value, we publish it and the corresponding view redraws. However, all the others are redrawn too. Whenever a new image is downloaded, the @Published images is updated and it fires a new event. The ListItem uses the ViewModels image(for:) method, which depends on this published variable. So it’s called for all views whenever a new image is downloaded and this can cause sloweness with the many not needed view updates.

How can we solve this? There are few options for this. For example, you can move the image loading logic in a separate helper object and pass that one directly to the ListItem, which will depend on one @Published image. Alternatively, if you want to go with an approach similar to the one above, you don’t have to publish all changes. You can remove the @Published property wrapper and manually call objectWillChange on certain periods of time (e.g. every 0.5 seconds). This will queue a lot of changes and publish them all at once, thus improving the scrolling experience.

private var scheduledUpdate = false

var images = [String: UIImage]() {
    willSet {
        if !scheduledUpdate {
            scheduledUpdate = true
            DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
                self?.objectWillChange.send()
                self?.scheduledUpdate = false
            }
        }
    }
}

Unnecessary data updates

Let’s see a different use-case. Imagine you are building a custom swiping functionality and you want to keep track of the view that’s being currently swiped (for example, swiping to reply in a chat experience). When the gesture reports a new dragging state, we are calling a method dragChanged. Also, the swipedMessageId is a @Binding that will cause view redraw on every state change.

private func dragChanged(to horizontalTranslation: CGFloat) {             
    if horizontalTranslation != 0 {
        swipedMessageId = message.id
        offsetX = horizontalTranslation
    } else {
        offsetX = 0
    }
}

If you try out solution similar to this, you will notice that while you drag the view, a lot of frames are dropped. The reason for this is that we always update the swipedMessageId, which causes many unnecessary view redraws. We need to update the value only when it’s changed, so a simple check will do the trick:

private func dragChanged(to horizontalTranslation: CGFloat) {             
    if horizontalTranslation != 0 {
        if swipedMessageId != message.id {
            swipedMessageId = message.id
        }
        offsetX = horizontalTranslation
    } else {
        offsetX = 0
    }
}

If you want to dig deeper on understanding how and when SwiftUI view decides to redraw views, I strongly recommend Donny’s article.

Using wrong SwiftUI components

Another common mistake is misusing some of the native SwiftUI views.

Usage of AnyView

One of these is definitely the usage of AnyView, which is a type-erased view.

For example, you have some container view, that allows a slot that can be injected from the outside. It can be very tempting to put AnyView here, since you will avoid all the generics dance.

struct ContainerView {
	
	var injectedView: () -> AnyView

	var body: View {
		VStack {
			// view content
			injectedView()
		}
	}

}

However, in some cases, especially in lists with a lot of data, this can have a performance impact. The reason is that SwiftUI relies on the type of the views to compute the diffing. If it’s AnyView (which is basically an unknown type), it will not know how to compute the diffing, and it will just redraw the whole view, which is not really efficient. You can find more details about SwiftUI’s diffing mechanism in this great WWDC talk.

We can solve this by using Generics. If there are many views that can be injected, you can create e.g. a View Factory, which will contain the generic types, while the Container view would be generic only over the View Factory.

View containers

A different flavor of the same mistake is the usage of lazy containers (LazyHStack, LazyVStack) and their non-lazy counterparts (VStack, HStack). For example, if you have a VStack embedded in a ScrollView, with many rows, you will notice performance issues, especially when the view first appears. The reason for this is that the VStack will render all the views at once, even the ones that are not currently visible. Using LazyVStack is definitely more appropriate here.

However, the lazy versions make sense mostly for the bigger container views, like in the example above. If you set everything as lazy (e.g. the rows in your scrollable list), SwiftUI will have hard time figuring out the frames on demand, and it might even not only impact performance, but also mess up the drawing of the views.

GeometryReader

In the iOS community, there are frequent debates about the GeometryReader, as a potentially expensive container view. My thoughts on this topic are to use it only when you really need it. For example, if you want to set the view as big as the size reported by the GeometryReader, you can accomplish the same thing either with a spacer, or a frame with max width / height set to .infinity. However, if you want to check how far the user has scrolled into a list, then you could really make use of it.

Offscreen rendering

As explained in this nice Apple Tech Talk, sometimes the rendering of the shapes can be “offscreen”. This means that at some point the GPU will draw something in a temporary place, other than the final texture. This can cause performance penalties. Things to look for are e.g. applying shadows to views.

Detecting these issues can be tricky. Fortunately, Apple has provided tools to simplify this process – you can use Xcode’s Optimization Opportunities. There are nice suggestions to avoid rendering offscreen and gain quick performance wins. When your app is running in debug mode, open the Debug View Hierarchy and make sure you have enabled “Show Optimization Opportunities” and “Show Layers”, like in the image below.

On the left, you can see several suggestions that can be quick wins for your app’s performance.

Heavy media data

If you are working with images, videos, giphies or other types of media, you need to be considerate about the sizes of these elements and how they fit in their view containers. For example, having a social feed experience with many large images will have impact on performance, even if you got everything else right.

The ideal solution here would be for your CDN to return smaller size media based on the space you have allocated for that file. For example, if you have a user avatar that’s displayed in a 40×40 pt slot, you would expect an image size in that region.

However, if the backend doesn’t support this and you can only receive one image size, you should do image resizing on the iOS client, on a background thread. There are several options here, either to do it in a custom way, or use something like NukeUI, which supports resizing of media after it’s downloaded.

Using Instruments

As already mentioned, Instruments help a lot in detecting performance issues. When starting out with the tool, it can be overwhelming at first, with a lot of data available and lower level system calls.

For debugging SwiftUI performance issues, I usually use the following profiling templates:

  • Animation hitches – for detecting the hitches.
  • SwiftUI – for counting how many times the view has been redrawn.
  • Time Profiler – for checking which methods took the most time to execute.

While looking at the time profiler, it helps if you invert the call tree, so you see the last methods being called, first (the ones most likely to be causing the performance issue). The best way to get started is by trying things out. You can even make a performance issue on purpose, and try to find it with instruments. It will help you to get started using it.

Conclusion

Most of the time, the performance issues that I’ve encountered were in one of the above groups. However, this is a live list, that will be updated if new patterns start appearing.

In any case, most of the time the issues were in the developer’s code, and not in SwiftUI. Of course, SwiftUI is not perfect and still has some things that need to be improved, but it’s definitely an amazing and powerful way of building user interfaces on iOS.

What are your thoughts on this topic? Have you had any performance issues with SwiftUI so far? If yes, how did you fix them? Let me know your thoughts on Twitter, my handle is @mitrevski.

Thanks for reading!

2 Comments

  1. “simple struct with the data you need and no CoreData dependency ” how to implementation?can you give some code to show?

    Like

    1. Yes, sure. For example, if you have a sample CoreDate entity, e.g. for payments:
      class Payment: ManagedObject {
      var amount: Double
      // other properties
      }
      Then, instead of using this object directly in your views, you can create another simple struct with the data you need:
      struct PaymentInfo {
      var amount: Double
      }
      And you can return this one instead of the Core Data object, by mapping it first.
      extension Payment {
      func toPaymentInfo() -> PaymentInfo {
      PaymentInfo(amount: self.amount)
      }
      }

      Like

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s