How to Store Data in iOS: Userdefaults, Keychains, and Sandwiches

How to Store Data in iOS: Userdefaults, Keychains, and Sandwiches


This article has been translated from its original publication at https://habr.com/ru/companies/redmadrobot/articles/730316/

Data storage is a critical aspect of software development, and choosing the right tools for different scenarios is crucial. In this article, we will delve into various storage mechanisms, discuss how to effectively work with them, and examine the advantages and disadvantages of each approach. Join Anya Kocheshkova, a seasoned senior iOS developer at red_mad_robot, as she shares her expertise on data storage strategies and optimization techniques.

Why store data at all? Well, storing data is essential in software development for caching, offline mode, and saving user settings. The choice of mechanism depends on factors like task requirements and data volume. Options include UserDefaults, Keychain, Property Lists, databases, and NSCache. Each has its own strengths and trade-offs. 

  • UserDefaults is ideal for storing user settings and flags, providing a lightweight storage solution.
  • Keychain is designed for secure storage of sensitive data, such as authentication tokens and passwords.
  • Property Lists are useful for storing application configuration or libraries, offering a structured storage format.
  • Databases excel at handling large volumes of diverse data, providing robust storage capabilities.
  • NSCache is well-suited for storing temporary data in memory, optimizing performance for caching purposes.

Now let's dive into more details about how to work with each storage tool, their data storage capacities, and the pros and cons of each approach.

UserDefaults

What to store?

The simplest and most commonly used approach to data storage is through UserDefaults. It allows you to store data as key-value pairs, where the value can be any primitive type such as booleans, strings, or arrays.

UserDefaults is typically used for storing small amounts of data, such as flags, user settings, and occasionally small caches. It is not suitable for storing large datasets because UserDefaults is loaded during application startup, which can impact the startup time if there is a significant amount of data to load.

One advantage of UserDefaults is that it supports thread-safe operations, enabling you to work with the stored data from different threads without concerns about data integrity or concurrency issues.

Data Lifecycle

Data stored in UserDefaults survives application restarts but gets erased when the application is reinstalled. They are accessible only within the application where they were saved. However, there is a mechanism called app groups that allows accessing application data from extensions or other related applications.

Working with UserDefaults

The easiest way to work with UserDefaults is to use the UserDefaults.standard instance without any wrappers:

let defaults = UserDefaults.standard

Setting a value

To set a value, use the set method

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

Reading a value

To read a value, you can use type-specific read methods

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

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

You can also use a generalizing getter that returns an optional

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

Note that the generalizing getter will return nil if no value is found for the specified key. Type-specific methods will return a default value, such as false for bool or 0 for integers.

Saving custom objects

You can save custom objects by archiving them into Data type:

To de-archive the user object back from the stored Data in UserDefaults using JSON decoding, you can use the following code:

Pros

  • iCloud synchronization
  • Ease of use
  • Data sharing
  • Thread safety

Cons

  • No encryption  
  • Can accidentally overwrite a value
  • Limited suitability for large data

Saving data to a file

When it comes to saving data to a file, each application has its own document directory where files and caches are stored. The FileManager class is used to handle file operations. You can store various types of data in the application folder, such as JSON files, property lists (plist), or plain text files.

Working with Files

It is important to ensure thread-safety when working with files. 

Pros

  • iCloud synchronization
  • Large data storage

Cons

  • Risk of execution errors
  • Slower read/write operations

Property lists

What to store?

The Info.plist file is a common example of a property list file that contains essential information about the application, such as its ID, version, name, and permissions.

In addition to application settings, property lists are used by various frameworks and libraries. For instance, Firebase uses a plist file to store the configuration details of the Firebase application.

Property lists support storing a variety of primitive data types, including booleans, strings, arrays, dictionaries, and more. It's worth noting that UserDefaults, which was previously mentioned, stores data in a binary format within a plist file.

Data lifecycle

