company_banner

Баги при работе с системной клавиатурой

    Взаимодействуя с приложением, мы в определенный момент активируем системную клавиатуру для набора сообщения или заполнения необходимых полей. Сталкивались ли вы с ситуациями, когда клавиатура отображается, а вот поля для ввода сообщения нет или наоборот — клавиатура есть, куда вводить текст, не видно? Баги могут быть связаны как с проблемами внутри конкретного приложения, так и с общими недостатками системной клавиатуры.

    Константин Мордань, iOS-разработчик из Mail.ru в своей работе повидал всякое: проанализировав способы управления клавиатурой в iOS, он решил поделиться основными найденными багами и подходами, которые применял для их обнаружения и исправления.



    Осторожно: под кат мы поместили много гифок, чтобы наглядно демонстрировать баги. А еще больше примеров вы найдете в видео доклада Константина на AppsConf.

    Реализация вызова системной клавиатуры


    Давайте для начала разберемся, как вообще можно реализовать вызов клавиатуры.

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

    Для реализации задачи можно воспользоваться InputAccessoryView или обработкой системных уведомлений.

    InputAccessoryView


    Давайте рассмотрим первый вариант.

    Во ViewController создаем View, которая будет подниматься вместе клавиатурой, и задаем ей фрейм. Важно, что эту View не надо добавлять в качестве subview. Далее переопределяем свойства canBecomeFirstResponder и возвращаем true. После переопределяем свойство UIResponder — inputAccessoryView и кладем туда View. Для закрытия клавиатуры добавим tapGesture и в его обработчике сбросим firstResponder, у созданной нами View.

    class ViewController: UIViewController {
        var tummyView: UIView {
            let frame = CGRect(x: x, y: y, width: width, height: height)
            let v = TummyView(frame: frame)
            return v
        }
        override var canBecomeFirstResponder: Bool {
            return true
        }
        override var input AccessoryView: UIView? {
            return tummyView
        }
        func tapHandler ( ) {
            tummyView.resignFirstResponder ( )
        }
    } 

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



    Обработка системных уведомлений


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

    • когда клавиатура будет/была показана: keyboardWillShowNotification, keyboardDidShowNotification;
    • когда клавиатура будет/была скрыта: keyboardWillHideNotification, keyboardDidHideNotification;
    • когда фрейм клавиатуры будет/был изменен: keyboardWilChangeFrameNotification, keyboardDidChangeFrameNotification.

    Для реализации нашего кейса возьмем keyboardWilChangeFrameNotification, так как эта нотификация посылается и в случае показа клавиатуры и при ее скрытии.

    Создаем keyboardTracker, в нем подписываемся на получение уведомления keyboardWillChangeFrame, а в обработчике получаем фрейм клавиатуры, конвертируем его из системы координат экрана в систему координат окна, вычисляем высоту клавиатуры и изменяем значение Y у View, которая должна подниматься клавиатурой, на эту высоту.

    class KeyboardTracker {
        func enable ( ) {
        notificationCenter.add0observer(self,
                    seleсtor: #selector( keyboardWillChangeFrame),
                    name: UIResponder.keyboardWillChangeFrameNotification,
                    object: nil)
        }
        func keyboardWillChangeFrame ( notification: NSNotification) {
        let screenCoordinatedKeyboardFrame = 
        (userInfo [ UIResponder.keyboardFrameEndUserInfoKey ] as! NSValue ) .cgRectValue
        let keyboardFrame = window.convert ( screenCoordinatedKeyboardFrame, from: nil ) 
        let windowHeight =  window.frame.height
        let keyboardHeight = windowHeight - keyboardFrame.minY
        delegate.keyboardWillChange ( keyboardHeight )
        }
    } 

    На этом наша задача выполнена, клавиатура поднимается, собирая Айка в канадца.

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

    В поисках багов


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

    Давайте рассмотрим пример. Для этого воспользуемся нашим приложением с Айком: откроем клавиатуру, переключимся в Заметки, напечатаем что-то и вернемся обратно в приложение.



    Какие проблемы уже видны? Во-первых, в App Switcher отсутствует клавиатура, хотя при сворачивании приложения она была, а вместо нее виден иной контент. Во-вторых, при возврате в приложение клавиатуры все еще нет, а ноги Айка опускаются вниз экрана.

    Давайте разберемся в причинах такого поведения. Как мы все помним из схемы жизненного цикла приложения переходы приложения из активного состояния в неактивной сначала в foreground, а после в background занимают время.

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

    При переключении из одного приложения в другое система сбрасывает его firstResponder, что выступает триггером к скрытию клавиатуры. Система отправляет сначала уведомление keyboardWillHide для исчезновения клавиатуры, а затем keyboardDidHideNotification. Уведомление улетает и во второе приложение. В новом приложении мы открываем клавиатуру: система посылает keyboardWillShowNotification для появления клавиатуры и затем досылает keyboardDidShowNotification — демо, с фазами цикла.



    Если вы посмотрите отрывок доклада (с 8:39), то увидите момент, когда после скрытия клавиатуры система посылает keyboardDidHideNotification для перехода первого приложения в неактивное состояние. При переключении в приложение для спорта и запуске клавиатуры система посылает keyboardWillShowNotification. Но так как процесс переключения и запуска быстр, а время перехода между фазами жизненного цикла бывает длиннее, то полученное уведомление обработает не только приложение для спорта, но и приложение для пива, которое еще не успело уйти в background.

    Разобравшись в причинах, давайте теперь найдем решение проблемы с Айком.

    Плохое решение


    Первым в голову приходит идея отписки/подписки на уведомления при сворачивании/разворачивании приложения через enable/disable KeyboardTracker.

    Для отписки используем метод applicationWillResignActive или обработчик аналогичного уведомления от системы, для подписки — applicationDidBecomeActive, но, чтобы ничего не упустить, мы еще поставим нотификацию при методе applicationWillEnterForeground, который вызывается когда приложение заходит в foreground, но еще не становится активным.

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



    Что произошло? После нажатия на кнопку набора голосового сообщения, у приложения сбросился firstResponder, клавиатура закрылась, вызвался метод applicationWillResignActive и мы отписались от нотификаций. После закрытия алерта система восстановила стейт приложения, но до того как вызвался метод applicationWillEnterForeground и тем более applicationDidBecomeActive.

    Хорошее решение


    Иное решение — это применение защитного бульчика (Bool).

    var wasTummyViewFirstResponderBeforeApp0idEnterBackground
    func willResignActive( notification: NSNotification) {
        wasTextFieldFirstResponderBeforeAppDidEnterBackground = tummyView.isFirstResponder
    }
    func willEnterForeground ( notification: NSNotification) {
        if wasTextFieldFirstResponderBeforeAppDidEnterBackground {
            UIView.performWithourAnimation {
                tummyView.becomeFirstResponder ( )
            }
        }
    }

    Запоминаем, была ли открыта клавиатура перед тему, как приложение перестало быть активным, а в методе applicationWillEnterForeground восстанавливаем предыдущий стейт. Единственное, что осталось исправить, — это дыру в app switcher.



    app switcher


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

    Неплохое решение


    Решение можно позаимствовать у банковских приложений, которые научились скрывать чувствительные данные, а еще почитать у Apple.

    Скрыть данные можно в методе applicationDidEnterBackground, заблюрить и показать splash screen, а в методе applicationWillEnterForeground вернуться к обычной иерархии вьюх.

    Нам этот вариант не подходит, так как к моменту вызова метода applicationDidEnterBackground у нашего приложения уже нет клавиатуры.

    Хорошее решение


    Воспользуемся уже знакомыми методами willResignActive, willEnterForeground и didBecomeActive.

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

    func willResignActive( notificaton: NSNotification) {
        let keyWindow = UIApplication.shared.keyWindow
    
        imageView = UIImageView( frame: keyWindow.bounds)
        imageView.image = snapshot ( )
    
        let lastSubview = keyWindow.subviews.last
        lastSubview( imageView)
    }

    В методах willEnterForeground и didBecomeActive восстанавливаем иерархию вьюх и удаляем наш снэпшот.

    func willEnterForeground( notification: NSNotification) {
    	imageView.removeFromSuperview( )
    }
    
    func didBecomeActive( notification: NSNotification) {
    	imageView.removeFromSuperview( )
    }

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

    Плохая новость


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



    Это не проблема только нашего приложения, такое поведение наблюдается и у Facebook, работающего с нотификациями, и даже у iMessage, использующего для управления клавиатурой inputAccessoryView. Это происходит из-за того, что перед переходом в фон приложения успевают обработать чужие клавиатурные нотификации.

    interactively keyboard dismissing


    Добавим немного функционала нашему приложению с Айком, научив программу интерактивно скрывать клавиатуру.



    Плохое решение


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

    func panGestureHandler( ) {
        let yPosition: CGFloat = value
        keyboardView( )?.frame.origin.y = yPosition
    }

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

    func keyboardView( ) -> UIView? {
        let windows = UIApplication.shared.windows
    
        let view = windows.first { (window) -> Bool in return keyboardView( fromWindow: window) != nil
        }
        return view
    }

    К сожалению, это решение не будет нормально работать на iPhone X и выше, так как при движении пальца можно слегка задеть нижний индикатор, отвечающий за сворачивание приложение. После этого интерактивное скрытие перестает работать.



    Проблема кроется в массиве окон.



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



    Получается, что, итерируясь по массиву окон, мы находим первое, удовлетворяющее условия, и начинаем с ним работать, не смотря на то, что оно скрыто.

    Как это исправляется? Переворачиванием массива окон.

    func panGeastureHandler( ) {
        let yPosition: CGFloat = 0.0
        keyboardView( )?.frame.origin.y = yPosition
    }
    func keyboardView( ) -> UIView? {
        let windows = UIApplication.shared.windows.reversed( )
        let view = windows.first { (window) -> Bool in
            return keyboardView( fromWindow: window) != nil
        }
        return view
    }

    Особенности работы с клавиатурой на iPad


    У клавиатуры на iPad помимо обычного состояния есть undocked состояние. Пользователь может перемещать ее по экрану, делить на две части и даже запускать приложение в режиме slide over (поверх другого). Конечно же, важно, чтобы во всех эти режимах клавиатура работала без багов.

    Проверим на нашем Айке.



    Увы, сейчас это не так. После того, как пользователь начинает двигать клавиатуру по экрану, ноги Айка улетают выше головы и появляются на месте только после следующего открытия клавиатуры. Попробуем это исправить на кейсе со сплитом клавиатуры.

    Причины


    Начнем с анализа уведомлений. После нажатия на кнопку split получаем две группы уведомлений — keyboardWillChangeFrameNotification, keyboardWillHideNotification, keyboardDidChangeFrameNotification, keyboardDidHideNotification. Отличие групп лишь в координатах клавиатуры.

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

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

    Почему же тогда ноги Айка улетают, как только мы начинаем двигать клавиатуру по экрану?

    В этот момент система посылает нам keyboardWillChangeFrameNotification, но координаты, которые там лежат — (0.0, 0.0, 0.0, 0.0), так как система не знает, в какой именно точке окажется клавиатура, после того как движение закончится.

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

    Хорошее решение


    Чтобы решить нашу проблему, для начала научимся понимать, когда клавиатура находится в undocked-режиме и пользователь может двигать ее по экрану.

    Для этого достаточно сравнить высоту окна и maxY клавиатуры. Если они равны, то клавиатура в своем обычном состоянии, если maxY меньше высоты окна, то пользователь двигает клавиатуру. В результате в keyboardTracker появляется следующий код:

    class KeyboardTracker {
        func enable( ) {
            notificationCenter.addObserver( self,
                    selector:#selector( keyboardWillChangeFrame),
                    name:UIResponder.keyboardWillChangeFrameNotification,
                    object:nil)
        }
        func keyboardWillChangeFrame( notification: NSNotification) {
            let screenCoordinatedKeyboardFrame = 
            (userInfo[UIResponder.keyboardFrameEndUserInfoKey] as! NSValue).cgRectValue
            let leyboardFrame = window.convert ( screenCoordinatedKeyboardFrame, from: nil)
            let windowHeight = window.frame.height
            let keyboardHeight = windowHeight - keyboardFrame.minY
            let isKeyboardUnlocked = isIPad ( ) && keyboardFrame/maxY < windowHeight
            if isKeyboardUnlocked {
                keyboardHeight = 0.0
            }
            delegate.keyboardWillChange ( keyboardHeight)
        }
    }

    Мы кастомно задали высоту равную нулю и теперь при движении клавиатуры ноги Айка опускаются вниз и фиксируются там.



    Единственное оставшееся недоразумение — это тот факт, что при сплите клавиатуры ноги Айка не сразу опускаются вниз. Как это исправить?

    Научим keyboardTracker работать не только с keyboardWillChangeFrameNotification, но и с keyboardDidChangeFrame. Новый код писать не придется, достаточно добавить проверку, что это iPad, чтобы не делать лишних вычислений.

    class KeyboardTracker {
        func keyboardDidChangeFrame( notification: NSNotification) {
            if isIPad ( ) == false {
                return
            }




    Как же обнаружить баги?


    Обильное логирование


    На нашем проекте логи пишутся в следующем формате: в квадратных скобках имя модуля и сабмодулей, к которому относится лог, а затем текст самого лога. Например, вот так:
    [keyboard][tracker] keyboardWillChangeFrame: calculated height - 437.9

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

    class KeyboardTracker {
        init(with logger: Logger) {
            self.trackerLogger = logger.dequeue(withTag: "[tracker]")
        }
    
        func keyboardWillChangeFrame(notification: NSNotification) {
            let height = 0.0
    
            trackerLogger.debug("\(#function): calculated height - \(height)")
        }
    }

    Таким образом я залогировал весь keyboardTracker, что хорошо. При обнаружении же тестировщиками проблем я брал файл с логами и искал, где именно не сошлись фреймы. Это занимало слишком много времени, поэтому кроме логирования стали применяться и другие приемы.

    Watchdog


    У нас на проекте для оптимизации UI потока используется Watchdog. Об этом рассказывал Дмитрий Куркин на одном из прошлых AppsConf.

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

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

    class Watchdog {
        var timer: Timer?
        func start ( ) {
            timer = Timer ( timeInterval: 1.0, repeats: true, block: { ( timer ) in
                self.woof ( ) 
            } )
        }
    }

    Кстати, логировать можно не только конечные результаты, но и промежуточные расчеты.

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

    А что если watchdog можно обучить не только находить проблемы, но и исправлять их?

    В код, где watchdog дает заключение о том, что координаты вьюх не сходятся, дописываем метод fixTummyPosition и автоматически ставим координаты на место.

    В этом варианте у меня в логах скапливается куча полезной информации, а пользователи вообще не замечают визуальных проблем. Это вроде отлично, но теперь не получится узнавать о каких-либо проблемах с клавиатурой.

    На помощь приходит добавление в метод watchdog возможности генерации тестового крэша при обнаружении ошибки. Конечно, этот код добавляется под ремоут конфигом.

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

    Dashboard


    Последний прием, который мы ввели — это отправка статистики в том момент, когда wahtchdog фиксировал статистику. По полученным данным мы построили график количества выявляемых ошибок и уже после первой итерации число срабатываний удалось уменьшить в четыре раза. Конечно, до нуля снизить проблемы не удалось, но основные жалобы от пользователей прекратились.

    Уже на следующей неделе в Питере пройдет Saint AppsConf, на которой можно задать вопросы не только Константину, но и многочисленным докладчикам iOS-трека.
    • +18
    • 3,3k
    • 3
    Конференции Олега Бунина (Онтико)
    345,30
    Конференции Олега Бунина
    Поделиться публикацией

    Комментарии 3

      0

      Спасибо за отличный текст. Все в одном месте. Обычно это делается один раз и потом забывается.

        0
        >клавиатура отображается, а вот поля для ввода сообщения нет или наоборот — клавиатура есть, куда вводить текст, не видно

        Вот уж наоборот так наоборот. Очевидно, системная клавиатура тут, как бы, ни при чём )
          0
          Из кода непонятно как выглядит функция
          keyboardView( fromWindow: window)

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое