Привет, Хабр! Меня зовут Иван Мясников, я CTO проекта «Виртуальный ассистент» в МТС Диджитал. Встраивание кода С++ в приложения для iOS — достаточно трудная задача. Еще сложнее собрать SDK для дальнейшей поставки в сторонние приложения, используя логику на С++ совместно со Swift. В этой статье я расскажу, как мы создавали такой SDK так, чтобы он встраивался в любое приложение без танцев с целевой архитектурой процессора.
Встраивание C++ в Swift позволяет использовать один код на разных платформах и ускорить некоторые задачи, где Swift не хватает быстродействия. У нас есть библиотека на C++ для работы с ML на Tensorflow Lite. И эту библиотеку мы хотели встроить на Android, iOS, Linux под различные платформы и архитектуры процессора без переписывания логики оттуда на Kotlin, Swift или что-нибудь еще. В этой статье я расскажу, как мы заставили код на C++ работать в iOS и какие тут есть тонкости и ограничения. Я ориентировался на читателей, у которых может не быть экспертизы в iOS или в C++, и старался не погружаться в глубокие дебри. Этот материал познакомит с решениями, к которым мы пришли экспериментально, подбирая подходящие варианты под нашу задачу.
Немножко про интероп
У нас код для iOS сначала разрабатывала отдельная команда, используя C++, а потом его же переписывали на Swift. Мы понимали все минусы такого подхода, поэтому при очередном обновлении кода решили сделать модуль кроссплатформенным и попытались собрать его совместимым с iOS.
Возможность использовать C++ из Swift и наоборот появилась с версии Swift 5.9. Тем не менее и в ней, и в более ранних версиях основной способ подключения кода на C++ — создание отдельного модуля, где будет находиться вся наша логика на C++. Это может быть статическая библиотека, идущая отдельным таргетом, пакет SPM или подключенный напрямую к проекту артефакт (.framework, .dylib и так далее). Если наша цель — собрать сборку для AppStore, подойдет только родной для XCode формат артефактов, который мы будем называть здесь и ниже .framework. Отдельно расскажу об одной из ловушек, связанной с .framework. XCode не просто позволит подключить, например, .dylib к проекту, но даже успешно запустит проект с таким артефактом как на симуляторе, так и на реальном устройстве. Ошибку, связанную с нелегальностью такой сборки, XCode выдаст только при попытки опубликовать сборку такого приложения. Для нас важно было сделать именно iOS-фреймворк/SDK (не приложение), который можно было бы встроить как библиотеку во всевозможные приложения МТС.
Уточню, что написанное ниже работает и для .framework, и для .xcFramework.
.xcFramework — относительно новый инструмент, по сути это бандл из фреймворков. Он упрощает работу с использованием разных архитектур процессора.
В этой статье я рассмотрю сборку модуля C++ в виде .framework (.xcframework) артефакта. Он представляет собой папку с .plist, хедерами, файлом модуля маппинга, ресурсами в виде ассетов, документацией, самим исполняемым файлом и много чем еще. Генерация такого артефакта возможна только с применением XCode. Его легко подключить напрямую, обернуть в пакет SPM или модуль cocoapod. Нас интересует как раз последнее.
Если следовать стандарту, надо учесть, что Swift (здесь подразумевается использование публичного API из C++ в Swift) поддерживает не весь синтаксис C++. Если следовать стандартам ниже C++20, то будет работать практически все. Ограничения по использованию C++ отличаются в зависимости от версий Xcode, Swift и минимальной iOS. Если у вас нет большого опыта с работой на C++, стоит ограничиться стандартом C+14 или даже свести публичное api своего модуля до синтаксиса обыкновенного С. Подробно ознакомиться с гайдлайнами можно тут.
Также нам потребуется хедер и файл модульного маппирования module.modulemap. Module map связывает логическую структуру модулей с их хедерами. Такой файл описывается своим синтаксисом и должен лежать рядом с самими хедерами:
module forestLib {
header "forest.h"
header "tree.h"
export *
}
В большинстве случаев Xcode сам делает хедеры или module.modulemap. Если все-таки нужно сделать что-то вручную, module.modulemap — отдельный мощный инструмент с богатыми возможностями.
В итоге, чтобы добавить код на C++ в Swift, нам нужно:
написать код на C++ с учетом ограничений XCode;
собрать .framework или .xcframework, подходящий для нашей минимальной версии iOS;
подготовить module.modulemap и хедеры;
обернуть все в виде pod cocoapods.
⠀
В работе будем использовать такие инструменты:
XCode, без которого под iOS не собрать даже табуретку;
Bazel — билдер программного обеспечения;
Bazelisk — удобная обертка над bazel;
CMake — кроссплатформенная утилита, которая генерирует файлы сборки из предварительно написанного файла сценария;
Lipo — этот инструмент собирает артефакты для разных архитектур в один универсальный — или наоборот.
Можно справедливо заметить, что в документации и туториалах по теме смешивания C++ c Swift описаны только базовые ситуации. Реальные задачи гораздо сложнее. Например, в «Виртуальном ассистенте» мы собирали «матрешку»:
Модуль (cocoapod) использует в качестве зависимости модуль C++, а тот внутри себя — библиотеку TensorflowLite. То есть у нас не только код на C++ зависит от сторонней библиотеки, но еще и вся конструкция работает как подключаемое SDK.
Сборка зависимости для C++ модуля на примере Tensorflow Lite
Наш код на C++ использует внутри себя библиотеку Tensorflow Lite. Мы хотели собирать либу Tensorflow Lite и для Android, и для iOS из единой кодовой базы так, чтобы его поддерживала отдельная команда. Дополнительно нужно обеспечить совместимость с iOS от версии 14.0 и выше.
Собрать модуль на XCode 14 под минимальный таргет 14 не удалось, поэтому первым шагом потребовалось установить XCode 12. У меня на маке уже стояла одна из следующих версий, поэтому пришлось копировать версии SDK для iOS и iOS simulator:
sudo cp -a ${XCODE12_DIR}/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk ${XCODE_BASE_DIR}/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS14.0.sdk
sudo cp -a ${XCODE12_DIR}/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk ${XCODE_BASE_DIR}/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator14.0.sdk
${XCODE12_DIR}
— это расположение Xcode 12, а ${XCODE_BASE_DIR}
— текущей версии Xcode.
После копирования SDK поменяем свойство MinimumSDKVersion
:
sudo /usr/libexec/PlistBuddy -c "Set :MinimumSDKVersion 14.0" ${XCODE_BASE_DIR}/Contents/Developer/Platforms/iPhoneOS.platform/Info.plist
sudo /usr/libexec/PlistBuddy -c "Set :MinimumSDKVersion 14.0" ${XCODE_BASE_DIR}/Contents/Developer/Platforms/iPhoneSimulator.platform/Info.plist
Теперь можно собрать из исходников tensorflow модуль, который мы подключим к себе. Для этого предварительно установим bazelisk:
brew install bazelisk
Дальше клонируем исходники tensorflow:
git clone https://github.com/tensorflow/tensorflow.git --recursive --branch v2.10.0
Конфигурируем репозиторий: в ответах нажимаем N везде, кроме вопроса про iOS:
cd tensorflow/
./configure
Закомментируем строки 1264 и 1265 в файле tensorflow/lite/BUILD
и собираем динамическую библиотеку .dylib
для различных архитектур. Нас интересуют arm64, ios_x86_64, ios_sim_arm64:
bazel build --define tflite_with_xnnpack=false --config=ios_arm64 -c opt --cxxopt=--std=c++17 //tensorflow/lite:libtensorflowlite.dylib
Повторим этот шаг для всех архитектур, заменив на --config=ios_x86_64 для архитектуры x86_64 и --config=ios_sim_arm64 для симулятора в arm64. Сборку можно найти по пути bazel-bin/tensorflow/lite/libtensorflowlite.dylib. Как я и говорил выше, .dylib в iOS мы использовать не сможем, поэтому будет нужен .framework:
cd bazel-bin/tensorflow/lite/
mkdir TensorflowLiteFramework.framework
mv libtensorflowlite.dylib TensorflowLiteFramework.framework/
cd TensorflowLiteFramework.framework/
lipo -create libtensorflowlite.dylib -output TensorflowLiteFramework
install_name_tool -id @rpath/TensorflowLiteFramework.framework/TensorflowLiteFramework TensorflowLiteFramework
Меняем минимальный таргет на нужный. Для начала смотрим идентификатор платформы и версию sdk.
Идентификатор платформы:
otool -l TensorflowLiteFramework | grep platform
Версия sdk:
otool -l TensorflowLiteFramework | grep sdk
Выполняем команду:
vtool -set-build-version <platform_id> 14.0 <sdk_version> -tool 3 857.1 -output tmp TensorflowLiteFramework
mv tmp TensorflowLiteFramework
Здесь <platform_id>
и <sdk_version>
— идентификатор платформы и версия sdk, полученные через otool.
Так мы создали TensorflowLiteFramework.framework, который можем использовать в проекте с минимальным таргетом iOS 14.0.
Сборка C++ модуля
Итак, мы получили код на C++ с Tensorflow Lite в качестве зависимости и хотим использовать его из проекта на Swift. Для удобства опишу шаги чуть более абстрактно и в общем ключе.
Для примера соберем библиотеку с названием MyFramework. Чтобы использовать итоговый фреймворк из Swift, добавим API на языке C. API можно сделать и на C++, предварительно разобравшись со всеми ограничениями XCode. На момент разработки нашего решения Swift 5.9 еще не было, а длительный и мучительный дебаг склонил нас переделать все публичные вызовы нашей либы просто на языке С. Такой подход в любом случае будет работать, и вы не увидите никаких необычных ошибок при билде проекта. Приступим!
Пусть в C++ есть класс Foo, объявленный в заголовочном файле foo.h
:
class Foo {
public:
void baz();
};
Сделаем для этого класса API на C в отдельном заголовочном файле foo_c.h
:
#ifdef __cplusplus
extern "C" {
#endif
extern void* Foo_C_new();
extern void Foo_C_delete(void* self);
extern void Foo_C_baz(void* self);
#ifdef __cplusplus
}
#endif
Определение API на C++ и C оставим в одном файле:
#include "foo.h"
#include "foo_c.h"
// Cpp API impl
void Foo::baz() { /* YOUR CODE */ }
// C API impl
extern void* Foo_C_new()
{
return new Foo();
}
extern void Foo_C_delete(void* self)
{
Foo* foo = (Foo*) self;
delete foo;
}
extern void Foo_C_baz(void* self)
{
Foo* foo = (Foo*) self;
foo->baz();
}
Теперь из Swift можно вызывать функции Foo_C_new()
, Foo_C_delete()
и Foo_C_baz()
Дальше нам потребуется заголовочный файл (зонтичный хедер) нашей библиотеки. Внутри него нужно прописать импорты всех необходимых заголовочных файлов, которые мы хотим пробросить. После этого код станет виден в Swift. Для примера назовем заголовочный файл MyFramework.h и наполним его таким содержимым:
#import MyFramework/first_header_c.h
#import MyFramework/second_header_c.h
// все импортированные хедеры составляют API на C
Теперь сконфигурируем сборку с помощью Cmake. В CMakeLists.txt нашей C++ библиотеки выполним следующие действия:
в add_library укажем, что нужно собирать динамическую библиотеку. Кроме путей, до файлов с имплементацией пропишем пути до заголовочных файлов с API на C в том числе и дополнительный из предыдущего пункта:
set(C_API_HEADERS
${INCLUDE_DIR}/MyFramework.h
${INCLUDE_DIR}/first_header_c.h
${INCLUDE_DIR}/second_header_c.h
)
add_library(MyFramework SHARED ${CPP_SRC} ${C_API_HEADERS})
В этом примере ${CPP_SRC}
— список путей до файлов имплементации C++;
линкуем с фреймворком Tensorflow Lite. Для этого указываем в
target_link_libraries/link_libraries
путь до фреймворка;в свойствах библиотеки укажем, что мы хотим собрать именно iOS-фреймворк:
set_target_properties(MyFramework PROPERTIES
FRAMEWORK TRUE
PUBLIC_HEADER "${C_API_HEADERS}"
)
Теперь все готово, можно сконфигурировать библиотеку:
cmake .. -GXcode -DCMAKE_SYSTEM_NAME=iOS \
-DCMAKE_OSX_DEPLOYMENT_TARGET=14.0 \
-DCMAKE_OSX_SYSROOT=${os_name} \
-DCMAKE_OSX_ARCHITECTURES=${arch_name}
В зависимости от типа устройства определим комбинации переменных:
для реального устройства с iOS на архитектуре arm64:
os_name = iphoneos
arch_name = arm64
для симулятора iOS на архитектуре arm64:
os_name = iphonesimulator
arch_name = arm64
для симулятора iOS на архитектуре x86_64:
os_name = iphonesimulator
arch_name = x86_64
Остается только собрать в виде .framework. Запускаем в XCode проект MyFramework.xcodeproj и внутри XCode собираем фреймворк под целевую платформу:
для реального устройства iOS с архитектурой arm64 выбираем Any iOS Device (arm64);
для симулятора iOS с архитектурой arm64 выбираем Any iOS Simulator Device (arm64);
для симулятора iOS с архитектурой x86_64 выбираем Any iOS Simulator Device (x86_64).
Финальный штрих — добавление module.modulemap. Внутри собранного фреймворка создаем папку Modules и кладем туда файл module.modulemap с таким содержимым:
framework module MyFramework {
umbrella header "MyFramework.h"
export *
module * { export * }
}
Подключение
Теперь осталось добавить к нашему Swift проекту оба полученных артефакта с помощью cocapods. Для этого в одну папку с TensorflowLiteFramework.podspec положим TensorflowLiteFramework.xcframework с таким содержимым:
Pod::Spec.new do |s|
s.name = 'TensorflowLiteFramework'
s.version = '0.1.0'
s.summary = 'TensorflowLite build for ios.'
s.homepage = 'https://mts.ru'
s.license = { :type => 'MIT', :file => 'LICENSE' }
s.ios.deployment_target = '14.0'
s.swift_version = '5.0'
s.vendored_frameworks = 'TensorflowLiteFramework.xcframework'
s.xcconfig = {
'CLANG_CXX_LANGUAGE_STANDARD' => 'c++14',
'CLANG_CXX_LIBRARY' => 'libc++'
}
s.libraries = 'c++'
end
Здесь мы с помощью нужных флагов CLANG_CXX_LANGUAGE_STANDARD и CLANG_CXX_LIBRARY показываем, что хотим использовать стандарт 14, и нам нужна поддержка стандартных библиотек C++. Дополнительно мы указываем зависимость libraries = 'c++'. Аналогично делаем для нашего фреймворка.
Теперь у нас есть два пода, которые мы подключаем к нашему проекту, используя podfile. В этом кейсе итоговым продуктом был фреймворк, поэтому мы просто дописали в podscpec:
s.dependency 'TensorflowLiteFramework'
s.dependency 'MyFramework'
Вместо выводов
У работы кода на C++ под iOS есть множество неочевидных тонкостей, а пул решаемых задач бесконечен. Каждая из них индивидуальна и требует решения своих уникальных подзадач. Если же вам потребовалось использовать что-то подобное, то сначала стоит разобраться со всеми базовыми инструментами: знать bazel/cmake/lipo, понимать работу зависимостей и артефактов в iOS.
Будьте готовы к тому, что не все пройдет гладко:
полученный результат может разочаровать по производительности или по влиянию на конечный размер приложения;
есть вероятность, что AppStore не примет сборку, даже если она запустилась в Xcode;
то, что работает на реальном устройстве, может сломаться на симуляторе.
Скрытый текст
На этом у меня все, но я готов отвечать на ваши вопросы. Спасибо, что читали!