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()
}