Flutter Beta

Learn cross-platform development with our collection of trusted high-quality Flutter tutorials!

Data Persistence on Flutter

See how to persist data to storage in a Flutter app, including to files and to a remote datastore, and use a Repository interface for the persistence.

5/5 4 Ratings

Version

  • Dart 2, Flutter 1.7, Android Studio 3.5

It’s really hard to use an app if you have to login every time you use it. Also, it is difficult if you have to reenter your data every time you use it. Thus you have to really be determined, or rather persistent, in using this particular app that always forgets you and your data. :]

In order to avoid frustrating users, your app needs to save data that survives an app restart. Saving app data to some type of storage that survives app restarts is called data persistence.

In this Flutter tutorial, you’ll:

  • Build an Alchemy-themed shopping app that persists data on different levels.
  • Learn to use Flutter’s File package.
  • Perform persistence on text, images, and classes.
  • Learn about the theory behind which persistence method to use.
  • Implement persistence on disk and online.
Note: This tutorial assumes that you’re already familiar with the basics of Flutter development. If you’re new to Flutter, read through the Getting Started With Flutter tutorial. You should also have knowledge of using Android Studio with Flutter, which you can learn about in this screencast.

Getting Started

Download the starter project by clicking on the Download Materials button at the top or bottom of the tutorial. Then, open the starter project in Android Studio 3.5 or later. You can also use VS Code, but you’ll have to adapt instructions below as needed.

You should use a recent version of Flutter, 1.7 or above. Make sure to get Flutter dependencies for the project if prompted to do so by Android Studio with a ‘Packages get’ has not been run message.

Looking at the starter code, you’ll find the starter project provides the interface and some logic for the shopping app already.

Try building and running the starter project. If you encounter issues running the project on iOS, try running pod install && pod update in Terminal at the project root. You should see the shopping cart application with a screen asking for a username, like below.

Login page with username and photo

Choose a username and photo (you may need to download a photo in your emulator or simulator browser first). Then after you log in, you should see the alchemical items available to be purchased.

Shopping Items List

Next, try using the app, e.g. adding some items to your cart. Afterwards, restart the app and you should see that you are being asked again for a username and all your shopping cart items are gone.

The app has lost your user’s data, and you will now learn how to fix this by implementing persistence.

What Kind of Persistence Can You Use

Before you can save data onto a persistence layer, you need to encode it. Typically, representations such as a string or an array of bytes are needed because persistence layers do not usually know how to handle your classes or objects.

Serialized Data and Encryption

Serialization is a way to convert a data structure, e.g. user data or an item in your shopping cart, into a string or a byte array. You will need to know how this concept works before heading into the topic of persistence. You can read about Google’s official documentation on serialization here.

Additionally, sometimes you want to protect the data you have serialized from being human readable. You can do this with encryption. There are no official docs about encryption for flutter but there are several plugins that exist. Check out these packages: encrypt, and flutter_string_encryption.

Saving Data to Memory

Once you have serialized your data, you need to know that saving it as a variable in memory does not achieve true persistence. While the app is running, the data is kept in memory, but once you restart the app, it’s gone. If you do not have another source of data, the data is lost. In contrast, saving it to disk lets you load the data again after a restart.

Flutter Persistence Options

The persistence options you have on Flutter include:

  • Key-Value Store
  • File storage
  • Local database using SQLite
  • Remote datastore

You’ll investigate three of these options in this tutorial. Saving data locally to an app database using SQLite will be left for another tutorial.

Persisting Data in a Key-Value Store

There’s multiple ways to store data in disk. For instance, one of them is using a key-value store. Accordingly, iOS and Android has native solutions for doing key-value storage in disk. Specifically, iOS has UserDefaults and Android has SharedPreferences. Instead of manually using each of them, you can use an existing Flutter package called shared_preferences that uses the appropriate one depending on the platform on which you are running.

