Combine, async await로 간단하게 리팩토링 하는 세션

rbw·2023년 3월 24일
0

TIL

목록 보기
78/97

Managing Combine, your existing code, and async / await - Donny Wals - Do iOS 2022

https://www.youtube.com/watch?v=r3WQTh1LB4k&list=PLw-3TTKkn1fM-5kugk9vyJTXZF8B0zHxC&index=8

위 영상을 보고 번역정리한 글, 자세한 내용은 링크를 눌러보시길바라바람


개요

  • 코드베이스의 현재상태 (Exploring the current state of codebases)
  • 실제 세계에서의 클로저, 콜백, 딜리게이트의 이해 (Understanding how closures, callbacks, and delegates are used in the real world)
  • Combine 소개
  • async / await 소개
  • 요약

먼저 컴플리션 핸들러와, 체인지 핸들러의 차이를 설명합니다

A typical network call

func loadData(_ completion: @escaping (Result<SomeModel, Error>) -> Void) {
    let url = URL(string: "https://donnywals.com")!
    URLSession.shared.dataTask(with: url) { data, response, error in
        if let error = error {
            completion(.failure(error))
            return
        }
        // use data and decode model
        completion(.success(decodeModel))
    }.resume()
}

컴플리션 핸들러를 사용한 네트워크 데이터 요청 메소드입니다. 제일 자주 사용하고 자주 봤다고 함니당

  • 한 번 호출됩니다.
  • 완료됨을 알립니다.
  • 결과를 전달 할 수 있습니다.

Slightly less common (but useful) use of closures

// 데이터가 변함을 관찰하는 클로저를 두어서 사용하는 예시임니다
class TrackListViewModel {
    private(set) var tracks: [Track] {
        didSet {
            onTracksChanged(tracks)
        }
    }
    var onTracksChanged: ([Track]) -> Void = { _ in }
    // ...
}

let viewModel = TrackListViewModel()
viewModel.onTracksChanged = { newTracks in 
    // ... 
}

요런 식의 클로저를 체인지 핸들러(Change handler)라고 하나보네용.

이런식의 사용은, 언제든지 호출되는 일부 핸들러 또는 관찰자를 제공할 수 있습니다.

  • 여러 개체가 이런 변경사항을 알려고 하는 경우에는 이 방식을 사용할 수 없습니다.
  • 이 방식은 반응형 프로그래밍의 매우 기초적인 방식입니다.
  • 한 번 이상 호출되면 혼란스러울 수 있는 컴플리션 핸들러와 달리, 여러 번 호출이 가능합니다.
  • 값의 변화를 알립니다.
  • 새로운 값을 전달이 가능합니다.

간단하게 Combine에 대해 알아봅시다.

Combine은 functional reactive programming framework 라고 합니다. 기능적 반응형 프로그래밍 ?

  • 순수 함수를 사용하여 값을 새로운 값으로 변환합니다.
  • 타이밍기반 연산자를 적용할 수도 있습니다. 유저가 검색창에 타이핑을 하고, 결과를 가져오기 위해 호출을 시작하기 전 사용자는 0.2~0.3초 동안 아무 작업도 하지 않습니다.
  • 퍼블리셔는 값들을 제공합니다. 여기서 다루는 주요 단위입니다.
  • 연산자의 거대한 파이프라인을 구축할수 있습니다. 네트워크 호출로부터 나온 값을 문자열로 변환한다고 말할 수 있습니다.

예제 코드는 다음과 같습니다.

// 이 친구는 구독에 대한 수명 주기를 관리합니다.
var cancellables = Set<AnyCancellable>()

// 퍼블리셔를 만듭니다.
[1, 2, 3].publisher
    .map({ integer in
        return integer * 2
    })
    .sink(receiveValue: { integer in
        print(integer)
    })
    .store(in: &cancellable)

할당 해제될 때마다 해당 구독을 취소가능한집합에 저장하므로 아무도 더 이상 구독에 대한 참조를 보유하지 않습니다.

  • 퍼블리셔는 완료함을 전달할 수 있습니다.
  • 퍼블리셔는 새로운 값에 대해 알려줄 수 있습니다.

이 두 가지를 살펴보면 위에서 설명한 핸들러들의 기능을 모두 수행할 수 있음을 알 수 있습니다.

따라서 Combine은 컴플리션 핸들러와, 체인지 핸들러를 대체할 수 있습니다.

A typical network call with Combine(simplified)

func loadData() -> AnyPublisher<SomeModel, Error> {
    let url = URL(string: "https://donnywals.com")!
    return URLSession.shared.dataTaskPublisher(for: url)
        .map(\.data)
        .decode(type: SomeModel.self, decoder: JSONDecoder())
        .eraseToAnyPublisher()
}

하지만, 기존 코드를 바로 변경하고 싶지 않은 경우가 있을 수 있습니다. 그래서 아래 코드로 천천히 Combine을 적용하는 모습을 볼 수 있습니다.

Bridging a callback based function into the Combine world

아래 코드의 loadData는 기존 컴플리션핸들러를 사용하던 네트워크 call 메소드 임니다.

func loadData() -> AnyPublisher<SomeModel, Error> {
    // Future도 퍼블리셔입니다.
    return Future { promise in
        loadData { result in
            promise(result)    
        }
    }.eraseToAnyPublisher()
}

위 코드를 통해, 콜백 기반 함수를 Combine World로 연결할 수 있습니다.

  • Future를 사용하여 천천히 Combine을 도입할 수 있었습니다.
  • eraseToAnyPublisher를 사용하여 Future를 숨길 수 있습니다.
  • 아쉽게도, Future는 완료 핸들러에만 적합합니다. 한 번만 호출되기 때문임다.

