在这里插入图片描述

网络请求是几乎所有应用都要做的事情。但如果每个页面都自己写网络请求代码,很快就会变成一团乱麻:重复代码到处都是,错误处理各不相同,想改个接口地址要改好几个地方。

今天这篇文章,咱们就来聊聊怎么把API服务封装好,让网络请求变得简单、统一、易维护。

为什么要封装API服务

先看看不封装会怎样。假设在首页要获取新闻列表:

// 首页
Future<void> _loadNews() async {
  final response = await http.get(Uri.parse('https://api.example.com/news'));
  if (response.statusCode == 200) {
    final data = json.decode(response.body);
    // 处理数据...
  }
}

然后在搜索页也要请求:

// 搜索页
Future<void> _searchNews(String query) async {
  final response = await http.get(Uri.parse('https://api.example.com/news?search=$query'));
  if (response.statusCode == 200) {
    final data = json.decode(response.body);
    // 处理数据...
  }
}

问题来了:

接口地址重复https://api.example.com写了好几遍,要改的话得改好几个地方。

错误处理不统一:有的地方处理了异常,有的地方没处理,出问题时行为不一致。

代码重复:JSON解析、状态码判断这些逻辑到处都是。

难以测试:网络请求散落在各个页面,想写单元测试很困难。

封装API服务就是为了解决这些问题。

ApiService的基本结构

来看看项目里的API服务是怎么封装的:

import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/news_article.dart';

class ApiService {
  static const String spaceflightNewsUrl = 'https://api.spaceflightnewsapi.net/v4/articles';

首先导入必要的包:dart:convert用于JSON编解码,http包用于发送HTTP请求,还有数据模型。

接口地址用常量定义static const表示这是一个编译时常量,不会变。把接口地址集中定义,要改的时候只改一个地方。

获取新闻列表

Future<List<NewsArticle>> fetchSpaceNews({int limit = 20, int offset = 0}) async {
  try {
    final response = await http.get(
      Uri.parse('$spaceflightNewsUrl?limit=$limit&offset=$offset'),
    );

    if (response.statusCode == 200) {
      final data = json.decode(response.body);
      final results = data['results'] as List;
      return results.map((json) => NewsArticle.fromSpaceflightJson(json)).toList();
    }
    return [];
  } catch (e) {
    return [];
  }
}

来拆解一下这段代码:

返回类型是Future<List<NewsArticle>>:异步方法返回Future,里面是新闻文章列表。调用方拿到的是类型安全的对象列表,不是原始JSON。

参数有默认值limit = 20offset = 0,调用时可以不传,用默认值;也可以传自定义值,实现分页。

用try-catch包裹:网络请求可能失败(网络断开、服务器错误、超时等),用try-catch捕获异常,避免应用崩溃。

检查状态码:只有200才处理数据,其他状态码返回空列表。实际项目中可能需要更细致的处理。

JSON转模型results.map((json) => NewsArticle.fromSpaceflightJson(json)).toList()把JSON数组转成模型对象列表。

搜索新闻

Future<List<NewsArticle>> searchSpaceNews(String query) async {
  try {
    final response = await http.get(
      Uri.parse('$spaceflightNewsUrl?search=$query&limit=20'),
    );

    if (response.statusCode == 200) {
      final data = json.decode(response.body);
      final results = data['results'] as List;
      return results.map((json) => NewsArticle.fromSpaceflightJson(json)).toList();
    }
    return [];
  } catch (e) {
    return [];
  }
}

搜索接口和列表接口结构类似,只是URL参数不同。search=$query把用户输入的关键词传给后端。

你可能注意到了,这两个方法有很多重复代码。后面会讲怎么优化。

模拟数据的实现

开发阶段,后端接口可能还没准备好,或者想避免频繁请求真实接口。这时候可以用模拟数据:

Future<List<NewsArticle>> fetchTechNews() async {
  await Future.delayed(const Duration(seconds: 1));
  return _generateMockNews('tech', '科技资讯');
}

Future.delayed模拟网络延迟:真实网络请求需要时间,模拟数据也加个延迟,让UI表现更接近真实情况。如果模拟数据瞬间返回,可能会掩盖一些加载状态的bug。

生成模拟数据

List<NewsArticle> _generateMockNews(String category, String source) {
  final titles = {
    'tech': [
      'AI突破:新模型超越人类表现',
      '量子计算达到新里程碑',
      '科技巨头宣布重大合作',
      '革命性电池技术发布',
      '网络安全威胁持续上升',
    ],
    'sports': [
      '冠军决赛:惊险获胜',
      '田径赛场打破纪录',
      '转会新闻:重要球员转会',
      '奥运会筹备工作进行中',
      '体育科技创新',
    ],
    // ... 其他分类
  };

  final categoryTitles = titles[category] ?? titles['tech']!;
  
  return List.generate(10, (index) {
    final now = DateTime.now().subtract(Duration(hours: index));
    return NewsArticle(
      id: '${category}_${now.millisecondsSinceEpoch}_$index',
      title: categoryTitles[index % categoryTitles.length],
      summary: '这是关于${categoryTitles[index % categoryTitles.length].toLowerCase()}的详细摘要。'
          '通过我们专家团队的最新更新和深度分析,让您随时了解最新资讯。',
      imageUrl: null,
      url: 'https://example.com/news/${category}_$index',
      publishedAt: now.toIso8601String(),
      source: source,
      category: category,
      tags: [category, '热门', '推荐'],
    );
  });
}

这段代码做了几件事:

预定义标题库:每个分类有一组预设的标题,模拟数据从中循环取用。

生成唯一ID:用分类、时间戳、索引组合成ID,确保每条数据的ID都不同。

模拟时间递减:每条新闻的发布时间比上一条早1小时,模拟真实的新闻流。

方法名以下划线开头_generateMockNews是私有方法,只在类内部使用,外部调用不到。

在Provider中使用API服务

API服务封装好了,怎么在应用里使用呢?看看NewsProvider:

class NewsProvider extends ChangeNotifier {
  final ApiService _apiService = ApiService();
  
