Разработка iOS Framework в унисон с помощью Swift и Objective-C

26 января 2023

Прошло много времени с тех пор, как язык программирования Swift был представлен на WWDC в 2014 году. С тех пор внедрение Swift сторонними разработчиками, безусловно, было массовым. Тем не менее, Objective-C всё ещё существует.

В этом посте мы предоставим несколько советов и приёмов для успешной разработки XCFrameworks (новые способы упаковки и поставки библиотек в различных вариантах). XCFrameworks сочетают Swift и Objective-C таким образом, чтобы совместимость языков не ставила под угрозу публичные API-интерфейсы фреймворка и не влияла на них.

 

Swift vs Objective-C

Действительно, можно утверждать, что разработка Objective-C за последние несколько лет продвигалась вперед только благодаря Swift. Но также реальностью является и то, что Objective-C по-прежнему является наиболее используемым языком программирования в системе iOS (см. диаграмму ниже, предоставленную компанией Apple: Apple’s use of Swift and SwiftUI in iOS 16). Похоже, что Swift потребуется ещё несколько лет, чтобы превзойти Objective-C в этом отношении.

 Если говорить о сторонних разработчиках для платформ Apple то, несмотря на то, что Swift теперь является языком программирования по умолчанию, есть несколько причин, по которым некоторые базы кода могут по-прежнему содержать изрядное количество кода Objective-C:

  • В зависимости от API-интерфейсов C++, которые можно вызывать только из Objective-C (до тех пор, пока не будет добавлена совместимость Swift и C++).
  • Имеют старую существующую кодовую базу Objective-C, которую можно или нельзя легко перенести на Swift.

Если мы добавим в уравнение Swift, то это, скорее всего, означает, что коды Swift и Objective-C должны взаимодействовать. Эта интероперабельность (способность к взаимодействию, совместимость) относительно проста при работе в App target:

  • Код Objective-C можно импортировать в Swift с помощью связующего заголовка Objective-C и добавления к нему всех файлов заголовков, которые необходимо предоставить Swift.
  • Код Swift можно импортировать в Objective-C, импортировав автоматически сгенерированный заголовок, содержащий публичные или открытые интерфейсы Swift, которые можно подключить к Objective-C. Например, перечисления Swift (enum) со связанными значениями нельзя использовать в Objective-C.
     

 

Совместимость Swift и Objective-C в framework target

Возникает множество нюансов, когда нам необходимо, чтобы Swift и Objective-C взаимодействовали в рамках framework target. Причина в том, что для того, чтобы код на одном языке был доступен на другом, он должен быть публичным (за некоторыми исключениями). Но что мы можем сделать, если эти интероперабельные интерфейсы не должны быть публичными?

Прежде чем вдаваться в подробности, давайте вернёмся назад и ответим на следующий вопрос: почему вас это должно беспокоить?

Разработка публичного API фреймворка — непростая задача. API должен быть хорошо документирован, чётко структурирован и прост в использовании. Следовательно, если эти интерфейсы взаимодействия будут публичными, это только добавит путаницы в публичные API-интерфейсы нашего фреймворка. Более того, автодополнение кода предложит интегратору использовать эти публичные, но не предназначенные для общего доступа API. Мы не должны путать интегратор нашей структуры с классами или методами, которые не предназначены для вызова их кода. Кроме того, может быть ещё одна причина. Может ли вызов этих интерфейсов взаимодействия API повлиять на поведение нашего фреймворка? Иногда использование этих API может привести к непредсказуемой или неправильной работе фреймворка. Хотя комментарий DO NOT USE, вероятно, мог бы помочь, он не может препятствовать интеграторам вызывать эти API по ошибке (или намеренно).

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

 

Импорт кода Objective-C в Swift внутри фреймворка

В этом разделе мы предлагаем обходной путь для очистки совместимых внутренних API-интерфейсов Objective-C от публичного API-интерфейса нашей платформы, чтобы Swift внутри платформы мог видеть эти API-интерфейсы Objective-C, а интеграторы не имели такой возможности. Прежде чем углубляться, мы должны уточнить, что этот обходной путь может применяться только к фреймворкам, распространяемым в двоичной форме (XCFramework). Приведённое ниже решение не может быть применено к фреймворкам в форме исходного кода, поскольку оно основано на сценарии очистки, выполняемом сразу после создания XCFramework.

Согласно документации Apple, импорт кода Objective-C в Swift в рамках целевой платформы достигается путём импорта заголовков Objective-C, которые должны быть представлены Swift в заголовке зонтика платформы. Заголовок зонтика (главный заголовок) — это файл FrameworkName.h, который должен содержать список всех импортов для всех публичных заголовков фреймворка. Обратите внимание, что этот зонтичный заголовок является публичным. Фактически, это должен быть единственный заголовочный файл, который необходимо импортировать интеграторам, чтобы начать использовать фреймворк:

import <FrameworkName/FrameworkName.h>

 

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

Мы объясним, как можно использовать скрипт для удаления всех заголовков Objective-C, которые не должны быть публичными, из XCFramework. Мы проиллюстрируем это на изображениях, соответствующих гипотетической среде под названием MyFramework, где нам необходимо предоставить Swift класс InternalClass класса Objective-C.

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

  • Добавить заголовки в заголовок зонтика.

  • Сделать заголовки публичными (Public) в Target Membership (это также можно сделать в разделе “Headers” фреймворка “Build Phases”). Не забывайте об этом шаге. В противном случае вы получите ошибку времени компиляции типа Include of a non-modular header inside the framework module ‘MyFramework’.

Теперь вы можете вызывать InternalClass из Swift (но помните, что на данный момент интеграторы тоже имеют такую возможность).

class MySwiftClass {                                 
    let objcClass = InternalClass()
}

 

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


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

Третий и последний шаг — написать сценарий, который будет выполняться после создания XCFramework и производит следующие действия:

  1. Читает заголовок зонтика и ищет метку __INTERNAL__.
  2. Удаляет все файлы заголовков, импортированные в заголовок зонтика после метки __INTERNAL__.
  3. Удаляет строку, содержащую метку __INTERNAL__, и все последующие строки.

Мы назовём его removeInternalHeaders.sh, и с этого момента будем предполагать, что у нас уже есть фреймворк MyFramework.xcframework (здесь вы можете проверить, как сгенерировать XCFrameworks). Наш сценарий получит один параметр, содержащий путь к XCFramework, чтобы удалить публичные заголовки Objective-C, которые должны быть внутренними.

#! /bin/sh -e
#
# removeInternalHeaders.sh
#

## 1
XCFRAMEWORK_DIR=$1
INTERNAL_MARK="__INTERNAL__"

