Опыт создания платформы для разработки нативных C++-приложений под Android/iOS, Windows/macOS и Web

Автор: Ким Бондаренко

Формат: Доклад

Более 20 лет мы занимаемся разработкой ПО для настольных и мобильных устройств в области цифрового видео и сетевых технологий. В совокупности эти направления охватывают значительную часть нашей практической деятельности: OTT-сервисы, видео- и аудиозвонки, технологии удаленного доступа.

Однако в рамках этого доклада хочется поговорить не об OTT, а о том, как необходимость создавать типовые сервисы для множества целевых устройств и платформ привела нас к разработке небольшой платформы, значительно упрощающей этот процесс.


Задача

- Создание типовых приложений
- Multiscreen-поддержка для широкой аудитории
- Высокая производительность

- Высокие затраты на разработку и поддержку
- Отсутствие единого look & feel сервиса на разных платформах

Problem

Итак, мы оказались в ситуации, довольно типичной для разработчиков современных сервисов. Особенно это касается компаний, которые предоставляют своего рода франчайзинг — помогают другим командам создавать сервисы на основе собственного продукта.

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

Если действовать в лоб, трудозатраты примерно равны произведению количества выпускаемых сервисов на количество поддерживаемых платформ. Это очень большой объем работы, который необходимо оптимизировать.


Производительность и гибкость

Performance and flexibility

- WebView
- Конструкторы приложений

Нельзя отказываться от функциональности, доступной на одной платформе, только потому, что она отсутствует на другой.

Вы скажете: какие проблемы? Можно использовать тонкие приложения на основе WebView или конструкторы, которые принимают данные в собственном формате и на выходе генерируют web-приложение либо нативный код с обертками над стандартными контролами.

Все это хорошо, пока нам хватает производительности и гибкости таких подходов. Но часть обработки данных может выполняться на клиенте: вычисления или транскодинг, относительно сложный рендеринг либо предварительный анализ входных данных.

В этом случае строго универсальное решение уже не подходит. Иначе мы получим подход, в котором функциональность во всех направлениях ограничена возможностями слабейшей платформы. И чемпион на этом пути уже есть — HTML + JavaScript.

Поэтому придется спускаться на более низкий уровень, но при этом хотелось бы не сделать разработку чрезмерно сложной.


Почему C++

- Экосистема

- Олдскульные разработчики

- Производительность

Why C++

Я хочу поделиться нашим опытом использования C++ на этом непростом пути. Почему именно C++, спросите вы?

- Экосистема. C++ — это огромная экосистема во многих сферах. Когда мы начинали, альтернатив вроде Rust просто не существовало. C++ до сих пор очень силен в таких областях, как игровые движки, а множество зрелых и широко используемых библиотек написано на C или C++.

- Опытные разработчики. Для создания сложных приложений по-прежнему нужны сильные инженеры. Сильные инженеры — это, как правило, опытные разработчики, и многие из них — олдскульные C/C++-разработчики.

- Производительность. В критичных к производительности местах C++ остается одним из сильнейших языков программирования. Речь не только и не столько о низкоуровневых оптимизациях, сколько о свободе, которую он дает при работе с многопоточностью и памятью.


А как же безопасность?

Why C++

- Следовать проверенным шаблонам

- Использовать отлаженные библиотеки

- Не опускаться на низкий уровень без острой необходимости

Более молодые языки часто ограничивают часть гибкости C++, чтобы выявлять максимум ошибок еще на этапе компиляции и не давать разработчику слишком легко «выстрелить себе в ногу». В C++ значительная часть этой ответственности остается на разработчике.

На практике с этим вполне можно жить: достаточно следовать проверенным шаблонам, использовать зрелые библиотеки и не допускать низкоуровневой работы с указателями в прикладном коде без необходимости. При таком подходе memory overrun и deadlock-и становятся редкими даже в достаточно сложных многопоточных системах.


Эволюция подхода

- От велосипедов до мини-платформы
- Свой STL-подобный набор утилит
- Свой скинодвижок
- Свой медиадвижок

- Web долго жил отдельно
- JavaScript + WebAssembly, C++14/17 и WebGL всё изменили

