company_banner

TamTam: как мы делали новый мессенджер


    Привет, Хабр! Меня зовут Юрий Буянов, я разработчик мессенджера 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 рублей. Как следствие, на более мощных девайсах приложение просто летает.


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


    image


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


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


    Как мы оптимизируем? В первую очередь выносим всё, что можно, из главного потока: работу с БД (об этом чуть ниже), работу с сетью, сериализацию и десериализацию данных, процессинг картинок и даже вычисления, связанные с вёрсткой текста.


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


    При выборе сторонних решений и библиотек в узких местах мы тоже старались учитывать быстродействие и компактность. В частности, именно поэтому мы выбрали MessagePack (причём для iOS специально делали бенчмарк разных реализаций), поменяли библиотеку для маппинга данных в объекты с Mantle на YYModel и остановились на lz4 в качестве алгоритма компрессии трафика.


    Кроме того, для достижения плавности работы интерфейса мы симптоматически оптимизируем рендеринг:


    • избегаем offscreen-рендеринга, нагружающего процессор;
    • заранее в фоне ресайзим картинки вместо использования работающих в главном потоке стандартных UIViewContentMode;
    • делаем наши иерархии UI более «плоскими» и простыми;
    • кешируем те объекты и данные, создание которых слишком затратно. Начиная с высоты ячеек с текстом и заканчивая YYTextLayout (объект, который хранит информацию об отображении текста в библиотеке YYText), NSAttributedStrings и даже самими UIViews.

    Во всех списках идёт ручная вёрстка без auto layout. Хотя auto layout мы тоже очень любим и используем декларативную вёрстку с помощью Masonry в коде — но только там, где это целесообразно.


    Офлайн и работа при плохом интернете


    При работе с сетью мы стараемся минимизировать трафик и задержки за счёт выбора быстрого компактного протокола и агрессивного кеширования.


    В качестве способа общения с сервером мы используем только TCP-сокеты и бинарный протокол. Это позволяет нам как получать обновления с сервера в реальном времени, так и работать в более привычном режиме «запрос — ответ».


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


    image


    Сами пакеты состоят из заголовка фиксированной длины со служебной информацией: код команды, версия протокола, длина пэйлоада. Ответы на запросы могут приходить в разном порядке и вперемешку с командами сервера, поэтому в заголовке есть 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 экстеншенов, естественно, сервисы конфигурируются отдельно, поскольку в этом случае нужен гораздо меньший их набор.


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


    Заключение


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

    Метки:
    Mail.Ru Group 959,90
    Строим Интернет
    Поделиться публикацией
    Похожие публикации
    Комментарии 33
    • +7
      А я, вероятно, тот чувак с собакой, со стартовой картинки этого поста.
      • 0
        Если б мог, то заплюсовал бы! Имхо, соцсети себя уже как-то изживают и месседжеры скоро пойдут туда же — в историю. Особенно, соцсети типа «Одноклассников» (минимум полгода туда не захожу — берегу психику).
      • 0

        А почему в этом блоге никогда не появляются разработчики инновационного браузера Амиго? Было бы очень интересно с ними пообщаться

        • 0
          Сначала вам прийдется установить Амиго. Добровольно. На рабочий ПК. Без виртуалок.
          • +2

            В docker контейнере?)

            • 0
              Вот поэтому они и не выходят на связь :)
            • 0
              Нет Linux-версии Амиго. Хнык (
              • 0
                Восславим ${GodName} || Науку за это проявление милосердия от mail.ru. НО если вам прям вот надо — можно запустить в VirtualBox
            • 0
              Они не могут зайти на хабр с Амиго. А сам Амиго не могут удалить и он не дает нормально пользоваться другими браузерами…
            • 0

              icq вроде тоже принадлежит mail.ru?

              • +3
                и «mail.ru агент»
                тоже мессенджер.
              • +2
                В самом начале мы использовали для DI фреймворк Typhoon, но в ходе оптимизации времени запуска приложения выяснили, что разрешение зависимостей занимает непозволительно долгое время на старте приложения (единицы секунд на слабых устройствах). Поэтому мы перешли на ручной DI через property-based injection.

                Скажите, а какую версию Typhoon вы использовали, до того, как отказались от него? Вроде начиная с четвертой версии разработчики оптимизировали время разрешения зависимостей.
                • +2
                  Последняя версия, которую мы пробовали — 3.1.7 (это было ещё до выхода первого релиза ОК Сообщений). Потом мы перешли на самописный механизм, и с тех пор никакой потребности вернуться обратно к стороннему решению для DI у нас не возникало.
                • +4
                  При этом у TamTam достаточно молодая аудитория: 28 % дневной аудитории — это люди в возрасте 27—34 лет, а более половины пользователей (54 %) — младше 35 лет.

                  Это не очень молодая аудитория, на мой взгляд.


                  У вас же есть Mail.Ru агент, ICQ. Новый мессенджер ради нового мессенджера.

                  • 0
                    Ага, потом он объединится в супер-мессенджер и захватит телеграм))
                    • 0
                      В подразделении которое занимается аськой и агентом (это одно и то же) все довольно печально: процессы крайне скверно организованы, простая задача настройки кластера под бекенд занимает с месяц времени и админы проекта так загружены (объективно), что нет никакой надежды на улучшение ситуации. Особенно учитывая что директор подразделения тратит время разработчиков, админов и маркетологов на создание заведомой провальной дряни вроде O!Life, а у техдира представления слишком часто на уровне «что-то слышал», например он не особо в курсе технических подробностей инфраструктуры.

                      Это я про то, что у разработчиков там-тама могут быть какие-то свои представления о работе мессенджера, которые силами подразделения мессенджеров в разумные сроки не реализовать.
                    • +1
                      Спасибо, познавательно. Насчет UITableView — было сложно сделать собственный вариант, без хаков transform и иже с ними?
                      • 0
                        А для настольных ПК планируете мессенджер адаптировать?
                        • 0
                          А почему про Андроид ничего особо не сказали? Или там проблем не было, тишь да гладь?:)
                          • +1
                            Нет, конечно. Просто я работаю именно над iOS-клиентом, поэтому и написал техническую часть статьи про него. Про Android в будущем тоже постараемся подробно рассказать.
                          • +2
                            Классный хак с UITableView! А protobuf или capnproto какой не подошел, потому что schemeless хотелось? Или messagepack настолько же быстрый?
                            • +2
                              Да, хотелось schemaless. Решили что синхронизация изменений схемы между сервером и двумя клиентами в процессе активного развития API может затормозить разработку.
                            • +2
                              Спасибо, что делитесь своим опытом! Без сомнения команда проделала огромную работу.

                              Хак с разворотом таблицы (UITableView) — весьма интересный, однако неправда, что
                              «UITableView просто хронически не приспособлен к тому, чтобы элементы добавлялись в начало списка.»


                              Если выставлять contentOffset через метод

                              [tableView setContentOffset:contentOffset];
                              


                              то анимация «скролла» сохраняется и пользователь ничего не заметит :)
                              • +1
                                Эм, нет. По крайней мере, на момент написания этого кода (по-моему, iOS 8 или 9) оно так не работало. А на какой версии iOS вы проверяли?
                              • +1
                                А почему UITableView, а не UICollectuonView с кастомным лейаутом? Помнится как перевели свой чат с первого на второй получили совершенно неадекватный, но приятный буст перформанса (весь лейаут текстов так-же кешировали в модели, и на ходу ничего не считали)
                                • 0
                                  Горшочек, не вари!
                                  Скоро мессенджеров различных систем и калибров будет больше чем их пользователей
                                  Может лучше довести до ума то что уже есть?
                                  • –3
                                    TamTam — это новый мессенджер Mail.Ru Group

                                    Знаете… вот на этой фразе я закончил читать пост. Дальше сразу стало неинтересно. ))
                                    • –2
                                      Да-да, прошу прощения, что не посмотрел сразу, чей это блог ))
                                      Я сам виноват — был бы внимательнее, на зашёл бы вообще )))))
                                    • 0

                                      А какое число новых пользователей у вас ориентировочно за день? И сколько уников в день?

                                      • +3
                                        К сожалению, не могу раскрыть цифры, но по уникам мы стабильно растём. Могу сказать, что установок на текущий момент в сумме около 2.5М.

                                        В общем, пользователей достаточно, чтобы заметить интерес к приложению и стимулировать нас улучшать его дальше :)
                                      • +2
                                        В iOS-команде мы стараемся тестировать и замерять быстродействие на iPhone 5 и iPhone 4S. Андроид-команда имеет в распоряжении Galaxy S3 и Мегафон логин за 1000 рублей. Как следствие, на более мощных девайсах приложение просто летает.

                                        Плюсую, если разрабатывать и тестировать на «дохлом бобре», то на нормальном железе будет летать.
                                        • 0
                                          А есть какая-нибудь статистика по уровню сжатия LZ4 в вашем случае? Всегда ли стоит его использовать?
                                          • 0
                                            В нашем случае, данные в среднем сжимаются раза в два.

                                            Естественно, для очень маленького или уже сжатого пакета сжатие не имеет смысла (может даже немного увеличить размер), поэтому сервер в таком случае может решить присылать клиенту несжатые данные.

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

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