  final Map<String, List<NewsArticle>> _newsCache = {};
  bool _isLoading = false;
  String? _error;

创建ApiService实例:Provider持有一个ApiService实例,所有网络请求都通过它发出。

缓存机制_newsCache是一个Map,key是分类名,value是该分类的新闻列表。请求过的数据会缓存起来,避免重复请求。

加载状态和错误信息_isLoading_error用于UI展示加载中和错误状态。

获取新闻的方法

Future<void> fetchNews(String category) async {
  // 如果缓存中有数据,直接返回
  if (_newsCache.containsKey(category) && _newsCache[category]!.isNotEmpty) {
    return;
  }

  _isLoading = true;
  _error = null;
  notifyListeners();

  try {
    List<NewsArticle> articles;
    
    switch (category) {
      case 'space':
        articles = await _apiService.fetchSpaceNews();
        break;
      case 'tech':
        articles = await _apiService.fetchTechNews();
        break;
      case 'sports':
        articles = await _apiService.fetchSportsNews();
        break;
      // ... 其他分类
      default:
        articles = await _apiService.fetchSpaceNews();
    }

    _newsCache[category] = articles;
  } catch (e) {
    _error = e.toString();
  } finally {
    _isLoading = false;
    notifyListeners();
  }
}

缓存检查:先检查缓存,有数据就直接返回,不发请求。这样用户切换分类时,已加载过的分类不会重新请求。

状态更新:请求前设置_isLoading = true,请求后设置_isLoading = false。每次状态变化都调用notifyListeners()通知UI刷新。

switch分发:根据分类调用不同的API方法。这里用switch是因为不同分类可能调用不同的接口。

finally确保状态恢复:无论成功还是失败,都要把_isLoading设回false。用finally确保这段代码一定会执行。

刷新新闻

Future<void> refreshNews(String category) async {
  _newsCache.remove(category);
  await fetchNews(category);
}

刷新就是清除缓存后重新请求。先remove删除缓存,再调用fetchNews。因为缓存被清了,fetchNews里的缓存检查会失败,就会发起新请求。

优化:提取公共请求方法

前面说过,fetchSpaceNewssearchSpaceNews有很多重复代码。可以提取一个公共方法:

class ApiService {
  static const String _baseUrl = 'https://api.spaceflightnewsapi.net/v4';
  