How the Approach Evolved

Начинали мы с набора платформенных оберток и собственного STL-подобного инструментария — еще со времен Symbian и Windows Mobile, когда стандартная экосистема была заметно слабее. Частично использовали Qt, затем сделали собственный скинодвижок с CPU-рендерингом и медиадвижок.

Web долго оставался отдельным миром: разработка под Flash принципиально отличалась от C++. Появление современного JavaScript, WebAssembly, WebGL и новых стандартов C++ наконец позволило замкнуть экосистему и переиспользовать значительно больше общего кода.


Требования

- Возможность использовать нативный код
- Гибкая граница для UI, Storage, Networking, Threading и Multimedia
- Гибкая граница между общей и платформозависимой частью
- Многоуровневый доступ к API платформы и ресурсам GPU

Мы сразу отказались от жесткого запрета на использование нативного кода: иначе значительная часть существующей C/C++-экосистемы оказалась бы недоступна. В прикладном коде низкоуровневый доступ нужен редко, но при развитии самого фреймворка без него не обойтись.

Также мы не хотели жесткой границы между общей и платформозависимой частью. Иногда разумнее напрямую использовать нативный виджет, API хранения данных или сетевой механизм на одной платформе, а на остальных — высокоуровневую реализацию или аналог.

Тот же принцип относится к GPU: разработчику может понадобиться как высокоуровневый UI-движок, так и доступ к 2D/3D-рендерингу, compute-шейдерам или платформенным GPU-возможностям.


Архитектура

Architecture

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


Память

Memory

- Смешанное владение объектами и ref-counting

- Быстрый Memory Manager

- Шаблон Resurrector

- Фреймворк безопасной работы с данными

Для автоматического управления временем жизни объектов мы выбрали ref-counting и построили вокруг него Java-подобный стиль работы со smart-объектами. При необходимости счетчик ссылок остается доступен пользовательскому коду, а модель поддерживает интерфейсы и множественное наследование.

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

На уровне приложения правило простое: использовать проверенные контейнеры и абстракции фреймворка и не заниматься ручной арифметикой указателей без действительно веской причины.


Графика

Graphics

Приложению обычно предлагается относительно простой Skin Engine для кнопок, layout-ов, изображений, списков, меню и анимаций. Там, где важна глубокая интеграция с системой — например, для edit box или виртуальной клавиатуры, — можно использовать нативные контролы.

Остальное рендерится платформонезависимым 2D Engine с использованием GPU. Сам 2D-слой опирается на более низкоуровневые 3D- и Compute Engine, которые уже отображаются на Metal, DirectX, Vulkan, WebGPU, OpenGL ES или WebGL.

Если на платформе отсутствует нужная возможность, например compute-шейдеры, фреймворк может использовать более медленный fallback и сообщить об этом приложению, чтобы при необходимости отказаться от эффекта в пользу производительности.


Хранение данных

Storage

- Асинхронное хранилище для небольших файлов и кешей
- Синхронное API для небольших настроек
- Virtual FS для больших файлов
- IndexedDB на Web, нативная файловая система на остальных платформах

Работа с файлами в Web принципиально отличается от нативных платформ. IndexedDB асинхронен, а синхронный доступ через OPFS ограничен worker-контекстами. Вместо попытки полностью скрыть эти различия мы построили Storage с учетом таких ограничений.

Небольшие файлы и кеши используют асинхронное API. Для настроек есть синхронная обертка поверх данных, загружаемых в память при старте приложения. Для больших файлов предусмотрен Virtual FS: в Web он хранит файл чанками в IndexedDB, а на нативных системах работает непосредственно с файловой системой.


Сетевое взаимодействие

Networking

Для простой загрузки данных приложение использует асинхронный Loader: ему передается URL, а результат возвращается позже. Loader может сочетать локальное кеширование через Storage с HTTP Client, который работает либо через платформенные API вроде fetch, либо через собственную реализацию HTTP.

При необходимости приложение может работать напрямую с бинарными потоками. Stream можно оборачивать в TLS, прокси или туннели и отображать на разные низкоуровневые транспорты: TCP на нативных платформах, WebSocket в Web или reliable-UDP-решения вроде WebRTC DataChannel, QUIC и KCP.


Мультимедиа

Multimedia

Современное видео обрабатывает слишком большие объемы данных, чтобы эффективный проигрыватель или транскодер можно было построить только на CPU. Поэтому критически важен доступ к аппаратным декодерам и GPU-ресурсам.

В Web, где низкоуровневые возможности доступны не всегда, фреймворк может использовать платформенный проигрыватель, например MSE. На нативных системах и в достаточно современных браузерах можно задействовать собственный pipeline с аппаратными декодерами, фильтрами и GPU-рендерингом.

Главное правило: декодированные видеоданные по возможности должны оставаться внутри GPU и не проходить лишний раз через CPU.


Потоки и синхронизация

Threading & Synchronization

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

Чтобы сохранить ту же модель программирования в Web, мы добавили небольшой эмулятор потоков. Дальше — о правилах, которые позволяют ему работать.


Подход к асинхронности

Our Approach to Asynchrony

- Запрещаем синхронные вызовы

- Используем JS-подобный стиль

- Передаем лямбда-функции как параметры

- Оборачиваем callbacks в smart-объекты

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


JS-подобный стиль C++

- Лямбда хранится как динамический объект вместе с захваченными переменными

- Вызывающая функция остается легкой

- Время жизни callback-а управляется ref-counting

JavaScript-like C++ Style

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


Паттерн SimpleThread

SimpleThread Pattern

- Сделать небольшой квант работы и вернуть управление

- Не использовать синхронные ожидания

- Разрешить досрочное пробуждение из другого потока

- Использовать критические секции только для атомарного доступа к общим ресурсам

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


Пример SimpleThread

- Создаем Thread и задаем лямбда-функцию-обработчик

- Получаем Waker и будим поток при необходимости

- Возвращаем WORK, WAIT, FINISH или время сна

- Освобождение последней smart-ссылки удаляет поток

SimpleThread Example

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


SimpleThread в Web

SimpleThread on Web

- Эмулируем потоки вместо создания настоящих

- Критические секции заменяем заглушками

- Атомарный ref-counting заменяем быстрыми неатомарными операциями

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


Настоящий мультитрединг

- Запрещаем одновременный доступ к общей памяти между worker-ами

- StrongThread инкапсулирует настоящий Web Worker

- Потоки обмениваются сообщениями асинхронно

- Критичные участки переводятся на StrongThread постепенно

Real Multithreading

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


Почему не SharedArrayBuffer?

Why Not SharedArrayBuffer?

- Дополнительные требования по безопасности и развертыванию

- Не везде одинаково удобно использовать

- Возвращает стоимость атомиков и синхронизации

- Усложняет оптимизацию JIT

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


Deadlock-и и утечки памяти

- Использовать ациклические графы ссылок
- Вызовы «против течения» отправлять через асинхронные очереди
- Обратные ссылки по возможности оформлять через ID или map

Один паттерн помогает и против deadlock-ов, и против циклических утечек

Deadlocks & Memory Leaks

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


Выводы

Conclusions

- Общая высокоуровневая логика для нативных приложений и Web

- Дружелюбная модель разработки на C++

- Общий UI, Multimedia, Networking, Storage и Memory

- Платформенные возможности остаются доступными

- Постепенная адаптация к Web вместо полной переделки приложения

В итоге нам удалось сохранить одну реализацию высокоуровневой логики для нативных платформ и Web, не отказываясь при этом от низкоуровневых C/C++-библиотек и платформенных возможностей там, где они действительно нужны.

Тот же подход охватывает UI, воспроизведение мультимедиа, Networking и Storage, не ограничивая все платформы возможностями самой слабой из них. При этом Web можно поддерживать постепенно: начать с эмулируемой модели и переводить на более мощные механизмы только критичные по производительности участки.

Цель доклада — не предложить единственно правильную архитектуру, а поделиться практическими компромиссами, набитыми шишками и паттернами, к которым мы пришли при разработке высокопроизводительных приложений одновременно для нативных устройств и Web.