rxSwift와 combine의 비동기 흐름은 아래와 같다

 

Observable     →     이벤트 생성     →     전달     →     Observer

 

RxSwift의 경우

Observable     →     이벤트 생성     →     전달     →     Observer
                              subscribe(on:)        observe(on:)

 

이벤트가 생성되는 쪽의 실행 스레드를 지정해주는 것이 subscribe(on:)

생성된 이벤트를 전달받아 처리하는 쪽의 실행 스레드를 지정해주는 것이 observe(on:)이다.

Observable<Int>
     .create { observer in
         // Subscription 영역
     }
     .subscribe(on:) // Subscription 영역의 실행 스레드 지정 (업스트림)
     .observe(on:) // Observing 영역을 지정 (다운 스트림)
     .subscribe { _ in
         // Observing 영역
     }
     .disposed(by: disposeBag)

 

 

subscribe(on:)은 어느곳에서 호출되더라도 이벤트를 방출하는 Subscription영역의 실행 스레드를 변경시키고

observe(on:)은 Observing 영역의 실행스레드를 변경시키기 때문에 호출된 곳의 아래 있는 실행스레드들이 바뀌게 된다

 

 

subscribe(on:)은 여러번 호출되더라도 Subscription영역은 한군데이기 때문에 위치와 관계 없이 가장 처음 호출된것이 적용된다.

observe(on:)은 다른 observe(on:)을 만날때 까지 적용된다

Observable<Int>
    .create { observer in
        // Subscription 영역
        print("create 스레드: \(Thread.current)")
        observer.onNext(1)
        observer.onCompleted()
        return Disposables.create()
    }
    .subscribe(on: ConcurrentDispatchQueueScheduler(qos: .background))  // 이것만 적용    
    .observe(on: ConcurrentDispatchQueueScheduler(qos: .background)) // 아래 전부 적용
    .map {
        // Observing 영역
        print("Map 스레드: \(Thread.current)")
        return $0
    }
    .subscribe(on: MainScheduler.instance)
    .observe(on: MainScheduler.instance) // 아래 전부 적용
    .subscribe { _ in
        // Observing 영역
        print("subscribe 스레드: \(Thread.current)")
    }
    .disposed(by: disposeBag)
    
    
Observable<Int>
    .create { observer in
        // Subscription 영역
        print("create 스레드: \(Thread.current)")
        observer.onNext(1)
        observer.onCompleted()
        return Disposables.create()
    }
    .subscribe(on: ConcurrentDispatchQueueScheduler(qos: .background))  // 이것만 적용
    .subscribe(on: MainScheduler.instance)
    .observe(on: ConcurrentDispatchQueueScheduler(qos: .background)) // 아래 전부 적용
    .map {
        // Observing 영역
        print("Map 스레드: \(Thread.current)")
        return $0
    }
    .observe(on: MainScheduler.instance) // 아래 전부 적용
    .subscribe { _ in
        // Observing 영역
        print("subscribe 스레드: \(Thread.current)")
    }
    .disposed(by: disposeBag)
    

// 결과
create 스레드: <NSThread: 0x600001750c00>{number = 5, name = (null)}
Map 스레드: <NSThread: 0x600001750c00>{number = 5, name = (null)}
subscribe 스레드: <_NSMainThread: 0x60000170c000>{number = 1, name = main}

 

 

Combine의 경우

Observable     →     이벤트 생성     →     전달     →     Observer
                              subscribe(on:)       receive(on:)

 

combine도 rxSwift와 동일하다 다만 이름이 observe(on:) -> receive(on:)로 변경된것 뿐이다.

Just(1)
    .subscribe(on: DispatchQueue.global()) // 이것만 적용
    .subscribe(on: RunLoop.main)
    .handleEvents(receiveSubscription: { _ in
        print("subscription 스레드 \(Thread.current)")
    })
    .receive(on: RunLoop.main)
    .map { (value) -> Int in
        print("map 스레드 \(Thread.current)")
        return value
    }
    .receive(on: DispatchQueue.global())
    .sink { value in
        print("received 스레드 \(Thread.current)")
    }
    
Just(1)
    .handleEvents(receiveSubscription: { _ in
        print("subscription 스레드 \(Thread.current)")
    })
    .receive(on: RunLoop.main)
    .map { (value) -> Int in
        print("map 스레드 \(Thread.current)")
        return value
    }
	.subscribe(on: DispatchQueue.global()) // 이것만 적용
    .subscribe(on: RunLoop.main)
    .receive(on: DispatchQueue.global())
    .sink { value in
        print("received 스레드 \(Thread.current)")
    }
    
// 결과
subscription 스레드 <NSThread: 0x6000017bf700>{number = 9, name = (null)}
map 스레드 <_NSMainThread: 0x600001710000>{number = 1, name = main}
received 스레드 <NSThread: 0x600001718300>{number = 7, name = (null)}

 

 

 

요약

이벤트 생성 시점 스레드 지정 (가장 처음 호출된 것만 적용 / 업스트림)

  • subscribe(on:) 

