1. Зачем Одноклассникам мессенджер
2. Неочевидные требования
- Устойчивая работа в ненадёжных сетях и на слабых устройствах
- Полная совместимость с существующим сервисом сообщений в Одноклассниках
3. Общая концепция проекта
- Целевая аудитория
- Платформы
- Скоуп (что точно должно было войти, и что осталось за бортом)
4. Сетевое взаимодействие
- Протокол (общее описание и обоснование, почему сделали именно так)
- Особенности API
4. Архитектура iOS-клиента
- Адаптации под изменчивость требований
- MVVM
- Навигация
- Service Layer
- Dependency Injection
- Кеширование, офлайн-режим, YapDatabase
- Оптимизации и хаки
5. Статистика и аналитика
9. Оптимизации
• Все тяжёлые операции — в фон
• Работа с БД
• Работа с сетью
• Сериализация/десериализация
• Работа с картинками
• Вёрстка и вычисление размеров текста
11. Оптимизации
• Оптимизация рендеринга (избавление от offscreen rendering) —
симптоматически
• UIViewContentModeCenter (по возможности)
• Упрощение иерархий UIView
• Кеширование всего
• “Тяжёлые” UIView
• Информация о размерах / вёрстке
• NSAttributedString
• YYTextLayout
• UIImage (нужного размера, decompressed, скруглённые углы)
12. Протокол
• TCP Socket (+TLS)
• Запрос-ответ
• Server-Side Push
• Транспорт легко поменять:
• Вебсокеты
• Шифрование на уровне пакета
13. • Фиксированная длина
• Opcode
• Версия протокола
• Длина payload
• Seq index (для связывания
запроса с ответом)
Header Payload
• Переменная длина
• LZ4-compressed
• MessagePack
Протокол
23. YapDatabase
• Key-Value (“на стероидах”)
• NSCoding или кастомная сериализация
• (нам нравится MessagePack)
• Асинхронность и многопоточность
• Объекты не привязаны к контексту
27. YapDatabase
• Модель транзакций и данных — проще
• (субъективно)
• Осторожнее с параллельным доступом
• Обновляем объекты только в рамках одной
транзакции
• Очень просто “реактивизировать” работу с БД
30. Бесконечный скролл
• Нужно переворачивать ячейки и быть
аккуратнее с жестами
• Нужно переворачивать индексы в массиве
моделей (пока, секции 🙁)
• Вычисления связанные с contentOffset/Inset
немного взрывают мозг
• При скролле вниз проблема возвращается
31. Обновление списков
• в одну секунду может идти несколько
обновлений чата:
• подгрузка истории вверх
• новое сообщение
• обновление статуса старых сообщений
(прочитано)
32. Обновление списков
• Если анимация предыдущего обновления не
кончилась, а следующее обновление уже
поменяло данные (массив моделей ячеек) —
случается креш
• Нужно отложить обновление данных до того
момента как анимация закончится
• UIUpdateQueue
Добрый день
Меня зовут Юрий Буянов и я делаю мессенджер в компании "Одноклассники". Сегодня я хочу рассказать вам немного о том, как он устроен внутри.
Для начала — пара слов о том, зачем и для кого мы это всё затеяли. Как и любая другая большая соцсеть, мы хотим дать своим пользователям возможность взаимодействовать друг с другом и с контентом, не покидая привычной среды и социального графа. И если людям хочется использовать для общения с близкими современный удобный мессенджер с анимированными стикерами — надо дать им такую возможность.
Забегая вперёд, скажу что идея себя оправдала и средний пользователь мессенджера пишет в три раза больше сообщений, чем средний пользователь, переписывающийся через веб или основное мобильное приложение.
Немного об основных сложностях, с которыми мы столкнулись
Не смотря на то, то мессенджер это отдельное приложение, оно должно было существовать на платформе одноклассников и бесшовно работать с имеющейся системой сообщений. В первую очередь это означает что мы с самого начала должны были поддержать все те типы контента, которые есть в сообщениях одноклассников. Помимо текста и картинок это видео (в том числе живые трансляции), аудио, стикеры, гифки, и т.н. коллажи (то есть, сообщения состоящие из текста и нескольких картинок или видео).
Одноклассники безумно популярны на всей территории России и стран СНГ (и не только), в том числе в местах с нерегулярным покрытием мобильной сети (а иногда и полным отутствием стационарного интернета). В некоторых странах СНГ за пределами крупных городов 2G-соединение вообще является фактически единственным окном в интернет.
Кроме того, многие наши пользователи сомневаются в целесообразности покупки каждый год новой модели айфона или ГОРЯЧЕЙ НОВИНКИ от фирмы "Самсунг". По статистике самыми популярными девайсами под iOS у наших пользователей является iPhone 5s, а под Android — недорогие Galaxy выпуска 2014-2015 годов.
Поэтому, одним из самых приоритетных направлений в разработке мессенджера для нас с самого начала была оптимизация приложения как с точки зрения быстродействия, так и с точки зрения работы с сетью.
Быстродействие с точки зрения пользователя это, в первую очередь скорость запуска (куда входит и время до отображения нового контента, например при открытии чата с новым сообщением по пуш-уведомлению) и плавность работы (в частности, скролла). В iOS-команде мы стараемся тестировать и замерять быстродействие на iPhone 5 и iPhone 4S. Андроид-команда имеет в распоряжении такие девайсы, как galaxy s3 и "мегафон логин" за 1000 рублей. Приятным следствием из этого является то, что на более мощных девайсах приложение просто летает.
Помимо субъективного тестирования "на плавность" у нас в каждой тестовой сборке можно включить счётчик ФПС, в логи и в систему статистике записывается длительность выполнения операций в "узких местах". Вот здесь, например, виден график из нашей системы статистики, где показано время с момента запуска приложения при открытии по пушу до момента когда пользователь увидит это конкретное сообщение на экране. Заметное падение на графике — это постепенное включение контент-пушей на пользователей.
Как мы оптимизируем? В первую очередь, мы выносим всё что можно из главного треда: работу с БД (об этом будет чуть ниже), работу с сетью, сериализацию/десериализацию данных, процессинг картинок, и даже вычисления, связанные с вёрсткой текста
При выборе сторонних решений и библиотек в узких местах мы тоже старались учитывать быстродействие и компактность. В частности, именно поэтому мы выбрали MessagePack (причём для iOS специально делали бенчмарк разных реализаций), поменяли библиотеку для маппинга данных в объекты с Mantle на YYModel и остановились на lz4 в качестве алгоритма компрессии трафика.
Кроме того, для достижения плавности работы интерфейса мы симптоматически занимаемся оптимизацией рендеринга (избегаем offcreen рендеринга, нагружающего процессор), заранее в фоне ресайзим картинки (вместо использования работающих в главном потоке стандартных UIViewContentMode), делаем наши иерархии UI более "плоскими и простыми", и стараемся кешировать те объекты и данные, создание которых слишком затратно, начиная с высоты ячеек с текстом, заканчивая YYTextLayout (это объект, который хранит информацию об отображении текста в библиотеке YYText), NSAttributedStrings и даже сами UIViews.
Во всех списках используется ручная вёрстка, без auto layout (хотя auto layout мы тоже очень любим и используем декларативную вёрстку в коде, там где это целесообразно).
При работе с сетью мы стараемся минимизировать трафик и задержки за счёт выбора быстрого компактного протокола и агрессивного кеширования.
В качестве способа общения с сервером мы используем только TCP-сокеты (с TLS-шифрованием). И бинарный протокол. Это позволяет нам как получать обновления с сервера в реальном времени, так и работать в более привычном режиме "запрос-ответ". Сам API, то есть набор команд поверх низкоуровневого протокола, можно в будущем при желании реализовать и на веб-сокетах, так и улучшить, например, добавив шифрование на уровне пакета.
Сами пакеты состоят из заголовка фиксированной длины, несущего служебную информацию, такую как код команды, версию протокола и длину пэйлоада. Также (поскольку ответы на запросы могут приходить в разном порядке и в перемешку с командами сервера) там есть sequence number, позволяющий связать запрос и ответ.
В качестве формата для пэйлоада мы решили попробовать messagepack. Он не требует жёсткого задания схемы, очень компактный и имеет довольно шустрые библиотеки сериализации под кучу платформ. Для того, чтобы снизить потребление трафика ещё сильнее мы сжимаем пэйлоад алгоритмом lz4 (который также выбрали за скорость и небольшую нагрузку на CPU и батарейку).
Схема синхронизации данных довольно простая
После коннекта клиент аутентифицируется, одновременно запрашивая критически важные данные: настройки, список контактов и чатов с последними сообщениями. Мы храним таймстемп последнего обновления (получая его от сервера, конечно) и передаём его в запросе, чтобы получить обратно только то, что действительно поменялось. После того как соединение установлено, мы можем получать обновления в реальном времени (например, новые сообщения или изменения данных контактов).
После коннекта клиент аутентифицируется, одновременно запрашивая критически важные данные: настройки, список контактов и чатов с последними сообщениями. Мы храним таймстемп последнего обновления (получая его от сервера, конечно) и передаём его в запросе, чтобы получить обратно только то, что действительно поменялось. После того как соединение установлено, мы можем получать обновления в реальном времени (например, новые сообщения или изменения данных контактов).
После коннекта клиент аутентифицируется, одновременно запрашивая критически важные данные: настройки, список контактов и чатов с последними сообщениями. Мы храним таймстемп последнего обновления (получая его от сервера, конечно) и передаём его в запросе, чтобы получить обратно только то, что действительно поменялось. После того как соединение установлено, мы можем получать обновления в реальном времени (например, новые сообщения или изменения данных контактов).
После коннекта клиент аутентифицируется, одновременно запрашивая критически важные данные: настройки, список контактов и чатов с последними сообщениями. Мы храним таймстемп последнего обновления (получая его от сервера, конечно) и передаём его в запросе, чтобы получить обратно только то, что действительно поменялось. После того как соединение установлено, мы можем получать обновления в реальном времени (например, новые сообщения или изменения данных контактов).
С историей сообщений в чате всё чуть сложнее. Грузить заранее всю историю всех чатов бессмысленно, но то что мы один раз получили — мы кешируем и стараемся больше не запрашивать. В результате, если посмотреть на то, какие участки истории чата закешированы, мы увидим что в истории есть "разрывы". Например, мы видим что в чате новое последнее сообщение (мы получили его с обновлением списка чатов после логина). Кроме того, у нас есть история, закешированная в ходе прошлой сессии. Таких закешированных кусков истории может быть несколько. Кроме того, что очень неудобно, мы не знаем, сколько сообщений есть на сервере между последним сообщением в чате и предыдущим закешированным сообщением.
Поэтому кроме самих сообщений, мы храним метаданные о непрерывных кусках истории (чанках), которые мы закешировали. При скролле чата мы используем эту информацию, для того чтобы знать, грузить следующую страницу из кеша, или необходимо сделать запрос на сервер (или и то и другое).
При этом чанки меняют размер и сливаются друг с другом.
Поскольку приложение под iOS не может постоянно работать в фоне, нам очень помогают контент-пуши: чаше всего, когда пользователь открывает пуш, сообщения в чате уже загружены мы можем их сразу показать пользователю (даже при плохом или отсутсивующем соединении).
При проектировании мы используем подход MVVM, но я не буду делать ещё один 152 доклад про введение в MVVM, расскажу про те вещи, которые оказались новыми именно для нас
В iOS-приложении для кеширования мы используем библиотеку YapDatabase.
(Спросить кто знаком)
YapDatabase это по сути Key-Value хранилище поверх SQLite с очень большим набором возможностей. Лично мне кажется что она гораздо проще в использовании и гибче, чем CoreData. В частности, можно выбрать механизм сериализации объектов в базе (по умолчанию используется NSCoding, а мы используем тот же MessagePack). YapDatabase не требует наследования объектов от базового класса или реализации какого-то протокола, и не привязывает объекты к контексту. Чтение и запись производятся с помощью синхронных или асинхронных транзакций.
При этом доступны все те же возможности, что и в "настоящей" БД, такие как прозвольные SQL-запросы и индексирование нескольких полей, полнотекстовый поиск, подписка на изменения (как в NSFetchedResultsController), etc.
Немного покажу как выглядит работа с YapDatabase. Инициализация соединения, то есть, объекта, который позволяет непосредственно работать с транзакциями занимает буквально две строки.
При необходимости, здесь же можно определить механизм сериализации.
(в этом примере испульзуется NSKeyedArchiver, а мы у себя используем MessagePack)
Транзакции делаются при помощи блоков, при этом сделать асинхронную транзакцию в фоне не сложнее чем синхронную.
Лично мне YapDatabase очень нравится и повышает продуктивность и понятность кода, но у меня есть несколько коллег, которые её не очень любят. В принципе, их можно понять, поскольку после длительной работы с CoreData для работы с YapDatabase нужно действительно несколько вывернуть мозг.
Кроме того, при асинхронной работе с базой через несколько соединений нужно хорошо понимать как база обрабатывает параллельные запросы на чтение и запись через одно или разные соединения (эта информация есть в вики). А также, помнить что объекты обновляются в БД целиком. Для обновления объекта в БД надо не просто сохранить тот экземпляр, который вы прочитали какое-то время назад и модифицировали, прочитать объект из базы, изменить его так, как вам нужно, и записать обратно в рамках одной транзакции. В противном случае можно случайно записать в БД устаревшие данные.
Кроме того, работа с базой очень удобно встраивается в наш реактивный стиль написания кода. Асинхронные шаблоны транзакций (чтение/запись/модификация отдельного объекта) очень просто завернуть, например, в сигналы ReactiveCocoa, и встраивать работу с базой в одну цепочку с обработкой сетевых запросов.
Немного расскажу о паре приёмов, которые мы использовали в iOS-приложении, для того чтобы обойти ограничения системы, которые мешали нам сделать дружелюбный и плавный интерфейс.
Одной из проблем стал безконечный скролл в чате, то есть, незаметная для пользователя подгрузка истории при прокрутке чата вверх. В 99% случаев пользователь находится именно внизу чата и хочет проскроллить его вверх для того, чтобы прочитать старые сообщения. Мы не дожидаемся пока пользователь доскроллит до самого верха и увидит там "крутилку", а стараемся заранее запрашивать предыдущие страницы истории ещё во время скролла (как из локального кеша, так и с сервера). Проблема заключается в том, что когда мы вставляем такую страницу в начало списка сообщений, contentOffset того участка, который уже был загружен — сдвигается и скролл скачет. Естественно, мы можем посчитать размер вставляемой страницы и изменить contentOffset обратно, но это (мы же помним, что это происходит во время скролла) приводит к резкой остановке анимации скролла, а это некрасиво.
Чтобы решить эту проблему мы (и не только мы, насколько я знаю, похожее решение используют ещё несколько мессенджеров) применили очень странный "костыль": мы переворачиваем список вверх ногами (с помощью .transform), а затем переворачиваем каждую ячейку в обратном направлении. Пользователь ничего не замечает, но теперь contentOffset отсчитывается снизу и подгрузка старых сообщений никак на него не влияет.
Это решение хорошо работает, но у него есть ряд подводных камней. Во первых, необходимо конвертировать перевёрнутые индексы ячеек в индексы в вашей модели данных (и обратно). В случае, если у вас больше одной секции, вычисления будут очень сложными, так что лучше ограничиться одной. Кроме того, плавающие заголовки работают не очень хорошо (но их можно имитировать).
Во вторых, могут возникнуть проблемы с вычислением координат внутри ячеек, наприимер при работе с жестами, но это происходит не очень часто.
В третьих — при подгрузке данных вниз проблема возвращается, но поскольку подгрузка при скролле вниз происходит сильно реже, то для нас это не очень большая проблема (хотя хотелось бы решить и её, конечно).
Вторая проблема — это анимированные (и вообще асинхронные) обновления списков. Если несколько независимых обновлений происходят почти одновременно (например, подгружается страница истории вверху чата и приходит новое сообщение внизу), то данные, используемые делегатом tableView могут измениться в момент, когда не закончилась анимация предыдущего обновления. Это может привести к тому, что UITableView отрендерит неправильную ячейку или вообще упадёт (особенно, если вы используете предыдущий хак). Можно, конечно использовать метод reloadData, который является синхронным в UITableView, однако это приводит к морганиям, остановке скролла, и прочим раздражающим пользователя вещам
Специально для таких случаев мы сделали специальную очередь для последовательной обработки таких обновлений. Все изменения модели и отображение их на UI производятся внутри блоков, которые ставятся в очередь. При этом блок может залочить очередь при старте анимации (или какой-то другой асинхронной операции) и разлочить её при завершении. Таким образом, вся работа с таблицей производится последовательно и данные не меняются, пока не завершится предыдущая анимация.