Биндинги в Swift. Делаем первые шаги к MVVM

в 3:41, , рубрики: bindings, ios development, mvvm, swift, разработка под iOS

Доброго времени суток. Эта статья будет полезна тем, кто устал изо дня в день бороться с изменяемостью данных в интерфейсе, тем, кто еще не знает о существовании MVVM, и тем, кто сомневается, что данный паттерн можно успешно применять на практике при разработке iOS приложений. Заинтересовавшихся прошу под кат.

Предыстория с кучей лирики

Не могу назвать себя опытным iOS разработчиком. Хотя знакомство с миром iOS состоялось несколько лет назад, стремление прокачаться в разработке приложений под iOS появилось у меня совсем недавно. Путь мой был тернист. Obj-C сразу не впечатлил, хотелось разрабатывать приложения на чем-то знакомом. Поэтому в ход шли PhoneGap, Apcelerator Titanium и вот это все. Но, естественно из этих начинаний ничего не вышло. После длительного перерыва компания, в которой я работаю, всерьез задумалась над разработкой мобильного приложения. Не стал ничего выдумывать и упрощать себе жизнь — выполнил работу исключительно на ObjC без использования сторонних фреймворков. И это была боль для меня. Простые вещи оказались сложными, я не мог управиться с autolayout, на код было невозможно смотреть. Поэтому в следующем проекте в ход был пущен Xamarin Forms. Поработав порядка двух месяцев над проектом, стало ясно, что данная технология еще далека от совершенства (в итоге оказалось, что проект находился в beta статусе, но об этом мало где упоминалось). Но за время работы с Xamarin Forms я проникся многими паттернами, которыми был пропитан этот проект, более того мне пришлось сделать кучу кастомных компонентов, что привело к более ясному пониманию работы UIKit. В тот момент, когда стало ясно, что наш проект должен быть переписан на натив, Swift стремительно приближался к релизу. Я прочел книгу по этому языку и чувствовал в себе силы, чтобы начать все сначала. Но первый опыт все еще напоминал о себе, поэтому я стал копать в сторону MVVM в iOS. Уж больно понравилась мне эта концепция.

На тот момент все статьи, что попадались мне на глаза, предлагали решать проблему с помощью ReactiveCocoa. Взглянув на примеры кода этой библиотеки, я понял, что мне еще учиться и учиться, т.к. я ничего не понимал в том, что видел. Для Swift также предлагали использовать ReactiveCocoa. Собственно статья от Colin Eberhardt стала для меня отправной точкой. Но вскоре мне пришлось столкнуться с тем, что подход, описанный вышеупомянутым автором, приводил к утечкам памяти. Видимо я что-то делал не так и тогда не понимал что именно. Плюс ReactiveCocoa оставался для меня черной коробкой. Было решено избавиться от этой библиотеки, учитывая, что использовалась она лишь для связывания view моделей с view. Наткнулся на проект Observable Swift, который решал проблему связывания. Вскоре наш проект был завершен, а на горизонте новый, и мне хотелось к нему основательно подготовиться.

Постановка задачи

На данный момент я не могу представить себе, как можно безболезненно привнести MVVM в UIKit. Имеется ввиду тот самый MVVM, который я увидел в Xamarin Forms и который меня так впечатлил. Скорее всего для этого придется написать фрэймворк поверх UIKit и привязать разработчика к этому фрэймворку. Мы же пойдем по пути наименьшего сопротивления: будем использовать то, что нам дает Apple. Но при этом будем стремиться к более декларативному описанию UI.

Первое и главное, что привлекло меня в MVVM — это динамической связывание ViewModel и View. Это нам позволяет описывать бизнес логику в отрыве от представления. Мы уже привыкли описывать логику во ViewController. И это настоящий ад. Давайте стремиться к минимизации кода во ViewController. Для начала нужно научиться понимать, что состояние нашей ViewModel изменилось и это изменение необходимо отразить в UI. Apple предлагает нам воспользоваться, например, KVO. ReactiveCocoa упростил бы эту задачу. Но ведь у нас Swift. И мы хотим сделать наше решение как можно проще и чище. Вот как предлагают решать эту проблему наши коллеги:

Кстати, не забываем о грядущем выходе Reactive Cocoa 3.0. Но пока библиотечка Bond является наиболее подходящей для нашей задачи. Пока я работал над тем, что покажу ниже, Bond только начинал свое существование и не подходил под мои требования. Он и сейчас под них немного не подходит, плюс ко всему мне показалось, что разработчик как-то все усложнил. Мне же хотелось как можно сильней все упростить. Но, по правде говоря, заходя в тупик во время работы над своим видением того, как должны связываться данные с представлениями, я то и дело находил ответы в исходниках Bond.

