Как хранить данные в iOS: UserDefaults, Keychain и сэндвичи

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Хранение данных — одна из самых ведущих тем в разработке. Очень важно уметь выбирать подходящий инструмент для разных ситуаций и знать, как хранить данные безопасно и максимально эффективно, не замедляя приложение. Старший iOS-разработчик red_mad_robot Аня Кочешкова рассказывает о механизмах хранения, способах работы с ними, а также плюсах и минусах каждого подхода.


Зачем вообще хранить данные? Например, если нужно кешировать какую-то информацию для ускорения работы, работать в офлайн-режиме или сохранить пользовательские настройки. Выбор механизма зависит от поставленной задачи, объёма данных, которые нужно хранить, и множества других факторов. Данные можно хранить в UserDefaults, Keychain, Property Lists или же базах данных и NSCache.

UserDefaults — для хранения пользовательских настроек и флагов.
Keychain — для безопасного хранения данных.
Property Lists — для хранения конфигурации приложения или его библиотек.
Базы данных — для хранения большого объёма различных данных.
NSCache — для хранения временных данных.

А теперь подробнее: как работать с каждым инструментом, сколько хранятся данные и в чём плюсы и минусы каждого подхода.

UserDefaults

Что хранить

Самый простой и часто используемый способ хранения данных — это UserDefaults. Данные хранятся в виде пары «ключ-значение», где значением может быть любой примитив: булевая переменная, строка или даже массив данных.

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

Работа с данными в UserDefaults потокобезопасна — то есть с ним можно работать из разных потоков.

Жизненный цикл данных

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

Работа с UserDefaults

Самый простой способ — использовать UserDefaults.standard, без всяких оберток:

let defaults = UserDefaults.standard

Задать значение

Чтобы задать значение, используется метод set:  

defaults.set(22, forKey: "userAge")

Считать значение

Чтобы считать значение, можно использовать специфичные для типа методы чтения:  

let darkModeEnabled = defaults.bool(forKey: "darkModeEnabled")

let toggleStates = defaults.dictionary(forKey: "toggleStates")

Либо обобщающий геттер:  

let favoriteFruits = defaults.object(forKey: "favoriteFruits")

В отличие от остальных, этот метод вернет nil в случае, если по указанному ключу ничего не найдено. Специфичные для типа методы в этом случае вернут дефолтное значение, например, false в случае с bool или 0 в случае с integer.

Сохранять кастомные объекты

Можно сохранять и кастомные объекты. Для этого нужно архивировать их в тип Data:

let user = User(name: "Swift Guide", age: 22)

let encoder = JSONEncoder()
if let encodedUser = try? encoder.encode(user) {
    defaults.set(encodedUser, forKey: "user")
}

Деархивировать обратно:

if let savedUserData = defaults.object(forKey: "user") as? Data {
    let decoder = JSONDecoder()
    if let savedUser = try? decoder.decode(User.self, from: savedUserData) {
        ...
    }
}

Сохранение данных в файл

У каждого приложения есть своя document-директория, где хранятся файлы приложения и кеш. Работа с файлами осуществляется через FileManager. Также можно хранить какие-то данные, которые не будут изменяться, в папке приложения. Это может быть json-файл, plist или, например, обычный текстовый файл.

Работа с файлами

Учтите, что потокобезопасность при работе с файлами вам придется обеспечивать самим.

let folderURL = try FileManager.default.url(
    for: .documentDirectory,
    in: .userDomainMask,
    appropriateFor: nil,
    create: false
)

let fileURL = folderURL.appendingPathComponent(documentName) 

...

try data.write(to: fileURL)

Property lists

Что хранить

Plist — это обычный XML-файл с данными, который, как правило, хранится в бандле приложения. Обычно нам приходится работать с файлом Info.plist, который содержит настройки приложения: идентификатор, версию, название, разрешения и прочее.

Plist-файлы также использует, например, Firebase. Он хранит там конфигурацию Firebase-приложения. Хранить можно любые примитивные типы данных, такие как bool, string, массивы, словари и прочее. Кстати, в UserDefaults, о котором мы говорили выше, данные под капотом хранятся в plist-файле, только бинарного формата.

Пример проперти-листа
Пример проперти-листа

Жизненный цикл данных

Данные, хранимые в проперти-листе, переживают перезапуск приложения. Но если вы хотите изменять их, нужно будет создать свой проперти-лист в document-папке приложения. Изменять данные, которые находятся в бандле вашего приложения, например в Info.plist, нельзя.

