Привет! Я Егор Ерусланов, Qt-разработчик.

В Android-приложение 2ГИС мы добавили новый режим PiP (Picture-in-Picture, или «картинка в картинке»). С PiP наши пользователи смогут следить за маршрутом в маленьком плавающем окне на основном экране. Например, когда нужно быстро прочитать сообщение или включить подкаст и при этом сохранять фокус на навигации. Режим PiP — это не просто «приятная мелочь», а функциональность, которая подстраивается под новые требования пользователей навигатора. 

В этом посте поделюсь, как решал несколько трудностей, которые встретились при разработке этой функции.

Зачем нужен PiP?

С PiP мы решаем несколько пользовательских ожиданий ↓

  1. Многозадачность: пользователи могут свернуть приложение и одновременно пользоваться другими функциями смартфона. Это подтверждается нашим внутренним исследованием: около 36% пользователей навигатора сворачивают 2ГИС в фоновый режим минимум на три минуты, чтобы переключиться на другие приложения. 

  2. Минимализм: компактное окно предоставляет только самую необходимую информацию — манёвры, скорость и текущие ограничения.

  3. Новаторство в пользовательских привычках: многие популярные приложения, такие как YouTube или Google Maps, уже поддерживают PiP, и пользователи могут ожидать увидеть эту функциональность в других приложениях. Ещё не так много российских компаний реализовали эту фичу, а мы решили поддержать, так как любим радовать пользователей, которые ценят новые технологии.

Старт разработки и первые шаги

Всё как и обычно началось с запроса продактов и дизайнеров. Мне показали пару скриншотов и макет, как это может элегантно выглядеть в приложении. Задача: сделать функциональность, которая будет плавной и красивой. На первом этапе я сделал поверхностный ресёрч и составил первую оценку. На первый взгляд всё выглядело просто: Android предоставляет готовое API для работы с PiP, и нужно было лишь использовать его.

Как же я ошибался 😅 

Про сложности

Уже во время разработки стало понятно, что задача потребу��т больше времени.

1. Адаптация карты

Помимо интерфейса 2ГИС (UI на QML поверх карты) пришлось адаптировать также карту, так как нам не подходил масштаб и зум, которые брались по умолчанию. Мы продумывали несколько вариантов решения: 

  • Там, где необходимо использовать особую стилизацию карты, её масштаб и зумы, мы обычно заводим новый тип карты, таблицу зумов и стили. Так, например, мы делаем в нашей мини-карте и карте в Cluster-дисплее CarPlay. 

  • Используем нашу основную карту, но изменять её масштаб в рантайме.

Первый вариант показался слишком дорогим, а мы хотели максимально сэкономить ресурсы разработки. Поэтому пошли по второму пути: заскейлили карту до 70% от её оригинального размера в момент перехода в режим PiP и сбрасывали коэффициент масштабирования (scale factor) при возвращении в приложение. По итогу мы использовали существующий функционал нашей карты, который позволял всё сделать без дополнительных доработок.

2. Неподдерживаемый PiP в Qt

Так как наше приложение разработано на C++/Qt/QML, первым делом возник вопрос, а поддерживает ли Qt 5.15 вообще такую функциональность Android, как режим Picture-in-Picture (PiP). Если коротко — нет. Классы-обёртки, которые предоставляет Qt для работы с Android Activity, не имеют встроенной поддержки PiP. 

Однако класс QtActivity, используемый в качестве главного Activity Android-приложения на Qt, расширяет стандартный класс Activity, и можно вручную вызвать setPictureInPictureParams, перегрузить метод onPictureInPictureModeChanged и настроить отображение карты, а также интерфейс в QML. 

На первый взгляд проблема решена. 

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

Жизненный цикл Android Activity и его связь с PiP

Чтобы разобраться в сути проблемы, начнём с рассмотрения жизненного цикла Android Activity и места, которое в нём занимает режим PiP. Согласно документации, при переходе в режим Picture-in-Picture система вызывает метод onPause(). Это означает, что в классической реализации Activity в режиме PiP находится в состоянии Paused и далее живёт в соответствии с жизненным циклом Activity.

Жизненный цикл Activity
Жизненный цикл Activity

Состояния Qt-приложения

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

Соотношение состояний Qt-приложения и жизненного цикла Activity
Соотношение состояний Qt-приложения и жизненного цикла Activity

По этой схеме:

  1. Вызов onResume() переводит состояние Qt-приложения в ApplicationActive.

  2. Вызов onPause() переводит состояние Qt-приложения в ApplicationInactive.

  3. Вызов onStop() переводит состояние Qt-приложения в ApplicationHidden или ApplicationSuspended (в зависимости от того, выполняется ли фоновая работа).

Итак, при переходе в режим PiP вызывается метод onPause(), и Qt переходит в состояние ApplicationInactive. Фреймворк обрабатывает вызов onPause() в своём делегате QtActivityDelegate.

При переходе в каждое из состояний Qt также накладывает определённые внутренние ограничения на работу циклов обработки событий. Например, выставляет флаги, из-за которых при вызове метода processEvents() перестают обрабатываться некоторые типы событий. При этом Qt не отбрасывает эти события, а заполняет ими очередь, которая будет обработана позже — когда processEvents() вызовется без соответствующих ограничивающих флагов.

В нашем случае при переходе в состояние ApplicationInactive Qt останавливает обработку некоторых событий, устанавливая ограничение через флаг X11ExcludeTimers. Этот флаг не задокументирован в Qt, но можно предположить, как он влияет на обновление QML. Например, анимации в QML основаны на таймерах, а значит, при выставлении этого флага события таймеров перестают обрабатываться.

Анализ показал, что ограничения, которые Qt накладывает на обработку событий при переходе в состояние ApplicationInactive, нам не подходят, так как именно они приводят к некорректному обновлению UI.

Решение проблемы

Всё оказалось довольно просто, хотя и грубовато. Мы использовали публичный метод QtNative, который позволяет вручную перевести приложение Qt в состояние ApplicationActive после перехода в режим PiP. Теперь, при переходе состояния Android Activity в Paused, мы переключаем внутреннее состояние Qt-приложения в ApplicationActive по условию нахождения в режиме PiP. Это позволяет приложению продолжать обрабатывать события так, как будто находится в обычном активном состоянии.

Схема финального решения
Схема финального решения

После применения такого подхода интерфейс в QML начал корректно обновляться даже в режиме Picture-in-Picture.

Как выглядит наш PiP

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

  • карту с маршрутом,

  • плашку манёвров,

  • маркер ведения,

  • спидометр.

Картинка в картинке во время ведения по маршруту для автомобиля (1) и пешехода (2). Чтобы вернуться в 2ГИС, нужно нажать на иконку с рамкой [ ], закрыть окно режима «Картинка в картинке» — на × (3)
Картинка в картинке во время ведения по маршруту для автомобиля (1) и пешехода (2). Чтобы вернуться в 2ГИС, нужно нажать на иконку с рамкой [ ], закрыть окно режима «Картинка в картинке» — на × (3)

Мы долго экспериментировали с другими элементами, такими как график высот или индикатор пробок, но пришли к выводу, что это только перегружает и так маленькое окно.

Итог

Несмотря на трудности, рад, что реализовали эту фичу — это интересный опыт для меня как разработчика. Получилось глубже разобраться с внутренностями Qt и искать нестандартные решения.

Я был уверен, что выпущенные в 2020 году Qt 5.15, который мы используем, уже поддерживает режим PiP, ведь эта возможность появилась в Android 8 ещё в 2017 году. Однако оказалось, что это не так.

Фича, кстати, уже доступна всем пользователям, поэтому если у вас есть вопросы, пишите в комментариях!

P.S. У нас открыта вакансия для С++/Qt/QML-разработчиков и для ребят, кто знает Qt. Посмотри, вдруг тебе тоже захочется развивать 2ГИС с нами! Или подпишись на телеграм-бота: подходящая вакансия сама постучится к тебе в личку.