iOS Data Persistence: A Guide for Swift Developers

iOS Data Persistence: A Guide for Swift Developers

iOSSwift
Fix bugs faster! Log Collection Made Easy
START NOW!

The term ‘data persistence’ refers to data that remains available, even when the program that created it is idle, sleeping or unable to open. In many cases, our iOS apps need to provide support around the clock, so we need our data to be ‘always on’ – even when the apps themselves are not.

In this article we’re going to look at the most commonly used tools for iOS data persistence we can use during the app development of our iOS app, providing a brief overview of each solution, an explanation of when it should (and shouldn’t) be used, and some real-world examples of how iOS data storage can be implemented.

We’ll be taking a look at:

  • UserDefaults
  • Keychain
  • FileManager
  • SQLite
  • Realm

The article will NOT cover the following topics, because the use cases for these frameworks (Code Data and Swift Data) are slightly more complex and we have dedicated guides for each of the technologies:

Let’s get started.

UserDefaults

How we use UserDefaults

UserDefaults are among the most common ways to store small chunks of structured data in our apps and we typically use them to store user settings, or certain flags that are relevant for us. Examples include theming options selected by the user, or boolean flags that allow us to ascertain whether a user has used the app before or any other user preference that need to be stored.

UserDefaults consist of a value store, which stores keys with associated values, just as Swift Dictionary does. Essentially an interface to a property list and data is stored in a .plist file.

They can also be shared between iOS apps, and synced with iCloud, but we’ll leave that for a more in-depth iOS development article.

How we deploy UserDefaults

Using UserDefaults is quite simple, as we’ll show you now:

//You set a value with a given key
UserDefaults.standard.setValue("text", forKey: "myKey")

//You then read the value from a given key at anytime
UserDefaults.standard.string(forKey: "myKey")

That’s pretty much it – you set a value, and read it.

There are several specialization methods that allow you to read the value straight to your intended type. Here’s an example:

//When reading there's several specialised methods, and you read the type you're looking for directly:
func url(forKey: String) -> URL?
func array(forKey: String) -> [Any]?
func dictionary(forKey: String) -> [String : Any]?
func string(forKey: String) -> String?
func stringArray(forKey: String) -> [String]?
func data(forKey: String) -> Data?
func integer(forKey: String) -> Int
func float(forKey: String) -> Float
func double(forKey: String) -> Double
func dictionaryRepresentation() -> [String : Any]

You can also set a given Object and read it with the generic getter, like so:

func object(forKey: String) -> Any?

Keychains

Keychains have several things in common with UserDefaults in that they also provide a place to store small chunks of app data and are quite simple to use. There is an important distinction however, specifically that Keychains are designed for sensitive information that we want to keep private and secure. While we might use UserDefaults for generic data where safe storage isn’t a major priority, we would use Keychains for passwords, keys and more sensitive data we need to protect.

Another difference is when the app is deleted from a device. When this happens, the saved Keychain items will remain stored, whereas with UserDefaults, any data on the user’s device will be deleted along with the app.

How to use Keychains

Since Keychainsrely on secure queries, using them is more complex than UserDefaults. In the example below, we’re adding a password to the Keychain:

func save(password: String) {
				// Create the Query that we will use
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,  
            kSecAttrService as String: "com.article.myApp",
            kSecAttrAccount as String: "mySecretKey",
            kSecValueData as String: password.data(using: .utf8)!
        ]
        
        // Delete any existing item with the same key
        SecItemDelete(query as CFDictionary)
        
        // Add the new item to the Keychain
        let status = SecItemAdd(query as CFDictionary, nil)
        if status != errSecSuccess {
            print("Error saving to Keychain: \(status)")
        }
    }

First we create the password query with the required elements. Each type of item saved to the Keychain will have a different set of primary required items that are used as a key to identify the item. For kSecClassGenericPassword, both kSecAttrService and kSecAttrAccount serve as a combined key to retrieve our kSecValueData.

