12

When a SwiftUI View binds to an ObservableObject, the view is automatically reloaded when any change occurs within the observed object - regardless of whether the change directly affects the view.

This seems to cause big performance issues for non-trivial apps. See this simple example:

// Our observed model
class User: ObservableObject {
    @Published var name = "Bob"
    @Published var imageResource = "IMAGE_RESOURCE"
}


// Name view
struct NameView: View {
    @EnvironmentObject var user: User
    
    var body: some View {
        print("Redrawing name")
        return TextField("Name", text: $user.name)
    }
}

// Image view - elsewhere in the app
struct ImageView: View {
    @EnvironmentObject var user: User
    
    var body: some View {
        print("Redrawing image")
        return Image(user.imageResource)
    }
}

Here we have two unrelated views, residing in different parts of the app. They both observe changes to a shared User supplied by the environment. NameView allows you to edit User's name via a TextField. ImageView displays the user's profile image.

Screenshot

The problem: With each keystroke inside NameView, all views observing this User are forced to reload their entire body content. This includes ImageView, which might involve some expensive operations - like downloading/resizing a large image.

This can easily be proven in the example above, because "Redrawing name" and "Redrawing image" are logged each time you enter a new character in the TextField.

The question: How can we improve our usage of Observable/Environment objects, to avoid unnecessary redrawing of views? Is there a better way to structure our data models?

Edit:

To better illustrate why this can be a problem, suppose ImageView does more than just display a static image. For example, it might:

  • Asynchronously load an image, trigged by a subview's init or onAppear method
  • Contain running animations
  • Support a drag-and-drop interface, requiring local state management

There's plenty more examples, but these are what I've encountered in my current project. In each of these cases, the view's body being recomputed results in discarded state and some expensive operations being cancelled/restarted.

Not to say this is a "bug" in SwiftUI - but if there's a better way to architect our apps, I have yet to see it mentioned by Apple or any tutorials. Most examples seem to favor liberal usage of EnvironmentObject without addressing the side effects.

3
  • This is the whole point behind ObsarvableObjects and EnviromentObjects. Structs are very ligh-weight and the fact that they are reinitialized and reevaluated is expected. There is also quite complex diffing going on behind the scenes, so unless you abuse AnyViews most things won't get redrawn. In your example it is only the TextField that gets redraw, everything else is just reevaluated. Commented Nov 25, 2019 at 20:21
  • What happens is that views affected by the changes are reevaluated. The result is then diffed and compared with the previous version of views. Only if something changes in the view definition, it gets drawn to the screen. Generating views and comparing them is rather simple and can be done very fast. Commented Nov 25, 2019 at 20:44
  • @LuLuGaGa I've edited my question to give some better examples. I understand that diffing is generally fast, these are some cases it can't seem to handle correctly. Commented Nov 25, 2019 at 21:01

2 Answers 2

14

Why does ImageView need the entire User object?

Answer: it doesn't.

Change it to take only what it needs:

struct ImageView: View {
    var imageName: String

    var body: some View {
        print("Redrawing image")
        return Image(imageName)
    }
}

struct ContentView: View {
    @EnvironmentObject var user: User

    var body: some View {
        VStack {
            NameView()
            ImageView(imageName: user.imageResource)
        }
    }
}

Output as I tap keyboard keys:

Redrawing name
Redrawing image
Redrawing name
Redrawing name
Redrawing name
Redrawing name
Sign up to request clarification or add additional context in comments.

3 Comments

Ok this is very interesting - if we use your example and add a log to ContentView, we get repeated "Redrawing content" & "Redrawing name". If ContentView's body is recomputed, this means ImageView is also being reinitialized. How can the struct be reinitialized, but its body not be recomputed?
In SwiftUI, body is essentially a function with one input (self) and one output (some View). It's supposed to be a pure function, meaning its output depends only on its input (not on any global variables) and it has no side effects. Based on this assumption of purity, SwiftUI knows that if self doesn't change, its body cannot change either.
In this case, self is an ImageView, which is a struct with one property, imageName. So if self.imageName doesn't change, self.body cannot change either.
4

A quick solution is using debounce(for:scheduler:options:)

Use this operator when you want to wait for a pause in the delivery of events from the upstream publisher. For example, call debounce on the publisher from a text field to only receive elements when the user pauses or stops typing. When they start typing again, the debounce holds event delivery until the next pause.

I have done this little example quickly to show a way to use it.

// UserViewModel
import Foundation
import Combine

class UserViewModel: ObservableObject {
  // input
  @Published var temporaryUsername = ""

  // output
  @Published var username = ""

  private var temporaryUsernamePublisher: AnyPublisher<Bool, Never> {
    $temporaryUsername
      .debounce(for: 0.5, scheduler: RunLoop.main)
      .removeDuplicates()
      .eraseToAnyPublisher()
  }


  init() {
    temporaryUsernamePublisher
      .receive(on: RunLoop.main)
      .assign(to: \.username, on: self)    
  }
}

// View
import SwiftUI

struct ContentView: View {

  @ObservedObject private var userViewModel = UserViewModel()

  var body: some View {
    TextField("Username", text: $userViewModel.temporaryUsername)
  }
}

struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    ContentView()
  }
}

I hope that it helps.

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.