Flutter Networking Tutorial: Getting Started

In this tutorial, you’ll learn how to make asynchronous network requests and handle the responses in a Flutter app connected to a REST API. By Karol Wrótniak.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

Preparing for Development

Before you begin writing the actual logic you have to add the dependencies and prepare the model classes for deserialization and serialization from/to JSON.

Adding Dependencies

Open pubspec.yaml in the starter project root directory (not the API).

Next, find # TODO: add runtime dependencies in the dependencies block and replace it with:

  dio: ^5.3.2 # 1
  retrofit: ^4.0.1 # 2
  json_annotation: ^4.8.1 # 3

Here, you’ve added the runtime dependencies. You can use them in the code of your app. Here are their purposes:

  1. dio is an HTTP client used for making requests.
  2. retrofit is a type-safe wrapper over dio for easily generating network requests making code.
  3. json_annotation contains the annotations used by the json_serializable package, described in the coming paragraphs.

Moving forward, find # TODO: add build time dependencies in the dev_dependencies and place the following code there:

  build_runner: ^2.4.6 
  json_serializable: ^6.7.1
  retrofit_generator: ^7.0.8

Those dependencies are available only during build time. You can’t use them from the app code like widgets. They’re for generating the files (build_runner) and code used for JSON parsing (json_serializable) and HTTP response (retrofit_generator) handling.

Note: Both json_serializable and retrofit_generator use source_gen to generate code under the hood.

Don’t forget to run flutter pub get from the terminal or IDE. You won’t be able to use the dependencies otherwise.

Generating Data Model Classes

The response from the server is serialized in the form of JSON. Dart can’t interpret it out of the box. So, you have to parse — or, deserialize — the JSON to a Dart data model class.

You could do it manually. For example, find each field such as author, treat it as a string and do the same with other fields. Finally, create the data model class instance out of them.

Then, to get a response, you have to construct the URL and pass it to the HTTP client. Next, read the status code to determine if a request was successful, handle the errors and so on.

However, you won’t do it that way: you’ll use code generation to generate all that boilerplate code! You won’t get into details of code generation in this tutorial. But you can read more about it in our other article: Flutter Code Generation: Getting Started.

Though json_serializable will generate the code related to JSON parsing for you, you have to give it some instructions about your data models. Open lib/model/book.dart and replace its content with the following:

import 'package:json_annotation/json_annotation.dart';

part 'book.g.dart'; // 1

@JsonSerializable() // 2
class Book {
  @JsonKey(includeIfNull: false) // 3
  final int? id;
  final String title;
  final String author;
  final String? description;

  factory Book.fromJson(Map<String, dynamic> json) => _$BookFromJson(json); // 4

  const Book(
      {this.id, required this.title, required this.author, this.description});

  Map<String, dynamic> toJson() => _$BookToJson(this); // 5
}

In the code above, you:

  1. Indicate that a part of the code is in another file — yet to be generated by build_runner.
  2. Add an annotation marking the class as deserializable/serializable from/to JSON.
  3. Annotate that null values should be omitted from the JSON serialization.
  4. Create a factory constructor for deserializing from JSON.
  5. Include a method for serializing to JSON.

When making a request to create a new book, the book doesn’t have an ID yet. The backend will assign it. So, it makes no sense to send explicit null values, which is the default behavior. Adjusting the @JsonSerializable and @JsonKey annotations parameters enables you to customize the serialization/deserialization process.

For example, you can change the field name in a serialized form. It’s especially handy if the backend uses snake_case while your Dart code uses camelCase. Read more about customizations in the package’s official documentation.

Next, run the following command in the terminal to generate the code:

dart run build_runner build --delete-conflicting-outputs

Without that, you’ll get compilation errors and red lines in the IDE!

Implementing the REST Client Skeleton

The last preparation step is a REST client stub. Create a lib/network/rest_client.dart file with the following content:

import 'package:dio/dio.dart';
import 'package:retrofit/retrofit.dart';

part 'rest_client.g.dart';

@RestApi(baseUrl: 'http://localhost:8888') // 1
abstract class RestClient {
  factory RestClient(Dio dio) = _RestClient; // 2
}

The code above defines:

  1. The base URL for all the endpoints, so you don’t need to repeat it for each of them.
  2. The factory constructor you’ll use to create the client instance.

Note the localhost hostname is valid only on the same machine where the server is running. It works on the desktop, web and iOS simulators (if you’re on macOS). In case of physical iOS devices you have to change the localhost to the IP address of your computer. Both devices have to be in the same local network. If you’re using Wi-Fi, it can’t use client isolation or the guest network.

For Android emulators, you have to turn on port forwarding. To do so, use the following command: adb reverse tcp:8888 tcp:8888. With physical Android devices, use a port forwarding or an IP of your computer.

Then, run the command to generate the code as before:

dart run build_runner build --delete-conflicting-outputs

That generates the RestClient you’ll use to make the network requests.

Note: You need to invoke this command after any time you change any source code affecting the generated part, like the annotations.

Performing GET Request

Now you’re ready to implement the actual networking logic. Open lib/ui/bookshelf_screen.dart and replace // TODO: add book stream controller with the following fragment:

final _bookStreamController = StreamController<List<Book>>();

The StreamController connects the data source with the UI layer. It’s like a pipe. Everything you add to it will appear at the other end — sink. Start with connecting the source. To do that, find // TODO: implement GET network call and replace the refreshBooks method with the following expression:

  Future<void> refreshBooks() => _dataSource // 1
      .getBooks() // 2
      .then(_bookStreamController.add) // 3
      .catchError(_bookStreamController.addError); // 4

Breaking down the code above:

  1. The method returns a future, signalling it’s asynchronous.
  2. The network call to get books also returns a future.
  3. Add the list of books to the stream using the stream controller when the request succeeds.
  4. Add the error to the stream controller when the request fails.

Because the method is asynchronous, its invocation won’t block the UI. You can still use the app while a request is in progress.

Next, connect the other end of the stream. Find // TODO: bind data source, replace it and the line below it with:

stream: _bookStreamController.stream,

Now the StreamBuilder will listen to the events and invoke the builder method on each update.

Note: The network call may sometimes fail. For instance, due to broken internet connection. Don’t forget to handle unhappy scenarios. Swallowing errors is a source of bugs.

Moving on, locate // TODO: clean up resources in dispose and change it to this code:

await _bookStreamController.close();

That closes the stream controller to avoid memory leaks.

Next, open lib/network/rest_client.dart and add the following method:

  @GET('/books')
  Future<List<Book>> getBooks();

Remember to import lib/model/book.dart at the top with import '../model/book.dart';.
Regenerate the code using build_runner:

dart run build_runner build --delete-conflicting-outputs

The generated implementation will appear in rest_client.g.dart. Open the file, observe getBooks() and see how many lines of code you don’t need to write yourself!

Finally, open lib/network/data_source.dart and import dio and the REST client at the top:

import 'package:dio/dio.dart';

import 'rest_client.dart';

Find // TODO: implement GET request and replace the commented method with this code:

  final _restClient = RestClient(Dio()); // 1

  Future<List<Book>> getBooks() => _restClient.getBooks(); // 2

Breaking down the code above, you:

  1. Instantiate the RestClient, used for making requests.
  2. Use the client to make a GET request call to get books.

Run the app. You’ll see something like this:
GET request result