11.
Serialization With JSON
Written by Kevin D Moore
In this chapter, you’ll learn how to serialize JSON data into model classes. A model class represents an object that your app can manipulate, create, save and search. An example is a recipe model class, which usually has a title, an ingredient list and steps to cook it.
You’ll continue with the previous project, which is the starter project for this chapter, and you’ll add a class that models a recipe and its properties. Then you’ll integrate that class into the existing project.
By the end of the chapter, you’ll know:
- How to serialize JSON into model classes.
- How to use Dart tools to automate the generation of model classes from JSON.
What is JSON?
JSON, which stands for JavaScript Object Notation, is an open-standard format used on the web and in mobile clients. It’s the most widely used format for Representational State Transfer (REST)-based APIs that servers provide. If you talk to a server that has a REST API, it will most likely return data in a JSON format. An example of a JSON response looks something like this:
{
"recipe": {
"uri": "http://www.edamam.com/ontologies/edamam.owl#recipe_b79327d05b8e5b838ad6cfd9576b30b6",
"label": "Chicken Vesuvio"
}
}
That is an example recipe response that contains two fields inside a recipe object.
While it’s possible to treat the JSON as just a long string and try to parse out the data, it’s much easier to use a package that already knows how to do that. Flutter has a built-in package for decoding JSON, but in this chapter, you’ll use the json_serializable and json_annotation packages to help make the process easier.
Flutter’s built-in dart:convert package contains methods like json.decode and json.encode, which convert a JSON string to a Map<String, dynamic> and back. While this is a step ahead of manually parsing JSON, you’d still have to write extra code that takes that map and puts the values into a new class.
The json_serializable package comes in handy because it can generate model classes for you according to the annotations you provide via json_annotation. Before taking a look at automated serialization, you’ll see in the next section what manual serialization entails.
Writing the code yourself
So how do you go about writing code to serialize JSON yourself? Typical model classes have toJson() and fromJson() methods, so you’ll start with those.
To convert the JSON above to a model class, you’d first create a Recipe model class:
class Recipe {
final String uri;
final String label;
Recipe({this.uri, this.label});
}
You don’t need to type this into your project, since in the next section you’ll instead switch to automated serialization.
Then you’d add toJson() and fromJson():
factory Recipe.fromJson(Map<String, dynamic> json) {
return Recipe(json['uri'] as String, json['label'] as String);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{ 'uri': uri, 'label': label}
}
In fromJson(), you grab data from the JSON map variable named json and convert it to arguments you pass to the Recipe constructor. In toJson(), you construct a map using the JSON field names.
While it doesn’t take much effort to do that by hand for two fields, what if you had multiple model classes, each with, say, five fields, or more? What if you renamed one of the fields? Would you remember to rename all of the occurrences of that field?
The more model classes you have, the more complicated it becomes to maintain the code behind them. Fear not, that’s where automated code generation comes to the rescue.
Automating JSON serialization
Open the starter project in the projects folder. You’ll use two packages in this chapter: json_annotation and json_serializable from Google.
You use the first to add annotations to model classes so that json_serializable can generate helper classes to convert JSON from a string to a model and back.
To do that, you mark a class with the @JsonSerializable() annotation so the builder package can generate code for you. Each field in the class should either have the same name as the field in the JSON string or use the @JsonKey() annotation to give it a different name.
Most builder packages work by importing what’s called a .part file. That will be a file that is generated for you. All you need to do is create a few factory methods which will call the generated code.
Adding the necessary dependencies
Add the following package to pubspec.yaml in the Flutter dependencies section underneath and aligned with flutter_statusbarcolor: ^0.2.3:
json_annotation: ^3.1.0
In the dev_dependencies section, after the flutter_test section, add:
build_runner: ^1.10.0
json_serializable: ^3.5.0
Make sure these are all indented correctly. The result should look like this:
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.0
cached_network_image: ^2.3.2+1
flutter_slidable: ^0.5.7
flutter_svg: ^0.19.0
shared_preferences: ">=0.5.8 <2.0.0"
flutter_statusbarcolor: ^0.2.3
json_annotation: ^3.1.0
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^1.10.0
json_serializable: ^3.5.0
build_runner is a package that all code generators require in order to build .part file classes.
Finally, press the Pub get button you should see at the top of the file. You’re now ready to generate model classes.
Generating classes from JSON
The JSON that you’re trying to serialize looks something like:
{
"q": "pasta",
"from": 0,
"to": 10,
"more": true,
"count": 33060,
"hits": [
{
"recipe": {
"uri": "http://www.edamam.com/ontologies/edamam.owl#recipe_09b4dbdf0c7244c462a4d2622d88958e",
"label": "Pasta Frittata Recipe",
"image": "https://www.edamam.com/web-img/5a5/5a5220b7a65c911a1480502ed0532b5c.jpg",
"source": "Food Republic",
"url": "http://www.foodrepublic.com/2012/01/21/pasta-frittata-recipe",
}
]
}
The q field is the query. In this instance, you’re querying about pasta. from is the starting index and to is the ending one. more is a boolean that tells you whether there are more items to retrieve, while count is the total number of items you could receive. The hits array is the actual list of recipes.
In this chapter, you’ll use the label and image fields of the recipe item. Your next step is to generate the classes that model that data.
Creating model classes
Start by creating a new directory named network in the lib folder. Inside this folder, create a new file named recipe_model.dart. Then add the needed imports:
import 'package:flutter/foundation.dart';
import 'package:json_annotation/json_annotation.dart';
part 'recipe_model.g.dart';
json_annotation lets you mark classes as serializable. recipe_model.g.dart doesn’t exist yet; you’ll generate it in a later step.
Next, add a class APIRecipeQuery with a @JsonSerializable() annotation:
@JsonSerializable()
class APIRecipeQuery {
}
That marks the APIRecipeQuery class as serializable so the json_serializable package can generate the .g.dart file.
Open the definition of JsonSerializable by using a Command-Click on it and you’ll see that you can change a few settings for this class:
final bool nullable;
/// Creates a new [JsonSerializable] instance.
const JsonSerializable({
this.anyMap,
this.checked,
this.createFactory,
this.createToJson,
this.disallowUnrecognizedKeys,
this.explicitToJson,
this.fieldRename,
this.ignoreUnannotated,
this.includeIfNull,
this.nullable,
this.genericArgumentFactories,
});
For example, you can make the class nullable and add extra checks for validating JSON properly.
Converting to and from JSON
Now, return to recipe_model.dart. Add methods that will convert from JSON to APIRecipeQuery and back:
factory APIRecipeQuery.fromJson(Map<String, dynamic> json) => _$APIRecipeQueryFromJson(json);
Map<String, dynamic> toJson() => _$APIRecipeQueryToJson(this);
Note that the methods on the right of the arrow operator don’t exist yet. You’ll create them later by running the build_runner build command.
Note also that the first call is a factory method. That’s because you need a class-level method when you’re creating the class, while you use the other method on an object that already exists.
Now, add the following fields right after the methods:
@JsonKey(name: 'q')
String query;
int from;
int to;
bool more;
int count;
List<APIHits> hits;
The @JsonKey annotation states that you represent the query field in JSON with the string q. The rest of the fields look in JSON just like their names here. You’ll define APIHits below.
Next, add a constructor:
APIRecipeQuery({
@required this.query,
@required this.from,
@required this.to,
@required this.more,
@required this.count,
@required this.hits,
});
The @required annotation says that these fields are mandatory when creating a new instance.
Then, at the bottom of the same file, add the APIHits class definition:
// 1
@JsonSerializable()
class APIHits {
// 2
APIRecipe recipe;
// 3
APIHits({
@required this.recipe,
});
// 4
factory APIHits.fromJson(Map<String, dynamic> json) => _$APIHitsFromJson(json);
Map<String, dynamic> toJson() => _$APIHitsToJson(this);
}
Here’s what this code does:
- Marks the class serializable.
- Defines a field of class
APIRecipe, which you’ll create soon. - Defines a constructor that accepts a
recipeparameter. - Adds the methods for JSON serialization.
Add the APIRecipe class definition next:
@JsonSerializable()
class APIRecipe {
// 1
String label;
String image;
String url;
// 2
List<APIIngredients> ingredients;
double calories;
double totalWeight;
double totalTime;
APIRecipe({
@required this.label,
@required this.image,
@required this.url,
@required this.ingredients,
@required this.calories,
@required this.totalWeight,
@required this.totalTime,
});
// 3
factory APIRecipe.fromJson(Map<String, dynamic> json) => _$APIRecipeFromJson(json);
Map<String, dynamic> toJson() => _$APIRecipeToJson(this);
}
// 4
String getCalories(double calories) {
if (calories == null) {
return "0 KCAL";
}
return calories.floor().toString() + ' KCAL';
}
// 5
String getWeight(double weight) {
if (weight == null) {
return '0g';
}
return weight.floor().toString() + 'g';
}
Here you:
- Define the fields for a recipe.
labelis the text shown andimageis the URL of the image to show. - State that each recipe has a list of ingredients.
- Create the factory methods for serializing JSON.
- Add a helper method to turn a calorie into a string.
- Add another helper method to turn the weight into a string.
Finally, add APIIngredients:
@JsonSerializable()
class APIIngredients {
// 1
@JsonKey(name: 'text')
String name;
double weight;
APIIngredients({
@required this.name,
@required this.weight,
});
// 2
factory APIIngredients.fromJson(Map<String, dynamic> json) => _$APIIngredientsFromJson(json);
Map<String, dynamic> toJson() => _$APIIngredientsToJson(this);
}
Here you:
- State that the
namefield of this class maps to the JSON field namedtext. - Create the methods to serialize JSON.
For your next step, you’ll create the .part file.
Generating the .part file
Open the terminal in Android Studio by clicking on the panel in the lower left, or by selecting View ▸ Tool Windows ▸ Terminal, and type:
flutter pub run build_runner build
The expected output will look something like this:
Precompiling executable...
Precompiled build_runner:build_runner.
[INFO] Generating build script...
...
[INFO] Creating build script snapshot......
...
[INFO] Running build...
...
[INFO] Succeeded after ...
Note: If you have problems running the command, make sure that you’ve installed Flutter on your computer and you have a path set up to point to it.
This command creates recipe_model.g.dart in the network folder. If you don’t see the file, right-click on the network folder and choose Reload from disk. If you still don’t see it, check for any error messages after running the command.
If you want the program to run every time you make a change to your file, you can use the watch command, like this:
flutter pub run build_runner watch
The command will continue to run and watch for changes to files. Now, open recipe_model.g.dart. Here is the first generated method:
// 1
APIRecipeQuery _$APIRecipeQueryFromJson(Map<String, dynamic> json) {
return APIRecipeQuery(
// 2
query: json['q'] as String,
// 3
from: json['from'] as int,
to: json['to'] as int,
more: json['more'] as bool,
count: json['count'] as int,
// 4
hits: (json['hits'] as List)
?.map((e) =>
e == null ? null : APIHits.fromJson(e as Map<String, dynamic>))
?.toList(),
);
}
Notice that it takes a map of String to dynamic, which is typical of JSON data in Flutter. The key is the string and the value will be either a primitive, a list or another map. The method:
- Returns a new
APIRecipeQueryclass. - Maps the
qkey to aqueryfield. - Maps the
frominteger to thefromfield, and maps the other fields. - Maps each element of the
hitslist to an instance of theAPIHitsclass.
You could have written this code yourself, but it can get a bit tedious and is error-prone. Having a tool generate the code for you saves a lot of time and effort. Look through the rest of the file to see how the generated code converts the JSON data to all the other model classes.
Now, build and run the app to make sure it still compiles and works as before. You won’t see any changes in the UI, but the code is now set up to parse recipe data.
Testing the generated JSON code
Now that you have the ability to parse model objects from JSON, you’ll read one of the JSON files included in the starter project and show one card to make sure you can use the generated code.
Open recipe_list.dart in the ui/recipes folder and add the following imports at the top:
import 'dart:convert';
import '../..//network/recipe_model.dart';
import 'package:flutter/services.dart';
import '../recipe_card.dart';
After List<String> previousSearches = List<String>(); in _RecipeListState, add:
APIRecipeQuery _currentRecipes1;
Then, after initState(), add:
Future loadRecipes() async {
// 1
var jsonString = await rootBundle.loadString('assets/recipes1.json');
setState(() {
// 2
_currentRecipes1 = APIRecipeQuery.fromJson(jsonDecode(jsonString));
});
}
This method:
- Loads recipes1.json from the assets directory.
rootBundleis a system class that holds references to all the items in the asset folder. This loads the file as a string. - Uses the built-in
jsonDecode()method to convert the string to a map, then usesfromJson(), which was generated for you, to make an instance of anAPIRecipeQuery.
Next, call loadRecipes() in initState():
@override
void initState() {
super.initState();
loadRecipes();
// ... rest of method
}
At the bottom of the class add:
Widget _buildRecipeCard(BuildContext context, List<APIHits> hits,
int index) {
// 1
APIRecipe recipe = hits[index].recipe;
return GestureDetector(
onTap: () {
},
// 2
child: recipeStringCard(recipe.image, recipe.label),
);
}
This method:
- Finds the recipe at the given index.
- Calls
recipeStringCard(), which shows a nice card below the search field.
Now, change _buildRecipeLoader() to:
Widget _buildRecipeLoader(BuildContext context) {
// 1
if (_currentRecipes1 == null || _currentRecipes1.hits == null) {
return Container();
}
// Show a loading indicator while waiting for the movies
return Center(
// 2
child: _buildRecipeCard(context, _currentRecipes1.hits, 0),
);
}
This code now:
- Checks to see if either the query or the list of recipes is
null. - If not, calls
_buildRecipeCard()using the first item in the list.
Perform a hot restart with Run ▸ Flutter Hot Restart and the app will show a Chicken Vesuvio sample card:
Now that the data model classes work as expected, you’re ready to load recipes from the web. Fasten your seat belt. :]
Key Points
- JSON is an open-standard format used on the web and in mobile clients, especially with REST APIs.
- In mobile apps, JSON code is usually parsed into the model objects that your app will work with.
- You can write JSON parsing code yourself, but it’s usually easier to let a JSON package generate the parsing code for you.
- json_annotation and json_serializable are packages that will let you generate the parsing code.
Where to go from here?
In this chapter, you’ve learned how to create models that you can parse from JSON and then use when you fetch JSON data from the network. If you want to learn more about json_serializable, go to https://pub.dev/packages/json_serializable.
In the next chapter, you build on what you’ve done so far and learn about getting data from the internet.