В этой статье мы опишем тонкости написания чата. Понимаю, что придумано уже достаточно готовых решений. Побродив по закоулкам бескрайнего, отыскали пару годных библиотек, которые предоставляют чат «из-коробки». В этой статье они перечислены не будут. Так вот, перспектива заюзать готовое решение казалась соблазнительной. Но, ещё раз подумав о комплексности предстоящей задачи, мы решили писать с нуля.

Итак, задача:

Бэкенд на основе кастомного (разрабатываемого параллельно) XMPP-сервера. Поддержка множества пользователей в одном чате. Ячейки — начиная с обычных текстовых блоков, заканчивая отправкой картинок и сообщениями со множеством текстовых полей, кнопками, анимациями и меняющейся высотой по событию. Статус сообщений (доставлено, прочитано и т.д.). Отображение аватарки в ячейке, начинающей череду сообщений от конкретного пользователя. Кеширование всего и переотправка сообщений в случае ошибки. Само собой, плавность подгрузки на лоу-енд девайсах.

image

Задача не совсем типичная, и требования по проекту до конца не определены. Рискованно загонять себя в рамки неиспользованной ранее библиотеки. Чего, конечно, не скажешь о проверенных в бою либах вроде RxDataSources, которую мы непременно решили использовать. Случись что, в любой момент разработки она легко замещается вручную прописанной логикой по работе с ячейками. Также не обошлось без насущного «джентльменского наборчика iOS-разработчика»: RxSwift, Realm, Alamofire и Kingfisher. Их мы также подключили к проекту.

Список сообщений реализовывали через UICollectionView из-за его трехкратной гибкости. И анимирована коллекция в UIKit красивей нежели таблица. При этом анимацией можно управлять. От Storyboard'a в рамках чата сразу отказались, потому как Autolayout и производительность — вещи, если не с разных галактик, то, как минимум, друг про друга не знающие.

Пришло время подумать о внутренней архитектуре ячеек. С первого взгляда, все вроде бы просто: сообщение может быть либо своим, либо чужим. Дополнительно стоит расписать логику отображения аватарки, просчета высоты в зависимости от контента… На макетах расстояние между своей и чужой ячейкой разное, и для этого нужно логику расписать. Еще выясняется, что дата сообщения в углу ячейки должна выталкивать основной текст на новую строчку, если он ее коснулся, а на девайсах до iPhone 6 вообще будет отдельный макет и свои размеры. Спустя пару месяцев дизайн приложения изменится, и нужно будет безболезненно изменять отступы и шрифты.

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

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

Сэлл может конфигурироваться всего тремя способами (с аватаркой и именем, без информации об отправителе и с аватаркой по середине поверх контента):

image

Контент размещается ближе к определенному краю экрана в зависимости от типа сообщения «своё-чужое». Высота стандартно рассчитывается с помощью шаблонной ячейки в зависимости от вышеописанной конфигурации. Здесь же рассчитывается расстояние между ячейками. В CollectionViewLayout оно равно нулю, расстояние просто добавляется к высоте ячейки, дабы логика не разносилась по объектам.

Стоит подробней рассмотреть класс вьюхи, помещаемой на ячейку. В нём будет львиная доля верстки всего экрана. Процесс это несложный, но длительный и рутинный. Основная сложность здесь — грамотно составить базовый класс. Применив паттерн Template method, мы создали класс, инкапсулирующий в себе алгоритм инициализации вью с контентом (расчет размера, подписка на события, инициализация и добавление subviews), оставив сами методы открытыми для переопределения.

Техническая сложность проявилась в постраничной загрузке. Десяток сообщений грузился половину секунды на iPhone 6 — очень слабый результат. На самом деле, ничего удивительного: ячейки тяжелые и весь процесс их создания и наполнения происходил в cellForRow. От этого Main-поток терпел бедствие. Довольно быстро пришла идея создавать вьюхи с контентом сразу после получения сообщений в Background-потоке. Некоторые скажут, что так нельзя, и будут правы. UILabel в фоне создать так и не получилось, приложение просто крашится. Остальные UIView-примитивы спокойно позволяют создавать себя в бэкграунде, причем, конкретно класс UIView разрешен к созданию в фоне официальной документацией.

Практика показала, что фреймы у объектов также можно задавать из фона. Этого нам хватило, чтобы размазать создание ячейки с сообщением и вынести логику расчета размера ячейки в фон. Также, стоит отметить, что, сместив добавление всех сабвьюх в ячейку на момент willDisplayCell, мы заметно повысили плавность подгрузки. И в этом методе мы даём понять контент-вьюхе, что она наконец попала в Main-поток, а, значит, здесь можно сконфигурировать все аутлеты (создать UILabel'ы) и подставить в них контент. По сути, сразу после получения сообщения, в фоне идет формирование размера ячейки, далее её генерация в этом размере, а в самый последний момент перед отображением отрисовка всего контента на ней:

image

В ячейках должен быть минимум прозрачности. Железу девайсов сложно переваривать смешанные слои, а потому полупрозрачные вьюхи — бич производительности, и любой сложный компонент должен быть от него избавлен. Скругление углов на вьюхах все равно делает их полупрозрачными. Для наглядности можно в симуляторе устройства выставить галочку на Color blended layers, и он отрисует их красным цветом. Здесь ничего не поделаешь, по крайней мере ничего, что можно придумать за короткое время.

Единственный минус примененного подхода — хранение ссылок на вьюхи с контентом.

Раз создавшись, вью уже никогда не деаллоцировалась, разве что после выхода с экрана. Это решение было принято осознанно. Это избавило нас от проблем с производительностью. Ритэйн вьюх позволил просчитывать размер и создавать их лишь однажды и в фоне. На памяти это почти никак не сказалось, главный принцип — не потащить за собой картинки. Поле image у UIImageView должно обязательно обнуляться в didEndDisplayingCell и снова обретать значение лишь при новом отображении ячейки.

Вуаля, и все плавно! Шутка. Плавно в Телеграме. Но для наших сроков очень неплохая производительность. Даже на минимально поддерживаемом девайсе подгрузка еле заметна, а на современных девайсах и вовсе не видна. Дальнейшее прибавление к типам сообщений не сказалось на производительности подгрузки.

И ничего не предвещало беды, как вдруг сломался CollectionViewLayout. Ну вы понимаете, как это обычно бывает: сбивается контент сайз, ячейки становятся невидимыми и т.д и никакой reloadData() в этом случае не спасает… сломался и все тут ?\_(?)_/?. Случалось это в неопределенный момент времени, просто при скролле чата. Попробовали поменять вставку одиночных ячеек на полную перезагрузку коллекции, избавиться от RxDataSources, перенести создание представлений в главный поток — всё без толку. Случилось это после обширного мержа веток, поэтому разобраться, чем именно был вызван брейк, с ходу не вышло. Случай был любопытный, и хотелось добраться до истины. Заказчик хотел сборку.

«TableView, дружище, мы так по тебе скучали!… в смысле предатели?»

Ушла буквально пара часов, прежде чем коллекцию удалось полностью заменить на таблицу. Не было абсолютной уверенности в том, что это решит проблему. Но TableView отрабатывал идеально. Казалось, что работал он даже чуть быстрей, чем коллекция. Но анимация инсерта ячеек оставляет желать лучшего. В чате она вовсе выглядит неприемлемо, потому было решено обновлять список через reloadData(). А поломка CollectionView так и осталась загадкой: это один из самых сложных элементов в UIKit'е, и разобраться в нём за выходные никак не получилось. Главное, что задача выполнена — база чата реализована на должном уровне.

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

Как все знают, сообщения в чате начинаются снизу, а у коллекций и таблиц нет подходящего функционала для подобного отображения ячеек. Здесь мы использовали CGAffineTransform: перевернули таблицу вверх дном, а ячейки перевернули еще раз — все идеально, и без каких-либо возможных багов и проблем с производительностью.

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

image

Стоит сказать пару слов про AutoresizingMask. Некоторые недооценивают её важность, а при верстке из кода полезно понимать, как работает эта технология. В нашем случае были ячейки, которые растягивались и сжимались по событию. И, чтобы каждый раз не восстанавливать фреймы, маски были заданы так, что представления меняли свой размер в соответствии с размерами своих родителей. Без единого констрейнта и строчки кода — чистая магия. Ниже представлен пример ее использования: изначально имеем ячейку, далее мы увеличиваем её высоту (без использования масок контент и остался бы в таком положении), после чего, в правый нижний угол, подтягивается дата, а весь остальной контент растягивается:

image

Следующий нюанс: при появлении клавиатуры для возможности увидеть сообщения, которые остались под ней у таблицы выставлялся нижний инсет. Еще он выставлялся сверху, в случае если всплывала плашка об отсутствии интернета, и снизу, когда поле для ввода сообщения меняло свою высоту, все это не считая верхний и нижний инсет по дефолту. Отнимать и прибавлять в каждом месте нужную константу — достаточно хрупкое решение и сломается если хотя бы один метод вызовется лишний раз. В качестве решения была написана обертка для UITableView, содержащая массив структур с инсетами по каждой причине. Причины были описаны в перечислении, а каждый раз при изменении массива все отступы складывались и присваивались таблице. Таким образом, появилась возможность управлять каждым инсетом, не ломая остальные. Стоит упомянуть: все отступы в нашем случае были перевернуты вместе с таблицей из-за примененных ранее трансформаций, поэтому верхний инсет был нижним и наоборот.

Любопытных моментов и открытий при разработке было предостаточно, всех не упомнить. Повторюсь, можно вложить бесконечное количество часов, делая приложение всё более плавным, быстрым и гибким. Но это, совместно с визуальной частью, — первая стадия, необходимый минимум для успешного применения чата. К следующей ступени можно отнести уникальность опыта его использования. Чего далеко ходить, возьмем нативный ios-мессенджер iMessage. Помимо отзывчивости, у мессенджера множество “модных фишек”, большинства которых просто нигде нет. Для коммуникации они не столь важны, но если рассматривать чат как отдельную сущность, обладатель таких “фишек” выглядит более совершенным и развитым на фоне остальных. Этап реализации таких особенностей в редких случаях можно отнести к первой версии приложения. Поэтому, выпустив первую версию чата, мы надеемся в будущем продолжить работу и наполнить чат уникальными особенностями.
Поделиться с друзьями
-->

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