이제 체인지 핸들러를 Combine을 통해 연결해보려고 합니다.

Replacing change handlers using Combine

class TrackListViewModel {
    @Published private(set) var tracks: [Track]
}   

// or

let viewModel = TrackListViewModel()
viewModel.$tracks
    .sink { tracks in
        // use tracks
    }
    .store(in: &cancellables)
  • 주로 뷰 레벨에서 일어납니다.
  • 콜백 기반 API를 리팩토링 하는것보다 영향이 훨씬 더 적습니다.

Combine and delgates

이번에는 위치 공급자를 구축하여서 Combine을 빌드해보고자 합니다.

로케이션 매니저와 딜리게이트를 래핑하는 개체를 빌드하려고 합니다.

두 개의 퍼블리셔를 노출할 것입니다.

  • 현재 위치
  • 위치 권한

이런 식으로 구현하면, 구독자는 유저의 마지막 위치와 뒤에 받을 새로운 위치를 받게 되어 매우 유용합니다.

첫 번째 위치를 얻을 때까지 기다리지 않아도 즉시 얻을 수 있는 장점이 있습니다.

위치를 수동으로 쿼리하지 않고도 UI를 업데이트할 수 있습니다.

먼저 대리자 기반 솔루션입니다.

class LocationProvider: NSObject {
    private let manager = CLLocationManger()

    private(set) var currentLocation: CLLocation? = nil {
        didSet {
            if let currentLoation = currentLocation {
                onLocationObtained(currentLocation)
            }
        }
    }
    var onLocationObtained: (CLLocation) -> Void = { _ in }

    private(set) var locationPermissons: CLAuthorizationStatus? = nil {
        didSet {
            onLocationPermissonChanged(locationPermissons ?? .notDetermined)
        }
    }
    var onLocationPermissonChanged: (CLAuthorizationStatus) -> Void = { _ in }
}
extension LocationProvider: CLLocationMangerDelegate {
    func locationMangerDidChangeAuthorization(_ manager: CLLocationManger) {
        locationPermissons = manager.authorizationStatus
    }
    func locationManger(_ manager: CLLocationManager, didUpadateLocations locations: [CLLocation]) {
        currentLocation = locations.last
    }
}

이제 위 코드를 리팩토링 하려고 합니다.

  • 현재 API를 유지하고 싶습니다.
  • 리팩토링의 영향을 최소한으로 하고 싶습니다.
// delegate 메소드는 유지합니다.
// locationProvider
private let manager = CLLocationManager()
@Published private(set) var currentLocation: CLLocation? = nil
@Published private(set) var locationPermissons: CLAuthorizationStatus? = nil

우리는 @Published를 사용하여 간단하게 프로퍼티를 게시자로 변환시켰습니다. 이는 쉽게 상태를 관찰할 수 있게 해줍니다.

// 사용하는 코드
var cancellables = Set<AnyCancellable>()
let provider = LocationProvider
provider.$currentLocation.sink(receiveValue: { location in 
    // use current location
}).store(in: &cancellables)
provider.$locationPermissons.sink(receiveValue: { location in
    // use auth status
}).store(in: &cancellables)

async / await

이 매커니즘으로 Combine을 대체할 수도 있다고 합니다.

  • AsyncStream, AsyncSequence로 퍼블리셔를 대체할수있습니다.
  • async-algorithms으로 우리는 대부분의 Combine 연산자를 대체 가능합니다.

하지만 이 친구들이 실제로 잘하는 것이 무엇인지 살펴본다고 함니다.

func loadData() async throws -> SomeModel {
    let url = URL(string: "~~~")
    let (data, _) = try await URLSession.shared.data(from: url)
    let decoder = JSONDecoder()
    return try decoder.decode(SomeModel.self, from: data)
}

이전에 살펴본 Combine을 사용한 loadData 메소드보다 확실히 가독성이 좋습니다.

만약 기존 컴플리션 핸들러를 이용한 loadData를 냅두고 사용한다면 다음과 같슴니다.

func loadData() async throws -> SomeModel {
    return try await withUnsafeThrowingContinuation { continuation in 
        loadData { result in
            continuation.resume(with: result)
        }
    }
}

Task 사용시 주의사항

  • self가 강한 참조가 되지 않게 해야합니다
  • self가 할당 해제 되었을 때 반복을 반드시 종료해야합니다

Task에서 weak self는 별 의미가 없고 취소를 이용하는 걸 권장하고 있었는데 이 분이 제시한 해결 방안도 그와 비슷하네여

이 분은 Combine을 좋아하기 때문에 아래 코드를 제안하였슴니다.

extension Task {
    func store(in cancellables: inout Set<AnyCancellable>) {
        cancellabels.insert(AnyCancellable {
            self.cancel()
        })
    }
}

마무리하며

이번 세션에서는 Combine에 대해 좀 알게 되었단 부분이 좋았슴다. 기존에 async await는 나름 사용을 좀 해봤어서 익숙했는데 Combine은 아무래도 @Published 이 정도로 SwiftUI에서 사용해봤어서, 좀 더 알게되었네요

그리고 기존 코드를 마이그레이션 하지 않고 사용하는 부분도 나름 인상적이였습니다. 저 같으면 바로 코드를 엎을거같은데 일단 래핑해서 사용을 하고, 천천히 변경해나가는게 좀 더 맞는 판단인것 같습니다.

반응형 프로그래밍은 아직 경험이 부족한데, 좀 더 공부해서 슬슬 적용을 해봐야겠슴니다.

profile
hi there 👋

0개의 댓글