Once we’ve created the query, it’s important to delete any older values that had the same keys and, when those those older values have been removed, we can save the new item to the Keychain.

Reading an item from the Keychain

The process for reading an item is very similar to the process for writing it, as we saw above. Let’s take a closer look:

func retrievePassword() -> String? {
        let query: [String: Any] = [
             kSecClass as String: kSecClassGenericPassword,
             kSecAttrService as String: "com.article.myApp",
             kSecAttrAccount as String: "mySecretKey",
             kSecReturnData as String: kCFBooleanTrue!,
             kSecMatchLimit as String: kSecMatchLimitOne
         ]

         var data: AnyObject?
         let status = SecItemCopyMatching(query as CFDictionary, &data)

         if status == errSecSuccess {
             return String(data: data as! Data, encoding: .utf8)
						// We are force casting data here, this is only in the article for example purposes, 
						// in the real world you should safely cast it
         } else {
             print("Error retrieving from Keychain: \(status)")
             return nil
         }
    }

As is the case when writing and saving our keychain, we need to create a query which is slightly different from the query to store the data. Once we have created the query get the data we can use it to retrieve our password. After we execute the query, we need to obtain the password converting the Data type to an utf8 String, and then we can use it as a String for anything we’d like.

File Manager

Now we’ve looked at tools for saving small chunks of data, it’s time to look at a way to save entire files. File Manager is great for everything from images to plist files.

In fact File Manager is perfect when we want to download certain resources (e.g. images) for use in an app, and retain them to be reused later.

How to use it

Imagine we want to save an image to use in our app later. If it’s a generic image, (i.e. not user-related), we can save it in the documents directory using File Manager. Here’s a way to do it:

func saveImageToDocumentsDirectory(image: UIImage, fileName: String) {
        // First, let's get the documents directory
        guard let documentsDirectory = FileManager.default.urls(for: .documentDirectory,
                                                            in: .userDomainMask).first else {
            return
        }

        // Now we append the filename to our directory
        let fileURL = documentsDirectory.appendingPathComponent(fileName)

        do {
            // We need to convert our image to a format that's savable, which is Data
            if let imageData = image.jpegData(compressionQuality: 1.0) {
                // And finally we write that data
                try imageData.write(to: fileURL)
                print("Image saved successfully at \(fileURL.path)")
            }
        } catch {
            print("Error saving image: \(error.localizedDescription)")
        }
    }

Pretty simple, right? And it’s reusable, too… we don’t even need to remember how the process goes.

When we need to read the image file, we can simply do the following:

static func loadImageFromDocumentsDirectory(fileName: String) -> UIImage? {
        // First, let's get the documents directory
        guard let documentsDirectory = FileManager.default.urls(for: .documentDirectory, 
																															in: .userDomainMask).first else {
            return nil
        }

        // Now we append the filename to our directory
        let fileURL = documentsDirectory.appendingPathComponent(fileName)

        do {
            // Afterwards we read the data from the file
            let imageData = try Data(contentsOf: fileURL)

            // We now convert it back to UIImage
            if let image = UIImage(data: imageData) {
								// And voilá, we can now return our UIImage
                return image
            } else {
                print("Error converting data to UIImage.")
                return nil
            }
        } catch {
            print("Error loading image: \(error.localizedDescription)")
            return nil
        }
    }

The process is very similar to saving. We really just need to change a couple of lines from one method to the other, after which they will start writing and reading our images.

Ok, now we’ve covered File Manager, let’s take a look at data persistence with actual database solutions.

SQLite

SQLite is an embedded database solution, comprising a lightweight, highly reliable and fast SQL relational database. Unlike the solutions we’ve covered so far, this one is more suitable for storing and querying large quantities of data.

The disadvantage, however, is that it relies on some SQL and database knowledge.

Adding data to our SQLite DB

Let’s think about how we can create a SQLite database in our app to start creating your data model:

import SQLite

