Сообщения
Одноклассников
Нюансы разработки мобильного мессенджера
Юрий Буянов
“Одноклассники” 2016
Что и зачем
Сложности
Типы медиа
• текст (с нестандартными смайлами, в том числе,
загружаемыми с сервера)
• картинки
• гифки
• видео
• аудиосообщения
• стикеры
• коллажи (текст + картинки + видео)
Оптимизация
Метрики
быстродействия
Оптимизации
• Все тяжёлые операции — в фон
• Работа с БД
• Работа с сетью
• Сериализация/десериализация
• Работа с картинками
• Вёрстка и вычисление размеров текста
Оптимизации
• Бенчмаркинг и выбор быстрых решений
• MessagePack
• Mantle -> YYModel
• lz4 fast compression
Оптимизации
• Оптимизация рендеринга (избавление от offscreen rendering) —
симптоматически
• UIViewContentModeCenter (по возможности)
• Упрощение иерархий UIView
• Кеширование всего
• “Тяжёлые” UIView
• Информация о размерах / вёрстке
• NSAttributedString
• YYTextLayout
• UIImage (нужного размера, decompressed, скруглённые углы)
Протокол
• TCP Socket (+TLS)
• Запрос-ответ
• Server-Side Push
• Транспорт легко поменять:
• Вебсокеты
• Шифрование на уровне пакета
• Фиксированная длина
• Opcode
• Версия протокола
• Длина payload
• Seq index (для связывания
запроса с ответом)
Header Payload
• Переменная длина
• LZ4-compressed
• MessagePack
Протокол
Синхронизация и
кеширование
Синхронизация и
кеширование
Чат 4
Чат 3
Чат 2
Чат 1
Чат 4
Чат 3
Чат 2 *
Чат 1
Чат 5 *
Offline
Синхронизация и
кеширование
Чат 4
Чат 3
Чат 2
Чат 1
Чат 4
Чат 3
Чат 2 *
Чат 1
Чат 5 *
Login
chatSync = 1472735750977
Синхронизация и
кеширование
Login ACK
chats = […]
chatSync = 1472735812543
Чат 4
Чат 3
Чат 2 *
Чат 1
Чат 5 *
Чат 4
Чат 3
Чат 2 *
Чат 1
Чат 5 *
Синхронизация и
кеширование
Online
Чат 4
Чат 3
Чат 2 *
Чат 1
Чат 5 *
Чат 4
Чат 3
Чат 2 *
Чат 1
Чат 5 *
Кеш сообщений
? ?
Last Message
?ChunkInfoChunkInfo
Закешированные сообщения
Кеш сообщений
? ?
Last Message
?ChunkInfoChunkInfo
Закешированные сообщения
? ? ChunkInfoChunkInfo
Кеш сообщений
? ?
Last Message
?ChunkInfoChunkInfo
Закешированные сообщения
? ? ChunkInfoChunkInfo
? ? ChunkInfoChunkInfo
Реализация
YapDatabase
• Key-Value (“на стероидах”)
• NSCoding или кастомная сериализация
• (нам нравится MessagePack)
• Асинхронность и многопоточность
• Объекты не привязаны к контексту
Инициализация БД
Инициализация БД
Транзакции
YapDatabase
• Модель транзакций и данных — проще
• (субъективно)
• Осторожнее с параллельным доступом
• Обновляем объекты только в рамках одной
транзакции
• Очень просто “реактивизировать” работу с БД
Бесконечный скролл
0
new page
contentOffset
0
Бесконечный скролл
0
new page
contentOffset
0
Бесконечный скролл
• Нужно переворачивать ячейки и быть
аккуратнее с жестами
• Нужно переворачивать индексы в массиве
моделей (пока, секции 🙁)
• Вычисления связанные с contentOffset/Inset
немного взрывают мозг
• При скролле вниз проблема возвращается
Обновление списков
• в одну секунду может идти несколько
обновлений чата:
• подгрузка истории вверх
• новое сообщение
• обновление статуса старых сообщений
(прочитано)
Обновление списков
• Если анимация предыдущего обновления не
кончилась, а следующее обновление уже
поменяло данные (массив моделей ячеек) —
случается креш
• Нужно отложить обновление данных до того
момента как анимация закончится
• UIUpdateQueue
UIUpdateQueue
UIUpdateQueue
Спасибо
Юрий Буянов
“Одноклассники” 2016ok.ru/digal
twitter.com/digal

Юрий Буянов | (Одноклассники)Нюансы разработки мобильного мессенджера

