16 января 2018

Как мы делали iOS-фреймворк и оформляли его как приватный CocoaPod

Для одного из наших проектов нам нужно было создать свой фреймворк для iOS и опубликовать его через CocoaPods. Реализуя эту задачу, мы столкнулись с рядом трудностей; изучение материалов в поиске ответов на все наши вопросы помогло нам хорошо разобраться в этой области, и мы решили не жадничать, а структурировать все знания и поделиться советами и хитростями о том, как избежать проблем и справиться со сложностями при создании библиотек и/или фреймворков. Слово iOS-разработчику Noveo Виктории:

Вы планируете поделиться частью своей программы с другими разработчиками? Или хотите распространять свой код так же, как это делают популярные библиотеки? А может, вы уже пытались сделать что-то подобное, но потерпели неудачу? Эта статья определенно поможет вам в решении вышеперечисленных задач. Опираясь на наш опыт, вам удастся создать вполне качественный продукт.

iOS framework by Noveo

Введение, или Зачем?

Исходные данные: есть проект, написанный на Objective-C и состоящий из нескольких приложений с общей кодовой базой, но кастомизированных для различных локальных условий. Будем называть проект AwesomeProject, а приложения, входящие в его состав, — AwesomeApp.

Задача: у заказчика возникла потребность интегрировать функциональность этих приложений целиком в другие сторонние проекты, и.при этом добавить возможность конфигурировать параметры, например, серверные URL-ы, включать/выключать отдельные функции приложений, кастомизировать UI.

Команда решила оформить несколько имеющихся приложений в виде скомпилированного фреймворка, который будет предоставлять API на языках Objective-С и Swift,  и, по согласованию с заказчиком, распространять фреймворк через CocoaPods.

Задачу заказчика можно разделить на следующие шаги:

  • Выполнить рефакторинг приложения. Выделить общую кодовую базу приложений AwesomeApp – ядро AwesomeCore – в отдельный подключаемый модуль. Написать API для доступа к ядру и управления различной функциональностью. Сделать API удобным для доступа из других приложений. При этом существующие приложения в составе AwesomeApp также перевести на использование API-модуля.
  • У AwesomeApp и, соответственно, предполагаемого ядра AwesomeCore были зависимости в виде pod-ов. Нужно было по возможности избавиться от этих зависимостей, используя самописные решения или встроив их в ядро, и оставить снаружи лишь самые большие библиотеки-зависимости, подключаемые динамически.
  • После рефакторинга AwesomeApp, выделения базы AwesomeCore и написания публичного интерфейса приступить к созданию фреймворка. Предоставить доступ к фреймворку, используя CocoaPods. Подключить получившийся pod к AwesomeApp.
  • Так как AwesomeApp написано на языке Objective-C и API к ядру также будет написан на Objective-C, написать оболочку над API на языке Swift. Использовать всю его мощь. Так же поставлять его через CocoaPods.

Рефакторинг и лучшие практики

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

✅ Потребление энергии на мобильных устройствах всегда является проблемой. В iOS потребляемая энергия уменьшается за счет отключения любых аппаратных функций, которые в настоящее время не используются. Вы можете увеличить время автономной работы iOS-устройства, оптимизировав использование следующих функций: CPU, Wi-Fi, радиочастотные модули (3G, LTE), Bluetooth, сервис определения местоположения, акселерометр, диск. Целью ваших оптимизаций должно быть максимально эффективное выполнение кода вашей программы. Всегда оптимизируйте алгоритмы вашего приложения с помощью инструментов. Но даже самый оптимизированный алгоритм может по-прежнему отрицательно влиять на время автономной работы устройства, поэтому при написании кода следует учитывать рекомендации Apple.

✅ Если ваш фреймворк использует слишком много памяти, то приложение, в которое он встраивается, может быть остановлено. Подпишитесь на уведомление UIApplicationDidReceiveMemoryWarning и освободите все ресурсы, которые можно воссоздать. Эта проблема осталась на старых устройствах, но не особенно актуальна, если вы тестируете современные устройства.

✅ Нужно стремится сохранить небольшой размер фреймворка. Если фреймворк значительно повлияет на размер приложения и оно превысит 150 МБ (ограничение Apple), пользователи не смогут загрузить приложение, не подключаясь к Wi-Fi. На WWDC 2015 Apple представила новую функцию App Thinning и один из ее механизмов Bitcode. Используя все методы App Thinning, вы предоставите более легковесный фреймворк, который поможет сохранить небольшой размер приложения, что, конечно же, оценят пользователи.