func createDatabase() {
	let dbPath = try! FileManager
												.default
												.url(for: .documentDirectory, 
															in: .userDomainMask, 
									appropriateFor: nil, 
													create: false)
																		.appendingPathComponent("myDatabase.db")
																		.path

	let db = try! Connection(dbPath)

	try? db.close()
}

First we’ll need to import the SQLite Swift module. Once that’s been imported, there are only a few steps left to create the database. These are:

  • Get a path to save the database. In this case, we can use our File Manager.
  • Connect to the database. If the database already exists, it will connect to it. Otherwise it will create it in the target directory.
  • Close the connection to the database. This is considered good practice but is not necessary.

Now we know how to create a database, let’s look at creating a table on it and adding data:

import SQLite

func createDatabase() {
	let dbPath = try! FileManager
												.default
												.url(for: .documentDirectory, 
															in: .userDomainMask, 
									appropriateFor: nil, 
													create: false)
																		.appendingPathComponent("myDatabase.db")
																		.path

	let db = try! Connection(dbPath)

	let users = Table("users") //We create the Table called users
	// Now with the users table created, let's make it so like the table has 2 fields
	// id: which will be an int and used as a Primary Key
	// name: which will hold the name of our user
	let id = Expression<Int64>("id") 
	let name = Expression<String>("name")

	// We just have left to insert the table into our DB
	try! db.run(users.create { t in
    t.column(id, primaryKey: true)
    t.column(name)
	})

	// And now we can add users to it
	try! db.run(users.insert(name <- "John Doe"))

	try? db.close()

}

So it’s easier to see what we’re adding, the code we previously used has been greyed out and we’ll explain step-by-step what’s happening in the comments. To quickly sum up:

  1. Get a directory for our database.
  2. Create or get an instance of our database.
  3. Create a user’s table on it.
  4. Add the table to our database.
  5. Add a user by running an insert query.
  6. Close our database connection.

Querying data from our SQLite database

We’ve seen how the database is saved, so it’s time to look into how we can read the data we have stored. If our goal is to show the entire database, we can achieve this by simply using a for-loop:

for user in try! db.prepare(users) {
    print("User ID: \(user[id]), Name: \(user[name])")
}

Alternatively, if we want to create a query for a specific user, we’d follow the steps below:

// We would define an expression for the condition
let query = name == "Steve"

// Querying for a specific user
let targetUser = try! db.pluck(users.filter(query))

if let user = targetUser {
    print("User ID: \(user[id]), Name: \(user[name])")
} else {
    print("User not found.")
}

Putting everything together

Now we’ve looked at various things we can do in SQLite, let’s put everything together. We will:

  1. Get the path to the database
  2. Open it (or create it if it doesn’t exist)
  3. Add a person
  4. Show all records
  5. Create a query for a specific record

Here’s how:

import SQLite

func createDatabase() {
	let dbPath = try! FileManager
												.default
												.url(for: .documentDirectory, 
															in: .userDomainMask, 
									appropriateFor: nil, 
													create: false)
																		.appendingPathComponent("myDatabase.db")
																		.path

	let db = try! Connection(dbPath)

	let users = Table("users") //We create the Table called users
	// Now with the users table created, let's make it so like the table has 2 fields
	// id: which will be an int and used as a Primary Key
	// name: which will hold the name of our user
	let id = Expression<Int64>("id") 
	let name = Expression<String>("name")

	// We just have left to insert the table into our DB
	try! db.run(users.create { t in
    t.column(id, primaryKey: true)
    t.column(name)
	})

	// And now we can add users to it
	try! db.run(users.insert(name <- "John Doe"))

	for user in try! db.prepare(users) {
    print("User ID: \(user[id]), Name: \(user[name])")
		// This will print only the John Doe we just added
	}

	// We would define an expression for the condition
	let query = name == "Steve"

	// Querying for a specific user
	let targetUser = try! db.pluck(users.filter(query))

	if let user = targetUser {
    print("User ID: \(user[id]), Name: \(user[name])")
	} else {
		// This is what would be printed, since we do not have any user called Steve
    print("User not found.")
	}

	try? db.close()
}