Dynamic

Начнем с малого и, вместе с тем, самого главного. Нам необходимо иметь возможность узнавать об изменениях состояния какой-либо переменной и как-то реагировать на эти изменения. Напомню, что мы стремимся к простоте и лаконичности. И в этом случае Swift предстает во всей красе. Он нам дает дженерики, лямбды с потрясающим синтаксисом, observable properties. Так давайте сваяем из этого нечто.

class Dynamic<T> {
  init(_ v: T) {
    value = v
  }
  var value: T {
    didSet {
      println(value)
    }
  }
}

Теперь у нас появилась возможность следить за изменением значения value. На практике это будет выглядеть примерно так:

let dynamicInt: Dynamic<Int> = Dynamic(0)
println(dynamicInt.value)
dynamicInt.value = 1
dynamicInt.value = 17

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

class Dynamic<T> {
  typealias Listener = T -> ()
  private var listeners: [Listener] = []
  init(_ v: T) {
    value = v
  }
  var value: T {
    didSet {
      for l in listeners { l(value) } }
  }
  func bind(l: Listener) {
    listeners.append(l)
    l(value)
  }
  func addListener(l: Listener) {
    listeners.append(l)
  }
}

Метод addListener просто добавляет хэндлер в свой список слушателей, а метод bind делает тоже самое, но при этом сразу вызывает добавленного слушателя и передает ему текущее значение value.

let dynText: Dynamic<String> = Dynamic("")
dynText.bind { someLabel.text = $0 }
dynText.addListener { otherLabel.text = $0 }
dynText.value = "New text"

Благодаря использованию дженериков нам не нужно проверять или делать приведение типов данных. Компилятор сделает это за нас. Например в следующем случае код не будет скомпилирован:

let dynInt: Dynamic<Int> = Dynamic(0)
dynInt.bind { someLabel.text = $0 }

Компилятор знает, что аргумент нашего слушателя типа Int и мы не можем присвоить значение этого аргумента полю text объекту класса UILabel, так как тип этого поля String. Более того, благодаря упрощенному синтаксису анонимных функций мы получили возможность добавлять слушателей без лишней писанины. Но нет предела совершенству. Мы же можем определить пару-тройку операторов, либо перегрузить имеющиеся с целью еще большего сокращения кода.

func >> <T>(left: Dynamic<T>, right: T -> Void) {
  return left.addListener(right)
}
infix operator >>> {}
func >>> <T>(left: Dynamic<T>, right: T -> Void) {
  left.bind(right)
}

let dynText: Dynamic<String> = Dynamic("")
dynText >>> { someLabel.text = $0 }
dynText >> { otherLabel.text = $0 }
dynText.value = "New text"

Мысли про unowned, weak, Void и Void?

На практике описанные выше примеры приведут к утечкам памяти. Вот пример:

class MyViewController: UIViewController {
  @IBOutlet weak var label: UILabel!
  let viewModel = MyViewModel()
  override func viewDidLoad() {
    viewModel.someText >>> { self.label.text = $0 }
    super.viewDidLoad()
  }
}

Очевидно, что теперь функция-слушатель и self жестко связаны друг с другом и объект класса MyViewController никогда не удалиться. Чтобы этого не случилось, необходимо ослабить связь:

viewModel.someText >>> { [unowned self] in self.label.text = $0 }

Так лучше. Но есть одно но. Нет гарантии, что функция-слушатель не будет вызвана после удаления объекта MyViewController. Чтобы обезопасить себя, мы используем weak:

viewModel.someText >>> { [weak self] in self?.label.text = $0 }

Но в таком случае код не будет скомпилирован, т.к. наш слушатель имеет тип String -> Void?, а должен иметь тип String -> Void для успешной компиляции. Поэтому изначально я добавил в Dynamic два типа слушателей: с возвращаемыми значениями Void и Void?.. Соответственно перегрузил методы bind и addListener для поддержки двух типов слушателей. Но вскоре выяснилось, что компилятор не может определить какой именно метод вызывать, если сделать, например, так:

viewModel.someText >>> { [weak self] in if self != nil { self!.label.text = $0 } }

Поэтому от идеи поддержки двух типов слушателей пришлось отказаться и воспользоваться подобными ухищрениями:

viewModel.someText >>> { [weak self]  in self?.label.text = $0; return }
viewModel.someText >>> { [weak self]  in self?.label.text = $0; () }
viewModel.someText >>> { [weak self]  v in v; self?.label.text = v }

Конечно, можно было бы отказаться от использования weak в пользу передачи динамическому объекту помимо функции-обработчика еще и ссылки на объект и не вызывать функцию, если объект вдруг оказался удаленным. Именно такой подход и используется в библиотеке Bond. Но это был не мой путь :)

Упрощение работы с UIKit

Согласитесь, неприятно постоянно описывать одни и те же лямбды для связывания текста и UILabel. Хочется простоты:

viewModel.someText >>> label

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

final class PropertyModifier<T> {
  typealias Modifier = (T) -> ()
  let modifier: Modifier

  init (_ l: Modifier) {
    self.modifier = l
  }
}

Объект класса PropertyModifier будет создаваться самим видом, а в конструктор будет передаваться лямбда с кодом, который изменяет значение у определенного поля вида.

private var UILabelPropertyKeyTextModifier: UInt8 = 0
extension UILabel {
  var textModifier: PropertyModifier<String?> {
    if let pm: AnyObject = objc_getAssociatedObject(self, &UILabelPropertyKeyTextModifier) {
      return pm as PropertyModifier<String?>
    } else {
      let pm = PropertyModifier<String?> { [weak self] in self?.text = v; () }
      objc_setAssociatedObject(self, &UILabelPropertyKeyTextModifier, pm, objc_AssociationPolicy(OBJC_ASSOCIATION_RETAIN_NONATOMIC))
      return pm
    }
  }
}

Замечу, что в extension мы не можем описывать хранимые поля (stored properties), поэтому на помощь приходит ObjC Runtime и функции objc_setAssociatedObject, objc_getAssociatedObject. Теперь мы можем делать так:

viewModel.someText >>> label.textModifier.modifier

Давайте упростим:

func >> <T>(left: Dynamic<T>, right: PropertyModifier<T>) {
  left.addListener(right.modifier)
}
func >>> <T>(left: Dynamic<T>, right: PropertyModifier<T>) {
  left.bind(right.modifier)
}

viewModel.someText >>> label.textModifier

Куда лучше. Но это еще не все. Мы можем выделить какое-либо наиболее используемое свойство вида и назначить ему PropertyModifier по-умолчанию.

protocol BindableObject {
  typealias DefaultPropertyModifierTargetType
  var defaulPropertytModifier: PropertyModifier<DefaultPropertyModifierTargetType> { get }
}
extension UILabel: BindableObject {
  typealias DefaultPropertyModifierTargetType = String?
  var defaulPropertytModifier: PropertyModifier<DefaultPropertyModifierTargetType> {
    return textModifier
  }
}
func >> <T, B: BindableObject where B.DefaultPropertyModifierTargetType == T>(left: Dynamic<T>, right: B) {
  left.addListener(right.defaulPropertytModifier.modifier)
}
func >>> <T, B: BindableObject where B.DefaultPropertyModifierTargetType == T>(left: Dynamic<T>, right: B) {
  left.bind(right.defaulPropertytModifier.modifier)
}

Вот и все. UILabel обзавелся стандартным PropertyModifier, который изменяет значения поля text. И мы пришли к назначенной цели, а именно можем создать связь следующим образом:

viewModel.someText >>> label

Команды

Одна из примечательных концепций в Xamarin Forms, которая пришлась мне по душе — это команды. На деле мы можем описать команду с помощью двух функций: одна возвращает true или false, указывая на то, что команда может быть выполнена, а вторая — действие, которое выполняет команда. Допустим у нас есть кнопка (UIButton). У кнопки есть поле enabled, кнопка может быть нажата пользователем, после чего должно произойти какое-то действие. Помните, что мы стремимся к декларативности описания поведения интерфейса? Так давайте распространим эту идею на наши контролы.

final class Command<T> {
  typealias CommandType = (value: T, sender: AnyObject?) -> ()
  weak var enabled: Dynamic<Bool>?
  private let command: CommandType
  init (enabled: Dynamic<Bool>, command: CommandType) {
    self.enabled = enabled
    self.command = command
  }
  init (command: CommandType) {
    self.command = command
  }
  func execute(value: T) {
    execute(value, sender: nil)
  }
  func execute(value: T, sender: AnyObject?) {
    var enabled = true
    if let en = self.enabled?.value { enabled = en }
    if enabled { command(value: value, sender: sender) }
  }
}

