Привет! Меня зовут Игорь Сорокин, я занимаюсь 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())
Итог
Таким образом нам удалось добиться нашей цели, а именно — подружить языки между собой без необходимости делать объекты публичными. Мы выбрали оптимальный для себя подход, и пусть у него есть свои недостатки, тем не менее он решает нашу задачу.
Поделитесь в комментариях, сталкивались ли вы с подобными проблемами и как их решали?
ws233
Apple ведь просто советует завести Swift-потомка от Obj-C родителя. Такой потомок получит область видимости всех свойств и методов, как у родителя. Его и выставляйте наружу своего фреймворка. Такой подход не пробовали?
Да, вам придется переименовать родителя, добавив ему префикс или постфикс, т.к.Swift-потомок должен иметь то же оригинальное имя родителя. Но это не проблема.
srk1nn Автор
Задача состояла в том, чтобы internal объекты как раз не были видны наружу. То есть сделать их public и использовать стандартный подход не получилось. При этом нужно было обеспечить видимость внутренних Objective-C и Swift объектов между собой.