Мы начали работу над проектом, поставив перед собой две цели:
- масштабируемая архитектура: нам нужна была возможность легко добавлять новые типы сообщений без ущерба для написанного ранее кода;
- хорошая производительность: мы хотели обеспечить плавную загрузку и прокрутку сообщений.
В этой статье будет подробнее рассказано о том, как мы достигли поставленных целей, какие методы при этом использовались и что у нас получилось в конечном счете. На нашей странице на GitHub выложено довольно подробное описание архитектуры приложения.
Что лучше: UICollectionView или UITableView?
В нашем старом чате использовался UITableView. Он вполне хорош, однако UICollectionView предлагает более богатый API с большим количеством возможностей для настроек (анимации, UIDynamics и т. д.) и оптимизации (UICollectionViewLayout и UICollectionViewLayoutInvalidationContext).
Более того, мы изучили несколько уже существующих приложений для чата и оказалось, что все они используют именно UICollectionView. Поэтому решение в пользу выбора UICollectionView было само собой разумеющимся.
Текстовые сообщения
Ни один чат не может обойтись без облачков с текстом. По правде говоря, в плане производительности труднее всего реализовать именно этот тип сообщений, поскольку рендеринг и масштабирование текста выполняются медленно. Мы хотели, чтобы чат автоматически обнаруживал ссылки и выполнял штатные действия, как это делает iMessage.
В UITextView изначально имеется поддержка всех этих требований, так что для обработки ссылок нет нужды писать ни строчки кода. Поэтому мы и выбрали этот класс, однако это решение стало для нас источником проблем. Далее мы расскажем почему.
Auto Layout и Self-Sizing Cells
Лейаут и вычисление размеров всегда вызывают трудности: очень легко написать дублирующийся код, а его сложнее поддерживать и он приводит к появлению багов, так что мы стремились этого избежать. Поскольку мы с самого начала обеспечивали поддержку iOS 8, было принято решение попробовать auto layout и sizing cells. Вот ветка с общим описанием реализации такого подхода. Попробовав, мы столкнулись с двумя крупными проблемами:
- скачки во время прокрутки. Обычно в чатах прокрутка происходит снизу вверх, поэтому в начале считаются размеры нижних ячеек, а затем, во время прокрутки, считаются размеры ячеек, появляющихся сверху. При этом точный размер ячеек, расположенных сверху, заранее не известен, а для расчета contentSize и положения нижних ячеек UICollectionView использует указанный estimatedItemSize. Для того чтобы получить точный размер ячейки, UICollectionViewFlowLayout вызывает метод preferredLayoutAttributesFittingAttributes(_:) у UICollectionViewCell. Затем, поскольку этот размер не соответствует указанному estimatedItemSize, выполняется корректировка положения созданных ранее ячеек, что приводит к их смещению вниз. Мы могли бы обойти этот баг, повернув UICollectionView и UICollectionViewCells на 180? (нижние ячейки на самом деле были бы самыми верхними), но была еще одна проблема, а именно…
- низкая производительность прокрутки. Мы не могли добиться прокрутки со скоростью 60 кадров в секунду даже при точно рассчитанных размерах ячеек. Узким местом оказался Auto Layout и изменение размеров UITextView. Нас это не очень удивило, ведь мы знали, что Apple не использует Auto Layout внутри ячеек в iMessage. Из этого совершенно не следует вывод о том, что Auto Layout не стоит использовать; на самом деле в Badoo используют его очень широко. Однако же он действительно имеет проблемы с производительностью, затрагивая, как правило, UICollectionView и UITableView
Ручной лейаут
Итак, для лейаута вместо Auto Layout мы решили использовать традиционный подход. Мы остановились на классическом методе, в рамках которого для вычисления размеров применяется ячейка-болванка, а для лейаута и подсчета размеров использовалось бы как можно больше общего кода. Этот подход работал гораздо быстрее, однако этого все еще было недостаточно для iPhone 4s. Профилирование выявило слишком большой объем работы внутри метода layoutSubviews.
По сути, мы дважды выполняли одну и ту же работу: в начале считали размеры в болванке, а затем делали это снова в реальной ячейке внутри layoutSubviews. Чтобы решить эту проблему, можно было бы кешировать значения sizeThatFits(_:) для UITextView, подсчет которых обходится очень дорого, но мы пошли еще дальше и создали модель лейаута, в рамках которой вычислялись и записывались в кеш размер ячейки и фреймы всех subview. В результате нам удалось не только заметно повысить скорость прокрутки, но и с максимальной эффективностью повторно использовать код между вызовами sizeThatFits(_:) и layoutSubviews.
Кроме того, наше внимание привлек метод updateViews. При небольшом размере, это оказался один из основных методов, ответственных за обновление ячейки в соответствии с заданным стилем и типом отображаемых данных. Наличие одного основного метода для обновления UI упрощало логику и сопровождение кода в будущем, но при этом он вызывался практически для каждого действия, изменяющего свойства ячеек. Чтобы справиться с этой проблемой, мы придумали два способа оптимизации.
- Два разных контекста: .Normal и .Sizing. Контекст .Sizing мы использовали для нашей ячейки-болванки, чтобы пропускать некоторые избыточные вызовы updateViews (например, обновление изображения облачка или отключение функции обнаружения ссылок в UITextView).
- Пакетное обновление: мы реализовали функцию performBatchUpdates(_:animated:completion) для ячеек. Это позволило нам обновлять свойства ячеек сколько угодно раз, но при это вызывать updateViews всего единожды.
Еще больше скорости
Мы уже добились хорошей скорости прокрутки, однако загрузка большего количества сообщений (пакетами по 50 единиц) приводила к слишком длительной блокировке основного потока, а это, в свою очередь, на долю секунды приостанавливало прокрутку. Конечно же, узким местом снова оказалась функция UITextView.sizeThatFits(_:). Нам удалось ее значительно ускорить, отключив в ячейке-болванке возможности обнаружения ссылок и выделения текста и включив несмежное позиционирование (non-contiguous layout):
textView.layoutManager.allowsNonContiguousLayout = true
textView.dataDetectorTypes = .None
textView.selectable = false
После этого одновременный показ 50 новых сообщений перестал быть проблемой — при условии, что до этого сообщений было не очень много. Но мы решили, что можно пойти еще дальше.
Учитывая уровень абстракций, которого мы достигли за счет кеширования и повторного использования модели лейаута для выполнения задач по расчету размеров и положения, теперь у нас было все необходимое, чтобы попробовать выполнять вычисления в фоновом потоке. Но… не считая UIKit.
Как вам известно, UIKit не потоко-безопасен, и наша первоначальная стратегия (которая заключалась в простом игнорировании этого факта) привела к ряду ожидаемых сбоев в работе UITextView. Мы знали, что можно было использовать метод NSString.boundingRectWithSize(_:options:attributes:context) в фоновом режиме, но возвращаемые им размеры не соответствовали размерам, полученным из UITextView.sizeThatFits(_:). Мы потратили немало времени, но все же смогли найти решение:
textView.textContainerInset = UIEdgeInsetsZero
textView.textContainer.lineFragmentPadding = 0
Мы также использовали округление размеров, получаемых из NSString.boundingRectWithSize(_:options:attributes:context), до экранных пикселей с помощью
extension CGSize {
func bma_round() -> CGSize {
return CGSize(width: ceil(self.width * scale) * (1.0 / scale), height: ceil(self.height * scale) * (1.0 / scale) )
}
}
Таким образом, мы могли готовить кеш в фоновом потоке, а затем очень быстро получать все размеры в основном потоке — при условии, что лейауту не приходилось иметь дело с 5000 сообщений.
В этом случае во время вызова метода UICollectionViewLayout.prepareLayout() iPhone 4s начинал тормозить. Главным узким местом оказалось создание объектов UICollectionViewLayoutAttributes и получение размеров для 5000 сообщений из NSCache. Каким образом мы решили эту проблему? Мы сделали то же самое, что и с ячейками: создали модель для UICollectionViewLayout, которая занималась созданием UICollectionViewLayoutAttributes, и точно так же перенесли ее создание в фоновый поток. Теперь в основном потоке мы просто заменяли старую модель новой. И все стало работать потрясающе быстро, но…
Вращение и режим Split View
Во время вращения устройства или же изменения размера Split View менялась доступная ширина для показа сообщений, поэтому нужно было считать все размеры и положения сообщений заново. Для нас это не представляло особой проблемы, так как наше приложение не поддерживает вращение, но мы уже тогда собирались выпускать Chatto в open source и решили, что достойная поддержка вращения и Split View была бы для этих целей большим плюсом. К тому времени мы уже реализовали вычисление размеров в фоновом потоке с плавной прокруткой и загрузкой новых сообщений, но это не особо помогало в случаях, когда приложению приходилось иметь дело с 10 000 сообщений. Чтобы вычислить размеры для такого большого количества сообщений в фоне, iPhone 4s требовалось от 10 до 20 секунд, и, конечно же, нельзя было заставлять пользователей ждать так долго. Мы видели два способа решения проблемы:
- вычислять размеры дважды: в первый раз — для текущей ширины, а во второй — для ширины, которую приняло бы сообщение на устройстве после его поворота на 90?.
- избегать необходимости иметь дело с 10 000 сообщений.
Первый вариант является, скорее, хаком, чем собственно решением — оно не особо помогает в режиме Split View и не масштабируется. Поэтому мы и выбрали второй способ.
Скользящий источник данных
После нескольких тестов на iPhone 4s мы пришли к выводу, что поддержка быстрого вращения означала обработку не более 500 сообщений, поэтому мы реализовали скользящий источник данных с настраиваемым количеством одновременно показываемых сообщений. В соответствии с этим, при открытии чата вначале должно было загружаться 50 сообщений, а затем подгружалась бы следующая порция из 50 сообщений, по мере того как пользователь прокручивал чат, чтобы увидеть более ранние записи. Когда пользователь прокручивал назад достаточно большое количество сообщений, первые из них удалялись из памяти. Таким образом, разбивка на страницы работала в обе стороны. Реализация этого метода была довольно простой задачей, однако теперь у нас возникала проблема в другом случае — когда источник данных уже был заполнен и при этом поступало новое сообщение.
Если уже было получено 500 сообщений и поступало новое, то нужно было удалять самое верхнее сообщение, сдвигать все остальные на одну позицию вверх и вставлять в чат только что поступившее. С решением этого тоже не возникло трудностей, однако такой подход не нравился методу UICollectionView.performBatchUpdates(_:completion:). Было две основных проблемы (их можно воспроизвести здесь):
- медленная прокрутка и скачки при получении большого количества новых сообщений;
- сломанная анимация при добавлении сообщения в связи изменением contentOffset.
Для устранения этих проблем мы решили ослабить ограничение, предусматривающее максимально допустимое количество сообщений. Теперь мы разрешили приложению вставлять новые сообщения, нарушая установленный лимит и обеспечивая тем самым плавное обновление UICollectionView. После выполнения вставки и при отсутствии необработанных изменений в очереди обновлений мы отправляли источнику данных предупреждение о том, что поступает слишком много сообщений. После этого мы производили необходимые корректировки с помощью reloadData, а не performBatchUpdates. Поскольку мы не могли особо контролировать момент, когда именно это произойдет, и учитывая, что пользователь мог прокрутить чат в любую позицию, нам требовалось сообщать источнику данных, в какое место разговора пользователь прокрутил чат, чтобы не удалить те сообщения, которые в данный момент просматривались:
public protocol ChatDataSourceProtocol: class {
...
func adjustNumberOfMessages(preferredMaxCount preferredMaxCount: Int?, focusPosition: Double, completion:(didAdjust: Bool) -> Void)
}
Хаки UITextView
Итак, мы пока рассмотрели только проблемы с производительностью Auto Layout и подсчетом размеров, а также препятствия на пути решения задачи по вычислению размеров в фоновом потоке с помощью NSString.boundingRectWithSize(_:options:attributes:context).
Чтобы воспользоваться возможностью обнаружения ссылок и некоторыми другими доступными действиями, нам пришлось активировать свойство UITextView.selectable. Это привело к некоторым нежелательным побочным эффектам для облачков (например, появилась возможность выбора текста и появления лупы). Кроме того, для поддержки этих возможностей UITextView использует систему распознавания жестов, которая мешала выполнению таких действий, как выделение облачков с текстом и обработка долгих нажатий внутри них. Мы не будем детально рассказывать о том, с помощью каких хаков нам удалось обойти эти проблемы, но вы можете сами узнать об этом больше, пройдя по ссылкам: ChatMessageTextView и BaseMessagePresenter.
Интерактивная клавиатура
Кроме вышеупомянутых проблем, работа UITextView сказывалась и на клавиатуре. По идее, в наши дни реализация интерактивного скрытия клавиатуры должна быть довольно простой задачей. Достаточно переопределить inputAccessoryView и canBecomeFirstResponder в используемом контроллере, как показано здесь. Однако же этот метод работал неэффективно при показе UIActionSheet из UITextView, когда пользователь выполнял долгое нажатие на какую-либо ссылку.
Суть проблемы заключалась в том, что меню появлялось под клавиатурой и его совсем не было видно. Вот еще одна ветка, в которой вы можете сами поиграться с этой проблемой (rdar://23753306).
Мы пробовали сделать поле ввода частью иерархии view controller, отслеживать уведомления, поступающие с клавиатуры, и вручную изменять contentInsets у UICollectionView. Однако при взаимодействии пользователя с клавиатурой уведомления не поступали, и поле ввода показывалось в центре экрана, оставляя зазор между ним и клавиатурой, когда пользователь тянул клавиатуру вниз. Эта проблема решается с помощью специального хака, который заключается в использовании фиктивного inputAccessoryView (расположенного под полем ввода) и наблюдения за ним с помощью KVO. Более подробно прочитать об этом можно здесь.
Резюме
- Мы пробовали использовать Auto Layout, но из-за недостаточно высокой производительности нам пришлось переключиться на ручной лейаут.
- Мы пришли к собственной модели позиционирования, позволившей нам повторно использовать код в layoutSubViews и sizeThatFits(_:), а также реализовали вычисление лейаута в фоновом режиме. Оказывается, найденные нами решения кое в чем совпали с некоторыми идеями проекта AsyncDisplayKit.
- Мы реализовали метод performBatchUpdates(_:animated:completion) и два отдельных контекста для ячеек с целью минимизации количества обновлений view.
- Мы внедрили в код скользящий источник данных с ограничением количества сообщений, тем самым добившись быстрого масштабирования при вращении устройства и переходе в режим Split View.
- UITextView оказалось действительно тяжело использовать, причем он до сих пор остается узким местом, снижающим производительность во время прокрутки на старых устройствах (iPhone 4s) из-за функции обнаружения ссылок. Тем не менее мы продолжили его использовать, поскольку нам нужна была возможность выполнения штатных действий при взаимодействии со ссылками.
- Из-за UITextView нам пришлось вручную реализовать интерактивное скрытие клавиатуры путем наблюдения с помощью KVO за фиктивным inputAccessoryView.
Команда разработчиков Badoo для iOS
Комментарии (7)
landan
04.03.2016 18:40Спасибо, искал что-то подобное, а скажите, в чем принципиальные отличия от https://github.com/jessesquires/JSQMessagesViewController кроме того, что JSQMessagesViewController написан на obj-c.
Account_is_busy
09.03.2016 10:31+1Основные отличия:
- расчет лейаута в фоновом потоке;
- поддержка постраничной загрузки сообщения в обоих направлениях;
- работающий интерактивный режим скрытия клавиатуры.
Krypt
10.03.2016 19:04К сожалению, Эппловская документация грозится полной блокировкой работы с UIKit из фонового потока — со следующей мажорной версии iOS попытка обратиться к интерфейсу из фонового потока будет кидать исключение.
Так же, используя autolayout, вы можете кешировать размер ячеек без предварительного расчёта совсем: в методе cellWillDisplay: размеры ячеек уже рассчитаны и вы можете получить актуальный frame ячейки.Krypt
10.03.2016 19:10Но вот основной проблемой остаётся прокрутка в середину таблицы — эта операция требует полного пересчёта всех промежуточных ячеек, либо кучи магии с перепозиционированием.
Account_is_busy
11.03.2016 15:26Мы не используем UIKit в фоновом потоке. Для расчета размеров текста используется функция NSString.boundingRectWithSize(_:options:attributes:context)
Krypt
11.03.2016 15:36Как вы решили неточность расчёта размера текста?
Если ничего не изменилось за 2 мажорные версии iOS — boundingRectWithSize и, собственно, UITextView используют разные движки рендера текста. И результирующие размеры не совпадают.
Собственно, типичная ситуация: на 100 строк текста шрифтом Helvetica Neue boundingRectWithSize: даёт ошибку больше, чем высота двух строк.
AnthonyBY
Спасибо, очень интересно. Выглядит и работает очень круто, жаль что вас небыло год назад :)