04. Flutter 鸿蒙实战 04:封装 ApiClient,请求 JSON、字节流和错误提示

在这里插入图片描述

不用简单 http 包,直接基于 dart:io 封装统一网络层。

网络层为什么单独封装

项目的网络请求没有散落在页面里,而是统一放在 lib/core/network/api_client.dart。这个文件超过一万行字节数,承担 JSON 请求、字节流请求、multipart 上传、token 头、错误转换等公共能力。对于 Flutter 鸿蒙项目,这样做非常有必要:移动端网络错误、后端状态码、文件上传进度、视频字节下载,都需要一致处理。

基础配置来自 AppConfig

abstract final class AppConfig {
  static const apiBaseUrl = String.fromEnvironment(
    'API_BASE_URL',
    defaultValue: 'https://talking-album.zifeiyu.site',
  );
}

这里保留了 --dart-define=API_BASE_URL=... 的能力。开发时可以指向本机后端,正式包默认走线上服务器。项目变更记录里也记录过一次真实问题:默认地址如果是 127.0.0.1,安装到手机后会请求手机本机,不会请求电脑。

JSON 请求

ApiClient 暴露了 getJsongetJsonListpostJsonpatchJsondeleteJson

Future<Map<String, dynamic>> getJson(String path) async {
  final result = await _send('GET', path);
  if (result is! Map<String, dynamic>) {
    throw ApiException(_invalidResponseMessage, path: path);
  }
  return result;
}

Future<List<dynamic>> getJsonList(String path) async {
  final result = await _send('GET', path);
  if (result is! List<dynamic>) {
    throw ApiException(_invalidResponseMessage, path: path);
  }
  return result;
}

这里强制校验返回结构。接口约定返回对象就必须是对象,返回数组就必须是数组。否则页面拿到错误结构后再崩溃,定位会更麻烦。

Token 注入

ApiClient 持有 accessToken。登录成功后,AuthController 会把 token 写进去:

_apiClient.accessToken = result.token;

请求时自动设置 Authorization:

if (accessToken case final token?) {
  request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $token');
}

这样 Repository 不需要关心 token。登录、故事、上传、个人资料等模块都通过同一个 ApiClient 请求,权限头由网络层统一处理。

字节流请求

项目里照片、头像、活化视频都不是普通 JSON。ApiClient 提供 getBytes

Future<Uint8List> getBytes(String path) => _requestBytes('GET', path);

Future<Uint8List> _requestBytes(String method, String path) async {
  return _requestBytesUrl(
    method,
    Uri.parse('${AppConfig.apiBaseUrl}$path'),
    path,
    authorized: true,
  );
}

故事列表展示缩略图、地图 marker 展示照片、故事详情加载原图,都会用到字节流。把字节请求封装起来,可以复用 token、状态码判断和错误转换。

Multipart 上传

照片上传和头像上传都需要 multipart。项目没有把上传逻辑塞到页面里,而是在 ApiClient 里实现了通用方法:

Future<Map<String, dynamic>> uploadMultipartBytes({
  required String path,
  required String fieldName,
  required Uint8List bytes,
  required String filename,
  required String mimeType,
  void Function(double)? onProgress,
}) async {
  final boundary = 'talking-album-${DateTime.now().microsecondsSinceEpoch}';
  final prefix = utf8.encode(
    '--$boundary\r\n'
    'Content-Disposition: form-data; name="$fieldName"; filename="$filename"\r\n'
    'Content-Type: $mimeType\r\n\r\n',
  );
  final suffix = utf8.encode('\r\n--$boundary--\r\n');
  final total = prefix.length + bytes.length + suffix.length;
}

上传进度通过 onProgress 回调给页面。页面只负责显示进度条,不需要理解 multipart 边界和分片写入。

用户可读错误

网络层定义了常见错误文案:

static const _networkMessage = '未连接网络,请确认网络是否正常';
static const _timeoutMessage = '网络连接超时,请稍后重试';
static const _requestFailedMessage = '操作失败,请稍后重试';
static const _invalidResponseMessage = '服务器返回数据异常,请稍后重试';

这类文案在移动端比原始异常更重要。用户看到 SocketException 没有意义,看到“未连接网络”才知道该检查网络。

在这里插入图片描述

扩展拆解

ApiClient 的可复用价值

ApiClient 最大的价值是把所有请求风格统一。登录、注册、故事、地图、上传、头像、视频都走同一个网络入口。后续如果要加请求日志、统一超时、统一重试、统一 token 过期处理,只需要改网络层,不需要逐个页面修改。

项目里的请求类型可以分成三类:

JSON 请求:登录、注册、故事列表、地图地点、纪念日
字节请求:照片原图、头像、地图缩略图、活化视频
Multipart:照片上传、头像上传

这三类请求都已经在 ApiClient 中有入口。页面不直接操作 HttpClient,而是调用 Repository;Repository 再调用 ApiClient,网络细节统一收口到基础层。

默认 API 地址的坑

项目记录里修过“重装后显示网络未连接”,原因是默认 API 地址曾经指向 127.0.0.1。手机里的 127.0.0.1 是手机自己,不是电脑。后来默认值改成线上服务,开发调试时通过 --dart-define 覆盖,这个做法更适合交付安装包。
认 API 地址的坑

项目记录里修过“重装后显示网络未连接”,原因是默认 API 地址曾经指向 127.0.0.1。手机里的 127.0.0.1 是手机自己,不是电脑。后来默认值改成线上服务,开发调试时通过 --dart-define 覆盖,这个做法更适合交付安装包。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