Swift и Objective-C в одном SDK. Стерпится, слюбится
Привет! Меня зовут Игорь Сорокин, я занимаюсь 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 мы нашли несколько адекватных вариантов.
Удалять .h-файлы из собранного фреймворка
Этот подход основывается на стандартном способе, то есть все объекты Objective-C нужно сделать публичными и добавить их в Umbrella header. Таким образом мы увидим их в Swift.
Хитрость заключается в том, что после сборки фреймворка нужно удалить внутренние .h-файлы из его Umbrella header. Так, во время разработки мы сможем использовать внутренние классы Objective-C, а вот разработчикам приложений они видны уже не будут. Подробнее, с примерами скрипта для удаления .h-файлов из Umbrella header, этот метод описан здесь.
Такой подход решает нашу задачу, однако всё равно остаётся риск «выпустить» внутренние объекты наружу.
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. Поэтому этот способ нам не подошёл и мы выбрали следующий.
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())
Итог
Таким образом нам удалось добиться нашей цели, а именно — подружить языки между собой без необходимости делать объекты публичными. Мы выбрали оптимальный для себя подход, и пусть у него есть свои недостатки, тем не менее он решает нашу задачу.
Поделитесь в комментариях, сталкивались ли вы с подобными проблемами и как их решали?