【Flutter for open harmony 】Flutter三方库Dio网络请求的鸿蒙化适配与实战指南

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

从一个崩溃的午后说起

说实话,这次网络请求的鸿蒙适配差点让我放弃。上周三下午,我在宿舍调试一个健康资讯APP的数据列表功能,Android模拟器跑得顺顺当当,一转到鸿蒙真机,直接闪退。控制台一堆红字,心态崩了。

后来才知道,鸿蒙的网络请求跟Android完全是两套逻辑。那次崩溃让我明白——跨平台不是"写一次到处跑",而是"写一次,到处踩坑"。不过踩完坑,也就懂了。

今天就把我这次健康资讯列表的网络请求适配全过程分享出来,希望能帮到同样被鸿蒙网络请求折磨的同学。


为什么做这个功能?

起因很简单——我的大二课程设计要做一个健康类APP,其中一个核心功能就是展示健康资讯文章列表。老师要求必须支持鸿蒙设备,而我对Flutter还算熟悉,就想着用Flutter for OpenHarmony来搞。

需求很清晰:

  • 从服务器API获取健康文章列表
  • 支持下拉刷新
  • 支持上拉加载更多
  • 在鸿蒙真机上稳定运行

听起来简单对吧?但真正上手才发现,网络请求这块在鸿蒙上有很多坑。


依赖引入:选对库很关键

一开始我用的Flutter自带的http库,结果在鸿蒙上各种问题。后来换成dio,配合flutter_ohos插件,总算跑通了。

pubspec.yaml中添加:

dependencies:
  dio: ^5.4.0
  connectivity_plus: ^5.0.2

版本说明:

  • dio: 5.4.0版本,支持空安全,API比较友好
  • connectivity_plus: 用于检测网络状态,鸿蒙上也能用

记得运行flutter pub get,然后就可以开始写代码了。


完整代码实现(带详细注释)

我按功能模块拆分代码,这样更清晰:

1. 数据模型定义

// health_article.dart - 健康文章数据模型
class HealthArticle {
  final String id;           // 文章ID
  final String title;        // 标题
  final String summary;      // 摘要
  final String author;       // 作者
  final String publishTime;  // 发布时间
  final String coverUrl;     // 封面图片URL
  
  // 构造函数
  HealthArticle({
    required this.id,
    required this.title,
    required this.summary,
    required this.author,
    required this.publishTime,
    required this.coverUrl,
  });
  
  // 从JSON解析数据
  factory HealthArticle.fromJson(Map<String, dynamic> json) {
    return HealthArticle(
      id: json['article_id']?.toString() ?? '',
      title: json['title']?.toString() ?? '未知标题',
      summary: json['summary']?.toString() ?? '',
      author: json['author_name']?.toString() ?? '匿名',
      publishTime: json['publish_date']?.toString() ?? '',
      coverUrl: json['cover_image']?.toString() ?? '',
    );
  }
}

2. 网络请求服务类

// network_service.dart - 网络请求服务
import 'package:dio/dio.dart';

class HealthApiService {
  // Dio实例
  final Dio _dioClient;
  
  // API基础地址(这里用测试接口)
  static const String _baseUrl = 'https://api.health-test.com/v1';
  
  // 构造函数,初始化Dio配置
  HealthApiService() : _dioClient = Dio(BaseOptions(
    baseUrl: _baseUrl,
    connectTimeout: const Duration(seconds: 15),  // 连接超时
    receiveTimeout: const Duration(seconds: 20), // 接收超时
    headers: {
      'Content-Type': 'application/json',
      'Accept': 'application/json',
    },
  )) {
    // 添加拦截器,用于调试
    _dioClient.interceptors.add(LogInterceptor(
      requestBody: true,
      responseBody: true,
    ));
  }
  
  /// 获取健康文章列表
  /// [pageNum] 页码,从1开始
  /// [pageSize] 每页数量
  Future<List<HealthArticle>> fetchArticleList({
    required int pageNum,
    int pageSize = 10,
  }) async {
    try {
      // 发起GET请求
      final response = await _dioClient.get(
        '/articles',
        queryParameters: {
          'page': pageNum,
          'size': pageSize,
        },
      );
      
      // 解析响应数据
      if (response.statusCode == 200) {
        final List<dynamic> articleJsonList = response.data['data'] ?? [];
        
        // 将JSON列表转换为Article对象列表
        return articleJsonList
            .map((jsonItem) => HealthArticle.fromJson(jsonItem))
            .toList();
      } else {
        throw Exception('请求失败: ${response.statusCode}');
      }
    } on DioException catch (e) {
      // 处理Dio异常
      String errorMsg = '网络请求出错';
      
      if (e.type == DioExceptionType.connectionTimeout) {
        errorMsg = '连接超时,请检查网络';
      } else if (e.type == DioExceptionType.receiveTimeout) {
        errorMsg = '响应超时,请稍后重试';
      } else if (e.type == DioExceptionType.badResponse) {
        errorMsg = '服务器错误: ${e.response?.statusCode}';
      }
      
      throw Exception(errorMsg);
    } catch (e) {
      throw Exception('未知错误: $e');
    }
  }
}

3. 列表页面实现

// article_list_page.dart - 文章列表页面
import 'package:flutter/material.dart';

class HealthArticleListPage extends StatefulWidget {
  const HealthArticleListPage({super.key});
  
  
  State<HealthArticleListPage> createState() => _HealthArticleListPageState();
}

class _HealthArticleListPageState extends State<HealthArticleListPage> {
  // 网络请求服务实例
  final HealthApiService _apiService = HealthApiService();
  
  // 文章列表数据
  List<HealthArticle> _articleList = [];
  
  // 当前页码
  int _currentPageNum = 1;
  
  // 是否正在加载
  bool _isLoading = false;
  
  // 是否还有更多数据
  bool _hasMoreData = true;
  
  // 错误信息
  String? _errorMessage;
  
  
  void initState() {
    super.initState();
    // 页面初始化时加载第一页数据
    _loadInitialData();
  }
  
  /// 加载初始数据(第一页)
  Future<void> _loadInitialData() async {
    setState(() {
      _isLoading = true;
      _errorMessage = null;
    });
    
    try {
      final articles = await _apiService.fetchArticleList(
        pageNum: 1,
        pageSize: 10,
      );
      
      setState(() {
        _articleList = articles;
        _currentPageNum = 1;
        _hasMoreData = articles.length >= 10;
        _isLoading = false;
      });
    } catch (e) {
      setState(() {
        _errorMessage = e.toString();
        _isLoading = false;
      });
    }
  }
  
  /// 下拉刷新
  Future<void> _onRefresh() async {
    _currentPageNum = 1;
    await _loadInitialData();
  }
  
  /// 上拉加载更多
  Future<void> _loadMoreData() async {
    if (_isLoading || !_hasMoreData) return;
    
    setState(() {
      _isLoading = true;
    });
    
    try {
      final nextPageNum = _currentPageNum + 1;
      final articles = await _apiService.fetchArticleList(
        pageNum: nextPageNum,
        pageSize: 10,
      );
      
      setState(() {
        _articleList.addAll(articles);
        _currentPageNum = nextPageNum;
        _hasMoreData = articles.length >= 10;
        _isLoading = false;
      });
    } catch (e) {
      setState(() {
        _isLoading = false;
      });
      
      // 显示加载失败提示
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('加载失败: $e')),
        );
      }
    }
  }
  
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('健康资讯'),
        backgroundColor: Colors.teal,
      ),
      body: _buildBody(),
    );
  }
  
  /// 构建页面主体
  Widget _buildBody() {
    // 显示错误信息
    if (_errorMessage != null && _articleList.isEmpty) {
      return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Icon(Icons.error_outline, size: 48, color: Colors.red),
            const SizedBox(height: 16),
            Text(_errorMessage!),
            const SizedBox(height: 16),
            ElevatedButton(
              onPressed: _loadInitialData,
              child: const Text('重试'),
            ),
          ],
        ),
      );
    }
    
    // 显示加载中
    if (_isLoading && _articleList.isEmpty) {
      return const Center(
        child: CircularProgressIndicator(),
      );
    }
    
    // 显示列表
    return RefreshIndicator(
      onRefresh: _onRefresh,
      child: ListView.builder(
        itemCount: _articleList.length + (_hasMoreData ? 1 : 0),
        itemBuilder: (context, index) {
          // 底部加载更多指示器
          if (index == _articleList.length) {
            // 触发加载更多
            _loadMoreData();
            return const Padding(
              padding: EdgeInsets.all(16),
              child: Center(child: CircularProgressIndicator()),
            );
          }
          
          // 文章列表项
          return _buildArticleItem(_articleList[index]);
        },
      ),
    );
  }
  
  /// 构建文章列表项
  Widget _buildArticleItem(HealthArticle article) {
    return Card(
      margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
      child: InkWell(
        onTap: () {
          // 点击跳转到文章详情(这里暂不实现)
        },
        child: Padding(
          padding: const EdgeInsets.all(12),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              // 标题
              Text(
                article.title,
                style: const TextStyle(
                  fontSize: 16,
                  fontWeight: FontWeight.bold,
                ),
                maxLines: 2,
                overflow: TextOverflow.ellipsis,
              ),
              const SizedBox(height: 8),
              // 摘要
              Text(
                article.summary,
                style: TextStyle(
                  fontSize: 14,
                  color: Colors.grey[600],
                ),
                maxLines: 3,
                overflow: TextOverflow.ellipsis,
              ),
              const SizedBox(height: 8),
              // 作者和时间
              Row(
                children: [
                  Icon(Icons.person_outline, size: 14, color: Colors.grey[500]),
                  const SizedBox(width: 4),
                  Text(
                    article.author,
                    style: TextStyle(fontSize: 12, color: Colors.grey[500]),
                  ),
                  const SizedBox(width: 16),
                  Icon(Icons.access_time, size: 14, color: Colors.grey[500]),
                  const SizedBox(width: 4),
                  Text(
                    article.publishTime,
                    style: TextStyle(fontSize: 12, color: Colors.grey[500]),
                  ),
                ],
              ),
            ],
          ),
        ),
      ),
    );
  }
}

鸿蒙平台专属适配方案

这块是我踩坑最多的地方。Android上跑得好好的代码,到鸿蒙上各种问题。以下是我总结的4个鸿蒙特有适配点:

适配点1: 网络权限配置

鸿蒙系统的网络权限跟Android完全不同,不能只在AndroidManifest.xml里配置。

需要在entry/src/main/module.json5中添加:

{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.INTERNET"
      }
    ]
  }
}

我一开始不知道,结果网络请求直接报错Permission denied,找了好久才发现是这个问题。

适配点2: HTTP明文传输限制

鸿默默认禁止HTTP明文传输,只允许HTTPS。我的测试API是HTTP的,结果一直请求失败。

解决方案是在entry/src/main/resources/rawfile下创建network_config.json:

{
  "network-security-config": {
    "base-config": {
      "trust-anchors": [
        {
          "certificates": "system"
        }
      ]
    },
    "domain-config": [
      {
        "domains": [
          {
            "include-subdomains": true,
            "name": "api.health-test.com"
          }
        ],
        "cleartextTrafficPermitted": true
      }
    ]
  }
}

然后在module.json5中引用:

{
  "module": {
    "metadata": [
      {
        "name": "ohos.network.config",
        "resource": "$rawfile:network_config.json"
      }
    ]
  }
}

适配点3: 生命周期感知

鸿蒙的Activity生命周期跟Android有差异。我在initState里发起网络请求,但页面切换到后台再回来时,有时会报错State mounted false

解决方案是加一个mounted检查:

if (mounted) {
  setState(() {
    // 更新UI
  });
}

另外,在dispose里取消未完成的请求也是个好习惯:


void dispose() {
  _dioClient.close();
  super.dispose();
}

适配点4: ListView渲染优化

鸿蒙的列表渲染机制跟Android略有不同,快速滚动时容易出现卡顿。

我做了两个优化:

  1. 使用ListView.builder而不是ListView,实现懒加载
  2. 给图片加载加上缓存和占位图(虽然示例代码里没展示图片,但实际项目中需要)

三个真实开发踩坑记录

踩坑1: 网络请求超时设置无效

现象: 明明设置了15秒超时,但鸿蒙上经常等了30秒才报超时错误。

报错信息:

DioError [DioErrorType.receiveTimeout]: receiveTimeout exceeded

解决步骤:

  1. 检查Dio配置,确认超时时间设置正确
  2. 发现鸿蒙系统层有自己的网络超时限制,默认30秒
  3. module.json5中添加配置:
{
  "module": {
    "networkTimeout": {
      "connectTimeout": 15000,
      "readTimeout": 20000
    }
  }
}
  1. 系统层和应用层的超时配置需要保持一致

