In the world of UIKit, developers can inject their will into each node of the view controller’s life cycle as if they were gods, with a host of hooks provided by the framework (viewDidLoad, viewWillLayoutSubviews, etc.). In SwiftUI, the system takes back those rights and the developer basically loses control over the view’s life cycle. Many SwiftUI developers have experienced unexpected behavior in the view life cycle (views are constructed multiple times, onAppear is out of control, etc.).

This article introduces the author’s understanding and research on SwiftUI view and SwiftUI view lifecycle for discussion.

Before we go into more detail, let’s be clear about two points:

  • SwiftUI does not have a view or view life cycle corresponding to UIkit
  • Avoid making assumptions about the timing and frequency of SwiftUI view creation, body calls, layout and rendering, and so on

The original post was posted on my blog wwww.fatbobman.com

Welcome to subscribe my public account: [Elbow’s Swift Notepad]

SwiftUI view

In SwiftUI, views define a piece of the user interface and are organized together as a view tree, which SwiftUI parses to create the appropriate rendering.

Within SwiftUI it creates at least two types of trees — type trees and view value trees

The type tree

Developers define the user interface they want to present by creating structures that conform to the View protocol. The body property of a structure is a large type with many generic parameters, and SwiftUI organizes these types into a type tree. It contains all view-compliant types that may appear on screen during the app’s life cycle (even though they may never be rendered).

Such as:

struct ContentView:View{
    var body: some View{
        Group {
            if true {
                Text("hello world")}else {
                Text("Won't be rendered")}}}}Copy the code

The code above builds the following type tree:

Group<_ConditionalContent<Text.Text>>
Copy the code

Even if Text(“Won’t be Rendered “) is never displayed, it’s still included in the type tree.

The type tree is fixed after compilation and does not change for the life of the app.

Tree view value

In SwiftUI, views are functions of state.

Developers declare interfaces using structs that comply with the View protocol. SwiftUI calls the body of the struct instance to get the corresponding View value. Body is calculated according to the user’s interface description and the corresponding dependency (Source of Truth).

When rendering for the first time after app runs, SwiftUI will create a type instance based on the type tree. The body of the instance calculates the view value based on the initial state and organizes it into a view value tree. Which instances need to be created depends on the state at that time. Each state change may result in a different view value tree (the view value of a node may change, or the structure of the view value tree may change greatly).

When the State changes, SwiftUI will generate a new view value tree (the nodes of Source of Truth that have not changed will not be recalculated, and the old value will be directly used) and compare it with the old view value tree. SwiftUI will rearrange and render the changed parts. Replace the old view value tree with a newly generated view value tree.

The view value tree usually stores only what is needed for the current layout and rendering (in some cases, a few view values that do not participate in the layout and rendering are cached) and changes over the life of the app as the State changes.

What is a view

Developers are more used to thinking of a structure or instance of a structure that complies with the View protocol as a View, whereas for SwiftUI’s perspective, the node content in the View value tree is what it thinks of as a View.

SwiftUI View lifecycle

Most articles describing the SwiftUI view life cycle describe the life cycle of a view as the following chain:

Initialize the view instance — register data dependencies — call the body to calculate the result — onAppear — destroy the instance — onDisapper

Given the above definition of a view, the problem with this description of the lifecycle is that the two view types are treated as one, forcing the lifecycle of the different types of views into a logical line.

In WWDC 2020’s Data Essentials in SwiftUI feature, Apple specifically points out that the life cycle of a view is separate from the life cycle of the structure that defines it.

Therefore, we need to treat the developer view and SwiftUI view separately and analyze their life cycles independently.

The lifecycle of the structure instance conforming to the View protocol

Initialize the

It’s easy to tell that SwiftUI has created an instance of a structure by adding a print command to the constructor of the structure. If you take a closer look at the constructors’ prints, you’ll see that instances of the structure are created more often and more often than you might expect.

To get the body value you must first create an instance, but it is not necessary to get the body value!

  • When SwiftUI generates the view value tree, when it finds no corresponding instance, SwiftUI creates an instance to get its body result.
  • When generating a new view value tree, SwiftUI may create a new instance even if there is already a corresponding instance that has not been destroyed. However, SwiftUI does not necessarily get the body result from the new instance. If the previous instance registered a data dependency, the view value tree may still get the body result from the original instance.
  • In NavigationView, if a static target view is used in NavigationLink, SwiftUI will create instances for all target views, whether accessed or not.
  • In TabView, SwiftUI creates instances for all tab-corresponding views at the beginning.

