Skip to the content.

내가 SwiftUI로 개발하면서 고민했던 내용 정리

Observation - View Model이 여러 번 만들어지는 문제

Xcode 15.2 (15C500b) + iPhone 15 Pro Max 17.2 Simulator + Swift Trunk Development (main, January 8, 2024) 기준

문제

아래 코드를 실행하고 Button을 누르면 init 0이 두 번 찍힘

import SwiftUI

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            CounterView()
        }
    }
}

@Observable
class CounterViewModel {
    var count: Int
    
    init(count: Int) {
        self.count = count
        print("init", self.count)
    }
}

struct CounterView: View {
    @State private var viewModel: CounterViewModel = .init(count: .zero)
    
    var body: some View {
        Button(String(viewModel.count)) { 
            viewModel.count += 1
        }
    }
}

해결

self.count -> self._count로 바꾸면 됨

과정 - Observation Framework의 이해

기본

그냥 문서보셈 Observation

이 글에서 기본적인 설명은 생략

UIKit에서는 쓰지마셈. Public API 만으로는 한계가 있음. 예를 들면 수동으로 cancel하는게 안 됨.

심화

apple/swift - Observation

크게 두 가지로 나뉨

여기서 이게 왜 Thread-safe하냐면

또한 나는 아래처럼 car.name을 호출하는 것 만으로 (= access를 호출하는 것 만으로) Observer를 등록되게 하기 위해 TLS를 사용한 것은 참신하다고 생각한다. Thread-safe하기도 하고.

func render() {
    withObservationTracking {
        for car in cars {
            print(car.name)
        }
    } onChange: {
        print("Schedule renderer.")
    }
}

나머지 내용은 Observer 기능을 구현하기 위한 흔한 코드라 굳이 다룰 필요는 없을 것 같음.

과정 - SwiftUI에서 Observation Framework를 어떻게 다루는가

SwiftUI의 내부가 너무 변칙적이라 분석에 굉장히 애를 먹었다.

부록

Swift Toolchain으로 @_spi(SwiftUI) API 호출해서 UIKit에서 ObservationRegistrar를 옵저빙하는 예시 코드

import UIKit
@_spi(SwiftUI) import Observation

@Observable
class ViewModel {
    var number: Int
    
    init(number: Int) {
        self.number = number
        print(self.number)
    }
}

class ViewController: UIViewController {
    @ViewLoading @IBOutlet var button: UIButton
    var viewModel: ViewModel!
    var viewTracking: ObservationTracking!
    var numberTracking: ObservationTracking!
    
    private func configureViewModel() {
        let viewModel: ViewModel = .init(number: .zero)
        self.viewModel = viewModel
    }
    
    private func observeNumber(viewModel: ViewModel) {
        let accessList: UnsafeMutablePointer<ObservationTracking._AccessList?> = .allocate(capacity: 1)
        pthread_setspecific(.init(0x6a), accessList)
        
        var configuration: UIButton.Configuration = .plain()
        configuration.title = self.viewModel.number.description
        self.button.configuration = configuration
        
        pthread_setspecific(.init(0x6a), nil)
        
        if let scope = accessList.pointee {
            accessList.deallocate()
            let tracking: ObservationTracking = .init(scope)
            
            ObservationTracking._installTracking(
                tracking,
                willSet: nil,
                didSet: { [weak self] tracking in
                    Task { @MainActor [self] in
                        guard let self = self else { return }
                        self.numberTracking?.cancel()
                        var configuration: UIButton.Configuration = .plain()
                        configuration.title = self.viewModel.number.description
                        self.button.configuration = configuration
                        
                        self.observeNumber(viewModel: viewModel)
                    }
                }
            )
            
            self.numberTracking = tracking
        } else {
            accessList.deallocate()
        }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let accessList: UnsafeMutablePointer<ObservationTracking._AccessList?> = .allocate(capacity: 1)
        pthread_setspecific(.init(0x6a), accessList)
        configureViewModel()
        pthread_setspecific(.init(0x6a), nil)
        
        if let scope = accessList.pointee {
            accessList.deallocate()
            let tracking: ObservationTracking = .init(scope)
            
            ObservationTracking._installTracking(
                tracking,
                willSet: nil,
                didSet: { [weak self] tracking in
                    Task { @MainActor [self] in
                        guard let self = self else { return }
                        self.viewTracking?.cancel()
                        self.configureViewModel()
                        self.observeNumber(viewModel: self.viewModel)
                    }
                }
            )
            
            self.viewTracking = tracking
        } else {
            accessList.deallocate()
        }
        
        //
        
        observeNumber(viewModel: viewModel)
    }

    @IBAction func increment(_ sender: Any) {
        viewModel.number += 1
    }
}

SwiftUI - Retain Cycle

Xcode 15.2 (15C500b) + iPhone 15 Pro Max 17.2 Simulator 기준

아래 코드를 실행하고 Present CounterView → Dismiss 버튼을 누르면 CounterViewModel가 deinit 되지 않는다.

import SwiftUI

struct ContentView: View {
    @State var isPresentingSheet: Bool = false
    
    var body: some View {
        Button("Present") {
            isPresentingSheet = true
        }
        .sheet(isPresented: $isPresentingSheet) {
            SecondaryView()
        }
    }
}

final class MyObject: NSObject {
    override init() {
        super.init()
        print("MyObject.init")
    }
    
    deinit {
        print("Never called")
    }
}

struct SecondaryView: View {
    @State var handler: (() -> Void)?
    let myObject: MyObject = .init()
    var body: some View {
        Color.clear
            .task {
                handler = { _ = myObject }
            }
    }
}

해결

myObject만 capture

handler = { [myObject] _ = myObject }

과정

SwiftUI.StoredLocation에서 Retain Cycle이 발생하여, CounterViewModel이 Storage에 계속 붙잡히고 있는 문제다.

Retain Cycle이 발생하는 이유는 handler가 생성될 때의 assembly를 보면

    0x1042c9e7c <+176>: ldr    x0, [sp, #0x20]
    0x1042c9e80 <+180>: str    x2, [x1, #0x10]
    0x1042c9e84 <+184>: str    x3, [x1, #0x18]
    0x1042c9e88 <+188>: str    x4, [x1, #0x20]
    0x1042c9e8c <+192>: str    x5, [x1, #0x28]
->  0x1042c9e90 <+196>: bl     0x1042c9238               ; MyApp.SecondaryView.handler.setter : Swift.Optional<() -> ()> at MyAppApp.swift:44

(lldb) expr -l c -O -- $x4
SwiftUI.StoredLocation<Swift.Optional<() -> ()>>

handler에는 총 5개의 값이 capture되며, 네번째가 StoredLocation다.

즉, StoredLocation는 handler를 capture하고 handler는 StoredLocation를 capture하므로 retain cycle이 발생한다.

iOS 17.0..<17.2의 SwiftUI Presentation에서 Leak

Xcode 15.1 (15C65), iPhone 15 Pro Max 17.0.1 Simulator 기준 (iOS 17.2에서 해결된 SwiftUI 버그)

유명한 버그이기도 하다. 아래 코드에서 Present를 하고 dismiss를 하면 ViewModel의 메모리가 해제되지 않는다.

import SwiftUI

@main
struct MyAppApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

struct ContentView: View {
  @State private var isPresenting: Bool = false

  var body: some View {
      Button("Present") {
        isPresenting = true
      }
    .fullScreenCover(isPresented: $isPresenting) {
        SheetView()
    }
  }
}

struct SheetView: View {
  @Environment(\.dismiss) var dismiss
  private let viewModel: ViewModel = .init()

  var body: some View {
    Button("Dismiss") {
      dismiss()
    }
  }
}

class ViewModel {
  init() {
    print("init")
  }

  deinit {
    print("deinit")
  }
}

해결

과정

Memory Inspector로 보면 AnyViewStorage라는 객체가 ViewModel를 retain하고 있고, AnyViewStorage는 Retain Count를 2~4 정도로 Leak이 걸린다.

아마 SwiftUI에서 내부적으로 Retain Count를 관리하고 있는 것 같은데, 관리가 잘못 되어서 Leak이 난 것으로 의심된다.

이 AnyViewStorage는 _TtGC7SwiftUI29PresentationHostingControllerVS_7AnyView_에서 아래처럼 Mirror를 활용하면 가져올 수 있다.

let hostingController: SwiftUI.PresentationHostingController<AnyView> = /* */

if 
  let delegate = Mirror(reflecting: hostingController).children.first(where: { $0.label == "delegate" })?.value,
  let some = Mirror(reflecting: delegate).children.first(where: { $0.label == "some" })?.value,
  let presentationState = Mirror(reflecting: some).children.first(where: { $0.label == "presentationState" })?.value,
  let base = Mirror(reflecting: presentationState).children.first(where: { $0.label == "base" })?.value,
  let requestedPresentation = Mirror(reflecting: base).children.first(where: { $0.label == "requestedPresentation" })?.value,
  let value = Mirror(reflecting: requestedPresentation).children.first(where: { $0.label == ".0" })?.value,
  let content = Mirror(reflecting: value).children.first(where: { $0.label == "content" })?.value,
  let storage = Mirror(reflecting: content).children.first(where: { $0.label == "storage" })?.value
{
  /* storage */
}

따라서 _TtGC7SwiftUI29PresentationHostingControllerVS_7AnyView_이 안 만들어지게 한다면, 다시 말해 UIKit Presentation으로 대체한다면 문제가 해결된다.

아니면 내가 꼼수로 만든 View+fixMemoryLeak.swift로 View가 사라질 때 AnyViewStorage의 메모리를 강제로 해제시켜주면 해결되기도 한다. 하지만 코드를 보면 알겠지만 메모리를 강제로 해제시키는 타이밍이 애매하다. (RunLoop에 언제 불릴지 모를 동작을 추가하는 것은 애매하다.) 이는 개선해야 한다.