Привет! Меня зовут Игорь Сорокин, я занимаюсь iOS-разработкой в myTarget. Мы разрабатываем SDK для показа рекламы в мобильных приложениях. Недавно мы решили переписать его с Objective-C на Swift. Так как делать это мы решили итеративно, то какое-то время в нашем SDK должны уживаться два языка одновременно. Я расскажу, какие подходы используют для этого, почему нам не подошёл стандартный способ и что у нас из этого получилось. Статья будет полезна тем, кто разрабатывает SDK, используя оба языка, а также тем, кто хочет переехать с Objective-C на Swift.

P.S. Своим личным опытом, открытиями и интересными статьями я делюсь в Telegram-канале. Буду рад, если подпишитесь!

Что предлагает Apple?

В iOS стандартным способом совмещать код на Swift и Objective-C в SDK является использование Umbrella header и файла -Swift.h. Подробно эти способы описаны в документации (Swift → Objective-C, Objective-C → Swift). И поскольку эти файлы являются частью публичного интерфейса фреймворка, то объекты, содержащиеся в них, должны быть публичными. Это значит, что в Swift-коде мы сможем увидеть публичные (public) объекты Objective-C , а в коде на Objective-C — публичные Swift-объекты.

Проблемы возникают тогда, когда мы хотим получить доступ ко внутренним (internal) объектам. Из-за того, что мы переписываем SDK итеративно, нам было бы удобно, например, использовать внутренние Swift-объекты в публичных объектах Objective-C, и наоборот.

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

Какие существуют варианты?

Swift → Objective-С

Сначала рассмотрим возможности импорта Swift в Objective-C. Нам удалось найти единственный рабочий вариант. Он основывается на маленькой хитрости. Дело в том, что объекты, помеченные @objc и унаследованные от NSObject, видны в среде исполнения Objective-C, их не видит только компилятор. Но мы можем ему помочь, создав header-файл (.h), который будет описывать объекты из Swift.

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

Написать такой header-файл можно несколькими способами:

  • вручную;

  • поменяв модификаторы доступа на public, собрать проект и сгенерировать -Swift.h, перенести из него нужные объявления в отдельный header-файл и вернуть модификаторы доступа на начальные.

Objective-C → Swift

Для импорта Objective-C в Swift мы нашли несколько адекватных вариантов.

  1. Удалять .h-файлы из собранного фреймворка

    Этот подход основывается на стандартном способе, то есть все объекты Objective-C нужно сделать публичными и добавить их в Umbrella header. Таким образом мы увидим их в Swift.

    Хитрость заключается в том, что после сборки фреймворка нужно удалить внутренние .h-файлы из его Umbrella header. Так, во время разработки мы сможем использовать внутренние классы Objective-C, а вот разработчикам приложений они видны уже не будут. Подробнее, с примерами скрипта для удаления .h-файлов из Umbrella header, этот метод описан здесь.

    Такой подход решает нашу задачу, однако всё равно остаётся риск «выпустить» внутренние объекты наружу.

  2. Private module map

    Второй способ использует private module map, который создаёт подмодуль в основном модуле. В private module map нужно указать внутренние .h-файлы, которые мы хотим видеть в Swift.

    Module map — это специальный файл, который описывает модуль и .h-файлы в нём. Такой модуль может быть импортирован как в Swift, так и в Objective-C (с помощью import или @import).

    Чтобы реализовать этот способ, нужно:

    • Описать подмодуль в файле modulemap:

    // MyTargetSDK/module.modulemap
    explicit module MyTargetSDK.Internal {
        // List of your private headers.
        header "Private1.h"
        header "Private2.h"
        export *
    }
    • В Build Settings фреймворка в поле MODULEMAP_PRIVATE_FILE указать путь до modulemap:

    $(SRCROOT)/MyTargetSDK/module.modulemap

    Теперь можно импортировать в Swift наш подмодуль (import MyTargetSDK.Internal), и из него будут доступны все файлы Objective-C, указанные в modulemap.

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

  3. Module map

    Как и в предыдущем способе, мы будем работать с module map, однако на этот раз создадим полностью отдельный модуль. Он будет доступен нашему SDK, но недоступен извне. Для этого нужно:

    • Создать modulemap и описать в нём приватный модуль:

    // module.modulemap
    module MyTargetSDK_Internal {
        header "MyTargetSDK-InternalObjC.h"
        export *
    }

    MyTargetSDK-InternalObjC.h — это header-файл, содержащий внутренние .h-файлы, которые мы хотим использовать в Swift.

    • В Build Settings фреймворка в поле SWIFT_INCLUDE_PATHS указать путь до папки с module.modulemap.

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

    Could not find module 'MyTargetSDK_Internal' for target ...

    Всё дело в том, что если фреймворк А использует фреймворк Б, то при сборке А в его файлы .swiftinterface будет импортирован фреймворк Б. Для нас это значит, что модуль MyTargetSDK_Internal импортируется в MyTargetSDK. Но при подключении SDK в приложение компилятор не может найти MyTargetSDK_Internal, потому что этот модуль только для разработки и его нет в собранном фреймворке.

    Чтобы такого не происходило, достаточно в SDK импортировать приватный модуль через модификатор @_implementationOnly:

    @_implementationOnly import MyTargetSDK_Internal

    Тем самым мы говорим компилятору, что импорт модуля является подробностью реализации, и поэтому наружу показывать его не нужно. Выполнение требований для @_implementationOnly обеспечивается компилятором. Например, если в публичном методе в качестве параметра будет использоваться объект из MyTargetSDK_Internal, то компилятор выдаст ошибку.

    Cannot use class 'InternalObjcClass' here; 'MyTargetSDK_Internal' has been imported as implementation-only

    Таким образом мы добились своей цели: внутренние объекты Objective-C можно использовать в Swift, причём они не будут видны извне и у нас нет возможности их случайно «выпустить».

Компромиссы

Конечно, некоробочные решения зачастую накладывают определённые трудности при разработке. В нашем подходе с отдельным внутренним модулем может возникнуть ситуация, когда один и тот же объект будет распознаваться компилятором как два разных. Это происходит из-за того, что объекты исходят из разных модулей. Легче объяснить это на примере. Представьте, что у нас есть Swift-класс и описывающий его .h-файл:

// LogInfo.swift
@objc(MTRGLogInfo)
final class LogInfo: NSObject {
    @objc var message: String = ""
}

// MTRGLogInfo.h
@interface MTRGLogInfo : NSObject
@property (nonatomic, copy) NSString *message;
@end

В Objective-C у нас есть объект, который использует Swift-класс в качестве параметра:

// MTRGLoggerManager.h
@class MTRGLogInfo;

@interface MTRGLoggerManager: NSObject
(void)logWithLogInfo:(MTRGLogInfo *)logInfo;
@end

Этот MTRGLoggerManager мы хотим использовать в Swift, значит, добавляем его в наш module map:

// MyTargetSDK-InternalObjC.h
#import "MTRGLoggerManager.h"
#import "MTRGLogInfo.h"

В нём мы вынуждены помимо MTRGLoggerManager.h импортировать MTRGLogInfo.h, чтобы у нас был виден метод logWithLogInfo:. Теперь, если мы захотим использовать всё это в Swift, то получим следующую ошибку:

@_implementationOnly import MyTargetSDK_Internal

final class SomeSwiftClass {
    func logViaLoggerManager() {
        let loggerManager = MTRGLoggerManager()
        let logInfo = LogInfo()
        // error: Cannot convert value of type 'LogInfo' to expected argument type 'MTRGLogInfo'
        loggerManager.log(with: logInfo)
    }
}

Мы-то с вами знаем, что MTRGLogInfo и LogInfo— один и тот же объект, но для компилятора они разные, потому что исходят из разных модулей.

Боремся мы с этим просто: принудительно приводим один тип к другому.

@_implementationOnly import MyTargetSDK_Internal

extension LogInfo {

    func objc() -> MTRGLogInfo {
        let logInfo = self as Any
        return logInfo as! MTRGLogInfo
    }

}

extension MTRGLogInfo {

    func swift() -> LogInfo {
        let logInfo = self as Any
        return logInfo as! LogInfo
    }

}

При передаче объекта в функцию конвертируем его в нужный тип:

loggerManager.log(with: logInfo.objc())

Итог

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

Поделитесь в комментариях, сталкивались ли вы с подобными проблемами и как их решали?