이벤트 처리 시점 스레드 지정 (호출된곳에서부터 아래로 전부 영향을 받음 / 다운스트림)

  • RxSwift - observe(on:)
  • Combine - receive(on:) 

combine에서 @Published는 값이 바뀔때마다 방출을 하는데 이때 방출되는 시점은 언제일까 ??

당연히 didSet이 호출되어 값이 바뀌고 난 뒤라고 생각했는데 실제 값과 receive된 값이 다르다는 걸 알게 되었다.

//
//  ContentView.swift
//  Test
//
//  Created by 이준복 on 6/20/25.
//

import SwiftUI
import Combine

struct ContentView: View {
    
    @StateObject
    private var viewModel = ViewModel()
    
    var body: some View {
        VStack {
            Text("Count: \(viewModel.count)")
                .font(.largeTitle)
            
            Button("Increment") {
                viewModel.increment()
            }
        }
        .padding()
    }
    
}

#Preview {
    ContentView()
}


class ViewModel: ObservableObject {
    
    @Published
    var count: Int = 0 {
        willSet {
            print("willSet: \(count)")
        }
        
        didSet {
            print("didSet: \(count)")
        }
    }
    
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        $count
            .sink { [weak self] value in
                print("receive: \(value)", "real: \(self?.count ?? -1)")
            }
            .store(in: &cancellables)
    }

    func increment() {
        print("변경 전: \(count)")
        count += 1
        print("변경 후: \(count)")
    }
    
}

 

 

코드를 실행하기 전 결과를 예상 했을땐 아래라고 생각했는데 실제 결과 값은 그렇지 않았다.

변경 전: 0
willSet: 0
didSet: 1
receive: 1 real: 0
변경 후: 1

 

 

실제 결과 값

 

조금 찾아보니 willSet에서 .send(newValue)를 호출하여 값을 방출하는 것 같았다. 실제 메모리 반영되고 값이 바뀐 시점은 didSet 시점이니 이 부분을 알고 있으면 좋을 것 같다.

일급 객체

일급객체, 일급함수란 객체나 함수가 값으로 취급될 수 있다는 것이다.

값으로 취급 할 수 있다는 것의 의미는 변수를 할당받거나, 파라미터로 전달되거나 리턴 값으로 취급 될 수 있다는 것이다.

설명으로만 보면 이해하기 힘든데 코드를 보면 쉽게 이해가 가능하다

// 반환 값
func getName() -> String {
    "Name"
}

// 파라미터로 전달
func add(_ a: Int, _ b: Int) -> Int {
    a + b
}

// 변수에 저장
struct Person {
    let name: String
    let age: Int
}

let person = Person(name: "준", age: 20)

위와 같이 처럼 정수형, 배열, 객체, 클래스 등 변수에 할당받을 수 있는 타입을 일급객체라고 한다.

 

일급함수

일급함수란 일급객체로 취급되는 함수를 일급 함수라고 한다.

즉 함수를 값처럼 다를 수 있다는 이야기. Swift에선 함수(클로저)를 일급객체로 취급한다.

// 매개변수
func run(_ completion: ((String) -> Void)?) {
    completion?("Finish")
}

// 변수에 저장
func helloWorld() {
    print("Hello World!")
}

let task1 = helloWorld
let task2 = {
    print("Good Bye World!")
}

var tasks: [() -> Void] = [task1, task2]

// 리턴 값
func plus(_ x: Int) -> (Int) -> Int {
    { y in x + y }
}

let plus5 = plus(5)
print(plus5(10)) // 15

 

고차함수

고차함수는 다른 함수를 인자를 받거나 함수를 결과값으로 반환하는 함수를 말한다.

그러므로 모든 고차함수는 일급함수(일급객체)이다.

당연하게도 모든 일급함수가 고차함수인것은 아니다

- 모든 일급함수가 다른 함수를 인자로 받거나 함수를 결과값으로 반환하는게 아니기 때문

 

Swift에선 클로저가 일급객체 취급이므로 고차 함수는 클로저를 인자로 받거나 반환할 수 있다

 

고차함수의 장점

  • 코드 가독성 향상
  • 재사용성 증가
  • 유연성 및 확장성 증가
// 코드 가독성 향상
let numbers = [1, 2, 3, 4, 5]

var evens: [Int] = []
for number in numbers {
    if number % 2 == 0 {
        evens.append(number)
    }
}

let evens2 = numbers.filter { $0 % 2 == 0 }


// 재사용성 증가
func performCalculation(_ a: Int, _ b: Int, using operation: (Int, Int) -> Int) -> Int {
    return operation(a, b)
}

let sum = performCalculation(3, 5, using: +) // 8
let product = performCalculation(3, 5, using: *) // 15


// 유연성 및 확장성 증가
// completion 구현에 따른 확장성
func reqeust(completion: (() -> Void)?) {
    DispatchQueue.global().async {
        completion?()
    }
}


reqeust {
    print("do SomeThing")
}


reqeust {
    print("do SomeThing 2")
}

 

 

하지만 단점도 존재하는데 대표적인것이 콜백 지옥이다

func getUserID(_ completion: @escaping (Result<String, Error>) -> Void) {
    ... 비동기처리 ...
    isSuccess ? completion(.success("ID")) : completion(.failure(SomeError))
}

func getUserName(id: String, _ completion: @escaping (Result<String, Error>) -> Void) {
    ... 비동기처리 ...
    isSuccess ? completion(.success("Name")) : completion(.failure(SomeError))
}


getUserID { result in
    switch result {
    case .success(let id):
        getUserName(id: id) { result in
            switch result {
            case .success(let name):
                print(name)
            case .failure(let error):
                print(error)
            }
        }
    case .failure(let error):
        print(error)
    }
}

 

순수함수

순수함수는 두가지 조건을 만족해야 한다

  • 동일한 input엔 항상 동일한 output
  • Side Effect가 없다 (함수 외부에서 변경이 일어나지 않고 함수 실행하는 것 외에 다른 외부 상호작용이 없다)

순수함수는 수학의 함수와 동일하다고 생각하면 된다

함수 f(x) = x + 5 에서 f(5)는 항상 10 이다. f(6)은 항상 11이다

 

func add(_ a: Int, _ b: Int) -> Int {
    a + b
}
add(10, 5) // 항상 10


func minus(_ a: Int, _ b: Int) -> Int {
    a - b
}
minus(10, 5) // 항상 5


// Side Effect가 존재
var multiple = 2
func increment(_ a: Int) -> Int {
    a * multiple
}

increment(5) // 10
multiple = 10
increment(5) // 100

 

즉 Side Effect가 존재하지 않으면 동일한 input에 동일한 output이 나온다

 

간혹가다 아래 처럼 Date(), random() 처럼 input이 없고 동일한 함수를 호출하는데 다른 output이 나오니까

앞에 말한 조건에 위배되는것 아닌가라고 생각할 수 있는데

엄연히 사이드 이팩트가 존재하는 불순 함수들이다

// 외부 환경인 현재 시간에 의존
func getCurrentTime() -> Date {
    return Date() 
}

// 외부 환경인 난수 생성기에 의존
func getRandomNumber() -> Int {
    return Int.random(in: 1...100)
}

서비스 하고 있는 App 하나의 리팩토링을 끝낸후 나머지 앱을 리팩토링 하던 중 아래와 같은 오류 발생

 

Thread 1: Swift runtime failure: Unexpectedly found nil while implicitly unwrapping an Optional value

 

Crash 발생시 함수 스택에도 표기되지 않고 storyboard나 xib파일이 아예없기에 아주 당황 ;;;

혹시몰라 다시 체크해보았는데 당연히 코드레벨에서 옵셔널을 강제 언래핑하는곳이 아무곳도 없었음

 

크래시가 발생하는 화면 처음부터 디버깅 시작

아래 함수 호출 시 크래시 발생

func getSectionPositions() async

 

기존에 리팩토링이 끝난 앱에서 공통코드로 뺀 부분이고 정상적으로 동작하는 것까지 검증이 끝난 함수인데 아주 당황;;

 

한줄한줄 디버깅해보니 아래 코드에서 Crash ...

// titleScript = "document.querySelector('#\(rawValue) span')?.textContent"
guard let titleResult = try? await webView.evaluateJavaScript(type.titleScript),
      let title = titleResult as? String else { return }

webView에서 특정 content를 가져오는 script인데 try?를 통해 강제 언래핑해제도 아닌데 터져버림 ...

 

 

 

혹시나 해서 do catch로 감싸 실행해보니 정상적으로 동작 ... 