✅ Swizzling — очень мощный механизм, который имеет некоторые серьезные недостатки. Когда вы свиззлите системные методы, нет гарантии, что эти методы делают то, что ожидается; из-за их повсеместности, изменения их поведения или добавления к ним побочных эффектов может резко возрасти возможность ошибок и сбоев. Swizzling открывает еще один путь для коллизий с другими фреймворками, которые могут так же расширять поведение методов, которые расширяете и вы при помощи swizzling. Наконец, явное лучше, чем неявное. Не добавляйте поведение за кулисами, а предоставьте API, который позволит пользователям решать, какое поведение они хотели бы выбрать.

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

✅ Добавьте префиксы к именам ваших классов, если имеете дело с Objective-C кодом, чтобы избежать коллизий имен с символами в других фреймворках и библиотеках. Также не забывайте, что все методы категорий должны использовать префиксы. Важно понимать, что код фреймворка будет сосуществовать со множеством других классов, констант, категорий и т.д. Если интеграция библиотеки конфликтует с другой, перед пользователем встает выбор: дождаться изменений от одного из двух поставщиков или убрать одну из библиотек.

✅ Старайтесь сохранять актуальность вашего Objective-C кода, особенно в плане взаимодействия со Swift. Используйте Nullability и Lightweight generics.

✅ Хорошо написанного фреймворка недостаточно, нужна также отличная документация и поддержка. Используйте HeaderDoc комментарии для Objective-C кода или Swift documentation markup в публичных интерфейсах, чтобы объяснить функциональность ваших методов, когда и как они используются, какие параметры должны передаваться в каждом методе, и какие ограничения должны соблюдаться каждым параметром. Форматы HeaderDoc и Swift documentation markup дают быстрый доступ к документации, интегрируя его в Xcode Quick Look.

✅ Предоставьте подробную документацию, которая объясняет основные возможности и функции вашего фреймворка. Укажите скрытые моменты, которые необходимо настроить перед использованием. Например, дайте знать, если вашему фреймворку нужны личные данные пользователей и потребуется запрос разрешений (доступ к камере, календарю, контактам, местоположению и д.р.). Без своевременного добавления нужного ключа в Info.plist в лучшем случае приложение упадет на этапе тестирования, в худшем – Apple отклонит заявку на публикацию приложения в Store.

✅ Предоставьте пример проекта, в который интегрирован ваш фреймворк. Обеспечьте сборку и работу проекта с последней версией Xcode.

Создание фреймворка

На этом этапе нашей целью было собрать универсальный фреймворк.

Собственно, создать фреймворк для iOS легко. Сложность кроется в том, как создать универсальную структуру, которая будет использоваться как на реальных устройствах с архитектурами armv7, armv7s, arm64, так и на симуляторах с архитектурами i386, x86_64, будет иметь динамические зависимости и поддерживать Bitcode. Здесь мы хотели бы поделиться хитростями, которые мы использовали в нашем проекте.

Почему нам нужны архитектуры i386, x86_64?

Частично вопрос на этот ответ уже был дан: чтобы приложение с зависимостью от фреймворка запустилось на симуляторе. К тому же Cocoapods требует наличие этих архитектур во фреймворке, иначе при валидации podspec вы получите сообщение об ошибке 'undefined symbols for architecture i386, x86_64'.

Но что насчет архитектуры armv7s, можно ли не включать ее во фреймворк?

В Xcode 6 и iOS 8 Apple удалили armv7s из набора стандартных архитектур. Набор инструкций armv7s можно найти только в процессорах Apple A6 (iPhone 5, iPhone 5C) и A6X (iPad 4). В основном armv7s содержит некоторую оптимизацию для операций с плавающей точкой (VFPv4) и целочисленного деления. Однако A6/A6X будет отлично работать с кодом armv7 без сильной потери производительности. Причиной включения armv7s может быть маргинальная обратная совместимость.

Дополнительные настройки Bitcode

Оказывается, настройки ENABLE_BITCODE=YES недостаточно. По-умолчанию только архивация (Archive) генерирует Bitcode. Для фреймворка же нужно сгенерировать Bitcode для Release-сборки (Build). Чтобы обойти это, вы можете использовать один из двух параметров в своем проекте:

  • В Build Settings -> Other C flags, установите, например, Debug на -fembed-bitcode-marker и Release на -fembed-bitcode. Убедитесь, что он установлен для вашего проекта, а не таргета. Xcode с флагом сборки -fembed-bitcode-marker помещает «пустой» Bitcode в конечный o-файл. А с флагом -fembed-bitcode действительно создает Bitcode с поддержкой двоичного кода.
  • В Build Settings добавьте пользовательский параметр сборки с именем BITCODE_GENERATION_MODE и установите, например, Debug на BITCODE_GENERATION_MODE=marker и Release на -BITCODE_GENERATION_MODE=bitcode. Если вы установили BITCODE_GENERATION_MODE=bitcode в User-defined Setting, даже во время фазы сборки, файлы будут скомпилированы с использованием флага -fembed-bitcode. И, если вы установите маркер BITCODE_GENERATION_MODE=marker, файлы будут скомпилированы с использованием флага -fembed-bitcode-marker, независимо от фазы действия.