  Future<List<NewsArticle>> _fetchArticles(String endpoint) async {
    try {
      final response = await http.get(Uri.parse('$_baseUrl$endpoint'));

      if (response.statusCode == 200) {
        final data = json.decode(response.body);
        final results = data['results'] as List;
        return results.map((json) => NewsArticle.fromSpaceflightJson(json)).toList();
      }
      return [];
    } catch (e) {
      return [];
    }
  }
  
  Future<List<NewsArticle>> fetchSpaceNews({int limit = 20, int offset = 0}) {
    return _fetchArticles('/articles?limit=$limit&offset=$offset');
  }
  
  Future<List<NewsArticle>> searchSpaceNews(String query) {
    return _fetchArticles('/articles?search=$query&limit=20');
  }
}

_fetchArticles是私有方法,封装了HTTP请求、JSON解析、错误处理的通用逻辑。公开方法只需要传入不同的endpoint。

优化:更完善的错误处理

返回空列表虽然简单,但调用方不知道是真的没数据,还是请求失败了。可以用自定义异常或Result类型:

方案一:抛出异常

class ApiException implements Exception {
  final String message;
  final int? statusCode;
  
  ApiException(this.message, {this.statusCode});
  
  
  String toString() => 'ApiException: $message (status: $statusCode)';
}

Future<List<NewsArticle>> fetchSpaceNews({int limit = 20, int offset = 0}) async {
  try {
    final response = await http.get(
      Uri.parse('$spaceflightNewsUrl?limit=$limit&offset=$offset'),
    );

    if (response.statusCode == 200) {
      final data = json.decode(response.body);
      final results = data['results'] as List;
      return results.map((json) => NewsArticle.fromSpaceflightJson(json)).toList();
    } else if (response.statusCode == 404) {
      throw ApiException('资源不存在', statusCode: 404);
    } else if (response.statusCode == 500) {
      throw ApiException('服务器错误', statusCode: 500);
    } else {
      throw ApiException('请求失败', statusCode: response.statusCode);
    }
  } on SocketException {
    throw ApiException('网络连接失败,请检查网络');
  } on TimeoutException {
    throw ApiException('请求超时,请稍后重试');
  } on FormatException {
    throw ApiException('数据格式错误');
  }
}

调用方用try-catch处理:

try {
  final articles = await _apiService.fetchSpaceNews();
  // 处理数据
} on ApiException catch (e) {
  // 显示错误信息
  showError(e.message);
}

方案二:Result类型

class Result<T> {
  final T? data;
  final String? error;
  
  Result.success(this.data) : error = null;
  Result.failure(this.error) : data = null;
  
