Доброго времени суток. Эта статья будет полезна тем, кто устал изо дня в день бороться с изменяемостью данных в интерфейсе, тем, кто еще не знает о существовании 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. И мы хотим сделать наше решение как можно проще и чище. Вот как предлагают решать эту проблему наши коллеги:
- Exploring KVO Alternatives With Swift
- Bindings, Generics, Swift and MVVM
- Solving the binding problem with Swift
- Swift Bond
Кстати, не забываем о грядущем выходе 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"
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