flutter学习第 12 节:网络请求与 JSON 解析

在现代移动应用开发中,几乎所有应用都需要与后端服务器进行数据交互,获取远程数据并展示给用户。Flutter 提供了多种方式来处理网络请求和数据解析,本节课将详细介绍如何在 Flutter 中进行网络请求、处理响应数据以及解析 JSON 格式的数据。

一、HTTP 库选择:dio 安装与基本使用

Flutter 官方提供了 http 包用于网络请求,但在实际开发中,dio 库因其更强大的功能和更简洁的 API 而被广泛使用。dio 是一个强大的 Dart HTTP 客户端,支持拦截器、FormData、请求取消、超时设置等高级功能。

1. 安装 dio

pubspec.yaml 文件中添加依赖:

yaml 复制代码
dependencies:
  flutter:
    sdk: flutter
  dio: ^5.9.0  # 使用最新版本

运行 flutter pub get 安装依赖。

2. dio 基本使用

首先导入 dio 包:

dart 复制代码
import 'package:dio/dio.dart';

创建 dio 实例:

dart 复制代码
// 创建默认实例
Dio dio = Dio();

// 也可以通过 BaseOptions 配置实例
BaseOptions options = BaseOptions(
  baseUrl: 'https://api.example.com',
  connectTimeout: const Duration(milliseconds: 5000),
  receiveTimeout: const Duration(milliseconds: 3000),
  headers: {
    'Content-Type': 'application/json',
  },
);

Dio dio = Dio(options);

二、发起 GET/POST 请求与参数处理

1. 发起 GET 请求

GET 请求通常用于从服务器获取数据:

dart 复制代码
// 简单的 GET 请求
Future<void> fetchData() async {
  try {
    Response response = await dio.get('/users');
    print('Response data: ${response.data}');
    print('Status code: ${response.statusCode}');
  } catch (e) {
    print('Error: $e');
  }
}

// 带查询参数的 GET 请求
Future<void> fetchUserData() async {
  try {
    // 方式一:直接在 URL 中添加参数
    Response response1 = await dio.get('/users?userId=123&name=John');
    
    // 方式二:使用 queryParameters
    Response response2 = await dio.get(
      '/users',
      queryParameters: {
        'userId': 123,
        'name': 'John',
      },
    );
    
    print('Response data: ${response2.data}');
  } catch (e) {
    print('Error: $e');
  }
}

2. 发起 POST 请求

POST 请求通常用于向服务器提交数据:

dart 复制代码
// 提交 JSON 数据
Future<void> submitData() async {
  try {
    Response response = await dio.post(
      '/users',
      data: {'name': 'John Doe', 'email': 'john@example.com', 'age': 30},
    );
    print('Response data: ${response.data}');
  } catch (e) {
    print('Error: $e');
  }
}

// 提交 FormData(表单数据)
Future<void> uploadForm() async {
  try {
    FormData formData = FormData.fromMap({
      'name': 'John Doe',
      'avatar': await MultipartFile.fromFile(
        '/path/to/avatar.jpg',
        filename: 'avatar.jpg',
      ),
      'hobbies': ['reading', 'sports'],
    });

    Response response = await dio.post('/user/profile', data: formData);
    print('Response data: ${response.data}');
  } catch (e) {
    print('Error: $e');
  }
}

3. 自定义请求头

可以为单个请求设置自定义请求头:

dart 复制代码
Future<void> fetchWithHeaders() async {
  try {
    Response response = await dio.get(
      '/protected/data',
      options: Options(
        headers: {
          'Authorization': 'Bearer your_token_here',
          'Custom-Header': 'custom_value',
        },
      ),
    );
    print('Response data: ${response.data}');
  } catch (e) {
    print('Error: $e');
  }
}

4. 处理请求超时

可以为单个请求设置超时时间:

