flutter 开发笔记(四):http 请求

166 阅读4分钟

在开发一个应用程序时,请求后端数据是必不可少的功能。本文将展示如何使用 Flutter 的 http 包,通过一个示例演示如何在 Flutter 中调用 API 并渲染数据

安装

在 Flutter 和 Dart 中,http 包是最广泛使用和知名的用于处理 HTTP 请求的包之一。它提供了简洁的 API 用于发送 GET、POST、PUT、DELETE 等请求,适合大多数常见的网络请求场景

dependencies:
  flutter:
    sdk: flutter
  http: ^1.2.2

运行 flutter pub get 时,Dart 会自动安装最新的稳定版本,想确保使用特定的最新版本,可以查看 pub.dev/packages/ht… ,找到最新的版本号并手动替换

GET 请求示例

以 GitHub 的 API 列表为例,链接为 api.github.com/ ;根据它的返回数据,我们可以创建一个数据类,用来反序列化 API 成功返回后的 body

class GitHubApiUrls {
  final String currentUserUrl;
  final String currentUserAuthorizationsHtmlUrl;
  final String authorizationsUrl;
  final String codeSearchUrl;
  final String commitSearchUrl;
  final String emailsUrl;
  final String emojisUrl;
  final String eventsUrl;
  final String feedsUrl;
  final String followersUrl;
  final String followingUrl;
  final String gistsUrl;
  final String hubUrl;
  final String issueSearchUrl;
  final String issuesUrl;
  final String keysUrl;
  final String labelSearchUrl;
  final String notificationsUrl;
  final String organizationUrl;
  final String organizationRepositoriesUrl;
  final String organizationTeamsUrl;
  final String publicGistsUrl;
  final String rateLimitUrl;
  final String repositoryUrl;
  final String repositorySearchUrl;
  final String currentUserRepositoriesUrl;
  final String starredUrl;
  final String starredGistsUrl;
  final String topicSearchUrl;
  final String userUrl;
  final String userOrganizationsUrl;
  final String userRepositoriesUrl;
  final String userSearchUrl;
  GitHubApiUrls({
    required this.currentUserUrl,
    required this.currentUserAuthorizationsHtmlUrl,
    required this.authorizationsUrl,
    required this.codeSearchUrl,
    required this.commitSearchUrl,
    required this.emailsUrl,
    required this.emojisUrl,
    required this.eventsUrl,
    required this.feedsUrl,
    required this.followersUrl,
    required this.followingUrl,
    required this.gistsUrl,
    required this.hubUrl,
    required this.issueSearchUrl,
    required this.issuesUrl,
    required this.keysUrl,
    required this.labelSearchUrl,
    required this.notificationsUrl,
    required this.organizationUrl,
    required this.organizationRepositoriesUrl,
    required this.organizationTeamsUrl,
    required this.publicGistsUrl,
    required this.rateLimitUrl,
    required this.repositoryUrl,
    required this.repositorySearchUrl,
    required this.currentUserRepositoriesUrl,
    required this.starredUrl,
    required this.starredGistsUrl,
    required this.topicSearchUrl,
    required this.userUrl,
    required this.userOrganizationsUrl,
    required this.userRepositoriesUrl,
    required this.userSearchUrl,
  });
  factory GitHubApiUrls.fromJson(Map<String, dynamic> json) {
    return GitHubApiUrls(
      currentUserUrl: json['current_user_url'],
      currentUserAuthorizationsHtmlUrl:
          json['current_user_authorizations_html_url'],
      authorizationsUrl: json['authorizations_url'],
      codeSearchUrl: json['code_search_url'],
      commitSearchUrl: json['commit_search_url'],
      emailsUrl: json['emails_url'],
      emojisUrl: json['emojis_url'],
      eventsUrl: json['events_url'],
      feedsUrl: json['feeds_url'],
      followersUrl: json['followers_url'],
      followingUrl: json['following_url'],
      gistsUrl: json['gists_url'],
      hubUrl: json['hub_url'],
      issueSearchUrl: json['issue_search_url'],
      issuesUrl: json['issues_url'],
      keysUrl: json['keys_url'],
      labelSearchUrl: json['label_search_url'],
      notificationsUrl: json['notifications_url'],
      organizationUrl: json['organization_url'],
      organizationRepositoriesUrl: json['organization_repositories_url'],
      organizationTeamsUrl: json['organization_teams_url'],
      publicGistsUrl: json['public_gists_url'],
      rateLimitUrl: json['rate_limit_url'],
      repositoryUrl: json['repository_url'],
      repositorySearchUrl: json['repository_search_url'],
      currentUserRepositoriesUrl: json['current_user_repositories_url'],
      starredUrl: json['starred_url'],
      starredGistsUrl: json['starred_gists_url'],
      topicSearchUrl: json['topic_search_url'],
      userUrl: json['user_url'],
      userOrganizationsUrl: json['user_organizations_url'],
      userRepositoriesUrl: json['user_repositories_url'],
      userSearchUrl: json['user_search_url'],
    );
  }
  Map<String, dynamic> toJson() {
    return {
      'current_user_url': currentUserUrl,
      'current_user_authorizations_html_url': currentUserAuthorizationsHtmlUrl,
      'authorizations_url': authorizationsUrl,
      'code_search_url': codeSearchUrl,
      'commit_search_url': commitSearchUrl,
      'emails_url': emailsUrl,
      'emojis_url': emojisUrl,
      'events_url': eventsUrl,
      'feeds_url': feedsUrl,
      'followers_url': followersUrl,
      'following_url': followingUrl,
      'gists_url': gistsUrl,
      'hub_url': hubUrl,
      'issue_search_url': issueSearchUrl,
      'issues_url': issuesUrl,
      'keys_url': keysUrl,
      'label_search_url': labelSearchUrl,
      'notifications_url': notificationsUrl,
      'organization_url': organizationUrl,
      'organization_repositories_url': organizationRepositoriesUrl,
      'organization_teams_url': organizationTeamsUrl,
      'public_gists_url': publicGistsUrl,
      'rate_limit_url': rateLimitUrl,
      'repository_url': repositoryUrl,
      'repository_search_url': repositorySearchUrl,
      'current_user_repositories_url': currentUserRepositoriesUrl,
      'starred_url': starredUrl,
      'starred_gists_url': starredGistsUrl,
      'topic_search_url': topicSearchUrl,
      'user_url': userUrl,
      'user_organizations_url': userOrganizationsUrl,
      'user_repositories_url': userRepositoriesUrl,
      'user_search_url': userSearchUrl,
    };
  }
}

然后,可以搭建一个 ApiPage 来渲染返回的结果。可以看到,Future 相关的概念在代码中出现了不少次。在 Dart 中,Future 是一个非常重要的概念,尤其是在进行 HTTP 请求时。Future 对象表示一个可能在未来某个时间点完成的异步操作,用于处理那些需要一段时间才能完成的操作,比如网络请求、文件读取等

class ApiPage extends StatefulWidget {
  const ApiPage({super.key});

  @override
  State<ApiPage> createState() => _ApiPageState();
}

class _ApiPageState extends State<ApiPage> with SingleTickerProviderStateMixin {
  late Future<GitHubApiUrls> futureApiUrls;

  @override
  void initState() {
    super.initState();
    futureApiUrls = fetchApiUrls();
  }

  Future<GitHubApiUrls> fetchApiUrls() async {
    final response = await http.get(Uri.parse('https://api.github.com/'));
    if (response.statusCode == 200) {
      return GitHubApiUrls.fromJson(json.decode(response.body));
    } else {
      throw Exception('Failed to load API URLs');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: const Text('GitHub API URLs'),
        ),
        body: FutureBuilder<GitHubApiUrls>(
          future: futureApiUrls,
          builder: (context, snapshot) {
            if (snapshot.connectionState == ConnectionState.waiting) {
              return const Center(child: CircularProgressIndicator());
            } else if (snapshot.hasError) {
              return Center(child: Text('Error: ${snapshot.error}'));
            } else if (!snapshot.hasData) {
              return const Center(child: Text('No data'));
            } else {
              GitHubApiUrls apiUrls = snapshot.data!;
              return ListView(
                padding: const EdgeInsets.all(16.0),
                children: apiUrls
                    .toJson()
                    .entries
                    .map((entry) => buildRow(entry.key, entry.value))
                    .toList(),
              );
            }
          },
        ));
  }

  Widget buildRow(String key, String value) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          key,
          style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
        ),
        const SizedBox(height: 4),
        Text(value, style: const TextStyle(fontSize: 16)),
        const SizedBox(height: 16),
      ],
    );
  }
}

其他 method 的请求示例

让我们用 PUT 请求给 vue3 点个 star,使用方法很容易旁类触通,类似于 js 的 axios,token 参数该如何获取,可以参考 juejin.cn/post/744480… 文章中提到的 Personal Access Token

  Future<void> starRepository(String token) async {
    final url = Uri.parse('https://api.github.com/user/starred/vuejs/core');
    final response = await http.put(
      url,
      headers: {
        'Authorization': 'token $token',
        'Accept': 'application/vnd.github.v3+json',
      },
    );
    if (response.statusCode == 204) {
      print('Repository starred successfully');
    } else {
      print('Failed to star repository: ${response.body}');
    }
  }