Data stored in a property list survives an application restart. However, if you need to modify the data, you must create your own property list in the document folder of the application. It's important to note that you cannot directly modify data that is located in your application's bundle, such as the Info.plist file.

Working with Plists

To access data from a property list stored in a bundle, you can use the Bundle.main API.

To read data from the standard Info.plist, you can call Bundle.main.infoDictionary and access the desired key. Alternatively, you can use the object(forInfoDictionaryKey: "key") method to retrieve specific values.

If you have a custom property list, you can access its data as follows:

Pros

  • Easy to view and read the contents
  • Can be used to create environment variables within them

Cons

  • Cannot handle voluminous or complex data

Keychain

What to store?

Keychain provides a secure storage mechanism with built-in encryption, making it an ideal choice for storing sensitive data. It is commonly used to store items such as authentication tokens, passwords, certificates, and other confidential information.

Data lifecycle

One important aspect of Keychain is that the data stored within it survives application restarts and even app deletion. This detail is often overlooked by users, and it means that when an app is reinstalled, tokens, passwords, and other stored data remain intact. This allows users to often log in to the app without needing to re-enter their credentials.

However, there may be cases where it is necessary to purge data stored in the Keychain. For example, when uninstalling an application, it may be required to reset a user's login information. Various strategies can be implemented for this purpose. One approach is to assign a unique UUID to the keys stored in the Keychain. This UUID can be stored in UserDefaults and cleared when the application is uninstalled.

Another approach is to implement logic to clear the entire Keychain upon the first launch of the application. This, however, requires more complex logic involving flags and careful handling.

Working with Keychain

Keychain has a rather complicated API, because you have to work with C types, complex and confusing documentation and constructions. That's why usually you use third-party libraries like KeychainAccess to work with it.

An example of keychain initialization:

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

You can split the Keychain into different services for easy accessibility. A separate service could be, for example, an application extension keychain. If your application extension needs to store data in the Keychain, you can move its work to a separate service to separate the main application's cache from the extension's cache.

You can also set a data access strategy. For example, whenUnlocked says that data can only be read if the device is unlocked.

You can set Keychain synchronization with iCloud. It will work if Keychain synchronization with iCloud is enabled in the device settings:

keychain.synchronizable(false) 


Read data (output type Data):  

try keychain.getData(key)


Writing and deleting:  

try keychain.set(data, key: key)  

try keychain.remove(key)

Pros

  • All data is encrypted
  • Thread safety

Cons

  • Complicated API, requiring frameworks
  • Challenging to test
  • Slower data retrieval
  • Inefficient for storing large data sets

NSCache

Data stored in NSCache typically includes items that are time-consuming to retrieve but not critical to persist. It is commonly used to cache large quantities of images, among other things.

What to store?

Data stored in NSCache typically includes items that are time-consuming to retrieve but not critical to persist. It is commonly used to cache large quantities of images, among other things.

Working with data in NSCache is thread-safe, allowing concurrent access from multiple threads without synchronization issues.

The life cycle of the data

The data stored in NSCache has an in-memory lifecycle. It remains stored as long as the application remains in memory. However, if memory pressure increases or the application is unloaded from memory, the data in the cache may be removed automatically to free up resources.

In addition to the automatic removal, developers have the flexibility to define additional rules or strategies to manage the cache within a session. These rules can be tailored to specific requirements, such as clearing the cache based on certain conditions or implementing a maximum cache size limit.

Working with NSCache

When working with NSCache, it is important to specify the type of keys and values that will be stored in the cache. For example:  

let cache = NSCache<NSString, UIImage>() 

Save and retrieve data:

You can set certain limits for NSCache to control its behavior. For example, you can define the maximum number of objects that the cache can hold by setting the countLimit property, like cache.countLimit = 10.

Additionally, you can limit the size of the cache based on the "price" of each object. The price of an object represents its cost or size and can be set when saving the object into the cache. For instance, when caching images, the price can be determined by the image's size in bytes, while for strings, it can be the length of the string.

Pros

  • Thread safety
  • Automatic data deletion and data cleaning strategies

Cons

  • Involves the use of Objective-C types, which may require bridging between Swift and Objective-C objects during the implementation.

Databases

Databases can be categorized as either relational or non-relational. Relational databases store data in tables and establish relationships between them. SQL is a commonly used language for working with relational databases. These databases adhere to ACID principles, ensuring data integrity and transactional consistency.

ACID is a set of requirements that define the properties of a reliable and predictable database operation. It was formulated by Jim Gray, a renowned computer systems theory scientist, in the late 1970s. 

  1. Atomicity: A transaction in a database should be treated as an indivisible unit of work.
  2. Consistency: The database should always be in a valid and consistent state before and after a transaction. 
  3. Isolation: Concurrently executing transactions should not interfere with each other. 
  4. Durability: Once a transaction is committed, its changes should be permanent and survive any subsequent failures, such as system crashes or power outages. 

To interact with relational databases, we utilize query languages that enable us to specify the data set we wish to retrieve.

Non-relational databases, also known as NoSQL databases, do not adhere to a rigid structure or establish explicit relationships between data. They can store diverse types of information, including documents, images, videos, and more. NoSQL databases are well-suited for handling large volumes of data, facilitating rapid development, and accommodating hypothesis testing.

There are different types of non-relational databases available. Columnar databases are designed for specific use cases such as analytics, metrics, and crash reporting systems. Document-oriented databases, like MongoDB, work with flexible document structures, enabling efficient storage and retrieval of complex data.

Non-relational databases typically conform to the BASE principles:

  1. Basic availability: Every request made to the system is guaranteed to receive a response, whether it succeeds or fails. The system remains operational and accessible to users.
  2. Soft State: The system's state can evolve over time, even without new data input. This allows for flexibility and adaptability in managing data consistency.
  3. Eventual Consistency: While data may be temporarily inconsistent across different replicas or nodes in the system, it will eventually converge to a consistent state over time. This allows for distributed systems to provide high availability and scalability while maintaining data integrity.

In iOS, developers have access to various database management systems (DBMS) for storing and managing data. Let's take a closer look at three popular options:

  • Core Data is a framework provided by Apple that enables developers to work with data in various formats, including SQL databases, XML files, or even in-memory storage. 
  • Realm is a non-relational, cross-platform DBMS 
  • SQLite is a lightweight and embedded relational DBMS that supports SQL queries

Core Data

Core Data is a powerful framework provided by Apple for interacting with various data repositories in iOS applications.

When working with Core Data, data models are created and managed using a graphical editor. This framework supports synchronization with iCloud, allowing data to be seamlessly shared across multiple devices. While Core Data is commonly used as a wrapper over SQLite, it also supports other types of storage options.

Core Data provides developers with four types of storage:

  • SQLite
  • Binary
  • In-Memory
  • XML (macOS only)

To understand the structure of Core Data, it's essential to be familiar with three key components:

  1. Model is defined by an XML file that describes the structure and relationships of the data entities.
  2. Context represents the transaction manager and serves as the entry point for interacting with data.
  3. Container is the main class that encapsulates the Core Data stack, including the model and the persistent store coordinator.

Data model

The data model in Core Data is represented by a file with the extension .xcdatamodeld. Although it is essentially an XML file, it can be easily modified using Xcode's graphical editor. The model defines the entities, their properties, and the relationships between them.

Once the data model is defined, Core Data automatically generates corresponding classes that inherit from NSManagedObject. These classes represent the entities in code and provide an object-oriented interface for working with the data. Xcode can generate these classes for you based on the data model, or you can choose to create them manually if you prefer.

Context

The NSManagedObjectContext in Core Data acts as a transaction manager. A database can have multiple contexts, with the default being the viewContext. Additionally, developers can create background contexts to handle tasks such as loading large amounts of data without blocking the main thread.

Contexts provide methods such as save and rollback, which allow for efficient management of transactions. 

Container

The NSPersistentContainer serves as the central component for managing Core Data operations. It plays a crucial role in loading the data model and handling situations where the model cannot be found or if there are missing classes for specific entities. Typically, the container is initialized with the name of the data model file and then utilizes the loadPersistentStores method to load the model and establish the necessary connections. Additionally, the NSPersistentContainer provides a viewContext property, which serves as the primary context for interacting with the data store.

Data deletion rules

Data deletion rules allow you to define the behavior for data associated with a parent entity when it is deleted. For instance, let's consider a scenario where you have a recipe category called "sandwiches" and a collection of recipes that belong to this category, specifically a list of sandwich recipes.

What do I do with recipes if a category is deleted?

  1. Nullify: This is the most common strategy. When a category is deleted, the reference to the parent is removed from the recipes, making it nil or unlinked.
  2. No Action: No specific action is taken when a category is deleted. The child objects (recipes) remain unchanged, even though their parent no longer exists.
  3. Cascade: With the cascade strategy, deleting a category will trigger the deletion of all associated recipes. It ensures that the child objects are removed along with the parent.
  4. Deny: An error occurs, preventing the deletion of the parent object until all child objects are removed or unlinked.

Working with Core Data

All Core Data classes are subclasses of NSManagedObject. These subclasses are standard Swift classes with some additional annotations and configurations.

This is what the model looks like:

The @NSManaged attribute is a special annotation used in Core Data to indicate that the property is managed by Core Data. This attribute enables Core Data to handle the property's value and track any changes made to it, ensuring proper data management and persistence.

Obtaining the NSPersistentContainer:

To save data, you need to use the context (NSManagedObjectContext) and call the save() method when you're finished. In case of an error, you can call the rollback() method, which will undo the changes made during the transaction.

Without calling the save() method, the data will not be saved.

To retrieve data from Core Data, you can use a fetch request, which enables you to apply filters or predicates.

NSPredicate is the filtering mechanism in Core Data. However, it requires specifying the filtering criteria as a string in a specific format. If there is an error in the predicate format, it can only be detected at runtime.

Here's an example:

The #keyPath function is used here to enhance security. The percentage values indicate the portions of the string that will be replaced by actual values. %K is used for paths, while %@ is used for objects. The value within %@ will be enclosed in quotation marks.

Data migration

Data migration is necessary when you make changes to the structure of your database, such as adding, deleting, renaming properties, or adding relationships to your classes. This process involves migrating to a new version of the database.

Fortunately, Core Data provides automatic migration for many types of changes. To perform a migration, you simply need to create a new version of the data model and specify the version to be used in your application.

It's important to note that running an application with an older version of the database on a device where a newer version is already in use will result in an error.

Multithreading

When working with Core Data, the context executes operations within the thread it was created in.

To ensure thread safety, Core Data provides two functions: perform and performAndWait. These functions can be called from other threads and guarantee safe execution of the provided block. performAndWait works synchronously and waits until the block has finished executing.

It's important to note that NSManagedObject subclasses cannot be passed directly between threads.

To create a background context, you can use the let context = persistentContainer.newBackgroundContext (). This initializes the context with a concurrency type of .privateQueueConcurrencyType, suitable for multithreading.

Another option for performing operations on a background context is to use performBackgroundTask, which creates the background context internally:

But it will create a new context every time it is called.

Pros

  • Automatic migrations
  • Speed
  • Convenient setup for in-memory storage
  • Graphical interface for model visualization

Cons

  • NSManagedObject subclasses
  • Thread safety complexity
  • Challenging API

Realm

Realm is a cross-platform database solution that supports multiple platforms, including Android, iOS, Xamarin, and JavaScript. It is a NoSQL database that provides a backend for synchronizing data across different sources.

Object-oriented structure

Realm is an object-oriented database where objects are updated as a whole. It works with model objects, which are subclasses of the Object class. The schema is automatically generated based on these model classes.