There are many similar cases above. This explains why many developers encounter views that are initialized multiple times for no apparent reason. This could be a case where SwiftUI creates a new instance after destroying the first instance, or it could be a case where SwiftUI creates a new instance without destroying the first instance.

In short, SwiftUI will create as many instances as it needs at any time. The only thing developers can do to accommodate this feature of SwiftUI is to keep the constructors of the structure as simple as possible. Do not do anything more than necessary parameter Settings. This way, even if SwiftUI creates redundant instances, it doesn’t burden the system.

Register data dependencies

In SwiftUI, state (or data) is what drives the UI. In order for a view to reflect changes in state, it needs to register its dependencies. Although we can declare dependencies with specific property wrappers (such as @State, @StateObject, and so on) in the constructor of a structure, I don’t think that registering data dependencies happens during initialization. There are three main reasons:

  • The burden of registration dependencies is not trivial. For example, @ObservableObject needs to reallocate the heap every time a dependency is created, which is costly and may run the risk of losing data. Registering dependencies in a constructor does not meet the guidelines for creating a lightweight constructor.
  • In addition to using property wrappers, SwiftUI also provides dependency registration for views in onReceive, onChange, onOpenURL, and onContinueUserActivity. These four methods must be parsed through the contents of the body.
  • As mentioned below, no matter how many instances are created during the view life cycle of the view value tree, only one copy of the dependency remains. When a new instance is used, SwiftUI still associates the new instance with the original dependency.

For these reasons, the time to register a view dependency should be after initialization and before the body result is obtained.

Call body to compute the result

We can be notified when SwiftUI calls the body of our instance by adding code like this:

let _ = print("update some view")
Copy the code

Calculating the body value is done on the main thread, and SwiftUI must do all the calculation, comparison, layout, and so on within a single render cycle. To avoid UI congestion, the body should be designed as a pure function that creates a simple view description and offloads complex logic operations and side effects to other threads (such as scheduling logic to other threads in a Store or dispatching tasks to other threads in a view using Task).

The destruction

Structs do not provide a destructor method, so we can observe the approximate destruction time of a struct instance with code like the following:

class LifeMonitor {
    let name: String
    init(name: String) {
        self.name = name
        print("\(name) init")}deinit {
        print("\(name) deinit")}}struct TestView:View {
    let lifeMonitor:LifeMonitor
    init(a){
        self.lifeMonitor = LifeMonitor(name:"testView")}}Copy the code

SwiftUI does not follow a uniform pattern in handling the destruction of structure instances.

For example, code like this:

ZStack {
    ShowMessage(text: "1")
        .opacity(selection = = 1 ? 1 : 0)
    ShowMessage(text: "2")
        .opacity(selection = = 2 ? 1 : 0)}struct ShowMessage:View{
    let text:String
    let lifeMonitor:LifeMonitor
    init(text:String){
        self.text = text
        self.lifeMonitor = LifeMonitor(name: text)
    }

    var body: some View{
        Text(text)
    }
}
Copy the code

Each time Selection switches between 1 and 2, SwiftUI recreates two new instances and destroys the old one.

And the following code:

TabView(selection: $selection) {
    ShowMessage(text: "1")
        .tag(1)
    ShowMessage(text: "2")
        .tag(2)}Copy the code

SwiftUI will create only two ShowMessage instances initially, and TabView will use only those two instances throughout regardless of selection.

SwiftUI may destroy instances and create new ones at any time, or it may retain instances for longer periods of time. In general, avoid making assumptions about the timing and frequency of instance creation and destruction.

The life cycle of a view in a view value tree

Survival time

The lifetime of a View in a View value tree is much easier to determine than the lifetime of a structure instance conforming to the View protocol, which is completely uncertain.

Each view value has a corresponding identifier, and together the value and identifier represent a particular view on the screen. When the Source of Trueh changes, the view value changes as well, but because the identifier does not change, the view will still exist.

Typically, SwiftUI creates a view in the view value tree when it needs to render an area of the screen or when it needs data from that area to work with the layout. The view is destroyed when it is no longer needed for layout or rendering.

In rare cases, SwiftUI keeps views in the view value tree for efficiency reasons, even though they are not required to participate in layout and rendering at the moment. For example, in List and LazyVStack, SwiftUI retains Cell views’ data until the List or LazyVStack is destroyed, even if they scroll out of the screen after creation and do not participate in layout and rendering.

@State and @StateObject, their life cycle is the same as the life cycle of the view, which is the view in the view value tree. If you are interested, you can use @stateObject to accurately determine the view’s life cycle.

OnAppear and onDisappear

To be precise, a view in a view value tree, as a value, has no nodes in its life cycle other than life and death. However, onAppear and onDisappear are indeed related to it.

It is important to note that closures in onAppear and onDisappear are not the view they wrap around, but their parent view.

OnAppear and onDisappear are described in the official SwiftUI documentation as actions to be performed when this view appears and actions to be performed when this view disappears. This description is close to how the two modifiers behave in most scenarios. So, people tend to think of it as the SwiftUI version of viewDidAppear and viewDidDisappear under UIKit, and think that they only happen once in their life cycle. But if you look at their timing in all directions, you’ll find that their behavior doesn’t quite match that description. For example, onAppear and onDisappear both violate most cognition in the following scenarios:

  • In ZStack, onAppear is triggered even if the view is not displayed, and onDisappear is not triggered even if it disappears. The view remains in the continuing state
ZStack {
    Text("1")
        .opacity(selection = = 1 ? 1 : 0)
        .onAppear { print("1 appear") }
        .onDisappear { print("1 disappear")}Text("2")
        .opacity(selection = = 2 ? 1 : 0)
        .onAppear { print("2 appear") }
        .onDisappear { print("2 disappear")}}// Output
2 appear
1 appear
Copy the code
  • In List or LazyVStack, onAppear is triggered when the Cell view enters the screen, onDisappear is triggered when the Cell view scrolls off the screen, and onAppear and onDisappear can be triggered multiple times during the lifetime of the Cell view
ScrollView {
    LazyVStack {
        ForEach(0..<100) { i in
            Text("\(i)")
                .onAppear { print("\(i) onAppear") }
                .onDisappear { print("\(i) onDisappear")}}}}Copy the code
  • In ScrollView + VStack, onAppear is triggered even if the Cell view is not displayed on screen
ScrollView {
    VStack {
        ForEach(0..<100) { i in
            Text("\(i)")
                .onAppear { print("\(i) onAppear") }
                .onDisappear { print("\(i) onDisappear")}}}}Copy the code

There are many similar examples, such as TabView, or setting frame to zero, etc.

As you can see, onAppear and onDisappear can be triggered multiple times during the lifetime of the view. OnAppear and onDisappear trigger conditions are not based on whether they appear or are seen.

Therefore, I think onAppear and onDisappear should be triggered by whether a view participates in or influences the layout of its parent view. It makes perfect sense to use this condition to explain the above situation.

  • In ZStack, even if the layer is hidden, the hidden layer will inevitably affect the layout planning of the superview ZStack. Similarly, when the display layer is switched to a hidden layer, that layer is still involved in the layout, so all layers of the ZStack trigger onAppear at the beginning, but not onDisappear.

  • In List and LazyVStack, SwiftUI keeps its view in the view value tree for efficiency reasons even if the Cell view is removed from display. Therefore, onAppear is triggered when the Cell view is in the display range (affecting the container layout), and onDisappar is triggered when the Cell view is out of the display range (not affecting the container layout). It can be triggered repeatedly during its lifetime.

    In addition, since the layout logic of List and LazyVStack is different (the container height of List is fixed, and the container height of LazyVStack is not fixed, and it is estimated downward), the timing of onDisappear is different between the two. List fires on both sides, LazyVStack only fires on the bottom.

  • In ScrollView + VStack, even though the Cell view is not in the visible area, it participates in the layout of the container from the very beginning, so onAppear is triggered at the beginning of creation, but no matter how you scroll, all Cell views will always participate in the layout. So it doesn’t trigger onDisappear.

The parent view calls closures in onAppear and onDisappear precisely on the basis that the view affects its own layout, which is why the scope of these modifiers is the parent view and not the view itself.

task

There are two forms of task, one similar to onAppear and one similar to onAppear + onChange (see onChange for SwiftUI).

The version that’s similar to onAppear, you can think of it as the asynchronous version of onAppear. If the execution time of the task is short, the following code can achieve the same effect:

.onAppear {
    Tesk{
        .}}Copy the code

Many sources assume that tasks and views have the same life cycle, which is not accurate. It would be more accurate to say that when the view is destroyed, a task cancellation signal is sent to the closure in the Task decorator. It is still up to the closure in the Task to decide whether to cancel.

