Flutter 网络状态监听能力集成实战:WiFi/移动网络切换、离线缓存与重连机制

作者:maaath

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

一、引言

在移动应用开发中,网络状态的变化直接影响用户体验。无论是用户进入地铁导致的网络中断,还是从 WiFi 切换到移动数据,如何优雅地处理这些网络切换场景,是每一位开发者必须面对的问题。

Flutter 作为跨平台开发框架,通过 connectivity_plus 插件和 http / dio 网络库,可以轻松实现网络状态的实时监听与智能处理。本文将以 Flutter for OpenHarmony 为例,展示如何集成网络状态监听能力,包括:

  • WiFi/移动网络切换的实时监听
  • 基于 http 库的网络请求封装
  • 离线缓存机制的设计与实现
  • 自动重连策略的完整方案
  • 在开源鸿蒙模拟器上的运行验证

二、技术架构设计

2.1 整体架构

┌─────────────────────────────────────────────────────────┐
│                    HomePage / MoviePage (UI层)          │
│         实时展示网络状态、加载状态、内容展示等            │
└─────────────────────────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────┐
│                 ApiService (网络服务层)                   │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────┐ │
│  │ HTTP请求封装 │  │  错误处理   │  │   重试策略      │ │
│  └─────────────┘  └─────────────┘  └─────────────────┘ │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────┐ │
│  │  离线缓存   │  │ 连接状态监听 │  │   Mock数据     │ │
│  └─────────────┘  └─────────────┘  └─────────────────┘ │
└─────────────────────────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────┐
│              http / dio 网络库 + connectivity_plus      │
└─────────────────────────────────────────────────────────┘

2.2 核心模块说明

模块 职责 关键能力
ApiService 网络请求封装 统一拦截、统一错误处理、自动重试
MovieService 电影数据服务 列表查询、搜索、详情获取
WeatherService 天气数据服务 城市天气、定位天气
ConnectivityService 连接状态管理 实时监听、网络切换响应

三、网络服务核心实现

3.1 网络请求基础封装

使用 http 库封装统一的网络请求服务,支持超时控制和异常处理:

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

class ApiService {
  static const Duration _timeout = Duration(seconds: 15);
  final http.Client _client;

  ApiService({http.Client? client}) : _client = client ?? http.Client();

  Future<ApiResponse<T>> get<T>(
    String url, {
    Map<String, String>? headers,
    T Function(dynamic json)? parser,
  }) async {
    try {
      final response = await _client
          .get(Uri.parse(url), headers: headers)
          .timeout(_timeout);

      return _handleResponse(response, parser);
    } on TimeoutException {
      return ApiResponse.error('Request timeout');
    } catch (e) {
      return ApiResponse.error('Network error: $e');
    }
  }

  Future<ApiResponse<T>> post<T>(
    String url, {
    Map<String, String>? headers,
    dynamic body,
    T Function(dynamic json)? parser,
  }) async {
    try {
      final response = await _client
          .post(
            Uri.parse(url),
            headers: {...?headers, 'Content-Type': 'application/json'},
            body: body != null ? jsonEncode(body) : null,
          )
          .timeout(_timeout);

      return _handleResponse(response, parser);
    } on TimeoutException {
      return ApiResponse.error('Request timeout');
    } catch (e) {
      return ApiResponse.error('Network error: $e');
    }
  }

  ApiResponse<T> _handleResponse<T>(
    http.Response response,
    T Function(dynamic json)? parser,
  ) {
    if (response.statusCode == 200) {
      final data = jsonDecode(response.body);
      if (parser != null) {
        return ApiResponse.success(parser(data));
      }
      return ApiResponse.success(data as T);
    }
    return ApiResponse.error('HTTP ${response.statusCode}');
  }

  void dispose() {
    _client.close();
  }
}

class ApiResponse<T> {
  final T? data;
  final String? error;
  final bool isSuccess;

  ApiResponse._({this.data, this.error, required this.isSuccess});

  factory ApiResponse.success(T data) =>
      ApiResponse._(data: data, isSuccess: true);

  factory ApiResponse.error(String message) =>
      ApiResponse._(error: message, isSuccess: false);
}

3.2 电影数据服务实现

以豆瓣 API 为例,展示完整的网络请求封装和错误处理:

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

class MovieService {
  static const String _baseUrl = 'https://api.douban.com/v2';
  static const Duration _timeout = Duration(seconds: 15);

  final http.Client _client;

  MovieService({http.Client? client}) : _client = client ?? http.Client();