Saving Plain Text with Key-Value Store

With this in mind, you can try storing plain text into disk using a key-value storage. Open the file lib/data/LocalKeyValuePersistence.dart. Add the _generateKey method below then go to the method saveString to add these lines:

// 1 
String _generateKey(String userId, String key) {
  return '$userId/$key';
}

@override
void saveString(String userId, String key, String value) async {
  // 2
  final prefs = await SharedPreferences.getInstance();
  // 3
  await prefs.setString(_generateKey(userId, key), value);
}

Click on SharedPreferences and hit option+return on Mac or Alt+Enter on PC to add the needed import.

In the above, you (1) add a method to generate a key from a user’s id and a key. Then (2) when saving, you get the instance for SharedPreferences. Lastly (3), you store a key-value pair onto disk using the shared_preferences Flutter plugin. The _generatedKey> method is called to make a new key for storing the string.

Note: Be sure to never persist user-sensitive information such passwords to disk. You’ll want to learn about the proper way to perform user authentication using some kind of token in a Flutter app. That’s why the sample project does not use passwords!

Reading Plain Text with Key-Value Store

Although you are now able to write a string using the key-value storage, you also need the ability to read it back. With this in mind, update getString to be the following:

@override
Future<String> getString(String userId, String key) async {
  // 1
  final prefs = await SharedPreferences.getInstance();
  // 2
  return prefs.getString(_generateKey(userId, key));
}

You are (1) again getting an instance of the SharedPreferences class from the Flutter plugin. Then (2) you are calling the getString method to return the previously stored value by providing key.

Swapping out the Persistence Layer

If you look into the LocalKeyValuePersistence class, you see it is implementing an abstract class called Repository.

class LocalKeyValuePersistence implements Repository {
  ...
}

This abstract class defines an interface for how you save your data. You can swap out different ways, say to disk, or to the cloud, or even maybe via SQLite. If you look into the file Repository.dart you will see the following:

abstract class Repository {
  void saveString(String userId, String key, String value);

  Future<String> saveImage(String userId, String key, Uint8List image);

  void saveObject(String userId, String key, Map<String, dynamic> object);

  Future<String> getString(String userId, String key);

  Future<Uint8List> getImage(String userId, String key);

  Future<Map<String, dynamic>> getObject(String userId, String key);

  Future<void> removeString(String userId, String key);

  Future<void> removeImage(String userId, String key);

  Future<void> removeObject(String userId, String key);
}

To clarify, you have save, get, and remove methods for three different types: String, Image, and Object. You also notice the presence of userId in the parameters. This allows the persistence layer to have enough information for separating data between users, since there is a login component in the app.

To use the version of Repository that saves via key-value storage, open main.dart and replace the repository parameter in Storage.create with this:

Storage.create(
  repository: LocalKeyValuePersistence(),
)

Try building and running your app. At this time, you see that when you restart the app a second time, you are no longer being asked for your username because the app remembers you now. The app goes right to the Magic Cart screen. Hooray!

Removing Plain Text with Key-Value Store

If you try to logout from the app on the cart screen, then restart the app, you will still see the previous username that you have. In order to clear this on logout, you need to implement removeString in LocalKeyValuePersistence.

@override
Future<void> removeString(String userId, String key) async {
  final prefs = await SharedPreferences.getInstance();
  await prefs.remove(_generateKey(userId, key));
}

This is very similar to previous code: it just calls the remove method on an instance of SharedPreferences using the key you provided.

Now that you added removeString, build and run the app again. Then check after logging out and restarting the app that you are no longer logged in.

Next, the question is how to store the image that you selected so that it persists even after the app restarts?

Saving Image with Key-Value Store

In order to save the image you selected, you need to serialize it. Since you are using a key-value store, one option for serialization is Base64 encoding. This encoding converts your image represented as a byte array into a string.

In order to save, get and remove images with a key-value store, update LocalKeyValuePersistence to use the following methods:

@override
Future<String> saveImage(String userId, String key, Uint8List image) async {
  // 1
  final base64Image = Base64Encoder().convert(image);
  final prefs = await SharedPreferences.getInstance();
  // 2
  await prefs.setString(_generateKey(userId, key), base64Image);
  // 3
  return key;
}

@override
Future<Uint8List> getImage(String userId, String key) async {
  final prefs = await SharedPreferences.getInstance();
  // 4
  final base64Image = prefs.getString(_generateKey(userId, key));
  // 5
  if (base64Image != null) return Base64Decoder().convert(base64Image);
  // 6
  return null;
}

Use the same keystroke as before to add the import for Base64Encoder and Base64Decoder.

You have now handled the common operations for persisting image data. Here’s what you have done:

  1. Convert the image into a Base64 string.
  2. Save the string using the generated key.
  3. Return the key as the identifier used to save the image.
  4. When getting an image, get the Base64 string of the image using key.
  5. If that was not null, convert it back to a byte array.
  6. Return null if there was no image.

Build and run the project and now you should see that your image is restored even when you restart the application. You can also implement remove on your own as an additional exercise, or check the final project in the tutorial materials.

Now that you are saving images, you also want to persist the shopping cart data.

Saving Objects into a Key-Value Store

Before saving objects to a persistence layer, you need to serialize them.

Open lib/Storage.dart and go to _saveCart. You will see the code below.

void _saveCart() async {
  await _repository.saveObject(_user.id, 'cart', _cart.toMap());
}

Notice that when saving the cart object, you call, toMap(). This serializes the cart object into a Map; check out the implementation inside lib/models/Cart.dart.

The map can be saved by serializing it further to a string. This string can then be saved, read and deleted the same way you did in the previous sections. In order to do that, update LocalKeyValuePersistence with the following methods for objects:

@override
void saveObject(String userId, String key, Map<String, dynamic> object) async {
  final prefs = await SharedPreferences.getInstance();
  // 1
  final string = JsonEncoder().convert(object);
  // 2
  await prefs.setString(_generateKey(userId, key), string);
}

@override
Future<Map<String, dynamic>> getObject(String userId, String key) async {
  final prefs = await SharedPreferences.getInstance();
  // 3
  final objectString = prefs.getString(_generateKey(userId, key));
  // 4
  if (objectString != null)
    return JsonDecoder().convert(objectString) as Map<String, dynamic>;
  return null;
}

@override
Future<void> removeObject(String userId, String key) async {
  final prefs = await SharedPreferences.getInstance();
  // 5
  prefs.remove(_generateKey(userId, key));
}

Here’s what you have done:

  1. First, convert object into a string using JsonEncoder. This encoder makes a String out of a Map.
  2. Then set that serialized object into the store.
  3. When getting an object, fetch the string using the generated key.
  4. Next, if there was a string, convert that into a Map, else return null.
  5. Lastly, when removing an object, remove using the generated key.

Build and run your project. It should now restore your cart items when you restart, or even when you logout and login. Hooray!

Do you stop now? No? Good, you persist for more learning.

Persisting Data in Disk with Files

In order to save strings, images, and objects to files, you need to serialize them as before. For strings and objects, they can be serialized to strings then saved to a file. The location of the file can depend on the user’s id and a key. The images, however can be saved directly on disk.

Flutter provides a File class. But before writing to a file, you need to get a reference to the location where you will write files. For that reason, you need to get the location of a directory, e.g. the documents directory or the temporary or another directory, of the platform you are running on.

In order to do that, you can use Flutter’s path_provider plugin. This plugin makes it possible to get the documents or temporary directory based on the platform you are running. On the other hand, reading files is the same once you have the location of the file. You can use Flutter’s File class to read the contents as a byte array.

Now that you know some theory, you should write some code. :]

But first, open main.dart and replace the repository parameter in Storage.create to use FilePersistence instead of LocalKeyValuePersistence:

Storage.create(repository: FilePersistence())

Saving Your Data to Files

When saving to a file, you need a reference to the file. You want to save inside the document directory. Open lib/data/FilePersistence.dart and add the following code:

// 1
import 'dart:io';
import 'package:path_provider/path_provider.dart';

class FilePersistence implements Repository {
  // 2
  Future<String> get _localPath async {
    final directory = await getApplicationDocumentsDirectory();
    return directory.path;
  }

  // 3
  Future<File> _localFile(String filename) async {
    final path = await _localPath;
    return File('$path/$filename');
  }

  ...
}

First, you import dart:io package that has the File class and the path_provider plugin that contains getApplicationDocumentsDirectory. Then you declare a method _localPath that returns the document path as a string. Next, you declare a _localFile method that makes a File object from a filename inside the documents directory.

You also need to have a method that will generate the filename using all the information you have, so add the following:

Future<String> getFilename(String userId, String type, String key) async {
  return userId + '/' + type + '/' + key;
}

Here you generate a path userId/type/key. For example, an object with the key cart for the userId 133t will be in l33t/object/cart file.

Now you are ready to add the persistence methods. Add the following save methods to FilePersistence.dart:

@override
Future<String> saveImage(String userId, String key, Uint8List image) async {
  // 1
  final filename = await getFilename(userId, 'images', key);
  // 2
  final file = await _localFile(filename);

  // 3
  if (!await file.parent.exists()) await file.parent.create(recursive: true);

  // 4
  await file.writeAsBytes(image);
  return filename;
}

@override
void saveObject(
    String userId, String key, Map<String, dynamic> object) async {
  final filename = await getFilename(userId, 'objects', key);
  final file = await _localFile(filename);

  if (!await file.parent.exists()) await file.parent.create(recursive: true);

  // 5
  final jsonString = JsonEncoder().convert(object);
  await file.writeAsString(jsonString);
}

@override
void saveString(String userId, String key, String value) async {
  final filename = await getFilename(userId, 'strings', key);
  final file = await _localFile(filename);

  if (!await file.parent.exists()) await file.parent.create(recursive: true);

  // 6
  await file.writeAsString(value);
}

You’ll need to add an import for JsonEncoder using the same keystroke as earlier.

With that, you have set up all the necessary things required to save to a file. Here’s what you have done:

  1. First, when saving an image, get a filename for the image using the user’s id, the type ‘images’, and a key.
  2. Then, get a file reference.
  3. Next, if the file’s parent directory does not exist, create it. Using true for the argument creates all the parent directories if they don’t exist.
  4. Save the image to the file as bytes and return the filename.
  5. When saving an object, convert the object into a string using JsonEncoder and write that to the file as a string.
  6. Lastly, when saving a string, write that string to the file.

Getting Strings, Images, and Objects from Files

Before you see any results when you build and run, you need to implement the get methods. Add the following to FilePersistence:

@override  
Future<Uint8List> getImage(String userId, String key) async {
  final filename = await getFilename(userId, 'images', key);
  final file = await _localFile(filename);

  // 1
  if (await file.exists()) return await file.readAsBytes();
  return null;
}

@override
Future<String> getString(String userId, String key) async {
  final filename = await getFilename(userId, 'strings', key);
  final file = await _localFile(filename);

  // 2
  if (await file.exists()) return await file.readAsString();
  return null;
}

@override
Future<Map<String, dynamic>> getObject(String userId, String key) async {
  final filename = await getFilename(userId, 'objects', key);
  final file = await _localFile(filename);

  // 3
  if (await file.exists()) {
    final objectString = await file.readAsString();
    return JsonDecoder().convert(objectString);
  }
  return null;
}