dart 复制代码
Future<void> fetchWithTimeout() async {
  try {
    Response response = await dio.get(
      '/slow/endpoint',
      options: Options(
        sendTimeout: const Duration(seconds: 2),
        receiveTimeout: const Duration(seconds: 5),
      ),
    );
    print('Response data: ${response.data}');
  } on DioException catch (e) {
    if (e.type == DioExceptionType.connectionTimeout) {
      print('Connection timeout');
    } else if (e.type == DioExceptionType.receiveTimeout) {
      print('Receive timeout');
    } else {
      print('Other error: $e');
    }
  }
}

三、JSON 数据解析

服务器返回的数据通常是 JSON 格式,Flutter 提供了多种方式来解析 JSON 数据。

1. 手动解析 JSON

Dart 内置了 dart:convert 库,可以手动解析 JSON 数据:

dart 复制代码
import 'dart:convert';

// 假设服务器返回的 JSON 数据如下:
// {
//   "id": 1,
//   "name": "John Doe",
//   "email": "john@example.com",
//   "age": 30,
//   "hobbies": ["reading", "sports"]
// }

// 创建模型类
class User {
  final int id;
  final String name;
  final String email;
  final int age;
  final List<String> hobbies;

  User({
    required this.id,
    required this.name,
    required this.email,
    required this.age,
    required this.hobbies,
  });

  // 从 JSON 映射创建 User 实例
  factory User.fromJson(Map<String, dynamic> json) {
    return User(
      id: json['id'],
      name: json['name'],
      email: json['email'],
      age: json['age'],
      hobbies: List<String>.from(json['hobbies']),
    );
  }

  // 转换为 JSON 映射
  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'name': name,
      'email': email,
      'age': age,
      'hobbies': hobbies,
    };
  }
}

// 解析 JSON 数据
Future<void> parseUserJson() async {
  try {
    Response response = await dio.get('/users/1');

    // 将 JSON 字符串转换为 Map
    Map<String, dynamic> jsonData = response.data;

    // 转换为 User 对象
    User user = User.fromJson(jsonData);

    print('User name: ${user.name}');
    print('User email: ${user.email}');
  } catch (e) {
    print('Error: $e');
  }
}

// 解析 JSON 数组
Future<void> parseUsersJson() async {
  try {
    Response response = await dio.get('/users');

    // 将 JSON 数组转换为 List
    List<dynamic> jsonList = response.data;

    // 转换为 User 对象列表
    List<User> users = jsonList.map((json) => User.fromJson(json)).toList();

    print('Number of users: ${users.length}');
    print('First user: ${users[0].name}');
  } catch (e) {
    print('Error: $e');
  }
}

手动解析的优点是简单直接,不需要额外依赖,但缺点是当 JSON 结构复杂或字段较多时,编写解析代码繁琐且容易出错。

2. 使用 json_serializable 自动生成代码

json_serializable 是一个自动化的源代码生成器,可以为 JSON 序列化和反序列化生成代码,减少手动编写解析代码的工作量。

安装依赖

pubspec.yaml 中添加依赖:

yaml 复制代码
dependencies:
  # ... 其他依赖
  json_annotation: ^4.8.1  # 注解包

dev_dependencies:
  # ... 其他开发依赖
  build_runner: ^2.4.4     # 构建工具
  json_serializable: ^6.7.1 # 代码生成器

运行 flutter pub get 安装依赖。

创建模型类

dart 复制代码
import 'package:json_annotation/json_annotation.dart';

// 生成的代码将在 user.g.dart 文件中
part 'user.g.dart';

@JsonSerializable()
class User {
  final int id;
  final String name;
  
  // 使用 @JsonKey 注解指定 JSON 字段名与类属性名不同的情况
  @JsonKey(name: 'email_address')
  final String email;
  
  final int age;
  
  // 忽略该字段,不参与序列化和反序列化
  @JsonKey(ignore: true)
  final String? token;
  
  final List<String> hobbies;

  User({
    required this.id,
    required this.name,
    required this.email,
    required this.age,
    this.token,
    required this.hobbies,
  });

  // 从 JSON 映射创建 User 实例
  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);

  // 转换为 JSON 映射
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

生成代码

在项目根目录运行以下命令生成序列化代码:

bash 复制代码
flutter pub run build_runner build

如果需要在开发过程中自动生成代码(当模型类变化时),可以使用 watch 模式:

bash 复制代码
flutter pub run build_runner watch

运行成功后,会生成 user.g.dart 文件,包含自动生成的序列化和反序列化代码。

使用自动生成的代码

dart 复制代码
Future<void> useGeneratedCode() async {
  try {
    Response response = await dio.get('/users/1');
    
    // 使用自动生成的 fromJson 方法解析
    User user = User.fromJson(response.data);
    
    print('User name: ${user.name}');
    print('User email: ${user.email}');
    
    // 序列化示例
    Map<String, dynamic> userJson = user.toJson();
    print('Serialized user: $userJson');
  } catch (e) {
    print('Error: $e');
  }
}

json_serializable 的优点是减少手动编写解析代码的工作量,提高代码的可靠性和可维护性,特别适合处理复杂的 JSON 结构。


四、网络状态处理

在实际应用中,网络请求通常有几种状态:加载中、成功、失败。我们需要根据不同的状态展示不同的 UI。

1. 创建网络状态管理类

dart 复制代码
enum NetworkStatus { initial, loading, success, error }

class NetworkResult<T> {
  final NetworkStatus status;
  final T? data;
  final String? errorMessage;

  NetworkResult.initial()
      : status = NetworkStatus.initial,
        data = null,
        errorMessage = null;

  NetworkResult.loading()
      : status = NetworkStatus.loading,
        data = null,
        errorMessage = null;

  NetworkResult.success(this.data)
      : status = NetworkStatus.success,
        errorMessage = null;

  NetworkResult.error(this.errorMessage)
      : status = NetworkStatus.error,
        data = null;
}

2. 基于状态展示不同 UI

dart 复制代码
class DataScreen extends StatefulWidget {
  const DataScreen({super.key});

  @override
  State<DataScreen> createState() => _DataScreenState();
}

class _DataScreenState extends State<DataScreen> {
  final Dio _dio = Dio();
  NetworkResult<List<User>> _result = NetworkResult.initial();

  @override
  void initState() {
    super.initState();
    fetchUsers();
  }

  Future<void> fetchUsers() async {
    setState(() {
      _result = NetworkResult.loading();
    });

    try {
      Response response = await _dio.get('https://api.example.com/users');
      List<User> users = (response.data as List)
          .map((json) => User.fromJson(json))
          .toList();
      
      setState(() {
        _result = NetworkResult.success(users);
      });
    } catch (e) {
      setState(() {
        _result = NetworkResult.error(e.toString());
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('User List')),
      body: _buildBody(),
    );
  }

  Widget _buildBody() {
    switch (_result.status) {
      case NetworkStatus.initial:
        return const Center(child: Text('Tap to load data'));
      case NetworkStatus.loading:
        return const Center(child: CircularProgressIndicator());
      case NetworkStatus.success:
        return _buildUserList(_result.data!);
      case NetworkStatus.error:
        return Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text('Error: ${_result.errorMessage}'),
              const SizedBox(height: 16),
              ElevatedButton(
                onPressed: fetchUsers,
                child: const Text('Retry'),
              ),
            ],
          ),
        );
    }
  }

  Widget _buildUserList(List<User> users) {
    return ListView.builder(
      itemCount: users.length,
      itemBuilder: (context, index) {
        User user = users[index];
        return ListTile(
          title: Text(user.name),
          subtitle: Text(user.email),
          trailing: Text('Age: ${user.age}'),
        );
      },
    );
  }
}

五、dio 拦截器

dio 提供了拦截器功能,可以在请求发送前或响应返回后进行一些统一处理,如添加认证 token、处理错误等。

1. 请求拦截器

dart 复制代码
// 添加请求拦截器
dio.interceptors.add(
  InterceptorsWrapper(
    onRequest: (options, handler) {
      // 在请求发送前做一些处理
      print('Request: ${options.method} ${options.uri}');
      
      // 添加认证 token
      options.headers['Authorization'] = 'Bearer your_token_here';
      
      // 继续发送请求
      return handler.next(options);
      
      // 如果想终止请求,可以调用 handler.reject()
      // return handler.reject(DioException(requestOptions: options, type: DioExceptionType.cancel));
    },
  ),
);