do {
    ...
    let titleResult = try await webView.evaluateJavaScript(type.titleScript)
    let title = titleResult as? String
    ...
} catch {
    LogManager.log(level: .error, self, #function, error, "title is Invalid : \(type.titleScript)")
    continue
}

 

 

 

혹시나 하는 마음에 몇일 후 guard let으로 재 실행

// titleScript = "document.querySelector('#\(rawValue) span')?.textContent"
guard let titleResult = try? await webView.evaluateJavaScript(type.titleScript),
      let title = titleResult as? String else { return }

 

정상적으로 동작 ;;;

 

뭐가 문제인지 결국 찾지 못했다 ....

'iOS > Trouble Shooting' 카테고리의 다른 글

[iOS] KakaoOpenSDK에서 Concurrency 사용시 App Crash  (0) 2025.05.11

App Crash Report에서 Concurrency 오류 발생

KakaoLogin시 간혈적으로 App Crash가 발생을 확인

 

다양한 ThirdPartyLogin을 위해 async/await을 사용하고 있었기때문에 withCheckedThrowingContinuation로 감싼 형태였고
특정 기기나 OS, 혹은 특정 유저마다 발생하는게 아니였고 같은 조건이여도 정상적으로 동작하는 경우가 있었고

CheckedContinuation.resume(returning:)
CheckedContinuation.resume(throwing:)

 

 

둘 모두에서 Crash가 발생하여 SDK에서 발생하는 문제임이라 생각하여 개발자 포럼에 확인 요청
- iOS SDK에서 Concurrency 사용시 app crash

 

iOS SDK에서 Concurrency 사용시 app crash

KakaoOpenSDK - 2.24.0 App ID - 573044 문의 시, 사용하시는 SDK 버전 정보와 디벨로퍼스 앱ID를 알려주세요. 앱에서 Concurrency 사용을 위해서 UserApi.shared.loginWithKakaoTalk 를 withCheckedThrowingContinuation로 감싸서

devtalk.kakao.com

 

@MainActor
    func loginWithKakao() async throws -> OAuthToken {
        try await UserApi.isKakaoTalkLoginAvailable() ? loginWithKakaoTalk() : loginWithKakaoAccount()
    }

    @MainActor
    func loginWithKakaoTalk() async throws -> OAuthToken {
        try await withCheckedThrowingContinuation { continuation in
            UserApi.shared.loginWithKakaoTalk { oAuthToken, error in
                if let error {
                    continuation.resume(throwing: error)
                } else if let oAuthToken {
                    continuation.resume(returning: oAuthToken)
                } else {
                    continuation.resume(throwing: KakaoLoginError.emptyData)
                }
            }
        }
    }

    @MainActor
    func loginWithKakaoAccount() async throws -> OAuthToken {
        try await withCheckedThrowingContinuation { continuation in
            UserApi.shared.loginWithKakaoAccount { oAuthToken, error in
                if let error {
                    continuation.resume(throwing: error)
                } else if let oAuthToken {
                    continuation.resume(returning: oAuthToken)
                } else {
                    continuation.resume(throwing: KakaoLoginError.emptyData)
                }
            }
        }
    }

 

 

카카오 측에서 오류 확인, 수정 후 배포

 

 

수정 된 SDK로 버전 업을 하였는데 같은 크래시 발생 .... 그 전 보단 확실히 줄었지만 내쪽 코드가 문제인것 같은데 혹시나 해서 재 문의

 

SDK 담당자분과 소통하면서 원인을 찾아가던 중 하기와 같은 답변을 받자마자 원인 파악이 가능했다.

 

라이브러리나 SDK는 문제가 없을거라는 안일한 생각이 문제였던것 .....

보자마자 nslock을 사용하여 단일 실행을 보장하였고 그 뒤엔 관련 크래시가 발생하지 않았다 ...ㅎㅎㅎㅎ

 

'iOS > Trouble Shooting' 카테고리의 다른 글

[iOS] gaurd let _ = try? await evaluateJavaScript 오류  (1) 2025.05.11

Dynamic App Icon

특정기간이나 특정 조건에서 앱 아이콘을 바꾸고 싶을 때 앱 배포를 하지 않고 앱 아이콘을 바꾸는 방법

Assets 에 원하는 AppIcon(BIcon, CIcon, DIcon) 들을 추가

 

스크린샷 2025-04-19 오후 11 47 01

Targets → Build Setting(All) → Alternate App Icon Sets 에서 Assets에 추가한 AppIcon(BIcon, CIcon, DIcon) 들을 입력

스크린샷 2025-04-19 오후 11 47 22

UIApplication.shared.setAlternateIconName(String?:) 호출

https://github.com/user-attachments/assets/345e9eda-495d-4ce7-a5f1-4d2f496ffb33

 

 

 

 

import UIKit

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
    }


    @IBAction func buttonTapped(_ sender: Any) {
        setAppIcon()
    }

    func setAppIcon() {

        let count = (UserDefaults.standard.integer(forKey: "AppIcon") + 1) % 4
        UserDefaults.standard.setValue(count, forKey: "AppIcon")

        var iconName: String? = nil
        switch count {
        case 1:
            iconName = "BIcon"
        case 2:
            iconName = "CIcon"
        case 3:
            iconName = "DIcon"
        default: break
        }

        UIApplication.shared.setAlternateIconName(iconName) { error in
            if let error = error {
                print("아이콘 변경 실패: \(error)")
            } else {
                print("아이콘 변경 성공!")
            }
        }
    }

}

alert이 뜨는게 싫다면 아래 함수 호출
주의해야할 점은 appStore 심사가 거절될 수 있고
UIApplication.shared.setAlternateIconName(String?:)에선 nil로 값을 주면 기본으로 설정된 appIcon으로 돌아가는데
setIconWithoutAlert을 사용할 경우엔 기본으로 돌아갈 appIcon을 따로 추가해주어야 함

 func setIconWithoutAlert(_ appIconName: String) {
        if let alternateIconName = UIApplication.shared.alternateIconName,
           alternateIconName == appIconName {
            return
        }

        if UIApplication.shared.responds(to: #selector(getter: UIApplication.supportsAlternateIcons)) && UIApplication.shared.supportsAlternateIcons {
            typealias setAlternateIconName = @convention(c) (NSObject, Selector, NSString, @escaping (NSError) -> ()) -> ()
            let selectorString = "_setAlternateIconName:completionHandler:"
            let selector = NSSelectorFromString(selectorString)
            let imp = UIApplication.shared.method(for: selector)
            let method = unsafeBitCast(imp, to: setAlternateIconName.self)
            method(UIApplication.shared, selector, appIconName as NSString, { _ in })
        }
    }

