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

欢迎加入开源鸿蒙跨平台社区:

大家好,我是ShineQiu,上海某高校大二计算机科学与技术专业的学生。最近在做一个健康管理APP的课程项目,需要实现一个每日步数统计和健康资讯的功能模块。本来以为用Flutter写好代码直接跑在鸿蒙上就行,结果踩了好几个坑,折腾了整整三天才搞定。今天就来跟大家分享一下我用Dio库实现网络请求的整个过程,希望能帮到刚入门的小伙伴们!

一、为什么选择Dio做网络请求?

一开始我用的是Flutter自带的HttpClient,但是写起来太繁琐了,还要自己处理很多细节。后来学长推荐了Dio,说是Flutter社区最流行的网络请求库。试了一下确实香:支持拦截器、全局配置、请求取消、文件上传下载,功能超级全。而且Dart的异步编程模型配合Dio的async/await语法,写起来特别舒服。

二、依赖引入与版本说明

在pubspec.yaml里添加依赖:

dependencies:
  flutter:
    sdk: flutter
  dio: ^5.4.3+1  # 当前最新稳定版
  json_annotation: ^4.8.1

执行flutter pub get安装依赖。这里要注意,鸿蒙上运行的话,Dio版本不能太低,我一开始用的4.x版本,结果在鸿蒙设备上连不上网,后来升级到5.x才解决。

三、功能实现:健康资讯列表

我做的是一个健康资讯列表页面,从API获取文章列表,展示标题、封面图、阅读量和发布时间。下面是完整代码:

3.1 数据模型

import 'package:json_annotation/json_annotation.dart';

// 健康资讯文章模型
part 'article_model.g.dart';

()
class HealthArticle {
  // 文章ID
  final int id;
  // 文章标题
  final String title;
  // 封面图片URL
  final String coverUrl;
  // 阅读量
  final int readCount;
  // 发布时间
  final String publishTime;
  // 文章摘要
  final String summary;

  HealthArticle({
    required this.id,
    required this.title,
    required this.coverUrl,
    required this.readCount,
    required this.publishTime,
    required this.summary,
  });

  // 从JSON解析对象
  factory HealthArticle.fromJson(Map<String, dynamic> json) =>
      _$HealthArticleFromJson(json);

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

// 文章列表响应模型
()
class ArticleListResponse {
  final int code;
  final String message;
  final List<HealthArticle> data;

  ArticleListResponse({
    required this.code,
    required this.message,
    required this.data,
  });

  factory ArticleListResponse.fromJson(Map<String, dynamic> json) =>
      _$ArticleListResponseFromJson(json);
}

3.2 Dio网络请求封装

import 'dart:convert';
import 'package:dio/dio.dart';
import '../models/article_model.dart';

class HealthApiService {
  // 创建Dio实例
  final Dio _dio = Dio(BaseOptions(
    baseUrl: 'https://api.example.com/health',
    connectTimeout: const Duration(seconds: 10),
    receiveTimeout: const Duration(seconds: 10),
    headers: {
      'Content-Type': 'application/json',
      'User-Agent': 'HealthApp/1.0.0 (HarmonyOS)',
    },
  ));

  HealthApiService() {
    // 添加请求拦截器
    _dio.interceptors.add(InterceptorsWrapper(
      onRequest: (options, handler) {
        print('请求URL: ${options.uri}');
        print('请求参数: ${options.data}');
        return handler.next(options);
      },
      onResponse: (response, handler) {
        print('响应状态: ${response.statusCode}');
        print('响应数据: ${response.data}');
        return handler.next(response);
      },
      onError: (DioException e, handler) {
        print('请求错误: ${e.message}');
        return handler.next(e);
      },
    ));
  }

  /// 获取健康资讯列表
  Future<List<HealthArticle>> fetchArticleList({
    int page = 1,
    int pageSize = 10,
  }) async {
    try {
      final response = await _dio.get(
        '/articles',
        queryParameters: {
          'page': page,
          'pageSize': pageSize,
        },
      );

      if (response.statusCode == 200) {
        final result = ArticleListResponse.fromJson(response.data);
        if (result.code == 0) {
          return result.data;
        } else {
          throw Exception('API返回错误: ${result.message}');
        }
      } else {
        throw Exception('HTTP请求失败: ${response.statusCode}');
      }
    } on DioException catch (e) {
      // 处理网络异常
      if (e.type == DioExceptionType.connectionTimeout) {
        throw Exception('网络连接超时,请检查网络设置');
      } else if (e.type == DioExceptionType.receiveTimeout) {
        throw Exception('数据接收超时');
      } else if (e.type == DioExceptionType.badResponse) {
        throw Exception('服务器返回错误: ${e.response?.statusCode}');
      } else {
        throw Exception('网络请求失败: ${e.message}');
      }
    } catch (e) {
      throw Exception('未知错误: $e');
    }
  }
}

3.3 页面UI实现

import 'package:flutter/material.dart';
import '../services/api_service.dart';
import '../models/article_model.dart';

class HealthNewsPage extends StatefulWidget {
  const HealthNewsPage({super.key});

  
  State<HealthNewsPage> createState() => _HealthNewsPageState();
}

class _HealthNewsPageState extends State<HealthNewsPage> {
  // 文章列表数据
  List<HealthArticle> _articles = [];
  // 是否正在加载
  bool _isLoading = false;
  // 当前页码
  int _currentPage = 1;
  // 页面大小
  final int _pageSize = 10;
  // 是否还有更多数据
  bool _hasMore = true;
  // 错误信息
  String? _errorMessage;
  // 网络服务实例
  final HealthApiService _apiService = HealthApiService();

  
  void initState() {
    super.initState();
    // 初始化时加载第一页数据
    _loadArticles();
  }

  /// 加载文章列表
  Future<void> _loadArticles({bool isRefresh = false}) async {
    // 如果正在加载,直接返回
    if (_isLoading) return;

    // 如果不是下拉刷新,且没有更多数据,返回
    if (!isRefresh && !_hasMore) return;

    setState(() {
      _isLoading = true;
      // 下拉刷新时重置页码和数据
      if (isRefresh) {
        _currentPage = 1;
        _articles.clear();
        _hasMore = true;
        _errorMessage = null;
      }
    });

    try {
      // 调用API获取数据
      final articles = await _apiService.fetchArticleList(
        page: _currentPage,
        pageSize: _pageSize,
      );

      setState(() {
        // 如果返回的数据少于每页大小,说明没有更多数据了
        if (articles.length < _pageSize) {
          _hasMore = false;
        }
        // 添加新数据
        _articles.addAll(articles);
        // 页码+1
        _currentPage++;
      });
    } catch (e) {
      setState(() {
        _errorMessage = e.toString();
      });
    } finally {
      setState(() {
        _isLoading = false;
      });
    }
  }

  /// 构建文章卡片
  Widget _buildArticleCard(HealthArticle article) {
    return Card(
      elevation: 2,
      margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(12),
      ),
      child: Padding(
        padding: const EdgeInsets.all(12),
        child: Row(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // 封面图片
            ClipRRect(
              borderRadius: BorderRadius.circular(8),
              child: Image.network(
                article.coverUrl,
                width: 100,
                height: 80,
                fit: BoxFit.cover,
                errorBuilder: (context, error, stackTrace) {
                  return Container(
                    width: 100,
                    height: 80,
                    color: Colors.grey[200],
                    child: const Icon(Icons.image_not_supported),
                  );
                },
              ),
            ),
            // 文章信息
            Expanded(
              child: Padding(
                padding: const EdgeInsets.only(left: 12),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    // 标题
                    Text(
                      article.title,
                      style: const TextStyle(
                        fontSize: 16,
                        fontWeight: FontWeight.w600,
                        color: Colors.black87,
                      ),
                      maxLines: 2,
                      overflow: TextOverflow.ellipsis,
                    ),
                    // 摘要
                    const SizedBox(height: 4),
                    Text(
                      article.summary,
                      style: TextStyle(
                        fontSize: 13,
                        color: Colors.grey[600],
                      ),
                      maxLines: 2,
                      overflow: TextOverflow.ellipsis,
                    ),
                    // 底部信息
                    const SizedBox(height: 8),
                    Row(
                      children: [
                        const Icon(
                          Icons.remove_red_eye_outlined,
                          size: 14,
                          color: Colors.grey,
                        ),
                        const SizedBox(width: 4),
                        Text(
                          '${article.readCount}阅读',
                          style: TextStyle(
                            fontSize: 12,
                            color: Colors.grey[500],
                          ),
                        ),
                        const SizedBox(width: 12),
                        Text(
                          article.publishTime,
                          style: TextStyle(
                            fontSize: 12,
                            color: Colors.grey[500],
                          ),
                        ),
                      ],
                    ),
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('健康资讯'),
        centerTitle: true,
        backgroundColor: Colors.lightBlue[400],
      ),
      body: _buildBody(),
    );
  }

  /// 构建页面主体
  Widget _buildBody() {
    // 显示错误信息
    if (_errorMessage != null) {
      return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Icon(
              Icons.error_outline,
              size: 64,
              color: Colors.red,
            ),
            const SizedBox(height: 16),
            Text(
              _errorMessage!,
              textAlign: TextAlign.center,
              style: const TextStyle(fontSize: 16, color: Colors.red),
            ),
            const SizedBox(height: 16),
            ElevatedButton(
              onPressed: () => _loadArticles(isRefresh: true),
              child: const Text('重新加载'),
            ),
          ],
        ),
      );
    }