封装

封装 http 在实际开发中基本是必不可少的,我们可以试着封装两个功能

  1. 实现 baseUrl
  2. 实现全局 loading

第一个功能实现很简单,创建一个 http_service.dart 文件,再创建一个 class HttpService,里面添加一个私有方法 _buildUrl,用于拼接 baseUrl 和剩余部分即可

import 'dart:convert';
import 'package:http/http.dart' as http;
import 'dart:async';

class HttpService {
  static final HttpService _singleton = HttpService._internal();
  factory HttpService() => _singleton;
  static const String baseUrl = 'https://api.github.com';

  Uri _buildUrl(String url) {
    return Uri.parse('$baseUrl$url');
  }

  HttpService._internal();

  Future<http.Response> get(String url, {Map<String, String>? headers}) async {
    try {
      final response = await http.get(_buildUrl(url), headers: headers);
      return response;
    } catch (e) {
      rethrow;
    }
  }

  Future<http.Response> post(String url,
      {Map<String, String>? headers, Object? body, Encoding? encoding}) async {
    try {
      final response = await http.post(_buildUrl(url),
          headers: headers, body: body, encoding: encoding);
      return response;
    } catch (e) {
      rethrow;
    }
  }

  Future<http.Response> put(String url,
      {Map<String, String>? headers, Object? body, Encoding? encoding}) async {
    try {
      final response = await http.put(_buildUrl(url),
          headers: headers, body: body, encoding: encoding);
      return response;
    } catch (e) {
      rethrow;
    }
  }
}

第二个需求就比较难了,首先,分几步走,我们希望的是当 api 开始 call 的时候自动出 loading,结束则自动结束,如何连续 call 多个 api,则需要等待 api 全部 call 完才结束 loading,因此,可以维护一个计数器,当计数器大于 0 的时候显示 loading,为 0 则隐藏;下面创建 loading_manager.dart 文件

为了状态一致性,这里使用了单例;ChangeNotifier 用于管理状态并通知监听器。它通常与 Provider 库一起使用,以便在 Flutter 应用中管理和共享状态

import 'package:flutter/material.dart';

class LoadingManager with ChangeNotifier {
  static final LoadingManager _singleton = LoadingManager._internal();
  factory LoadingManager() => _singleton;

  LoadingManager._internal();

  int _requestCount = 0;

  int get requestCount => _requestCount;

  void showLoading() {
    _requestCount++;
    notifyListeners();
  }

  void hideLoading() {
    _requestCount--;
    notifyListeners();
  }
}

安装 Provider 库以便后续使用

dependencies:
  flutter:
    sdk: flutter
  provider: ^6.1.2

接下来就是加上 UI 了,明确下需求,当 loading 的时候肯定不可以点击其他区域,因此外面得加上一个 Modal,在 flutter 中可以使用 ModalBarrier,另外 loading 肯定得是全局的,因此得使用 OverlayEntry ,它是 Flutter 中用于在应用的最顶层显示临时 UI 元素的类。它允许你在当前 widget 树上添加新的 widget,而不需要重新构建整个 widget 树;下面创建 global_loading.dart 文件

import 'package:flutter/material.dart';

class GlobalLoading {
  static final GlobalLoading _singleton = GlobalLoading._internal();
  factory GlobalLoading() => _singleton;

  GlobalLoading._internal();

  OverlayEntry? _overlayEntry;

  void show(BuildContext context) {
    if (_overlayEntry == null) {
      _overlayEntry = OverlayEntry(
        builder: (context) => Stack(
          children: [
            ModalBarrier(
              dismissible: false,
              color: Colors.black.withOpacity(0.5),
            ),
            const Center(
              child: CircularProgressIndicator(),
            ),
          ],
        ),
      );
      Overlay.of(context).insert(_overlayEntry!);
    }
  }

  void hide() {
    _overlayEntry?.remove();
    _overlayEntry = null;
  }
}

接下来就应该在 HttpService 中接入 loading 相关的内容了

import 'dart:convert';

import 'package:http/http.dart' as http;
import 'dart:async';
import 'loading_manager.dart';

class HttpService {
  static final HttpService _singleton = HttpService._internal();
  factory HttpService() => _singleton;
  static const String baseUrl = 'https://api.github.com';

  Uri _buildUrl(String url) {
    return Uri.parse('$baseUrl$url');
  }

  HttpService._internal();

