Pull to refresh
63.1
ПСБ
ПСБ – один из десяти крупнейших банков страны

Responder Chain, или как правильно передавать действия пользователя между компонентами

Reading time14 min
Views9.9K

Эту статью я решил написать под впечатлением от выступления Евгения Ртищева (@katleta) на конференции Mobius. Так же как и в его докладе, в этой статье я хочу показать, как можно, используя подзабытые нативные средства iOS, без труда выполнять простые и очень частые задачи.

Я расскажу о том, как предельно легко перенаправлять действия пользователя внутри приложения без ненужных усложнений — с помощью нативного инструмента под названием Responder Chain.

Все примеры, описываемые в статье, можно посмотреть тут.

Что такое Responder Chain

На русский язык термин можно перевести как «цепочка ответчиков».

Сам механизм был разработан в 1988 году и стал выдающимся достижением по сравнению с одной монолитной функцией, которая вызывалась при любом действии от пользователя. Максимальное применение он находит в macOS-разработке c его строкой меню и командами, доступными для выполнения из любой части интерфейса приложения. Этот же механизм был принесён на iOS и идеально подошёл для обработки действий пользователя со сложной древовидной структурой экранов и разнообразием навигаций (UITabBarController, UINavigationController, UISplitController, модальные и другие типы контроллеров навигации).

Про сам механизм можно почитать в первоисточнике. Однако про необходимую нам возможность передачи селекторов по цепочке ответчиков Responder Chain у Apple имеется всего один абзац, который не так просто понять на фоне всего остального описания механизма:

Controls communicate directly with their associated target object using action messages. When the user interacts with a control, the control sends an action message to its target object. Action messages are not events, but they may still take advantage of the responder chain. When the target object of a control is nil, UIKit starts from the target object and traverses the responder chain until it finds an object that implements the appropriate action method. For example, the UIKit editing menu uses this behavior to search for responder objects that implement methods with names like cut(_:), copy(_:), or paste(_:).

Перевод

Элементы управления напрямую взаимодействуют с целевым объектом с помощью действий. Когда пользователь использует элемент управления, тот отправляет действие целевому объекту. Действия не являются событиями, но они могут использовать цепочку ответчиков. Когда целевой объект равен nil, то UIKit, начиная с целевого объекта, проходит всю цепочку ответчиков, пока не найдёт объект, в котором есть метод, реализующий действие. Например, меню редактирования UIKit использует это поведение для поиска объектов, которые реализуют методы с такими именами, как cut(_ :), copy(_ :) или paste(_ :).

Видимо, этим вызвана такая малая популярность данного инструмента в iOS-сообществе. Однако на Хабре недавно была статья, более подробно рассказывающая про передачу селекторов. Можно для начала ознакомиться с ней.

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

Открытие URL-ссылки

Каждое приложение обязательно содержит экран с детальной информацией о приложении. Обычно на таком экране располагают ссылку на веб-страницу с правовой информацией. Иногда такие ссылки в приложении могут располагаться на нескольких разных экранах. Например, на splash-скрине и экране «О программе». Как будет выглядеть типичное решение данной задачи? На рисунке ниже я попытался его изобразить.

Как мы видим, 2 наших контроллера общаются с неким роутером, инкапсулирующим в себе весь код, по открытию внешнего URL. Данный роутер общается с экземпляром приложения UIApplication, потому что только этот объект в приложении имеет метод open(_:options:completionHandler:), позволяющий открывать внешние ссылки. Если читатель является фанатом архитектурного паттерна VIPER, часто упоминавшегося на Хабре, то он добавит ещё по 2 презентера между каждым из контроллеров и роутером. А если читатель — сеньор, то он вдобавок воспользуется DI-фреймворком для настройки всего указанного. Полученную в итоге схему приводить не буду, уважаемый читатель сможет представить её сам.

«Что не так с этой схемой?» — спросит меня читатель.

Слишком много зависимостей и лишнего кода для выполнения очень простой задачи.

Давайте посмотрим, как эту задачу можно реализовать с помощью Responder Chain.

Сначала выполним предварительную подготовку:

  1. Создадим пустой проект и добавим вместо имеющегося пустого контроллера — UITabBarController.

  2. В оба контроллера каждой вкладки поместим по одинаковой кнопке, установим им констреинты и название «Открыть Хабр!».

Теперь мы можем перейти к реализации.

Для этого создадим расширение UIApplication:

import UIKit

