RxSwift + PropertyWrapper: инкапсуляция и синтаксический сахар

от автора

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

Основа кода для BehaviorPublishSubject почти не отличается от аналогичной для 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.


ссылка на оригинал статьи https://habr.com/ru/post/671036/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *