Share extension как общий компонент

Введение

Всем привет от мобильной платформы компании «Тензор»! Меня зовут Галина и в этой статье я хочу поделиться историей развития нашего Share Extension.

За последние 3 года количество выпускаемых нами мобильных приложений значительно выросло, а в процессе их разработки увеличивались и требования к функционалу шаринга. Под каждую бизнес задачу требуются разные опции, будь то отправка фотографий в диалог или загрузка документа на диск. Не каждое наше приложение поддерживает тот или иной функционал, но и писать отдельную реализацию под новый продукт не рационально. Поэтому share extension превратился в отдельный модуль, конфигурируемый за счёт подключенных внешних зависимостей.

518fdc30c419c7e5c8d7c2710b655727.png

Освежим память

Для тех кто плотно не углублялся в ios-разработку или подзабыл, что такое Share Extension, краткий экскурс в предметную область:
Share extension — удобный интерфейс, предоставляющий пользователю возможность обмена контентом с другими приложениями, например отправка документа из одного мессенджера в другой.

Более подробно можно почитать здесь

Конфигурация таргета

Весь основной функционал шаринга хранится в отдельном модуле «ShareExtension», по сути это такой же обычный фреймворк, как и все остальные,  подключается он к приложению через СocoaPods.

Для дальнейшей работы обычно создаем соответствующий таргет и заменяем автоматически сгенерированный ShareViewController на файл-пустышку, который обязательно должен быть привязан к ранее созданному таргету ios-extension, storyboard тоже нам не понадобится.

Добавляем в Podfile информацию о таргете и его зависимостях, в info.plist расширения указываем NSExtensionPrincipalClass = ShareViewController«у из сабмодуля (в текущем случае это ShareExtension.ShareViewController).

Замена автоматически сгенерированного ShareViewController на файл-пустышку

Замена автоматически сгенерированного ShareViewController на файл-пустышку

Настройка NSExtensionPrincipalClass

Настройка 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 ().

После того как нам известно, какие опции мы можем предложить пользователю, отображаем сконфигурированное меню:

  1. отправка контакта;

  2. отправка картинки из галереи;

  3. отправка текста пользователем, у которого нет прав на диск компании;

  4. отправка документа пользователем, которому доступен только модуль каналов.

f946c35576d6a41096634bb16ea98440.png

Добавление новой опции 

Для добавления новой опции был создан демо модуль: 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’ов при фильтрации. 

Получаем новую опцию при шаринге картинок:

96849bb19f4d22f957509b538b15b0dd.pngba203ca08ea1a37db8756ce968004fa8.gif

Обмен данными с основным приложением:

В завершение хотелось бы уделить внимание способам обмена информацией с основным приложением, нами используются несколько из них:

  • общий локальный контейнер:
    Позволит вам хранить большой объем данных в виде файлов.
    В приложениях СБИС обработкой данных занимается слой C++ контроллера, ему мы также сообщаем путь до этой директории при старте приложения/расширения.

FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupId)
  • userDefaults:
    Отлично подойдет для хранения настроек приложения или предпочтений пользователя (например цветовая тема), а также для другой локальной информации, такой как дата установки приложения или CookiesStorage.

UserDefaults(suiteName: groupId).object(forKey: key)

Помимо используемых нами способов, возможны обмен данными через keychain, а также использование общего CoreData, так или иначе любой способ завязан на AppGroup.

Неожиданные ограничения

Также хотелось бы отметить, что Share Extension имеет свои особенности в ограничениях по памяти (120 мб), длительности процессов и не только. Углубляться мы в это не будем, а порекомендуем вам статью со всеми подробностями.

Заключение

Не смотря на то, что на просторах интернета легко можно найти сотни гайдов по созданию Share Extension, вам нужно понимать, что большинство из этих инструкций актуальны только для самых простых и тривиальных задач.

Создание чего-либо более сложного, наполненного нелинейной бизнес логикой, принесет вам массу интересных часов дебаггинга и поиска ответов в интернете. Надеюсь, что данная статья станет одним из таких ответов или вдохновит кого-нибудь на переработку существующей реализации :)

© Habrahabr.ru