DEV Community

Cover image for Building a Generic and Performant Networking Layer in Flutter
dklimkin
dklimkin

Posted on

4 1

Building a Generic and Performant Networking Layer in Flutter

Implement a high-performant and scalable networking layer

These days it’s hard to find an app that doesn’t perform any API calls. So might be yours. Luckily, Flutter provides packages out of the box to make this process a bit simpler. Examples of those could be easily found on pub.dev: HTTP and DIO.

Essentially, both packages allow you to assemble a network request and then perform it. Leaving to you error handling, JSON parsing, and all other aspects of mobile application networking.

For example, your API call looks like this:

final dio = Dio()
..options.baseUrl = 'https://www.domain.com/api';
..options.connectTimeout = 5000; // 5 seconds
..options.receiveTimeout = 3000; // 3 seconds
try {
final response = await dio.post('/authenticate', data: {'login': 'user_name', 'password': 'password'});
// Check response then response.data and convert response to your model
} on Dio.DioError catch (error) {
// Handle Dio exceptions here
} catch (error) {
// Handle generic exceptions here
}

This code is totally fine for a small pet project. But you start noticing inconveniences when the project starts growing at the scale stage.

Specifically:

❌ Duplicate code
❌ Blocking main thread and dropping FPS
❌ Error-prone codebase
❌ More and more time on adding new services and API calls
❌ Unreliable error handling

Image description

Smells bad🤮. Right?

Here we’re going to tackle all these issues and implement high-performant and scalable networking layer. Let’s start from the very top. Ideally, our API call should look very simple:

//Instantiate a service and keep it in your DI container:
final service = NetworkService(baseUrl: 'https://www.domain.com/api');
// Prepare a request:
final request = NetworkRequest(
type: NetworkRequestType.POST,
path: '/authenticate',
data: NetworkRequestBody.json({
'login': 'user_name',
'password': 'password'
}),
);
// Execute a request and convert response to your model:
final response = await service.execute(
request,
AccessTokenResponse.fromJson, // <- Function to convert API response to your model
);
// Handle possible outcomes:
response.maybeWhen(
ok: (authResponse) {
// Save access token or proceed with other parts of your app
},
badRequest: (info) {
// Handle specific error
},
orElse: () {
// Handle generic error
}
);

Image description

Quite nice?

Now let’s start crafting our Networking Layer step by step with a help of one amazing package: Freezed (yet another code generator for unions/pattern-matching and copy)!

Step 0: Design our data model. Let it be something easy to grasp

class AccessTokenResponse {
String? accessToken;
AccessTokenResponse.fromJson(Map<String, dynamic> json) {
accessToken = json['access_token'];
}
}

Step 1: Create a flexible request body

Request body encapsulated data to send along with your API call and could contain JSON object, binary data, text, etc.

import 'package:freezed_annotation/freezed_annotation.dart';
part 'NetworkRequestBody.freezed.dart';
@freezed
class NetworkRequestBody with _$NetworkRequestBody {
const factory NetworkRequestBody.empty() = Empty;
const factory NetworkRequestBody.json(Map<String, dynamic> data) = Json;
const factory NetworkRequestBody.text(String data) = Text;
}

Step 2: Create a request

Our request will contain common parameters you might need to serve an API call: type (Get, Post, etc), API path, data to pass as a body, optionally query parameters and headers to override globally specified headers if needed:

enum NetworkRequestType { GET, POST, PUT, PATCH, DELETE }
class NetworkRequest {
const NetworkRequest({
required this.type,
required this.path,
required this.data,
this.queryParams,
this.headers,
});
final NetworkRequestType type;
final String path;
final NetworkRequestBody data;
final Map<String, dynamic>? queryParams;
final Map<String, String>? headers;
}

Step 3. Create a response

Our response object will contain only raw data received from your API:

import 'package:freezed_annotation/freezed_annotation.dart';
part 'NetworkResponse.freezed.dart';
@freezed
class NetworkResponse<Model> with _$NetworkResponse {
const factory NetworkResponse.ok(Model data) = Ok;
const factory NetworkResponse.invalidParameters(String message) = InvalidParameters;
const factory NetworkResponse.noAuth(String message) = NoAuth; //401
const factory NetworkResponse.noAccess(String message) = NoAccess; //403
const factory NetworkResponse.badRequest(String message) = BadRequest; //400
const factory NetworkResponse.notFound(String message) = NotFound; //404
const factory NetworkResponse.conflict(String message) = Conflict; //409
const factory NetworkResponse.noData(String message) = NoData; //500
}

Now we are ready to implement the most intriguing part: network service/layer itself!

Step 4. Let’s prototype our service with what we already have

Our service needs a base URL and optionally DIO instance and HTTP headers. It will create a default DIO instance if not provided:

class NetworkService {
NetworkService({
required this.baseUrl, // Base service url
dioClient, // Prepared Dio instance could be injected
httpHeaders, // Global headers could be provided as well
}) : this._dio = dioClient,
this._headers = httpHeaders ?? {};
Dio? _dio;
final String baseUrl;
Map<String, String> _headers;
Future<Dio> _getDefaultDioClient() async {
// Global http header
_headers['content-type'] = 'application/json; charset=utf-8';
final dio = Dio.Dio()
..options.baseUrl = baseUrl
..options.headers = _headers
..options.connectTimeout = 5000 // 5 seconds
..options.receiveTimeout = 3000; // 3 seconds
return dio;
}
// Generic type and parser are used to properly deserialise JSON
Future<NetworkResponse<Model>> execute<Model>(
NetworkRequest request,
Model Function(Map<String, dynamic>) parser, {
ProgressCallback? onSendProgress = null,
ProgressCallback? onReceiveProgress = null,
}) async {
if (_dio == null) {
_dio = await _getDefaultDioClient();
}
// 1. Prepare the request
// 2. Execute it
// 3. Return parsed result or error
}
}

You can add other body types and conversions here so I’m leaving it to you.

Everything is ready to execute our request and handle exceptions:

try {
final response = await _dio!.request(
request.path,
data: body,
queryParameters: request.queryParams,
options: Dio.Options(
method: request.type.name,
headers: {..._headers, ...(request.headers ?? {})}, // Combine all headers
),
onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress,
);
return NetworkResponse.ok(parser(response.data));
} on DioError catch (error) {
final errorText = error.toString();
if (error.requestOptions.cancelToken!.isCancelled) {
return NetworkResponse.noData(errorText);
}
switch (error.response?.statusCode) {
case 400:
return NetworkResponse.badRequest(errorText);
case 401:
return NetworkResponse.noAuth(errorText);
case 403:
return NetworkResponse.noAccess(errorText);
case 404:
return NetworkResponse.notFound(errorText);
case 409:
return NetworkResponse.conflict(errorText);
default:
return NetworkResponse.noData(errorText);
}
} catch (error) {
return NetworkResponse.noData(error.toString());
}

We can also add a method(s) to simplify authenticated API calls:

void addBasicAuth(String accessToken) {
_headers['Authorization'] = 'Bearer $accessToken';
}
view raw basic_auth.dart hosted with ❤ by GitHub

Let’s summarise what we’ve got here:

  • API call is prepared with all headers combined and parameters incapsulated
  • API call is executed by DIO package
  • Data returned from DIO is converted to your Model type provided
  • Exceptions are handled and wrapped in a corresponding API response

But all this stuff still happens on main thread. And this is something we are going to fix now by utilizing a powerful yet simple Isolates mechanism in Dart.

Image description

Step 5. Make Networking Layer to be performant!

The way Dart work is organized to pass data between Isolates we need to copy all parameters and then call a globally defined function. We will encapsulate all data in a private PreparedNetworkRequest class:

class _PreparedNetworkRequest<Model> {
const _PreparedNetworkRequest(
this.request,
this.parser,
this.dio,
this.headers,
this.onSendProgress,
this.onReceiveProgress,
);
final NetworkRequest request;
final Model Function(Map<String, dynamic>) parser;
final Dio dio;
final Map<String, dynamic> headers;
final ProgressCallback? onSendProgress;
final ProgressCallback? onReceiveProgress;
}