Как собрать фреймворк с зависимостями от других библиотек pod-ов?

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

Все просто: к вашему проекту фреймворка добавьте podfile с указанием, от каких именно pod-ов зависит ваш таргет, и флага use_frameworks! Не хотелось бы компилировать зависимости во фреймворк, т.к. это увеличивает его размер и может привести к конфликтам с приложениями, куда фреймворк будет интегрирован.  Это значит, что сторонние зависимости должны быть подключены динамически, а это возможно только с использованием use_frameworks! со всеми вытекающими ограничениями. Установите pod-ы командой pod install, как обычно.

Теперь, после того, как вы подключили ваши pod-зависимости, нужно пользоваться workspace, а не project, и ваш фреймворк будет собираться.

Сборка фреймворка

Опишем шаги по сборке фреймворка:

  • В Xcode перейдите в File > New > Project и выберите iOS > Framework & Library > Cocoa Touch Framework:

iOS framework by Noveo

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

Не забудьте пометить нужные заголовочные файлы как публичные и поместить их импорт в “главный” заголовочный файл (umbrella header) всего фреймворка.

iOS framework by Noveo

  • Если у вашего фреймворка были сторонние зависимости CocoaPods, подключите их как обычно. Далее работайте в workspace.
  • В настройках сборки для таргета вашего фреймворка убедитесь, что для Build Active Architectures Only установлено значение NO для конфигурации Release (и для Debug, если планируете собирать фреймворк в такой конфигурации). Настройте Bitcode, как описано выше. Но помните, если сторонние зависимости не используют Bitcode, то для вашего фреймворка выставленные параметры не будут иметь смысла.
  • Соберите проект на симуляторе в Release-конфигурации. На этом этапе ваш фреймворк будет создан с использованием только симуляторных архитектур.
  • Найдите файл .framework, который был создан. Он будет находиться в папке Derived Data вашего проекта в одной из подпапок Build / Products, но точная папка будет зависеть от ваших настроек сборки.

iOS framework by Noveo

Так как проект был собран для симулятора и для Release, то папка будет называться Release-iphonesimulator. Для Debug соответственно Debug-iphonesimulator. Если же делать сборку для реального устройства, то суффикс iphonesimulator в названии папки поменяется на iphoneos.

Проверить архитектуры фреймворка можно командой lipo -info из терминала:

lipo -info FrameworkName.framework/FrameworkName

Вывод должен быть следующий:

Architectures in the fat file: FrameworkName.framework/FrameworkName are: i386 x86_64

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

Сборка мультиархитектурного фреймворка

Чтобы была возможность использовать фреймворк, нужно, чтобы он содержал правильные архитектуры, которые соответствуют настройкам сборки для потребителя. Как объединить фреймворки в один – создать мультиархитектурный (aka fat) фреймворк? Можно использовать тот же инструмент командной строки lipo, который использовался ранее.

Конечно, мы не хотели каждый раз вручную создавать фреймворк для симулятора и устройства по отдельности, а затем запускать lipo -create. Мы стремились автоматизировать процесс, убрав запуски IDE и подключение устройств. В итоге мы написали свой собственный скрипт, опираясь на уже существующие, запустить который можно из терминала и который учитывает все моменты, описанные выше.

Так как наш скрипт работает напрямую из терминала, а не помещен в Run Script Phases, мы должны явно указать в нем некоторые параметры. Например, папку для сборки проекта, конфигурацию, имя рабочего пространства и схемы. Обычно скрипт помещен в Run Script Phases, и нужные параметры передаются в скрипт, но в нашем случае без определения параметром можно получить ошибку Unbound variable.

Имеем следующий скрипт build-framework-ios.sh:

#!/bin/sh
#  build-framework-ios.sh
#
set -e # causes the shell to exit if any subcommand or pipeline returns a non-zero status.
set +u
# Avoid recursively calling this script.
if [[ $SF_MASTER_SCRIPT_RUNNING ]] ; then
    exit 0
