Notion — это инструмент для мыслей, поэтому он и должен работать со скоростью мысли. Мы давно слышали отзывы о том, что нашим приложением для Android трудно пользоваться из-за того, что оно долго запускается и медленно работает. Когда вы пытаетесь записать мысль, создать новую задачу или ответить на комментарий, медленный запуск приложения может стать решающим фактором. Поэтому, начиная с 2020 года, наша команда в течение нескольких месяцев изучала способы повышения производительности приложений для таких повседневных задач.
Результат: сегодня приложение Notion для Android запускается более чем в два раза быстрее, чем в начале 2023 года. В этой статье описаны некоторые шаги, которые мы предприняли для повышения производительности, а также наши постоянные усилия по улучшению мобильного опыта для всех наших пользователей Android.
Переход от веб-кода к нативному
Мобильные приложения Notion раньше были простыми обертками, которые открывали веб-приложение в WebView. Затем, в 2020 году, мы решили использовать больше нативного кода в ранних продуктовых экспериментах. Перенос самых видимых продуктовых поверхностей с самым высоким уровнем взаимодействия с веб на нативный код повысил производительность как нативного слоя, так и веб-приложения за счет использования возможностей ОС Android и библиотек его фреймворка.
В 2021 году мы увеличили инвестиции в нативную инфраструктуру для поддержки сложной модели данных Notion, включая создание поддержки запросов, кэширования и обновления Block данных в режиме реального времени. Эти инвестиции уже на ранних этапах позволили сократить время загрузки веб-приложений, а также заложить основу для будущих усилий по кэшированию и оптимизации запросов.
В 2022 году мы представили нативную вкладку «Главная» (Home), которая в 3 раза улучшила загрузку приложений, а в начале 2023 года — нативную вкладку «Поиск», которая улучшила время загрузки более чем на 80%.
Выявление узких мест в производительности
При запуске приложения Notion первым экраном, который обычно видят пользователи, является вкладка Home и ее содержимое (например, Favorites, Private и Teamspaces). Этот пользовательский поток, который называется Initial Home Render, является нашим аналогом для измерения производительности при запуске приложения.
Нашей первой задачей было определить, запуск каких частей Initial Home Render занимает больше времени, чем ожидалось. Соответствующая метрика, initial_home_render
, берется из всех продакшен сессий приложения и дополняется метаданными (например, конфигурацией устройства и состоянием сессии), чтобы помочь в анализе. При проведении анализа мы ориентировались на 95-й процентиль (P95), поскольку именно этот порог отражает опыт подавляющего большинства людей.
Внутри initial_home_render
мы измерили несколько небольших сегментов/подсегментов, где выполняются тяжелые шаги инициализации и со временем создаются и настраиваются новые зависимости. Например, мы измерили функцию Application onCreate
, которая служит точкой входа приложения, чтобы узнать, сколько времени уходит на создание глобальных объектов, таких как компоненты Dagger и сторонние библиотеки. Мы также измерили onCreate
главной активити, где инициализировались объекты и состояние для рендеринга пользовательского интерфейса главной вкладки.
С помощью этих подсегментов мы смогли определить области, которые могут влиять на скорость запуска. Но это был лишь высокоуровневый взгляд. Чтобы добраться до корня проблем с производительностью, нам нужно было получить гораздо более подробные данные.
В поисках более детальных сведений мы вручную профилировали различные устройства низкого и среднего ценового диапазона, используя комбинацию Android Studio CPU Profiling и трассировки Perfetto в тестовых средах. Затем мы просмотрели эти профили выполнения кода в виде графиков с помощью таких инструментов, как Android Studio Profiler и Firefox Profile Viewer. Визуализация показывала, на что тратит время процессор устройства во время запуска.
Во вновь созданных трассерах мы искали широкие единичные участки, которые обычно указывают на медленное выполнение кода, что, в свою очередь, часто означает узкие места в производительности.
Некоторые из этих участков появлялись в рабочих потоках, где мы часто ожидаем длительного выполнения сетевого/дискового ввода-вывода, сериализации JSON и сложных запросов к базе данных. Другие возникали в главном потоке, где происходила основная часть рендеринга пользовательского интерфейса. Когда основной поток занимался чем-то другим, кроме рендеринга пользовательского интерфейса, система часто пропускала кадры, и пользователь сталкивался с заеданиями, заиканиями или задержками при рендеринге/прокрутке.
Мы уже определили основных нарушителей — onCreate
приложения и активити — с помощью наших поддиапазонов. Трассировка помогла пролить свет на то, что именно занимает так много времени при выполнении.
Мы разделили эти узкие места производительности на три категории:
- Ожидание инициализации зависимостей
- Последовательные операции загрузки и блокировки
- Использование основного потока
Нашей целью было отложить выполнение длительных операций и инициализацию зависимостей в фоновые рабочие потоки, чтобы освободить основной поток для обработки пользовательского ввода, анимации и изменений макета. Оптимизация выполнения в этих фоновых рабочих потоках также ускорила бы рендеринг, который зависел от выполняемых операций. Все эти улучшения неизбежно помогли бы сделать пользовательский интерфейс и запуск приложения более отзывчивыми.
Получив эти сведения, мы начали вносить постепенные улучшения.
Применение наших знаний на практике
Кэширование конфигурации экспериментов
Мы начали с рассмотрения использования нашей библиотеки конфигурации экспериментов во время выполнения функции onCreate
приложения. Для использования этой библиотеки, которая контролирует, какие функции и возможности взаимодействия включены, требовалось разрешить сторонние зависимости и загрузить последнюю конфигурацию эксперимента, прежде чем ее можно было использовать. Поскольку эксперименты часто использовались во время запуска (например, вкладка «Главная» во время разработки), мы не могли просто отложить инициализацию.
Чтобы повысить производительность, мы внедрили слой быстрого кэширования с помощью простого key-value хранилища. Мы решили, что не всегда нужно иметь под рукой самую свежую конфигурацию эксперимента, если она относительно постоянна при разных запусках приложения и любые воздействия эксперимента обрабатываются корректно. В рабочем потоке хранилище может быть обновлено последней конфигурацией и затем использовано при следующем запуске.
Изоляция медленного кода с помощью слоя кэширования станет постоянной темой, когда мы начнем делать другие оптимизации производительности.
Буферизация аналитики и логирования
События аналитики и ведение логов очень важны для принятия решений по продукту и отладки проблем пользователей — производительность при запуске приложения является лишь одним из таких примеров. Первоначальная реализация наших служб аналитики и ведения логов требовала инициализации ряда зависимостей для управления событиями и сообщениями журнала. Однако обрабатывать эти события и сообщения во время запуска приложения было не обязательно, поэтому их можно было отложить до завершения запуска.
Мы внедрили облегченный буферный слой для событий и логов, чтобы уменьшить накладные расходы на загрузку библиотек аналитики и протоколирования во время запуска. Как только критические части запуска приложения были завершены, процессоры могли начать выгрузку из буферов и передачу данных в сеть. До этого момента буферизованные данные также сохранялись на диске для повышения надежности в случае нестабильной работы приложения.
Кэширование пользовательской сессии
Медленная загрузка вкладки «Главная» в основном была связана с зависимостью от пользовательской сессии при извлечении содержимого вкладки. Пользовательская сессия предоставляет остальным частям приложения информацию об учетных записях, доступных рабочих пространствах и активном в данный момент рабочем пространстве, поэтому загрузка пользовательской сессии была важным шагом при отображении чего-либо в приложении.
Из трассировки мы узнали, что загрузка пользовательской сессии требует гидратации множества записей из локальной базы данных, что требует синхронной инициализации всей инфраструктуры для управления записями, синхронизации новых и обновленных значений в/из сети и создания подписок для изменений в реальном времени. Для пользователя этот процесс выглядел как мерцающий плейсхолдер до тех пор, пока не загружалась пользовательская сессия, а затем содержимое вкладки Home.
Мы поняли, что текущий пользователь и рабочее пространство относительно стабильны между открытиями приложения, поэтому вместо того, чтобы ждать, пока все зависимости инициализируют полную пользовательскую сессию, содержимое вкладки «Главная» могло начать загружаться раньше, используя кэшированную копию данных пользователя и рабочего пространства.
Одно только это изменение позволило улучшить метрику initial_home_render
на ~30%.
Проверка миграций перед инициализацией SQLite
Мобильные приложения Notion используют SQLite для хранения Block данных, необходимых для загрузки содержимого вкладки «Главная», результатов поиска и рендеринга страниц в веб-приложении.
Время от времени, когда схемы таблиц менялись, запускались миграции для их обновления. Эти миграции схем упакованы в один большой JSON-файл, который загружался и разбирался при каждом запуске. Этот JSON-файл также содержал версию схемы миграции, которая сравнивалась с версией активной базы данных, чтобы решить, нужна ли сама миграция.
С помощью трассировки мы обнаружили, что парсинг JSON-файла миграции был одним из самых медленных и выполнялся в основном потоке.
Чтобы избавиться от необходимости загружать весь JSON-файл, мы предоставили версию для миграции отдельно в виде целого числа, что позволило сравнивать активную и новую версии миграции гораздо быстрее. В редких случаях, когда требовалась миграция, полный JSON-файл мог быть загружен как обычно для завершения миграции.
Перенос сериализации JSON в рабочий поток
Мы используем Message Ports, IPC-канал, предоставляемый WebView, для связи между веб-приложением и нативным слоем. Сообщения, отправляемые и получаемые через Message Ports, представляют собой строки JSON разного размера, которые десериализуются и сериализуются нативным слоем. Трассировка показала, что в некоторых случаях — часто во время запуска приложения — большие JSON-блобы десериализовались в главном потоке при синхронизации общего состояния между слоями. Как и в предыдущих примерах, мы смогли исправить это, внедрив буферы и распараллеливание, чтобы сериализацию/десериализацию можно было перенести исключительно в фоновый поток.
С каждым выпуском запуск приложения немного улучшался. Между версиями мы оценивали влияние каждого изменения и при необходимости вкладывали дополнительные средства в работу. К концу года эти и другие более мелкие изменения привели к ускорению начального рендера P95 на ~45%.
Базовые профили
Большая часть приложения Notion, включая вкладку «Главная», построена с помощью Jetpack Compose. Мы добавили поддержку базовых профилей, как только они стали доступны, поскольку запуск приложения часто происходил медленнее, чем ожидалось, даже в релизных сборках. Обещания решить проблему инициализации Compose и улучшить производительность рендеринга при запуске приложения были очень интересным.
Мы создали базовые профили для Android-приложения Notion, определив путь запуска приложения с помощью UIAutomator в тесте JUnit. Этот тест запускал приложение, ждал, пока отобразится вкладка Home, затем прокручивал и разворачивал различные разделы и страницы в тестовом рабочем пространстве. После каждого успешного запуска тестовый прогонщик выдавал сгенерированный файл baseline-prof.txt
, который мог быть упакован в релизные сборки.
Включенные базовые профили могут быть использованы Android Runtime для опережающей компиляции, что приводит к ускорению выполнения кода и повышению производительности запуска приложения и рендеринга кадров. Когда мы впервые применили эти профили, мы измерили улучшение P95 на ~12% по метрике Initial Home Render.
Убедившись в эффективности базовых профилей, мы ввели в действие процесс их генерации. В процессе сборки каждого релиза сначала запускался процесс генерации Baseline Profile на реальных устройствах, работающих в Firebase Device Lab, а затем они включались в создаваемый APK/AAB релиза. По мере того как мы вносили улучшения в запуск приложения и реструктурировали код, старые определения профилей автоматически заменялись новыми.
Измерение улучшений
Мы тщательно отслеживали улучшения между выпусками приложения. Каждый раз при слиянии пул-реквестов мы запускали автоматические тесты Macrobenchmark, чтобы оценить производительность при запуске, используя тот же путь пользователя, на основе которого мы создали базовые профили. В дополнение к стандартным измерениям, фиксируемым и сообщаемым этими тестами Macrobenchmark, мы использовали маркеры Trace и TraceMetrics для получения пользовательских метрик для различных введенных нами поддиапазонов.
Затем, после каждого запуска теста, мы публиковали метрики для initial_home_render
и его поддиапазонов на нашей платформе наблюдения для просмотра в виде временных рядов и создавали еженедельные отчеты по мере выхода новых релизов, чтобы отслеживать улучшения и выявлять регрессии до того, как они получат широкое распространение.
Путь вперед
Улучшения, которые мы внесли благодаря профилированию, внедрению базовых профилей и различным более мелким оптимизациям, дали значительный результат: запуск приложений теперь происходит в два раза быстрее, чем в начале 2023 года. Пользователи также должны заметить значительные улучшения в производительности прокрутки на вкладках «Главная» и «Поиск».
Эти оптимизации — лишь малая часть наших усилий, направленных на то, чтобы сделать использование Notion приятным. Сегодня Notion на Android должен запускаться быстрее и прокручиваться быстрее, чем когда-либо прежде. А созданная нами основа для мониторинга и бенчмаркинга старта дает нам уверенность в том, что со временем опыт наших пользователей будет значительно улучшаться.