При разработке практически любого мобильного приложения разработчику придётся столкнуться с полями ввода. А где поля ввода — там и клавиатура, а также логика, связанная с обработкой событий её жизненного цикла: появления, сокрытия, изменения размеров.

Кто разрабатывал приложение под iOS, знает, что работа с клавиатурой — это часть очень похожего или даже одинакового кода, название которому — копипаста. Как мы с ним в Surf боролись и насколько удалось сократить кодовую базу, поговорим в статье.

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

// метод, в котором происходит подписка на события от NotificationCenter
func subscribeOnKeyboardNotifications() {
    let center = NotificationCenter.default
    center.addObserver(self,
                       selector: #selector(keyboardWillBeShown(notification:)),
                       name: UIResponder.keyboardWillShowNotification,
                       object: nil)
    center.addObserver(self,
                       selector: #selector(keyboardWillBeHidden(notification:)),
                       name: UIResponder.keyboardWillHideNotification,
                       object: nil)
}

// метод, вызываемый при появлении клавиатуры
@objc
func keyboardWillBeShown(notification: Notification) {
    // пытаемся получить доступ к высоте клавиатуры и времени анимации
    guard
        let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect,
        let animationTime = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double
    else {
        return
    }
    let keyboardHeight = keyboardFrame.height
    // выполняем код
}

// метод, вызываемый при сокрытии клавиатуры
@objc
func keyboardWillBeHidden(notification: Notification) {
    // пытаемся получить доступ к высоте клавиатуры
    guard
        let animationTime = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double
    else {
        return
    }
    // выполняем код
}

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

А потом появится третий экран, четвёртый… Думаю, вы уже поняли, к чему я клоню: на всех экранах появится абсолютно одинаковый код, отличающийся только обработкой событий. Высока вероятность, что разработчик будет набирать код не с нуля, а повторять с помощью Cmd+C/Cmd+V.

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

  • Позволяла подписаться на события клавиатуры или отписаться от них.

  • Вызывала заранее определённый метод на события появления и сокрытия клавиатуры.

  • При этом передавала туда не сырой объект Notification, а данные нужного типа: animationDuration, keyboardFrame и так далее. 

  • Освобождала от работы с NotificationCenter, позволяя не вспоминать мучительно каждый раз нужные ключи и типы данных.

  • Освобождала от необходимости копипастить код.

Как мы утилиту делали

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

  • Где выполнять обработку событий?

  • Как с ViewController-ом связать объект, где будет происходить эта обработка?

  • Как сделать так, чтобы где-нибудь хранилась strong-ссылка на подобный объект?

Базовые классы — не наш подход. Хочется сделать систему гибкой: базовые классы этому способствовать не будут.

Понять первую версию утилиты поможет схема и небольшой листинг ниже:

public protocol KeyboardObservable: class {
    func subscribeOnKeyboardNotifications()
    func unsubscribeFromKeyboardNotifications()
    func keyboardWillBeShown(notification: Notification)
    func keyboardWillBeHidden(notification: Notification)
}

Принцип работы таков:

  • Указываем, что ViewController удовлетворяет протоколуKeyboardObservable.

  • Протокол содержит 4 метода. Два из них — subscribe и unsubscribe — реализованы в дефолтном расширении этого протокола, так что потребности в их реализации нет.

  • В процессе подписки на нотификации создается объект observer, который содержит слабую ссылку на ViewController. Он будет отвечать за обработку событий появления и сокрытия клавиатуры: именно его методы будут вызываться при срабатывании нотификаций.

  • Если клавиатуры покажется или сокроется, observer вызовет два соответствующих метода у view.

Это решает часть проблемы: теперь нет необходимости реализовывать методы подписки и отписки от нотификаций, так как подобная логика будет реализована в одном месте. Но остается необходимость обработать объект Notification и вытянуть из него необходимые параметры — то есть нужно реализовать два оставшихся метода протокола KeyboardObservable.

Чтобы решить эту проблему, мы предусмотрели протоколы, обозначенные на схеме как <Specific>KeyboardPresentable. Они могут иметь следующий вид:

public protocol CommonKeyboardPresentable: class {
    func keyboardWillBeShown(keyboardHeight: CGFloat, duration: TimeInterval)
    func keyboardWillBeHidden(duration: TimeInterval)
}

Для применения протокола необходимо указать, что ViewController, помимо KeyboardObservable, удовлетворяет еще и протоколу CommonKeyboardPresentable, и реализовать его методы. 

У протокола CommonKeyboardPresentable есть extension, где реализуются два оставшихся метода протокола KeyboardObservable. В момент их вызова из объекта Notification извлекаются необходимые параметры и вызываются соответствующие методы протокола CommonKeyboardPresentablе.

Теперь не нужно копипастить логику обработки полезной нагрузки из нотификации — она будет реализована в одном месте. При этом остаётся возможность расширить механизм и написать собственный <Specific>KeyboardPresentable, в котором методы будут иметь необходимые именно вам параметры.

Отдельного внимания заслуживает способ хранения объекта observer в памяти. На схеме место его хранения обозначено как Pool.

  • Pool — хранилище observer-ов, которое держит на каждый из них strong-ссылку и не даёт уйти из памяти.

  • Каждый observer держит weak-ссылку на ViewController, для которого он был создан.

  • Таким образом удалось избежать reference-cycle между ViewController-ом и соответствующим ему observer-ом.

  • Остаётся проблема «бесхозных» observer-ов, когда объект observer будет содержать view == nil. Это кейс, когда ViewController ушел из жизни, а observer остался. Проблема решается путем периодической очистки пула от таких объектов.

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

  • Объявить, что ViewController поддерживает протокол KeyboardObservable.

  • Поправить появившиеся в Xcode ошибки, реализовав два метода этого протокола.

  • Либо объявить, что ViewController поддерживает SpecificKeyboardPresentable протокол, и реализовать его методы.

Структура класса может выглядеть так:

final class ViewController: UIViewController, KeyboardObservable {
    ...
}

extension ViewController: CommonKeyboardPresentable {

    func keyboardWillBeShown(keyboardHeight: CGFloat, duration: TimeInterval) {
        // do something useful
    }

    func keyboardWillBeHidden(duration: TimeInterval) {
        // do something useful
    }

}

При этом в качестве SpecificKeyboardPresentable вы можете использовать уже готовые протоколы, которые содержит утилита (например, CommonKeyboardPresentable), либо написать свой. Достаточно только чтобы он удовлетворял протоколу KeyboardObservable и реализовывал два метода, которые отсутствуют в дефолтной реализации.

Как бонус — структура KeyboardInfo, упрощающая работу со словарем userInfo нотификации:

extension Notification {

    public struct KeyboardInfo {
        public var frameBegin: CGRect?
        public var animationCurve: UInt?
        public var animationDuration: Double?
        public var frameEnd: CGRect?
    }

    public var keyboardInfo: KeyboardInfo

}

Результат: минус сотни строк абсолютно одинакового кода

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

И самое важное: теперь можно не вспоминать каждый раз названия ключей для Notification. Даже названия методов из протоколов помнить необязательно: Xcode предложит вставить объявление пропущенных методов за вас. Всё, что остается, — только добавить реализацию.

Полный код этой и других утилит — в репозитории Surf.

Комментарии (0)