2. 响应拦截器

dart 复制代码
// 添加响应拦截器
dio.interceptors.add(
  InterceptorsWrapper(
    onResponse: (response, handler) {
      // 在响应返回后做一些处理
      print('Response: ${response.statusCode} ${response.data}');
      
      // 继续处理响应
      return handler.next(response);
    },
  ),
);

3. 错误拦截器

dart 复制代码
// 添加错误拦截器
dio.interceptors.add(
  InterceptorsWrapper(
    onError: (DioException e, handler) {
      // 处理错误
      print('Error: ${e.message}');
      
      // 统一处理 401 未授权错误
      if (e.response?.statusCode == 401) {
        // 可以在这里跳转到登录页面
        print('Unauthorized, redirecting to login');
      }
      
      // 继续处理错误
      return handler.next(e);
      
      // 如果想掩盖错误,可以返回一个成功的响应
      // return handler.resolve(Response(requestOptions: e.requestOptions, data: {}));
    },
  ),
);

4. 日志拦截器

dio 提供了一个内置的日志拦截器,方便调试:

dart 复制代码
import 'package:dio/io.dart';

dio.interceptors.add(LogInterceptor(
  request: true,  // 打印请求信息
  requestHeader: true,  // 打印请求头
  requestBody: true,  // 打印请求体
  responseHeader: true,  // 打印响应头
  responseBody: true,  // 打印响应体
  error: true,  // 打印错误信息
  logPrint: (object) {
    print('Dio Log: $object');
  },
));

六、实例:请求开源 API 展示新闻列表

下面我们将实现一个完整的示例,使用公开的新闻 API 获取新闻列表并展示。

1. 创建新闻模型类

dart 复制代码
import 'package:json_annotation/json_annotation.dart';

part 'news.g.dart';

@JsonSerializable()
class NewsArticle {
  @JsonKey(name: 'source')
  final NewsSource source;
  
  @JsonKey(name: 'author')
  final String? author;
  
  @JsonKey(name: 'title')
  final String title;
  
  @JsonKey(name: 'description')
  final String? description;
  
  @JsonKey(name: 'url')
  final String url;
  
  @JsonKey(name: 'urlToImage')
  final String? urlToImage;
  
  @JsonKey(name: 'publishedAt')
  final String publishedAt;
  
  @JsonKey(name: 'content')
  final String? content;

  NewsArticle({
    required this.source,
    this.author,
    required this.title,
    this.description,
    required this.url,
    this.urlToImage,
    required this.publishedAt,
    this.content,
  });

  factory NewsArticle.fromJson(Map<String, dynamic> json) =>
      _$NewsArticleFromJson(json);

  Map<String, dynamic> toJson() => _$NewsArticleToJson(this);
}

@JsonSerializable()
class NewsSource {
  @JsonKey(name: 'id')
  final String? id;
  
  @JsonKey(name: 'name')
  final String name;

  NewsSource({
    this.id,
    required this.name,
  });

  factory NewsSource.fromJson(Map<String, dynamic> json) =>
      _$NewsSourceFromJson(json);

  Map<String, dynamic> toJson() => _$NewsSourceToJson(this);
}

@JsonSerializable()
class NewsResponse {
  @JsonKey(name: 'status')
  final String status;
  
  @JsonKey(name: 'totalResults')
  final int totalResults;
  
  @JsonKey(name: 'articles')
  final List<NewsArticle> articles;

  NewsResponse({
    required this.status,
    required this.totalResults,
    required this.articles,
  });

  factory NewsResponse.fromJson(Map<String, dynamic> json) =>
      _$NewsResponseFromJson(json);

  Map<String, dynamic> toJson() => _$NewsResponseToJson(this);
}

运行代码生成命令:

bash 复制代码
flutter pub run build_runner build

2. 创建新闻服务类

dart 复制代码
import 'package:dio/dio.dart';

