Привет! Меня зовут Юрий Буянов, я разработчик мессенджера TamTam. Сегодня я хочу рассказать вам немного о том, как он создавался и как устроен изнутри. TamTam — это новый мессенджер Mail.Ru Group, который был разработан на базе приложения «ОК Сообщения». В 2016 году мы сделали отдельный мессенджер в Одноклассниках для тех, кто часто переписывается в соцсети и кому удобнее это делать с помощью отдельного приложения.
Эксперимент получился удачным, поэтому в начале года мы решили развивать «ОК Сообщения» как отдельный от соцсети мессенджер под собственным брендом TamTam, но уже с набранной стартовой аудиторией. Уже за первые недели после запуска в TamTam появились десятки тысяч каналов, а аудитория продолжила общаться так же активно, как и в «ОК Сообщениях». Это стало возможным в том числе благодаря быстрой работе приложения и нескольким техническим фишкам. О них я расскажу подробнее.
Сложности, которые натолкнули на идеи
Начнём со сложностей: именно они принесли нам идеи, которые потом реализовались в продукте и в итоге превратились в преимущества приложения. Речь прежде всего о быстрой и стабильной работе мессенджера.
Стартовая аудитория TamTam — из самых разных уголков мира, в том числе с нерегулярным покрытием мобильной сети (а иногда и с полным отсутствием стационарного интернета). В некоторых странах СНГ за пределами крупных городов 2G-соединение — вообще фактически единственное окно в интернет.
Важно было и то, что далеко не все потенциальные пользователи TamTam каждый год бегут покупать новый айфон или ГОРЯЧУЮ НОВИНКУ от Samsung. По статистике, самый популярный девайс под iOS у наших пользователей — iPhone 5s, а под Android — недорогие Galaxy выпуска 2014—2015 годов. При этом у TamTam достаточно молодая аудитория: 28 % дневной аудитории — это люди в возрасте 27—34 лет, а более половины пользователей (54 %) — младше 35 лет.
Поэтому одним из приоритетных направлений в разработке мессенджера для нас с самого начала была оптимизация приложения с точки зрения как быстродействия, так и работы с сетью. Словом, требовалось незаметно для пользователей сделать так, чтобы приложение работало при любом уровне подключения. И при любом росте аудитории тоже. TamTam в первые же месяцы показывает неплохие цифры: число установок уже приближается к 3 миллионам, а число каналов уже больше 50 000.
Как мы делали приложение быстрым
Быстродействие с точки зрения пользователя — это в первую очередь скорость запуска. Время, которое проходит до отображения нового контента (например, при открытии чата с новым сообщением по push-уведомлению). Плавность работы в целом — в частности скролла. В iOS-команде мы стараемся тестировать и замерять быстродействие на iPhone 5 и iPhone 4S. Андроид-команда имеет в распоряжении Galaxy S3 и Мегафон логин за 1000 рублей. Как следствие, на более мощных девайсах приложение просто летает.
В каждой тестовой сборке можно включить счётчик кадров в секунду, а в логи и в систему статистики записывается длительность выполнения операций в узких местах.
Например, на этом графике показано время с момента запуска приложения при открытии по пушу до момента, когда пользователь увидит это конкретное сообщение на экране. Два падения на графике соответствуют включению контент-пушей на половину и на всех пользователей.
Несмотря на обилие инструментов и метрик, главным инструментом оценки быстродействия приложения остаются субъективные ощущения. Никто не может точно ответить, какая задержка в миллисекундах допустима при открытии экрана сообщения, но практически каждый может сказать, есть ли у него ощущение того, что приложение «тупит».
Как мы оптимизируем? В первую очередь выносим всё, что можно, из главного потока: работу с БД (об этом чуть ниже), работу с сетью, сериализацию и десериализацию данных, процессинг картинок и даже вычисления, связанные с вёрсткой текста.
Когда мы запускаем приложение или открываем экран чата, выполнение тяжелых операций в фоне не спасёт от видимой задержки. Так что одни операции вроде вёрстки бабблов всё равно нужно оптимизировать по времени, а другие лучше делать сразу при получении сообщения и кешировать результат их выполнения в базе.
При выборе сторонних решений и библиотек в узких местах мы тоже старались учитывать быстродействие и компактность. В частности, именно поэтому мы выбрали MessagePack (причём для iOS специально делали бенчмарк разных реализаций), поменяли библиотеку для маппинга данных в объекты с Mantle на YYModel и остановились на lz4 в качестве алгоритма компрессии трафика.
Кроме того, для достижения плавности работы интерфейса мы симптоматически оптимизируем рендеринг:
- избегаем offscreen-рендеринга, нагружающего процессор;
- заранее в фоне ресайзим картинки вместо использования работающих в главном потоке стандартных UIViewContentMode;
- делаем наши иерархии UI более «плоскими» и простыми;
- кешируем те объекты и данные, создание которых слишком затратно. Начиная с высоты ячеек с текстом и заканчивая YYTextLayout (объект, который хранит информацию об отображении текста в библиотеке YYText), NSAttributedStrings и даже самими UIViews.
Во всех списках идёт ручная вёрстка без auto layout. Хотя auto layout мы тоже очень любим и используем декларативную вёрстку с помощью Masonry в коде — но только там, где это целесообразно.
Офлайн и работа при плохом интернете
При работе с сетью мы стараемся минимизировать трафик и задержки за счёт выбора быстрого компактного протокола и агрессивного кеширования.
В качестве способа общения с сервером мы используем только TCP-сокеты и бинарный протокол. Это позволяет нам как получать обновления с сервера в реальном времени, так и работать в более привычном режиме «запрос — ответ».
Сам API, т. е. набор команд поверх низкоуровневого протокола, можно в будущем при желании реализовать поверх другого транспорта, например на веб-сокетах. При всём этом нам не придётся трогать верхнеуровневую логику работы приложения.
Сами пакеты состоят из заголовка фиксированной длины со служебной информацией: код команды, версия протокола, длина пэйлоада. Ответы на запросы могут приходить в разном порядке и вперемешку с командами сервера, поэтому в заголовке есть sequence number, позволяющий связать запрос и ответ.
В качестве формата для пэйлоада мы решили попробовать messagepack. Он не требует жёсткого задания схемы, очень компактный и имеет довольно шустрые библиотеки сериализации под множество платформ. По сути, это эффективный бинарный аналог JSON. Для того чтобы ещё более снизить потребление трафика, мы сжимаем пэйлоад алгоритмом lz4. Его мы также выбрали за скорость и небольшую нагрузку на CPU и батарейку.
Один из главных способов обеспечить нормальную работу приложения в условиях плохой сети — максимальная поддержка офлайн-режима. Приложение должно кешировать максимум данных, тратить меньше времени и трафика на синхронизацию и уметь откладывать отправку команд до появления соединения. Причём соединение может вернуться даже при следующем запуске приложения, т. е. все отложенные задачи по отправке надо уметь сохранять в БД.
После коннекта клиент аутентифицируется, одновременно запрашивая критически важные данные: настройки, список контактов и чатов с последними сообщениями. Мы храним таймстемп последнего обновления (в серверной системе отсчета времени) и передаём его в запросе, чтобы получить обратно только то, что действительно поменялось. После того как соединение установлено, мы можем получать обновления в реальном времени: например новые сообщения или изменения данных контактов.
С историей сообщений в чате всё чуть сложнее. Грузить заранее всю историю всех чатов бессмысленно, но что мы один раз получили — то мы кешируем и стараемся больше не запрашивать. Если посмотреть на то, какие участки истории чата закешированы, мы увидим, что в истории есть «разрывы». Например, с обновлением списка чатов после логина мы увидели, что последнее сообщение в чате изменилось. При этом у нас в БД есть участок (или несколько участков) истории чата, закешированный в ходе предыдущей сессии. Кроме того, мы не знаем, сколько сообщений есть на сервере между последним сообщением в чате и предыдущим закешированным сообщением, и это добавляет своих сложностей.
Поэтому, кроме самих сообщений, мы храним метаданные о непрерывных кусках истории — чанках, которые мы закешировали. При скролле чата мы используем эту информацию: она помогает нам определить, грузить следующую страницу из БД или отправлять запрос на сервер. А может быть, делать и то, и другое. При получении новых участков истории с сервера эти чанки меняют размер и сливаются друг с другом (в случае если клиент понимает, что только что полученный участок истории соединяет два разрозненных чанка, имеющихся в БД).
Поскольку многие операции можно выполнять в офлайне, мы разработали механизм сохранения задач. Он умеет запускать задачи, дожидаться их выполнения, сохранять их состояние в БД или загружать и запускать при старте приложения.
Задачи могут сохраняться в БД, они инкапсулируют в себя всю логику выполнения. Поскольку зависимости от других задач и от состояния приложения могут быть довольно сложными, то слежение за ними тоже реализовано в самих задачах. Например, задача отправки сообщения с фотографией должна убедиться в том, что фотография обработана, загружена на CDN (за это отвечают отдельные задачи), дождаться (при необходимости) сетевого подключения и только потом непосредственно попытаться отправить само сообщение.
Два приёма для плавной работы приложения
Немного расскажу о паре приёмов, которые мы использовали для обхода ограничений системы, мешающих нам сделать дружелюбный и плавный интерфейс. На примере iOS-приложения.
Одной из сложностей при разработке стал бесконечный скролл в чате, т. е. незаметная для пользователя подгрузка истории сообщений при прокрутке чата вверх. В 99 % случаев пользователь находится именно внизу чата и хочет проскроллить его вверх для того, чтобы прочитать старые сообщения. Здесь мы столкнулись с двумя проблемами.
Во-первых, постоянное натыкание на верхнюю границу списка сообщений и ожидание подгрузки каждые несколько экранов раздражает. Эту проблему было не очень сложно решить: мы не дожидаемся, пока пользователь доскроллит до самого верха и увидит там «крутилку», а стараемся заранее запрашивать предыдущие страницы истории еще во время скролла: как из локального кеша, так и с сервера. При наличии сообщений в кеше или на быстром соединении пользователь просто не успеет доскроллить до самого верха к тому моменту, как мы сможем отобразить новую пачку сообщений.
Вторая проблема оказалась гораздо серьезнее: после вставки такой страницы в начало списка сообщений (сделанного на основе UITableView) contentOffset для уже загруженного участка сдвигается, и скролл «прыгает». Конечно, мы можем посчитать размер вставляемой страницы и изменить contentOffset обратно, но это приводит к резкой остановке анимации скролла, что некрасиво и обескураживает пользователя. Мы пытались делать это различными способами, включая такие, например, как отслеживание contentSize таблицы через KVO, но неизменно терпели неудачу: UITableView просто хронически не приспособлен к тому, чтобы элементы добавлялись в начало списка.
В итоге после ряда попыток мы смогли решить эту проблему, применив своего рода «хак»: переворачиваем список вверх ногами с помощью .transform, а затем переворачиваем каждую ячейку в обратном направлении. Пользователь ничего не замечает, но теперь contentOffset отсчитывается снизу, и подгрузка старых сообщений никак на него не влияет.
У этого решения есть ряд подводных камней, но их мы тоже сумели обойти, и нам они не мешают. Во-первых, необходимо конвертировать перевёрнутые индексы ячеек в индексы в вашей модели данных, и обратно. Если у вас больше одной секции, вычисления будут очень сложными, так что лучше ограничиться одной. Конечно, это не даёт нам использовать плавающие заголовки секций, которые на экране чата пригодились бы, например, для отображения разделителей по дням в истории. Но плавающие разделители в итоге оказалось не так сложно сделать вручную.
Во-вторых, в редких случаях могут возникнуть сложности с вычислением координат внутри ячеек, например при работе с жестами, но все они тоже решаемы. В-третьих, при подгрузке данных вниз проблема возвращается, но подгрузка при скролле вниз происходит очень редко, так что для нас это не очень большая сложность. В этом случае мы не делаем предварительную подгрузку при скролле, а дожидаемся, пока пользователь доскроллит до самого низа таблицы, затем показываем индикатор загрузки, обновляем таблицу и меняем contentOffset.
Вторая сложность, с которой мы столкнулись, — это анимированные и асинхронные обновления списков. Если несколько независимых обновлений происходят почти одновременно (например, подгружается страница истории вверху чата и приходит новое сообщение внизу), то данные, используемые делегатом tableView, могут измениться, даже если не закончилась анимация предыдущего обновления.
Это может привести к тому, что UITableView отрендерит неправильную ячейку или вообще упадёт: это ещё более вероятно, если вы используете предыдущий хак. Можно, конечно, обратиться к методу reloadData, синхронному в UITableView, однако это приводит к морганиям, остановке скролла и прочим раздражающим пользователя вещам.
Специально для таких случаев мы сделали отдельную очередь для последовательной обработки таких обновлений. Все изменения модели и отображение их на UI производятся внутри блоков, которые ставятся в очередь. При этом блок может залочить очередь при старте анимации или какой-то другой асинхронной операции и разлочить её при завершении. Таким образом, вся работа с таблицей идёт последовательно, а данные не меняются, пока не завершится предыдущая анимация.
Persistence
Для кеширования данных в iOS-клиенте мы используем библиотеку YapDatabase.
YapDatabase — это Key-Value хранилище поверх SQLite с очень большим набором возможностей. Мне эта библиотека кажется гораздо более простой и гибкой, чем CoreData. Здесь можно выбрать механизм сериализации объектов в базе: по умолчанию это NSCoding, а мы используем всё тот же MessagePack.
YapDatabase не требует наследования объектов от базового класса или реализации какого-то протокола, не привязывает объекты к контексту. Чтение и запись производятся с помощью синхронных или асинхронных транзакций.
А при помощи системы расширений доступны все те же возможности, что и в «настоящей» БД: произвольные SQL-запросы и индексирование нескольких полей, полнотекстовый поиск, подписка на изменения (как в NSFetchedResultsController), подключаемое шифрование, работа с CloudKit и т. д. Hello-world примеры работы с БД приводить здесь не буду, они есть в вики на github.
На мой вкус, YapDatabase повышает продуктивность и понятность кода, но некоторые мои коллеги её не очень любят. И их можно понять: после длительной работы с CoreData для перехода на YapDatabase нужно действительно несколько вывернуть
Кроме того, при асинхронной работе с базой через несколько соединений нужно хорошо понимать, как база обрабатывает параллельные запросы на чтение и запись: через одно или разные соединения. А ещё помнить, что объекты обновляются в БД целиком. Нельзя просто сохранить тот экземпляр, который вы прочитали какое-то время назад и модифицировали. Необходимо прочитать объект из базы, изменить его так, как вам нужно, и записать обратно в рамках одной транзакции. В противном случае можно случайно записать в БД устаревшие данные.
Вообще работа с базой очень удобно встраивается в наш реактивный стиль написания кода. Асинхронные шаблоны транзакций (чтение/запись/модификация отдельного объекта) очень просто завернуть, например, в сигналы ReactiveCocoa, и встраивать работу с базой в одну цепочку с отправкой и обработкой сетевых запросов.
Архитектура приложения
Много рассказывать про архитектуру не буду, но совсем не упомянуть о её законах жанра, как говорится, не позволяют. Докладов и статей про MVVM уже очень много (например, классический туториал в версии для Objective-C b RAC: часть 1, часть 2, или статья о реализации этого паттерна для Swift).
Под слоем ViewModels есть набор сервисов, который реализует (и по возможности инкапсулирует) бизнес-логику, логику работы с протоколом и кеширование. Навигация в приложении осуществляется с помощью так называемого роутера, т. е. объекта, инкапсулирующего код, необходимый для открытия того или иного экрана. На самом деле роутеров в процессе стало несколько, поскольку у роутера есть тенденция становиться эдаким очень жирным God Object. Поэтому там, где это возможно, мы стараемся его декомпозировать. Например, за весь процесс регистрации/аутентификации пользователя отвечает отдельный роутер.
По опыту предыдущих проектов мы знали, что Dependency Injection очень упрощает структуру приложения и здорово облегчает изменения в архитектуре. В самом начале мы использовали для DI фреймворк Typhoon, но в ходе оптимизации времени запуска приложения выяснили, что разрешение зависимостей занимает непозволительно долгое время на старте приложения (единицы секунд на слабых устройствах). Поэтому мы перешли на ручной DI через property-based injection. Не сказал бы, что кода стало больше: уровень сервисов в приложении обычно настраивается в одном классе, а вся конфигурация сервисов легко читается. Для share и imessage экстеншенов, естественно, сервисы конфигурируются отдельно, поскольку в этом случае нужен гораздо меньший их набор.
Таким образом, связанность кода была изначально не очень большой, и даже через довольно продолжительное время после начала разработки мы без особого труда смогли вынести часть сервисов и обслуживающего кода в отдельную библиотеку (точнее, даже набор библиотек), которая реализует бо́льшую часть внутренней логики мессенджера, включая работу с протоколом и кеширование, и которую можно встраивать в другие приложения.
Заключение
Быстродействие приложения или работа в офлайне — для нас это не желание быть в тренде, а скорее реальный способ дать удобную возможность для общения конкретным пользователям. Тем, у кого дорогой мобильный трафик или просто плохой интернет. И это довольно серьёзная мотивация, чтобы сделать хорошо. Как получилось в итоге — оценивать пользователям, так что предлагаю вам установить мессенджер и поделиться в комментариях своим фидбеком. Буду рад ответить на вопросы.
Автор: Digal