How to save data locally in Flutter

Introduction

Introduction

It's a rare app that doesn't need to store some sort of data, whether it's remembering the last article read, the user's email address, or the night mode setting. Both Android and iOS give us several options for how to save data locally. Flutter makes these options available to us, too. Specifically, we'll learn how to save data using the following methods:

  • Shared preferences
  • SQLite database
  • Text file

We’ll look at each one of these and go through some easy examples to help us understand them.

Prerequisites

I'm assuming that you:

  • Have the Flutter development environment set up (This tutorial was tested with Flutter 1.0.)
  • Know the way around your IDE (I'm using Android Studio, but VSCode and IntelliJ are fine, too.)
  • Have experience creating a basic Flutter app (If not see First steps with Flutter parts one, two, and three.)

Beyond that there is very little that you need to know for this lesson. I'll give you cut-and-paste code blocks for you to try out. From there you'll be able to experiment and adapt them to your own needs.

Setup

Start a new Flutter project. I’m calling mine flutter_saving_data.

Saving to shared preferences

When you have small amounts of data that you want to persist across app runs, you can use Flutter's shared_preferences plugin to save that data. Here are some examples of things you might save using shared preferences:

  • User selected color theme
  • Whether night mode is enabled
  • Preferred app language

Some data may not have been explicitly chosen by the user, but is still important to save. For example:

  • Last scroll position
  • Current tab
  • Time length already played in an audio or video file

Any customization that makes the user do less work the next time they use your app is a good candidate for shared preferences. When you want to save larger amounts of data, though, you should consider using a database.

Since shared preferences saves app related settings and defaults, the system erases that data when the user uninstalls your app. So if there are settings that should persist across installs or devices, then you should consider saving to the cloud.

In Flutter, we'll use a plugin that is a wrapper around the same underlying functionality in Android and iOS. Android calls it SharedPreferences while iOS calls it NSUserDefaults. Like Android, Flutter also calls it SharedPreferences.

The following steps will get you set up using SharedPreferences.

Dependency

Open your pubspec.yaml file and in the dependencies section add the line shared_preferences: ^0.4.3 like this:

1dependencies:
2      flutter:
3        sdk: flutter
4      shared_preferences: ^0.4.3

This tutorial is using version 0.4.3. You can find the most recent version on pub.

Note: Do you understand the meaning of the ^ caret before the version number? Based on semantic versioning, this allows the dependency to automatically update to the latest version as long as that version does not contain breaking changes to whatever version number you specified. See this post for more details.

You might have also seen the any keyword used, as in shared_preferences: any. It's better not to do this, though, since it would allow automatic updates with changes that could break your app.

Minimal example

Let's make a simple app to read and save data with SharedPreferences.

flutter-local-data-buttons

There are two buttons. One button will read from SharedPreferences. The other button will write to it. In order to keep the UI as simple as possible we will log the output using print(). In Android Studio be sure to have the Run tab selected so that you can see the output.

Replace the code in your main.dart file with the following:

1import 'package:flutter/material.dart';
2    import 'package:shared_preferences/shared_preferences.dart';
3    
4    void main() => runApp(MyApp());
5    
6    class MyApp extends StatelessWidget {
7      @override
8      Widget build(BuildContext context) {
9        return MaterialApp(
10          theme: ThemeData(primarySwatch: Colors.blue),
11          home: MyHomePage(),
12        );
13      }
14    }
15    
16    class MyHomePage extends StatelessWidget {
17      @override
18      Widget build(BuildContext context) {
19        return Scaffold(
20          appBar: AppBar(
21            title: Text('Saving data'),
22          ),
23          body: Row(
24            //mainAxisAlignment: MainAxisAlignment.center,
25            children: <Widget>[
26              Padding(
27                padding: const EdgeInsets.all(8.0),
28                child: RaisedButton(
29                  child: Text('Read'),
30                  onPressed: () {
31                    _read();
32                  },
33                ),
34              ),
35              Padding(
36                padding: const EdgeInsets.all(8.0),
37                child: RaisedButton(
38                  child: Text('Save'),
39                  onPressed: () {
40                    _save();
41                  },
42                ),
43              ),
44            ],
45          ),
46        );
47      }
48      
49      // Replace these two methods in the examples that follow
50      
51      _read() async {
52        final prefs = await SharedPreferences.getInstance();
53        final key = 'my_int_key';
54        final value = prefs.getInt(key) ?? 0;
55        print('read: $value');
56      }
57      
58      _save() async {
59        final prefs = await SharedPreferences.getInstance();
60        final key = 'my_int_key';
61        final value = 42;
62        prefs.setInt(key, value);
63        print('saved $value');
64      }
65    }

In this minimal app setup, we are going to save an integer to shared preferences. Because I am trying to make the app as simple as possible, I hard coded the integer 42 as the value to save. In a production app we would get this value from somewhere else, for example, from a text field or a scroll position or a preferred font size. See First steps with Flutter: Responding to user input for some examples of how to get user input.

Run the app and press the Read button. We are trying to read a value that has never been set, so you should see the following output:

    read: 0

Now press the Save button. This will save the integer 42 to shared preferences. Then press the Read button again. You should see the following output:

1saved: 42
2    read: 42

Even if you close the app and restart it the read value should still be 42.

Explanation

Let's take a look at the code in the _read() method from above:

1_read() async {
2      final prefs = await SharedPreferences.getInstance();
3      final key = 'my_int_key';
4      final value = prefs.getInt(key) ?? 0;
5      print('read: $value');
6    }

Notes:

  • Any read/write to data storage can be expensive so you have to do it in an async method and await the shared preference instance before trying to read from it.
  • Shared preferences use key-value pairs to save data. To get a saved integer we use the getInt() method and pass in our key to look up.
  • The ?? double question mark operator means "if null", so if the key that we are looking up doesn't exist, then we will use the default value after the ??, in this case 0.

In the _save() method above we use setInt() to save an integer value for a particular key string.

    prefs.setInt(key, value);

The SharedPreferences types that you can save are:

  • int
  • double
  • bool
  • string
  • stringList

You can see examples of these here. Play around with the code above to save some of the other types.

Saving to a database

For large amounts of data SharedPreferences is not a good option. Here are some examples of the kind of data I am talking about:

  • Names and addresses
  • A word frequency list
  • High scores

In iOS we would use Core Data and in Android we would use SQLite to store that kind of data. Because of the complexities of dealing with Core Data, when I was developing iOS apps I ended up ignoring Core Data and just using an SQLite plugin. This greatly simplified cross platform development. Flutter takes this to a whole new level by allowing us to effectively ignore the entire platform.

In Flutter we can interact with an SQLite database through a plugin called SQFlite. To set it up we will apply the following points:

  • We will keep the database management code in a database helper class. Doing so will help to isolate the plugin from the rest of the app. That way in the future if there are updates to the plugin, or if we want to use a different plugin, we will only have to update this one class. (If we wanted to further apply the principles of clean architecture, we could define an interface for the database helper class to implement. That way the rest of the app could just use the interface and know nothing about the helper class. We won't do that today, though.)

  • The database helper will be a singleton class and it will maintain a single app-wide global reference to the database, which it will keep open. This will prevent the concurrency issues and memory leaks that can occur when multiple database connections are open at the same time and not closed properly.

  • We will also create a data model class that will mirror a row in the database. Creating such a class isn't strictly necessary for teaching you how to use SQFlite, but it greatly simplifies passing data around. The class will also include some convenience methods for converting the data to and from a Map object, which SQFlite uses to interact with the database. (Note that this data model class is different than the concept of an Entity in clean architecture theory. It's just a convenient way to pass around related data.)

So here is a summary of what we will be doing:

  • Add the dependency
  • Make a data model class
  • Make a database helper class
  • Use the above classes to read and save data in our app

For our minimal example below, we will use a simple database schema. Each row in the database table will have three columns:

  • id
  • word
  • frequency

In a full app you could use these to record the frequency of every word in a book or article.

Dependencies

Open your pubspec.yaml file and in the dependencies section add the following two lines:

1sqflite: ^1.0.0
2      path_provider: ^0.4.1

The version numbers above were tested for this tutorial, but you can find the current versions at sqflite and path_provider. We will use the path provider plugin to give us the data directory where we can store the database in Android and iOS. In Android this maps to the AppData directory, and in iOS to NSDocumentsDirectory.

Database helpers file

Create a new Dart file called database_helpers.dart. Dart allows us to put multiple classes in the same file (also known as a library), so we are going to put both our data model class and our database helper class in here.

Paste the following code into the database_helpers.dart file. This is a modification of and expansion on the documentation.

1import 'dart:io';
2    import 'package:path/path.dart';
3    import 'package:sqflite/sqflite.dart';
4    import 'package:path_provider/path_provider.dart';
5    
6    // database table and column names
7    final String tableWords = 'words';
8    final String columnId = '_id';
9    final String columnWord = 'word';
10    final String columnFrequency = 'frequency';
11    
12    // data model class
13    class Word {
14    
15      int id;
16      String word;
17      int frequency;
18      
19      Word();
20      
21      // convenience constructor to create a Word object
22      Word.fromMap(Map<String, dynamic> map) {
23        id = map[columnId];
24        word = map[columnWord];
25        frequency = map[columnFrequency];
26      }
27      
28      // convenience method to create a Map from this Word object
29      Map<String, dynamic> toMap() {
30        var map = <String, dynamic>{
31          columnWord: word,
32          columnFrequency: frequency
33        };
34        if (id != null) {
35          map[columnId] = id;
36        }
37        return map;
38      }
39    }
40    
41    // singleton class to manage the database
42    class DatabaseHelper {
43    
44      // This is the actual database filename that is saved in the docs directory.
45      static final _databaseName = "MyDatabase.db";
46      // Increment this version when you need to change the schema.
47      static final _databaseVersion = 1;
48      
49      // Make this a singleton class.
50      DatabaseHelper._privateConstructor();
51      static final DatabaseHelper instance = DatabaseHelper._privateConstructor();
52      
53      // Only allow a single open connection to the database.
54      static Database _database;
55      Future<Database> get database async {
56        if (_database != null) return _database;
57        _database = await _initDatabase();
58        return _database;
59      }
60      
61      // open the database
62      _initDatabase() async {
63        // The path_provider plugin gets the right directory for Android or iOS.
64        Directory documentsDirectory = await getApplicationDocumentsDirectory();
65        String path = join(documentsDirectory.path, _databaseName);
66        // Open the database. Can also add an onUpdate callback parameter.
67        return await openDatabase(path,
68            version: _databaseVersion,
69            onCreate: _onCreate);
70      }
71      
72      // SQL string to create the database 
73      Future _onCreate(Database db, int version) async {
74        await db.execute('''
75              CREATE TABLE $tableWords (
76                $columnId INTEGER PRIMARY KEY,
77                $columnWord TEXT NOT NULL,
78                $columnFrequency INTEGER NOT NULL
79              )
80              ''');
81      }
82      
83      // Database helper methods:
84      
85      Future<int> insert(Word word) async {
86        Database db = await database;
87        int id = await db.insert(tableWords, word.toMap());
88        return id;
89      }
90      
91      Future<Word> queryWord(int id) async {
92        Database db = await database;
93        List<Map> maps = await db.query(tableWords,
94            columns: [columnId, columnWord, columnFrequency],
95            where: '$columnId = ?',
96            whereArgs: [id]);
97        if (maps.length > 0) {
98          return Word.fromMap(maps.first);
99        }
100        return null;
101      }
102      
103      // TODO: queryAllWords()
104      // TODO: delete(int id)
105      // TODO: update(Word word)
106    }

Use the database

Now open the main.dart file. We are going to use the same UI layout from the SharedPreferences example.

flutter-local-data-buttons

To keep this as simple as possible, we will be saving a hard coded word hello with a hard coded word frequency of 15 to the database. This is what we would save in a real app if we counted the word “hello” occurring 15 times in a text passage.

Replace the _read() method with

1_read() async {
2        DatabaseHelper helper = DatabaseHelper.instance;
3        int rowId = 1;
4        Word word = await helper.queryWord(rowId);
5        if (word == null) {
6          print('read row $rowId: empty');
7        } else {
8          print('read row $rowId: ${word.word} ${word.frequency}');
9        }
10      }

And replace the _save() method with

1_save() async {
2        Word word = Word();
3        word.word = 'hello';
4        word.frequency = 15;
5        DatabaseHelper helper = DatabaseHelper.instance;
6        int id = await helper.insert(word);
7        print('inserted row: $id');
8      }

You will need to import the package that you created above with the database helper and data model class.

1// I called my project `flutter_saving_data`. If you called yours something
2    // different then adjust the import location.
3    import 'package:flutter_saving_data/database_helpers.dart';

Run the app (I had to do a full stop and restart). First press the Read button, which will try to query row 1. We haven’t inserted a row yet, though, so you should see the following output:

    read row 1: empty

Now press the Save button to insert a row whose word column is hello and whose frequency column is 15. Then press the Read button again to query row 1. You should see

1inserted row: 1
2    read row 1: hello 15

Congratulations! You have written to and read from a database.

Challenge

  • Continuing to press the Save button will insert more rows. The row ID will be auto-incremented. Try changing the word and frequency of the inserted row. Then change the rowId in the _read() method to query other rows.
  • Modify the app so that when you press the Read button it will list all of the rows in the database. Hint: db.query(tableWords) returns a list of every row as a Map.
  • Modify the app so that when you press the Save button it will either update or delete an existing row. See the documentation for help.

Saving to a file

Not all data fits well in a database. Sometimes the easiest way to store it is in a file. Here are some examples of when you might want to save data in a file:

  • Exporting database content as a csv file
  • Creating a log file
  • Converting a canvas bitmap to a png file

In the minimal example below we will save a string to a text file and then read it back again.

Dependency

You should have already added the path_provider dependency to your pubspec.yaml file when you did the last section. But in case you came directly here, you can add it now:

      path_provider: ^0.4.1

Minimal example

The UI is still the same as before.

flutter-local-data-buttons

When we press the Save button it will save some text to a file. When we press the Read button it will read the text file.

Use the same main.dart file that we had above, but replace the _read() and _save() methods with the code below.

1_read() async {
2        try {
3          final directory = await getApplicationDocumentsDirectory();
4          final file = File('${directory.path}/my_file.txt');
5          String text = await file.readAsString();
6          print(text);
7        } catch (e) {
8          print("Couldn't read file");
9        }
10      }
11      
12      _save() async {
13        final directory = await getApplicationDocumentsDirectory();
14        final file = File('${directory.path}/my_file.txt');
15        final text = 'Hello World!';
16        await file.writeAsString(text);
17        print('saved');
18      }

You will have to add the following two imports:

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

Restart the app and press the Read button. The file doesn’t exist yet so you should see:

    Couldn't read file

Now press the Save button. This will create a file and save the hardcoded string Hello World! to it. Then press the Read button again. You should see the following output:

1saved
2    Hello World!

Great! We were able to read and write a text file. The File class also has writeAsBytes() and readAsBytes() methods for non-text files.

Conclusion

In this tutorial, we learned three different ways to save data locally. For small amounts of discrete data, shared preferences is a good option. If you have a long list of data items, though, a database is a better choice. In other situations, saving data in a file makes more sense. All of these are local storage options. If the app is uninstalled then the user will lose this data. To prevent data loss, you could use a cloud storage API to backup user data online. This has the added benefit of being able to sync data across devices. However, it also makes you responsible for protecting users' private data. But cloud storage is a lesson for a different day. For now continue to hone your skills at storing data locally.

The source code for this tutorial is available on GitHub. (If you got stuck on the database challenge, you can find the answers there, too.)