  bool get isSuccess => error == null;
  bool get isFailure => error != null;
}

Future<Result<List<NewsArticle>>> fetchSpaceNews({int limit = 20, int offset = 0}) async {
  try {
    final response = await http.get(
      Uri.parse('$spaceflightNewsUrl?limit=$limit&offset=$offset'),
    );

    if (response.statusCode == 200) {
      final data = json.decode(response.body);
      final results = data['results'] as List;
      final articles = results.map((json) => NewsArticle.fromSpaceflightJson(json)).toList();
      return Result.success(articles);
    }
    return Result.failure('请求失败:${response.statusCode}');
  } catch (e) {
    return Result.failure('网络错误:$e');
  }
}

调用方判断结果:

final result = await _apiService.fetchSpaceNews();
if (result.isSuccess) {
  // 处理 result.data
} else {
  // 显示 result.error
}

Result类型的好处是不用try-catch,代码更清晰。缺点是每次都要判断成功失败。

优化:添加请求超时

网络请求可能卡住很久,需要设置超时:

Future<List<NewsArticle>> fetchSpaceNews({int limit = 20, int offset = 0}) async {
  try {
    final response = await http.get(
      Uri.parse('$spaceflightNewsUrl?limit=$limit&offset=$offset'),
    ).timeout(const Duration(seconds: 10));

    // ...
  } on TimeoutException {
    // 超时处理
    return [];
  }
}

.timeout(Duration)设置超时时间,超过这个时间没响应就抛出TimeoutException

优化:添加请求重试

网络不稳定时,自动重试几次可能就成功了:

Future<http.Response> _getWithRetry(String url, {int maxRetries = 3}) async {
  int retries = 0;
  
  while (retries < maxRetries) {
    try {
      final response = await http.get(Uri.parse(url))
          .timeout(const Duration(seconds: 10));
      
      if (response.statusCode == 200) {
        return response;
      }
      
      // 5xx错误可以重试,4xx错误不重试
      if (response.statusCode >= 500) {
        retries++;
        await Future.delayed(Duration(seconds: retries));  // 递增延迟
        continue;
      }
      
      return response;
    } on TimeoutException {
      retries++;
      if (retries >= maxRetries) rethrow;
      await Future.delayed(Duration(seconds: retries));
    } on SocketException {
      retries++;
      if (retries >= maxRetries) rethrow;
      await Future.delayed(Duration(seconds: retries));
    }
  }
  
  throw Exception('请求失败,已重试$maxRetries次');
}

递增延迟:每次重试前等待的时间递增(1秒、2秒、3秒),避免短时间内大量请求。

只重试可恢复的错误:超时、网络断开、服务器错误可以重试;404、403这种客户端错误重试也没用。

优化:请求取消

用户快速切换页面时,之前的请求可能还没完成。这些请求的结果已经不需要了,应该取消掉:

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

class ApiService {
  http.Client? _client;
  
  void cancelRequests() {
    _client?.close();
    _client = null;
  }
  
  Future<List<NewsArticle>> fetchSpaceNews({int limit = 20, int offset = 0}) async {
    _client?.close();
    _client = http.Client();
    
    try {
      final response = await _client!.get(
        Uri.parse('$spaceflightNewsUrl?limit=$limit&offset=$offset'),
      );
      // ...
    } finally {
      _client?.close();
      _client = null;
    }
  }
}

每次请求前关闭之前的client,创建新的client。这样之前的请求就会被取消。

在页面dispose时调用cancelRequests()


void dispose() {
  _apiService.cancelRequests();
  super.dispose();
}

优化:添加请求日志

调试时需要知道请求了什么、返回了什么:

class ApiService {
  final bool _enableLogging = true;  // 生产环境设为false
  
  void _log(String message) {
    if (_enableLogging) {
      print('[ApiService] $message');
    }
  }
  
  Future<List<NewsArticle>> fetchSpaceNews({int limit = 20, int offset = 0}) async {
    final url = '$spaceflightNewsUrl?limit=$limit&offset=$offset';
    _log('GET $url');
    
    final stopwatch = Stopwatch()..start();
    
    try {
      final response = await http.get(Uri.parse(url));
      
      stopwatch.stop();
      _log('Response: ${response.statusCode} (${stopwatch.elapsedMilliseconds}ms)');
      
      if (response.statusCode == 200) {
        final data = json.decode(response.body);
        final results = data['results'] as List;
        _log('Parsed ${results.length} articles');
        return results.map((json) => NewsArticle.fromSpaceflightJson(json)).toList();
      }
      return [];
    } catch (e) {
      stopwatch.stop();
      _log('Error: $e (${stopwatch.elapsedMilliseconds}ms)');
      return [];
    }
  }
}

日志包含:请求URL、响应状态码、耗时、解析结果数量、错误信息。这些信息对调试非常有帮助。

优化:使用拦截器模式

如果有很多通用逻辑(日志、认证、错误处理),可以用拦截器模式:

abstract class Interceptor {
  Future<http.Request> onRequest(http.Request request);
  Future<http.Response> onResponse(http.Response response);
  Future<void> onError(dynamic error);
}

class LoggingInterceptor implements Interceptor {
  
  Future<http.Request> onRequest(http.Request request) async {
    print('[Request] ${request.method} ${request.url}');
    return request;
  }
  
  
  Future<http.Response> onResponse(http.Response response) async {
    print('[Response] ${response.statusCode}');
    return response;
  }
  
  
  Future<void> onError(dynamic error) async {
    print('[Error] $error');
  }
}

class AuthInterceptor implements Interceptor {
  final String token;
  
  AuthInterceptor(this.token);
  
  
  Future<http.Request> onRequest(http.Request request) async {
    request.headers['Authorization'] = 'Bearer $token';
    return request;
  }
  
  
  Future<http.Response> onResponse(http.Response response) async {
    return response;
  }
  
  
  Future<void> onError(dynamic error) async {}
}

实际项目中,可以考虑使用dio包,它内置了拦截器支持。

使用dio替代http

dio是一个功能更强大的HTTP客户端,支持拦截器、取消请求、文件上传下载等:

import 'package:dio/dio.dart';

class ApiService {
  late final Dio _dio;
  
  ApiService() {
    _dio = Dio(BaseOptions(
      baseUrl: 'https://api.spaceflightnewsapi.net/v4',
      connectTimeout: const Duration(seconds: 10),
      receiveTimeout: const Duration(seconds: 10),
    ));
    
    // 添加日志拦截器
    _dio.interceptors.add(LogInterceptor(
      requestBody: true,
      responseBody: true,
    ));
  }
  
  Future<List<NewsArticle>> fetchSpaceNews({int limit = 20, int offset = 0}) async {
    try {
      final response = await _dio.get(
        '/articles',
        queryParameters: {'limit': limit, 'offset': offset},
      );
      
      final results = response.data['results'] as List;
      return results.map((json) => NewsArticle.fromSpaceflightJson(json)).toList();
    } on DioException catch (e) {
      if (e.type == DioExceptionType.connectionTimeout) {
        throw ApiException('连接超时');
      } else if (e.type == DioExceptionType.receiveTimeout) {
        throw ApiException('接收超时');
      } else {
        throw ApiException('网络错误:${e.message}');
      }
    }
  }
}

dio的优势:

BaseOptions:统一配置baseUrl、超时时间等。

queryParameters:自动处理URL参数编码。

拦截器:内置LogInterceptor,也可以自定义。

异常类型:DioException包含详细的错误类型,方便针对性处理。

单例模式

API服务通常全局只需要一个实例:

class ApiService {
  static final ApiService _instance = ApiService._internal();
  
  factory ApiService() => _instance;
  
  ApiService._internal() {
    // 初始化代码
  }
}

这样无论在哪里ApiService(),拿到的都是同一个实例。

或者用Provider/GetIt等依赖注入方案管理实例。

环境配置

开发环境和生产环境的接口地址可能不同:

class ApiConfig {
  static const bool isProduction = bool.fromEnvironment('dart.vm.product');
  
  static String get baseUrl {
    if (isProduction) {
      return 'https://api.example.com';
    } else {
      return 'https://dev-api.example.com';
    }
  }
}

class ApiService {
  final String _baseUrl = ApiConfig.baseUrl;
  // ...
}

bool.fromEnvironment('dart.vm.product')在release模式下是true,debug模式下是false。

常见问题排查

问题一:请求一直loading

检查是否正确调用了notifyListeners()。检查是否在finally里把_isLoading设回false。

问题二:数据不更新

检查缓存逻辑。可能是缓存没清除,一直返回旧数据。

问题三:跨域错误(Web平台)

Flutter Web运行在浏览器里,受同源策略限制。需要后端配置CORS,或者用代理。

问题四:证书错误

开发环境可能用自签名证书,需要配置信任。生产环境一定要用正规证书。

写在最后

API服务封装看起来简单,但要做好需要考虑很多细节。

从架构角度,要把网络请求集中管理,和业务逻辑分离。调用方只关心"获取新闻列表",不关心HTTP细节。

从健壮性角度,要处理各种异常情况:网络断开、超时、服务器错误、数据格式错误。用户看到的应该是友好的错误提示,而不是应用崩溃。

从性能角度,要考虑缓存、取消无用请求、避免重复请求。

从可维护性角度,要有日志、要能方便地切换环境、要便于测试。

好的API服务封装是应用稳定运行的基础。花时间把它做好,后面的开发会顺畅很多。

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

在这里你可以找到更多Flutter开发资源,与其他开发者交流经验,共同进步。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