【maaath】Flutter for OpenHarmony网络状态监听能力集成实战
·
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 模拟器环境准备
- 打开 DevEco Studio,选择 Flutter for OpenHarmony 项目
- 配置模拟器或连接真实鸿蒙设备
- 确保
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 性能优化建议
- 避免重复请求:使用
Provider或Riverpod缓存数据,网络变化时复用已有数据 - 合理设置超时:默认 15 秒超时,移动网络可适当延长至 30 秒
- 图片加载优化:使用
cached_network_image缓存图片,减少流量消耗 - 请求取消:页面销毁时取消未完成的请求,避免内存泄漏
6.2 稳定性保障
- 优雅降级:网络异常时返回 Mock 数据,保证应用可用性
- 错误分类:区分网络错误、业务错误、服务器错误,针对性处理
- 重试策略:对临时性错误实现自动重试,使用指数退避算法
- 日志记录:记录网络错误详情,便于排查问题
6.3 用户体验优化
- 骨架屏加载:使用骨架屏替代传统 Loading 指示器,减少等待感知
- 离线数据展示:网络恢复前显示已缓存数据,提供流畅体验
- 状态提示:区分加载中、无数据、网络错误等不同状态
- 手势反馈:下拉刷新、长按重试等交互增强可用性
七、总结与展望
7.1 本文总结
本文详细介绍了在 Flutter for OpenHarmony 平台上实现网络状态监听与数据请求的完整方案,通过 Dart 语言和 Flutter 声明式开发范式,实现了以下核心功能:
| 功能模块 | 实现方式 | 关键价值 |
|---|---|---|
| 网络请求封装 | http + ApiResponse |
统一错误处理、统一响应格式 |
| 超时控制 | timeout + TimeoutException |
防止请求无限等待 |
| Mock 降级 | 异常时返回模拟数据 | 保障离线可用性 |
| 骨架屏 | 条件渲染 + 动画 | 减少加载等待感知 |
| 离线提示 | connectivity_plus 监听 |
实时感知网络变化 |
7.2 扩展方向
基于本文的基础架构,开发者可以进一步扩展以下能力:
- Dio 拦截器:使用 Dio 的拦截器实现更强大的请求/响应统一处理
- 本地数据库缓存:结合 Hive 或 SQLite 实现数据的本地持久化
- 网络诊断工具:集成 ping、traceroute 等能力,帮助用户排查网络问题
- 智能重试队列:在离线时将请求加入队列,网络恢复后自动发送
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
首发平台:开源鸿蒙跨平台社区
未经授权禁止转载
更多推荐
所有评论(0)