- PVSM.RU - https://www.pvsm.ru -
Наш чат устарел: за несколько лет эволюции он превратился в громоздкий View Controller со странными исправлениями, в которых никто не мог разобраться. Стало трудно добавлять новые типы сообщений, зато с легкостью появлялись новые баги. Поэтому мы решили переписать чат на Swift с чистого листа и выложить его в open source [1].
Мы начали работу над проектом, поставив перед собой две цели:
В этой статье будет подробнее рассказано о том, как мы достигли поставленных целей, какие методы при этом использовались и что у нас получилось в конечном счете. На нашей странице на GitHub [1] выложено довольно подробное описание архитектуры приложения.
В нашем старом чате использовался UITableView. Он вполне хорош, однако UICollectionView предлагает более богатый API с большим количеством возможностей для настроек (анимации [2], UIDynamics [3] и т. д.) и оптимизации (UICollectionViewLayout и UICollectionViewLayoutInvalidationContext).
Более того, мы изучили [4] несколько уже существующих приложений для чата и оказалось, что все они используют именно UICollectionView. Поэтому решение в пользу выбора UICollectionView было само собой разумеющимся.
Ни один чат не может обойтись без облачков с текстом. По правде говоря, в плане производительности труднее всего реализовать именно этот тип сообщений, поскольку рендеринг и масштабирование текста выполняются медленно. Мы хотели, чтобы чат автоматически обнаруживал ссылки и выполнял штатные действия, как это делает iMessage.
В UITextView изначально имеется поддержка всех этих требований, так что для обработки ссылок нет нужды писать ни строчки кода. Поэтому мы и выбрали этот класс, однако это решение стало для нас источником проблем. Далее мы расскажем почему.
Лейаут и вычисление размеров всегда вызывают трудности: очень легко написать дублирующийся код, а его сложнее поддерживать и он приводит к появлению багов, так что мы стремились этого избежать. Поскольку мы с самого начала обеспечивали поддержку iOS 8, было принято решение попробовать auto layout и sizing cells. Вот [5] ветка с общим описанием реализации такого подхода. Попробовав, мы столкнулись с двумя крупными проблемами:
Итак, для лейаута вместо Auto Layout мы решили использовать традиционный подход. Мы остановились на классическом методе, в рамках которого для вычисления размеров применяется ячейка-болванка, а для лейаута и подсчета размеров использовалось бы как можно больше общего кода. Этот подход работал гораздо быстрее, однако этого все еще было недостаточно для iPhone 4s. Профилирование [6] выявило слишком большой объем работы внутри метода layoutSubviews.
По сути, мы дважды выполняли одну и ту же работу: в начале считали размеры в болванке, а затем делали это снова в реальной ячейке внутри layoutSubviews. Чтобы решить эту проблему, можно было бы кешировать значения sizeThatFits(_:) для UITextView, подсчет которых обходится очень дорого, но мы пошли еще дальше и создали модель лейаута, в рамках которой вычислялись и записывались в кеш размер ячейки и фреймы всех subview. В результате нам удалось не только заметно повысить скорость прокрутки, но и с максимальной эффективностью повторно использовать код между вызовами sizeThatFits(_:) и layoutSubviews.
Кроме того, наше внимание привлек метод updateViews. При небольшом размере, это оказался один из основных методов, ответственных за обновление ячейки в соответствии с заданным стилем и типом отображаемых данных. Наличие одного основного метода для обновления UI упрощало логику и сопровождение кода в будущем, но при этом он вызывался практически для каждого действия, изменяющего свойства ячеек. Чтобы справиться с этой проблемой, мы придумали два способа оптимизации.
Мы уже добились хорошей скорости прокрутки, однако загрузка большего количества сообщений (пакетами по 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 менялась доступная ширина для показа сообщений, поэтому нужно было считать все размеры и положения сообщений заново. Для нас это не представляло особой проблемы, так как наше приложение не поддерживает вращение, но мы уже тогда собирались выпускать Chatto в open source и решили, что достойная поддержка вращения и Split View была бы для этих целей большим плюсом. К тому времени мы уже реализовали вычисление размеров в фоновом потоке с плавной прокруткой и загрузкой новых сообщений, но это не особо помогало в случаях, когда приложению приходилось иметь дело с 10 000 сообщений. Чтобы вычислить размеры для такого большого количества сообщений в фоне, iPhone 4s требовалось от 10 до 20 секунд, и, конечно же, нельзя было заставлять пользователей ждать так долго. Мы видели два способа решения проблемы:
Первый вариант является, скорее, хаком, чем собственно решением — оно не особо помогает в режиме Split View и не масштабируется. Поэтому мы и выбрали второй способ.
После нескольких тестов на iPhone 4s мы пришли к выводу, что поддержка быстрого вращения означала обработку не более 500 сообщений, поэтому мы реализовали скользящий источник данных с настраиваемым количеством одновременно показываемых сообщений. В соответствии с этим, при открытии чата вначале должно было загружаться 50 сообщений, а затем подгружалась бы следующая порция из 50 сообщений, по мере того как пользователь прокручивал чат, чтобы увидеть более ранние записи. Когда пользователь прокручивал назад достаточно большое количество сообщений, первые из них удалялись из памяти. Таким образом, разбивка на страницы работала в обе стороны. Реализация этого метода была довольно простой задачей, однако теперь у нас возникала проблема в другом случае — когда источник данных уже был заполнен и при этом поступало новое сообщение.
Если уже было получено 500 сообщений и поступало новое, то нужно было удалять самое верхнее сообщение, сдвигать все остальные на одну позицию вверх и вставлять в чат только что поступившее. С решением этого тоже не возникло трудностей, однако такой подход не нравился методу UICollectionView.performBatchUpdates(_:completion:). Было две основных проблемы (их можно воспроизвести здесь [7]):
Для устранения этих проблем мы решили ослабить ограничение, предусматривающее максимально допустимое количество сообщений. Теперь мы разрешили приложению вставлять новые сообщения, нарушая установленный лимит и обеспечивая тем самым плавное обновление UICollectionView. После выполнения вставки и при отсутствии необработанных изменений в очереди обновлений мы отправляли источнику данных предупреждение о том, что поступает слишком много сообщений. После этого мы производили необходимые корректировки с помощью reloadData, а не performBatchUpdates. Поскольку мы не могли особо контролировать момент, когда именно это произойдет, и учитывая, что пользователь мог прокрутить чат в любую позицию, нам требовалось сообщать источнику данных, в какое место разговора пользователь прокрутил чат, чтобы не удалить те сообщения, которые в данный момент просматривались:
public protocol ChatDataSourceProtocol: class {
...
func adjustNumberOfMessages(preferredMaxCount preferredMaxCount: Int?, focusPosition: Double, completion:(didAdjust: Bool) -> Void)
}
Итак, мы пока рассмотрели только проблемы с производительностью Auto Layout и подсчетом размеров, а также препятствия на пути решения задачи по вычислению размеров в фоновом потоке с помощью NSString.boundingRectWithSize(_:options:attributes:context).
Чтобы воспользоваться возможностью обнаружения ссылок и некоторыми другими доступными действиями, нам пришлось активировать свойство UITextView.selectable. Это привело к некоторым нежелательным побочным эффектам для облачков (например, появилась возможность выбора текста и появления лупы). Кроме того, для поддержки этих возможностей UITextView использует систему распознавания жестов, которая мешала выполнению таких действий, как выделение облачков с текстом и обработка долгих нажатий внутри них. Мы не будем детально рассказывать о том, с помощью каких хаков нам удалось обойти эти проблемы, но вы можете сами узнать об этом больше, пройдя по ссылкам: ChatMessageTextView [8] и BaseMessagePresenter [9].
Кроме вышеупомянутых проблем, работа UITextView сказывалась и на клавиатуре. По идее, в наши дни реализация интерактивного скрытия клавиатуры должна быть довольно простой задачей. Достаточно переопределить inputAccessoryView и canBecomeFirstResponder в используемом контроллере, как показано здесь [10]. Однако же этот метод работал неэффективно при показе UIActionSheet из UITextView, когда пользователь выполнял долгое нажатие на какую-либо ссылку.
Суть проблемы заключалась в том, что меню появлялось под клавиатурой и его совсем не было видно. Вот еще одна ветка [11], в которой вы можете сами поиграться с этой проблемой (rdar://23753306 [12]).
Мы пробовали сделать поле ввода частью иерархии view controller, отслеживать уведомления, поступающие с клавиатуры, и вручную изменять contentInsets у UICollectionView. Однако при взаимодействии пользователя с клавиатурой уведомления не поступали, и поле ввода показывалось в центре экрана, оставляя зазор между ним и клавиатурой, когда пользователь тянул клавиатуру вниз. Эта проблема решается с помощью специального хака, который заключается в использовании фиктивного inputAccessoryView (расположенного под полем ввода) и наблюдения за ним с помощью KVO. Более подробно прочитать об этом можно здесь [13].
Команда разработчиков Badoo для iOS
Автор: Badoo
Источник [15]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/razrabotka/114123
Ссылки в тексте:
[1] open source: https://github.com/badoo/Chatto
[2] анимации: https://www.objc.io/issues/12-animations/collectionview-animations/
[3] UIDynamics: https://www.objc.io/issues/5-ios7/collection-views-and-uidynamics/
[4] изучили: http://petersteinberger.com/blog/2013/how-to-inspect-the-view-hierarchy-of-3rd-party-apps/
[5] Вот: https://github.com/diegosanchezr/Chatto/tree/badoo-blog-autolayout-try
[6] Профилирование: https://yalantis.com/blog/mastering-uikit-performance/
[7] здесь: https://github.com/diegosanchezr/Chatto/tree/badoo-blog-sliding-datasource-glitches
[8] ChatMessageTextView: https://github.com/badoo/Chatto/blob/ea3dc6b79adb0df07ff3578a919a039a25eb4549/ChattoAdditions/Source/Chat%20Items/TextMessages/Views/TextBubbleView.swift
[9] BaseMessagePresenter: https://github.com/badoo/Chatto/blob/ea3dc6b79adb0df07ff3578a919a039a25eb4549/ChattoAdditions/Source/Chat%20Items/BaseMessage/BaseMessagePresenter.swift
[10] здесь: https://robots.thoughtbot.com/input-accessorizing-uiviewcontroller
[11] ветка: https://github.com/diegosanchezr/Chatto/tree/badoo-blog-uitextview-keyboard-bug
[12] rdar://23753306: https://openradar.appspot.com/radar?id=4992538469466112
[13] здесь: https://medium.com/ios-os-x-development/a-stickler-for-details-implementing-sticky-input-fields-in-ios-f88553d36dab#.ibywum8z7
[14] AsyncDisplayKit: http://asyncdisplaykit.org/
[15] Источник: https://habrahabr.ru/post/278581/
Нажмите здесь для печати.