fi
set -u # causes the shell to exit if any undefined variable is referenced, `set +u` turns off
export SF_MASTER_SCRIPT_RUNNING=1
DEBUG=${DEBUG:-0}
export DEBUG
[ $DEBUG -ne 0 ] && set -x # `set -x` shows the commands that get run
# Fully qualified binaries (_B suffix to prevent collisions)
RM_B="/bin/rm"
CP_B="/bin/cp"
MKDIR_B="/bin/mkdir"
LIPO_B="/usr/bin/lipo"
# Constants
CURRENTPATH=`pwd` # Example, specify as you wish.
UNIVERSAL_OUTPUTFOLDER=${CURRENTPATH}/AwesomeFrameworks # Specify it
PROJECT_DIR=${CURRENTPATH}
SYMROOT=${PROJECT_DIR}/build
BUILD_DIR=${SYMROOT}
BUILD_ROOT=${SYMROOT}
HERE_INTERMEDIATES=${BUILD_DIR}/Intermediates
OBJROOT=${HERE_INTERMEDIATES}
SYMROOT=${BUILD_DIR}/Products
CONFIGURATION=Release # Specify it
PRODUCT_NAME=AwesomeCore # Specify it
WORKSPACE_NAME=AwesomeCore.xcworkspace # Specify it
SCHEME_NAME=AwesomeCore # Specify it
IPHONE_SIMULATOR_BUILD_DIR=${BUILD_DIR}/${CONFIGURATION}-iphonesimulator
IPHONE_DEVICE_BUILD_DIR=${BUILD_DIR}/${CONFIGURATION}-iphoneos
# Build the simulator platform
echo "building x86_64 i386"
xcodebuild -workspace "${WORKSPACE_NAME}" -scheme "${SCHEME_NAME}" -configuration "${CONFIGURATION}" -sdk iphonesimulator BUILD_DIR="${BUILD_DIR}" OBJROOT="${OBJROOT}" BUILD_ROOT="${BUILD_ROOT}" CONFIGURATION_BUILD_DIR="${IPHONE_SIMULATOR_BUILD_DIR}" SYMROOT="${SYMROOT}" ENABLE_BITCODE=YES OTHER_CFLAGS="-fembed-bitcode" ONLY_ACTIVE_ARCH=NO clean build
# Build the other (non-simulator) platform
echo "building armv7 armv64"
xcodebuild -workspace "${WORKSPACE_NAME}" -scheme "${SCHEME_NAME}" -configuration "${CONFIGURATION}" -sdk iphoneos BUILD_DIR="${BUILD_DIR}" OBJROOT="${OBJROOT}" BUILD_ROOT="${BUILD_ROOT}" CONFIGURATION_BUILD_DIR="${IPHONE_DEVICE_BUILD_DIR}" SYMROOT="${SYMROOT}" ENABLE_BITCODE=YES OTHER_CFLAGS="-fembed-bitcode" ONLY_ACTIVE_ARCH=NO clean build
# Copy the framework structure to the universal folder (clean it first)
$RM_B -rf "${UNIVERSAL_OUTPUTFOLDER}"
$MKDIR_B -p "${UNIVERSAL_OUTPUTFOLDER}"
$CP_B -R "${IPHONE_DEVICE_BUILD_DIR}/${PRODUCT_NAME}.framework" "${UNIVERSAL_OUTPUTFOLDER}/${PRODUCT_NAME}.framework"
# Smash them together to combine all architectures
echo "smashing together"
$LIPO_B -create "${IPHONE_DEVICE_BUILD_DIR}/${PRODUCT_NAME}.framework/${PRODUCT_NAME}" "${IPHONE_SIMULATOR_BUILD_DIR}/${PRODUCT_NAME}.framework/${PRODUCT_NAME}" -output "${UNIVERSAL_OUTPUTFOLDER}/${PRODUCT_NAME}.framework/${PRODUCT_NAME}"
echo "remove build dir"
$RM_B -rf build

Принцип работы скрипта:

  • Задаем параметры.
  • Собираем фреймворк командой xcodebuild два раза, чтобы получить все нужные архитектуры.
  • Сливаем полученные фреймворки командой lipo -create.

Обратите внимание на то, что фреймворк собирается с флагами -workspace и -scheme, так как мы устанавливали зависимости через CocoaPods и использовали workspace для работы с фреймворком.  Если у вас нет таких зависимостей и только один проект без рабочего пространства, просто укажите в качестве флагов -project и -target. Не стесняйтесь расширять и модифицировать скрипт по мере необходимости.

Теперь можно подключить фреймворк в другой проект. Для этого перетащите фреймворк в раздел Embedded Binaries целевого таргета. В появившемся всплывающем окне убедитесь, что отмечена опция Copy items if needed. Xcode автоматически добавит фреймворк в раздел Linked Frameworks and Libraries.

