Привет, Хабр! Меня зовут Иван Мясников, я 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++ позволяет экономить силы, если коллеги что-нибудь поменяют в моделях. Плюс так его можно использовать на разных платформах.
Использование кода на C++ позволяет экономить силы, если коллеги что-нибудь поменяют в моделях. Плюс так его можно использовать на разных платформах.

Возможность использовать 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:

```bash
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`:

```bash
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:

```bash
brew install bazelisk
```

Дальше клонируем исходники tensorflow:

```bash
git clone https://github.com/tensorflow/tensorflow.git --recursive --branch v2.10.0
```

Конфигурируем репозиторий: в ответах нажимаем N везде, кроме вопроса про iOS:

```bash
cd tensorflow/
./configure
```

Закомментируем строки 1264 и 1265 в файле `tensorflow/lite/BUILD` и собираем динамическую библиотеку `.dylib` для различных архитектур. Нас интересуют arm64, ios_x86_64, ios_sim_arm64:

```bash
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:

```bash
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.

Идентификатор платформы:

```bash
otool -l TensorflowLiteFramework | grep platform
```

Версия sdk:

```bash
otool -l TensorflowLiteFramework | grep sdk
```

Выполняем команду:

```bash
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. Для удобства опишу шаги чуть более абстрактно и в общем ключе.

Сборка TensorFlow Lite
Сборка TensorFlow Lite

Для примера соберем библиотеку с названием MyFramework. Чтобы использовать итоговый фреймворк из Swift, добавим API на языке C. API можно сделать и на C++, предварительно разобравшись со всеми ограничениями XCode. На момент разработки нашего решения Swift 5.9 еще не было, а длительный и мучительный дебаг склонил нас переделать все публичные вызовы нашей либы просто на языке С. Такой подход в любом случае будет работать, и вы не увидите никаких необычных ошибок при билде проекта. Приступим!

Пусть в C++ есть класс Foo, объявленный в заголовочном файле foo.h:

```cpp
class Foo {
public:
  void baz();
};
```

Сделаем для этого класса API на C в отдельном заголовочном файле `foo_c.h`:

```cpp
#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 оставим в одном файле:

```cpp
#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 и наполним его таким содержимым:

```cpp
#import MyFramework/first_header_c.h
#import MyFramework/second_header_c.h
// все импортированные хедеры составляют API на C
```

Теперь сконфигурируем сборку с помощью Cmake. В CMakeLists.txt нашей C++ библиотеки выполним следующие действия:

  • в add_library укажем, что нужно собирать динамическую библиотеку. Кроме путей, до файлов с имплементацией пропишем пути до заголовочных файлов с API на C в том числе и дополнительный из предыдущего пункта:

```cmake
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-фреймворк:

```cmake
set_target_properties(MyFramework PROPERTIES
 FRAMEWORK TRUE
 PUBLIC_HEADER "${C_API_HEADERS}"
)
```

Теперь все готово, можно сконфигурировать библиотеку:

```bash
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 с таким содержимым:

```ruby

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:

```ruby

  s.dependency 'TensorflowLiteFramework'
  s.dependency 'MyFramework'

```

Вместо выводов

У работы кода на C++ под iOS есть множество неочевидных тонкостей, а пул решаемых задач бесконечен. Каждая из них индивидуальна и требует решения своих уникальных подзадач. Если же вам потребовалось использовать что-то подобное, то сначала стоит разобраться со всеми базовыми инструментами: знать bazel/cmake/lipo, понимать работу зависимостей и артефактов в iOS.

Будьте готовы к тому, что не все пройдет гладко:

  • полученный результат может разочаровать по производительности или по влиянию на конечный размер приложения;

  • есть вероятность, что AppStore не примет сборку, даже если она запустилась в Xcode;

  • то, что работает на реальном устройстве, может сломаться на симуляторе. Не забывайте, что архитектур много, и учитывайте таргетную платформу:

Скрытый текст

На этом у меня все, но я готов отвечать на ваши вопросы. Спасибо, что читали!

Комментарии (0)