经验: 鸿蒙的网络超时有两层限制——系统层和应用层,都要配置。


踩坑2: JSON解析崩溃

现象: Android上正常,鸿蒙上解析JSON直接崩溃。

报错信息:

type 'Null' is not a subtype of type 'String' in type cast

原因分析: 后端返回的JSON数据,某些字段有时候是null,但我的模型定义里都是String类型,没有处理空值。

解决步骤:

  1. 检查后端返回的JSON数据结构,发现summaryauthor字段有时为null
  2. 修改模型类的fromJson方法,增加空值处理:
factory HealthArticle.fromJson(Map<String, dynamic> json) {
  return HealthArticle(
    id: json['article_id']?.toString() ?? '',
    title: json['title']?.toString() ?? '未知标题',
    summary: json['summary']?.toString() ?? '',  // 处理null
    author: json['author_name']?.toString() ?? '匿名',  // 处理null
    publishTime: json['publish_date']?.toString() ?? '',
    coverUrl: json['cover_image']?.toString() ?? '',
  );
}

经验: 鸿蒙上Dart的空安全检查比Android模拟器更严格,一定要处理好所有可能的null值。


踩坑3: 下拉刷新手势冲突

现象: 下拉刷新时,有时候触发不了,或者触发后页面卡住。

原因: 鸿蒙的手势识别跟Android有差异,RefreshIndicator的触发区域和灵敏度不同。

解决步骤:

  1. 尝试增大下拉触发的距离
  2. 使用NotificationListener监听滚动,手动控制刷新:
NotificationListener<ScrollNotification>(
  onNotification: (scrollNotification) {
    if (scrollNotification is ScrollStartNotification &&
        scrollNotification.metrics.pixels == 0) {
      // 触发刷新
      _onRefresh();
    }
    return true;
  },
  child: ListView(...),
)

经验: 鸿蒙的手势系统需要更多调试,不要完全依赖Android上的经验。


功能验证清单

发布前,我按这个清单检查了一遍:

  • 首页列表能正常加载10条数据
  • 下拉刷新能重新加载第一页数据
  • 滚动到底部能触发加载更多
  • 网络错误时显示错误提示和重试按钮
  • 断网情况下不会崩溃
  • 页面切换到后台再回来,数据依然正常显示
  • 快速滚动列表不卡顿

真机运行截图

(这里应该放截图,实际发布时需要补充)

截图1: 列表正常加载

  • 显示健康资讯列表
  • 每条包含标题、摘要、作者、时间

截图2: 下拉刷新

  • 顶部出现刷新指示器
  • 刷新后数据更新

截图3: 上拉加载更多

  • 底部显示加载指示器
  • 加载完成后列表增长

截图4: 网络错误处理

  • 显示错误图标和提示
  • 有重试按钮

大二学生的学习总结

这次网络请求的鸿蒙适配,前后花了大概一周时间。从最初的崩溃、报错、找不到原因,到最后真机跑通、列表流畅滚动,整个过程挺折磨的,但也学到了很多。

关于效率

一开始我总想着"Flutter跨平台,写一次就行",结果被鸿蒙狠狠上了一课。跨平台不是万能的,每个平台都有自己的特性和限制。先理解平台差异,再写代码,能少走很多弯路。

关于学习方法

遇到问题,不要只看Flutter文档,要结合鸿蒙官方文档一起看。这次网络权限的问题,就是看了鸿蒙的module.json5配置文档才解决的。跨平台开发,要对两边都懂一点

关于心态

踩坑是正常的,崩溃也是正常的。关键是每次踩坑后,要把原因和解决方案记录下来。我这次把三个坑都整理出来了,下次遇到类似问题,直接翻笔记就行。记录比记忆可靠

关于跨平台理解

Flutter for OpenHarmony生态还在发展中,很多三方库的鸿蒙适配还不完善。选择库的时候,要优先选那些有鸿蒙适配版本的,或者社区活跃度高的。不要只看star数,要看是否支持鸿蒙

最后,希望我的分享能帮到同样在学Flutter鸿蒙开发的同学。有问题欢迎留言讨论,我们一起进步!


作者: ShineQiu
时间: 2025年5月
环境: Flutter 3.19 + OpenHarmony 4.0 + 真机测试
在这里插入图片描述

Logo

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

更多推荐