## 2
function removeInternalHeadersInUmbrellaHeader {
  local framework_name="$(basename $1 .framework)"
  local headers_dir="$1/Headers"
  local umbrella_header_file="$headers_dir/$framework_name.h"
  local internal_mark_found=false
  local internal_headers=()
  ## 2.1
  while read -r line; do
    if $internal_mark_found; then
      if [[ $line == "#import"* ]]; then
        local filename=$(sed 's/.*\"\(.*\)\".*/\1/' <<< $line)
        internal_headers[${#internal_headers[@]}]=$filename
      fi
    elif [[ $line == *$INTERNAL_MARK* ]]; then
        internal_mark_found=true
    fi
  done < $umbrella_header_file

  ## 2.2
  echo "${#internal_headers[@]} files will be removed"
  for filename in ${internal_headers[@]}; do
    local file="$headers_dir/$filename"
    if [ -f "$file" ]; then
      rm $file
      echo "Removed file: $file"
    else
      echo "Tried to remove file but it does not exist: $file"
    fi    
  done

  ## 2.3
  sed -i "" '/'$INTERNAL_MARK'/,$d' $umbrella_header_file 
}

## 3
for directory in ${XCFRAMEWORK_DIR}/**/*.framework; do
  [ -d "$directory" ] || continue
  removeInternalHeadersInUmbrellaHeader $directory
done

 

Давайте углубимся в детали. Обратите внимание, что мы добавили в код скрипта несколько тегов типа ## X. Мы будем использовать их для пояснений:

  1. Скрипт получает путь к XCFramework ($1) и объявляет метку, идентифицирующую общедоступные, но внутренние заголовки. Эта отметка должна точно совпадать с меткой, добавленной в заголовок зонтика (в нашем случае — “__INTERNAL__”).
  2. Поскольку XCFramework — это просто набор фреймворков и библиотек, нам потребуется повторить процесс очистки для каждого элемента внутри XCFramework. Вот почему мы определяем функцию removeInternalHeadersInUmbrellaHeader: чтобы избежать повторения одного и того же кода для каждого элемента.
    1. Эта функция считывает заголовок зонтика, строка за строкой, пока не найдёт первую строку, содержащую метку __INTERNAL__. Для всех последующих строк зонтичного заголовка он добавляет каждое имя импортированного файла в массив internal_headers, чтобы отслеживать файл заголовка, который впоследствии будет удалён. Каждое имя файла получается путём извлечения текста между кавычками в строке.
    2. Затем функция удаляет файлы, собранные в массив internal_headers.
    3. Наконец, функция редактирует заголовок зонтика, чтобы удалить строку, содержащую метку __INTERNAL__, и все последующие строки, поскольку файлы заголовков, указанные в этих строках, были удалены из фреймворка.
  3. В этом случае XCFramework объединяет один или несколько фреймворков. Поэтому функция removeInternalHeadersInUmbrellaHeader должна выполняться для всех фреймворков.

Обратите внимание, что скрипт рассматривает только импорт файлов с двойными кавычками. Его можно легко изменить, чтобы он также включал импорт с угловыми скобками: #import <Classes/InternalClass.h>.

После выполнения removeInternalHeaders.sh и передачи ему пути к XCFramework мы можем убедиться, что файл InternalClass.h удалён и больше не импортируется в заголовок зонтика.

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


 
Заголовок зонтика после запуска скрипта для удаления публичных заголовков после метки __INTERNAL__:


 
Интеграторы, использующие XCFramework, не смогут получить доступ к InternalClass.

 

Импорт кода Swift в Objective-C внутри фреймворка

В этом разделе мы намерены использовать интероперабельность в противоположном направлении. Мы хотим использовать символы Swift в коде Objective-C в нашем фреймворке.

Опять же, мы ссылаемся на документацию Apple, подчеркивая, что для использования кода Swift в файлах Objective-C .m нам необходимо импортировать сгенерированный Xcode заголовок для кода Swift. В нашем примере выше это будет файл MyFramework-Swift.h. Однако этот файл является публичным и, по сути, включает только объявления Swift, помеченные модификатором public или open. Это означает, что если у нас есть внутренний класс Swift, совместимый с Objective-C (либо с использованием внутреннего модификатора, либо без модификатора), этот класс не будет включен в заголовок, сгенерированный Xcode. Похоже, мы сталкиваемся с той же проблемой, что и раньше.

Однако есть один возможный обходной путь. На странице документации Apple мы можем прочитать следующее:

[…]. Методы и свойства, отмеченные модификатором internal и объявленные в классе, который наследуется от класса Objective-C, доступны во время выполнения Objective-C. Однако они недоступны во время компиляции и не отображаются в сгенерированном заголовке для framework target.

Это дает нам подсказку. Поскольку Objective-C может получить доступ к этим внутренним символам Swift во время выполнения, возможно, нам нужно помочь Objective-C получить к ним доступ во время компиляции. Можем ли мы как-то добиться этого? Что ж, есть хорошие новости. Да, это возможно. Давайте посмотрим, как.

Ключ в том, чтобы наш внутренний заголовок объявлял внутренние символы кода Swift для Objective-C. Этот внутренний заголовок должен включать те же интерфейсы, которые в противном случае были бы добавлены в открытый заголовок -Swift.h, сгенерированный Xcode.

Внимание. Решение, которое мы предлагаем далее, имеет свои проблемы и риски:

  • Это не так идеально или удобно, как если бы интерфейсы, соединяющие код Objective-C, автоматически генерировались средой  Xcode для нас.
  • Это опасно, потому что есть некоторый объём ручной работы по обслуживанию: объявления Objective-C во внутреннем заголовочном файле необходимо обновить, если мы изменим символы Swift, которые они соединяют. 

Но он работает и сохраняет наши публичные API чистыми и ограниченными желаемым публичным интерфейсом фреймворка.

Поясним, как это сделать, на примере. Предположим, что нам необходимо получить доступ к следующему классу InternalSwiftClass из Objective-C в нашем фреймворке, не объявив его public или open.


 
Как указывалось выше, поскольку этот класс не имеет модификатора public или open, файл MyFramework-Swift.h не включает интерфейсы Objective-C для этого класса Swift.

Небольшой совет: вы можете получить доступ к содержимому файла -Swift.h, сгенерированного Xcode, с помощью Xcode. Для этого импортируйте файл в формате .m (в нашем примере с #import <MyFramework/MyFramework-Swift.h>), выполните cmd + клик на import, а затем нажмите “Jump to Definition”.

Давайте создадим наш внутренний заголовок, который будет содержать внутренние интерфейсы Swift. В нашем примере мы назовём этот файл MyFramework-Swift-Internal.h. Убедитесь, что файл включен в framework target с уровнем доступа Project и что он импортирует фактически сгенерированный Xcode заголовок -Swift.h.


 
Теперь нам необходимо объявить в этом файле интерфейсы Objective-C класса InternalSwiftClass. Это деликатный шаг потому, что интерфейсы Swift преобразуются в Objective-C. Мы рекомендуем использовать очень простой трюк, чтобы позволить Xcode сделать преобразование для нас, устраняя риск сделать какие-либо ошибки или опечатки. Хитрость состоит во временном добавлении модификатора public к объявлениям, чтобы интерфейсы могли быть добавлены в заголовочный файл, сгенерированный средой Xcode.

Соберите проект (cmd + b) и откройте файл заголовка, сгенерированный Xcode. Там вы увидите интерфейс Objective-C для нашего класса Swift:


 
Скопируйте объявления @interface (включая часть SWIFT_CLASS(…)) и вставьте их в созданный нами внутренний файл заголовка. Не забываем удалить временный модификатор public для нашего интерфейса Swift (в примере в файле InternalSwiftClass.swift).

И это всё. Теперь вы можете вызывать внутренние интерфейсы Swift из Objective-C из файлов .m, импортировав только что созданный внутренний файл заголовка.

Важное примечание: не забывайте обновлять внутренний заголовок всякий раз, когда вы вносите какие-либо изменения во внутренние интерфейсы Swift, которые будут использоваться из Objective-C. В противном случае ваш проект соберётся, но вылетит во время выполнения!

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

Разработка платформы iOS с несколькими языками отнюдь не проста. Во Fleksy мы работали и с Objective-C, и со Swift вместе. Если вы хотите обсудить эту статью или вам нужна помощь с вашим проектом, не стесняйтесь обращаться к нам напрямую или через наш сервер Discord. Наша команда будет рада помочь вам.

Содержание