Share extension как общий компонент
Введение
Всем привет от мобильной платформы компании «Тензор»! Меня зовут Галина и в этой статье я хочу поделиться историей развития нашего Share Extension.
За последние 3 года количество выпускаемых нами мобильных приложений значительно выросло, а в процессе их разработки увеличивались и требования к функционалу шаринга. Под каждую бизнес задачу требуются разные опции, будь то отправка фотографий в диалог или загрузка документа на диск. Не каждое наше приложение поддерживает тот или иной функционал, но и писать отдельную реализацию под новый продукт не рационально. Поэтому share extension превратился в отдельный модуль, конфигурируемый за счёт подключенных внешних зависимостей.
Освежим память
Для тех кто плотно не углублялся в ios-разработку или подзабыл, что такое Share Extension, краткий экскурс в предметную область:
Share extension — удобный интерфейс, предоставляющий пользователю возможность обмена контентом с другими приложениями, например отправка документа из одного мессенджера в другой.
Более подробно можно почитать здесь
Конфигурация таргета
Весь основной функционал шаринга хранится в отдельном модуле «ShareExtension», по сути это такой же обычный фреймворк, как и все остальные, подключается он к приложению через СocoaPods.
Для дальнейшей работы обычно создаем соответствующий таргет и заменяем автоматически сгенерированный ShareViewController на файл-пустышку, который обязательно должен быть привязан к ранее созданному таргету ios-extension, storyboard тоже нам не понадобится.
Добавляем в Podfile информацию о таргете и его зависимостях, в info.plist расширения указываем NSExtensionPrincipalClass = ShareViewController«у из сабмодуля (в текущем случае это ShareExtension.ShareViewController).
Замена автоматически сгенерированного ShareViewController на файл-пустышку
Настройка NSExtensionPrincipalClass
Взаимодействие с Share Extension
Познакомимся с протоколами и структурами, которыми оперирует наш модуль шаринга.
Поскольку приложения СБИС являются конструкторами, составленными из множества отдельных модулей, каждый из таких модулей может иметь свой контекст для шаринга, в данном случае таковым выступает ShareModuleContext
/// Элемент меню share extension
public struct ShareExtensionAction {
/// Заголовок
public let title: String
/// Идентификатор экшена
public let type: ShareExtensionActionType
/// Идентификатор контекста
public let contextId: String
/// :nodoc:
public init(title: String, type: ShareExtensionActionType, contextId: String) {
self.title = title
self.type = type
self.contextId = contextId
}
}
/// Протокол для обработки нажатия на пункт shareextension
public protocol ShareExtensionActionType {}
/// Протокол для VC, в который можно что-то зашарить
public protocol ShareExtensionViewController: UIViewController {
/// Данные, которые передают
var shareData: ShareData? { get set }
/// Протокол обратной связи
var shareControllerDelegate: ShareControllerDelegate? { get set }
/// Тип выбранной опции в меню шаринга
var selectedAction: ShareExtensionActionType { get set }
}
/// Протокол обратной связи
public protocol ShareControllerDelegate: AnyObject {
/// Метод будет вызван, когда VC свернут
func onDismiss()
}
Механизм настройки доступных опций
Реализованный нами share extension конфигурируется, опираясь на несколько факторов:
ShareExtensionConfig.plist — файл свойств, который создается на уровне приложения и может контролировать, какие из доступных ShareExtensionConfigItem«ов (перечисление модулей, реализующих шаринг) мы хотим подключить.
import SbisServiceAPI
/// Айтемы, которые ожидаем получить из ShareExtensionConfig.plist
public enum ShareExtensionConfigItem: String, CaseIterable {
case communicator
case disk
case preview
case buffer
}
/// Получение настроек списка пунктов в меню ShareExtension
public class Configuration {
/// Получить возможные опции при шаринге
/// - Returns: массив опций
static public func getList() -> [ShareExtensionConfigItem] {
// Если нет листа с настройками - отдаём все пункты
guard let path = Bundle.main.path(forResource: .appTag, ofType: .plist) else {
return ShareExtensionConfigItem.allCases
}
var configList: [ShareExtensionConfigItem] = []
if let list = NSArray(contentsOfFile: path) as? [String] {
for item in list {
if let configItem = ShareExtensionConfigItem(rawValue: item) {
configList.append(configItem)
}
}
} else {
assertionFailure("Ошибка настроек для ShareExtensionConfig.plist")
}
return configList
}
}
private extension String {
static let appTagName = "ShareExtensionConfig"
static let appTag = "ShareExtensionConfig"
static let plist = "plist"
}
Каждый контекст реализует возможность предоставления информации о доступных опциях getActionList () и поддерживаемых им типах данных getAcceptedTypes ()
Жизненный цикл
При вызове viewDidLoad () ShareViewController«a, presenter начинает конфигурировать массив доступных опций, опираясь на ранее описанный механизм.
// Запрос разрешенных на уровне приложения опций
let configList = Configuration.getList()
// Маппинг опций из получанного списка в массив ShareModuleContext’ов
let allContexts: [ShareModuleContext] = configList.compactMap {
switch $0 {
case .communicator:
return communicatorShareContext
case .disk:
return diskFullShareContext
case .preview:
return previewerContext
case .buffer:
return bufferShareContext
case .demo:
return demoShareContext
}
}
let extensionItemList = extensionList.flatMap { Array($0.attachments ?? [] )}
// Выбор опций, поддерживающих шаринг текущего типа файлов
for context in allContexts {
let supportedTypes = context.getAcceptedTypes()
let supportedItems: [ShareExtensionData] = extensionItemList.compactMap { item in
if let type = supportedTypes.first(where: item.hasItemConformingToTypeIdentifier) {
let data = ShareExtensionData(
item: item,
ofType: type,
type: ExtensionAttachmentType(withUTType: type)
)
return data
} else {
return nil
}
}
if !supportedItems.isEmpty {
availableShareContexts.append(context)
shareContextAttachmentData[context.getContextId()] = supportedItems
}
}
Каждый из контекстов проводит те или иные необходимые проверки на доступность функционала перед тем, как вернуть массив опций в getActionList ().
После того как нам известно, какие опции мы можем предложить пользователю, отображаем сконфигурированное меню:
отправка контакта;
отправка картинки из галереи;
отправка текста пользователем, у которого нет прав на диск компании;
отправка документа пользователем, которому доступен только модуль каналов.
Добавление новой опции
Для добавления новой опции был создан демо модуль: SbisDemoShareExt, попробуем отобразить на его примере способность share extension к расширению.
import SnapKit
import SbisNavigationAPI
import SbisServiceAPI
enum DemoSharePreviewAction: ShareExtensionActionType {
case demo
}
final class DemoShareViewController: UIViewController, ShareExtensionViewController {
var selectedAction: ShareExtensionActionType = DemoSharePreviewAction.demo
var shareData: ShareData?
weak var shareControllerDelegate: ShareControllerDelegate?
private let imageView = UIImageView()
override func viewDidLoad() {
super.viewDidLoad()
navigationController?.isNavigationBarHidden = true
configureUI()
showData()
}
private func configureUI() {
imageView.contentMode = .scaleAspectFit
view.addSubview(imageView)
imageView.snp.makeConstraints {
$0.centerX.equalToSuperview()
$0.centerY.equalToSuperview()
$0.width.lessThanOrEqualToSuperview()
$0.height.lessThanOrEqualToSuperview()
}
}
private func showData() {
guard let current = shareData?.getLocalFileList()?.first else {
return
}
if case .localFile(let file) = current,
let url = file.url {
DispatchQueue.global().async {
let image = UIImage(url: url)
DispatchQueue.main.async { [weak self] in
self?.imageView.image = image
}
}
}
}
}
DemoShareContext
import SbisNavigationAPI
/// Контекст для shareExtension
public class DemoShareContext: ShareModuleContext {
// модуль будет обрабатывать только картинки
private static let acceptedTypes = [kUTTypeImage as String]
/// Тип выбранной опции в меню шаринга
public var selectedAction: ShareExtensionActionType?
private let vc = DemoShareViewController()
private let contextID = String(describing: DemoShareContext.self)
/// Получение VC окна для логики share
public func getShareViewController() -> ShareExtensionViewController {
return vc
}
/// Получение типов данных, которые модуль обрабатывает
public func getAcceptedTypes() -> [String] {
return DemoShareContext.acceptedTypes
}
/// Получение ИД контекста
public func getContextId() -> String {
return contextID
}
/// Получение списка действий, которые умеет производить модуль
public func getActionList() -> [ShareExtensionAction] {
return [
ShareExtensionAction(
title: localization[.title],
type: DemoSharePreviewAction.demo,
contextId: contextID)
]
}
}
Далее прокидываем зависимости в ShareExtension через DI, у нас это DITranquillity
В модуле SbisDemoShareExt:
import DITranquillity
import SbisServiceAPI
import SbisNavigationAPI
/// for share
public final class DemoDIShareFramework: DIFramework {
/// :nodoc:
public static func load(container: DIContainer) {
container.append(part: DemoDISharePart.self)
}
}
private final class DemoDISharePart: DIPart {
static func load(container: DIContainer) {
container.register(DemoShareContext.init)
.as(check: ShareModuleContext.self,
name: ModuleName.demoShowcase.name()) { $0 }
}
}
В основном ShareDIFramwork:
container.append(framework: DemoDIShareFramework.self)
Затем нам нужно добавить новый кейс в ShareExtensionConfigItem, DemoShareContext в презентер Share Extension’a и в switch context’ов при фильтрации.
Получаем новую опцию при шаринге картинок:
Обмен данными с основным приложением:
В завершение хотелось бы уделить внимание способам обмена информацией с основным приложением, нами используются несколько из них:
общий локальный контейнер:
Позволит вам хранить большой объем данных в виде файлов.
В приложениях СБИС обработкой данных занимается слой C++ контроллера, ему мы также сообщаем путь до этой директории при старте приложения/расширения.
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupId)
userDefaults:
Отлично подойдет для хранения настроек приложения или предпочтений пользователя (например цветовая тема), а также для другой локальной информации, такой как дата установки приложения или CookiesStorage.
UserDefaults(suiteName: groupId).object(forKey: key)
Помимо используемых нами способов, возможны обмен данными через keychain, а также использование общего CoreData, так или иначе любой способ завязан на AppGroup.
Неожиданные ограничения
Также хотелось бы отметить, что Share Extension имеет свои особенности в ограничениях по памяти (120 мб), длительности процессов и не только. Углубляться мы в это не будем, а порекомендуем вам статью со всеми подробностями.
Заключение
Не смотря на то, что на просторах интернета легко можно найти сотни гайдов по созданию Share Extension, вам нужно понимать, что большинство из этих инструкций актуальны только для самых простых и тривиальных задач.
Создание чего-либо более сложного, наполненного нелинейной бизнес логикой, принесет вам массу интересных часов дебаггинга и поиска ответов в интернете. Надеюсь, что данная статья станет одним из таких ответов или вдохновит кого-нибудь на переработку существующей реализации :)