Привет, Хабр! Меня зовут Иван Мясников, я 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:

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

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

Для примера соберем библиотеку с названием 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;

  • то, что работает на реальном устройстве, может сломаться на симуляторе.

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

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

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


  1. Ingulf
    27.08.2024 23:58

    Где же вы были 5 лет назад, когда я очень хотел нагуглить такую статью))


    1. ws233
      27.08.2024 23:58
      +1

      5 лет назад код на C++ без танцев с бубном собирался просто включением соответствующих файлов на C++ в ObjC-проект с изменением их расширения на .mm. Или даже и переименовывать не надо было, не помню уже -- старый стал. Полагаю, что и сейчас этот способ работает.

      Этот же совет можно использовать и для работы со Swift ниже версии 5.9.

      Ох, уж эти зуммеры, раз и навсегда похоронившие ObjC ^.^


      1. dnszaikin
        27.08.2024 23:58
        +1

        Все так, достаточно переименовать файл в .mm что бы использовать совместный код C++ и ObjC++


  1. kambala
    27.08.2024 23:58

    Ошибку, связанную с нелегальностью такой сборки, XCode выдаст только при попытки опубликовать сборку такого приложения.

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

    Собрать модуль на XCode 14 под минимальный таргет 14 не удалось, поэтому первым шагом потребовалось установить XCode 12

    очень странно... И как раз было бы интересно посмотреть на ошибку и изучить ее причину, а не использовать подобные «хаки» (понятно, что тогда могло не быть времени разбираться, но уж сколько времени прошло с тех пор). Да и почему хкод 12, а не 13? А что насчет современного хкода 15 (и даже беты 16)?

    После копирования SDK поменяем свойство MinimumSDKVersion

    а зачем это делать, если в итоге мы все равно через vtool задаем нужную версию?

    И кстати получить версию сдк и платформы также можно через vtool (вместо otool).

    Так мы создали TensorflowLiteFramework.framework, который можем использовать в проекте с минимальным таргетом iOS 14.0.

    но ради чего, если никто не мешает использовать библиотеки с меньшим деплоймент таргетом?..

    Теперь сконфигурируем сборку с помощью Cmake

    а зачем нам смаке, если в итоге все равно используется хкод генератор? Что мешало просто создать хкод проект?

    cmake ..

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

    1. так не делать и держать папку сборки снаружи папки исходников

    2. явно указывать каталоги исходников и сборки. В данном случае мы бы написали cmake -S .. -B ., хотя более традиционной формой является конфигурация из папки исходников: cmake -S . -B build

    Запускаем в XCode проект MyFramework.xcodeproj и внутри XCode собираем фреймворк под целевую платформу

    до этого мы все собирали в терминале, а теперь внезапно надо открывать иде :) Собрать хкод проект можно через xcodebuild или fastlane.

    TensorflowLiteFramework.podspec

    не очень понятно зачем явно указывать CLANG_CXX_LIBRARY , если она уже сто лет как установлена в libc++ по умолчанию.

    s.dependency 'TensorflowLiteFramework'

    подождите, а разве MyFramework не зависит от TensorflowLiteFramework , выступая оберткой над ней? В таком случае зависимость от TensorflowLiteFramework должна быть указана в подспеке MyFramework . Если же основной фреймворк также напрямую зависит от TensorflowLiteFramework (вызывает что-то оттуда в обход обертки), тогда да, следует указать эту зависимость в основном фреймворке.

    Я понимаю, что оно работает и так, но лучше все делать аккуратно :)

    P.S. Вопросики к колонке Архитектура для симуляторов у картинки под спойлером: какие-то там очень странные значения написаны. Они ж должны практически совпадать со значениями в строке Макбук за исключением того, что для симуляторов iOS < 11 использовалась i386 (интел 32-бит).