protocol Commander {
  typealias CommandType
  func setCommand(command: Command<CommandType>)
}

func >> <T, B: Commander where B.CommandType == T>(left: B, right: Command<T>) {
  left.setCommand(right)
}

private var UIButtonPropertyKeyCommand: UInt8 = 0
extension UIButton: Commander {
  typealias CommandType = ()
  func setCommand(command: Command<CommandType>) {
    if let c: AnyObject = objc_getAssociatedObject(self, &UIButtonPropertyKeyCommand) {
      fatalError("Multiple assigment to command")
      return
    }
    objc_setAssociatedObject(self, &UIButtonPropertyKeyCommand, command, objc_AssociationPolicy(OBJC_ASSOCIATION_ASSIGN))
    command.enabled?.bind { [weak self] in self?.enabled = $0; () }
    addTarget(self, action: Selector("buttonTapped:"), forControlEvents: .TouchUpInside)
  }
  func buttonTapped(sender: AnyObject?) {
    if let c: Command<CommandType> = objc_getAssociatedObject(self, &UIButtonPropertyKeyCommand) as? Command<CommandType> {
      c.execute((), sender: sender)
    }
  }
}

Итак, у нас появилась команда, у которой есть поле enabled и функция, которая должна быть выполнена при вызове метода execute. Мы должны связать нашу команду с кнопкой. Для этого завели протокол Commander с методом setCommand. Реализуем наш протокол для UIButton, связав динамическое поле команды enabled с соответствующим свойством UIButton. Так же мы перегрузили оператор >> для удобства. Что получаем в итоге:

class PageModel {
  let nextPageEnabled: Dynamic<Bool> = Dynamic(true)
  lazy var openNextPage: Command<()> = Command (
    enabled: self.nextPageEnabled,
    command: {
      [weak self] value, sender in
      //Open next page
    })
}

class MyViewController: UIViewController {
  @IBOutlet weak var nextButton: UIButton!
  let pageModel = PageModel()
  override func viewDidLoad() {
    nextButton >> pageModel.openNextPage
    super.viewDidLoad()
  }
}

Заключениe

В нашем распоряжении появились динамические объекты, которые мы можем связать с чем угодно. У нас появились команды, которые позволяют описать действие по нажатию на кнопку более выразительно. И этого уже достаточно, для того, чтобы упростить наши UIViewController. За кадром остались map и filter для Dynamic, двунаправленные биндинги и упрощенная работа с UITableView. Но на это вы можете взглянуть и самостоятельно. Проект с демонстрацией возможностей описанного подхода доступен на GitHub. Рекомендую на него взглянуть.

Пара примеров для затравки

class TwoWayBindingPage: Page {
  typealias PMT = TwoWayBindingPageModel
  @IBOutlet weak var switchLabel: UILabel!
  @IBOutlet weak var switchControl: UISwitch!
  @IBOutlet weak var switchButton: UIButton!
  @IBOutlet weak var textFieldLabel: UILabel!
  @IBOutlet weak var textField: UITextField!
  @IBOutlet weak var textFieldButton: UIButton!
  @IBOutlet weak var sliderLabel: UILabel!
  @IBOutlet weak var slider: UISlider!
  @IBOutlet weak var sliderButton: UIButton!
  override func bindPageModel() {
    super.bindPageModel()
    let pm = pageModel as PMT
    switchButton >> pm.changeSomethingEnabled
    textFieldButton >> pm.changeUserName
    sliderButton >> pm.changeAccuracy
    pm.somethingEnabled | { "Current dynamic value: ($0)" } >>> switchLabel
    pm.userName | { "Current dynamic value: ($0)" } >>> textFieldLabel
    pm.accuracy | { "Current dynamic value: ($0)" } >>> sliderLabel
    pm.somethingEnabled <<>>> switchControl
    pm.userName <<>>> textField
    pm.accuracy <<>>> slider
  }
}
class BeerListPage: Page {
  typealias PMT = BeerListPageModel
  @IBOutlet weak var tableView: UITableView!
  private var tableViewHelper: SimpleTableViewHelper!
  override func bindPageModel() {
    super.bindPageModel()
    let pm = pageModel as PMT
    tableViewHelper = SimpleTableViewHelper(tableView: tableView, data: pm.beerList, cellType: BeerTableCell.self, command: pm.openBeerPage)
    tableView.pullToRefreshControl >> pm
    tableView.infiniteScrollControl >> pm
  }

}

Спасибо за внимание. Замечания, предложения и критика приветствуются.

Автор: NayZaK

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js