Лекция 15. Документо-ориентированная архитектура. CS193P Spring 2023.

Ниже представлен небольшой фрагмент Лекции 15 Стэнфордского курса CS193P Весна 2023 «Разработка iOS приложений с помощью SwiftUI«.
Полный русскоязычный неавторизованный конспект Лекции 15 в формате Google Doc и в виде PDF-файла, который можно скачать и использовать offline, доступны здесь.
Код находится на GitHub.

С полным перечнем Лекций и Домашних Заданий на русском языке можно познакомиться здесь.

. . . . . . . . . . . . . .

DocumentGroup в демонстрационном примере

Вот так обычно выглядит наше приложение.

И куда нам двигаться дальше? 
Что ж, чтобы наше приложение перестало быть тем, чем оно является сейчас, давайте, кстати, посмотрим, на что наше приложение способно сейчас, пока еще не добавили поддержку документов.
Вот мое приложение, и я могу сказать: “Add another window” (“Добавить еще одно окно”), чтобы получить красивое фоновое изображение. Можно добавить сюда еще эмодзи (смайлики). 
Я  еще раз могу сказать: “Add another window” и посмотрите, что произойдет, если я кликну на  иконке нашего приложения.
Я получаю второй Emoji Art.
Итак, теперь у меня фактически есть два окна, смотрящих на один и тот же документ. Вы видите здесь два документа. Это то же самое. И у обоих есть “грузовик” 🚚. 

Это потому, что оба этих окна смотрят на один и тот же @StateObject.

Это документ EmojiArt по умолчанию — defaultDocument. Поскольку они видят одну и ту же ViewModel, они показывают вам один и тот же документ.
У каждого из них есть собственное масштабирование zoom и смещение pan, поскольку это отдельные View, они немного отличаются, но оба они просматривают один и тот же документ.
Так что это в некотором смысле довольно удобно, что вы можете иметь несколько окон, просматривающих один и тот же документ, но это не так удобно, как иметь возможность просматривать множество документов.
Итак, первое, что мы собираемся сделать, это заменить WindowGroup на DocumentGroup. Это то, что лежит в основе поведения всех документов.
И вы помните, что у DocumentGroup был аргумент config, который является его конфигурацией.
И вместо того, чтобы использовать наш документ по умолчанию defaultDocument, который у нас есть, мы просто возьмем нашу ViewModel из этой конфигурации — config.document:

И еще одна вещь, которую мы собираемся сделать, это задать здесь еще один аргумент, newDocument, который имеет небольшое замыкание, создающее новый документ.
Поэтому, когда пользователь говорит New Document, он его получает.

Я сделаю ещё две вещи. Во-первых, я могу избавиться от этого документа по умолчанию defaultDocument, потому что больше им не пользуюсь.

Теперь мой документ, моя ViewModel, создается из этих файлов документов, но вы также видите, что здесь у меня действительно важная старая ошибка. Давайте посмотрим на неё.

‘EmojiArtDocument’ needs to conform to ‘ReferenceFileDocument’.”
(“‘EmojiArtDocument’  должен соответствовать ‘ReferenceFileDocument’.)

ReferenceFileDocument — это тот протокол protocol, о котором я вам рассказывал. И как только мы реализуем его, у нас будут открывающиеся и закрывающиеся документы. Это все, что нам нужно сделать.
Давайте перейдем к EmojiArtDocument и реализуем протокол ReferenceFileDocument. В настоящее время EmojiArtDocument уже реализует протокол ObservableObject.
Мы сделаем так, чтобы он реализовал и протокол ReferenceFileDocument:

И если мы кликнем на ReferenceFileDocument, чтобы получить небольшую документацию по нему, то увидим, что это протокол, наследует от протокола ObservableObject:

Таким образом, ReferenceFileDocument могут быть только ObservableObject, только ViewModels. Это также означает, что что у нас нет необходимости определять ObservableObject дважды, потому что ReferenceFileDocument по определению также является наследником ObservableObject:

Теперь, конечно, нам нужно реализовать этот протокол. Итак, давайте воспользуемся  кнопкой “Fix«:

Теперь у нас есть некоторые из функций func и переменных var протокола ReferenceFileDocument, не все:

У нас есть readableContentTypes, это хорошо, это то, что мы собираемся заполнить с помощью EmojiArt.
И у нас есть инициализатор init для открытия документа, но мы не получили сохранения файлов fileWrapper, где они находятся. Но прежде, чем мы сможет сгенерировать их, на нужно знать этот “не важно, какой” (Generic) ТИП — этот ТИП Snapshot, о котором мы говорили на слайдах. Посмотрите на самую первую строку typealias Snapshot = type.
Что касается нашего случая, то я хочу, чтобы наш Snapshot был Data:

Повторюсь, я мог бы сделать Snapshot EmojiArt и преобразовать его в Data позже, a также выполнить JSONEncoder при создании Snapshot, обрабатывая это на другом потоке.
Как только я написал typealias Snapshot = Data, я опять получил ту же ошибку, с которой я до сих пор не согласен:

 Давайте еще раз кликнем на “Fix”.

Теперь у меня есть все функции func и переменные var, все пять элементов протокола ReferenceFileDocument: здесь вы видите snapshot, fileWrapper, readableContentTypes, инициализатор init и, конечно же, небольшой псевдоним ТИПа typealias Snapshot для того, чтобы указать, каким ТИПом мы являемся.
Кстати, как только эти функции с ТИПом Data для Snapshot добавлены, нам фактически даже не нужен этот typealias, потому что этот ТИП можно вывести из контекста, то есть из ТИПа возвращаемого значения или ТИПа аргумента:

Какая ошибка у нас здесь? 

«Cannot find type ‘UTType’«.
(“Невозможно найти ТИП ‘UTType’”)

Snapshot — это универсальный идентификатор типа. И чтобы это заработало, нам фактически нужно импортировать import UniformTypeIdentifiers модуль:

Так что не забывайте эту часть: вы будете озадачены, почему это не работает, если вы не импортируете UniformTypeIdentifiers; это совсем другой модуль.
Давайте просто пройдемся по всем нашим ошибкам:

«’static var’ declaration requires an initializer…»
(“объявление ‘static’ переменной ‘static var’ требует инициализатора.” )

Ну, конечно, у нас есть static переменная var readableContentTypes, мы не говорим чему она равна. Я хочу сделать её вычисляемой переменной собираюсь вернуть массив с тем, что может быть .jpeg:

  

… или .pdf:

… или что-то в этом роде. Я верну массив с .emojiart:

Да, кстати, это те ContentTypes, которые я хочу иметь возможность читать и писать. И, конечно, это не работает, и говорится, что у UTType нет такого члена, как .emojiart. Хотя есть .jpeg и .pdf.
Нет проблем.
Используем расширение extension для UTType, в которое в самом верху добавим static let emojiart:

Итак, я только что добавил еще один static let, и, кстати, если мы посмотрим на UTType в документации, то увидим там все, которые у нас есть, довольно много объявленных системой типов: movie (фильмы), mpeg4Audio, mpeg4Movie и много всего другого:

Итак, это все хорошо известные UTType, и мы только что добавили еще один, emojiart. И наше приложение теперь тоже широко известно.
Посмотрите, ошибок нет.

Теперь все, что нам нужно сделать, это заполнить эти маленькие серые блоки code, чтобы реализовать эти три маленькие функции, и давайте начнем сверху и будем двигаться вниз.
Функция func snapshot действительно очень простая. Мы знаем, как вернуть return нашу модель emojiArt в формате JSON как Data:

Здесь есть небольшая ошибка:

«Call can throw but it's not marked with ‘try»
(“Вызов может выбрасывать ошибки, но не помечен ‘try”)

Конечно, мы знаем, что преобразование нашего emojiArt в JSON может “выбрасывать” throw ошибки, потому что это функция func json() throws. Поэтому мы просто напишем try. Кстати, нам не нужен return, потому что это однострочный код:

Вот и все.
А как насчет функция func fileWrapper?
Что ж, нам нужно создать FileWrapper, и мы пишем в обычный файл. Так что нам нужно просто вернем return FileWrapper с одним из этих аргументов здесь внизу:

Это будет regularFileWithContents вместо directoryWithFileWrapper:

… и данные Data, которые мы собираемся предоставить здесь, являются нашим snapshot, потому что наша функция snapshot возвращает Data, а затем они передаются нам сюда:

Мы просто собираемся создать обычный файл с данными snapshot в качестве нашей файловой оболочки. Это также однострочный код и return нам не нужен:

Хорошо, это довольно легко.
А что насчет этого парня, required init?

Мы создаем EmojiArtDocument или ViewModel из этого ReadConfiguration. Давайте посмотрим на ReadConfiguration, он представляется нам как FileDocumentReadConfiguration:

Смотрим, что представляет собой FileDocumentReadConfiguration:

У структуры struct FileDocumentReadConfiguration только два свойства: contentType: UTType и file: FileWrapper.
Что касается contentType, то для нас это всегда будет .emojiart. Это единственный тип, который мы знаем, как открыть, так что это всегда будет .emojiart.
Ещё у него есть file: FileWrapper. Если мы посмотрим на FileWrapper, у него есть целая куча вещей, связанных с созданием обернутого каталога файлов. Но если мы спустимся вниз, там есть тот, который нам нужен, а именно: содержимое обычного файла regularFileContents:

Это та переменная var regularFileContents, которая нам нужна.
Если мне удастся извлечь данные Data из configuration.file, то я создам из него свой документ EmojiArtDocument.
Кстати, если это не так, то я “выброшу” throw ту ошибку CocoaError, которую вы видели на слайдах. Вы можете увидеть во всплывающем окне различные ошибки файлов, которые вы можете вызвать с помощью CocoaError, множество разных вещей:

В данном случае это похоже на поврежденный файл fileReadCorruptFile. Если мы зашли так далеко, значит, файл испорчен или что-то в этом роде. Иначе как бы мы не смогли получить regularFileContents, если запрашиваются данные Data.
A вот если у нас есть эти данные data, это данные JSON для EmojiArt, то как мы создадим модель emojiArt? Я просто собираюсь установить свою модель emojiArt равной EmojiArt, который инициализируется из JSON данных data.

