project/개발 업무

Combine ReadOnly Publisher

lgvv 2024. 11. 20. 00:45

Combine ReadOnly Publisher

Combine을 통해 개발하는데, Read만 가능한 Publiser가 필요한 상황이 생김.
Combine과 SwiftUI에서 기본 제공되는 PassthroughSubject, CurrentValueSubject, @Published로는 읽기 전용으로 제한하기에 마땅치 않아서 커스텀하게 만들어서 사용하고자 함.
 

목차

  • 모듈 전체 코드
    • CurrentValueSubject을 통한 구현
    • PassthroughtSubject을 통한 구현 
  • PassthroughSubject를 활용한 구현에서 value를 지원하는 형태
  • 간단 사용 예제

 

모듈 전체 코드

구현할 때 고려했던 것들

  • Swift Package를 활용해서 모듈 형태로 구현해서 접근제어자 활용.
  • 상속을 통해 타입 계층의 이점을 누리고자 CurrentValuePubliser가 ReadOnlyCurrentValuePubliser를 상속하는 형태로 구현.
  • CurrentValuePubliser를 인터페이스에 의존하는 형태로 구현해도 괜찮을 것 같기는 한데, 비교적 간단한 구현에 인터페이스 도입시 복잡도가 구현 복잡도가 올라갈 것 같은데 이점이 크지 않아서 따로 두지 않음.
  • 대신 ReadOnlyCurrentValuePubliser 값에 접근하기 위해 fileprivate을 사용하지 않고 메서드를 포워딩하는 형태로 구현

 

CurrentValueSubject을 통한 구현

public class ReadOnlyCurrentValuePublisher<Element, Failure: Error>: Publisher {
    public typealias Output = Element
    public typealias Failure = Failure
    
    public var value: Output { currentValueSubject.value }
    
    private let currentValueSubject: CurrentValueSubject<Output, Failure>
    
    fileprivate init(_ initialValue: Output) {
        self.currentValueSubject = CurrentValueSubject(initialValue)
    }
    
    public func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Element == S.Input {
        currentValueSubject.receive(subscriber: subscriber)
    }
    
    fileprivate func send(_ value: Element) {
        currentValueSubject.send(value)
    }
}

public final class CurrentValuePublisher<Element, Failure: Error>: ReadOnlyCurrentValuePublisher<Element, Failure> {
    typealias Output = Element
    typealias Failure = Failure
    
    public override init(_ initialValue: Element) {
        super.init(initialValue)
    }
    
    public override func send(_ value: Element) {
        super.send(value)
    }
}

 
 
 
 

PassthroughtSubject을 통한 구현

public class ReadOnlyPassthroughPublisher<Element, Failure: Error>: Publisher {
    public typealias Output = Element
    public typealias Failure = Failure
    
    fileprivate let passthroughSubject: PassthroughSubject<Output, Failure>
    
    fileprivate init() {
        self.passthroughSubject = PassthroughSubject()
    }
    
    public func receive<S>(subscriber: S) where S: Subscriber, Failure == S.Failure, Element == S.Input {
        passthroughSubject.receive(subscriber: subscriber)
    }
    
    fileprivate func send(_ value: Element) {
        passthroughSubject.send(value)
    }
}

public final class PassthroughPublisher<Element, Failure: Error>: ReadOnlyPassthroughPublisher<Element, Failure> {
    public override init() {
        super.init()
    }
    
    public override func send(_ value: Element) {
        super.send(value)
    }
}

 
 
 

PassthroughSubject를 활용한 구현에서 value를 지원하는 형태

초기값이 없는 경우도 분명히 존재해서 초기값이 없는 상태로 사용하기 위해서 PassthroughSubject로도 구현해 보았는데, 해당 구현 형태 방향은 여러가지 이유로 추천하지 않음.

  • value가 optional로 구현하게 되는데, 클라이언트에서 값만 확인하고 싶을 때 결국 value가 optional 상태라 값을 계속 확인해야하는 불편함 존재
  • 초기값이 없는 이점 + value 접근의 이점을 누리고자 한다면, 구현이 너무 복잡해지는 것 같고, dropFirst 등과 같이 처리가능한 형태가 존재해서 유의미한 개발 방향으로 판단하기 어려웠음.