  Future<http.Response> get(String url, {Map<String, String>? headers}) async {
    LoadingManager().showLoading();
    try {
      final response = await http.get(_buildUrl(url), headers: headers);
      return response;
    } catch (e) {
      rethrow;
    } finally {
      LoadingManager().hideLoading();
    }
  }

  Future<http.Response> post(String url,
      {Map<String, String>? headers, Object? body, Encoding? encoding}) async {
    LoadingManager().showLoading();
    try {
      final response = await http.post(_buildUrl(url),
          headers: headers, body: body, encoding: encoding);
      return response;
    } catch (e) {
      rethrow;
    } finally {
      LoadingManager().hideLoading();
    }
  }

  Future<http.Response> put(String url,
      {Map<String, String>? headers, Object? body, Encoding? encoding}) async {
    LoadingManager().showLoading();
    try {
      final response = await http.put(_buildUrl(url),
          headers: headers, body: body, encoding: encoding);
      return response;
    } catch (e) {
      rethrow;
    } finally {
      LoadingManager().hideLoading();
    }
  }
}

当你做了以上所有的步骤后,仍然看不到 loading,毕竟还没在 main.dart 中把根据 requestCount 显示的逻辑加上

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:test_drive/api_page.dart';
import 'package:test_drive/global_loading.dart';
import 'package:test_drive/loading_manager.dart';

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => LoadingManager(),
      child: const MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Routes Example',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      routes: {
        '/': (context) => const HomePage(),
        '/api': (context) => const ApiPage(),
      },
      initialRoute: '/',
    );
  }
}

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: const Text('Home Page'),
        ),
        body: Stack(children: [
          Center(
            child: ElevatedButton(
              onPressed: () {
                Navigator.pushNamed(context, '/api');
              },
              child: const Text('Go to Api Page'),
            ),
          ),
          Consumer<LoadingManager>(
            builder: (context, loadingManager, child) {
              if (loadingManager.requestCount > 0) {
                WidgetsBinding.instance
                    .addPostFrameCallback((_) => GlobalLoading().show(context));
              } else {
                GlobalLoading().hide();
              }
              return const SizedBox.shrink(); // 返回一个占位空间
            },
          ),
        ]));
  }
}

上面的代码中,ChangeNotifierProvider 是用于提供 ChangeNotifier 类型的状态,并在状态发生变化时通知监听器。ConsumerProvider 库中的一个 widget,用于在 Flutter 应用中监听和响应状态的变化。它提供了一种简洁的方式来访问和使用 Provider 提供的状态,并在状态发生变化时自动重建相关的 widget

使用

首先需要实例化

final HttpService httpService = HttpService();

上面提及的 put 方法很容易改成

  Future<void> starRepository() async {
    final response = await httpService.put(
      '/user/starred/vuejs/core',
      headers: {
        'Authorization': 'token $token',
        'Accept': 'application/vnd.github.v3+json',
      },
    );
    if (response.statusCode == 204) {
      print('Repository starred successfully');
    } else {
      print('Failed to star repository: ${response.body}');
    }
  }

但是 initState 处调用的 futureApiUrls = fetchApiUrls(); 就需要改动比较大了,如果不改的话,会遇到 setState() or markNeedsBuild() called during build.,很显然,在构建过程中,requestCount 会加 1,大于 0 则会触发 loading 部分的构建,但此时 widget 尚未完成构建好

解决方法是使用 WidgetsBinding.instance.addPostFrameCallback,确保在构建完成后再调用 setState(),避免在构建过程中触发重建

  @override
  void initState() {
    super.initState();
    final completer = Completer<GitHubApiUrls>(); 
    futureApiUrls = completer.future;
    WidgetsBinding.instance.addPostFrameCallback((_) {
      setState(() {
        futureApiUrls = fetchApiUrls();
      });
    });
  }
  
  Future<GitHubApiUrls> fetchApiUrls() async {
    final response = await httpService.get('/');
    if (response.statusCode == 200) {
      return GitHubApiUrls.fromJson(json.decode(response.body));
    } else {
      throw Exception('Failed to load API URLs');
    }
  }

如果有很多个变量需要赋值的话,就得写很多次类似 final completer = Completer<GitHubApiUrls>(); futureApiUrls = completer.future; 的代码,可以创建一个通用的方法来简化这个过程

  @override
  void initState() {
    super.initState();
    futureApiUrls = _initializeFuture(fetchApiUrls);
  }

  Future<T> _initializeFuture<T>(Future<T> Function() fetchFunction) {
    final completer = Completer<T>();
    WidgetsBinding.instance.addPostFrameCallback((_) async {
      try {
        final data = await fetchFunction();
        completer.complete(data);
      } catch (e) {
        completer.completeError(e);
      }
    });
    return completer.future;
  }