간혹가다 icon 변경이 안될때는 assets 이미지 이름에 icon(ex: special -> specialIcon)을 붙혀 적용해보면 변경됨

'iOS > iOS' 카테고리의 다른 글

[iOS] App Sandbox  (1) 2024.07.01
[iOS] HitTest  (2) 2024.06.13
[iOS] UIResponder, Responder Chain, First Responder  (0) 2024.06.09
[iOS] 모듈화 Library, Framework, Package,  (0) 2024.05.09
[iOS] 하위 뷰의 frame이 잡히지 않을 때  (1) 2023.12.28

모듈화시 각 모듈에서 사용하는 ThirdParty Library들의 효율적인 관리를 위해 ThirdPartyKit이라는 모듈로 분리하여 사용하였다.

123

App 테스트를 위해 Configurations를 각각 다르게 줘고 Scheme를 각각 설정하였다.

Dev, QA, Release Scheme이다

4567

App-Release 스키마와 App-Dev 스키마는 정상적으로 실행되는데 App-QA 스키마는 아래와 같은 에러가 발생한다.

Framework 'ThirdPartyKit' not found
Linker command failed with exit code 1 (use -v to see invocation)

~/DerivedData/프로젝트이름/Build/Products의 폴더를 들어가 각각의 폴더를 확인해보면

QA에만 ThirdPartyKit.framework만 포함되어 있지 않고 있다.

하지만 ThirdPartyKit에 추가한 라이브러리들(SnapKit)은 다 포함되어있다

891011

ThirdPartyKit.framework가 포함되지 않는 이유는 ThirdPartyKit의 Configurations와 App의 Configurations가 일치하지 않기 때문이다.

12131415

당연하게도 Configurations의 Name은 일치해야 하고

16

App에 Configurations을 추가할 경우 참조하고 있는 모든 framework의 Configurations를 App과 맞춰줘야 한다.

App에 AnotherFramework 추가

AnotherFramework에 QA Configurations를 추가하기 전 App Build Fail

17

 

18

AnotherFramework에 QA Configurations를 추가한 후 App Build Success

19

 

 

Example

 

https://github.com/junbok97/TIL/tree/main/Swift/%EB%B8%94%EB%A1%9C%EA%B7%B8/Framework-Configurations

 

TIL/Swift/블로그/Framework-Configurations at main · junbok97/TIL

Today I Learn. Contribute to junbok97/TIL development by creating an account on GitHub.

github.com

 

What is Observation ?

1

기본 데이터가 변경될 때 프레젠테이션을 업데이트하는 반응형 앱을 만듭니다.

@Obserable 은 normal type의 property를 observable type으로 마킹하여 UI가 data의 변화에 대응할 수 있게 해주는 매크로

@Obserable 은 Swift 컴파일러에게 명령을 내려서 코드를 확장형 Observable type으로 바꿔 SwiftUI가 뷰를 동작하게 만듬

reference type, value type을 모두 지원 

@Published @State @Binding @ObservedObject 등 다른 프로퍼티 래퍼가 없어도 작동함

각 property마다 @Published @State @Binding @ObservedObject 등을 붙인것을 data model에 붙인다고 이해하면 됨

// Before
class FoodTruckModel {    
    var orders: [Order] = []
    var donuts = Donut.all
}

// After
@Observable class FoodTruckModel {    
    var orders: [Order] = []
    var donuts = Donut.all
}

Examples

SwiftUI는 body가 실행될 때 Observable 타입에서 사용된 property의 모든 접근을 추적함

이 추적 정보를 이용해 특정 인스턴스에서 property의 다음변화가 언제 일어날지를 예측

DonutMenu의 body에서 model.donuts에 접근이 이루어져 SwiftUI가 donuts를 추적

도넛 추가 버튼을 클릭해 donuts가 변경되면 DonutMenu View가 무효화 되고 변경사항에 맞춰 UI가 업데이트 됨

orders가 변경되었을 때는 DonutMenu View가 무효화 되지 않음

view의 body에서 접근하지 않고 있어 SwiftUI가 추적하지 않는 property이기 때문

연산 프로퍼티에도 같은 규칙이 적용 됨 프로퍼티가 변하면 UI는 업데이트

@Observable class FoodTruckModel {    
  var orders: [Order] = []
  var donuts = Donut.all
  var orderCount: Int { orders.count }
}

struct DonutMenu: View {
  let model: FoodTruckModel

  var body: some View {
    List {
      Section("Donuts") {
        ForEach(model.donuts) { donut in
          Text(donut.name)
        }
        Button("Add new donut") {
          model.addDonut()
        }
      }
      Section("Orders") {
        LabeledContent("Count", value: "\(model.orderCount)")
      }
    }
  }
}

위의 코드에서 FoodTruckModel에 orderCount라는 연산 프로퍼티를 추가

view의 body에서 orderCount에 접근하여 orderCount를 추적

orderCount는 orders에 접근했으므로 orders가 변경되면 view가 업데이트

@Observable

  • @Observable 매크로를 사용하면 타입이 확장되어 Observation을 지원
  • SwiftUI는 프로퍼티의 접근을 추적, Observation에서 해당 프로퍼티가 언제 변할지 관찰 가능
  • 추적이 가능해지면 프로퍼티가 변할때 UI는 view의 body를 재계산 하면 끝

SwiftUI property wrappers

SwiftUI의 핵심 프로퍼티 래퍼 3가지는 State, Environment, Bindable

@Observable에서 SwiftUI의 프로퍼티 래퍼가 필요한 경우

@State

model안에 view 전용 상태를 저장해야할 때 사용

donutToAdd는 소속된 donutListView의 view의 수명 동안만 관리

@Environment

Environment는 각 값을 어디에서든 접근 가능하게 만들어 줌 → Global로 만들어 준다

observable 타입이 Environment와 잘 맞는데 observable은 접근을 추적하여 업데이트 하기 때문에 여러곳에서 공유하는 Environment와 잘 맞음

FoodTruckMenuView의 body를 불러올 때 Observable 타입의 Account의 userName에 접근하게 됨

userName이 변경되면 FoodTruckMenuView의 body도 업데이트

@Bindable

가장 최근에 생긴 프로퍼티 래퍼 굉장히 가볍다

해당 타입으로부터 바인딩이 생성되게 하는 기능만 있음

래핑된 Bindable 프로퍼티에서 바인딩을 만드는 방법은 굉장히 쉬운데 $구문을 사용해 프로퍼티에 바인딩을 만들면 됨

모델이 view로 있어야 한다면 → @State

모델이 전역(Global)으로 있어야 한다면 → @Environment

모델에 바인딩만 필요하다면 → @Bindable

셋 다 아니라면 var

Advanced uses

SwiftUI는 프로퍼티 접근을 인스턴스마다 추적하기 때문에 Observable 모델이 포함된 어떤 타입도 사용될 수 있음

Example


DonutList는 donuts 배열을 가지고 있는데 이 배열은 모두 @Observable

Donut의 name이 하나라도 변경되면 SwiftUI는 언제 뷰를 무효화할지를 파악하기 위해 해당 프로퍼티에 일어난 접근을 포착하여 추적함

Observable의 기본 규칙은 사용중인 프로퍼티가 변경되면 뷰도 업데이트 된다는 것

단 예외도 존재하는데 연산 프로퍼티에 사용된 저장 프로퍼티가 없다면 Observation에서 사용되기 전에 2단계를 더 거쳐가게 됨

이는 관찰 대상인 프로퍼티가 observable 타입에 저장된 구성에 의해 변경되지 않았을 때만 필요한 조치임

이 경우엔 프로퍼티에 접근과 변경이 언제 이루어졌는지 Observation에 전달해주기만 하면 됨

Manual Observation

access point를 수동으로 고쳐서 someNonObservableLocation의 name을 저장하게 해줌

이러한 직접 구현이 필요한 경우는 드문데 왜냐하면 보통 이런 모델의 프로퍼티는 다른 저장 프로퍼티처럼 구성되기 때문

하지만 고급 기능이 필요한 경우엔 직접 구현하면 됨

Computed properties (연산 프로퍼티)

  • SwiftUI는 구성의 변화를 바로 인식하는데 프로퍼티 접근을 바탕으로 Observable 타입을 추적하기 때문
  • 연산 프로퍼티에 다른 저장 프로퍼티를 사용하여도 Observation이 잘 작동하게 됨
  • 하지만 드물게 Observation이 작동하지 않으면 직접 구현하여 프로퍼티의 접근과 변경이 언제 일어나는지만 알려주면 됨

@ObservableObject

@ObservableObject → @Observable

@ObservableObject 에서 @Observable 로 넘어가려면 대부분의 어노테이션을 삭제하거나

@State @Environment @Bindable 이 세가지로 단순화하면 됨

// Before
class FoodTruckModel: ObservableObject {
    @Published var truck = Truck()

    @Published var orders: [Order] = []
    @Published var donuts = Donut.all

    var dailyOrderSummaries: [City.ID: [OrderSummary]] = [:]
    var monthlyOrderSummaries: [City.ID: [OrderSummary]] = [:]
    ...
}

struct AccountView: View {
    @ObservableObject var model: FoodTruckModel

    @EnvironmentObject private var accountStore: AccountStore
    @Environment(\.authorizationController) private var authorizationController

    @State private var isSingUpSheetPresented = false
    @State private var isSingOutSheetPresented = false
    ...
}

// After
@Observable
class FoodTruckModel {
    var truck = Truck()

    var orders: [Order] = []
    var donuts = Donut.all

    var dailyOrderSummaries: [City.ID: [OrderSummary]] = [:]
    var monthlyOrderSummaries: [City.ID: [OrderSummary]] = [:]
    ...
}

struct AccountView: View {
    var model: FoodTruckModel

    @Environment(AccountStore.self) private var accountStore: AccountStore
    @Environment(AuthorizationController.self) private var authorizationController

    @State private var isSingUpSheetPresented = false
    @State private var isSingOutSheetPresented = false
    ...
}

@ObservableObject와 @Observable 차이점

코드가 간결해짐

observable에서는 대부분의 어노테이션이 필요가 없어지기 때문

옵셔널 가능

observable은 옵셔널 가능

observableObject 은 옵셔널 불가능

// ObservableObject
class FoodTruckModel: ObservableObject {
    ...
}

struct AccountView: View {
    @ObservableObject var model: FoodTruckModel? // 컴파일 에러
}

// Observable
@Observable
class FoodTruckModel {
    ...
}

struct AccountView: View {
    var model: FoodTruckModel? // 가능
}

성능 향상

ObservableObject 에서는 published property 가 변하면 뷰의 body에서 해당 프로퍼티를 읽고 있지 않아도 다시 그려짐

final class Food: ObservableObject {

    let name = "Food Name"
    @Published var isAvailable = true

    var cancellable: Cancellable?

    func connectTimer() {
        cancellable = Timer.publish(every: 0.1, on: .main, in: .default)
            .autoconnect()
            .sink { [weak self] _ in
                self?.isAvailable.toggle()
            }
    }
}

struct FoodMenuView: View {
    @ObservedObject var food: Food

    var body: some View {
        Text(food.name)
            .background(.random)
            .onAppear {
                food.connectTimer()
            }
    }
}

FoodMenuView의 body에서 isAvailable를 접근하고 있지 않음에도 isAvailable가 변경되면 뷰가 계속 업데이트 됨

Observable은 뷰의 body에서 프로퍼티를 읽어야 업데이트 되기 때문에 불필요한 업데이트가 일어나지 않아 성능이 향상

@Observable
final class Food: ObservableObject {

    let name = "Food Name"
    var isAvailable = true

    var cancellable: Cancellable?

    func connectTimer() {
        cancellable = Timer.publish(every: 0.1, on: .main, in: .default)
            .autoconnect()
            .sink { [weak self] _ in
                self?.isAvailable.toggle()
            }
    }
}

struct FoodMenuView: View {
    var food: Food

    var body: some View {
        Text(food.name)
            .background(.random)
            .onAppear {
                food.connectTimer()
            }
    }
}

FoodMenuView의 body에서 isAvailable를 접근하고 있지 않기 때문에 isAvailable가 변경되어도 뷰가 업데이트 되지 않음

참고

https://developer.apple.com/videos/play/wwdc2023/10149/

https://developer.apple.com/videos/play/wwdc2023/10166

https://developer.apple.com/videos/play/wwdc2023/10167

https://developer.apple.com/documentation/observation

https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app

https://developer.apple.com/documentation/swiftui/migrating-from-the-observable-object-protocol-to-the-observable-macro

https://eunjin3786.tistory.com/580

https://eunjin3786.tistory.com/582

[WWDC - 2021] Demystify SwiftUI ‐ Identity (1/3)
[WWDC - 2021] Demystify SwiftUI ‐ Lifetime (2/3)
[WWDC - 2021] Demystify SwiftUI ‐ Dependency (3/3)

🤔 SwiftUI는 우리의 코드에서 어떤걸 볼까 ?

Identity

  • SwiftUI가 여러번의 업데이트 동안 요소(element) 가 동일한것인지 다른것인지 인식(recognize) 하는 방법

Lifetime

  • SwiftUI가 뷰와 데이터의 존재(existence) 를 시간에 따라 추적하는 방법

Dependency

  • SwiftUI가 인터페이스를 업데이트해야하는 시기(when) 와 이유 (why) 를 이해하는 방법

이 세 가지 개념을 통해 SwiftUI는 변경해야할 사항(what needs to change), 시기(when), 방법(how)결정할 수 있어 결과적으로 동적인 사용자 인터페이스가 화면에 표시

How SwiftUI updates the UI ?

The goal is to give you a better mental model for how to structure SwiftUI code.

Dependency

  • dependency는 단순히 view에 대한 input일 뿐임
  • dependency가 변경되면 view는 새로운 body를 생성해야 함
  • body는 뷰 계층 구조를 생성하는 곳
  • action은 뷰의 dependencies에 대한 변경의 trigger

위 코드를 도식화하면 아래와 같음

버튼을 탭하면 강아지에게 보상을 주는 액션이 전달됨

그 결과 강아지는 변화가 생김

dependecy가 변화되었기 때문에 DogView는 새로운 body를 그려야 함

 

 

뷰 계층 구조에 초점을 맞춰 그래프를 조금 더 단순화 하여 확인해보면 DogView에 dependecy를 추가해도 여전히 tree로 보임

 