And that’s a short example of SQLite , all wrapped up.

Realm

The final tool to persist data we’ll present in this article is the Realm database. Realm is very well-known in the mobile development world because it offers a multi-platform database solution. It is also very simple to learn and use. In this article we give you a quick introduction on how to use Realm for Swift, but you can read our more detailed article about Realm Swift and how to use it for data persistence in iOS applications.

Setting up Realm

Since we’re talking about a third-party Framework, we first need to set it up by importing it into our project. There are several ways to do this.

We can download the Framework from GitHub and drag it into our project to use as a static Framework, or we can use cocoapods, SwiftSPM, or even Carthage. For this example, we’re going to use cocoapods.

Assuming you have cocoapods set up, you can start by adding this line to your podfile:

pod 'RealmSwift'

Now just open the command line and run:

pod install

And that’s it! You now have Realm ready to use on your project.

Adding data to our Realm database

Now that we’ve set up our Framework, let’s take a look at how it is used.

First we’ll need to make a Realm Model. Our User could be defined as:

import RealmSwift

class User: Object {
    @objc dynamic var id = UUID().uuidString
    @objc dynamic var name = ""
}

With our model created, we can now execute any Create, Read, Update, Delete (CRUD) operations. Let’s save an object:

do {
		// We first open a Realm instance
		let realm = try Realm()
		
		// Create our user
		let newUser = User()
		newUser.name = "John Doe"
		
		// Save our User
		try realm.write {
		    realm.add(newTask)
		}

		// Finally we close the Realm instance
    realm.invalidate()
} catch {
    // Handle errors
    print("\(error)")
}

Can’t get much simpler than that, right?

Note that we’re using two good practices here. One is closing the Realm instance when we’re finished with the operations we’ve performed. The other is wrapping everything into a try-catch block to handle any possible errors.

Querying data from our Realm database

To query data, we simply need to Filter out our objects. For example, if we wanted to check for any users called Steve, as we did for SQLite, we could do it the following way:


do {
		// We first open a Realm instance
		let realm = try Realm()
		
		let specificUser = realm.objects(User.self).filter("name == %@", "Steve")

		// Finally we close the Realm instance
    realm.invalidate()
} catch {
    // Handle errors
    print("\(error)")
}

It’s as simple as adding elements to our database to start with. And that’s it, we’re done with Realm.

Summary

Swift offers a variety of persistence mechanisms that help us build our apps. From solutions for quickly storing small chunks of data, to proper SQL-based solutions that we can customise to our needs. As a mobile developer, it is crucial to have a comprehensive understanding of iOS data persistence to meet the demands of app development effectively.

To recap:

  • UserDefaults – great for small chunks of data, especially flags or user customization options.
  • Keychain – the best solution for any sensitive data like passwords and usernames, and any identity tokens we might want to save.
  • FileManager – great for spontaneous usage and cases where you don’t want to bundle all your assets with your app to have a smaller binary. It’s a great place to store your app resources later.
  • SQLite – takes us into the realm of fully fledged databases. This is the lowest level you can have in Swift, and it’s recommended for anyone who wants to deal with SQL directly.
  • Realm – a very easy-to-use third-party database that’s a great option for any project.

There are options for every developer, and we hope we’ve provided a solid grounding on each one we’ve discussed. If you have any questions, however, we’d love to hear from you!

Expect the Unexpected! Debug Faster with Bugfender
START FOR FREE

Trusted By

/assets/images/svg/customers/highprofile/axa.svg/assets/images/svg/customers/highprofile/volkswagen.svg/assets/images/svg/customers/projects/sk_telecom.svg/assets/images/svg/customers/cool/airmail.svg/assets/images/svg/customers/projects/vorwerk.svg/assets/images/svg/customers/highprofile/kohler.svg/assets/images/svg/customers/cool/levis.svg/assets/images/svg/customers/highprofile/deloitte.svg

Already Trusted by Thousands

Bugfender is the best remote logger for mobile and web apps.

Get Started for Free, No Credit Card Required