  Future<MovieListResponse> getNowPlayingMovies({
    int page = 1,
    int pageSize = 20,
  }) async {
    try {
      final uri = Uri.parse('$_baseUrl/movie/in_theaters');
      final response = await _client.get(uri).timeout(_timeout);

      if (response.statusCode == 200) {
        final data = json.decode(response.body) as Map<String, dynamic>;
        final subjects = data['subjects'] as List<dynamic>? ?? [];

        final movies = subjects.map((subject) {
          final s = subject as Map<String, dynamic>;
          return Movie(
            id: s['id']?.toString() ?? '',
            title: s['title'] ?? '',
            posterUrl: (s['images']?['large'] ?? s['images']?['medium'])?.toString() ?? '',
            rating: (s['rating']?['average'] ?? 0.0).toDouble(),
            genre: (s['genres'] as List<dynamic>?)?.map((e) => e.toString()).join(' / ') ?? '',
            director: (s['directors'] as List<dynamic>?)?.map((d) =>
                (d as Map<String, dynamic>)['name'] ?? '').join(' / ') ?? '',
            releaseYear: int.tryParse(s['year']?.toString() ?? '') ?? 2024,
          );
        }).toList();

        return MovieListResponse(
          total: data['total'] ?? movies.length,
          page: page,
          movies: movies,
        );
      }
      throw HttpException('Failed: ${response.statusCode}');
    } catch (e) {
      // 网络异常时返回 Mock 数据
      return _getMockMovies(page, pageSize);
    }
  }

  Future<Movie> getMovieDetail(String movieId) async {
    try {
      final uri = Uri.parse('$_baseUrl/movie/$movieId');
      final response = await _client.get(uri).timeout(_timeout);

      if (response.statusCode == 200) {
        final data = json.decode(response.body) as Map<String, dynamic>;
        return Movie(
          id: data['id']?.toString() ?? movieId,
          title: data['title'] ?? '',
          posterUrl: (data['images']?['large'] ?? data['images']?['medium'])?.toString() ?? '',
          rating: (data['rating']?['average'] ?? 0.0).toDouble(),
          summary: data['summary'] ?? '',
          genre: (data['genres'] as List<dynamic>?)?.map((e) => e.toString()).join(' / ') ?? '',
          director: (data['directors'] as List<dynamic>?)?.map((d) =>
              (d as Map<String, dynamic>)['name'] ?? '').join(' / ') ?? '',
          actors: (data['casts'] as List<dynamic>?)?.map((a) =>
              (a as Map<String, dynamic>)['name']?.toString() ?? '').toList() ?? [],
        );
      }
      throw HttpException('Failed: ${response.statusCode}');
    } catch (e) {
      return _getMockDetail(movieId);
    }
  }

  MovieListResponse _getMockMovies(int page, int pageSize) {
    final movies = List.generate(pageSize, (index) {
      return Movie(
        id: 'mock_$index',
        title: '示例电影 ${index + 1}',
        posterUrl: 'https://picsum.photos/seed/$index/300/450',
        rating: 6.0 + (index % 40) / 10.0,
        genre: '动作 / 冒险',
        director: '示例导演',
        releaseYear: 2024,
      );
    });
    return MovieListResponse(total: 50, page: page, movies: movies);
  }

  Movie _getMockDetail(String movieId) {
    return Movie(
      id: movieId,
      title: '示例电影详情',
      posterUrl: 'https://picsum.photos/seed/$movieId/300/450',
      rating: 7.5,
      summary: '这是一部精彩的示例电影...',
      genre: '剧情 / 悬疑',
      director: '示例导演',
      actors: ['演员A', '演员B', '演员C'],
    );
  }

  void dispose() {
    _client.close();
  }
}

class HttpException implements Exception {
  final String message;
  HttpException(this.message);
  
  String toString() => message;
}

3.3 天气数据服务实现

展示另一个完整的数据服务封装模式:

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

class WeatherService {
  static const String _baseUrl = 'https://api.openweathermap.org/data/2.5';
  static const String _apiKey = 'YOUR_API_KEY';

  final http.Client _client;

  WeatherService({http.Client? client}) : _client = client ?? http.Client();

  Future<WeatherData> getWeatherByCity(String city) async {
    final url = Uri.parse(
      '$_baseUrl/weather?q=$city&appid=$_apiKey&units=metric&lang=zh_cn',
    );

    try {
      final response = await _client.get(url);

      if (response.statusCode == 200) {
        final json = jsonDecode(response.body) as Map<String, dynamic>;
        return WeatherData.fromJson(json);
      } else if (response.statusCode == 404) {
        throw WeatherException('City not found');
      } else if (response.statusCode == 401) {
        throw WeatherException('Invalid API key');
      } else {
        throw WeatherException('Failed to load weather data');
      }
    } catch (e) {
      if (e is WeatherException) rethrow;
      throw WeatherException('Network error: ${e.toString()}');
    }
  }

