Site icon AppTractor

Остерегайтесь UserDefaults: история о труднопонимаемых ошибках и потерянных данных

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

Краткое вступление

Для непосвященных, UserDefaults (в оригинале NSUserDefaults) — в iOS это де-факто стандарт для сохранения не персональных небольших данных на «диске» (AKA в офлайне). Другими словами, вы храните какие-то пользовательские настройки, возможно, любимые вкусы мороженого вашего пользователя. UserDefaults — отличный вариант, который широко используется практически во всех приложениях для iOS и в примерах кода Apple. Большой объем данных или конфиденциальные данные? Храните в другом месте! Главное отличие от простого хранения в памяти в том, что  при перезапуске приложения все данные не будут потеряны.

Это действительно удобный инструмент с массой приятных встроенных функций:

Поэтому неудивительно, что он широко используется. Но да, не забывайте о двух ограничениях, которые Apple вдалбливает в голову:

В чем проблема

Оказывается, иногда вы можете запросить сохраненные данные из UserDefaults, а он… просто не захочет их получать! Это довольно большая проблема для системы, которая должна надежно хранить данные для вас.

Это может привести к еще более серьезной проблеме — к безвозвратной потере данных.

Представьте себе ситуацию, когда пользователь скрупулезно открывал ваше приложение 364 дня подряд. На 365-й день ваше приложение обещает крутое вознаграждение! Когда пользователь в последний раз закрывал приложение, вы сохранили значение 364 в UserDefaults.

Пользователь просыпается на 365-й день, предвкушая награду:

  1. Приложение запускается
  2. Приложение запрашивает UserDefaults о том, сколько дней подряд пользователь открывал приложение
  3. Приложение возвращает 0 (UserDefaults таинственным образом недоступен, поэтому его API возвращает целочисленное значение 0 по умолчанию)
  4. Наступил новый день, поэтому вы увеличиваете это значение на 1, так что 0 меняется на 1
  5. Сохраняете это новое значение обратно в UserDefaults

Вместо веселья и празднования ваш пользователь получил навсегда перезаписанные и сброшенные данные! День печали™.

Это означает, что если в какой-то момент вы доверитесь UserDefaults и посчитаете, что он точно вернет ваши данные (что, знаете ли, звучит как справедливое предположение), вы можете получить неверные данные, которые вы можете ухудшить, перезаписав хорошие данные.

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

Как это происходит

Как я понимаю, в основном две системы объединяются (и работают неправильно, если вы спросите меня), чтобы вызвать это:

1. Шифрование конфиденциальных данных

При использовании связки ключей или файлов напрямую разработчик может отметить данные, которые должны быть зашифрованы до разблокировки устройства с помощью Face ID/Touch ID/пароля. Таким образом, если вы храните на устройстве конфиденциальные данные, например токен или пароль, их содержимое будет зашифровано и, следовательно, не будет прочитано до разблокировки устройства.

Это означает, что если устройство все еще заблокировано, а у вас, скажем, есть виджет экрана блокировки, который выполняет API-запрос, вам придется показывать данные в виде плейсхолдера, пока пользователь не разблокирует устройство, потому что конфиденциальные данные, а именно API-токен пользователя, были зашифрованы и не могли быть использованы приложением для получения и отображения данных, пока пользователь не разблокирует устройство. Это не конец света, но следует иметь в виду для защищенных данных, таких как API-токены, пароли, секреты и т. д.

2. Предварительный разогрев приложений

Начиная с iOS 15, iOS будет иногда пробуждать ваше приложение раньше времени, чтобы в дальнейшем, когда пользователь запустит его, оно запустилось еще быстрее, так как iOS успела сделать часть тяжелой работы раньше. Это называется предварительным разогревом (prewarming). К счастью, Apple ваше приложение не запускает полностью, а лишь выполняет некоторые процессы, необходимые для его работы:

Предварительный разогрев выполняет последовательность запуска приложения до момента, но не включая момент, когда main() вызывает UIApplicationMain(::::).

Итак, что же произошло?

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

Опять же, кого это волнует? Пользователи должны разблокировать устройство перед запуском моего приложения, верно? Я тоже так думал! Оказывается, несмотря на то, что в документации Apple по предварительному разогреву указано обратное, разработчики уже много лет сообщают, что это просто неверно, и ваше приложение может быть полностью запущено в любой момент, в том числе ещё до разблокировки устройства.

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

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

Суть UserDefaults в том, что он должен надежно хранить простые, нечувствительные данные, чтобы к ним можно было обратиться в любой момент. Тот факт, что теперь эта ситуация кардинально изменилась, и в то же время ваше приложение может быть запущено фактически в любое время, делает ситуацию невероятно запутанной, опасной и трудной для отладки.

А с Live Activities все еще хуже

Если вы вообще используете Live Activities, новый классный API, который позволяет добавлять активности в динамический остров и экран блокировки, то, похоже, если в вашем приложении активна Live Activity и пользователь перезагружает устройство, практически в 100% случаев произойдет описанная выше ситуация, когда ваше приложение запускается в фоновом режиме без UserDefaults, которые ему недоступны. Это означает, что в следующий раз, когда пользователь действительно запустит приложение и в какой-то момент во время запуска приложения вы доверитесь содержимому UserDefaults, ваше приложение, скорее всего, окажется в некорректном состоянии с неверными данными.

Меня это сильно задело, и в течение долгого времени пользователи писали мне, что столкнулись с потерей данных, и было невероятно сложно определить причину. Оказывается, все дело в том, что приложение запускалось, предполагая, что UserDefaults вернет хорошие данные, а когда это не происходило, оно в конечном итоге перезаписывало хорошие данные плохими.

Я говорил об этом с несколькими другими разработчиками, и они также сообщали о случайных случаях выхода пользователей из системы или потери данных, и после дальнейших экспериментов они смогли определить, что именно это и было причиной их ошибки. Такое случалось и со мной в прошлых приложениях (а именно, пользователи выходили из Apollo из-за отсутствия ключа), и я никогда не мог понять причину, но это точно было оно.

Если вы когда-либо чесали голову над письмом в службу поддержки по поводу случайного сброса приложения пользователя, надеюсь, это вам поможет!

Мне это не нравится

Я не могу переоценить, насколько ошибочным я считаю этот шаг. Безопасность — это всегда баланс между удобством и безопасностью. Face ID и Touch ID идеально подходят для этого. Обе эти технологии, по собственному признанию Apple, менее безопасны, чем, скажем, 20-значный длинный пароль, но пользователи гораздо охотнее принимают биометрическую защиту, так что в целом это большая победа.

Подобное изменение UserDefaults больше похоже на «сисадмина вашей компании, требующего от вас менять пароль каждую неделю»: сомнительное повышение безопасности ценой снижения производительности и головной боли пользователей.

Кроме того, нельзя сказать, что UserDefaults действительно безопасен при таком изменении. Они шифруются только в период между перезагрузкой и разблокировкой устройства. Так что, конечно, если вы выключили телефон и кто-то включил его снова, имя вашей домашней коровы не будет известно злоумышленнику, но буквально в любое время после разблокировки устройства после первой загрузки, даже если вы заблокируете его снова, мистер Му будет под угрозой.

Обновление: После дополнительного расследования кажется, что это изменение существует уже много лет, но результаты его применения стали более распространенными благодаря таким вещам, как предварительная разогрев и Live Activities.

Я не хочу обидеть милых людей из Apple, работающих над UserDefaults, я уверен, что там есть множество унаследованных соображений, которые я не могу уловить, и которые делают эту машину сложной. Мне просто грустно, что у этого прекрасного, простого API в 2024 году будут очень острые края.

Но хватит стенать, давайте исправлять.

Решение 1

Поскольку iOS теперь, похоже, шифрует UserDefaults, самое простое решение — проверить UIApplication.isProtectedDataAvailable и, если он возвращает false, подписаться на NotificationCenter на случай срабатывания protectedDataDidBecomeAvailableNotification. Раньше это было очень полезно для того, чтобы знать, когда связка ключей или заблокированные файлы становятся доступными после разблокировки устройства, но теперь, похоже, это относится и к UserDefaults (несмотря на то, что об этом нигде не упоминается ни в его документации, ни в документации UserDefault 🙃).

Мне не нравится это решение, потому что оно фактически превращает UserDefaults либо в асинхронный API («Доступно? Нет? Хорошо, я подожду здесь, пока доступно»), либо в API, значениям которого можно доверять только иногда, потому что, в отличие, например, от Keychain API, API UserDefaults само не раскрывает никакой информации об этом, когда вы пытаетесь получить к нему доступ, а оно находится в заблокированном состоянии.

Кроме того, некоторые разработчики сообщали, что UserDefaults остается недоступным даже после того, как isProtectedDataAvailable возвращает true, предположительно, когда (по тем или иным причинам) UserDefaults не считывает дисковое хранилище обратно в память, хотя файл стал доступен.

Решение 2

По указанным причинам мне не очень нравится/я не доверяю решению 1. Мне нужна версия UserDefaults, которая работает так, как написано — просто, быстро и надежно извлекает сохраненные, нечувствительные значения. Это достаточно легко сделать самостоятельно, просто нужно помнить о некоторых вещах, которые UserDefaults делает для нас хорошо, а именно: безопасность потоков, совместное использование целей и простой API, когда он сериализует данные, не беспокоясь о записи на диск. Давайте быстро покажем, как мы можем подойти к некоторым из этих вещей.

UserDefaults — это, по сути, просто plist-файл, хранящийся на диске и считываемый в память, поэтому давайте создадим наш собственный файл, и вместо того, чтобы пометить его как требующий шифрования, как это странно делает iOS, мы скажем, что это не требуется:

// Example thing to save
let favoriteIceCream = "chocolate"

// Save to your app's shared container directory so it can be accessed by other targets outside main
let appGroupID = ""

// Get the URL for the shared container
guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID) else {
    fatalError("App Groups not set up correctly")
}

// Create the file URL within the shared container
let fileURL = containerURL.appendingPathComponent("Defaults")
    
do {
    let data = favoriteIceCream.data(using: .utf8)
    try data.write(to: fileURL)

    // No encryption please I'm just storing the name of my digital cow Mister Moo
    try FileManager.default.setAttributes([.protectionKey: .none], ofItemAtPath: fileURL.path)
    print("File saved successfully at \(fileURL)")
} catch {
    print("Error saving file: \(error.localizedDescription)")
}

(Обратите внимание, что теоретически вы могли бы изменить системный файл UserDefaults таким же образом, но документация Apple рекомендует не трогать файл UserDefaults напрямую).

Далее давайте сделаем его потокобезопасным, используя DispatchQueue.

private static let dispatchQueue = DispatchQueue(label: "DefaultsQueue")

func retrieveFavoriteIceCream() -> String? {
   return dispatchQueue.sync { 
      guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "app-group-id") else { return nil }

      let fileURL = containerURL.appendingPathComponent(fileName)
            
      do {
         let data = try Data(contentsOf: fileURL)
         return String(data: data, encoding: .utf8)
      } catch {
         print("Error retrieving file: \(error.localizedDescription)")
         return nil
      }
   }
}

func save(favoriteIceCream: String) {
   dispatchQueue.sync { 
      guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "app-group-id") else { return }

      let fileURL = containerURL.appendingPathComponent(fileName)

      do {
         let data = favoriteIceCream.data(using: .utf8)
         try data.write(to: fileURL)
         try FileManager.default.setAttributes([.protectionKey: .none], ofItemAtPath: fileURL.path)
         print("File saved successfully at \(fileURL)")
      } catch {
         print("Error saving file: \(error.localizedDescription)")
      }
   }
}

(Вероятно, для этого вам не нужна конкурентная очередь, поэтому я этого не делал).

Но с этим нам придется беспокоиться о типах данных, давайте просто сделаем так, чтобы пока тип соответствует Codable, мы могли сохранять или извлекать его:

func saveCodable(_ codable: Codable, forKey key: String) {
    do {
        let data = try JSONEncoder().encode(codable)
        // Persist raw data bytes to a file like above
    } catch {
        print("Unable to encode \(codable): \(error)")
    }
}

func codable<T: Codable>(forKey key: String, as type: T.Type) -> T? {
    let data = // Fetch raw data from disk as done above
    
    do {
        return try JSONDecoder().decode(T.self, from: data)
    } catch {
        print("Error decoding \(T.self) for key \(key) with error: \(error)")
        return nil
    }
}

// Example usage:
let newFavoriteIceCream = "strawberry"
saveCodable(newFavoriteIceCream, forKey: "favorite-ice-cream")

let savedFavoriteIceCream = codable(forKey: "favorite-ice-cream", as: String.self)

Соедините все вместе, оберните в небольшую красивую библиотеку, и бам, у вас есть замена UserDefaults, которая действует так, как вы ожидаете. На самом деле, если вам нравится опция шифрования, вы можете легко добавить ее обратно (не меняйте атрибуты защиты файлов). Вы можете сделать так, чтобы в API было четко понятно, когда данные недоступны из-за блокировки устройства, throw ошибку, сделав ваш синглтон nil, await пока устройство не будет разблокировано, и т.д.

Решение 3

Обновление! Я написал небольшую библиотеку, которая как бы упаковывает все вышеперечисленное в удобный инструмент! Она называется TinyStorage.

Конец

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

Источник

Exit mobile version