DogView뿐 아니라 다른곳에서도 dependecy가 추가될 수 있음 SwiftUI는 자체 dependecy를 가질 수도 있음

 

또한 아래처럼 동일한 state나 data에 다수의 view가 의존할 수 도 있음

 

 

아직까진 tree처럼 보이지만 선이 겹치는 것을 피하기 위해 재배치 해보면 실제론 tree가 아니라 greaph 임을 알 수 있음

이 구조를 dependecy graph 라고 부름

이 구조는 SwiftUI가 새로운 body가 필요한 view만 효율적으로 업데이트 할 수 있도록 해주기 때문에 중요함

 

 

가장 하단의 dependency를 예로 들면 이 dependency에 영향을 받는 view는 2개가 있음

dependency graph의 비밀은 dependency가 변경되면 dependency에 영향을 받는 뷰만 무효화

SwiftUI는 무효화된 각 view의 body를 호출하여 각 view에 대한 새로운 body value를 인스턴스

이로 인해 무효화된 view와 연관된 더 많은 dependency가 변경될 수 있지만 항상 그런 것은 아님

SwiftUI에서 view는 value type이기 때문에 이를 효율 적으로 비교하여 view의 올바른 하위 집합만 업데이트

view의 value(struct)는 수명이 매우 짧아 값 비교에만 사용되지만 view 자체 수명은 이보다 더 김


Identity is the backbone of the graph

  • identity는 dependecy graph의 핵심(backbone)
  • 모든 view는 명시적(Explicit) 또는 구조적(Structural) identity를 가지고 있음
  • 이 identity를 통해 SwiftUI는 변경사항을 올바른 View에 전달하고 UI를 효율적으로 업데이트 할 수 있음

 

identity의 활용을 향상시키는 방법

Explicit Identity 사용 시 고려사항

Identifier의 안정성(stablilty)

뷰의 수명은 id의 지속시간이므로 identity의 안정성(stablilty)이 중요함

안정성이 강한 identity를 갖게 되면 SwiftUI는 view에 대한 storage를 지속적으로 생성하거나
dependency graph를 업데이트 하지 않아도 되기 때문에 성능(performance) 향상에 도움

또한 SwiftUI는 identity를 사용하여 persisted storage를 관리하기 때문에 state 손실 방지에도 도움

하지만 안정성이 좋다고 해서 유일한 것은 아님

Identifier의 고유성(uniqueness)

좋은 identity의 또 다른 조건은 고유성(uniqueness)

즉, 각각의 identity는 단 하나의 뷰(single view)에만 매핑 되어야 함 (1:1 매치)

 

Example of Explicit identity

이 코드에는 버그가 있는데 바로 id를 UUID()로 초기화 해주는 것임

데이터가 변경될때마다 새로운 id를 얻기 때문에 안정적이지 않음

 

 

index로 바꾸어도 안정적이지 않음 왜냐하면 index는 데이터의 추가나 삭제에 의해 변경될 수 있기 때문

 

 

그렇기 때문에 database에서 가져온 것처럼 안정적인 속성에서 파생된 안정적인 id를 사용해야 함

 

 

name은 unique하지 않기 때문에 id로는 적합하지 않음

 

 

serialNumber 같은 unique한 id를 사용하는 것이 더 나은 애니메이션과 퍼포먼스를 보장

 

Explicit identity의 주의점

  • 계산 속성에서 random identifier(ex: UUID())를 사용할 때는 주의
  • 일반적으로 모든 identity는 안정적이기를 원함
  • identity는 시간이 지나도 변경되어선 안됨
  • 새로운 identity는 새로운 lifetime을 가지는 새로운 item을 보여줌
  • identity는 고유해야함 identity를 공유하면 안됨

SwiftUI는 이러한 속성을 사용하여 버그없이 원할하게 실행되도록 함

 

Structural Identity 사용 시 고려사항

Example of Structural Identity

if 구문을 통해 분기 처리되었기에 content는 구조적으로 각각 다른 id를 가진 2개의 view로 취급함

하지만 단일 id를 가지는 하나의 content로 처리하고 싶다면 ??

 

 

modifier 내에서 컨디션에 따라 값을 변경하여 branch를 제거 해줌

 

 

참고로 결과값이 false면 불투명도가 1이기 때문에 아무 변화도 일어나지 않는데 이러한 modifier을 비활성화 수정자(Insert modifier)라고 부름

SwiftUI의 modifier은 저렴하기 때문에 Inser modifier의 비용은 거의 없다고 봐도 무방함

 

Structural Identity의 주의점

  • branch를 불필요하게 사용하면 성능 저하, 애니메이션 오류, state 손실 등이 발생
  • branch 도입 시에는 여러 view를 나타내는지 아니면 단일 view의 다른 state를 나타내는지 것인지 고려
  • 단일 view를 나타내는 것이라면 보통은 Insert modifier을 사용하는 것이 더 나음

참고

https://developer.apple.com/videos/play/wwdc2021/10022

+ Recent posts