And now all we need to do is to move request execution call along with exception handling into Isolate function:

Future<NetworkResponse<Model>> executeRequest<Model>(
_PreparedNetworkRequest request,
) async {
try {
dynamic body = request.request.data.whenOrNull(
json: (data) => data,
text: (data) => data,
);
final response = await request.dio.request(
request.request.path,
data: body,
queryParameters: request.request.queryParams,
options: Dio.Options(
method: request.request.type.name,
headers: request.headers,
),
onSendProgress: request.onSendProgress,
onReceiveProgress: request.onReceiveProgress,
);
return NetworkResponse.ok(request.parser(response.data));
} on Dio.DioError catch (error) {
final errorText = error.toString();
if (error.requestOptions.cancelToken!.isCancelled) {
return NetworkResponse.noData(errorText);
}
switch (error.response?.statusCode) {
case 400:
return NetworkResponse.badRequest(errorText);
case 401:
return NetworkResponse.noAuth(errorText);
case 403:
return NetworkResponse.noAccess(errorText);
case 404:
return NetworkResponse.notFound(errorText);
case 409:
return NetworkResponse.conflict(errorText);
default:
return NetworkResponse.noData(errorText);
}
} catch (error) {
return NetworkResponse.noData(error.toString());
}
}

And call it in our Network Service:

final req = _PreparedNetworkRequest<Model>(
request,
parser,
_dio!,
{..._headers, ...(request.headers ?? {})},
onSendProgress,
onReceiveProgress,
);
final result = await compute(
executeRequest<Model>,
req,
);

Image description

Huh! We’re done coding☺️.

Finally, our NetworkService looks like this:

class NetworkService {
NetworkService({
required this.baseUrl,
dioClient,
httpHeaders,
}) : this._dio = dioClient,
this._headers = httpHeaders ?? {};
Dio? _dio;
final String baseUrl;
Map<String, String> _headers;
Future<Dio> _getDefaultDioClient() async {
_headers['content-type'] = 'application/json; charset=utf-8';
final dio = Dio()
..options.baseUrl = baseUrl
..options.headers = _headers
..options.connectTimeout = 5000 // 5 seconds
..options.receiveTimeout = 3000; // 3 seconds
}
return dio;
}
void addBasicAuth(String accessToken) {
_headers['Authorization'] = 'Bearer $accessToken';
}
Future<NetworkResponse<Model>> execute<Model>(
NetworkRequest request,
Model Function(Map<String, dynamic>) parser, {
ProgressCallback? onSendProgress = null,
ProgressCallback? onReceiveProgress = null,
}) async {
if (_dio == null) {
_dio = await _getDefaultDioClient();
}
final req = _PreparedNetworkRequest<Model>(
request,
parser,
_dio!,
{..._headers, ...(request.headers ?? {})},
onSendProgress,
onReceiveProgress,
);
final result = await compute(
executeRequest<Model>,
req,
);
return result;
}
}

Recap of what we’ve achieved today

✅ No duplicate code
✅ Not blocking main thread and dropping FPS
✅ Error-proof codebase
✅ Minimised time on adding new services and API calls
✅ Structured and strongly-typed error handling

Resources

Concurrency in Dart
Freezed Dart package
Dio Dart package
RealHTTP Modern Networking Layers in iOS Using Async/Await
Full source code available here

To get hand-picked the latest tech stories subscribe on my Telegram Channel where I post daily.

Happy coding!😉

Sentry mobile image

App store rankings love fast apps - mobile vitals can help you get there

Slow startup times, UI hangs, and frozen frames frustrate users—but they’re also fixable. Mobile Vitals help you measure and understand these performance issues so you can optimize your app’s speed and responsiveness. Learn how to use them to reduce friction and improve user experience.

Read full post →

Top comments (0)

Billboard image

📊 A side-by-side product comparison between Sentry and Crashlytics

A free guide pointing out the differences between Sentry and Crashlytics, that’s it. See which is best for your mobile crash reporting needs.

See Comparison

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay