Лекция 12. Постоянное хранение (Persistence). Обертки свойства (Property Wrappers). CS193P Spring 2023.

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

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

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

UserDefaults для сохранения палитр palettes 

Давайте займемся нашими палитрами. 
Вы видите палитры прямо здесь?
Если я зайду в свои палитры и скажу New (новая), то добавляется математическая палитра  Math. Если я заново перезапущу приложения, то мы обнаружим, что  математическая палитра  Math исчезла. 

Приложение не помнит, что я добавил палитру  Math (Математика). 
Или, если я удалю что-то, например, Sports, ну, нам не нравятся виды спорта. 
Если мы вернемся и перезапустим наше приложение, то палитра Sports возвращается. 

Итак, мы хотим сделать так, чтобы все, что мы здесь делаем с палитрами, новые и удаленные палитры запоминались.
Мы запомним это в UserDefaults, главным образом потому, что я уже показал вам, как это сделать в файловой системе, теперь я хочу показать, как это делать в UserDefaults. Вероятно, как мы говорили ранее, это не совсем уместно делать это в UserDefaults. Но учитывая довольно маленькое количество данных, мы собираемся сделать это в UserDefaults.
Мы сделаем это очень крутым способом.
Видите мои @Published палитры palettes, которые находятся в моем PaletteStore?

Позвольте мне избавиться от этого кода и превратить palettes в вычисляемое свойство:

В get {  } мы получаем значение моего свойства из UserDefaults, поэтому я прошу UserDefaults дать мне палитры palettes для ключа c именем name моего PaletteStore.
Когда я устанавливаю set {  } моё свойство, я собираюсь установить новое значение newValue c помощью функции UserDefaults.set(newValue, forKey: name).
Если бы все работало как есть, то каждый раз, когда кто-то получает доступ к палитрам palettes, мы ищем их в UserDefaults. И каждый раз, когда мы устанавливаем значение палитрам palettes, они сохраняются в UserDefaults.
По сути, мы изменили нашу модель palettes для нашего PaletteStore на кучу палитр, хранящихся в UserDefaults.
Наша модель palettes вполне могла представлять собой базу данных SQL (таблицы и строки). Но в данном случае это UserDefaults, потому что мы храним нашу модель там напрямую.
На самом деле мы больше не храним палитры palettes как локальную переменную, мы всегда обращаемся к источнику данным, точно так же, как мы всегда делаем SQL запрос, чтобы получить данные, если бы мы хранили palettes в базе данных SQL.

Конечно, это не работает, потому что есть некоторые ошибки. 
Первая ошибка:
Property wrapper cannot be applied to a computed property
(“Обертку свойства нельзя применить к вычисляемому свойству.”)
Нам определенно нужно применить @Published, в противном случае, наш View не будет обновляться. Что же нам делать?
Для начала нам нужно понять, как работает @Published.
Что @Published реально делает? Я говорил вам, что если у тебя есть класс class, и вы реализовали протокол ObservableObject, вы получаете бесплатную версию переменной var objectWillChange:

Нам не нужно самим размещать эту переменную var в коде, мы автоматически получаем её “за кулисами”. У этой переменной var есть значение, и это та переменная, которую мы используем, чтобы сообщить View: ”Что-то изменится. Будьте готовы, возможно, вам придется обновиться”.
Вот что @Published делает. Всякий раз, когда вы меняете эту переменную, @Published вызывает функцию objectWillChange.send().
Если мы не сможем быть @Published здесь, мы должны убрать @Published, тогда мы сможем вызвать objectWillChange.send() напрямую. На самом деле нет никаких причин, чтобы не вызвать objectWillChange.send() в этом случае.
Поэтому, как только мы установили set {   } переменную var palettes, я собираюсь написать objectWillChange.send():

Вот как вы отправляете сообщение о том, что этот объект изменится. Кстати, когда вы посылаете View сообщение objectWillChange.send(), вы говорите View: “Обрати на это внимание, потому что это может измениться”, и в следующий раз, когда вы сделаете такой же “пасс”, чтобы обновить весь UI, View посмотрит на эту вещь и узнает, изменилась ли она. Так что objectWillChange.send() не всегда означает, что что-то изменится, но это значит, что на это стоит обратить внимание, мистер UI, потому что это может измениться. Вот почему objectWillChange уведомляет View о необходимости быть осторожным.

Кстати, пока я здесь, у меня нет причин отказаться от моей приятной функции в моей ViewModel, где я не позволяю массиву палитр palettes быть пустым. Помните, когда я убирал код в didSet { }, то убрал и код, который не позволял моим палитрам palettes быть пустым массивом. Позвольте мне восстановить это здесь, тем более что сделать это действительно легко.
Я могу сказать, если newValue не пусто, то мы обновляем UserDefaults. В противном случае, игнорируйте любые попытки установить эти палитры palettes в виде пустого массива:

Так что теперь у меня функционально то же самое, что было в didSet { }, но с хранением в UserDefaults. Мне не нужна эта переменная var objectWillChange, даже если я использую её, мне не нужно её декларировать, это происходит “за кулисами”. Вы можете использовать objectWillChange в любое время, когда захотите, сказав View: ”Я делаю то, на что вам (View) нужно обратить внимание, потому что это может измениться, запишите это”.

Теперь у нас ещё большая проблема, потому что я использовал функцию palettes(forKey:) в UserDefaults.
Но такой функции нет. 
Помните по слайдам?
Были функции integer(forKey:) и float(forKey:) и data(forKey:), stringArray(forKey:), но не было ничего, что называлось бы palettes(forKey:).
Это чисто моя вещь, присущая моему приложению. Итак, как мне это исправить? У кого-нибудь есть идеи, как мы можем это исправить?

Бинго.
Давайте добавим расширение extension к UserDefaults, чтобы создать там наш пользовательский ТИП. И как мы собираемся сделай это?
Мы собираемся использовать Data, преобразовав palettes как JSON, и вот так будем хранить наши палитры palettes в UserDefaults. Итак, будет действительно легко реализовать эту небольшую функцию func palettes(forKey:).
А что касается стороны сохранения palettes в UserDefaults, то достаточно предоставить небольшую функцию func set(_ palettes:, forKey:), записав на самом деле туда JSON Data.

Итак, давайте создадим расширение extension для UserDefaults, и я собираюсь добавить функцию func c названием palettes(forKey:), которая принимает ключ key и возвращает массив палитр [Palette].
И у меня будет еще одна функция func set(_ palettes: [Palette], forKey: String), которая устанавливает значения в UserDefaults:

Теперь, когда я разместил эти функции, у меня больше нет той ошибки.
Конечно, я должен реализовать эти функции, и мы собираемся начать с функции
func set(_ palettes: [Palette], forKey: String), которая, по сути, является однострочной.
Поэтому я собираюсь написать:

                 let data = try? JSONEncoder( ).encode (palettes)

То есть я пытаюсь закодировать encode (palettes) палитры palettes, a затем я установлю их в UserDefaults как JSON Data. Для этого я вызову set(data, forKey: key):

Мы устанавливаем Data в UserDefaults.
В чем здесь проблема?
JSONEncoder требует, чтобы палитра Palette соответствовала протоколу Codable. Конечно, как можно получить массив палитр [Palette] в формате JSON, если палитра Palette не является Codable?
Нет проблем, давайте пойдем в Palette и сделайте её Codable:

И как только мы это сделали, мы получили интересное предупреждение:
“Immutable property will not be decoded because  it's declared with an initial value that cannot be  overwritten”.
(“Неизменяемое свойство не будет декодировано, поскольку он объявлен с начальным значением, которое нельзя перезаписать”.)
Потому что это let. Мы сделали id константой let, это означает, что оно установлено во время создания и никогда не может быть установлено снова. Когда вы используете decode, то создаётся пустая палитра Palette, a затем устанавливаются все её свойства, вот что такое decode. У нас не может быть ни одной let в наших переменных, которые будут закодированы с помощью decode, они все должны быть var. Итак, id должна быть var:

Переменная id все равно получит это начальное значение или мы можем установить её, когда инициализируем палитру Palette, если палитра Palette будет восстанавливаться с помощью decoder, то её можно будет сбросить обратно в то, что было сохранено encoder.

Пока я здесь, я заметил еще одну ошибку, которую я использовал в этом коде — это мой builtins:

Видите? builtins — это static let, который представляет собой массив палитр [Palette].
Если бы я создал два PaletteStore с разными именами, обоим были бы установлены builtins в виде палитры с одинаковыми id, потому что builtins— это массив [Palette], когда я их создаю, они получают id.
Я собираюсь исправить это, что действительно легко , сделав builtins static переменной var, который имеет ТИП [Palette], и это будет вычисляемая переменная, возвращающая этот массив:

Это означает, что каждый раз, когда кто-то запрашивает мои builtins, создаются новые палитры Palette с новыми id.
Вернёмся в наш PaletteStore, это код должен работать сейчас:

Это происходит потому, что палитра Palette теперь стала Codable.
Что нужно сделать, чтобы получить наши палитры palettes извне? Нам просто нужно декодировать decode( ) считанные из UserDefaults данные Data. Поэтому я могу написать:

Я получаю jsonData из UserDefaults с помощью jsonData = data (forKey:key) с ключом key, который мы передаем в функцию func palettes (forKey:), и далее я использую try? для декодирования decode() массива палитр [Palette], который мы закодировали encode() ранее.
И да, совершенно законно написать [Palette].self, потому что [Palette]— это ТИП. Может быть, если бы я написал это как Array<Palette>.self, вас бы это так не удивило, но я могу написать это именно так: [Palette].self. Мы будем выполнять декодирование decode([Palette].self) из только что полученных jsonData, выполнив data(forKey: key).
Я проверяю эти вещи, и если обе эти вещи работают, тогда я смогу вернуть return эти декодированные палитры decodedPalettes. A если что-то не сработает, то есть если там ничего не хранилось или оно каким-то образом испортилось, или кто знает что, тогда я просто верну пустой массив палитр [], это нормально, только мой PaletteStore будет в этом случае пустым.
Одну вещь нам нужно сделать в нашем init:

Мы всегда устанавливаем наши палитры palettes в builtins независимо ни от чего.
Теперь мы хотим это сделать только в том случае, если палитры palettes — это пустой массив:

Итак, только если palettes.isEmpty, я буду использовать builtins; в противном случае я буду использовать все, что найду в UserDefaults.

Запускаем приложение. Среди палитр есть Vehicles (транспортные средства), удаляем палитру  Vehicles и добавляем новую палитру Math (математические операции), всё это запоминается в UserDefaults, словаре “постоянного хранения”.

Это не записывает на диск каждый раз, когда вы сохраняете данные UserDefaults, изменения буферизируются и записываются, когда UserDefaults считает нужным. Для пользователей это на самом деле не проблема, потому что он записывает данные на диск каждые несколько секунд или что-то в этом роде, но когда вы находитесь в Xcode, вы очень быстро убиваете свое приложение, и не всегда есть возможность записать данные на диск.
Как мы можем это исправить?
Лучший способ сделать это — переключиться на другое приложение. Посмотрите, если вы переключитесь на другое приложение, то логика вашего приложения будет такой: “Я больше не буду активным приложением, поэтому лучше я запишу свои UserDefaults.”
Так что это хороший способ заставить приложение сделай это. 

Возвращаемся к коду  и запускаем еще раз.
Там есть математика Math и нет машин Vehicles

Есть еще одна вещь, которую я хочу сделать здесь для чистоты кода.
Я вам говорил о том, что мы собираемся использовать UserDefaults.standard во всем нашем приложении, и мне не очень нравится ключ key для моих палитр:

… который будет “Main”, поскольку это имя моего PaletteStore в моем EmojiArt_app:

Это слово слишком общего смысла для ключа самого верхнего уровня в этом небольшой легковесном словаре. Поэтому я собираюсь сделайте это, и я настоятельно рекомендую это сделать и вам, a именно создать private переменную var, которую назову myUserDefaultsKey. Это строка String, и я вычислю ее:

Теперь я сохраняю свои палитры palettes с ключом “PaletteStore:Main” — это гораздо более уникальный ключ верхнего уровня. Давайте использовать его в качестве ключа для хранения палитр.
Если я запущу приложение, то оно потеряет все предыдущие изменения, которые я внес, потому что теперь это хранится с другим ключом. Следовательно, автомобили Vehicles вернулись, и никакой математики Math.
Давайте удалим Animal Faces, переключимся на другое приложение, заставим его  запомнить изменения в UserDefaults.
Опять вернемся в код и запустим приложение. У нас нет Animal Faces, никаких Animal Faces там нет.

Это все, что касается использовать UserDefaults, там много чего происходит. Не так много кода, но множество концептуальных вещей.

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

Это небольшой фрагмент Лекции 12.

Примечание переводчика. 

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

Способ Пола Хэгерти использования UserDefaults как вычисляемого свойства с get {} и set {} для хранения палитр palettes привел к тому, что нам пришлось отказаться от явного использования @Published

… и задействовать внутренние возможности протокола protocol ObservableObject, a именно получаемую за куликами” и “бесплатно” благодаря этому протоколу переменную var objectWillChange и её возможности явно послать View сообщение objectWillChange.send() о том, что “что-то изменилось” :

Мы знаем, что начиная с iOS 17, iPadOS 17, macOS 14, tvOS 17 и watchOS 10, SwiftUI предлагает  для Swift специальный фреймворк Observation, реализующий паттерн наблюдателя.
Использование Observation обеспечивает вашему приложению следующие преимущества:

  • Отслеживание Optional значений и коллекций Collection объектов, что невозможно при использовании ObservableObject.
  • Использование существующих примитивов потока данных, таких как @State и @Environment, вместо основанных на объектах эквивалентов, таких как @StateObject и @EnvironmentObject.
  • Обновление Views на основе изменений наблюдаемых свойств, которые считывает body конкретных View, а не любых изменений свойств, происходящих с наблюдаемым объектом, что может повысить производительность вашего приложения.

Хорошей новостью является то, что если вы используете SwiftUI, вам не нужно беспокоиться о том, что находится внутри этого фреймворка, поскольку Apple уже сделала всю тяжелую работу за нас.

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

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

Но здесь есть исключение из этого правила. Если у вычисляемого свойства отсутствуют какие-либо связанные с ним сохраненные свойства, нам нужны два дополнительных шага, чтобы включить его для наблюдения. Эта ситуация возникает только тогда, когда на наблюдаемое свойство не влияет состав хранимых свойств внутри наблюдаемого ТИПа. В таких случаях единственными необходимыми действиями являются уведомление @Observable о доступе к свойству и о его изменениях.

Обычно эти ручные настройки не нужны, поскольку большинство свойств модели обычно извлекаются из других сохраняемых свойств. SwiftUI может автоматически обнаруживать изменения в таких композициях, отслеживая наблюдаемые ТИПы посредством доступа к свойствам. Это означает, что если вычисленное свойство зависит от других сохраняемых свойств, @Observable будет работать прекрасно.

Тем не менее, в исключительных случаях, когда вычисляемая переменная позволяет нам считывать и хранить данные в ненаблюдаемых местах, a это именно наш случай с хранением палитр palettes в UserDefault, требуются расширенные функциональные возможности Observation и у вас появляется возможность напрямую явно пометить доступ к свойству и его изменение с помощью access(keyPath:) и withMutation(keyPath:).

Использование макроса @Observable для ViewModel PaletteStore

Для замены существующего исходного кода в PaletteStore, который полагается на ObservableObject, на код, использующий макрос @Observable, единственная вещь, которую вам необходимо сделать, это пометить класс class, который вы хотите сделать наблюдаемым (observable), новым Swift макросом @Observable, убрать все @Pulished и обращение к переменной var objectWillChange:

Ошибок нет, но никакие действия с палитрами palettes не будут реактивно отслеживаться в View,потому что механизм макроса @Observable, также как и протокола protocol ObservableObject, не отслеживает автоматически вычисляемые переменные, не основанные на хранимых свойствах, a у нас именно такой случай с UserDefaults.

Для этого мы будем использовать встроенные в механизм макроса @Observable функции access(keyPath:) и withMutation(keyPath:):

Теперь всё будет работать.

Конечно, надо внести изменения в Emoji_ArtApp и заменить @StateObject на @State для переменной var paletteStore и .environmentObject(paletteStore) на .environment(paletteStore):

Внести изменения в PaletteChooser и заменить @EnvironmentObject на @Environment:

Во всех предварительных просмотрах Preview заменить .environmentObject(paletteStore) на .environment(paletteStore):

Смотрите код на Github.

. . . . . . . . . . . . . .
На Лекции 12 рассматриваются следующие вопросы:

  • Постоянное хранение (persistence)
  • Файловая система
  • Доступ к файлам через URL
  • Запись и чтение данных из файлов с помощью Data
  • Протокол Codable. Расшифровка Decode и кодирование Encode
  • UserDefaults
  • Обработка ошибок
  • Демонстрационный пример
  • Обработка ошибок в AsyncImage
  • Автосохранение документа EmojiArt
  • UserDefaults для сохранения палитр palettes
  • Property Wrappers (“Обертки” свойства)
  • @Published Property Wrapper
  • @State
  • @StateObject и @ObservedObject
  • @Binding
  • @EnvironmentObject
  • @Environment

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

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