public class ReadOnlyPassthroughPublisher<Element, Failure: Error>: Publisher {
    public typealias Output = Element
    public typealias Failure = Failure
    
    private var _value: Output? = nil
    public var value: Output? { return _value }

    fileprivate let passthroughSubject: PassthroughSubject<Output, Failure>
    
    fileprivate init() {
        self.passthroughSubject = PassthroughSubject()
    }
    
    public func receive<S>(subscriber: S) where S: Subscriber, Failure == S.Failure, Element == S.Input {
        passthroughSubject.receive(subscriber: subscriber)
    }
        
    fileprivate func updateValue(_ newValue: Output) {
        _value = newValue
        passthroughSubject.send(newValue)
    }
}

public final class PassthroughPublisher<Element, Failure: Error>: ReadOnlyPassthroughPublisher<Element, Failure> {
    public override init() {
        super.init()
    }
    
    public func send(_ value: Element) {
        updateValue(value)
    }
}

 
 
 

간단 사용 예제

구현 후 테스트 용으로 간단하게 만들어 본 예제

import SwiftUI
import Combine

final class ParentViewModel: ObservableObject {
    private(set) var currentValuePublisher: CurrentValuePublisher<Int, Never>
    
    enum Action {
        case countUpButtonTapped
    }
    
    func send(action: Action) {
        switch action {
        case .countUpButtonTapped:
            currentValuePublisher.send(currentValuePublisher.value + 1)
        }
    }
    
    init() {
        self.currentValuePublisher = CurrentValuePublisher<Int, Never>(0)
    }
}

protocol ChildViewDependency {
    var readOnlyCurrentValuePublisher: ReadOnlyCurrentValuePublisher<Int, Never> { get }
}

struct DefaultChildViewDependency: ChildViewDependency {
    var readOnlyCurrentValuePublisher: ReadOnlyCurrentValuePublisher<Int, Never>
    
    init(readOnlyCurrentValuePublisher: ReadOnlyCurrentValuePublisher<Int, Never>) {
        self.readOnlyCurrentValuePublisher = readOnlyCurrentValuePublisher
    }
}

final class ChildViewModel: ObservableObject {
    
    @Published var count: Int
    
    private func bind() {
        self.dependency.readOnlyCurrentValuePublisher
            .sink { [weak self] value in
                guard let self else { return }
                self.count = value
            }.store(in: &cancellables)
    }
    
    private let dependency: ChildViewDependency
    private var cancellables: Set<AnyCancellable>
    
    init(dependency: ChildViewDependency) {
        self.dependency = dependency
        self.cancellables = []
        
        self.count = dependency.readOnlyCurrentValuePublisher.value
        
        bind()
    }
}

struct ChildView: View {
    @StateObject var viewModel: ChildViewModel
    
    var body: some View {
        Text("count is : \(viewModel.count)")
    }
}

struct ChildViewBuilder {
    
    func build(
        readOnlyCurrentValuePublisher: ReadOnlyCurrentValuePublisher<Int, Never>
    ) -> ChildView {
        let viewModel = ChildViewModel(
            dependency: DefaultChildViewDependency(readOnlyCurrentValuePublisher: readOnlyCurrentValuePublisher)
        )
        return ChildView(
            viewModel: viewModel
        )
    }
}

struct ParentView: View {
    @StateObject var viewModel = ParentViewModel()
    
    var body: some View {
        VStack {
            Button {
                viewModel.send(action: .countUpButtonTapped)
            } label: {
                Text("count ⬆️ ")
            }
            
            ChildViewBuilder().build(readOnlyCurrentValuePublisher: viewModel.currentValuePublisher)
        }
    }
}

#Preview {
    ParentView()
}