iOS framework by Noveo

Не забудьте добавить зависимости фреймворка, если таковые были. И можно работать.

Бонус. Проверить, содержит ли фреймворк Bitcode, можно командой

(otool -arch arm64 -l AwesomeCore.framework/AwesomeCore | grep __LLVM) || echo "no bitcode"

При запуске команды появится вывод segname __LLVM, если в библиотеке содержится Bitcode, иначе no bitcode. Запуск команды с флагом __bitcode не является надежным, рекомендуется флаг __LLVM. Обсуждение команды можно найти здесь.

Создание приватного Pod

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

Если вы хотите поделиться кодом или библиотекой между вашими проектами, но не хотите делиться ими с публикой, у вас есть возможность создать приватный pod. Приватный pod не будет указан в репозитории публичных спецификаций. Подробные инструкции по созданию pod-а можно найти здесь и здесь. И конечно же, на самом сайте CocoaPods.

Шаги создания приватного pod-а вкратце:

  • Создайте репозиторий для вашей podspec-и. У нас есть корпоративный GitLab, где и находится AwesomeApp, поэтому выбор репозитория был очевиден для нас.
  • Добавьте репозиторий в CocoaPods командой pod repo add.
  • В репозиторий с исходным кодом, отличным от репозитория с podspec-ой, добавьте необходимые файлы и саму podspec, настроенную нужным вам образом.
  • Провалидируйте podspec командой pod lib lint. Эта команда проверит синтаксис podspec-и, а также попытается собрать тестовый проект с вашим pod-ом.
  • Зафиксируйте файлы. Поставьте тег на коммит, такой же, как и версия podspec-и.
  • Залейте ваш pod командой pod repo push.

Проделав первые шаги выше, мы попытались провалидировать podspec-у командой pod lib lint со следующими параметрами:

s.vendored_frameworks = 'AwesomeCore.framework' # Cам фреймворк
# Зависимости, которые ранее определили в podfile при создании фреймворка.
# У нас они были такие:
s.dependency 'CardIO'
s.dependency 'OpenSSL'
s.dependency 'FMDB/SQLCipher'

Ох, у нас не получилось провалидировать podspec-у с этими зависимостями.

Первая загвоздка была в том, что CardIO и OpenSSLpod-ы со статическими библиотеками, включающими двоичные файлы. CocoaPods не позволяет использовать такие библиотеки в качестве транзитивных зависимостей при создании фреймворка. Например, в проекте на Swift и использовании флага use_frameworks! в podfile, не удастся установить pod. И при валидации возникала ошибка:

The ‘Pods-...’ target has transitive dependencies that include static binaries

use_frameworks! сообщает CocoaPods, что вы хотите использовать динамические фреймворки вместо статических библиотек, поскольку Swift не поддерживает статические библиотеки.

На эту тему очень широко дискутируют. Предложен ряд хаков, но ни один для нас не работал. А разработчики CocoaPods сами так и не предложили внятного решения.

В итоге, порывшись поглубже, мы нашли решение, которое нам подходило и работало: статическую библиотеку нужно обернуть в динамический фреймворк. Подробнее о том, как это сделать, здесь. Для OpenSSL такое уже было проделано добрыми людьми и поставляется через pod. Для CardIO мы сами сделали динамическую обертку и положили ее в приватный pod через vendored_frameworks. Но от этого можно отказаться при соответствующем обновлении CardIO.

Применяя такой подход, можно использовать use_frameworks! в podfile.

Второй проблемой, с которой мы столкнулись, была ошибка от FMDB/SQLCipher при валидации podspec.

- ERROR | [iOS] xcodebuild: FMDB/src/fmdb/FMDatabase.m:427:14: error: implicit declaration of function 'sqlite3_key' is invalid in C99 [-Werror,-Wimplicit-function-declaration]
- ERROR | [iOS] xcodebuild: FMDB/src/fmdb/FMDatabase.m:401:14: error: implicit declaration of function 'sqlite3_rekey' is invalid in C99 [-Werror,-Wimplicit-function-declaration]

Когда используется pod FMDB/SQLCipher, то используется SQLCipher-версия sqlite3.h. В коде FMDB регулируется макросами подключение нужных файлов. Но в настройках FMDB неправильно прописаны header search path до SQLCipher-версии sqlite3.h. Поэтому при сборке получается ошибка. К сожалению, FMDB разработчики не спешат исправлять эту проблему, к слову, нужный PR висит уже полгода.