Here’s what you did:

  1. First, when getting an image, if the file exists, read the file as bytes and return it. Otherwise, return null.
  2. When getting a string, if the file exists, read the file as a string and return it. Otherwise, return null.
  3. Lastly, when getting an object, if the file exists, read it as a string. Then convert that string into a map using JsonDecoder. Otherwise, return null.

Build and run the application, and now you should be able to see your login and shopping cart survive an app restart. This time, with file persistence as your repository instead of a key-value store.

However, the remove methods are still empty. Without these, even if you logout, you are still logged in when you restart the app. You should implement them as an additional exercise. Implementing the remove methods will allow you to logout and see the login screen after restarting.

Persisting Data Online

At this point, you’ve tried two types of disk persistence in your project. However, say you want to access your cart data on another device by logging in. In order to do that, you should persist your data online over the network. One way to persist data online is using Google’s Firestore.

Before doing so, you need to setup Firebase in your project, for both iOS and Android. You can follow the setup steps here for Flutter. But it also includes steps to setup for both iOS and Android. Make sure you have updated the google-services.json for the Android project and the GoogleService-Info.plist file for iOS. You can do that by following the steps here.

Also, make sure to create the Cloud Firestore database and set the rules properly on the Rules tab in the console for the database. You can see an example of the rules that enables global writing below.

Note: Global writes are insecure so this is only intended for testing purposes.
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write;
    }
  }
}

Another Firebase service you will use is Firebase Storage. Make sure to follow the setup here to create a default bucket. Then set the rules like below for global write access, just for development.

Note: Global writes are insecure so this is only intended for testing purposes.
rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    match /{allPaths=**} {
      allow read, write: if true;
    }
  }
}

Another question you might have is, how do you recover your logged in session when using purely cloud persistence. That’s a good question. Without saving your login session information to disk, your other option is to store the login session information to the cloud using another user identifier.

One identifier that fits this purpose is the device ID for android and iOS. Open lib/Storage.dart and look at the deviceId method:

// 1
import 'package:device_info/device_info.dart';

Future<String> deviceId() async {
  // 2
  final DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
  if (Platform.isAndroid) {
    // 3
    final AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
    return await androidInfo.androidId;
  } else {
    // 4
    final IosDeviceInfo iosInfo = await deviceInfo.iosInfo;
    return await iosInfo.identifierForVendor;
  }
}

Here’s what that method does:

  1. Imports the device_info package.
  2. Get a reference to the device info plugin.
  3. Next, if it’s Android return the androidId inside androidInfo.
  4. If it’s iOS return the identifierForVendor inside iosInfo.

Now that you understand how the login information is persisted for your device, you can start writing the persistence methods.

But first, open main.dart and replace the repository parameter in Storage.create with a CloudPersistence object:

Storage.create(repository: CloudPersistence())

Saving Your Data Online

Start by opening lib/data/CloudPersistence.dart. Then, add imports for dart:convert,dart:convert and for the firebase_storage package:

import 'dart:convert';
import 'package:cloud_firestore/cloud_firestore.dart';  
import 'package:firebase_storage/firebase_storage.dart';

Next, update the save methods to the following:

@override  
Future<String> saveImage(String userId, String key, Uint8List image) async {
  // 1
  final StorageReference storageReference =
      FirebaseStorage().ref().child('$userId/$key');
  
  // 2
  final StorageUploadTask uploadTask = storageReference.putData(image);

  // 3
  await uploadTask.onComplete;
  return '$userId/$key';
}

@override
void saveObject(
    String userId, String key, Map<String, dynamic> object) async {
  // 4
  await Firestore.instance
      .collection('users')
      .document(userId)
      .collection('objects')
      .document(key)
      .setData(object);
}

@override
void saveString(String userId, String key, String value) async {
  // 5
  await Firestore.instance
      .collection('users')
      .document(userId)
      .collection('strings')
      .document(key)
      .setData({'string': value});
}

