Согласно последнему опросу российских команд iOS разработки made by iOS Good Reads, архитектура MVVM занимает лидирующую строчку в хит-параде, этого подхода придерживаются 59% опрошенных. А как известно, наиболее частый спутник MVVM - реактивный подход. Наша команда Upstarts - не исключение, мы используем MVVM + RxSwift последние 5 лет на большинстве проектов, и за это время столкнулись с множеством проблем и челленджей, написали десятки расширений, оберток и сформировали свой собственный пул инструментов для максимального удобства работы с RxSwift.
В этом материале я раскрою и предложу решение для одной из самых распространенных проблем при работе с Rx свойствами - инкапсуляцией прав на чтение / запись, а также предложу удобную запись для инкапсулированных Rx свойств.
Для желающих скипнуть лирику и сразу смотреть финальный код
Добро пожаловать на github: Rx+Output.swift, Rx+Input.swift.
Суть проблемы
Рассмотрим кейс на примере ViewModel.
В классическом представлении ViewModel использует Rx свойства для того, чтобы с их помощью получать какие-то данные на вход от сервисов, баз данных или других модулей (Input), обрабатывать эти данные, а затем на выход (Output) отдавать контент для презентационного слоя, контекст для роутинга или какие-то данные/команды для других дочерних или зависимых модулей.

Для примера, условная 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.swift, Rx+Input.swift.