А мы решили по уже отработанной схеме создать приватный pod для FMDB, где мы полностью его копировали и внесли изменения по исправлению ошибки. Мы огорчены тем, что сами разработчики FMDB до сих пор не сделали исправлений. И ждем их, чтобы заменить приватный pod на обычный, дабы не доставлять неудобств пользователям фреймворка.

В итоге мы заменили зависимости в проекте фреймворка, пересобрали его и успешно создали приватный pod.

При подключении pod-a в такой конфигурации не забывайте прописывать use_frameworks! в podfile.

Swift–оболочка

Следующий шаг — создание Swift-оболочки для публичного API нашего фреймворка.

У вас возникают резонные вопросы: почему мы решили создать такую оболочку? Ведь наш код уже Swift-совместим. Или почему все методы публичного API сразу не были написаны на Swift?

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

Если метод или переменная из Swift-класса не отображается в вашем Objective-C-файле, — это, скорее всего, потому, что Objective-C не поддерживает определенную функцию, которая определяет этот метод или переменную.

Ниже приведен короткий список функций Swift, которые недоступны в Objective-C: кортежи, универсальные типы, структуры, перечисления.

Простым подходом с использованием @obj-аннотации для Swift- типов дело не решить. И опять один из способов справиться с этим — сделать класс-оболочку, который может обрабатывать недоступные функции и вызываться для Objective-C-классов.

Это то, что касается подключения Swift-кода в Objective-C. Что же со случаем наоборот? Здесь же мы хотели использовать всю мощь Swift, для того чтобы сделать наш API более привлекательным, используя универсальные типы, структуры и перечисления.

Еще одним ограничением для использования скомпилированного Swift-фреймворка или скомпилированного фреймворка, частично написанного на Swift, является стабильность ABI. Стабильность ABI разрешает бинарную совместимость между приложениями и библиотеками, скомпилированными в разных версиях Swift. Так, сейчас распространенной ошибкой является “Module compiled with swift X cannot be imported in swift Y”. Пока Swift не имеет стабильности ABI, вам нужно будет перекомпилировать исходный код с каждой версией Swift. Чуть позже еще раз коснемся этого вопроса.

Итак, мы написали Swift-обертку. Как ее поставлять?

Был создан отдельный фреймворк-проект для обертки. К нему подключены pod-ы с нужными зависимостями, собранный AwesomeCore как Linked Framework and Libraries. Таким образом, фреймворк Swift-обертки зависит от собранного AwesomeCore-фреймворка. Назвали новый фреймворк AwesomeCoreSwift.

Затем нужно было осуществить поставку фреймворка через CocoaPods. Для этого мы использовали уже созданный pod AwesomeCore. В podspec-у добавили subspec-у с новым vendored_frameworks. И не забыли о проблеме стабильность ABI, поставляя также исходный код Swift- обертки через другую subspec.

Таким образом, мы поставляем ядро-фреймворк в скомпилированном виде, Swift-оболочку над API фреймворка в скомпилированном виде и в виде кода.

Итого, получилось такая podspec-а:

Pod::Spec.new do |s|
  s.name             = 'AwesomeCore'
  #...
  s.default_subspec = 'Objective-C'
  s.subspec 'Objective-C' do |objc|
    objc.vendored_frameworks = 'AwesomeCore.framework'
    objc.dependency 'MyCardIO'
    objc.dependency 'MyOpenSSL'
    objc.dependency 'MyFMDB/SQLCipher'
  end
  s.subspec 'Swift' do |swift|
    swift.vendored_frameworks = 'AwesomeCoreSwift.framework'
    swift.dependency 'AwesomeCore/Objective-C'
  end
  s.subspec 'SwiftSource' do |source|
    source.source_files = 'AwesomeCore/SwiftSource/**/*.{h,swift}'
    source.dependency 'AwesomeCore/Objective-C'
  end
end

Данная podspec-а прошла валидацию успешно и была залита в приватный репозиторий. Но при подключении pod AwesomeCore/SwiftSource в проект и попытке его собрать линковщик выдавал ошибку “framework not found AwesomeCore for architecture x86_64”. В таргет, для которого мы подключаем pod, не были переданы пути поиска фреймворка AwesomeCore, от которого зависит SwiftSource.

Как видно, не все с CocoaPods складывается гладко. Проблему не удалось решить на этапе валидации podspec. Хотя в podspec возможно прописать некоторые настройки конфигурации, например, для нашего случая:

source.user_target_xcconfig = {
    'FRAMEWORK_SEARCH_PATHS' => '"${SRCROOT}/AwesomeCore/AwesomeCore"',
    'OTHER_LDFLAGS' => '-framework "AwesomeCore"'
}

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