// MARK: - Actions
extension UIApplication {
    @IBAction func openHabr() {
        guard let url = URL(string: "https://habr.com/ru/feed/") else { return }
        open(url, options: [:])
    }
}

Далее в Interface Builder привязываем событие Touch Up Inside обеих кнопок к только что созданному действию (action) openHabr через иконку First Responder.

Запускаем, кликаем по обеим кнопкам, проверяем результат.

Да-да! Всё очень просто!

Давайте проверим, что получится, если переименовать наше действие и запустить приложение. Приложение собирается, стартует, при нажатии на кнопки краша не происходит (в отличие от прямого вызова селектора у указанного таргета механизм Responder Chain краша не вызывает).

Обращаем внимание, что:

1. Код написан в единственном экземпляре, но доступен из любого отображённого на экране контроллера или контрола. Это благодаря тому, что UIApplication является потомком UIResponder и включён в Responder Chain.

2. Действия не обязательно располагать в том контроллере, которому принадлежит контрол. Лучше располагать его в расширениях того класса, чей метод вам надо использовать для выполнения действия. А вызов этого действия использовать через Responder Chain. Тогда вам не придётся заводить лишние связи, писать код по их инициализации, следить за отсутствием циклов сильных ссылок. В общем, связность увеличиваться не будет, что является одним из основных принципов разработки программного обеспечения.

3. От переименований селекторов действий можно защищаться тестами (юнит-тестами, интеграционными, end-to-end).

Переключение вкладок

Давайте попробуем более сложные примеры и сделаем всё в коде.

Создадим ещё одно расширение. На этот раз на UITabBarController:

import UIKit

extension UITabBarController {
    @IBAction func openItem1() {
        selectedIndex = 0
    }
    
    @IBAction func openItem2() {
        selectedIndex = 1
    }
}

Добавим на первую вкладку кнопку с текстом «Открыть Item2», а на вторую — «Открыть Item1». Кнопки будут переключать таббар-контроллер на другую вкладку. Расставим им констреинты. Привяжем событие Touch Up Inside каждой кнопки к соответствующему действию из расширения таббар-контроллера. Но на сей раз сделаем это в коде.

Скопируем созданный для нас Xcode’ом ViewController и проиндексируем каждую копию цифрами 1 и 2, чтобы различать. В каждом контроллере создаём аутлет (outlet) для соответствующей кнопки, связываем кнопку с этим аутлетом. Не забываем в сториборде указать соответствующий класс для каждого контроллера. И дальше во `viewDidLoad()` каждого контроллера помещаем примерно следующий код:

class ViewController1: UIViewController {

    @IBOutlet public weak var buttonOpenItem2: UIButton!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        buttonOpenItem2.addTarget(nil, 
                                  action: #selector(UITabBarController.openItem2), 
                                  for: .touchUpInside)
    }

}

Запускаем, проверяем: при нажатии на кнопки «Открыть ItemX» происходит переход на соответствующую вкладку.

Обращаем внимание, что в данном случае пришлось написать довольно много кода (в первом примере мы смогли обойтись без создания файла контроллера) и произвести много настроек в Interface Builder.

Responder Chain при работе с ячейками

Использование Segue-переходов

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

Давайте попробуем реализовать.

Снова начнём с подготовки:

  1. Добавим в третью вкладку таббара UINavigationController с вложенным UITableViewController. Назовём эту вкладку Item3.

  2. Создадим примитивное табличное представление с тремя динамическими ячейками: «Ячейка1», «Ячейка2», «Ячейка3». В каждой ячейке имеется кнопка с текстом «Open Modal». Просьба не предъявлять завышенных требований к данному коду. Как правильно создавать табличные представления, мы подробно рассказываем в нашем цикле статей.

Теперь мы можем перейти к реализации. Добавляем в сториборде пустой UIViewController и с кнопки Open Modal настраиваем модальный переход (segue) на вновь созданный контроллер.

Запускаем, проверяем — контроллер действительно открывается модально. Но параметры, соответствующие ячейке и нажатой кнопке, в него пока не передаются и в нём не отображаются.

Давайте это исправим. Для этого добавляем в TableViewController следующие функции и расширения:

class TableViewController: UITableViewController {
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        setData(in: segue.destination, for: sender)
    }
}

// MARK: - Data setter
extension TableViewController {
    
    func setData(in controller: UIViewController, for sender: Any?) {
        guard
            let view = sender as? UIView,
            let item = viewModel(for: view)
        else { return }
        
        let navigationController = controller as? UINavigationController
        navigationController?.topViewController?.title = item
    }
}

// MARK: - List
extension TableViewController: List { }

Функция prepareForSender просто вызывает функцию setData(in controller: UIViewController, for sender: Any?), которой мы ещё воспользуемся в будущем. А пока опишем, что представляет из себя эта функция:

  1. Удостоверяется, что инициатор перехода sender является вьюшкой.

  2. Находит ViewModel, соответствующую sender’у из массива вьюмоделей текущего контроллера TableViewController, удовлетворяющего протоколу List.

  3. Устанавливает найденные данные в целевой контроллер, передаваемый параметром.

Довольно просто.

Обратим внимание, что реализация функции setData(in controller: UIViewController, for sender: Any?) сделана так для упрощения примера. В реальных приложениях она будет более абстрактной, может быть включена в протокол List и переиспользована в единственном экземпляре во всех контроллерах со списками.

Сам протокол List определяет, что контроллер содержит массив вьюмоделей. Также протокол предоставляет дефолтную реализацию функции viewModel(for view: UIView) -> ViewModel?, которая возвращает вьюмодель, соответствующую вьюхе на экране. В реальном приложении протокол будет определять вьюмодель или презентер, содержащий массив вьюмоделей.

protocol List {
    associatedtype ViewModel
    var viewModels: [ViewModel] { get }
    func viewModel(for view: UIView) -> ViewModel?
}

extension List where Self: UITableViewController {
    
    /// Возвращает вьюмодель, соответствующую sender'у от которого пришёл action.
    /// Рекомендованный Apple способ.
    /// - Parameter view: sender экшена
    /// - Returns: соответсвующая sender'у вьюмодель из массива viewModels.
    func viewModel(for view: UIView) -> ViewModel? {
        let point = tableView.convert(view.center, from: view)
        guard let indexPath = tableView.indexPathForRow(at: point) else { return nil }
        return viewModels[indexPath.row]
    }
        
}

Обратим внимание, что индекс конкретных данных в массиве вьюмоделей мы можем получить, имея лишь указатель на объект — инициатор действия. В данном случае — по кнопке ячейки, на которую нажал пользователь. Этот код можно абстрагировать и в единственном экземпляре использовать для всех ваших таблиц, не дублируя его из контроллера в контроллер. Данный код является рекомендуемым Apple для определения indexPath ячейки в таблице или коллекции.

Использование функции present

Теперь давайте повторим без Segue, с помощью функции present(_, animated:, completion:).

Для упрощения примера повторим подготовку, как в прошлый раз: поместим на четвёртую вкладку таббара ещё один табличный контроллер с теми же тремя ячейками.

А контроллеру, выполняющему функцию модального в прошлом примере, зададим Storyboard ID = ModalNavigationController. Это нужно, чтобы не писать код по его созданию вручную, а прогрузить уже имеющийся контроллер.

На этот раз вместо настройки перехода от кнопки в ячейке добавим следующий код в расширение действий TableViewController:

// MARK: - Actions
extension TableViewController {
    @IBAction func openModal(sender: Any) {
        
        guard let navigationController = makeModalViewController() as? UINavigationController else { // builder
            return
        }
        setData(in: navigationController, for: sender)
            
        present(navigationController, animated: true) // routing
    }
    
}

// MARK: - Builder
extension TableViewController {
    func makeModalViewController() -> UIViewController {
        let storyboard = UIStoryboard(name: "Main", bundle: nil)
        let controller = storyboard.instantiateViewController(withIdentifier: "ModalNavigationController")
        return controller
    }
}

Свяжем данное действие с соответствующей кнопкой.

Запускаем, проверяем — всё работает, как мы и ожидали.

Заметим, что код обработки действия получился практически идентичным тому, что находится в функции prepareForSender(). Различия незначительны:

  1. Добавлен код билдера, создающего модальный контроллер для показа — вместо настройки его в сториборде.

  2. Добавлен код роутера, чтобы не использовать для этого сториборд.

Сториборд — это инструмент роутинга и внедрения зависимостей.

Menu в iOS

Статью мы начинали со слов, что Responder Chain разрабатывался для работы с командами меню в macOS. Но ведь своё меню есть и в iOS: UIMenuController. Продемонстрируем, насколько легко работать с контекстным меню в iOS.

