Flutter (101): Network request encapsulation

Time: Column:Mobile & Frontend views:203

In this section, we will encapsulate the network request interfaces used in our app based on the previously introduced Dio network library, while also applying a simple network request caching strategy. First, we will introduce the principles of network request caching, and then we will encapsulate the app's business request interfaces.

1 Network Interface Caching

Due to slow access to the GitHub server in China, we will implement some simple caching strategies: using the requested URL as the key, we will cache the response value for a specified duration and also set a maximum cache size. When the maximum cache size is exceeded, the oldest cache will be removed. However, we also need to provide a mechanism to determine whether caching should be enabled for specific interfaces or requests. This mechanism allows us to specify which interfaces or requests should not use caching; for example, the login interface should not be cached, and when a user performs a pull-to-refresh, caching should also not apply. Before implementing caching, we define a CacheObject class to store cache information:

class CacheObject {
  CacheObject(this.response)
      : timeStamp = DateTime.now().millisecondsSinceEpoch;
  Response response;
  int timeStamp; // Cache creation time

  @override
  bool operator ==(other) {
    return response.hashCode == other.hashCode;
  }

  // Use the request URI as the cache key
  @override
  int get hashCode => response.realUri.hashCode;
}

Next, we need to implement the specific caching strategy. Since we are using the Dio package, we can directly implement the caching strategy through an interceptor:

import 'dart:collection';
import 'package:dio/dio.dart';
import '../index.dart';

class CacheObject {
  CacheObject(this.response)
      : timeStamp = DateTime.now().millisecondsSinceEpoch;
  Response response;
  int timeStamp;

  @override
  bool operator ==(other) {
    return response.hashCode == other.hashCode;
  }

  @override
  int get hashCode => response.realUri.hashCode;
}

class NetCache extends Interceptor {
  // Use LinkedHashMap to ensure the order of insertion matches iteration order
  var cache = LinkedHashMap<String, CacheObject>();

  @override
  onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
    if (!Global.profile.cache!.enable) {
      return handler.next(options);
    }
    // refresh indicates if it is a "pull-to-refresh"
    bool refresh = options.extra["refresh"] == true;
    // If it's a pull-to-refresh, first remove related caches
    if (refresh) {
      if (options.extra["list"] == true) {
        // For lists, remove all caches containing the current path (simple implementation, not precise)
        cache.removeWhere((key, v) => key.contains(options.path));
      } else {
        // If not a list, only remove caches with the same URI
        delete(options.uri.toString());
      }
      return handler.next(options);
    }
    if (options.extra["noCache"] != true &&
        options.method.toLowerCase() == 'get') {
      String key = options.extra["cacheKey"] ?? options.uri.toString();
      var ob = cache[key];
      if (ob != null) {
        // If the cache has not expired, return the cached content
        if ((DateTime.now().millisecondsSinceEpoch - ob.timeStamp) / 1000 <
            Global.profile.cache!.maxAge) {
          return handler.resolve(ob.response);
        } else {
          // If expired, delete the cache and continue requesting the server
          cache.remove(key);
        }
      }
    }
    handler.next(options);
  }

  @override
  onResponse(Response response, ResponseInterceptorHandler handler) async {
    // If caching is enabled, save the returned result to cache
    if (Global.profile.cache!.enable) {
      _saveCache(response);
    }
    handler.next(response);
  }

  _saveCache(Response object) {
    RequestOptions options = object.requestOptions;
    if (options.extra["noCache"] != true &&
        options.method.toLowerCase() == "get") {
      // If the number of caches exceeds the maximum limit, remove the oldest record first
      if (cache.length == Global.profile.cache!.maxCount) {
        cache.remove(cache[cache.keys.first]);
      }
      String key = options.extra["cacheKey"] ?? options.uri.toString();
      cache[key] = CacheObject(object);
    }
  }

  void delete(String key) {
    cache.remove(key);
  }
}

The explanations of the code are in the comments. It is important to note that the options.extra in the Dio package is specifically for extending request parameters. We defined two parameters, “refresh” and “noCache,” to implement the mechanism for deciding whether to enable caching for specific interfaces or requests. The meanings of these parameters are as follows:

Parameter NameTypeDescription
refreshboolIf true, this request will not use the cache, but the new request result will still be cached.
noCacheboolThis request disables caching; the request result will not be cached.

2 Encapsulating Network Requests

A complete app may involve many network requests. To facilitate management and consolidate the request entry points, the best practice is to place all network requests in the same source file. Since our interfaces all call APIs provided by the GitHub development platform, we define a Git class specifically for GitHub API calls. Additionally, during debugging, we often need tools to view network request and response packets. Using a network proxy tool to debug network data issues is the mainstream approach. Configuring the proxy requires specifying the address and port of the proxy server in the application, and since the GitHub API uses HTTPS, we should also disable certificate verification after configuring the proxy. These configurations are executed when initializing the Git class (in the init() method). Below is the source code for the Git class:

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:dio/adapter.dart';
import 'package:dio/dio.dart';
import '../index.dart';
export 'package:dio/dio.dart' show DioError;

class Git {
  // During network requests, the current context information may be needed, such as opening a new route on request failure.
  Git([this.context]) {
    _options = Options(extra: {"context": context});
  }

  BuildContext? context;
  late Options _options;
  static Dio dio = new Dio(BaseOptions(
    baseUrl: 'https://api.github.com/',
    headers: {
      HttpHeaders.acceptHeader: "application/vnd.github.squirrel-girl-preview,"
          "application/vnd.github.symmetra-preview+json",
    },
  ));

  static void init() {
    // Add cache plugin
    dio.interceptors.add(Global.netCache);
    // Set user token (may be null, representing not logged in)
    dio.options.headers[HttpHeaders.authorizationHeader] = Global.profile.token;

    // In debug mode, we need to capture and debug packets, so we use a proxy and disable HTTPS certificate verification
    if (!Global.isRelease) {
      (dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate =
          (client) {
        // client.findProxy = (uri) {
        //   return 'PROXY 192.168.50.154:8888';
        // };
        // The proxy tool will provide a self-signed certificate for packet capture, which will fail certificate verification, so we disable it
        client.badCertificateCallback =
            (X509Certificate cert, String host, int port) => true;
      };
    }
  }

  // Login interface, returns user information upon successful login
  Future<User> login(String login, String pwd) async {
    String basic = 'Basic ' + base64.encode(utf8.encode('$login:$pwd'));
    var r = await dio.get(
      "/user",
      options: _options.copyWith(headers: {
        HttpHeaders.authorizationHeader: basic
      }, extra: {
        "noCache": true, // Disable caching for this interface
      }),
    );
    // After a successful login, update the common header (authorization); all subsequent requests will carry user identity information
    dio.options.headers[HttpHeaders.authorizationHeader] = basic;
    // Clear all caches
    Global.netCache.cache.clear();
    // Update the token information in the profile
    Global.profile.token = basic;
    return User.fromJson(r.data);
  }

  // Get user repositories
  Future<List<Repo>> getRepos({
    Map<String, dynamic>? queryParameters, // Query parameters for pagination information
    refresh = false,
  }) async {
    if (refresh) {
      // List pull-to-refresh, need to delete cache (the interceptor will read this information)
      _options.extra!.addAll({"refresh": true, "list": true});
    }
    var r = await dio.get<List>(
      "user/repos",
      queryParameters: queryParameters,
      options: _options,
    );
    return r.data!.map((e) => Repo.fromJson(e)).toList();
  }
}

In the init() method, we check whether the environment is in debug mode and perform some network configurations specific to the debug environment (such as setting a proxy and disabling certificate verification). The Git.init() method is called during the application startup (it is invoked within the Global.init() method).

Additionally, it is important to note that all our network requests are made through a single dio instance (a static variable). When creating this dio instance, we globally configured the base URL for the GitHub API and the headers supported by the API. As a result, all requests made through this dio instance will use these configurations by default.

In this example, we only utilized the login interface and the interface for fetching user repositories. Therefore, the Git class only defines the login(...) and getRepos(...) methods. If readers wish to expand the functionality based on this example, they can add other interface request methods to the Git class, achieving centralized management and maintenance of network request interfaces at the code level.