Mobile Development 33 min read

Encapsulating Network Requests in Flutter with Dio and Riverpod

This article demonstrates how to encapsulate Flutter network requests using Dio, create a reusable ApiClient with singleton pattern, integrate Riverpod for automatic UI updates, handle errors with custom exceptions, and organize code with repositories and providers for clean architecture.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Encapsulating Network Requests in Flutter with Dio and Riverpod

1. Introduction

The author revisits previous articles on JSON parsing and network requests, noting their shortcomings, and proposes a more comfortable way to encapsulate network requests using Riverpod for state management.

2. Encapsulated Effect Demo

Shows UI screenshots of API definition, UI call site, and runtime output, along with a download link for the demo project.

3. Common Encapsulation Techniques

3.1. Singleton & Multiton

Explains the singleton design pattern and provides a Dart implementation with a private constructor, static instance, and accessor method.

class Singleton {
  // ① private constructor
  Singleton._internal();

  // ② static private instance
  static Singleton? _instance = Singleton._internal();

  // ③ static getter for the instance
  static Singleton get instance => _instance ??= Singleton._internal();

  int _counter = 0;
  void incrementCounter() => _counter++;
  int get counter => _counter;
}

void main() {
  var s1 = Singleton.instance;
  var s2 = Singleton.instance;
  print(identical(s1, s2)); // true
  s1.incrementCounter();
  print(s2.counter); // 1
}

Describes multiton implementation using a static map keyed by a string.

class Multiple {
  Multiple._internal();
  static final Map
_instances = {};
  static Multiple getInstance(String key) {
    _instances.putIfAbsent(key, () => Multiple._internal());
    return _instances[key]!;
  }
}

void main() {
  var m1 = Multiple.getInstance("a");
  var m2 = Multiple.getInstance("b");
  var m3 = Multiple.getInstance("a");
  print(identical(m1, m2)); // false
  print(identical(m1, m3)); // true
}

3.2. Compile‑time Code Generation

Introduces Dart's source_gen and build_runner for generating code at compile time, with an example of a @ToString annotation that generates a custom toString method.

class ToString {}

class ToStringGenerator extends GeneratorForAnnotation
{
  @override
  Future
generateForAnnotatedElement(Element element, ConstantReader annotation, BuildStep buildStep) async {
    // generate toString extension
  }
}

3.3. Generics

Discusses Dart's generic classes, methods, and bounds, contrasting them with Java's type erasure. Provides examples of runtime generic type checks and variance.

void main() {
  List
numbers = [1, 2, 3];
  print(numbers is List
); // true
}

3.4. Closures

Explains lexical scoping and closure behavior in Dart, showing stateful closures that retain variables across calls.

void main() {
  var printer = () {
    int num = 0;
    return () => print(++num);
  }();
  printer(); // 1
  printer(); // 2
}

3.5. Mixins

Shows how mixins can be used to compose behavior without inheritance, and how method resolution order works.

mixin A { void printName() => print("A"); }

class D with A, B, C { void printName() => super.printName(); }

3.6. Extensions

Demonstrates Dart extensions to add methods to existing types without modifying the original class.

extension StringExtensions on String {
  bool get isNotEmpty => this.isNotEmpty;
}

3.7. Cascade (.. ) and Spread (... ) Operators

Shows practical uses of the cascade operator for method chaining and the spread operator for list concatenation.

var obj = MyClass()
  ..property = 'value'
  ..method1()
  ..method2();

var list2 = [0, ...list1];

4. Encapsulation Thought & Practice

4.1. Original Implementation

Provides a simple Flutter app that directly uses Dio inside UI widgets, highlighting tight coupling between UI and data layers.

4.2. ApiClient

Introduces a singleton ApiClient that configures Dio with interceptors, logging, and optional proxy support.

class ApiClient {
  late final Dio _dio;
  static ApiClient? _instance;

  ApiClient._internal(this._dio) {
    _dio.interceptors.add(DefaultInterceptorsWrapper());
    if (kDebugMode) {
      _dio.interceptors.add(LogInterceptor(responseBody: true, requestBody: true));
      _dio.httpClientAdapter = IOHttpClientAdapter(createHttpClient: localProxyHttpClient);
    }
  }

  static Future
init(String baseUrl, {Map
? requestHeaders}) async {
    _instance ??= ApiClient._internal(Dio(BaseOptions(
      baseUrl: baseUrl,
      responseType: ResponseType.json,
      connectTimeout: const Duration(seconds: 30),
      receiveTimeout: const Duration(seconds: 30),
      headers: requestHeaders ?? await _defaultRequestHeaders,
      validateStatus: (status) => true,
    )));
  }

  static ApiClient get instance {
    if (_instance == null) throw Exception('ApiClient not initialized');
    return _instance!;
  }

  Future
> get
(String endpoint, {Map
? queryParameters, Options? options, CancelToken? cancelToken}) {
    return _dio.get
(endpoint, queryParameters: queryParameters, options: options, cancelToken: cancelToken);
  }

  Future
> post
(String endpoint, {dynamic data, Map
? queryParameters, Options? options, CancelToken? cancelToken}) {
    return _dio.post
(endpoint, data: data, queryParameters: queryParameters, options: options, cancelToken: cancelToken);
  }
}

4.3. API Service & UI Auto‑Refresh

Shows how to place API endpoints in a separate service file and use Riverpod's @riverpod annotation to generate providers that automatically rebuild UI when data changes.

@riverpod
Future
testGet(TestGetRef ref) => ApiClient.instance.get("/testGet");

@riverpod
Future
testPost(TestPostRef ref, int page) => ApiClient.instance.post("/testPost", data: {"page": page, "keyword": "${DateTime.now().millisecondsSinceEpoch}"});

Explains handling of null values during refresh and demonstrates using NotifierProvider for finer‑grained state control.

4.4. Data Parsing & Error Handling

Defines a hierarchy of custom ApiException classes to represent network, HTTP, and business‑logic errors, and shows how to parse JSON responses into typed DataResponse or ListResponse objects.

class ApiException implements Exception {
  final int? code;
  final String? message;
  String? stackInfo;
  ApiException([this.code, this.message]);
  // factory constructors for different DioException types omitted for brevity
}

class BadRequestException extends ApiException { BadRequestException(super.code, super.message); }
// other specific exception classes omitted

The ApiClient provides generic get and post methods that accept a fromJsonT closure to deserialize the response.

Future
get
(String endpoint, {D Function(dynamic json)? fromJsonT, Map
? queryParameters, Options? options, CancelToken? cancelToken}) =>
    _performRequest
(() => _dio.get(endpoint, queryParameters: queryParameters, options: options, cancelToken: cancelToken), fromJsonT);

Shows two error‑handling strategies: throwing exceptions that Riverpod captures as AsyncError , or returning a default object with an embedded ApiException field.

5. Summary

Flutter disables reflection, making runtime code generation limited, but the article provides a practical baseline for encapsulating network calls, integrating Riverpod for reactive UI, and structuring code with repositories and providers. Readers are encouraged to extend the demo with Retrofit, additional interceptors, or more sophisticated state management.

Fluttermobile developmentstate managementnetwork requestRiverpodAPI clientDio
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.