Для этого вернёмся к созданному ранее табличному контроллеру TableViewController и реализуем в нём табличный делегат. Как правильно его реализовывать, чтобы не дублировать код, мы уже подробно рассказали в отдельной статье из нашего цикла статей. А пока для простоты реализуем его следующим образом:

    // MARK: - Table view delegate
    
    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        guard let cell = tableView.cellForRow(at: indexPath) else { return }
        
        let selector = #selector(UIViewController.showMenu(sender:))
        let target = cell.target(forAction: selector, withSender: cell) as? UIResponder
        target?.perform(selector, with: cell)
    }

Данный код:

  1. Получает ячейку, в которой произошло нажатие.

  2. Ищет таргет в цепочке ответчиков, чтобы он выполнил селектор UIViewController.showMenu(sender:), показывающий меню.

  3. Заставляет этот таргет выполнить найденный селектор с ячейкой, переданной в качестве параметра.

Для показа меню необходимы 3 вещи:

  1. Объект, который собирается отобразить меню и станет ответчиком (first responder’ом), должен быть потомком UIResponder (не обязательно вьюшка).

  2. Для этого он должен реализовать свойство canBecomeFirstResponder, если оно у него ещё не реализовано.

  3. Реализовать функцию func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool. По умолчанию она проверяет, реализован ли соответствующий селектор для команды меню. Т. е. в нашем случае достаточно реализовать селектор копирования @IBAction open override func copy(_ sender: Any?).

Выполним условия и не забудем реализовать селектор показа меню:

class TableViewController: UITableViewController {
    // MARK: - Menu implementation
    override var canBecomeFirstResponder: Bool {
        return true
    }
}
extension UIViewController {
    @IBAction func showMenu(sender: Any?) {
        guard
            let sender = sender as? UIView,
            becomeFirstResponder()
        else { return }
        
        let menu = UIMenuController.shared
        let rect = view.convert(sender.bounds, from: sender)
        menu.showMenu(from: view, rect: rect)
    }
    
    @IBAction open override func copy(_ sender: Any?) {
        let description = sender ?? "nil"
        print("copy from \(description)")
    }
}

Действие копирования мы реализовали лишь как печать логов в консоль для упрощения примера.

Запускаем, проверяем — в консоль печатается строка типа «copy from Optional(<UIMenuController: 0x6000037d4640>)».

Для реализации аналогичного поведения через Interface Builder перейдём на первую вкладку нашего таббара и добавим в него кнопку с текстом «Показать Menu». Привяжем её действие к селектору showMenu(sender:).

Не забудем про 3 обязательных условия для показа меню. Реализуем свойство canBecomeFirstResponder уже для первого контроллера:

class ViewController1: UIViewController {
    // MARK: - Menu implementation
    override var canBecomeFirstResponder: Bool {
        return true
    }
}

Запускаем, проверяем: по нажатию на кнопку появляется меню, при выборе пункта copy в консоль печатается аналогичная надпись.

Вот так просто можно реализовывать работу с меню в различных частях вашего приложения без дублирования кода.

Обращаем внимание на методы:

1. Установки первого ответчика.

2. Поиска целевого объекта в цепочке ответчиков, способного выполнить необходимое нам действие.

3. Выполнения найденного селектора.

С помощью этих трёх простых методов мы можем выполнить из кода любое действие в дереве наших UI-классов: вьюх, контроллеров, окон (UIWindow), делегата приложения и самого объекта приложения (UIApplication). Более того, мы можем группировать действия и вызывать их поочерёдно.

Обращаем внимание, что экшены необходимо именовать максимально абстрактно, т. к. они выполняют общие действия и могут вызываться из разных мест UI вашего приложения. Например, стоит иметь лишь одну сигнатуру copy(:) для реализации различного функционального поведения в различных частях вашего UI.

Аналогично стоит сделать лишь одну абстрактную реализацию pushDetail(:), которая будет пушить в стек навигации детальный контроллер. И передавать в него данные из соответствующей ячейки. При этом неважно, из какого контроллера, таблицы и ячейки эта универсальная реализация селектора вызвалась. Данную реализацию мы оставляем за скобками и предлагаем читателю попробовать написать её самостоятельно.