Работа с Plists

Получить доступ к данным проперти-листа можно через бандл, используя Bundle.main.

Чтобы считать данные из стандартного Info.plist, можно вызвать Bundle.main.infoDictionary, а затем обратиться по необходимому ключу.

Можно воспользоваться также таким API: object(forInfoDictionaryKey: "key")

Если же нужно считать данные из кастомного проперти-листа, можно получить его вот так:

 if let path = Bundle.main.path(forResource: name, ofType: “plist”),
       let xml = FileManager.default.contents(atPath: path)
    {
        let plistData = try? PropertyListSerialization.propertyList(from: xml, options: .mutableContainersAndLeaves, format: nil)
        return (plistData) as? [String]
    }

Keychain

Что хранить

Keychain использует шифрование и позволяет хранить данные безопасно. Обычно здесь сохраняют токены, пароли, сертификаты и другие конфиденциальные данные.

Работа с данными в Keychain потокобезопасна
Работа с данными в Keychain потокобезопасна

Жизненный цикл данных

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

Данные можно чистить, если того требует бизнес-логика. Например, если нужно сбросить логин пользователя при удалении приложения. Для этого существует несколько стратегий — к примеру, добавление уникального UUID к хранимым в Keychain ключам. Этот UUID, в свою очередь, хранится в UserDefaults и очищается при удалении приложения.

Также можно реализовать логику очистки всего Keychain при первом запуске приложения, но это потребует довольно запутанной логики с флагами.

Работа с Keychain

У Keychain довольно сложное API, потому что приходится работать с типами из языка С, сложная и запутанная документация и конструкции. Поэтому обычно для работы с ним используются сторонние библиотеки, такие как KeychainAccess.

Пример инициализации кейчейна:

let keychain = Keychain(service: serviceName).accessibility(.whenUnlocked)

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

Также можно задать стратегию доступа к данным. Например, whenUnlocked говорит о том, что данные можно будет считать, только если устройство разблокировано.

Можно задать синхронизацию Keychain с iCloud. Она будет работать, если в настройках устройства синхронизация Keychain с iCloud включена:

keychain.synchronizable(false) 

Чтение данных (на выходе тип Data):  

try keychain.getData(key)

Запись и удаление:  

try keychain.set(data, key: key)  

try keychain.remove(key)

NSCache

Для кеширования данных в iOS есть механизм под названием NSCache.

Что хранить

В таком кеше хранятся данные, которые довольно долго получать, но не страшно потерять. Часто NSCache используется, например, для кеширования большого объёма картинок.

Работа с данными в NSCache потокобезопасна.

Жизненный цикл данных

Данные хранятся в памяти (in-memory), а при выгрузке приложения из памяти — либо при её нехватке — удаляются. Также можно задать дополнительные правила очистки кеша в рамках сессии, о которой расскажем ниже.

Работа с NSCache

При создании нужно указать тип ключа и значения, например:  

let cache = NSCache<NSString, UIImage>() 

Сохраняем и достаём данные: 

let webImage = UIImage(named: "banner.png")
cache.setObject(webImage, forKey: "banner")

...

let image = cache.object(forKey: "banner")

Можно задать некоторые ограничения, например, максимальное число объектов в кеше: cache.countLimit = 10

Размер кеша также можно отграничивать при помощи цены, а при сохранении объектов задавать цену каждого из них. Её можно ограничивать и задавать при сохранении объекта.

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

cache.totalCostLimit = 50_000_000 // bytes

...

cache.setObject(webImage, forKey: "banner", cost: bannerData.count)

Базы данных

Базы данных бывают реляционными и нереляционными. В реляционных данные хранятся в формате таблиц, они связаны между собой. Пример такого языка работы с базами данных — SQL. Такие базы данных соответствуют требованиям ACID.

ACID — это набор требований к базе данных, обеспечивающий наиболее надёжную и предсказуемую её работу. Сформулированы в конце 1970-х годов Джимом Греем, учёным в области теории вычислительных систем.

  1. Атомарность (Atomicity) — ни одна транзакция (один или несколько запросов к базе данных, объединённых в одну пачку) не будет зафиксирована частично. Либо выполняются все её подоперации, либо ни одной.

  2. Согласованность (Consistency) — транзакция должна быть валидной, а её результаты должны быть допустимыми.

  3. Изолированность (Isolation) — при выполнении транзакции параллельно выполняемые транзакции никак не должны влиять на её результат.

  4. Устойчивость (Durability) — в системе не должно быть сбоев, транзакция, которая помечена как выполненная, действительно должна быть выполнена.

Для работы с такими базами данных используется язык запросов (query language), позволяющий описывать набор данных, который мы хотим получить.

Нереляционные базы данных не имеют чёткой связи и структуры. В них может храниться множество различных документов, изображений, видео и т. д. Их также называют nosql.

Они хорошо подходят для хранения больших объёмов информации, для быстрой разработки и тестирования гипотез.

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

Такие базы данных соответствуют требованиям BASE.

  1. Базовая доступность (Basic Availability) — каждый запрос гарантированно завершится (успешно или безуспешно).

  2. Гибкое состояние (Soft State) — состояние системы может изменяться со временем, даже без ввода новых данных, для достижения согласования данных.

  3. Согласованность в конечном счёте (Eventual Consistency) — какое-то время данные могут быть рассогласованы, но приходят к согласованию через некоторое время.

В iOS можно работать с разными системами управления базами данных (СУБД). Подробнее остановимся на трёх:

  1. Core Data — фреймворк от Apple, позволяющий работать с sql, xml-файлами, либо хранить данные в памяти.

  2. Realm — нереляционная кроссплатформенная СУБД.

  3. SQLite — СУБД для базы данных sql.

Core Data

Core Data — фреймворк, позволяющий взаимодействовать с различными хранилищами. Официальное решение от Apple.

Работа с моделями данных происходит в графическом редакторе. Данные можно синхронизировать с iCloud. Как правило, используется как обертка над SQLite, но есть и другие типы хранилища.

Core Data позволяет использовать четыре типа хранилища:

  • SQLite,

  • Binary,

  • In-Memory,

  • XML (только для MacOS).

Структура: модель, контекст, контейнер

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

  1. Модель — XML-файл, описывающий структуру данных.

  2. Контекст — менеджер транзакций.

  3. Контейнер — основной класс для работы с Core Data.

Модель данных

Модель данных хранится в файле с расширением .xcdatamodeld. По сути, это просто XML-файл, но его можно изменять в графическом редакторе Xcode. Модель описывает объекты, их свойства и связи между ними.

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

Контекст

NSManagedObjectContext — по сути, менеджер транзакций. База данных может иметь несколько разных контекстов, по умолчанию используется viewContext. Также можно создавать контексты, которые выполняются в фоне (бэкграунд-контексты) для загрузки объёмных данных. У контекста есть методы save и rollback, позволяющие управлять транзакциями.

Контейнер

NSPersistentContainer — это основной класс, охватывающий работу с Core Data. В его обязанности входит загрузка модели данных, а также реагирование, если он не может её найти или отсутствуют классы для определенных сущностей. Обычно его инициализируют с именем файла модели, а затем вызывают loadPersistentStores для загрузки. Он имеет свойство viewContext.

Правила удаления данных

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

Что делать с рецептами, если удалили категорию?

  1. Nullify. Самая популярная стратегия. Связь между родителем и дочерним объектом удаляется, то есть в дочернем объекте вместо ссылки на родителя будет nil.

  2. No Action. Ничего не происходит. Дочерние объекты никак не уведомляются, а ссылка на родительский объект, который уже не существует, остаётся.

  3. Cascade. Все дочерние объекты удаляются, если удалён их родитель.

  4. Deny. При удалении объекта, имеющего связи с дочерними объектами, возникает ошибка.

Работа с Core Data

Все классы Core Data являются подклассами NSManagedObject. Это стандартные классы Swift с несколькими дополнительными аннотациями.

Так выглядит модель:

public class Transaction: NSManagedObject {
    @NSManaged public var amount: String
    @NSManaged public var createdAt: Date
    @NSManaged public var signature: String
}

NSManaged — это специальная аннотация, которая позволяет Core Data работать со свойствами особым образом. Благодаря этому атрибуту Core Data может отслеживать изменения свойств объекта и, следовательно, знать, что необходимо сохранить.

Получаем контейнер:

public lazy var persistentContainer: NSPersistentContainer = {
    let container = NSPersistentContainer(name: "DataModel")
        
    container.loadPersistentStores { description, error in
        if let error = error {
            fatalError("Unable to load persistent stores: \(error)")
        }
    }
    return container
}()

