Согласно последнему опросу российских команд iOS разработки made by iOS Good Reads, архитектура MVVM занимает лидирующую строчку в хит-параде, этого подхода придерживаются 59% опрошенных. А как известно, наиболее частый спутник MVVM - реактивный подход. Наша команда Upstarts - не исключение, мы используем MVVM + RxSwift последние 5 лет на большинстве проектов, и за это время столкнулись с множеством проблем и челленджей, написали десятки расширений, оберток и сформировали свой собственный пул инструментов для максимального удобства работы с RxSwift.

В этом материале я раскрою и предложу решение для одной из самых распространенных проблем при работе с Rx свойствами - инкапсуляцией прав на чтение / запись, а также предложу удобную запись для инкапсулированных Rx свойств.

Для желающих скипнуть лирику и сразу смотреть финальный код

Добро пожаловать на github: Rx+Output.swiftRx+Input.swift.

Суть проблемы

Рассмотрим кейс на примере ViewModel.

В классическом представлении ViewModel использует Rx свойства для того, чтобы с их помощью получать какие-то данные на вход от сервисов, баз данных или других модулей (Input), обрабатывать эти данные, а затем на выход (Output) отдавать контент для презентационного слоя, контекст для роутинга или какие-то данные/команды для других дочерних или зависимых модулей.

Упрощенная схема взаимодействия с ViewModel
Упрощенная схема взаимодействия с ViewModel

Для примера, условная ViewModel с одним Rx свойством может выглядеть так:

protocol ViewModelProtocol {
    var text: BehaviorRelay<String> { get }
}

class ViewModel: ViewModelProtocol {
    let text = BehaviorRelay<String>(value: "initial text")
}

View хранит ее инстанс и должна иметь доступ к чтению свойства:

var viewModel: ViewModelProtocol!

/// Читать и подписываться - ок
viewModel.text.bind(to: label.rx.text)

Проблема в том, что при такой записи View получает доступ и к записи в text, что нам совсем не нужно, это чревато багами и является нарушением инкапсуляции:

/// Одновременно с правом на чтение, View сможет записывать значения
/// в Relay / Subject. Это нарушение инкапсуляции
viewModel.text.accept("some new text")

Такая же проблема актуальна и для случаев записи - иногда нужно раскрыть свойство только на запись, без возможности подписаться или получить другие детали реализации извне.

Существующие решения

Способов спрятать реактивное проперти существует как минимум несколько, а пытливый ум вполне может придумать и свой собственный, изощеренность которого зависит только от фантазии и ограничений языка.

Самый банальный способ привнести инкапсуляцию в код выглядит так:

/// Приватная реализация конкретного BehaviorRelay
private let _text = BehaviorRelay(value: "initial text")

/// Ну а в протокол можно добавить этот Observable,
/// чтобы разрешить только подписку.
var text: Observable<String> { _text.asObservable() }

Очевидный минус такого подхода - каждое свойство превращается в два, а если их у вас 10 или 20? Решение массивное и требует много boilerplate.

Одним энтузиастом предлагался RxProperty, который под капотом держит BehaviorRelay. По задумке автора, использование RxProperty выглядит так:

class ViewModel<T> {
    // Hide variable.
    private let _state = BehaviorRelay<T>(...)

    // `Property` is a better type than `Observable`.
    let state: Property<T>
    
    init() {
        self.state = Property(_state)
    }
}

Можно согласиться с тем, что "Property is a better type than Observable". Лучше он только тем, что помимо .asObservable() предоставляет еще и текущий value. В остальном - точно так же дублируется объявление переменной, а код выглядит не менее сложно, чем в предыдущем варианте.

В статье на Medium раскрывается решение этой задачи через propertyWrapper @ReadWrite. Это уже смотрится гораздо лучше:

@propertyWrapper
final class ReadWrite<Element> {

    var wrappedValue: RxProperty<Element>

    init(wrappedValue: RxProperty<Element>) {
        self.wrappedValue = wrappedValue
    }
    
    var projectedValue: BehaviorRelay<Element> {
        return wrappedValue._behaviorRelay
    }
}

// Usage:
@ReadWrite let state = RxProperty(initialValue)

Плюсы: свойство state можно объявить одной строкой. Минусы: использование ограничено RxProperty, и соответственно, BehaviorRelay, а остальные типы реактивных свойств нам как будто и не нужны.

Output: желаемый результат

Как бы выглядела декларация реактивных свойств без проблем с инкапсуляцией? Удобная и минималистичная? Такая, чтобы можно было использовать любой тип, а не только BehavorRelay?

Время пофантазировать. Корневую обертку назовем Output, а основных юз-кейсов выделим 7 штук:

/// `BehaviorRelay`
@Output.Relay(value: "initial value")
var output_1: Observable<String>

/// `PublishRelay`
@Output.Relay()
var output_2: Observable<String>

/// `BehaviorSubject`
@Output.Subject(value: "initial value")
var output_3: Observable<String>

/// `PublishSubject`
@Output.Subject()
var output_4: Observable<String>

/// `ReplaySubject`
@Output.Subject(replay: .once)
var output_5: Observable<String>

/// `Completable`
@Output.Completable
var output_6: Completable

/// `Single`
@Output.Single()
var output_7: Single<String>

Базовая реализация Output.Stream

Приступаем к воплощению задуманных деклараций. Для начала опишем сущность Output.Stream, которая сможет оборачивать любое ObservableConvertibleType свойство:

/// Output - название корневой обертки
struct Output {
    
    /// Stream - базовый класс для будущих конкретных реализаций
    @propertyWrapper
    class Stream<Element, RxPropertyType: ObservableConvertibleType> where Element == RxPropertyType.Element {

        /// Rx Свойство будем хранить открыто.
        /// Доступ к нему пригодится
        let rx: RxPropertyType

        /// Обязательная реализация для любого @propertyWrapper
        var wrappedValue: Observable<Element> {
            rx.asObservable()
        }
    }
}

Что с инициализацией? Тут посложнее. Для BehaviorRelay и BehaviorSubject нужно сразу задать начальное значение, тогда как для Publish- и ReplaySubject свойств оно не нужно. Придется написать отдельный init для Behavior-based свойств. А чтобы не плодить совсем уже одинаковых init-ов, для начала объединим Behavior-based свойства под один протокол:

protocol RxBehaviorPropertyInitializable: ObservableType {
    init(value: Element)
}

extension BehaviorSubject: RxBehaviorPropertyInitializable {}
extension BehaviorRelay: RxBehaviorPropertyInitializable {}

Теперь напишем первый init:

class Stream<...> where ... {
	  
		let rx: RxPropertyType
		
		// MARK: - Init with `RxBehaviorPropertyInitializable`
		
		init(value: RxPropertyType.Element,
		     _ rxPropertyType: RxPropertyType.Type) where RxPropertyType: RxBehaviorPropertyInitializable {
		    rx = rxPropertyType.init(value: value)
		}	
}

Аналогичным образом добавим поддержку PublishSubject и PublishRelay:

protocol RxPublishPropertyInitializable: ObservableType {
    init()
}

extension PublishSubject: RxPublishPropertyInitializable {}
extension PublishRelay: RxPublishPropertyInitializable {}

Инициализатор для Publish- свойств выглядит так:

class Stream<...> where ... {
	  
		let rx: RxPropertyType
		
		// MARK: - Init with `RxPublishPropertyInitializable`
		
		init(_ rxPropertyType: RxPropertyType.Type) where RxPropertyType: RxPublishPropertyInitializable {
        rx = rxPropertyType.init()
    }
}

Теперь мы можем создавать Observable, под капотом которого может быть любой из четырех типов. Пока что выглядит не идеально, но первый кирпич заложен:

// 1. BehaviorRelay
@Output.Stream(value: .zero, BehaviorRelay.self)
var someOutput: Observable<Int>

// 2. BehaviorSubject
@Output.Stream(value: .zero, BehaviorSubject.self)
var someOutput: Observable<Int>

// 3. PublishSubject
@Output.Stream(PublishSubject.self)
var someOutput: Observable<Int>

// 4. PublishRelay
@Output.Stream(PublishRelay.self)
var someOutput: Observable<Int>

Синтаксис для Relay

Чтобы сделать запись более приятной, расширим Output.Stream, добавив синтаксис для Relay:

extension Output {
    
    @propertyWrapper
    class Relay<Element, RxPropertyType: ObservableConvertibleType>: Stream<Element, RxPropertyType> where Element == RxPropertyType.Element {
        
        init(value: Element) where RxPropertyType == BehaviorRelay<Element> {
            super.init(value: value, BehaviorRelay.self)
        }
        
        init<Element>() where RxPropertyType == PublishRelay<Element> {
            super.init(PublishRelay.self)
        }
       
        override var wrappedValue: Observable<Element> {
            rx.asObservable()
        }
    }
}

По итогу мы имеем вот такие записи, что уже соответствует изначальной идее:

/// `BehaviorRelay`
@Output.Relay(value: "initial value")
var output_1: Observable<String>

/// `PublishRelay`
@Output.Relay()
var output_2: Observable<String>

Синтаксис для Subject + Replay

Основа кода для Behavior- PublishSubject почти не отличается от аналогичной для Relay. Но помимо них в RxSwift есть еще другие штуки вроде ReplaySubject и AsyncSubject. Первый - довольно частый гость в наших проектах, поэтому я бы добавил удобный код и для него.

При инициализации ReplaySubjectпринимает размер буфера, то есть количество элементов, повторяемых для каждого нового подписчика. Для начала обернем размер буфера в синтаксически более приятный enum:

enum RxReplayStrategy {
    case once, all, custom(Int), none
    
    var count: Int? {
        switch self {
        case .none: return 0
        case .once: return 1
        case .custom(let count): return count
        default: return nil
        }
    }
}

И добавим к нам в Output.Stream новый инициализатор:

class Stream<...> where ... {

    let rx: RxPropertyType

    // MARK: - Init with `ReplaySubject`

    fileprivate init(replay: RxReplayStrategy) where RxPropertyType == ReplaySubject<Element> {
        let replaySubject: ReplaySubject<RxPropertyType.Element>
        if let bufferSize = replay.count {
            replaySubject = ReplaySubject.create(bufferSize: bufferSize)
        } else {
            replaySubject = ReplaySubject.createUnbounded()
        }
        rx = replaySubject
    }
}

Обертка для Subject будет выглядеть так:

extension Output {
    
    @propertyWrapper
    class Subject<Element, RxPropertyType: ObservableConvertibleType>: Stream<Element, RxPropertyType> where Element == RxPropertyType.Element {
        
        init(value: Element) where RxPropertyType == BehaviorSubject<Element> {
            super.init(value: value, BehaviorSubject.self)
        }
        
        init() where RxPropertyType == PublishSubject<Element> {
            super.init(PublishSubject.self)
        }
        
        override init(replay: RxReplayStrategy) where RxPropertyType == ReplaySubject<Element> {
            super.init(replay: replay)
        }
      
        override var wrappedValue: Observable<Element> {
            rx.asObservable()
        }
    }
}

Получилось довольно вкусно:

/// `BehaviorSubject`
@Output.Subject(value: "initial value")
var output_3: Observable<String>

/// `PublishSubject`
@Output.Subject()
var output_4: Observable<String>

/// `ReplaySubject`
@Output.Subject(replay: .once)
var output_5: Observable<String>

Completable

Иногда раскрыть соседям нужно лишь одну простую команду. Например, в случае с прогресс-баром это может быть сигнал о том, что он заполнился на 100% и завершил свою анимацию. В RxSwift для таких случаев есть Completable. На существующий каркас его реализация ложится очень просто:

extension Output {
    
    @propertyWrapper
    class Completable: Stream<Never, PublishSubject<Never>> {
        
        init() {
            // `PublishSubject` хорошо подходит для основы `Completable`.
            super.init(PublishSubject.self)
        }
        
        var wrappedValue: RxSwift.Completable {
            rx.asCompletable()
        }
        
        /**
         Функция для удобный байндингов любых событий
         к событию `completed` нашего `Completable`
         */
        func complete<Element>() -> AnyObserver<Element> {
            AnyObserver { [weak rx] observer in
                rx?.onCompleted()
            }
        }
    }
}

Использование выглядит так:

@Output.Completable
var output: Completable

А что насчет Input?

В случае c Output, все реактивные свойства можно привести к ObservableConvertibleType, то есть, на выходе получить Observable<Element>.

С Input ситуация сложнее. Приемник событий в RxSwift, как правило, ObserverType. Но Relay-свойства под него не подписаны, поскольку в ObserverType можно передать любой Event, включая события error и completed.

Так что теперь поколдуем немного над Relay свойствами:

/// Под одним протоколом объединим Relay-based свойства
protocol RxRelayPropertyAcceptable: AnyObject {
    associatedtype Element
    func accept(_ event: Element)
}

extension BehaviorRelay: RxRelayPropertyAcceptable {}
extension PublishRelay: RxRelayPropertyAcceptable {}

Затем, нам нужен некий объект, который будет являться ObserverType и сможет принимать на вход события, независимо от того, что у него под капотом - Relay или Subject. Назовем его AnyRxInput:

class AnyRxInput<Value>: ObserverType {
    
    typealias Element = Value
    
    /// Свойства - приемники событий
    private let acceptValue: (Value) -> Void
    private var acceptError: ((Error) -> Void)?
    private var complete: (() -> Void)?
    
    /**
      Инициализатор для Relay-based свойств
     */
    init<RxRelayProperty: RxRelayPropertyAcceptable>(_ relay: RxRelayProperty) where RxRelayProperty.Element == Value {
        acceptValue = { [weak relay] value in
            relay?.accept(value)
        }
    }
    
    /**
      Инициализатор для Subject-based свойств, 
      которые сами по себе являются `ObserverType`
     */
    init(_ observer: AnyObserver<Value>) {
        acceptValue = { value in
            observer.onNext(value)
        }
        acceptError = { error in
            observer.onError(error)
        }
        complete = {
            observer.onCompleted()
        }
    }
    
    /**
      Единственная необходимая реализация для `ObserverType`
     */
    func on(_ event: Event<Element>) {
        switch event {
        case .next(let element):
            acceptValue(element)
        case .error(let error):
            acceptError?(error)
        case .completed:
            complete?()
        }
    }
}

Дальнейшая реализация Input.Stream мало чем отличается от Output.Stream, за исключением использования AnyRxInput вместо Observable. Приведу пример только для Relay:

struct Input {
 
    @propertyWrapper
    class Stream<Value, RxPropertyType: ObservableConvertibleType> where Value == RxPropertyType.Element {
        
        /// Rx свойство под капотом остается доступным
        let rx: RxPropertyType

        /// Обернутое свойство для записи извне
        fileprivate let input: AnyRxInput<Value>
        
        /**
         Инициализатор для BehaviorRelay, который конформит
         `RxBehaviorPropertyInitializable` & `RxRelayPropertyAcceptable`
         */
        init(value: Value,
             _ rxPropertyType: RxPropertyType.Type) where RxPropertyType: RxBehaviorPropertyInitializable & RxRelayPropertyAcceptable {
            let rxProperty = rxPropertyType.init(value: value)
            rx = rxProperty
            input = .init(rxProperty)
        }
        
        var wrappedValue: AnyRxInput<Value> {
            input
        }
    }
}

Обертка для Relay:

extension Input {
    
    @propertyWrapper
    class Relay<Value, RxPropertyType: ObservableConvertibleType>: Stream<Value, RxPropertyType> where Value == RxPropertyType.Element {
        
        init(value: Value) where RxPropertyType == BehaviorRelay<Value> {
            super.init(value: value, BehaviorRelay.self)
        }
        
        init() where RxPropertyType == PublishRelay<Value> {
            super.init(PublishRelay<Value>.self)
        }
        
        override var wrappedValue: AnyRxInput<Value> {
            input
        }
    }
}

Использование выглядит аналогично Output:

/// `BehaviorRelay`
@Input.Relay(value: "initial value")
var input_1: AnyRxInput<String>

/// `PublishRelay`
@Input.Relay()
var input_2: AnyRxInput<String>

Результаты

Итак, инкапсулированные Rx свойства теперь можно записывать следующим образом:

// MARK: - Output
    
/// `BehaviorRelay`
@Output.Relay(value: "????")
var output_1: Observable<String>

/// `PublishRelay`
@Output.Relay()
var output_2: Observable<String>

/// `BehaviorSubject`
@Output.Subject(value: "????")
var output_3: Observable<String>

/// `PublishSubject`
@Output.Subject()
var output_4: Observable<String>

/// `ReplaySubject`
@Output.Subject(replay: .once)
var output_5: Observable<String>

/// `Completable`
@Output.Completable
var output_6: Completable

// MARK: - Input

/// `BehaviorRelay`
@Input.Relay(value: "????")
var input_1: AnyRxInput<String>

/// `PublishRelay`
@Input.Relay()
var inputB_2: AnyRxInput<String>

/// `BehaviorSubject`
@Input.Subject(value: "????")
var input_3: AnyRxInput<String>

/// `PublishSubject`
@Input.Subject()
var input_4: AnyRxInput<String>

/// `ReplaySubject`
@Input.Subject(replay: .once)
var input_5: AnyRxInput<String>

Что дальше?

В этом материале я привел упрощенную реализацию Input / Output. У себя в проектах мы используем более полную версию, в которой есть пара дополнительных фич.

Во-первых, mutators для модификации Observable, на практике может выглядеть так:

/// Под капотом лежит BehaviorRelay<String>
/// Но с помощью mutator мы раскрываем Observable<Int>
/// Который считает количество символов в строке
@Output.Relay(value: "some_text", mutator: { $0.map(\.count) })
var charCount: Observable<Int>

Также, по аналогии с остальными типами, добавили поддержку Single:

@Output.Single()
var output: Single<String>

Выводы

Достаточно интересных результатов можно добиться, если поставить целью лаконизацию привычного кода. В творческом процессе проникаешься особенностями языка, узнаешь несколько новых фишек и вдохновляешься на новые решения на основе полученного опыта.

Данное решение не претендует на роль идеального. Но некоторые идеи могут быть развиты и дальше, а с приходом новых версий Swift (как там комбайн, эппл?) и вовсе преображаться до неузнаваемости.

Обертывание Rx свойств обернулось (извините за тавтологию) относительно большим количеством кода. Но такой trade-off есть всегда при написании фреймворка: либо core будет супер простой, но массивный usage, либо наоборот - под капотом будет спрятана массивная реализация, а ее использование станет лаконичным. Я являюсь сторонником второго подхода, поэтому доволен реализацией и у себя в команде мы повсеместно ее используем.

Периодически мы улучшаем и расширяем код, а полную его версию можно посмотреть и взять на вооружение у нас на github: Rx+Output.swiftRx+Input.swift.

Комментарии (1)


  1. adsa1
    13.06.2022 11:39

    Круто!