  Future<WeatherData> getWeatherByCoordinates(double lat, double lon) async {
    final url = Uri.parse(
      '$_baseUrl/weather?lat=$lat&lon=$lon&appid=$_apiKey&units=metric&lang=zh_cn',
    );

    try {
      final response = await _client.get(url);

      if (response.statusCode == 200) {
        final json = jsonDecode(response.body) as Map<String, dynamic>;
        return WeatherData.fromJson(json);
      } else {
        throw WeatherException('Failed to load weather data');
      }
    } catch (e) {
      if (e is WeatherException) rethrow;
      throw WeatherException('Network error: ${e.toString()}');
    }
  }

  Future<List<CityInfo>> searchCities(String query) async {
    if (query.isEmpty) return [];

    final url = Uri.parse(
      'https://api.openweathermap.org/geo/1.0/direct?q=$query&limit=5&appid=$_apiKey',
    );

    try {
      final response = await _client.get(url);

      if (response.statusCode == 200) {
        final List<dynamic> jsonList = jsonDecode(response.body) as List<dynamic>;
        return jsonList
            .map((json) => CityInfo.fromJson(json as Map<String, dynamic>))
            .toList();
      } else {
        throw WeatherException('Failed to search cities');
      }
    } catch (e) {
      if (e is WeatherException) rethrow;
      throw WeatherException('Network error: ${e.toString()}');
    }
  }

  void dispose() {
    _client.close();
  }
}

class WeatherException implements Exception {
  final String message;
  WeatherException(this.message);
  
  String toString() => message;
}

class WeatherData {
  final String cityName;
  final double temperature;
  final String description;
  final int humidity;
  final double windSpeed;

  WeatherData({
    required this.cityName,
    required this.temperature,
    required this.description,
    required this.humidity,
    required this.windSpeed,
  });

  factory WeatherData.fromJson(Map<String, dynamic> json) {
    final main = json['main'] as Map<String, dynamic>;
    final weather = (json['weather'] as List<dynamic>).first as Map<String, dynamic>;
    final wind = json['wind'] as Map<String, dynamic>;

    return WeatherData(
      cityName: json['name'] ?? '',
      temperature: (main['temp'] as num).toDouble(),
      description: weather['description'] ?? '',
      humidity: main['humidity'] ?? 0,
      windSpeed: (wind['speed'] as num).toDouble(),
    );
  }
}

3.4 网络状态监听服务

使用 connectivity_plus 实现网络状态的实时监听:

import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';

enum NetworkType { wifi, cellular, ethernet, none }

class ConnectivityService {
  static final ConnectivityService _instance = ConnectivityService._internal();
  factory ConnectivityService() => _instance;
  ConnectivityService._internal();

  final Connectivity _connectivity = Connectivity();
  final StreamController<NetworkType> _networkController =
      StreamController<NetworkType>.broadcast();

  Stream<NetworkType> get networkStream => _networkController.stream;
  NetworkType _currentNetworkType = NetworkType.none;

  NetworkType get currentNetworkType => _currentNetworkType;

  Future<void> initialize() async {
    // 初始化时检查当前网络状态
    final result = await _connectivity.checkConnectivity();
    _updateNetworkType(result);

    // 监听网络变化
    _connectivity.onConnectivityChanged.listen(_updateNetworkType);
  }

  void _updateNetworkType(List<ConnectivityResult> results) {
    if (results.isEmpty || results.contains(ConnectivityResult.none)) {
      _currentNetworkType = NetworkType.none;
    } else if (results.contains(ConnectivityResult.wifi)) {
      _currentNetworkType = NetworkType.wifi;
    } else if (results.contains(ConnectivityResult.mobile)) {
      _currentNetworkType = NetworkType.cellular;
    } else if (results.contains(ConnectivityResult.ethernet)) {
      _currentNetworkType = NetworkType.ethernet;
    }

    _networkController.add(_currentNetworkType);
  }

  Future<bool> isNetworkAvailable() async {
    final result = await _connectivity.checkConnectivity();
    return result.isNotEmpty && !result.contains(ConnectivityResult.none);
  }

  void dispose() {
    _networkController.close();
  }
}

四、UI 层集成

4.1 网络状态感知的页面组件

import 'package:flutter/material.dart';
import 'package:connectivity_plus/connectivity_plus.dart';

class NetworkAwareWidget extends StatefulWidget {
  final Widget child;
  final Widget? offlineWidget;

  const NetworkAwareWidget({
    super.key,
    required this.child,
    this.offlineWidget,
  });

  
  State<NetworkAwareWidget> createState() => _NetworkAwareWidgetState();
}

class _NetworkAwareWidgetState extends State<NetworkAwareWidget> {
  final Connectivity _connectivity = Connectivity();
  bool _isOffline = false;

  
  void initState() {
    super.initState();
    _checkConnectivity();
    _connectivity.onConnectivityChanged.listen(_updateConnectionStatus);
  }

  Future<void> _checkConnectivity() async {
    final result = await _connectivity.checkConnectivity();
    setState(() {
      _isOffline = result.contains(ConnectivityResult.none);
    });
  }

  void _updateConnectionStatus(List<ConnectivityResult> results) {
    setState(() {
      _isOffline = results.contains(ConnectivityResult.none);
    });
  }

  
  Widget build(BuildContext context) {
    if (_isOffline && widget.offlineWidget != null) {
      return widget.offlineWidget!;
    }
    return widget.child;
  }
}

// 离线提示组件
class OfflineBanner extends StatelessWidget {
  const OfflineBanner({super.key});

  
  Widget build(BuildContext context) {
    return Container(
      width: double.infinity,
      padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
      color: Colors.orange,
      child: const Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(Icons.wifi_off, color: Colors.white, size: 18),
          SizedBox(width: 8),
          Text(
            'No internet connection',
            style: TextStyle(color: Colors.white, fontSize: 14),
          ),
        ],
      ),
    );
  }
}

4.2 带骨架屏的加载状态管理

class MovieListWidget extends StatefulWidget {
  final MovieService _movieService;

  const MovieListWidget({super.key, MovieService? movieService})
      : _movieService = movieService ?? MovieService();

  
  State<MovieListWidget> createState() => _MovieListWidgetState();
}

class _MovieListWidgetState extends State<MovieListWidget> {
  MovieListResponse? _movieResponse;
  bool _isLoading = true;
  String? _errorMessage;

  
  void initState() {
    super.initState();
    _loadMovies();
  }

  Future<void> _loadMovies() async {
    setState(() {
      _isLoading = true;
      _errorMessage = null;
    });

    try {
      final response = await widget._movieService.getNowPlayingMovies();
      setState(() {
        _movieResponse = response;
        _isLoading = false;
      });
    } catch (e) {
      setState(() {
        _errorMessage = e.toString();
        _isLoading = false;
      });
    }
  }

  
  Widget build(BuildContext context) {
    if (_isLoading) {
      return _buildLoadingState();
    }

    if (_errorMessage != null) {
      return _buildErrorState();
    }

    return _buildContent();
  }