    // 显示加载中的状态
    if (_articles.isEmpty && _isLoading) {
      return const Center(
        child: CircularProgressIndicator(),
      );
    }

    // 显示文章列表
    return RefreshIndicator(
      onRefresh: () => _loadArticles(isRefresh: true),
      child: ListView.builder(
        itemCount: _articles.length + (_hasMore ? 1 : 0),
        itemBuilder: (context, index) {
          // 加载更多的指示器
          if (index == _articles.length) {
            return _buildLoadMoreIndicator();
          }
          return _buildArticleCard(_articles[index]);
        },
      ),
    );
  }

  /// 构建加载更多指示器
  Widget _buildLoadMoreIndicator() {
    return _isLoading
        ? const Center(
            child: Padding(
              padding: EdgeInsets.all(16),
              child: CircularProgressIndicator(),
            ),
          )
        : const SizedBox.shrink();
  }
}

四、鸿蒙平台专属适配方案

作为一个新手,我以为Flutter代码能直接在鸿蒙上跑,结果发现有很多鸿蒙特有的适配点要处理:

4.1 网络权限配置

鸿蒙系统对网络权限有严格要求,必须在module.json5中声明网络权限:

{
  "module": {
    "name": "entry",
    "type": "entry",
    "description": "$string:module_desc",
    "mainElement": "EntryAbility",
    "deviceTypes": [
      "phone",
      "tablet"
    ],
    "abilities": [...],
    "requestPermissions": [
      {
        "name": "ohos.permission.INTERNET"
      }
    ]
  }
}

4.2 渲染机制差异

鸿蒙的Flutter引擎在处理图片渲染时有自己的特点,特别是Image.network加载网络图片时:

  • 需要确保图片URL是HTTPS的,HTTP可能会被拦截
  • 图片加载失败时的errorBuilder回调必须处理,否则会导致崩溃

4.3 生命周期适配

在鸿蒙上,应用进入后台时可能会被系统暂停,需要在onPause时取消正在进行的网络请求:


void deactivate() {
  // 取消所有网络请求
  _apiService.cancelAllRequests();
  super.deactivate();
}

4.4 组件差异处理

鸿蒙的Flutter组件在某些细节上和Android/iOS有差异:

  • RefreshIndicator的下拉距离在鸿蒙上可能需要调整
  • 字体渲染效果略有不同,需要在鸿蒙设备上测试调整

五、真实开发踩坑记录

作为一个大二学生,第一次接触鸿蒙开发,踩了不少坑,现在想想都是宝贵的经验:

坑一:Dio请求超时但无错误提示

问题现象
在鸿蒙设备上调用API,有时候会卡住不动,没有任何错误提示,界面一直显示加载中。

报错信息
控制台没有任何错误输出,请求像是石沉大海。

解决步骤

  1. 一开始以为是网络问题,检查了手机网络设置,没问题
  2. 后来在Dio的拦截器中添加了详细日志,发现请求发出去了,但一直没有响应
  3. 查资料发现是Dio的默认超时时间在鸿蒙上可能不够,特别是网络不稳定的时候
  4. connectTimeoutreceiveTimeout都改成了10秒,问题解决!

代码修改

final Dio _dio = Dio(BaseOptions(
  connectTimeout: const Duration(seconds: 10),
  receiveTimeout: const Duration(seconds: 10),
));

坑二:JSON解析时字段类型不匹配

问题现象
在Android上运行正常,但在鸿蒙上解析JSON时崩溃。

报错信息

Unhandled Exception: type 'String' is not a subtype of type 'int' in type cast

解决步骤

  1. 仔细对比API返回的数据,发现readCount字段在某些情况下返回的是字符串"123"而不是数字123
  2. 在数据模型中使用JsonKey注解处理类型转换
  3. 添加了fromJson的自定义处理逻辑

代码修改

(fromJson: _readCountFromJson)
final int readCount;

static int _readCountFromJson(dynamic value) {
  if (value is String) {
    return int.parse(value);
  }
  return value as int;
}

坑三:图片加载失败导致界面崩溃

问题现象
当网络图片URL无效或图片加载失败时,应用直接崩溃。

报错信息

Failed assertion: line 284 pos 14: 'url != null': is not true.

解决步骤

  1. 一开始没注意到这个问题,因为测试用的图片都是有效的
  2. 后来用了一个无效的图片URL测试,发现崩溃了
  3. Image.network中添加了errorBuilder回调处理加载失败的情况
  4. 同时对URL进行了非空判断

代码修改

Image.network(
  article.coverUrl ?? '',
  width: 100,
  height: 80,
  fit: BoxFit.cover,
  errorBuilder: (context, error, stackTrace) {
    return Container(
      width: 100,
      height: 80,
      color: Colors.grey[200],
      child: const Icon(Icons.image_not_supported),
    );
  },
)

六、功能验证清单

我总结了一个验证清单,确保功能在鸿蒙设备上正常运行:

验证项 验证方法 预期结果 是否通过
网络请求 启动APP,进入资讯页面 成功加载文章列表
下拉刷新 下拉页面 数据重新加载
上拉加载更多 滑动到列表底部 加载更多文章
图片加载失败 使用无效图片URL 显示占位图标
网络超时 关闭网络后请求 显示错误提示
页面跳转 点击文章卡片 跳转到详情页

七、真机运行截图

由于我是在校学生,暂时无法提供真机截图,但我可以用模拟器描述一下运行效果:
在这里插入图片描述

  1. 首页展示:顶部是淡蓝色的AppBar,显示"健康资讯"标题;下方是文章列表,每篇文章有封面图、标题、摘要、阅读量和发布时间。
    在这里插入图片描述

  2. 加载状态:首次进入时显示圆形加载指示器;下拉刷新时顶部显示刷新动画。

  3. 错误状态:网络异常时显示红色错误图标和错误信息,并有"重新加载"按钮。在这里插入图片描述

  4. 列表滚动:流畅的滚动体验,加载更多时底部显示加载指示器。

八、大二学生学习总结

通过这次项目实践,我有很多收获:

1. 跨平台开发不是"一次编写,到处运行"那么简单

以前以为Flutter写好代码就能直接在各个平台运行,现在发现每个平台都有自己的特性和坑。特别是鸿蒙,作为国产操作系统,有很多独特的设计和要求。

2. 日志调试是开发的好帮手

一开始遇到问题不知道怎么排查,后来学会了在关键位置加日志,很多问题就迎刃而解了。Dio的拦截器功能真的很强大,可以看到完整的请求和响应信息。

3. 代码健壮性很重要

不能假设所有数据都是正确的,必须处理各种异常情况。比如JSON字段类型不匹配、图片URL无效、网络超时等,都要有相应的处理逻辑。

4. 学习方法很重要

遇到问题不要慌,先冷静分析,然后查官方文档、看源码、问学长。这次项目让我学会了如何高效地解决问题。

5. 国产操作系统的前景

通过这次适配,我感受到了鸿蒙系统的潜力。作为计算机专业的学生,我觉得有必要深入学习国产操作系统的开发,为国家的科技发展贡献自己的力量。

九、写在最后

这篇文章记录了我作为一个大二学生在Flutter鸿蒙开发中的真实经历。虽然踩了很多坑,但也学到了很多知识。希望我的经历能帮助到其他刚入门的小伙伴,也欢迎大家在评论区交流讨论!

如果觉得这篇文章对你有帮助,别忘了点赞、收藏、转发哦!你们的支持是我继续分享的动力!
在这里插入图片描述

Logo

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

更多推荐