All class properties need to be marked as dynamic. This requirement means that properties cannot be made immutable, and the constructor for the Entity class is often empty. It's important to be cautious when adding new properties to ensure they are properly implemented in all the necessary places.

A significant disadvantage of Realm is that it stores its own data types. Enumerations are not directly supported, so they need to be converted to String or Int. Optional values need to be converted to RealmOptional, arrays to List, and backlinks to LinkedList.

Working with Realm

Set Object:

Record the object:

Read the object:

let dogs = Realm().objects(Dog)


Data migration

In Realm, migrations need to be manually written. You have to specify the new version of the schema and, if required, write code to perform the migration to the new version. However, a limitation of Realm is that it does not store the schema of individual versions, so the migration is always from the current format to the latest one.

Multithreading

Realm is designed to be thread-safe. You can read objects from different threads, but modifications should be made from a single thread. To modify objects, you need to open a write transaction and make the changes within that transaction.

Objects cannot be transferred between different threads.

Pros

  • Fast performance
  • Thread-safety
  • Cross-platform compatibility
  • Convenient and user-friendly API
  • Ease of use

Cons

  • No automatic migration
  • No graphical interface for data modeling
  • Significant library dependency
  • Necessity for data type conversions

SQLite

SQLite is a default relational database management system available on iOS. It is commonly utilized when specific query and database optimizations are required, which cannot be achieved using Core Data alone. It can be accessed directly or through various wrapper frameworks.

Working with SQLite

Open the database:

To create a table, insert or delete data, you have to use pure SQL-query language.

Here you need to pass the query as sqlString.  


For example, to create a table:

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


To insert data:

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


To query data:

let queryStatementString = "SELECT * FROM Contact;"


When executing these queries, you need to bind the values to the corresponding fields in the statement:

Upon receipt of the same:

Data migration

In SQLite, data migration needs to be done manually. There are no built-in mechanisms for automatic migration. 

Multithreading

SQLite can be compiled and configured to support both single-threaded and multithreaded use. You can check if SQLite is built with multithreading support by calling the sqlite3_threadsafe() function. If it returns 0, then SQLite is built as a single-threaded library.

By default, SQLite is built with multithreading support. To use SQLite in a multithreaded environment, you can create separate database connections for each thread:

Serialized

You need to specify the SQLITE_OPEN_FULLMUTEX flag when opening a connection.

In this mode, SQLite employs a serialized mode where all calls to SQLite from different threads are blocked and processed sequentially.

Multi-thread

You need to specify the SQLITE_OPEN_NOMUTEX flag.

In this mode, you cannot share the same connection across multiple threads simultaneously. However, you can create separate connections for each thread and use them concurrently.

Pros

  • Easy-going, fast
  • Not an external dependency
  • Open-source

Cons

  • Complex API, you need to know and use the query language
  • No data encryption
  • No automatic migrations

Bottom line

We have explored various data storage options available in iOS. We discussed UserDefaults, which is suitable for storing user settings and flags. We examined Property List configuration files and file handling in general. We also covered secure data storage in the Keychain and caching using NSCache.

Additionally, we delved into the details of several databases specifically designed for iOS:

  1. Realm: A cross-platform database known for its fast performance and user-friendly API.
  2. Core Data: A native solution offering a graphical interface for data modeling and support for different storage types. It is a convenient choice, particularly due to its testing capabilities.
  3. SQLite: A powerful option that allows direct interaction with SQL queries, without the need for additional heavyweight third-party libraries.

Each of these databases has its advantages and considerations. Realm provides simplicity and performance, Core Data offers convenience and flexibility, and SQLite enables direct SQL manipulation. The choice depends on the specific requirements of your project.

Ultimately, Core Data stands out as a recommended solution due to its comprehensive feature set, ease of use, and native integration with iOS. However, the selection should be based on the specific needs and constraints of your application.



Report Page