Опыт создания платформы для разработки нативных C++-приложений под Android/iOS, Windows/macOS и Web
Автор: Ким Бондаренко
Формат: Доклад
Более 20 лет мы занимаемся разработкой ПО для настольных и мобильных устройств в области цифрового видео и сетевых технологий. В совокупности эти направления охватывают значительную часть нашей практической деятельности: OTT-сервисы, видео- и аудиозвонки, технологии удаленного доступа.
Однако в рамках этого доклада хочется поговорить не об OTT, а о том, как необходимость создавать типовые сервисы для множества целевых устройств и платформ привела нас к разработке небольшой платформы, значительно упрощающей этот процесс.
Задача

Итак, мы оказались в ситуации, довольно типичной для разработчиков современных сервисов. Особенно это касается компаний, которые предоставляют своего рода франчайзинг — помогают другим командам создавать сервисы на основе собственного продукта.
Будь то телевидение или видео, социальные игры, обучающие курсы, фитнес-приложения, маркетплейсы, доставка, бронирование, программы лояльности, сервисы мероприятий, транспорт и т. д. Каждый новый продукт — собственный или франчайзинговый — требует разработки и выпуска приложения для целого набора мобильных и десктопных платформ: Android и iOS, Web, Windows и macOS, а иногда даже Linux.
Если действовать в лоб, трудозатраты примерно равны произведению количества выпускаемых сервисов на количество поддерживаемых платформ. Это очень большой объем работы, который необходимо оптимизировать.
Производительность и гибкость

Вы скажете: какие проблемы? Можно использовать тонкие приложения на основе WebView или конструкторы, которые принимают данные в собственном формате и на выходе генерируют web-приложение либо нативный код с обертками над стандартными контролами.
Все это хорошо, пока нам хватает производительности и гибкости таких подходов. Но часть обработки данных может выполняться на клиенте: вычисления или транскодинг, относительно сложный рендеринг либо предварительный анализ входных данных.
В этом случае строго универсальное решение уже не подходит. Иначе мы получим подход, в котором функциональность во всех направлениях ограничена возможностями слабейшей платформы. И чемпион на этом пути уже есть — HTML + JavaScript.
Поэтому придется спускаться на более низкий уровень, но при этом хотелось бы не сделать разработку чрезмерно сложной.
Почему C++

Я хочу поделиться нашим опытом использования C++ на этом непростом пути. Почему именно C++, спросите вы?
- Экосистема. C++ — это огромная экосистема во многих сферах. Когда мы начинали, альтернатив вроде Rust просто не существовало. C++ до сих пор очень силен в таких областях, как игровые движки, а множество зрелых и широко используемых библиотек написано на C или C++.
- Опытные разработчики. Для создания сложных приложений по-прежнему нужны сильные инженеры. Сильные инженеры — это, как правило, опытные разработчики, и многие из них — олдскульные C/C++-разработчики.
- Производительность. В критичных к производительности местах C++ остается одним из сильнейших языков программирования. Речь не только и не столько о низкоуровневых оптимизациях, сколько о свободе, которую он дает при работе с многопоточностью и памятью.
А как же безопасность?

Более молодые языки часто ограничивают часть гибкости C++, чтобы выявлять максимум ошибок еще на этапе компиляции и не давать разработчику слишком легко «выстрелить себе в ногу». В C++ значительная часть этой ответственности остается на разработчике.
На практике с этим вполне можно жить: достаточно следовать проверенным шаблонам, использовать зрелые библиотеки и не допускать низкоуровневой работы с указателями в прикладном коде без необходимости. При таком подходе memory overrun и deadlock-и становятся редкими даже в достаточно сложных многопоточных системах.
Эволюция подхода

Начинали мы с набора платформенных оберток и собственного STL-подобного инструментария — еще со времен Symbian и Windows Mobile, когда стандартная экосистема была заметно слабее. Частично использовали Qt, затем сделали собственный скинодвижок с CPU-рендерингом и медиадвижок.
Web долго оставался отдельным миром: разработка под Flash принципиально отличалась от C++. Появление современного JavaScript, WebAssembly, WebGL и новых стандартов C++ наконец позволило замкнуть экосистему и переиспользовать значительно больше общего кода.
Требования
Мы сразу отказались от жесткого запрета на использование нативного кода: иначе значительная часть существующей C/C++-экосистемы оказалась бы недоступна. В прикладном коде низкоуровневый доступ нужен редко, но при развитии самого фреймворка без него не обойтись.
Также мы не хотели жесткой границы между общей и платформозависимой частью. Иногда разумнее напрямую использовать нативный виджет, API хранения данных или сетевой механизм на одной платформе, а на остальных — высокоуровневую реализацию или аналог.
Тот же принцип относится к GPU: разработчику может понадобиться как высокоуровневый UI-движок, так и доступ к 2D/3D-рендерингу, compute-шейдерам или платформенным GPU-возможностям.
Архитектура

Вместо одной жесткой границы между приложением и API среды фреймворк построен слоями. Компоненты могут опираться друг на друга, а приложение — входить в стек на разной глубине в зависимости от того, насколько низкоуровневый контроль ему требуется.
Память

Для автоматического управления временем жизни объектов мы выбрали ref-counting и построили вокруг него Java-подобный стиль работы со smart-объектами. При необходимости счетчик ссылок остается доступен пользовательскому коду, а модель поддерживает интерфейсы и множественное наследование.
Для часто создаваемых объектов появился опциональный Memory Manager с гарантированной сложностью O(1). Для совсем критичных мест используется шаблон Resurrector: объект не уничтожается полностью, а возвращается в пул для повторного использования.
На уровне приложения правило простое: использовать проверенные контейнеры и абстракции фреймворка и не заниматься ручной арифметикой указателей без действительно веской причины.
Графика

Приложению обычно предлагается относительно простой Skin Engine для кнопок, layout-ов, изображений, списков, меню и анимаций. Там, где важна глубокая интеграция с системой — например, для edit box или виртуальной клавиатуры, — можно использовать нативные контролы.
Остальное рендерится платформонезависимым 2D Engine с использованием GPU. Сам 2D-слой опирается на более низкоуровневые 3D- и Compute Engine, которые уже отображаются на Metal, DirectX, Vulkan, WebGPU, OpenGL ES или WebGL.
Если на платформе отсутствует нужная возможность, например compute-шейдеры, фреймворк может использовать более медленный fallback и сообщить об этом приложению, чтобы при необходимости отказаться от эффекта в пользу производительности.
Хранение данных
- Асинхронное хранилище для небольших файлов и кешей
- Синхронное API для небольших настроек
- Virtual FS для больших файлов
- IndexedDB на Web, нативная файловая система на остальных платформах
Работа с файлами в Web принципиально отличается от нативных платформ. IndexedDB асинхронен, а синхронный доступ через OPFS ограничен worker-контекстами. Вместо попытки полностью скрыть эти различия мы построили Storage с учетом таких ограничений.
Небольшие файлы и кеши используют асинхронное API. Для настроек есть синхронная обертка поверх данных, загружаемых в память при старте приложения. Для больших файлов предусмотрен Virtual FS: в Web он хранит файл чанками в IndexedDB, а на нативных системах работает непосредственно с файловой системой.
Сетевое взаимодействие

Для простой загрузки данных приложение использует асинхронный Loader: ему передается URL, а результат возвращается позже. Loader может сочетать локальное кеширование через Storage с HTTP Client, который работает либо через платформенные API вроде fetch, либо через собственную реализацию HTTP.
При необходимости приложение может работать напрямую с бинарными потоками. Stream можно оборачивать в TLS, прокси или туннели и отображать на разные низкоуровневые транспорты: TCP на нативных платформах, WebSocket в Web или reliable-UDP-решения вроде WebRTC DataChannel, QUIC и KCP.
Мультимедиа

Современное видео обрабатывает слишком большие объемы данных, чтобы эффективный проигрыватель или транскодер можно было построить только на CPU. Поэтому критически важен доступ к аппаратным декодерам и GPU-ресурсам.
В Web, где низкоуровневые возможности доступны не всегда, фреймворк может использовать платформенный проигрыватель, например MSE. На нативных системах и в достаточно современных браузерах можно задействовать собственный pipeline с аппаратными декодерами, фильтрами и GPU-рендерингом.
Главное правило: декодированные видеоданные по возможности должны оставаться внутри GPU и не проходить лишний раз через CPU.
Потоки и синхронизация

Для фоновой работы приложению предлагается паттерн SimpleThread. На нативных платформах у него есть прямая реализация, а платформонезависимый Thread Pool переиспользует worker-потоки и старается не создавать больше активных потоков, чем процессор способен эффективно выполнять.
Чтобы сохранить ту же модель программирования в Web, мы добавили небольшой эмулятор потоков. Дальше — о правилах, которые позволяют ему работать.
Подход к асинхронности

Поскольку в Web многие синхронные API недоступны, прикладные API фреймворка строятся асинхронно. Современные лямбды C++ и вариативные шаблоны заметно упрощают такой стиль: callback может захватить локальное состояние и передаваться почти так же естественно, как функция в JavaScript.
JS-подобный стиль C++

Тип callback-функции представляется объектом в куче и передается через smart-указатель с ref-counting. Функция может сохранить callback и вызвать его позже, а захваченные переменные будут жить столько же, сколько сам объект callback-а. В результате стиль оказывается привычным для разработчика из JavaScript, хотя код остается обычным C++.
Паттерн SimpleThread

Вместо worker-потоков, выполняющих одну длинную задачу, работа разбивается на небольшие шаги. После каждого шага обработчик возвращает управление и сообщает, нужно ли продолжить сразу, поспать некоторое время, ждать сигнала или завершиться. Спящий поток при необходимости можно разбудить досрочно из другого потока.
Пример SimpleThread

Обработчик вызывается из worker-потока и выполняет только небольшой квант работы. Возвращаемое значение сообщает scheduler-у, что делать дальше. Сам объект потока подчиняется той же ref-counting-модели, что и остальные объекты фреймворка, поэтому автоматически удаляется после освобождения последней ссылки.
SimpleThread в Web

Чтобы упростить перенос приложения в Web, базовая реализация использует один scheduler, который последовательно вызывает обработчики SimpleThread. Поскольку одновременного доступа к общей памяти нет, критические секции превращаются в заглушки, а ref-counter-ам не нужны атомарные операции. Побочный выигрыш в скорости частично компенсирует отсутствие настоящего мультитрединга.
Настоящий мультитрединг

Полностью отказываться от настоящего параллелизма тоже не хочется. StrongThread работает как обычный нативный поток на desktop- и mobile-платформах, а в браузере инкапсулирует настоящий Web Worker. Вместо общей изменяемой памяти фреймворк предоставляет асинхронный обмен сообщениями, поэтому критичные участки можно постепенно переводить на реальное параллельное выполнение.
Почему не SharedArrayBuffer?

SharedArrayBuffer позволяет организовать общую память между worker-ами, но требует дополнительных браузерных настроек безопасности, включая cross-origin isolation. Кроме того, приходится возвращать полноценную синхронизацию и атомарные операции, теряя часть преимуществ упрощенной Web-модели и усложняя работу JIT-оптимизатора.
Deadlock-и и утечки памяти

Достаточно эффективным правилом оказалось избегать циклических графов ссылок. Взаимодействие «против течения» выполняется через асинхронную очередь в стиле postMessage, а обратные связи при необходимости оформляются через идентификаторы и map-соответствия. Такой подход убирает типичную структуру циклического ожидания, лежащую в основе deadlock-ов, и одновременно предотвращает утечки ref-counting, вызванные циклическими ссылками.
Выводы

В итоге нам удалось сохранить одну реализацию высокоуровневой логики для нативных платформ и Web, не отказываясь при этом от низкоуровневых C/C++-библиотек и платформенных возможностей там, где они действительно нужны.
Тот же подход охватывает UI, воспроизведение мультимедиа, Networking и Storage, не ограничивая все платформы возможностями самой слабой из них. При этом Web можно поддерживать постепенно: начать с эмулируемой модели и переводить на более мощные механизмы только критичные по производительности участки.
Цель доклада — не предложить единственно правильную архитектуру, а поделиться практическими компромиссами, набитыми шишками и паттернами, к которым мы пришли при разработке высокопроизводительных приложений одновременно для нативных устройств и Web.