Here’s what you did:

  1. When saving an image, you get a reference to Firebase storage where you will save the image. The path is at userId/key.
  2. Then, you call putData on the storage reference.
  3. Next, you wait for the upload to finish. Finally return the path used for saving.
  4. When saving an object, you do a similar procedure, but instead of ‘images’, you use the collection ‘objects’. Then you set the data as object because it is already a map.
  5. Lastly, when saving a string, you do another similar procedure, but instead of ‘objects’, you use the collection ‘strings’. Then you set the data as a map containing the string.

After you’ve saved data to Firestore, you’ll be able to see the cloud persistence structure in the Firestore console.

Firestore persistence data structure

But at this point, you still need to read back your data in order to see the effect of saving it.

Reading Your Data Online

In order to read your login and cart data back, update CloudPersistence with the following methods:

// 1
@override
Future<Uint8List> getImage(String userId, String key) async {
  final maxImageBytes = 30 * 1024 * 1024;

  try {
    final StorageReference storageReference =
        FirebaseStorage().ref().child('$userId/$key');

    final bytes = await storageReference.getData(maxImageBytes);
    return bytes;
  } catch (e) {
    return null;
  }
}

// 2
@override
Future<Map<String, dynamic>> getObject(String userId, String key) async {
  final ref = await Firestore.instance
      .collection('users')
      .document(userId)
      .collection('objects')
      .document(key)
      .get();
  return ref.data;
}

// 3
@override
Future<String> getString(String userId, String key) async {
  final ref = await Firestore.instance
      .collection('users')
      .document(userId)
      .collection('strings')
      .document(key)
      .get();
  if (ref.data != null) return ref.data['string'] as String;
  return null;
}

To sum up, here’s what you did:

  1. First, when getting an image, try to get the data from Firebase storage at the same path where you saved it. Then, return it as bytes. Otherwise if there’s an exception, return null.
  2. When getting an object, read the data from users/userId/objects/key. Then return the data.
  3. When getting a string, read the data from users/userId/strings/key. Then if it exists, return ‘string’ inside the data. Otherwise return null.

Build and run your project to see that cloud persistence works like the other methods of persistence, but this time the data is saved online. You were able to easily swap from local to remote persistence thanks to your use of the Repository abstract class.

Removing Your Data Online

In order to complete the solution, you need to remove the items you stored online, e.g. when you remove them from the cart. Otherwise, you will see items in the cart with zero quantity.

In order to do this, add the following code, still in CloudPersistence/code>:

// 1
@override
Future<void> removeImage(String userId, String key) async {
  try {
    final StorageReference storageReference =
        FirebaseStorage().ref().child('$userId/$key');

    storageReference.delete();
  } catch (e) {
    return null;
  }
}

// 2
@override
Future<void> removeObject(String userId, String key) async {
  await Firestore.instance
      .collection('users')
      .document(userId)
      .collection('objects')
      .document(key)
      .delete();
}

// 3
@override
Future<void> removeString(String userId, String key) async {
  await Firestore.instance
      .collection('users')
      .document(userId)
      .collection('strings')
      .document(key)
      .delete();
}

Here you are just deleting each image, object or string on the appropriate collection and document in Firestore, and in Firebase Storage for the image.

Build and run the app again, and you’ll be able to successfully add and remove items from the cart, even across logouts.

Finally, you finished; you persisted until the end. You wrote, you read, and you deleted. Congrats!

Where to Go From Here?

You can download the completed project by clicking on the Download Materials button at the top or bottom of the tutorial.

Flutter has done a cookbook on how to do some basics of data persistence. You can check it out here.

Look for a tutorial soon on data persistence with SQLite, another way to save data locally in your app. You can also read about using SQLite in Flutter in the official docs.

I hope you enjoyed this tutorial on Data Persistence on Flutter! If you have any questions or comments, please join the forum discussion below.

Average Rating

5/5

Add a rating for this content

4 ratings

Contributors

Comments