跳转至

10 Serialization With JSON

In this chapter, you’ll learn how to serialize JSON data into model classes. A model class represents data for a particular object. 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 (https://en.wikipedia.org/wiki/Representational_state_transfer). 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_annotationpackages to help make the process easier.

Flutter’s built-in dart:convert package contains methods like json.decode and json.encode, which converts 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.

In the next section you learn how to use automated serialization. For now, you don’t need to type this into your project, but need to understand the methods to convert the JSON above to a model class.

First, you’d create a Recipe model class:

class Recipe {
  final String uri;
  final String label;

  Recipe({this.uri, this.label});
}

Then you’d add a toJson() factory method and a fromJson() method:

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

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

Continue with your current project or open the starter project in the projects folder. Add the following package to pubspec.yaml in the Flutter dependencies section underneath and aligned with shared_preferences:

json_annotation: ^4.6.0

In the dev_dependencies section, after the flutter_test section, replace # TODO: Add new dev_dependencies with:

build_runner: ^2.2.0
json_serializable: ^6.3.1

Make sure these are all indented correctly. The result should look like this:

dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^1.0.5
  cached_network_image: ^3.2.2
  flutter_slidable: ^2.0.0
  platform: ^3.1.0
  flutter_svg: ^1.1.4
  shared_preferences: ^2.0.15
  json_annotation: ^4.6.0

dev_dependencies:
  flutter_test:
    sdk: flutter

  build_runner: ^2.2.0
  json_serializable: ^6.3.1
  flutter_lints: ^2.0.1

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.
  • to is the ending index.
  • 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:json_annotation/json_annotation.dart';

part 'recipe_model.g.dart';

The json_annotation library lets you mark classes as serializable. The file recipe_model.g.dartdoesn’t exist yet; you’ll generate it in a later step.

Next, add a class named APIRecipeQuery with a @JsonSerializable() annotation:

@JsonSerializable()
class APIRecipeQuery {
  // TODO: Add APIRecipeQuery.fromJson
}

// TODO: Add @JsonSerializable() class APIHits
// TODO: Add @JsonSerializable() class APIRecipe
// TODO: Add @JsonSerializable() class APIIngredients

That marks the APIRecipeQuery class as serializable so the json_serializable package can generate the .g.dart file.

Command-Click on JsonSerializable and you’ll see its definition:

...

/// Creates a new [JsonSerializable] instance.
const JsonSerializable({
  @Deprecated('Has no effect') bool? nullable,
  this.anyMap,
  this.checked,
  this.createFactory,
  this.createToJson,
  this.disallowUnrecognizedKeys,
  this.explicitToJson,
  this.fieldRename,
  this.ignoreUnannotated,
  this.includeIfNull,
  this.genericArgumentFactories,
});

...

For example, you can make the class nullable and add extra checks for validating JSON properly. Close the json_serialization.dart source file after reviewing it.

Converting to and from JSON

Now, you need to add JSON conversion methods within the APIRecipeQuery class. Return to recipe_model.dart and replace // TODO: Add APIRecipeQuery.fromJson with:

factory APIRecipeQuery.fromJson(Map<String, dynamic> json) =>
  _$APIRecipeQueryFromJson(json);

Map<String, dynamic> toJson() => _$APIRecipeQueryToJson(this);
// TODO: Add fields here

// TODO: Add APIRecipeQuery constructor

Note that the methods on the right of the arrow operator don’t exist yet, so ignore any red squiggles. You’ll create them later by running the build_runner 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 instance, while you use the other method on an object that already exists.

Now, add the following fields right after the conversion methods by replacing // TODO: Add fields here with the following:

@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 in a couple of steps.

Next, replace // TODO: Add APIRecipeQuery constructor with this constructor, again ignoring the red squiggles:

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, find // TODO: Add @JsonSerializable() class APIHits and replace it with a new class named APIHits, continuing to ignore the red squiggles:

// 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:

  1. Marks the class serializable.
  2. Defines a field of class APIRecipe, which you’ll create soon.
  3. Defines a constructor that accepts a recipe parameter.
  4. Adds the methods for JSON serialization.

Add the APIRecipe class definition by replacing // TODO: Add @JsonSerializable() class APIRecipe with:

@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);
}

// TODO: Add global Helper Functions