Для сохранения данных необходимо использовать контекст и по окончании вызвать метод save, а в случае ошибки — rollback, который откатит транзакцию:

let context = persistentContainer.viewContext

var person = Person(context: context)
person.age = 32

if context.hasChanges {
    do {
        try context.save()
    } catch {
        context.rollback()
    }
}

Без вызова метода save данные не будут сохранены.

Получение данных происходит при помощи запроса данных, который называется фетч-реквест. Ему можно задать фильтр, или предикат.

NSPredicate — это метод фильтрации в мире Core Data. Но его нужно указать как строку специального формата. Если допустить ошибку, узнать об этом получится только при запуске приложения.

Например:

let fetchRequest: NSFetchRequest<Content> = NSFetchRequest(entityName: "Content")
fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(Note.wasDeleted), NSNumber(value: false))
let allContentWithNames = try persistentContainer.viewContext.fetch(fetchRequest)

Здесь используется функция #keyPath для обеспечения некоторой безопасности. Значения в процентах отмечают части строки, которые должны быть заменены значением. %K — зарезервировано для путей, а %@ — для объектов. Значение внутри %@ будет заключено в кавычки.

Миграция данных

Поскольку базы данных имеют строго определённую структуру, в соответствии с которой они сохраняют данные, если решить изменить свою модель (добавить, удалить, переименовать свойства в классах или добавить отношения), то будет нужно выполнить миграцию — то есть перенести на новую версию.

К счастью, Core Data может обрабатывать множество изменений с помощью автоматической миграции.

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

Многопоточность

Контекст выполняет операции в том треде, в котором он был создан.

У него есть функции perform и performAndWait, они потокобезопасны и их можно вызывать из других потоков. performAndWait работает синхронно и дожидается выполнения переданного в него блока.

Но нужно помнить, что сабкласс NSManagedObject нельзя передавать между потоками.

Чтобы создать бэкграунд-контекст, нужно вызвать let context = persistentContainer.newBackgroundContext (). Он инициализирует контекст с типом многопоточности — .privateQueueConcurrencyType.

Ещё один способ выполнить операцию на бэкграунд-потоке — вызвать performBackgroundTask, который сам создаёт бекграунд-контекст:

persistentContainer.performBackgroundTask { (backgroundContext) in
    ...
}

Но он будет создавать новый контекст каждый раз, когда будет вызван.

Realm

Realm — это база данных для нескольких платформ. Если кратко, то это no-sql база данных для Android, iOS, Xamarin и JavaScript. У них есть backend, который позволяет синхронизировать данные из всех источников.

Структура

Realm — это объектная база данных. В отличие от Core Data, он обновляет объекты сразу. А ещё работает с объектами-моделями, которые являются сабклассами Object. И сам генерирует схему на основании этих классов.

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

Существенный минус Realm в том, что он хранит данные только в своих типах. Там нет Enum, а значит, его нужно преобразовывать в String или Int. Optional необходимо преобразовать в RealmOptional, массивы — в List, обратные ссылки — в LinkedList.

Работа с Realm

Задать объект:

class Dog: Object {
    @objc dynamic var name = ""
    @objc dynamic var age = 0
}

Записать объект:

let newDog = Dog()
newDog.name = "Earl"

let realm = Realm()
realm.write {
    realm.add(newDog)
}

Считать объект:

let dogs = Realm().objects(Dog)

Миграция данных

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

Но проблема Realm в том, что он не хранит схему отдельных версий — миграция происходит от текущего формата к самому новому.

Многопоточность

Realm потокобезопасен. Читать объекты можно из разных потоков, но модифицировать — лишь из одного. Нужно открыть транзакцию на запись и произвести изменения в ней.

Объекты нельзя передавать между разными потоками.

SQLite

SQLite — это реляционная система управления базой данных, доступна по умолчанию на iOS. Как правило, используется, если нужны оптимизации на уровне запросов и работы с базой данных, которых нельзя достичь, используя Core Data. Можно использовать напрямую либо через различные обёртки.

Работа с SQLite

Открыть базу данных:

func openDatabase() -> OpaquePointer? {
    var db: OpaquePointer?
    
    if sqlite3_open(dbPath, &db) == SQLITE_OK { 
        return db
    }
}

Чтобы создать таблицу, вставить или удалить данные, придётся использовать чистый язык SQL-запросов.

    var statement: OpaquePointer?
    if sqlite3_prepare_v2(db, sqlString, -1, &statement, nil) == SQLITE_OK {
        if sqlite3_step(statement) == SQLITE_DONE {
            ...
        }
    }
    sqlite3_finalize(statement)

Здесь в качестве sqlString нужно передать запрос.  

Например, на создание таблицы:  

let createTableString = "CREATE TABLE Contact(Id INT PRIMARY KEY NOT NULL, Name CHAR(255));"

Или на вставку:  

let insertStatementString = "INSERT INTO Contact (Id, Name) VALUES (?, ?);"

Или на выборку:  

let queryStatementString = "SELECT * FROM Contact;"

При вставке нужно связать поля с данными:  

if sqlite3_prepare_v2(db, insertStatementString, -1, &insertStatement, nil) == SQLITE_OK {
    let id: Int32 = 1
    let name: NSString = "Ray"

    sqlite3_bind_int(insertStatement, 1, id)
    sqlite3_bind_text(insertStatement, 2, name.utf8String, -1, nil)

    if sqlite3_step(insertStatement) == SQLITE_DONE {
        ...
    } 
} 

При получении так же:

    if sqlite3_prepare_v2(db, queryStatementString, -1, &queryStatement, nil) == SQLITE_OK {
        if sqlite3_step(queryStatement) == SQLITE_ROW {
    
            let id = sqlite3_column_int(queryStatement, 0)
   
            guard let queryResultCol1 = sqlite3_column_text(queryStatement, 1) else {
                return
            }
            let name = String(cString: queryResultCol1)
            ...
        }
    }

Миграция данных

Всю миграцию данных придется имплементировать самостоятельно, вручную. И точка.

Многопоточность

SQLite может быть собран как для однопоточного использования, так и для многопоточного.

Проверить, есть ли многопоточность, можно через вызов sqlite3_threadsafe (): если он вернул 0, то это однопоточный SQLite.

По умолчанию SQLite собран с поддержкой многопоточности.

Есть два способа использования многопоточного SQLite:

Итог

Мы разобрали несколько способов хранения данных в iOS. Поговорили про UserDefaults, который подходит для хранения пользовательских настроек и флагов. Обсудили конфигурационные файлы Property list и работу с файлами в целом. Разобрались с тем, как безопасно хранить данные в Keychain, и с работой с кешем, для которой подходит NSCache.

Но главное — детально разобрали несколько доступных на iOS баз данных:

  1. Кроссплатформенную Realm, известную своей быстрой работой и простым API.

  2. Нативную Core Data, которая удобна своим графическим интерфейсом моделей и разным типом хранилищ, что облегчает тестирование. Среди перечисленных — это самое удобное решение, рекомендуем именно его.

  3. И, наконец, SQLite, которая позволяет работать напрямую с языком SQL и не подтягивать в свой проект лишних тяжеловесных сторонних библиотек.

А чем в своей работе пользуетесь вы? Делитесь в комментариях.


Над материалом работали:

  • текст — Аня Кочешкова, Ника Черникова,

  • редактура — Виталик Балашов,

  • иллюстрации — Юля Ефимова.

Делимся железной экспертизой от практик в нашем телеграм-канале red_mad_dev. А полезные видео складываем на одноимённом YouTube-канале. Присоединяйся!

Источник: https://habr.com/ru/companies/redmadrobot/articles/730316/


Интересные статьи

Интересные статьи

Для успеха компании уже недостаточно, чтобы выпущенный продукт был «достаточно хорошим». Сегодня бизнесы должны предоставлять высококачественные цифровые сервисы, которые обладают не только высокой ...
Так или иначе, все реже можно найти приложение, которое не требует создания аккаунта для полноценной работы. В связи с этим возникает необходимость в некоторого рода защищенном хранилище аутентификаци...
Чуть более года назад мне посчастливилось погрузиться в углублённое изучение Redis. Всё, что я знал про него на тот момент, это две команды — get и set. Примерно в э...
На днях я наконец-то получила свой долгожданный сертификат по работе с сервисом для генерации тестовых данных GenRocket.  И теперь как сертифицированный специалист г...
Рано или поздно многие сталкиваются с необходимостью что-то массово исправить в записях таблицы. Я уже рассказывал, как это делать лучше, а как — лучше не делать. Сегодня расскажу о втором аспект...