Хранение данных — одна из самых ведущих тем в разработке. Очень важно уметь выбирать подходящий инструмент для разных ситуаций и знать, как хранить данные безопасно и максимально эффективно, не замедляя приложение. Старший 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-канале. Присоединяйся!

Комментарии (1)


  1. anomaly86
    21.04.2023 11:29

    В CoreData можно расширять классы с данными через наследование. В Realm такой возможности нет.