Editor's Notes

  • #2 Добрый день Меня зовут Юрий Буянов и я делаю мессенджер в компании "Одноклассники". Сегодня я хочу рассказать вам немного о том, как он устроен внутри.
  • #3 Для начала — пара слов о том, зачем и для кого мы это всё затеяли. Как и любая другая большая соцсеть, мы хотим дать своим пользователям возможность взаимодействовать друг с другом и с контентом, не покидая привычной среды и социального графа. И если людям хочется использовать для общения с близкими современный удобный мессенджер с анимированными стикерами — надо дать им такую возможность. Забегая вперёд, скажу что идея себя оправдала и средний пользователь мессенджера пишет в три раза больше сообщений, чем средний пользователь, переписывающийся через веб или основное мобильное приложение.
  • #4 Немного об основных сложностях, с которыми мы столкнулись
  • #5 Не смотря на то, то мессенджер это отдельное приложение, оно должно было существовать на платформе одноклассников и бесшовно работать с имеющейся системой сообщений. В первую очередь это означает что мы с самого начала должны были поддержать все те типы контента, которые есть в сообщениях одноклассников. Помимо текста и картинок это видео (в том числе живые трансляции), аудио, стикеры, гифки, и т.н. коллажи (то есть, сообщения состоящие из текста и нескольких картинок или видео).
  • #6 Одноклассники безумно популярны на всей территории России и стран СНГ (и не только), в том числе в местах с нерегулярным покрытием мобильной сети (а иногда и полным отутствием стационарного интернета). В некоторых странах СНГ за пределами крупных городов 2G-соединение вообще является фактически единственным окном в интернет.
  • #7 Кроме того, многие наши пользователи сомневаются в целесообразности покупки каждый год новой модели айфона или ГОРЯЧЕЙ НОВИНКИ от фирмы "Самсунг". По статистике самыми популярными девайсами под iOS у наших пользователей является iPhone 5s, а под Android — недорогие Galaxy выпуска 2014-2015 годов. Поэтому, одним из самых приоритетных направлений в разработке мессенджера для нас с самого начала была оптимизация приложения как с точки зрения быстродействия, так и с точки зрения работы с сетью.
  • #8 Быстродействие с точки зрения пользователя это, в первую очередь скорость запуска (куда входит и время до отображения нового контента, например при открытии чата с новым сообщением по пуш-уведомлению) и плавность работы (в частности, скролла). В iOS-команде мы стараемся тестировать и замерять быстродействие на iPhone 5 и iPhone 4S. Андроид-команда имеет в распоряжении такие девайсы, как galaxy s3 и "мегафон логин" за 1000 рублей. Приятным следствием из этого является то, что на более мощных девайсах приложение просто летает.
  • #9 Помимо субъективного тестирования "на плавность" у нас в каждой тестовой сборке можно включить счётчик ФПС, в логи и в систему статистике записывается длительность выполнения операций в "узких местах". Вот здесь, например, виден график из нашей системы статистики, где показано время с момента запуска приложения при открытии по пушу до момента когда пользователь увидит это конкретное сообщение на экране. Заметное падение на графике — это постепенное включение контент-пушей на пользователей.
  • #10 Как мы оптимизируем? В первую очередь, мы выносим всё что можно из главного треда: работу с БД (об этом будет чуть ниже), работу с сетью, сериализацию/десериализацию данных, процессинг картинок, и даже вычисления, связанные с вёрсткой текста
  • #11 При выборе сторонних решений и библиотек в узких местах мы тоже старались учитывать быстродействие и компактность. В частности, именно поэтому мы выбрали MessagePack (причём для iOS специально делали бенчмарк разных реализаций), поменяли библиотеку для маппинга данных в объекты с Mantle на YYModel и остановились на lz4 в качестве алгоритма компрессии трафика.
  • #12 Кроме того, для достижения плавности работы интерфейса мы симптоматически занимаемся оптимизацией рендеринга (избегаем offcreen рендеринга, нагружающего процессор), заранее в фоне ресайзим картинки (вместо использования работающих в главном потоке стандартных UIViewContentMode), делаем наши иерархии UI более "плоскими и простыми", и стараемся кешировать те объекты и данные, создание которых слишком затратно, начиная с высоты ячеек с текстом, заканчивая YYTextLayout (это объект, который хранит информацию об отображении текста в библиотеке YYText), NSAttributedStrings и даже сами UIViews. Во всех списках используется ручная вёрстка, без auto layout (хотя auto layout мы тоже очень любим и используем декларативную вёрстку в коде, там где это целесообразно).
  • #13 При работе с сетью мы стараемся минимизировать трафик и задержки за счёт выбора быстрого компактного протокола и агрессивного кеширования. В качестве способа общения с сервером мы используем только TCP-сокеты (с TLS-шифрованием). И бинарный протокол. Это позволяет нам как получать обновления с сервера в реальном времени, так и работать в более привычном режиме "запрос-ответ". Сам API, то есть набор команд поверх низкоуровневого протокола, можно в будущем при желании реализовать и на веб-сокетах, так и улучшить, например, добавив шифрование на уровне пакета.
  • #14 Сами пакеты состоят из заголовка фиксированной длины, несущего служебную информацию, такую как код команды, версию протокола и длину пэйлоада. Также (поскольку ответы на запросы могут приходить в разном порядке и в перемешку с командами сервера) там есть sequence number, позволяющий связать запрос и ответ. В качестве формата для пэйлоада мы решили попробовать messagepack. Он не требует жёсткого задания схемы, очень компактный и имеет довольно шустрые библиотеки сериализации под кучу платформ. Для того, чтобы снизить потребление трафика ещё сильнее мы сжимаем пэйлоад алгоритмом lz4 (который также выбрали за скорость и небольшую нагрузку на CPU и батарейку).
  • #15 Схема синхронизации данных довольно простая
  • #16 После коннекта клиент аутентифицируется, одновременно запрашивая критически важные данные: настройки, список контактов и чатов с последними сообщениями. Мы храним таймстемп последнего обновления (получая его от сервера, конечно) и передаём его в запросе, чтобы получить обратно только то, что действительно поменялось. После того как соединение установлено, мы можем получать обновления в реальном времени (например, новые сообщения или изменения данных контактов).
  • #17 После коннекта клиент аутентифицируется, одновременно запрашивая критически важные данные: настройки, список контактов и чатов с последними сообщениями. Мы храним таймстемп последнего обновления (получая его от сервера, конечно) и передаём его в запросе, чтобы получить обратно только то, что действительно поменялось. После того как соединение установлено, мы можем получать обновления в реальном времени (например, новые сообщения или изменения данных контактов).
  • #18 После коннекта клиент аутентифицируется, одновременно запрашивая критически важные данные: настройки, список контактов и чатов с последними сообщениями. Мы храним таймстемп последнего обновления (получая его от сервера, конечно) и передаём его в запросе, чтобы получить обратно только то, что действительно поменялось. После того как соединение установлено, мы можем получать обновления в реальном времени (например, новые сообщения или изменения данных контактов).
  • #19 После коннекта клиент аутентифицируется, одновременно запрашивая критически важные данные: настройки, список контактов и чатов с последними сообщениями. Мы храним таймстемп последнего обновления (получая его от сервера, конечно) и передаём его в запросе, чтобы получить обратно только то, что действительно поменялось. После того как соединение установлено, мы можем получать обновления в реальном времени (например, новые сообщения или изменения данных контактов).
  • #20 С историей сообщений в чате всё чуть сложнее. Грузить заранее всю историю всех чатов бессмысленно, но то что мы один раз получили — мы кешируем и стараемся больше не запрашивать. В результате, если посмотреть на то, какие участки истории чата закешированы, мы увидим что в истории есть "разрывы". Например, мы видим что в чате новое последнее сообщение (мы получили его с обновлением списка чатов после логина). Кроме того, у нас есть история, закешированная в ходе прошлой сессии. Таких закешированных кусков истории может быть несколько. Кроме того, что очень неудобно, мы не знаем, сколько сообщений есть на сервере между последним сообщением в чате и предыдущим закешированным сообщением.
  • #21 Поэтому кроме самих сообщений, мы храним метаданные о непрерывных кусках истории (чанках), которые мы закешировали. При скролле чата мы используем эту информацию, для того чтобы знать, грузить следующую страницу из кеша, или необходимо сделать запрос на сервер (или и то и другое).
  • #22 При этом чанки меняют размер и сливаются друг с другом. Поскольку приложение под iOS не может постоянно работать в фоне, нам очень помогают контент-пуши: чаше всего, когда пользователь открывает пуш, сообщения в чате уже загружены мы можем их сразу показать пользователю (даже при плохом или отсутсивующем соединении).
  • #23 При проектировании мы используем подход MVVM, но я не буду делать ещё один 152 доклад про введение в MVVM, расскажу про те вещи, которые оказались новыми именно для нас
  • #24 В iOS-приложении для кеширования мы используем библиотеку YapDatabase. (Спросить кто знаком) YapDatabase это по сути Key-Value хранилище поверх SQLite с очень большим набором возможностей. Лично мне кажется что она гораздо проще в использовании и гибче, чем CoreData. В частности, можно выбрать механизм сериализации объектов в базе (по умолчанию используется NSCoding, а мы используем тот же MessagePack). YapDatabase не требует наследования объектов от базового класса или реализации какого-то протокола, и не привязывает объекты к контексту. Чтение и запись производятся с помощью синхронных или асинхронных транзакций. При этом доступны все те же возможности, что и в "настоящей" БД, такие как прозвольные SQL-запросы и индексирование нескольких полей, полнотекстовый поиск, подписка на изменения (как в NSFetchedResultsController), etc.
  • #25 Немного покажу как выглядит работа с YapDatabase. Инициализация соединения, то есть, объекта, который позволяет непосредственно работать с транзакциями занимает буквально две строки.
  • #26 При необходимости, здесь же можно определить механизм сериализации. (в этом примере испульзуется NSKeyedArchiver, а мы у себя используем MessagePack)
  • #27 Транзакции делаются при помощи блоков, при этом сделать асинхронную транзакцию в фоне не сложнее чем синхронную. Лично мне YapDatabase очень нравится и повышает продуктивность и понятность кода, но у меня есть несколько коллег, которые её не очень любят. В принципе, их можно понять, поскольку после длительной работы с CoreData для работы с YapDatabase нужно действительно несколько вывернуть мозг.
  • #28 Кроме того, при асинхронной работе с базой через несколько соединений нужно хорошо понимать как база обрабатывает параллельные запросы на чтение и запись через одно или разные соединения (эта информация есть в вики). А также, помнить что объекты обновляются в БД целиком. Для обновления объекта в БД надо не просто сохранить тот экземпляр, который вы прочитали какое-то время назад и модифицировали, прочитать объект из базы, изменить его так, как вам нужно, и записать обратно в рамках одной транзакции. В противном случае можно случайно записать в БД устаревшие данные. Кроме того, работа с базой очень удобно встраивается в наш реактивный стиль написания кода. Асинхронные шаблоны транзакций (чтение/запись/модификация отдельного объекта) очень просто завернуть, например, в сигналы ReactiveCocoa, и встраивать работу с базой в одну цепочку с обработкой сетевых запросов.
  • #29 Немного расскажу о паре приёмов, которые мы использовали в iOS-приложении, для того чтобы обойти ограничения системы, которые мешали нам сделать дружелюбный и плавный интерфейс. Одной из проблем стал безконечный скролл в чате, то есть, незаметная для пользователя подгрузка истории при прокрутке чата вверх. В 99% случаев пользователь находится именно внизу чата и хочет проскроллить его вверх для того, чтобы прочитать старые сообщения. Мы не дожидаемся пока пользователь доскроллит до самого верха и увидит там "крутилку", а стараемся заранее запрашивать предыдущие страницы истории ещё во время скролла (как из локального кеша, так и с сервера). Проблема заключается в том, что когда мы вставляем такую страницу в начало списка сообщений, contentOffset того участка, который уже был загружен — сдвигается и скролл скачет. Естественно, мы можем посчитать размер вставляемой страницы и изменить contentOffset обратно, но это (мы же помним, что это происходит во время скролла) приводит к резкой остановке анимации скролла, а это некрасиво.
  • #30 Чтобы решить эту проблему мы (и не только мы, насколько я знаю, похожее решение используют ещё несколько мессенджеров) применили очень странный "костыль": мы переворачиваем список вверх ногами (с помощью .transform), а затем переворачиваем каждую ячейку в обратном направлении. Пользователь ничего не замечает, но теперь contentOffset отсчитывается снизу и подгрузка старых сообщений никак на него не влияет.
  • #31 Это решение хорошо работает, но у него есть ряд подводных камней. Во первых, необходимо конвертировать перевёрнутые индексы ячеек в индексы в вашей модели данных (и обратно). В случае, если у вас больше одной секции, вычисления будут очень сложными, так что лучше ограничиться одной. Кроме того, плавающие заголовки работают не очень хорошо (но их можно имитировать). Во вторых, могут возникнуть проблемы с вычислением координат внутри ячеек, наприимер при работе с жестами, но это происходит не очень часто. В третьих — при подгрузке данных вниз проблема возвращается, но поскольку подгрузка при скролле вниз происходит сильно реже, то для нас это не очень большая проблема (хотя хотелось бы решить и её, конечно).
  • #32 Вторая проблема — это анимированные (и вообще асинхронные) обновления списков. Если несколько независимых обновлений происходят почти одновременно (например, подгружается страница истории вверху чата и приходит новое сообщение внизу), то данные, используемые делегатом tableView могут измениться в момент, когда не закончилась анимация предыдущего обновления. Это может привести к тому, что UITableView отрендерит неправильную ячейку или вообще упадёт (особенно, если вы используете предыдущий хак). Можно, конечно использовать метод reloadData, который является синхронным в UITableView, однако это приводит к морганиям, остановке скролла, и прочим раздражающим пользователя вещам
  • #34 Специально для таких случаев мы сделали специальную очередь для последовательной обработки таких обновлений. Все изменения модели и отображение их на UI производятся внутри блоков, которые ставятся в очередь. При этом блок может залочить очередь при старте анимации (или какой-то другой асинхронной операции) и разлочить её при завершении. Таким образом, вся работа с таблицей производится последовательно и данные не меняются, пока не завершится предыдущая анимация.