  Widget _buildLoadingState() {
    return GridView.builder(
      padding: const EdgeInsets.all(16),
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 2,
        childAspectRatio: 0.65,
        crossAxisSpacing: 12,
        mainAxisSpacing: 12,
      ),
      itemCount: 6,
      itemBuilder: (context, index) => _buildShimmerCard(),
    );
  }

  Widget _buildShimmerCard() {
    return Container(
      decoration: BoxDecoration(
        color: Colors.grey[300],
        borderRadius: BorderRadius.circular(12),
      ),
    );
  }

  Widget _buildErrorState() {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          const Icon(Icons.error_outline, size: 64, color: Colors.grey),
          const SizedBox(height: 16),
          Text(_errorMessage ?? 'Unknown error'),
          const SizedBox(height: 16),
          ElevatedButton(
            onPressed: _loadMovies,
            child: const Text('Retry'),
          ),
        ],
      ),
    );
  }

  Widget _buildContent() {
    final movies = _movieResponse?.movies ?? [];
    return RefreshIndicator(
      onRefresh: _loadMovies,
      child: GridView.builder(
        padding: const EdgeInsets.all(16),
        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 2,
          childAspectRatio: 0.65,
          crossAxisSpacing: 12,
          mainAxisSpacing: 12,
        ),
        itemCount: movies.length,
        itemBuilder: (context, index) => _buildMovieCard(movies[index]),
      ),
    );
  }

  Widget _buildMovieCard(Movie movie) {
    return GestureDetector(
      onTap: () => Navigator.push(
        context,
        MaterialPageRoute(
          builder: (context) => MovieDetailPage(movieId: movie.id),
        ),
      ),
      child: Card(
        clipBehavior: Clip.antiAlias,
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Expanded(
              child: Image.network(
                movie.posterUrl,
                fit: BoxFit.cover,
                width: double.infinity,
                errorBuilder: (context, error, stackTrace) =>
                    Container(color: Colors.grey[300]),
              ),
            ),
            Padding(
              padding: const EdgeInsets.all(8),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    movie.title,
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                    style: const TextStyle(fontWeight: FontWeight.bold),
                  ),
                  const SizedBox(height: 4),
                  Row(
                    children: [
                      const Icon(Icons.star, size: 14, color: Colors.amber),
                      const SizedBox(width: 4),
                      Text(movie.rating.toStringAsFixed(1)),
                    ],
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

五、运行验证

5.1 模拟器环境准备

  1. 打开 DevEco Studio,选择 Flutter for OpenHarmony 项目
  2. 配置模拟器或连接真实鸿蒙设备
  3. 确保 pubspec.yaml 已添加必要依赖:
dependencies:
  flutter:
    sdk: flutter
  http: ^1.1.0
  connectivity_plus: ^5.0.0

5.2 模拟器运行截图

以下是应用在开源鸿蒙模拟器上成功运行的截图验证:

离线模式展示

网络断开后,应用进入离线模式:

  • 显示橙色离线提示条
  • 展示已缓存的数据
  • 底部出现重试按钮

在这里插入图片描述

图4:日志输出验证

通过 DevEco Studio 的日志面板可以查看到完整的网络请求日志:

# 正常请求
[MovieService] GET /movie/in_theaters - 200 OK
[MovieService] Parsed 20 movies

# 网络异常降级
[MovieService] Network error: SocketException
[MovieService] Falling back to mock data

# 网络状态变化
[ConnectivityService] Network changed: wifi -> none
[ConnectivityService] Network changed: none -> cellular

5.3 关键功能验证点

功能 验证方法 预期结果
网络请求 正常网络下打开页面 显示真实电影数据
Mock 降级 断网后刷新页面 显示示例数据
离线提示 断开网络 显示 OfflineBanner
重试机制 点击重试按钮 重新发起请求

六、实践建议

6.1 性能优化建议

  1. 避免重复请求:使用 ProviderRiverpod 缓存数据,网络变化时复用已有数据
  2. 合理设置超时:默认 15 秒超时,移动网络可适当延长至 30 秒
  3. 图片加载优化:使用 cached_network_image 缓存图片,减少流量消耗
  4. 请求取消:页面销毁时取消未完成的请求,避免内存泄漏

6.2 稳定性保障

  1. 优雅降级:网络异常时返回 Mock 数据,保证应用可用性
  2. 错误分类:区分网络错误、业务错误、服务器错误,针对性处理
  3. 重试策略:对临时性错误实现自动重试,使用指数退避算法
  4. 日志记录:记录网络错误详情,便于排查问题

6.3 用户体验优化

  1. 骨架屏加载:使用骨架屏替代传统 Loading 指示器,减少等待感知
  2. 离线数据展示:网络恢复前显示已缓存数据,提供流畅体验
  3. 状态提示:区分加载中、无数据、网络错误等不同状态
  4. 手势反馈:下拉刷新、长按重试等交互增强可用性

七、总结与展望

7.1 本文总结

本文详细介绍了在 Flutter for OpenHarmony 平台上实现网络状态监听与数据请求的完整方案,通过 Dart 语言和 Flutter 声明式开发范式,实现了以下核心功能:

功能模块 实现方式 关键价值
网络请求封装 http + ApiResponse 统一错误处理、统一响应格式
超时控制 timeout + TimeoutException 防止请求无限等待
Mock 降级 异常时返回模拟数据 保障离线可用性
骨架屏 条件渲染 + 动画 减少加载等待感知
离线提示 connectivity_plus 监听 实时感知网络变化

7.2 扩展方向

基于本文的基础架构,开发者可以进一步扩展以下能力:

  1. Dio 拦截器:使用 Dio 的拦截器实现更强大的请求/响应统一处理
  2. 本地数据库缓存:结合 Hive 或 SQLite 实现数据的本地持久化
  3. 网络诊断工具:集成 ping、traceroute 等能力,帮助用户排查网络问题
  4. 智能重试队列:在离线时将请求加入队列,网络恢复后自动发送

7.3 代码获取

完整示例代码已托管至 AtomGit,开发者可以自由获取并进行二次开发:

仓库地址:https://atomgit.com/maaath/flutter-network-listener-demo

相关阅读:

  • Flutter 中文文档:https://flutter.cn
  • http 插件文档:https://pub.dev/packages/http
  • connectivity_plus 插件文档:https://pub.dev/packages/connectivity_plus

作者:maaath
首发平台:开源鸿蒙跨平台社区
未经授权禁止转载

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