App Extensions появились в iOS 8 и сделали систему более гибкой, мощной и доступной для пользователей. Приложения могут отображаться в виде виджета в Центре Уведомлений, предлагать свои фильтры для фотографий в Photos, отображать новую системную клавиатуру и многое другое. При этом сохранилась безопасность пользовательских данных и системы. Об особенностях работы App Extensions и пойдёт речь ниже.
Apple всегда стремилась тщательно изолировать приложения друг от друга. Это лучший способ обеспечить безопасность пользователей и защитить их данные. Каждому приложению отводится отдельное место в файловой системе с ограниченным доступом. Появление App Extensions позволило взаимодействовать с приложением без его запуска или показа на экране. Таким образом, часть его функциональности будет доступной для пользователей, когда они взаимодействуют с другими приложениями или системой.
App Extensions представляют собой исполняемые файлы, которые запускаются независимо от содержащего их приложения – Containing App. Сами по себе они не могут быть опубликованы в App Store, только вместе с Containing App. Все App Extensions выполняют одну определённую задачу и привязаны только к одной области iOS в зависимости от своего типа. Например: Custom Keyboard Extensions предназначены для замены стандартной клавиатуры, а Photo Editing Extensions — для редактирования фотографий в Photos. Всего сейчас существует 25 типов App Extensions.
Жизненный цикл App Extension
Приложение, которое пользователь использует для запуска App Extension, называется Host App. Host App запускает жизненный цикл App Extension, отправляя ему запрос в ответ на действие пользователя:
- Пользователь выбирает App Extension через Host App.
- Host App отправляет запрос App Extension.
- iOS запускает App Extension в контексте Host App и устанавливает между ними канал связи.
- Пользователь выполняет действие в App Extension.
- App Extension завершает запрос от Host App, выполняя задачу, или запускает фоновый процесс для ее выполнения; по завершении задачи результат может быть возвращен Host App.
- Как только App Extension выполнит свой код, система завершает этот App Extension.
Например, когда делимся фотографией из Photos с помощью Facebook Share Extension, Facebook является Containing App, а Photos – Host App. В этом случае Photos запускает жизненный цикл Facebook Share Extension, когда пользователь выбирает его в меню «Поделиться»:
Взаимодействие с App Extension
- Containing App – Host App
Не взаимодействуют друг с другом. - App Extension – Host App
Взаимодействуют с использованием IPC. - App Extension – Containing App
Непрямое взаимодействие. Для обмена данными используются App Groups, а для общего кода – Embedded Frameworks. Запустить Containing App из App Extension можно с помощью URL Schemes.
Общий код: динамические фреймворки
Если Containing App и App Extension используют один и тот же код, его стоит поместить в динамический фреймворк.
Например, с приложением для редактирования пользовательских фотографий может быть связано Photo Editing Extension, использующее некоторые фильтры из Containing App. Хорошим решением будет создать для этих фильтров динамический фреймворк.
Для этого добавляем новый Target и выбираем Cocoa Touch Framework:
Указываем имя (например, ImageFilters), и в панели навигатора можно увидеть новую папку с названием созданного фреймворка:
Необходимо убедиться, что фреймворк не использует API, недоступные для App Extensions:
- Shared из UIApplication.
- API, помеченные макросами недоступности.
- Камера и микрофон (кроме iMessage Extension).
- Выполнение длительных фоновых задач (особенности этого ограничения различаются в зависимости от типа App Extension).
- Получение данных с помощью AirDrop.
Использование чего-либо из этого списка в App Extensions приведёт к его отклонению при публикации в App Store.
В настройках фреймворка в General необходимо поставить галочку напротив «Allow app extension API only»:
В коде фреймворка все классы, методы и свойства, используемые в Containing App и App Extensions, должны быть public
. Везде, где нужно использовать фреймворк, делаем import
:
import ImageFilters
Обмен данными: App Groups
Containing App и App Extension имеют собственные ограниченные участки файловой системы, и только они имеют к ним доступ. Чтобы Containing App и App Extension имели общий контейнер с доступом на чтение и запись, нужно создать для них App Group.
App Group создаётся в Apple Developer Portal:
В правом верхнем углу нажимаем «+», в появившемся окне вводим необходимые данные:
Далее Continue -> Register -> Done.
В настройках Containing App переходим на вкладку Capabilities, активируем App Groups и выбираем созданную группу:
Аналогично для App Extension:
Теперь Containing App и App Extension имеют общий контейнер. Далее поговорим о том, как осуществлять в него чтение и запись.
UserDefaults
Для обмена небольшим количеством данных удобно использовать UserDefaults
, нужно лишь указать название App Group:
let sharedDefaults = UserDefaults(suiteName: "group.com.maxial.onemoreapp")
NSFileCoordinator и NSFilePresenter
Для больших данных лучше подойдёт NSFileCoordinator
, благодаря которому можно обеспечить согласованность чтения и записи. Это позволит избежать повреждения данных, так как существует вероятность, что к ним одновременно могут обращаться сразу несколько процессов.
URL-адрес общего контейнера получаем следующим образом:
let sharedUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.maxial.onemoreapp")
Запись:
fileCoordinator.coordinate(writingItemAt: sharedUrl, options: [], error: nil) { [unowned self] newUrl in
do {
let data = try NSKeyedArchiver.archivedData(withRootObject: self.object, requiringSecureCoding: false)
try data.write(to: newUrl, options: .atomic)
} catch {
print(error)
}
}
Чтение:
fileCoordinator.coordinate(readingItemAt: sharedUrl, options: [], error: nil) { newUrl in
do {
let data = try Data(contentsOf: newUrl)
if let object = try NSKeyedUnarchiver.unarchivedObject(ofClass: NSString.self, from: data) as String? {
self.object = object
}
} catch {
print(error)
}
}
Стоит учесть, что NSFileCoordinator
работает синхронно. Пока какой-либо файл будет занят некоторым процессом, другим придётся ждать его освобождения.
Если нужно, чтобы App Extension знало, когда Containing App меняет состояние данных, используется NSFilePresenter
. Это протокол, реализация которого может выглядеть следующим образом:
extension TodayViewController: NSFilePresenter {
var presentedItemURL: URL? {
let sharedUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.maxial.onemoreapp")
return sharedUrl?.appendingPathComponent("Items")
}
var presentedItemOperationQueue: OperationQueue {
return .main
}
func presentedItemDidChange() {
}
}
Свойство presentedItemOperationQueue
возвращает очередь, которая используется для обратных вызовов при изменении файлов. Метод presentedItemDidChange()
вызовется, когда какой-либо процесс, в этом случае Containing App, изменит содержимое данных. Если изменения были сделаны напрямую с помощью низкоуровневых вызовов записи, то presentedItemDidChange()
не вызывается. Учитываются только изменения с использованием NSFileCoordinator
.
При инициализации объекта NSFileCoordinator
рекомендуется передавать объект NSFilePresenter
, особенно если он запускает какую-либо файловую операцию:
let fileCoordinator = NSFileCoordinator(filePresenter: self)
Иначе объект NSFilePresenter
будет получать уведомления об этих операциях, что может привести к взаимоблокировке при работе в одном потоке.
Чтобы начать отслеживание состояния данных, нужно вызвать метод addFilePresenter(_:)
с соответствующим объектом:
NSFileCoordinator.addFilePresenter(self)
Любые созданные позже объекты NSFileCoordinator
автоматически будут знать об этом объекте NSFilePresenter
и уведомлять об изменениях, происходящих в его директории.
Чтобы перестать отслеживать состояние данных, используется removeFilePresenter(_:)
:
NSFileCoordinator.removeFilePresenter(self)
Core Data
Для совместного доступа к данным можно использовать SQLite и, соответственно, Core Data. Они умеют управлять процессами, работающими с общими данными. Чтобы настроить Core Data на совместный доступ для Containing App и App Extension, создадим подкласс NSPersistentContainer
и переопределим метод defaultDirectoryURL
, который должен возвращать адрес хранилища данных:
class SharedPersistentContainer: NSPersistentContainer {
override open class func defaultDirectoryURL() -> URL {
var storeURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.maxial.onemoreapp")
storeURL = storeURL?.appendingPathComponent("OneMoreApp.sqlite")
return storeURL!
}
}
В AppDelegate
изменим свойство persistentContainer
. Оно автоматически создаётся, если при создании проекта поставить галочку напротив Use Core Data. Теперь будем возвращать объект класса SharedPersistentContainer
:
lazy var persistentContainer: NSPersistentContainer = {
let container = SharedPersistentContainer(name: "OneMoreApp")
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
fatalError("Unresolved error (error), (error.userInfo)")
}
})
return container
}()
Остается только добавить .xcdatamodeld в App Extension. Выбираем в панели навигатора файл .xcdatamodeld. В File Inspector в разделе Target Membership ставим галочку напротив App Extension:
Таким образом, Containing App и App Extension смогут читать и записывать данные в одно хранилище и использовать одну модель.
Запуск Containing App из App Extension
Когда Host App отправляет запрос App Extension, оно предоставляет extensionContext
. Этот объект имеет метод open(_:completionHandler:)
, с помощью которого можно открыть Containing App. Однако не для всех типов App Extension доступен этот метод. В iOS он поддерживается Today Extension и iMessage Extension. iMessage Extension может использовать его только для открытия Containing App. Если Today Extension открывает с помощью него другое приложение, для отправки в App Store может потребоваться дополнительная проверка.
Чтобы открыть приложение из App Extension, нужно в Containing App определить URL Scheme:
Далее вызвать метод open(_:completionHandler:)
с этой схемой из App Extension:
guard let url = URL(string: "OneMoreAppUrl://") else { return }
extensionContext?.open(url, completionHandler: nil)
Для тех типов App Extensions, которым вызов метода open(_:completionHandler:)
недоступен, также существует способ. Но есть вероятность, что приложение может быть отклонено при проверке в App Store. Суть способа заключается в проходе по цепи объектов UIResponder
до тех пор, пока не найдётся UIApplication
, который и примет вызов openURL
:
guard let url = URL(string: "OneMoreAppUrl://") else { return }
let selectorOpenURL = sel_registerName("openURL:")
var responder: UIResponder? = self
while responder != nil {
if responder?.responds(to: selectorOpenURL) == true {
responder?.perform(selectorOpenURL, with: url)
}
responder = responder?.next
}
Будущее App Extensions
App Extensions привнесли много нового в iOS-разработку. Постепенно появляется больше типов App Extensions, развиваются их возможности. Например, с выходом iOS 12 SDK теперь можно взаимодействовать с областью контента в уведомлениях, чего так давно не хватало.
Таким образом, Apple продолжает развивать этот инструмент, что внушает оптимизм по поводу его дальнейшего будущего.
Полезные ссылки:
Официальная документация
Sharing data between iOS apps and app extensions
iOS 8 App Extension Development Tips
Автор: Максим