struct ContentView: View {
    @State var show = true
    var body: some View {
        VStack {
            if show {
                Text("Hello, world!")
                    .padding()
                    .task{
                        var i = 0
                        while !Task.isCancelled {  // Try to change this line to while true {
                            try? await Task.sleep(nanoseconds: 1 _000_000_000)
                            print("task:",i)
                            i + = 1
                        }
                    }
                    .onAppear{
                        Task{
                            var i = 0
                            while !Task.isCancelled {
                                try? await Task.sleep(nanoseconds: 1 _000_000_000)
                                print("appear:",i)
                                i + = 1}}}}Button("show"){
                show.toggle()
            }
        }
    }
}
Copy the code

The relationship between the two life cycles

In this section, we will look for links between the two life cycles and summarize them.

For convenience, [View protocol-compliant structure instance] is referred to as [Instance] and [View in the View value tree] is referred to as [View].

  • An instance must be created before a view can be generated
  • The instance created is not necessarily used to generate the view
  • Multiple instances can be created during the life of a view
  • Instances can be destroyed at any time during the view’s life cycle
  • At least one instance is always maintained throughout the view’s life cycle
  • The first instance that generates the view value completes the dependency creation
  • During the lifetime of a view, there is only one copy of a dependency
  • During the life of a view, no matter how many instances are created, only one instance can be connected to a dependency at a time
  • The dependency is the Source of Truth for the view

Understand the meaning of the SwiftUI view lifecycle

SwiftUI tries to downplay the concept of view life cycle, and in most scenarios it does achieve its design goals. Developers can make SwiftUI code work well on a daily basis even if they don’t know the above. But a deeper understanding of the view’s lifecycle can help developers improve code execution in specific situations. A few examples will follow.

Lightweight constructor

Currently, many SwiftUI developers have noticed the problem of struct instances being created multiple times. Especially since the WWDC 2020 project explicitly told you to create the lightest possible structure constructor, developers have shifted much of the data preparation work that was done in the constructor to onAppear.

This significantly alleviates the efficiency issues associated with multiple instance creation.

Make complex tasks perform only once

However, onAppear or Task is not executed only once, so how do you ensure that some of the heavier tasks are executed only once on the page? This problem can be solved by taking advantage of the fact that the @state lifecycle is consistent with the view lifecycle.

struct TabViewDemo1: View {
    @State var selection = 1
    var body: some View {
        TabView(selection: $selection) {
            TabSub(idx: 1)
                .tabItem { Text("1")}TabSub(idx: 2)
                .tabItem { Text("2")}}}}struct TabSub: View {
    @State var loaded = false
    let idx: Int
    var body: some View {
        Text("View \(idx)")
            .onAppear {
                print("tab \(idx) appear")
                if !loaded {
                    print("load data \(idx)")
                    loaded = true
                }
            }
            .onDisappear{
                print("tab \(idx) disappear")}}}// OutPut
tab 1 appear
load data 1
tab 2 appear
load data 2
tab 1 disappear
tab 1 appear
tab 2 disappear
tab 2 appear
tab 1 disappear
tab 1 appear
Copy the code

Reduce the view’s computation

As mentioned in the introduction to the view value tree above, when SwiftUI reconstructs the tree, if the Source of Truth of a node (view) in the tree does not change, it will not recalculate and directly use the old value. Using this feature, we can split the definition of certain areas in the View structure into node-recognized forms (views created by the structure conforming to the View protocol) to improve the View tree refresh efficiency.

struct UpdateTest: View {
    @State var i = 0
    var body: some View {
        VStack {
            let _ = print("root update")
            Text("\(i)")
            Button("change") {
                i + = 1
            }
            // Circle is recalculated on every refresh
            VStack {
                let _ = print("circle update")
                Circle()
                    .fill(.red.opacity(0.5))
                    .frame(width: 50, height: 50)}}}}// Output
root update
circle update
root update
circle update
root update
circle update
Copy the code

Break out the Circle

struct UpdateTest: View {
    @State var i = 0
    var body: some View {
        VStack {
            let _ = print("root update")
            Text("\(i)")
            Button("change") {
                i + = 1
            }
            UpdateSubView()}}}struct UpdateSubView: View {
    var body: some View {
        VStack {
            let _ = print("circle update")
            Circle()
                .fill(.red.opacity(0.5))
                .frame(width: 50, height: 50)}}}// Output
root update
circle update
root update
Copy the code

conclusion

SwiftUI is a young framework that is not well understood. As the official documentation and WWDC continue to improve, more and more principles and mechanisms behind SwiftUI will be understood and mastered by developers.

I hope this article has been helpful to you.

The original post was posted on my blog wwww.fatbobman.com

Welcome to subscribe my public account: [Elbow’s Swift Notepad]