Конечно же, этот инициализатор тоже может “выбрасывать” throws ошибки. Мы используем try:

И вы видите, что здесь мы “перевыбрасываем” ошибки, чтобы мне не приходилось “ловить” их с помощью блоков do { } catch { }. Можно использовать и try? с вопросительным ? знаком или что-то в этом роде чтобы “перевыбросить” ошибку выше в систему документов.
Вуаля. Итак, эти четыре строки кода делают все, чтобы наше приложение способным читать и писать документы.
Давайте запустим приложение и посмотрим.

Наше приложение выглядит совершенно по-другому. 
Нет палитры внизу. Что тут происходит? Вместо этого мы получили вот этот хороший UI для управления всеми нашими документами. И просмотрите, мы можем создать новый документ. Я говорил вам, что у нас будет UI для создания документов.
Но посмотрите на левую часть экрана.
Здесь мы можем просматривать документы на моем iPad. Вот моя директория с EmojiArt документами. Мы можем зайти сюда, и здесь будут размещаться все EmojiArt документы, но я также могу сохранять свои файлы на диске iCloud. В данный момент мой симулятор не подписан на iCloud, поэтому я его не вижу, но я могу сохранить свой EmojiArt документ в облаке iCloud так же легко, как и в локальной файловой системе.

Давайте продолжим и создадим здесь EmojiArt документ. 
Добавим к нему фоновое изображение. Получилось прекрасное фоновое изображение.
Добавим сюда еще один грузовик. 
А потом мы вернемся обратно в директорию с EmojiArt документами. 
Вот наш документ.
О!, здесь что-то страшное. Там написано 31 байт.
Я не думаю, что этого достаточно, чтобы описать этот URL и этот маленький грузовик.  И действительно, если я открою этот документ еще раз, то я потеряю все.

Почему? Нет отмены Undo.  Отсутствие отмены Undo означает отсутствие сохранения документа. Хорошо, нам нужно вернуться и реализовать отмену Undo, чтобы все это заработало.

. . . . . . . . . . . . . .

P.S.  iOS 17 Реактивный UI: Observation

Что отслеживает  @Observable?

Макрос @Observable позволяет вам строить модели Model или ViewModel так, как вы хотите. У вас могут быть массивы наблюдаемых моделей или ТИПы моделей, которые содержат другие наблюдаемые ТИПы моделей, наподобие “матрешки”. Общее правило (для @Observable ): если используемое свойство изменится, View обновится.

Для Лекций 12, 13 и 14 этого курса в примечании (P.S.) мы использовали макрос @Observable для нашей второй ViewModel класса class PaletteStore. На Лекции 15 Пол Хэгерти добавил уведомления Notification в PaletteStore.
Для варианта @Observable надо заставить свойства palette и observer игнорировать @Observable с помощью @ObservationIgnored:

Что касается var palettes, то это вычисляемая переменная с get {} и set {}, и её изменение по-любому не отслеживаются автоматически макросом @Observable, поэтому нам пришлось в ручную сделать её @Observable с помощью
.access(keyPath: \.palettes) и withMutation(keyPath: \.palettes) { }.
Отслеживание изменений свойства observer не требуется, так как это внутреннее дело ViewModel.
Внутри замыкания для NotificationCenter.default.addObserver посылка сообщения objectWillChange.send() не действует, так как мы используем макрос @Observable вместо протокола ObservableObject. Но мы можем “притвориться”, что мы изменяем одно из отслеживаемых макросом @Observable свойств и тогда наш View обновится, чего мы и хотим.
Этим свойством мы выбрали cursorIndex:

Есть прекрасное видео поясняющее эту ситуацию Nested Observables in SwiftUI.
Очень полезные статьи Comparing @Observable to ObservableObjects, SwiftUI Observable in iOS 17.

Смотрите код на Github EmojiArt L15 и EmojiArt L !5 Observation palettes.
На Лекции 15 рассматриваются следующие вопросы:

  • App и Scene протоколы
  • WindowGroup и DocumentGroup — средства создания сцен Scene
  • WindowGroup
  • DocumentGroup
  • Протоколы FileDocument и ReferenceFileDocument
  • Универсальный идентификатор типа UTType
  • Undo и UndoManager
  • Демонстрационный пример: иконка приложения
  • Project Settings для “документо-ориентированного” приложения
  • DocumentGroup в демонстрационном примере
  • Undo в демонстрационном примере: undoablyPerform в ViewModel
  • UndoManager в View
  • Undo на UI
  • Notification в демонстрационном примере

Полный русскоязычный неавторизованный конспект Лекции 15 в формате Google Doc и в виде PDF-файла, который можно скачать и использовать offline, доступны здесь.

С полным перечнем Лекций и Домашних Заданий Стэнфордского курса CS193P Весна 2023 «Разработка iOS приложений с помощью SwiftUI» на русском языке можно познакомиться здесь.