04-Flutter 鸿蒙实战 04:封装 ApiClient,请求 JSON、字节流和错误提示
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 暴露了 getJson、getJsonList、postJson、patchJson、deleteJson:
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 覆盖,这个做法更适合交付安装包。
更多推荐




所有评论(0)