Согласно последнему опросу российских команд 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.
adsa1
Круто!