Привет! На связи iOS-разработчик KODE — Семён Медведев. Наша команда разрабатывает крутые цифровые продукты для государства и бизнеса, в том числе мобильные приложения.
На одном из последних проектов для реализации бизнес-требований нужно было сделать так, чтобы пользователь мог делиться любыми файлами с нашим приложением. А для этого нужно было обеспечить доступ к приложению из любого места в операционной системе пользователя.
В процессе я столкнулся со множеством нюансов, выяснение которых заняло у меня некоторое время. Я решил обобщить свой опыт в одном материале и рассказать об ограничениях и сложностях, с которыми могут столкнуться начинающие iOS-еры при разработке Share Extension.
Немного о реализации Share Extension
Если вы уже сталкивались с такой задачей, то знаете, что Apple предоставляет для этих целей интегрированную функциональность — App Extensions. Благодаря ей разработчик может выстраивать бесшовное взаимодействие со своим продуктом на разных устройствах пользователя. На данный момент для iOS и iPadOS доступно 30 расширений. Также есть варианты для MacOS, tvOS и WatchOS.
Для того чтобы приложение попало в меню «Поделиться…», когда пользователь хочет отправить файлы или фотографии, нужно использовать Share Extension. А если в дизайне есть кнопка в секции ниже, как справа на скриншоте, то понадобится Action Extension. Это и пришлось мне делать при разработке одного из проектов. Я не стал останавливаться на двух строчках кода, которые предлагает Apple, а решил разрабатывать фичу самостоятельно.
Ограничения Share Extension
В интернете легко найти информацию, как работать с базовой эппловской имплементацией с использованием SLComposeServiceViewController, но у iOS App Extensions есть ряд ограничений, которые не позволили решить наши задачи. Их важно учитывать при разработке, потому что иначе есть риск появления неожиданных крашей и того, что приложение не пройдёт ревью.
– Ограничение на объём памяти
Apple заявляет, что у App Extension лимиты по выделяемой памяти значительно меньше, чем у Share Extension. Тестирование Share Extension в рамках проекта определило, что ограничение памяти для этого типа расширений установлено на уровне 120 Мб.
Соответственно, если в дизайне проекта предусмотрено кастомизированное отображение превью файлов, необходимо обрабатывать и сжимать изображения внутри loadItem(forTypeIdentifier:, options:, completionHandler:)
. Возвращая из этого метода минифицированные превью, удастся не выйти за пределы эппловских ограничений памяти, и расширение не скрашится.
– Ограничение жизненного цикла App Extension
Контекстом для исполнения кода расширения становится Host App — приложение, из которого пользователь пытается запустить расширение. Система запускает код расширения и устанавливает канал связи между расширением и Host App. Благодаря наличию этой связи Share Extension может получить доступ к массиву вложений (файлов), переданных из Host App в App Extension.
Документация про жизненный цикл — по ссылке.
Ограничение заключается в том, что у App Extension нет прямого доступа к коду и связи с Containing App — оригинальным приложением, частью которого и является App Extension. Когда запущен App Extension, Containing App не может быть запущен даже в фоне. Вы не сможете пользоваться кодом из основного таргета в App Extension — только импортировать отдельные фреймворки.
Подразумевается, что Host App и App Extension взаимодействуют исключительно в рамках запрос–ответ. В Share Extension же запрос уходит на старте расширения, а ответ — после его завершения или запуске Background task, после которого система убивает расширение.
Однако всё не так плохо. Внутри App Extension можно получить доступ к общим контейнерам с Containing App и даже к исполняемому коду, если он расположен в отдельных фреймворках. Как обойти это ограничение я расскажу в следующей главе.
– Ограничение на длительные процессы
Согласно App Extension Programming Guide, сами App Extension’ы имеют мало возможностей для манёвра. Время их загрузки строго ограничено, как ограничено и использование ими GPU. Если система посчитает, что расширение расходует слишком много ресурсов, его работа может быть приостановлена в любой момент. Это также напрямую связано с лимитами памяти. Apple не рекомендует на длительный срок (например, для выгрузки объёмных файлов в сеть) занимать процесс.
Рекомендации Apple следующие:
Переносить ресурсоемкие операции из App Extension в Containing App.
Стартовать выгрузку в background thread при помощи NSUrlSession.
Вызывать
completeRequestReturningItems: completionHandler
илиcancelRequestWithError:
, для того чтобы система понимала, чем завершилась работа пользователя внутри расширения и могла свободно убить Extension.
Для проекта я рассмотрел, как ведут себя другие приложения, позволяющие выгружать объёмные файлы, в частности Google Drive и Яндекс.Диск. Несмотря на рекомендации Apple, они осуществляют загрузку, не закрывая контроллер расширения. Это резонно, ведь сложно гарантировать завершение процесса загрузки в фоновом режиме, особенно если Share Extension находится в состоянии terminated. Никто не застрахован от обрыва сети в процессе выгрузки. Также низкий приоритет background-задач может сделать невозможным быстрое переключение пользователя между мобильным приложением и его веб-версией на десктопе: файлы ещё не будут переданы, но пользователь этого не поймёт.
Поскольку задачи проекта схожи с изученными продуктами, я решил не придумывать свой велосипед, а для MVP-версии придерживаться аналогичной концепции. Хоть пользователь на время загрузки не сможет вернуться к основным задачам, которыми он занимался в Host App, зато он будет точно понимать, чем завершился и завершился ли его процесс выгрузки файлов в онлайн-хранилище.
– Ограничение на View Debugger
На момент написания статьи я не нашёл, как использовать визуальный дебаггер на Share Extension. Возможно, UIWindow расширения ограничен из-за того, что вызван поверх Host App. Пока можно воспользоваться сторонними инструментами вроде Reveal.
6 сложностей, которые я решал во время разработки Share Extension
Чтобы проиллюстрировать процесс разработки Share Extension и выделить его главные особенности, я смоделировал проект-пример. Это же решение я применял на внутреннем проекте во время разработки облачного хранилища для банка. Исходный код — по ссылке на Гитхабе.
1. Минимальная возможная реализация
Apple предоставляет API для Share Extension из коробки. Этим решением является класс ShareViewController, который наследуется от SLComposeServiceViewController. Этот родитель предоставляет следующие методы:
-
isContentValid() → Bool
Метод позволяет валидировать объекты, поступающие на вход расширению: отдельно взятый текст и объекты, переданные внутри класса NSExtensionContext. В этом классе можно получить содержимое объектов любого типа.
-
didSelectPost()
Вызывается при нажатии пользователем на кнопку Post и является подтверждением отправки. На этом этапе должен инициироваться процесс выгрузки.
-
configurationItems()
Список SLComposeSheetConfigurationItem, передаваемый для отображения дополнительных элементов внутри стандартного Compose Controller.
Для тестирования коробочного варианта реализации от Apple достаточно добавить стандартный таргет Share Extension. Выбрать ProjectNavigator → ShareExtensionExample, Editor → AddTarget → Share Extension.
Стандартная имплементация Share Extension — за 40 секунд
2. Ограничение допустимых типов данных
Допустим, вы хотите позволить пользователю загрузить в свою онлайн-галерею несколько фотографий и видео. Чтобы не нагружать сервер, аналитики выдвинули требование: можно загружать максимум 10 фото и 5 видео за 1 раз.
Для того чтобы дать расширению понять, какие файлы и в каком количестве мы хотим принимать, можно воспользоваться активационными правилами (NSExtensionActivationRule). Для этого нужно:
Перейти в info.plist расширения.
Раскрыть вложенные списки свойств в порядке: NSExtension → NSExtensionAttributes → NSExtensionActivationRule.
Заменить тип свойства NSExtensionActivationRule c String на Dictionary.
Прописать в связках «ключ – значение» созданного словаря желаемые ограничения на допустимые файлы. В нашем случае, чтобы соответствовать требованиям, пришлось бы указать следующие правила: NSExtensionActivationSupportsImageWithMaxCount = 10, NSExtensionActivationSupportsMovieWithMaxCount = 5. Правила можно выбирать на разные случаи жизни, подробно о них расписано в документации (таблица 3).
После применения правил и перезапуска, вы заметите, что если попытаться поделиться, например, выделенным текстом, то вашего приложения не будет в предложенном меню. В то же самое время, если попытаться поделиться несколькими фото из галереи (но не больше 10), приложение появится.
Важно! Необходимо задать хоть какие-то правила для расширения, удалив из info.plist значение TRUEPREDICATE. Это значение установлено как активационное правило по умолчанию и позволяет в процессе разработки триггерить запуск расширения, чем бы вы не пытались поделиться. При ревью приложения в стор оно будет отвергнуто, если будет найдено значение TRUEPREDICATE.
Настройка активационных правил для открытия Share Extension — за 30 секунд
3. Кастомный UI для Share Extension
Окей, простой вариант реализовали, ограничения установили, а что дальше? Возможно, нам нужно сделать красивый интерфейс для нашего расширения: с превью, кастомными кнопочками согласно фирменному стилю и так далее.
Если вы привыкли верстать экраны кодом, то нужно знать пару тонкостей, чтобы делать так и дальше:
Нужно удалить MainInterface.storyboard. Он нам уже не понадобится.
Вместе с ним удалить boilerplate код из ShareViewController, в том числе
import Social
. Сделать ShareViewController наследником UIViewController.-
Следующий шаг — правильно отредактировать info.plist:
В списке NSExtension удалить свойство NSExtensionMainStoryboard
В NSExtension добавить свойство NSExtensionPrincipalClass, значением которого будет класс нашего контроллера — ShareViewController
И самое главное, чтобы компилятор мог сопоставить класс и свойство в .plist, нужно добавить перед объявлением класса ShareViewController его название для Obj-c:
@objc(ShareViewController)
.
Ура! Теперь можно спокойно настраивать экран. Заполним ShareViewController простой вёрсткой.
Код
import UIKit
@objc(ShareViewController)
class ShareViewController: UIViewController {
private let titleLabel = UILabel()
private let previewsTable = UITableView()
private let confirmButton = UIButton()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
configureTitle()
configureTable()
configureConfirmButton()
setupLayout()
}
}
// Layout methods go here
}
Теперь при попытке поделиться фотографиями и видео из галереи мы видим следующий результат:
Слишком много пустого пространства — чего-то тут не хватает. Нужно заполнить экран превьюшками.
4. Генерация превью
Чтобы заполнить контроллер красивыми превью, нам понадобится доступ к пошаренным файлам. Прежде нужно подготовить почву, настроив таблицу. Описываем структуру для превью и дальнейшей выгрузки.
public struct ShareFile {
public struct Preview {
public let title: String
public let size: String
public let preview: UIImage?
}
public struct Upload {
public let name: String
public let size: UInt64
public let mimeType: String
public let data: Data?
}
var preview: Preview
var upload: Upload
}
Добавим список моделей в класс ShareViewController, который потом будем заполнять из контекста расширения. При получении нового значения будем обновлять данные на вход таблицы:
// Properties
var itemsData: [ShareFile] = [] {
didSet {
DispatchQueue.main.async { [weak self] in
self?.previewsTable.reloadData()
}
}
}
В configureTable()
добавим для previewsTable источник данных и зарегистрируем нашу ячейку для превью (PreviewCell.swift).
Код для ячейки
import Foundation
import UIKit
final class PreviewCell: UITableViewCell {
private let containerView = UIView()
private let previewImage = UIImageView()
private let previewTitleLabel = UILabel()
private let previewSubtitleLabel = UILabel()
override func prepareForReuse() {
super.prepareForReuse()
contentView.subviews.forEach { $0.removeFromSuperview() }
}
func configure(with model: ShareFile) {
previewImage.image = model.preview.preview
previewTitleLabel.text = model.preview.title
previewSubtitleLabel.text = model.preview.size + " Б"
setupViews()
}
private func setupViews() {
addSubview(containerView)
containerView.backgroundColor = .init(red: 238/255, green: 239/255, blue: 168/255, alpha: 1)
containerView.layer.cornerRadius = 16
containerView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
containerView.leadingAnchor.constraint(equalTo: leadingAnchor),
containerView.topAnchor.constraint(equalTo: topAnchor, constant: 8),
containerView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
containerView.trailingAnchor.constraint(equalTo: trailingAnchor)
])
containerView.addSubview(previewImage)
previewImage.layer.cornerRadius = 12
previewImage.layer.masksToBounds = true
previewImage.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
previewImage.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 10),
previewImage.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 10),
previewImage.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -10),
previewImage.widthAnchor.constraint(equalToConstant: 50),
previewImage.heightAnchor.constraint(equalToConstant: 50)
])
containerView.addSubview(previewTitleLabel)
previewTitleLabel.translatesAutoresizingMaskIntoConstraints = false
previewTitleLabel.font = UIFont.preferredFont(forTextStyle: .headline)
NSLayoutConstraint.activate([
previewTitleLabel.leadingAnchor.constraint(equalTo: previewImage.trailingAnchor, constant: 16),
previewTitleLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 10)
])
containerView.addSubview(previewSubtitleLabel)
previewSubtitleLabel.translatesAutoresizingMaskIntoConstraints = false
previewSubtitleLabel.font = UIFont.preferredFont(forTextStyle: .subheadline)
NSLayoutConstraint.activate([
previewSubtitleLabel.leadingAnchor.constraint(equalTo: previewImage.trailingAnchor, constant: 16),
previewSubtitleLabel.topAnchor.constraint(equalTo: previewTitleLabel.bottomAnchor, constant: 10),
previewSubtitleLabel.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -10)
])
}
}
previewsTable.delegate = self
previewsTable.dataSource = self
previewsTable.register(PreviewCell.self, forCellReuseIdentifier: "preview")
Добавим стандартный вариант делегата UITableViewDelegate и UITableViewDataSource:
Код
// MARK: - UITableView Data Source
extension ShareViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return previews.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "preview")!
cell.textLabel?.text = "Превью файла"
return cell
}
}
// MARK: - UITableViewDelegate
extension ShareViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: false)
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return 86
}
}
Теперь самое интересное. Обратимся к NSExtensionContext для получения доступа к переданным файлам. В контроллере Share Extension есть доступ к контексту, внутри которого хранятся все переданные файлы.
Проверим, можем ли мы получить доступ к вложенным файлам. Для этого список вложенных элементов inputItems внутри extensionContext должен быть типа [NSExtensionItem] и не быть пустым. Если же скастить не удалось, или список внезапно оказался пустым — завершим работу с расширением стандартным методом completeRequest внутри метода-помощника close().
func close() {
extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
}
func getFilesExtensionContext() {
guard let inputItems = extensionContext?.inputItems as? [NSExtensionItem],
inputItems.isNotEmpty
else {
close()
return
}
}
Чтобы разобраться, что есть что среди передаваемых файлов, нужно проверять, к какому типу относится каждое вложение. Использовать для этого придётся UTI – Uniform Type Identifiers.
Сначала нужно импортировать namespace “MobileCoreServices”: import MobileCoreServices
. В нём содержатся константы типов вроде kUTTypeImage, kUTTypeAudio, kUTTypeSpreadsheet и многие другие. Для определения фото и видео понадобятся два: kUTTypeImage и kUTTypeMovie (для определения файлов из приложения «Файлы» пригодится kUTTypeURL).
Напишем коротенькое расширение NSItemProvider для удобства:
extension NSItemProvider {
var isImage: Bool {
return hasItemConformingToTypeIdentifier(kUTTypeImage as String)
}
var isMovie: Bool {
return hasItemConformingToTypeIdentifier(kUTTypeMovie as String)
}
}
Теперь дополним метод getFilesExtensionContext вызовом обработчиков вложений в зависимости от типа:
inputItems.forEach { item in
if let attachments = item.attachments,
!attachments.isEmpty {
attachments.forEach { attachment in
if attachment.isImage {
handleImageAttachment(attachment)
} else if attachment.isMovie {
handleMovieAttachment(attachment)
}
}
}
}
Получая вложения в цикле, обработаем их, получим содержимое и отдельно взятые свойства вложений. Для загрузки информации о вложении воспользуемся методом NSItemProvider. loadItem(forTypeIdentifier:options:completionHandler:).
func handleImageAttachment(_ attachment: NSItemProvider) {
attachment.loadItem(forTypeIdentifier: kUTTypeImage as String, options: nil) { [weak self] item, error in
// handling attacment
}
}
Внутри метода loadItem проверим, что ошибок нет:
guard let self = self else { return }
guard error == nil else {
self.close()
return
}
// Обработка успешно полученных вложений
Напишем несколько методов помощников, которые сделают своё дело в определении свойств вложения. Для определения MIME-типа вложения воспользуемся встроенными методам CoreServices для определения и конвертации тегов Universal Type Identifiers. Возьмем UTI из kUTTagClassFilenameExtension и конвертируем его в kUTTagClassMIMEType.
func getMimeType(for url: URL) -> String {
guard
let uti = UTTypeCreatePreferredIdentifierForTag(
kUTTagClassFilenameExtension,
url.pathExtension as CFString,
nil
)?.takeRetainedValue(),
let mimeType = UTTypeCopyPreferredTagWithClass(
uti,
kUTTagClassMIMEType
)?.takeRetainedValue() as String?
else {
return "application/octet-stream"
}
return mimeType
}
Воспользуемся встроенным методом структуры URL - resourceValues (forKeys keys: Set<URLResourceKey>) для определения размера файла. Напишем и для этого коротенькую функцию:
func getSize(for url: URL) -> UInt64? {
guard let resources = try? url.resourceValues(forKeys: [.fileSizeKey]),
let size = resources.fileSize
else { return 0 }
return UInt64(size)
}
Сгенерируем минифицированные превью для фото и видео с использованием AVFoundation и ImageIO:
Код
// MARK: - File Previews
private extension ShareViewController {
func getResizedImage(from imageData: Data) -> UIImage? {
guard let imageSource = CGImageSourceCreateWithData(imageData as NSData, nil)
else { return nil }
return resizedImage(from: imageSource)
}
func resizedImage(from imageSource: CGImageSource) -> UIImage? {
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageIfAbsent: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceThumbnailMaxPixelSize: 45
]
guard let image = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary)
else { return nil }
return UIImage(cgImage: image)
}
func makeThumbnailForMovie(with url: URL) -> UIImage? {
do {
let asset = AVURLAsset(url: url)
let imageGenerator = AVAssetImageGenerator(asset: asset)
imageGenerator.appliesPreferredTrackTransform = true
let cgImage = try imageGenerator.copyCGImage(
at: .zero,
actualTime: nil
)
return UIImage(cgImage: cgImage)
} catch { return nil }
}
}
Далее, имея все описанные методы, дополним функцию handleImageAttachment определением заголовка, MIME-типа, превью, размера. Также создадим объект ShareFile и добавим его к списку itemsData.
Код
var title: String = "Картинка"
var imageData: Data?
var thumbnail: UIImage?
var mimeType: String = "application/octet-stream"
var size: UInt64 = 0
if let itemUrl = item as? URL,
let fileName = self.nameAndExtension(from: itemUrl.lastPathComponent) {
imageData = try? Data(contentsOf: itemUrl)
title = fileName.title
size = self.getSize(for: itemUrl) ?? 0
mimeType = self.getMimeType(for: itemUrl)
} else if let data = item as? Data {
imageData = data
size = UInt64(data.count)
} else if let image = item as? UIImage,
let pngData = image.pngData() {
imageData = pngData
size = UInt64(pngData.count)
} else {
self.close()
}
if let imageData = imageData,
let image = self.getResizedImage(from: imageData) {
thumbnail = image
}
self.itemsData.append(
.init(
preview: .init(title: title, size: String(size), preview: thumbnail),
upload: .init(name: title, size: size, mimeType: mimeType, data: imageData)
)
)
Аналогичным образом заполним метод handleMovieAttacment():
Код
func handleMovieAttachment(_ attachment: NSItemProvider) {
attachment.loadItem(forTypeIdentifier: kUTTypeMovie as String, options: nil) { [weak self] item, error in
guard let self = self else { return }
guard error == nil else {
self.close()
return
}
var title = "Видео"
var thumbnail: UIImage? = nil
var mimeType: String = "application/octet-stream"
var size: UInt64 = 0
if let urlItem = item as? URL,
let fileName = self.nameAndExtension(from: urlItem.lastPathComponent) {
if let image = self.makeThumbnailForMovie(with: urlItem) {
thumbnail = image
}
title = fileName.title
mimeType = self.getMimeType(for: urlItem)
size = self.getSize(for: urlItem) ?? 0
}
self.itemsData.append(.init(
preview: .init(title: title, size: String(size), preview: thumbnail),
upload: .init(name: title, size: size, mimeType: mimeType, data: nil)
))
}
}
Теперь можно запустить наш код и проверить, насколько круто стал выглядеть App Extension. Теперь все полученные картинки и видео будут отображаться в таблице в виде опрятных ячеек. На этом этапе можно внедрять то, что недоступно в стандартной имплементации SLComposeServiceViewController: например, возможность детального просмотра некоторых типов данных, создание групп (папок) для отдельных вложений, переименование и многое другое.
Как получилось в итоге
5. Взаимодействие с Containing App
Что делать, если необходимо понять, авторизован пользователь или нет? Для этого можно воспользоваться общими контейнерами между Containing App и Host App. Таким контейнером могут выступать общие UserDefaults, Keychain, CoreData или даже контейнер FileManager’а для передачи больших объёмов данных.
Чтобы воспользоваться этими возможностями, в настройках Apple Developer Account нужно добавить App Group для конкретного проекта, а также активировать “App Goups” Capability в настройках основного таргета. (Для доступа к Keychain из App Extension понадобится активировать не только “App Groups”, но и “Keychain Sharing” Capability.)
Допустим, мы создали общий Keychain Storage и получили доступ к информации о конкретном пользователе из Share Extension. Эту информацию составляет последний его серверный accessToken. Но валидный ли это токен? Как нам загрузить выбранные файлы на сервер?
Для этого нужен наш сетевой слой из основного приложения. Чтобы сделать это возможным, нужно, чтобы сетевой слой располагался в отдельном фреймворке и импортировался в основной таргет. Тогда можно использовать его и внутри App Extension.
Для данного тестового проекта мы не будем это реализовывать, но узнать, как это сделать, можно в другом русскоязычном источнике.
6. Редирект в Containing App
Теперь у нас есть налаженный канал связи между нашим расширением и Containing App. Раз у нас приложение-галерея, пользователь может загружать туда личные файлы. Также мы можем проверить, авторизован ли пользователь, благодаря значениям токенов из Keychain. Если токен out-of-date, то нужно показать пользователю кнопку перехода в основное приложение для авторизации.
Особенность App Extensions состоит в том, что не для всех видов расширений есть открытая функциональность редиректа в Containing App. Например, для виджета Today такая возможность есть, потому что его суть — быстрый переход в приложение. Там можно просто вызвать openURL:completionHandler:
внутри NSExtensionContext.
Суть Share Extension в другом, поэтому нам приходится искать обходные пути, так как простые способы, которые позволяют открыть «главное» приложение, закрыты. Есть ряд решений (источник), которые почему-то не сработали в моём случае. Говорят, что за такие обходные пути приложение может быть отвергнуто на этапе ревью, но я не нашёл проверенных данных.
Для меня сработало следующее решение:
@objc private func loginButtonTapped() {
let application = UIApplication.value(forKeyPath: #keyPath(UIApplication.shared)) as! UIApplication
let selector = NSSelectorFromString("openURL:")
if let url = URL(string: "YourApplicationUrlScheme://") {
application.perform(selector, with: url)
}
}
Надеюсь, мой опыт будет полезен всем, кто собирается реализовать Share Extension на своём проекте. Исходный код опубликован на Гитхабе.
Семён Медведев
iOS-разработчик KODE, автор статьи