13.1 Data Persistence
# Shared Preferences
SharedPreferences
in Flutter are a lot like SessionStorage
in the browser. You create a String key for each value you want to save and then save a value associated with that key.
The primary differences are:
- that you don't have to worry about other apps or webpages interfering with your key names because all the values are connected to your app and the code that you are writing.
- When you save or retrieve the value, you need to know what the datatype is for the information that you are saving.
- The read write operations for
SharedPreferences
are asynchronous so we need to useFuture
and possiblyasync
andawait
. - While the data might exist the next time you launch the app, there is no guarantee.
To use SharedPreferences
we need to import the dart package shared_preferences from pub.dev.
Add shared_preferences: ^2.2.2
to your pubspec.yaml
dependencies list and run flutter pub get
(if VSCode does not run it for you).
Then you can import it at the top of any file that needs it.
import 'package:shared_preferences/shared_preferences.dart';
//inside an async function we will access the SharedPreferences storage for our app
final prefs = await SharedPreferences.getInstance();
2
3
4
Here is the shared_preferences reference.
Once you have a reference to the SharedPreferences
storage instance for your app then you can start saving data. The data that you save must be one of the following types: boolean, string, integer,
double, or List of Strings.
//To SAVE values use these methods
// Save an integer value to 'counter' key.
await prefs.setInt('counter', 10);
// Save an boolean value to 'repeat' key.
await prefs.setBool('repeat', true);
// Save an double value to 'decimal' key.
await prefs.setDouble('decimal', 1.5);
// Save an String value to 'action' key.
await prefs.setString('action', 'Start');
// Save an list of strings to 'items' key.
await prefs.setStringList('items', <String>['Earth', 'Moon', 'Sun']);
2
3
4
5
6
7
8
9
10
11
To read the values from your SharedPreferences
instance
// Try reading data from the 'counter' key. If it doesn't exist, returns null.
final int? counter = prefs.getInt('counter');
// Try reading data from the 'repeat' key. If it doesn't exist, returns null.
final bool? repeat = prefs.getBool('repeat');
// Try reading data from the 'decimal' key. If it doesn't exist, returns null.
final double? decimal = prefs.getDouble('decimal');
// Try reading data from the 'action' key. If it doesn't exist, returns null.
final String? action = prefs.getString('action');
// Try reading data from the 'items' key. If it doesn't exist, returns null.
final List<String>? items = prefs.getStringList('items');
2
3
4
5
6
7
8
9
10
To remove an entry from your SharedPreferences
instance, use the remove
method.
// Remove data for the 'counter' key.
final success = await prefs.remove('counter');
2
It is worth noting that the write and delete methods are asynchronous and require the await
keyword, but the read methods do not.
But, what about my big chunk of JSON, you ask? What if I have an array of numbers plus a whole host of other properties that I need to save?
The answer comes from the fact that JSON is already a String. If the data came from an API as a JSON String then you can simply use the await prefs.setString()
method.
If you have data that you have gathered from other locations, like a form, and you want to save that information in SharedPreferences
, then you can put all your information into a Map
and convert
that Map
into a JSON String using the dart:convert
package.
import 'package:shared_preferences/shared_preferences.dart';
import 'data:convert';
String convertToJsonAndSave (String key, String name, int id, String email) async{
//pass in the values you want to save as a JSON string
//plus a key to use for your saved data in SharedPreferences
//access the Shared Preferences instance for your app
final prefs = await SharedPreferences.getInstance();
//build the map to hold the parameters passed to the function
Map<String, dynamic> data = {
'id': id,
'name': name,
'email': email,
};
//convert the Map to a JSON string
String jsonStr = json.encode(data);
//write the String to SharedPreferences
await prefs.setString(key, jsonStr);
//return the saved string in case it is needed elsewhere
return jsonStr;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Path Provider
Getting the location of files that are saved on the device requires knowing about two folders - the temp folder and the app document directory. The pub.dev package path_provider
can get us these two
locations. We will also need the dart:io
package to be imported.
import 'dart:io';
import 'package:path_provider/path_provider.dart';
void _getFolders() {
Directory tempDir = await getTemporaryDirectory();
Directory appDocDir = await getApplicationDocumentDirectory();
String tempPath = tempDir.path;
String appDocPath = appDocDir.path;
}
2
3
4
5
6
7
8
9
A useful application of the path
and path_provider
packages is the ability to take pictures and save them in an app folder. The Flutter Cookbook section has a recipe on how to do this.
# Reading and Writing JSON
One of the alternatives to temporary storage via Shared Preferences is to read and write data to local JSON files. This gives you a permanent way to store information that will definitely be there the next time the app starts.
If you import the dart:io
package, you will be able to work with File and Directory objects.
Here is an example of a helper class for files, similar to how we create a helper class for Http functionality.
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:convert';
import 'dart:io';
import 'dart:async';
class FileHelper {
//a class for reading the contents from a data.json file
Future<Map<String, dynamic>> readContents(String filename) async {
Directory dataDir = await getApplicationDocumentsDirectory();
//find the app Documents directory
//open the file filename inside Documents/data
String path = '${dataDir.path}/data/${filename}';
File file = File(path); //create a file object
bool doesExist = await file.exists(); //check if the file actually exists
if (doesExist) {
//read the contents of the file as a String
return file.readAsString().then((String contents) {
//convert the string from the file into a Map object
//this could be a List...depends on the file.
Map<String, dynamic> data = jsonDecode(contents);
//now the json and data props have values
if (kDebugMode) {
print(contents);
print('file existed and read');
}
//this example function returns both the String and Map versions of the file contents
return {'json': contents, 'data': data};
}).catchError((err) {
if (kDebugMode) {
print('failed to read file ${err}');
}
return {'json': '', 'data': {}};
// throw Exception('Unable to read file.');
});
} else {
//create the file if it doesn't exist.
//the recursive property means that missing folders in the path
//will also be created.
file.create(recursive: true);
print('created file');
return {'json': '', 'data': {}};
}
}
Future<void> writeData(String filename, String jsonString) async {
//get the Documents directory inside the App
Directory dataDir = await getApplicationDocumentsDirectory();
final file = File('${dataDir.path}/data/${filename}');
return file.exists().then((bool exists) async {
if (exists) {
try {
// Write JSON string to the local file
await file.writeAsString(jsonString);
if (kDebugMode) {
print('Map data written to file: ${file.path}');
}
} catch (err) {
if (kDebugMode) {
print('Error writing to file: $err');
}
}
} else {
//no file
if (kDebugMode) {
print('No such file: ${file.path}');
}
}
});
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
Good to note that Documents
directory is inside the app once the app is installed. It is NOT a directory that exists inside your Flutter project structure. If you want to put files inside there
then you need to do that with code.
Once you have the Map
object from the JSON file you can access any of the values via their key.
class _HomeBodyState extends State<HomeBody> {
Map<String, dynamic> data = {};
String json = '';
void getFileContents() async {
FileHelper fileHelper = FileHelper();
fileHelper.readContents('data.json').then((Map<String, dynamic> details) {
setState(() {
data = details['data'];
json = details['json'];
});
}).onError((error, stackTrace) {
setState(() {
data = {};
json = 'No file contents.';
});
});
}
void initState() {
getFileContents();
super.initState();
}
//Now you have access to the values via their key like
// data['somekey']
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Repo example of reading and writing JSON files
The File
class also includes methods like copy
for making copies of files, rename
for renaming a file, readAsBytes
for reading the data inside a binary file such as an image, writeAsString
and writeAsBytes
.
# Providers and Consumers
Another way that you can save data globally and make it accessible to the rest of your app is by creating a Provider
. This is similar to Context objects and their providers in React.
To start with you need to add the provider
package as a dependency in your pubspec.
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.2
provider: ^6.1.1
2
3
4
5
The provider package reference
There are several different Providers that you can use at the top of your Widget tree.
- MultiProvider: when you want to load several providers at the top level.
- Provider: when you want to save static data at the top level.
- ChangeNotifierProvider: when you want global data that you want to read and write.
The most common, and the one we will discuss here, is the ChangeNotifierProvider
.
Start by adding the provider as a wrapper around your App inside the runApp()
method inside your main()
function. It has two properties - create
which builds the provider using a data model
class, and child
which is your app.
//main.dart
void main(){
runApp(
ChangeNotifierProvider(
create: (context) => MyDataModel(),
child: MyApp(),
)
);
}
2
3
4
5
6
7
8
9
The data model is a simple class that contains the member fields which hold the data that you want to access throughout the app. The member fields can be any data type that you want. Import this data
model class into main.dart
where it is referenced in the create
property value.
Typically you will have private variable that hold the values and then create a getter and a setter method for each.
//my_data_model.dart
class MyDataModel extends ChangeNotifier {
//default values
int _num = 42;
String _name = 'Douglas';
//getter and setter for _num
int get num => _num;
set num(int value){
if(value > 0 && value < 100){
_num = value;
}
}
//getter and setter for _name
String get name => _name.toUpperCase();
set name(String value){
if(value.length > 0){
_name = name;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
The data model file also needs to be imported on any file where you want to access the data, along with provider
package import.
On each page, or in each widget that needs to access the data from the provider we need to add a Consumer()
widget as the first widget inside the build
method. The Consumer
widget needs to be
typed as the data model type - Consumer<MyDataModel>()
. The Consumer
object has one property - builder
which accepts a function that will create the Widget tree inside the consumer.
build(BuildContext context){
return Consumer<MyDataModel>(
builder: (context, value, child) => Center( )
);
}
Widget
2
3
4
5
6
Inside the child of the Consumer
, you can use the value
variable from the function parameters to access the static value of any of the variables inside the data model. These are a snapshot of the
values at the point where the build function ran.
Column(
children: [
Text(value.num), //retrieve the value from the `get num` function in the data model
Text(value.name), //retrieve the value from the `get name` function in the data model
],
)
2
3
4
5
6
If you want to access the values and also be able to update the values, then we use a different approach. We use the context
parameter from the function, and call the read
method to retrieve the
typed instance of the data model class. Once you have the instance of the data model then you can access any of the member fields via their getters and setters.
TextButton(
child: Text('click me'),
onPressed: () {
final model = context.read<MyDataModel>();
model.num = 77; // calling the set num method
model.name = 'Arthur Dent'; //calling the set name method
}
)
2
3
4
5
6
7
8
Repo example of using a Provider
# ValueNotifier
The ValueNotifier
class is a subclass of a ChangeNotifier
, which we used in our Provider
. The difference with the ValueNotifier is that it holds a single value.
It is able to hold this single value and then notify all of its listeners if the value
property changes. The listeners are parts of the interface that are using this value.
To access or change (get or set) the value
property inside the ValueNotifier.
final ValueNotifier<int> age = ValueNotifier(42);
//this variable can be used across multiple widgets
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Text('${age.value}'),
TextButton(
child: Text('click to change'),
onPressed: () {
//set a new value
age.value = Random().nextInt(100);
//any widget that is accessing age.value
//will be informed about the new value.
}
)
],
)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Use state variables when the variable's scope and changes are limited to a specific StatefulWidget
and its associated State.
Use ValueNotifier
when you need to manage a value that's observable across multiple widgets or when you want to notify multiple parts of your app about changes in the value. It is better for more
complex scenarios where multiple widgets need to respond to changes in the same value.
Reference page for the ValueNotifier Class
Tutorial article about ValueNotifier
# Data Storage Options
There are a few other options that you can use in a Flutter app for permanent data storage. Databases that you can use in your app include:
- SQLite along with the SQLite libs
- HiveDB
- Isar DB
You can use any of these as a permanent LOCAL data storage solution. This is for times when you have a lot of data to manage but are not working with an external API that maintains your data.
# HiveDB
The HiveDB package works like LocalStorage in the web. It is saved across different loads of the app. It works with key-value pairs. The values can be any basic datatype - int
, double
, bool
,
String
, List
, and Map
.
# SQLite
Both iOS and Android phones have SQLite available on the device. The https://pub.dev/packages/sqlite3 package allows you to pass commands like open()
,
prepare()
, and execute()
, to the DB on the device. It works in conjunction with the https://pub.dev/packages/sqlite3_flutter_libs package that is
needed to talk with the native libraries which are needed to run the SQLite DB.
# Isar DB
Is a more full featured database solution, like SQLite, written by the authors of HiveDB. It requires a lot more work to install, configure, and run.
# What to do this week
TODO Things to do before next week
- Finish your Flutter App 2 assignment
- Start planning the screens for your Flutter Final Project