class NewsService {
  final Dio _dio = Dio();
  final String _apiKey = 'your_news_api_key'; // 替换为你的 API Key
  final String _baseUrl = 'https://newsapi.org/v2';

  NewsService() {
    // 配置 dio
    _dio.options.baseUrl = _baseUrl;
    _dio.options.connectTimeout = const Duration(seconds: 5);
    _dio.options.receiveTimeout = const Duration(seconds: 3);

    // 添加日志拦截器
    _dio.interceptors.add(LogInterceptor(responseBody: true));
  }

  // 获取头条新闻
  Future<NewsResponse> getTopHeadlines({String country = 'us'}) async {
    try {
      Response response = await _dio.get(
        '/top-headlines',
        queryParameters: {
          'country': country,
          'apiKey': _apiKey,
        },
      );
      return NewsResponse.fromJson(response.data);
    } on DioException catch (e) {
      print('News API error: ${e.message}');
      throw Exception('Failed to fetch news: ${e.message}');
    }
  }

  // 搜索新闻
  Future<NewsResponse> searchNews(String query) async {
    try {
      Response response = await _dio.get(
        '/everything',
        queryParameters: {
          'q': query,
          'apiKey': _apiKey,
        },
      );
      return NewsResponse.fromJson(response.data);
    } on DioException catch (e) {
      print('News API error: ${e.message}');
      throw Exception('Failed to search news: ${e.message}');
    }
  }
}

注意:需要在 News API 网站注册获取 API Key。

3. 实现新闻列表页面

dart 复制代码
class NewsListScreen extends StatefulWidget {
  const NewsListScreen({super.key});

  @override
  State<NewsListScreen> createState() => _NewsListScreenState();
}

class _NewsListScreenState extends State<NewsListScreen> {
  final NewsService _newsService = NewsService();
  NetworkResult<List<NewsArticle>> _newsResult = NetworkResult.initial();
  final TextEditingController _searchController = TextEditingController();

  @override
  void initState() {
    super.initState();
    _fetchTopHeadlines();
  }

  Future<void> _fetchTopHeadlines() async {
    setState(() {
      _newsResult = NetworkResult.loading();
    });

    try {
      NewsResponse response = await _newsService.getTopHeadlines();
      setState(() {
        _newsResult = NetworkResult.success(response.articles);
      });
    } catch (e) {
      setState(() {
        _newsResult = NetworkResult.error(e.toString());
      });
    }
  }

  Future<void> _searchNews() async {
    String query = _searchController.text.trim();
    if (query.isEmpty) return;

    setState(() {
      _newsResult = NetworkResult.loading();
    });

    try {
      NewsResponse response = await _newsService.searchNews(query);
      setState(() {
        _newsResult = NetworkResult.success(response.articles);
      });
    } catch (e) {
      setState(() {
        _newsResult = NetworkResult.error(e.toString());
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Latest News'),
      ),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: TextField(
              controller: _searchController,
              decoration: InputDecoration(
                hintText: 'Search news...',
                suffixIcon: IconButton(
                  icon: const Icon(Icons.search),
                  onPressed: _searchNews,
                ),
                border: const OutlineInputBorder(),
              ),
              onSubmitted: (value) => _searchNews(),
            ),
          ),
          Expanded(
            child: _buildNewsContent(),
          ),
        ],
      ),
    );
  }

  Widget _buildNewsContent() {
    switch (_newsResult.status) {
      case NetworkStatus.initial:
        return const Center(child: Text('Loading news...'));
      case NetworkStatus.loading:
        return const Center(child: CircularProgressIndicator());
      case NetworkStatus.success:
        return _buildNewsList(_newsResult.data!);
      case NetworkStatus.error:
        return Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text('Error: ${_newsResult.errorMessage}'),
              const SizedBox(height: 16),
              ElevatedButton(
                onPressed: _fetchTopHeadlines,
                child: const Text('Retry'),
              ),
            ],
          ),
        );
    }
  }

  Widget _buildNewsList(List<NewsArticle> articles) {
    if (articles.isEmpty) {
      return const Center(child: Text('No news found'));
    }

    return ListView.builder(
      itemCount: articles.length,
      itemBuilder: (context, index) {
        NewsArticle article = articles[index];
        return Card(
          margin: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
          child: Column(
            children: [
              if (article.urlToImage != null)
                Image.network(
                  article.urlToImage!,
                  height: 180,
                  width: double.infinity,
                  fit: BoxFit.cover,
                  errorBuilder: (context, error, stackTrace) {
                    return Container(
                      height: 180,
                      color: Colors.grey[200],
                      child: const Center(child: Icon(Icons.image_not_supported)),
                    );
                  },
                ),
              Padding(
                padding: const EdgeInsets.all(12.0),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      article.source.name,
                      style: TextStyle(
                        color: Colors.grey[600],
                        fontSize: 12,
                      ),
                    ),
                    const SizedBox(height: 8),
                    Text(
                      article.title,
                      style: const TextStyle(
                        fontSize: 16,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                    const SizedBox(height: 8),
                    if (article.description != null)
                      Text(
                        article.description!,
                        style: const TextStyle(fontSize: 14),
                        maxLines: 3,
                        overflow: TextOverflow.ellipsis,
                      ),
                    const SizedBox(height: 8),
                    Text(
                      _formatDate(article.publishedAt),
                      style: TextStyle(
                        color: Colors.grey[600],
                        fontSize: 12,
                      ),
                    ),
                  ],
                ),
              ),
            ],
          ),
        );
      },
    );
  }

  String _formatDate(String dateString) {
    DateTime date = DateTime.parse(dateString);
    return DateFormat.yMMMd().add_jm().format(date);
  }

注意:需要添加 intl 依赖来格式化日期,在 pubspec.yaml 中添加 intl: ^0.18.1 并运行 flutter pub get

4. 配置网络权限

对于 Android,需要在 android/app/src/main/AndroidManifest.xml 中添加网络权限:

xml 复制代码
<uses-permission android:name="android.permission.INTERNET" />

对于 iOS,需要在 ios/Runner/Info.plist 中添加:

xml 复制代码
<key>NSAppTransportSecurity</key>
<dict>
  <key>NSAllowsArbitraryLoads</key>
  <true/>
</dict>

七、网络请求最佳实践

  1. 封装网络层:将网络请求逻辑封装在专门的服务类中,与 UI 层分离,提高代码复用性和可维护性。
  2. 统一错误处理:使用拦截器统一处理网络错误,如超时、无网络、认证失败等。
  3. 合理管理请求状态:清晰展示加载中、成功、失败等状态,提供良好的用户体验。
  4. 数据缓存:对于不经常变化的数据,可以实现本地缓存,减少网络请求,提高应用性能。
  5. 请求取消:在页面销毁时取消未完成的网络请求,避免内存泄漏和不必要的资源消耗。
  6. 图片处理 :使用 cached_network_image 等库处理网络图片,实现缓存和占位图功能。
  7. 避免在 UI 线程处理复杂任务:确保网络请求在异步线程执行,避免阻塞 UI。
  8. 添加日志:在开发环境添加详细的网络日志,方便调试;在生产环境关闭或简化日志。
相关推荐
OperateCode9 分钟前
Android Studio 格式规范
android
张风捷特烈19 分钟前
鸿蒙纪·Flutter卷#02 | 已有 Flutter 项目鸿蒙化 · 3.27.4 版
android·flutter·harmonyos
TralyFang3 小时前
Flutter 导致Positioned的不断重构问题
flutter
QING6183 小时前
Media3 ExoPlayer 快速实现背景视频播放(干货)
android·前端·kotlin
鹏多多3 小时前
flutter-使用SafeArea组件处理各机型的安全距离
前端·flutter·客户端
用户2018792831673 小时前
PengdingIntent之“我想要的很简单时光还在你还在”
android
weiwuxian3 小时前
js与原生通讯版本演进
android·前端
wayne2143 小时前
Android 跨应用广播通信全攻略
android
y东施效颦4 小时前
uni-app app端安卓和ios如何申请麦克风权限,唤起提醒弹框
android·ios·uni-app