Недостатки Responder Chain

  1. Невозможно использовать сигнатуры методов с любым числом и типом параметров. Сигнатуры экшенов, путешествующих по Responder Chain, должны иметь строго заданный вид. Однако на конкретном примере выше мы показали, что Apple предоставляет всё необходимое API, позволяющее найти по вьюшке, передаваемой в сигнатуру метода, соответствующие данные.

  2. Responder Chain можно использовать только с потомками UIResponder. Но это является больше плюсом, нежели минусом, т. к. позволяет ограничить применение инструмента только слоем вью и не опускать его ниже. Если какую-то задачу по передаче действия пользователя вы не можете выполнить из-за этого ограничения, то, скорее всего, вы где-то допустили архитектурное нарушение. Стоит сначала исправить его, и тогда задача просто решается с использованием Responder Chain.

  3. Responder Chain создаёт неявную связь. Мы не указываем конкретный таргет, а лишь говорим направить селектор в некую цепочку ответчиков. При этом содержимое и порядок на этапе компиляции нам неизвестны и могут меняться по ходу работы пользователя с приложением. Самовалидирующийся код — в идеале это здорово, но в реальности редко достижимо. Есть другие средства валидации кода, которые мы можем использовать, чтобы нивелировать данный минус. Например, можно использовать интеграционные или E2E-тесты, проверяющие правильность построения дерева вьюшек, контроллеров и цепочку ответчиков, соответствующую этому дереву.

  4. Все действия имеют глобальную область видимости и видны по всему приложению из любой его точки. Поэтому если создавать действия бездумно, то скоро их станет очень много и в них все будут путаться.

    Особенно проблема остра в Interface Builder, где все действия отображаются в одном прокручивающемся списке. Но проблема легко устраняется, если выработать ясные правила именования. Каждое действие должно быть чётким и лаконичным, а их количество — ограниченным, как команды в меню. Ведь механизм этот вырабатывался именно для работы с меню в macOS. У вас же не возникает проблемы с вызовом команды Print с разных окон десктоп-приложения? По такому же принципу должны именоваться и действия в iOS. Например, нет смысла заводить в различных табличных контроллерах действия типа pushUserDetail или pushAccountDetail. Вместо этого в обоих контроллерах должно быть одно действие pushDetail. Оно открывает окно деталей и передаёт ему идентификатор отображаемой информации. Всё остальное решается инжектированием нужной фабрики и соответствующими generic-протоколами.

    Также эту проблему можно решить микромодулями за счёт ограничения количества функционала в модуле.

Заключение

Мы рассмотрели самые простые и удобные способы применения Responder Chain в типичных iOS-приложениях:

  1. Передача конкретного действия (а не только события) от вьюшек до рутовых контроллеров без единой строчки кода.

  2. Переиспользование одного действия любым из потомков контроллера или вьюшки.

  3. Поиск и определение соответствующих данных в списке (массиве) по одной вьюшке, с которой взаимодействовал пользователь.

  4. Использование Responder Chain как из Interface Builder, так и из кода.

Также в этой статье мы указали недостатки технологии и способы их нивелировать.

Конечно, Responder Сhain не является универсальной реализацией паттерна «Наблюдатель». Поэтому не стоит ждать, что он поможет выполнить вам всякую задачу, связанную с отправкой события любому слушателю.

Однако он идеально подходит для перечисленных и многих других задач. А именно — задач, где необходимо обращение от вложенных вьюшек и контроллеров к вьюшкам и контроллерам более высокого уровня. В том числе при работе с UIApplication или UIApplicationDelegate. Если вы в своём проекте используете 2 указанных класса, то, скорее всего, Responder Chain вам поможет выполнить работу с ними гораздо эффективнее.

Реализации на делегатах (и многие другие способы) ведут к написанию ненужного кода, существенному увеличению связности и уменьшению зацепления. Да, да, те самые циклические зависимости, за которые на code review отрывают руки, а иногда и головы. Responder Chain позволяет сделать граф контролов более простым и понятным, без лишних кросс-пересечений и циклических связей.

Мы не рассказали, как можно комбинировать действия или дебажить Responder Chain. Если у сообщества будет интерес к этой теме, то можем написать ещё одну статью.

В заключение призываем больше использовать нативные инструменты, специально предназначенные для конкретных задач, а не пытаться искать универсальные инструменты под все случаи жизни.

Only registered users can participate in poll. Log in, please.
Используете ли вы Responder Chain?
32.14% Нет и не планирую9
42.86% Нет, но стало интересно попробовать12
25% Да, люблю этот инструмент7
28 users voted. 3 users abstained.
Tags:
Hubs:
Total votes 8: ↑8 and ↓0+8
Comments0

Articles

Information

Website
www.psbank.ru
Registered
Founded
Employees
over 10,000 employees
Location
Россия
Representative
Наталья Низкоус