Here you:

  1. Define the fields for a recipe. label is the text shown and image is the URL of the image to show.
  2. State that each recipe has a list of ingredients.
  3. Create the factory methods for serializing JSON.

Now replace the TODO: Add global Helper Functions with:

// 4
String getCalories(double? calories) {
  if (calories == null) {
    return '0 KCAL';
  }
  return '${calories.floor()} KCAL';
}

 // 5
 String getWeight(double? weight) {
   if (weight == null) {
     return '0g';
   }
   return '${weight.floor()}g';
}
  1. Add a helper method to turn a calorie into a string.
  2. Add another helper method to turn the weight into a string.

Finally, replace // TODO: Add @JsonSerializable() class APIIngredients with:

@JsonSerializable()
class APIIngredients {
  // 6
  @JsonKey(name: 'text')
  String name;
  double weight;

  APIIngredients({
    required this.name,
    required this.weight,
  });

  // 7
  factory APIIngredients.fromJson(Map<String, dynamic> json) =>
      _$APIIngredientsFromJson(json);
  Map<String, dynamic> toJson() => _$APIIngredientsToJson(this);
}

Here you:

  1. State that the name field of this class maps to the JSON field named text.
  2. 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 Terminal 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:

[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. See Flutter installation documentation for more details, https://docs.flutter.dev/get-started/install.

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.

Note: If you still don’t see it, restart Android Studio so it recognizes the presence of the new generated file when it starts up.

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. To stop the process you can press Ctrl-C. Now, open recipe_model.g.dart. Here is the first generated method:

// 1
APIRecipeQuery _$APIRecipeQueryFromJson(Map<String, dynamic> json) =>
    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<dynamic>)
          .map((e) => 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:

  1. Returns a new APIRecipeQuery class.
  2. Maps the q key to a query field.
  3. Maps the from integer to the from field, and maps the other fields.
  4. Maps each element of the hits list to an instance of the APIHits class.

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.

Hot restart 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 can 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 ui/recipes/recipe_list.dart 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';
import 'recipe_details.dart';

In _RecipeListState, replace // TODO: Add _currentRecipes1 with:

APIRecipeQuery? _currentRecipes1;

Next, replace // TODO: Add loadRecipes with:

Future loadRecipes() async {
  // 1
  final jsonString = await rootBundle.loadString('assets/recipes1.json');
  setState(() {
    // 2
    _currentRecipes1 = APIRecipeQuery.fromJson(jsonDecode(jsonString));
  });
}

This method:

  1. Loads recipes1.json from the assets directory. rootBundle is the top-level property that holds references to all the items in the asset folder. This loads the file as a string.
  2. Uses the built-in jsonDecode() method to convert the string to a map, then uses fromJson(), which was generated for you, to make an instance of an APIRecipeQuery.

Now, replace // TODO: Call loadRecipes() with:

loadRecipes();

At the bottom, replace // TODO: Add _buildRecipeCard with:

Widget _buildRecipeCard(
  BuildContext topLevelContext, List<APIHits> hits, int index) {
  // 1
  final recipe = hits[index].recipe;
  return GestureDetector(
    onTap: () {
      Navigator.push(topLevelContext, MaterialPageRoute(
        builder: (context) {
          return const RecipeDetails();
        },
      ));
    },
    // 2
    child: recipeStringCard(recipe.image, recipe.label),
  );
}

This method:

  1. Finds the recipe at the given index.
  2. Calls recipeStringCard(), which shows a nice card below the search field.

Now, locate // TODO: Replace method and substitute the existing _buildRecipeLoader()with the following:

Widget _buildRecipeLoader(BuildContext context) {
  // 1
  if (_currentRecipes1 == null || _currentRecipes1?.hits == null) {
    return Container();
  }
  // Show a loading indicator while waiting for the recipes
  return Flexible(
    child: ListView.builder(
      itemCount: 1,
      itemBuilder: (BuildContext context, int index) {
        return Center(
          child: _buildRecipeCard(context, _currentRecipes1!.hits, 0));
      },
    ),
  );
}

This code now:

  1. Checks to see if the list of recipes is null.
  2. If not, calls _buildRecipeCard() using the first item in the list.

Perform a hot restart (not hot reload) and the app will show a Chicken Vesuvio sample card:

img

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.