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

    Доброго времени суток. Эта статья будет полезна тем, кто устал изо дня в день бороться с изменяемостью данных в интерфейсе, тем, кто еще не знает о существовании 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
      }
    
    }
    


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

    Similar posts

    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 17

      +2
      >Мы уже привыкли описывать (бизнес) логику во ViewController. И это настоящий ад.
      Это потому, что в MVC бизнес логику нужно описывать в модели.
        –2
        Согласен. Видимо я выразился некорректно.
        Но от того, что работа с данными, сетью и т.д. и т.п. остается в моделях, контроллеры от этого не становятся менее уродливыми. Скорее всего этому способствует масса материала, где показывается решение задачи в лоб, и начинающий разработчик её воспринимает как должное, не пытаясь как-то раскидать все по своим местам. Со временем у него накапливается такой опыт проект оказывается неподдерживаемым. Видимо я сам попал в такую ситуацию, но решение нашлось уже на другой стороне :)
          –1
          Просто ViewController это все таки V в MVC.
            0
            Кто не согласен? Готов аргументировать.
              0
              Не я поставил минус, но тоже не согласен.
              Аргументируйте, и за одно расшифруйте остальные символы в аббревиатуре.
                0
                UIViewController в iOS выполняет исключительно связанные с View обязанности, повороты, appear/dissappear, navigation и прочее. То, что туда суют взаимодействие с моделью приводит к другой расшифровке MVC = Massive View Controller. Все с этим сталкивались. Принятие того, что UIViewController это часть View и переход к шаблону MVVM оставляет за ViewController именно те обязаности которые описаны в интерфейсе базового класса. Вот отличная статья на эту тему.
                  0
                  Я понял Вашу шутку, но связи между
                  Просто ViewController это все таки V в MVC.

                  и
                  MVC = Massive View Controller

                  не вижу.
                    –1
                    Если мы говорим о переходе к шаблону MVVM, то ViewController будет в нем ViewModel, предоставляя для View все необходимые данные в нужном формате. Мы ведь сами в storyboard/xib однозначно соединяем представлением с тем или иным классом. В ViewController действительно не должно быть кода, из которого понятно от куда данные пришли и что пользователь/приложение с ними делает.

                    View — это xib или storyboard хоть в MVC, хоть в MVVM.

                    Наглядней всего MVVM реализована в Windows Phone (Silverlight). Советую как-нибудь попробовать, мозги переворачиваются после iOS.
                    0
                    Проблема не в MVC, а в игнорировании паттерна и превращения кода в мясо. Использование другого паттерна не решает проблему того, что этот паттерн тоже игнорируется и код превращяется в неподдерживаемое месево. Вся бизнес логика должна отделяться от MVC, MVVM и прочих букв этого семейства. Бизнес логика дожна жить самостоятельно. Контроллер в MVC должен заниматься контролем и не заниматься обязанностями представления (максимум — делегировать представлению).
                      0
                      Так и есть :) Когда писал свой пост, то мои знания были ограничены лишь MVC паттерном и тем, как он применяется в современных веб фреймворках. Но недавно наткнулся на блог Роберта Мартина (Uncle Bob) и прозрел…
            0
            Не прошла затравка (((
              0
              Чего у Вас не хватает в классе Dynamic, так это методов: unBind и removeListener.
              Иногда ведь нужно отменять подписку не дожидаясь своей смерти (освобождения памяти).
              Наличие этих методов также решает проблему weak, unowned в замыканиях (closures), которые используются в качестве коллбэков. Используете unowned, а в методе deinit вызываете removeListener или unBind.
              Изначально паттерн Observer подразумевает симметричность вызовов add/remove.
              Появляется дополнительная строчка кода, это да, но как по мне, гонясь за локоничностью, Вы можете потерять логичность.

              В целом статья хорошая, расширяет понимание возможностей swift.
                0
                А с потоками такой подход будет работать? Т.е. у нас нечто будет хранить target (ссылка на объект) и callback. При необходимости оповестить слушателей (targets) мы сначала проверяем существование этого target, затем либо удаляем его из списка, либо дергаем callback. Так вот если target удалился в одном потоке, а в этот момент в другом потоке возникла необходимость подергать callback'и, то можно быть уверенным, что все пройдет гладко?
                  0
                  При необходимости оповестить слушателей (targets) мы сначала проверяем существование этого target, затем либо удаляем его из списка, либо дергаем callback.

                  Вы не можете проверить существование target либо другого объекта, так как при обращении к объекту, который освобожден из памяти, мы получим крэш. Это, если я правильно понял смысл слова «проверяем существование target».
                  Я предлагал, чтобы target сам добавлялся и удалялся из подписки, когда ему нужно. Обычно это выполняется в методах init и deinit.
                  Чтобы с несколькими потоками все было гладко, нужно обеспечить потокобезопасный доступ к списку, который хранит слушателей.
                  Судя по всему аналога atomic в swift нет.
                  Видел такой вариант реализации потокобезопасности:
                  objc_sync_enter(self.targets)
                  self.targets.removeAtIndex(index)
                  objc_sync_exit(self.targets)
                  
                    +1
                    Вы не можете проверить существование target либо другого объекта, так как при обращении к объекту, который освобожден из памяти, мы получим крэш. Это, если я правильно понял смысл слова «проверяем существование target».

                    Представлял это как-то так:
                    class SimpleDynamic<T> {
                      private weak var target: AnyObject?
                      private var listener: T -> Void
                      init(value: T, target: AnyObject, listener: T -> Void) {
                        self.value = value
                        self.target = target
                        self.listener = listener
                      }
                      var value: T {
                        didSet {
                          if target != nil {
                            listener(value)
                          }
                        }
                      }
                    }
                    

                    По-моему проверка на nil должна работать. Во всяком случае где-то уже подобное видел.
                    Насчет самостоятельной подписки и отписки: не хотелось бы, чтобы разработчик держал в голове второй пункт.
                    Насчет потокобезопасности. Да, такое уже есть в Bond, только сегодня заметил. Именно Ваш вариант.
                      0
                      Значит изначально я все-таки вас неправильно понял.
                      В таком варианте да, нужно проверять на nil.
                      Получается, target хранится как weak, то есть как Вы хотели — не нужно заботиться о втором пункте.
                      В теле listener можно спокойно использовать self как unowned, так как механизм гарантирует вызов замыкания только пока self жив.
                      Ну и потокобезопасность добавить.
                      Но наличие методов unBind и removeListener я бы все же оставил, так как иногда действительно нужно отписаться где-то в середине жизненного цикла подписчика.
                0
                Но наличие методов unBind и removeListener я бы все же оставил, так как иногда действительно нужно отписаться где-то в середине жизненного цикла подписчика.

                Без этого никуда :) Благодарю за советы.

                Only users with full accounts can post comments. Log in, please.