У CocoaPods есть еще один трюк. В podfile возможно прописать post_install скрипт, который позволяет вносить любые изменения в сгенерированный проект Xcode до его записи на диск или любые другие задачи.

Был написан такой скрипт:

post_install do |installer|
    installer.pods_project.targets.each do |target|
        if target.name == "Pods-YourTarget"
            target.build_configurations.each do |config|
                xcconfig_path = config.base_configuration_reference.real_path
                xcconfig = File.read(xcconfig_path)
                xcconfig = xcconfig.sub('FRAMEWORK_SEARCH_PATHS = $(inherited)', 'FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_ROOT}/AwesomeCore/AwesomeCore"')
                xcconfig = xcconfig.sub('OTHER_LDFLAGS = $(inherited)', 'OTHER_LDFLAGS = $(inherited) -framework "AwesomeCore"')
                File.open(xcconfig_path, "w") { |file| file << xcconfig }
            end
        end
    end
end

Что ж, это работает, хоть и выглядит немного неудобным для клиента.

Другой возможный вариант решения проблемы – использовать не subspec-у для поставки кода оболочки, а отдельный pod. Тогда зависимость в виде фреймворка будет поставлена в пользовательский таргет правильно и на этапе валидации, и на этапе подключения pod-a в реальный проект. Например, как это реализовано с зависимостями CardIO и OpenSSL.

Автоматическая сборка фреймворков и заливка в CocoaPods

На этом шаге нашей целью была автоматизация процесса сборки фреймворков и заливка их в CocoaPods. В качестве удаленного сборщика мы выбрали наш CI сервис Jenkins.

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

Мы активно развиваем наш CI сервис, добавляя новые полезные функции. Одной из таких функций является интеграция с Fastlane. Fastlane — это инструмент командной строки, используемый для автоматизации множества задач (создание проектов, запуск тестов, распространение сборок) с помощью набора дополнительных инструментов. Fastlane и Jenkins хорошо работают вместе. Сочетание гибкости Jenkins и мощности Fastlane должно позволить вам охватить практически любой сценарий доставки вашего приложения. Мы расскажем вам об использовании этой связки более подробно в другой статье.

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

Сборка на Jenkins состоит из этапов:

  • Скачивание кода ядра. Для удобства разработки код ядра находится в одном репозитории с продуктовыми проектами AwesomeApp.xcodeproj (это подробнее будет описано ниже).
  • Сборка и упаковка Objective-C фреймворка через скрипт, о котором рассказали выше.
  • Аналогично, сборка и упаковка Swift-оболочки фреймворка.
  • Перенос артефактов предыдущих этапов в репозиторий-хранилище. На него указывает podspec, этот репозиторий будет использован для поставок фреймворка клиентам.
  • Оформление новой версии pod-а:
    • Валидация успешности компоновки с помощью pod lib lint.
    • Создание коммита, тэга с указанием версии фреймворка.
    • Публикация обновлённой podspec в соответствующий репозиторий.

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

Таким образом, разработчику нужно только дождаться завершения цепочки, и можно обновлять pod в проектах, где он используется.

Структура рабочего пространства

В конце концов, с учетом всех наработок, мы создали следующую структуру рабочего пространства (workspace).

Рабочее пространство объединяет в себе несколько проектов.

Есть продуктовый проект AwesomeApp.xcodeproj и проект фреймворка AwesomeCore.xcodeproj. AwesomeApp-проект в зависимости от конфигурации (Debug, Release) работает либо с кодом фреймворка через локальный pod AwesomeCoreInternal, для отладки кода ядра, либо с собранным фреймворком через pod AwesomeCore, для тестирования продукта. Сам продуктовый проект написан на Objective-C, но кода в нем немного —  это навигация посредством меню, аналитика и инициализация фреймворка. В дальнейших планах — перевести его на Swift для унификации.

Есть Swift-оболочка над публичным API AwesomeCore. Она располагается в проекте AwesomeCoreSwift.xcodeproj. Также имеется тестовый проект AwesomeSwiftTest.xcodeproj для тестирования этой оболочки. Через таргеты организовано подключение зависимостей в этот проект. Зависимость является либо локальным pod-ом AwesomeCoreInternalSwift, позволяющим работать с кодом оболочки и кодом ядра, либо pod-ом AwesomeCore/Swift (AwesomeCore/SwiftSource) со Swift-фреймворком.

Обычный сценарий работы при необходимости доработки ядра (например, при обновлении сетевого API):

  • Меняем код в проекте фреймворка, если нужно в swift-оболочке.
  • Проверяем AwesomeApp.xcodeproj и/или AwesomeSwiftTest.xcodeproj.
  • Собираем AwesomeCore-pod.
  • Еще раз проверяем AwesomeApp.xcodeproj и/или AwesomeSwiftTest.xcodeproj с обновленным внешним AwesomeCore-pod.
  • Поставляем заказчику.

Некоторые особенности при работе с динамическими фреймворками

Фреймворки — это пакеты (bundles), что в основном означает, что они являются каталогами, имена которых имеют суффикс .framework, и Finder обрабатывает их в основном как обычные каталоги. В то время как статические библиотеки — только одиночные fat-двоичные файлы, которые не могут нести какие-либо ресурсы как отдельные файлы. Структура фреймворка состоит из:

  • публичных заголовков,
  • ресурсов,
  • хостинговых динамических фреймворков и библиотек,
  • Clang Module Map,
  • Info.plist.

Учитывая вышесказанное, обратите внимание на то, откуда вы загружаете ресурсы, будь то картинки или storyboard-ы и xib-ы.

Если вы хотите загрузить ваш кастомный UIViewController (UIView), который находятся в ресурсах фреймворка, из storyboard или xib в коде фреймворка, то теперь нужно явно указывать текущий bundle фреймворка вместо передачи параметра nil или же NSBundle.mainBundle.

Узнать текущий bundle можно методом +bundleForClass: у NSBundle. Или init(for:) у Bundle для Swift.

// Load storyboard from main bundle
[UIStoryboard storyboardWithName:@"StoryboardName" bundle:NSBundle.mainBundle]; 
[UIStoryboard storyboardWithName:@"StoryboardName" bundle:nil];
// Load storyboard from framework bundle
[UIStoryboard storyboardWithName:@"StoryboardName" bundle:[NSBundle bundleForClass:SomeFrameworkClass.class]];

Что касается работы с картинками: как и прежде, используется метод +imageNamed: у UIImage загружает картинку из Main.bundle. Но вот картинки в Interface builder загружаются из текущего bundle-фреймворка, если Interface builder тоже является частью фреймворка. Если вам по-прежнему нужны картинки из Main.bundle, или какого-либо другого bundle, отличного от главного, то можно воспользоваться следующей схемой. В Interface builder картинку для UIImageView устанавливайте не обычным путем, а через Inspectable-свойство. В категории UIImageView+Inspectable определите это свойство. И в геттере загружайте картинку из Main.bundle. Аналогичная ситуация с картинками для UIButton.

А чтобы однозначно определить bundle для загрузки картинки, можно воспользоваться UIImage методом +imageNamed:inBundle:compatibleWithTraitCollection:.

// Load image from main bundle
[UIImage imageNamed:@"SomeName"];
[UIImage imageNamed:@"SomeName" inBundle:NSBundle.mainBundle compatibleWithTraitCollection:nil];
[UIImage imageNamed:@"SomeName" inBundle:nil compatibleWithTraitCollection:nil];
// Load image from framework bundle
[UIImage imageNamed:@"SomeName" inBundle:[NSBundle bundleForClass:SomeFrameworkClass.class] compatibleWithTraitCollection:nil];
// Load image from another bundle
[UIImage imageNamed:@"SomeName" inBundle:[NSBundle bundleWithURL:someURL] compatibleWithTraitCollection:nil];

Обратите внимание, что загрузка любого ресурса для фреймворка должна быть сигналом для его создателя о возможной кастомизации в этом месте. Возможно, пользователь фреймворка предпочёл бы предоставить свои картинки, строки, аудио файлы или другое. Предоставьте дефолтный набор ресурсов, например, в виде каталога .bundle. Дайте пользователю возможность выбрать путь загрузки своих собственных ресурсов: из Main.bundle, по соседству с ресурсами его приложения, или же его каталогом .bundle.

Заключение

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

Задача создания собственного pod-а является простой в случае небольшой библиотеки и обладает множеством препятствий и неочевидных сложностей в более хитрых сценариях, особенно при наличии плохо поддерживаемых сторонних зависимостей.

Вместе с тем, создание своего фреймворка – увлекательная задача, позволяющая лучше разобраться в основах построения модульной архитектуры, управления зависимостями, сборки (в т.ч. и автоматической) приложений и Pod-ов. Наша команда получила большое удовольствие, решая эти проблемы и зачастую не прибегая к компромиссам на пути решения.

Создавайте свои фреймворки, делитесь опытом, задавайте вопросы!

Если вы нашли ошибку, пожалуйста, выделите фрагмент текста и нажмите Ctrl+Enter.

Читайте в нашем блоге

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: