Flutter集成Dio实现鸿蒙网络请求:蘑菇百科列表实战
我一直在折腾跨平台方案组里有个开源鸿蒙的项目正好需要把之前Flutter那套东西跑上去。这次训练营DAY3的任务很直接在Flutter里集成Dio做网络请求把本地一份“蘑菇百科”的数据拉下来做成列表展示。听起来简单实际动手发现坑不少而且大部分坑不是Dio本身是Flutter和鸿蒙环境磨合的问题。先说结论这套方案最终跑通了鸿蒙设备上能正常请求、解析、渲染列表。但如果你只是照着Flutter官方文档装完环境就开干大概率会在打包、权限、数据解析这几个环节卡住。这篇文章我把整个流程拆开讲每个步骤都带有实操代码和排查思路特别是那些报错信息和解决方案都是我现场踩过坑之后整理出来的。1. 项目整体设计与思路拆解1.1 为什么在鸿蒙上用Flutter还要单独处理网络层以前做鸿蒙原生开发网络请求直接用鸿蒙自带的ohos.net.http模块就行。但Flutter是跨平台框架它跑在鸿蒙上本质是Flutter引擎在鸿蒙系统上渲染UIDart代码通过Platform Channel调鸿蒙底层能力。所以网络请求也有两条路可以走纯Dart方案直接用dart:io的HttpClient或者用dio、http这类库。这样代码不依赖鸿蒙API一套代码到处跑。平台通道方案写鸿蒙原生网络代码通过Channel暴露给Dart调用。好处是能用到鸿蒙特有的能力坏处是每写一个接口都要维护双端代码非常麻烦。训练营这次选的是纯Dart方案也就是Flutter标准做法。原因很简单跨平台开发的初衷就是不重复造轮子网络请求这种通用能力Dart生态已经很成熟了没必要非走系统通道。1.2 为什么选Dio而不是http或者HttpClientFlutter生态里最常用的HTTP库有三类官方http包、dio、用HttpClient自己封装。我这次选Dio主要看中它这几个能力拦截器机制可以统一处理Token、日志、错误码。这功能在调试阶段特别好用每个请求的耗时、状态码一目了然。支持取消请求、上传下载进度回调。全局公共Header、BaseUrl配置不用每个请求写一遍。内置FormData后面如果要提交表单直接用它就行。http包其实更适合简单场景比如只GET一下JSON不超过几十行逻辑。但现实项目中日志、错误统一处理、动态BaseURL这些需求总会冒出来与其半路换库不如一开始就上Dio。1.3 “本地蘑菇百科数据”到底怎么设计标题里有个关键词叫“本地蘑菇百科数据”。这个“本地”并不是离线数据而是指数据源是一份放在本地服务器或者本地文件里的蘑菇百科资源由网络请求去获取。我这次做了两种数据源方便大家理解不同场景的玩法本地静态JSON文件打包进Assets里用Dio配file://协议或者直接用rootBundle.loadString读取。这种方式适合Demo演示、离线兜底、UI联调。本地HTTP服务在电脑上起一个Flask或者Node服务提供/mushrooms接口返回蘑菇列表JSON。Flutter通过局域网IP访问这个接口。这种方式更接近真实项目能完整打通网络请求链路。训练营DAY3的主题是Dio网络请求所以我重点展示第二种方式同时保留第一种作为兜底方案。2. 环境准备与基础配置2.1 Flutter与开源鸿蒙开发环境版本选型开源鸿蒙的Flutter支持目前还在快速迭代阶段版本对齐很重要。我这次用的组合是组件版本说明Flutter SDK3.22.2稳定版对OpenHarmony支持较好OpenHarmony SDKAPI 10当前主流设备已适配DevEco Studio4.0 Release鸿蒙官方IDEDio5.4.0Dart侧网络库Flutter鸿蒙引擎ohos_flutter社区版需单独编译或拉取这里要特别提醒不要直接用Flutter官方SDK去跑鸿蒙设备因为官方SDK默认只输出Android/iOS产物。需要在Flutter工程里接入OpenHarmony SDK桥接层也就是社区说的flutter_flutter分支加上ohos目录。实际操作中我是在现有Flutter工程根目录执行flutter create --platformsandroid,ios .然后手动把OpenHarmony平台工程加进来。具体来说去拉一份适配过的Flutter引擎代码放到engine/src/flutter下再配置好ohos模块路径。这个流程比较绕最稳的办法是直接克隆社区维护好的Flutter SDK分支git clone -b openharmony-3.2 https://gitee.com/openharmony-sig/flutter_flutter.git然后把它配置为Flutter SDK路径。这样后面flutter run就能识别到鸿蒙设备了。2.2 鸿蒙网络权限配置不管你用Dio还是http包只要涉及网络请求都要在鸿蒙工程里声明网络权限。鸿蒙项目里权限声明文件位于AppScope/app.json5或entry/src/main/module.json5中。需要加入{ module: { requestPermissions: [ { name: ohos.permission.INTERNET } ] } }还有一个坑如果后面要访问局域网地址比如192.168.x.x部分鸿蒙版本还需要额外配置网络安全策略否则会报“Cleartext HTTP traffic not permitted”之类的问题。解决办法是允许明文流量在module.json5里加{ module: { deviceConfig: { network: { cleartextTraffic: true } } } }注意生产环境建议用HTTPS这里为了本地联调才开明文流量发布前记得关掉。2.3 在pubspec.yaml里引入Dio依赖Flutter工程里加依赖很简单打开pubspec.yamldependencies: flutter: sdk: flutter dio: ^5.4.0然后执行flutter pub get如果下载慢或者拉不下来可以先配置镜像源在环境变量里设置export PUB_HOSTED_URLhttps://pub.flutter-io.cn export FLUTTER_STORAGE_BASE_URLhttps://storage.flutter-io.cn这些配置完环境就算准备好了。下面进入代码设计阶段。3. 本地蘑菇百科数据列表功能编码实战3.1 定义数据模型先规划一下蘑菇数据的结构。一份单条蘑菇记录大体包含这些字段{ id: 1, name: 香菇, alias: 冬菇、花菇, category: 食用菌, edible: true, color: 棕褐色, description: 常见的食用菌肉质肥厚香气浓郁。, imageUrl: https://example.com/images/xianggu.jpg }在Dart里我习惯手写模型类或者用json_serializable。考虑到训练营场景我直接手写代码清爽也方便初学者理解JSON解析过程class Mushroom { final int id; final String name; final String alias; final String category; final bool edible; final String color; final String description; final String imageUrl; Mushroom({ required this.id, required this.name, required this.alias, required this.category, required this.edible, required this.color, required this.description, required this.imageUrl, }); factory Mushroom.fromJson(MapString, dynamic json) { return Mushroom( id: json[id] as int, name: json[name] as String, alias: json[alias] as String, category: json[category] as String, edible: json[edible] as bool, color: json[color] as String, description: json[description] as String, imageUrl: json[imageUrl] as String, ); } }小技巧json[edible] as bool这个写法在服务端返回0/1时容易挂保险起见可以用json[edible] 1 || json[edible] true这样兼容。3.2 封装Dio请求工具类网络层我习惯封装一个单例方便全局统一配置。下面是这次用的ApiClientimport package:dio/dio.dart; class ApiClient { ApiClient._internal(); static final ApiClient _instance ApiClient._internal(); factory ApiClient() _instance; late final Dio dio; void init() { dio Dio(BaseOptions( baseUrl: http://192.168.1.101:8080, connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10), headers: { Content-Type: application/json; charsetUTF-8, }, )); dio.interceptors.add(LogInterceptor( requestBody: true, responseBody: true, logPrint: (obj) debugPrint(DioLog: $obj), )); } FutureResponse get(String path, {MapString, dynamic? query}) { return dio.get(path, queryParameters: query); } }我一般会在init()里把LogInterceptor挂上调试阶段能清晰看到请求地址、Header、响应Body。到了Release就注释掉避免日志刷屏和性能损耗。3.3 蘑菇列表数据仓库与状态管理数据逻辑我分成两层Repository负责拿数据ViewModel/Controller负责管理页面状态。这里我用的状态管理方案是ValueNotifier比较轻量适合训练营项目不用引入Bloc或Provider。class MushroomRepository { final ApiClient _api ApiClient(); FutureListMushroom fetchMushroomList() async { final response await _api.get(/mushrooms); if (response.statusCode 200) { final Listdynamic data response.data as Listdynamic; return data.map((e) Mushroom.fromJson(e as MapString, dynamic)).toList(); } else { throw Exception(请求失败状态码: ${response.statusCode}); } } }页面侧的核心控制器class MushroomListController extends ChangeNotifier { final MushroomRepository _repository MushroomRepository(); ListMushroom _mushrooms []; bool _isLoading false; String? _errorMessage; ListMushroom get mushrooms _mushrooms; bool get isLoading _isLoading; String? get errorMessage _errorMessage; Futurevoid loadData() async { _isLoading true; _errorMessage null; notifyListeners(); try { _mushrooms await _repository.fetchMushroomList(); } catch (e) { _errorMessage e.toString(); } finally { _isLoading false; notifyListeners(); } } }3.4 蘑菇百科列表UI实现列表页我用ListView.builder来做支持按需加载数据量大时也不会卡顿。卡片设计参考了百科类App的常见样式左边缩略图右边是名称、别名和一句话介绍。class MushroomListPage extends StatefulWidget { const MushroomListPage({super.key}); override StateMushroomListPage createState() _MushroomListPageState(); } class _MushroomListPageState extends StateMushroomListPage { final MushroomListController _controller MushroomListController(); override void initState() { super.initState(); _controller.loadData(); } override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text(蘑菇百科)), body: AnimatedBuilder( animation: _controller, builder: (context, _) { if (_controller.isLoading) { return const Center(child: CircularProgressIndicator()); } if (_controller.errorMessage ! null) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text(加载失败${_controller.errorMessage}), const SizedBox(height: 12), ElevatedButton( onPressed: _controller.loadData, child: const Text(重试), ), ], ), ); } final items _controller.mushrooms; if (items.isEmpty) { return const Center(child: Text(暂无数据)); } return ListView.builder( itemCount: items.length, itemBuilder: (context, index) { final mushroom items[index]; return Card( margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: ListTile( leading: ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.network( mushroom.imageUrl, width: 56, height: 56, fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) const Icon(Icons.image_not_supported_outlined, size: 40), ), ), title: Text(mushroom.name, style: const TextStyle(fontWeight: FontWeight.bold)), subtitle: Text(mushroom.alias), trailing: mushroom.edible ? const Chip(label: Text(可食用), backgroundColor: Colors.green) : const Chip(label: Text(有毒), backgroundColor: Colors.red), onTap: () { // TODO: 跳转详情页 }, ), ); }, ); }, ), ); } }这里我加了错误重试、加载状态、空数据三种状态的UI实际体验会好很多。3.5 本地Mock服务搭建与联调为了让Dio真切地发一次网络请求我在本地起了个Flask服务。项目目录下新建server.pyfrom flask import Flask, jsonify app Flask(__name__) mushrooms [ { id: 1, name: 香菇, alias: 冬菇、花菇, category: 食用菌, edible: True, color: 棕褐色, description: 常见的食用菌肉质肥厚香气浓郁。, imageUrl: https://example.com/images/xianggu.jpg }, { id: 2, name: 毒蝇伞, alias: 毒蘑菇, category: 毒菌, edible: False, color: 红底白斑, description: 含有毒蝇碱误食后会引起神经中毒。, imageUrl: https://example.com/images/damain.jpg } ] app.route(/mushrooms, methods[GET]) def get_mushrooms(): return jsonify(mushrooms) if __name__ __main__: app.run(host0.0.0.0, port8080)启动服务后把ApiClient里的baseUrl改成电脑的局域网IP。鸿蒙设备或模拟器访问这个地址就能拿到数据了。注意手机和电脑要在同一局域网且电脑防火墙要放行8080端口。4. 常见问题与调试排坑记录4.1 鸿蒙设备上请求不到数据第一个遇到的坑就是数据请求为“网络错误”。排查步骤我按顺序走了一遍先确认电脑端接口通不通curl http://192.168.1.101:8080/mushrooms发现正常返回JSON。看鸿蒙端日志发现报错信息是“Connection refused”或者“SocketException”。检查权限发现ohos.permission.INTERNET没加。加上权限后重跑还是不通。再查发现模拟器网络用的是NAT模式得配置端口映射或者改用真机USB调试才行。所以如果你遇到网络错误优先从这几个维度排查权限声明、网络环境、地址可达性、防火墙。4.2 JSON解析类型不匹配第二次崩溃发生在Mushroom.fromJson里报错是type int is not a subtype of type bool。核心原因后端把布尔值返回成0和1了而Dart的as bool不会做隐式转换。解决方式edible: json[edible] 1 || json[edible] true,以后接任何后端接口都要提防类型不匹配尤其是数字和布尔、字符串和数字这种。最稳的办法是写个工具函数做安全类型转换。4.3 列表图片加载失败鸿蒙端跑Image.network加载网络图片时如果证书有问题或者图片域名非HTTPS也会失败。我在代码里加了errorBuilder作为兜底至少不会让整个页面崩掉。如果生产环境要更稳健可以用cached_network_image库它会做磁盘缓存和更细粒度的错误处理。4.4 Dio LogInterceptor日志太大导致性能下降一开始我开着LogInterceptor打印整个响应体。数据量一大logcat直接刷屏界面肉眼可见卡顿。后面我把responseBody设成false只保留请求行和状态码。调试精准数据时才手动打开。4.5 鸿蒙真机调试时Flutter热重载失效训练营实操时发现鸿蒙设备上Flutter热重载有时候会断连尤其是网络模块初始化之后。后来我养成一个习惯先跑通数据逻辑再调UI样式改UI时用真机热重载改网络逻辑时直接冷启动。这套流程下来效率高很多。5. 性能优化与体验细节打磨5.1 列表页卡顿优化第一版列表是直接用ListView.builder数据量只有十几条没压力。但蘑菇百科如果扩展成上百条就需要注意ListView.builder已经做到了按需构建费性能的操作不要放在build里比如频繁的正则、JSON解析。图片不要直接设置无限高度固定宽高加上cacheWidth能显著减少解压内存。如果列表项需要频繁变化考虑给Card加上const关键字减少重建。5.2 请求失败的降级策略真实场景里网络不可能永远稳定。我这次在Repository层做了个“本地静态JSON兜底”策略请求远端失败后自动加载Assets里的mushrooms_fallback.json。这样即使后台故障用户也不会看到空页面。FutureListMushroom fetchMushroomListWithFallback() async { try { return await fetchMushroomList(); } catch (e) { final jsonString await rootBundle.loadString(assets/mushrooms_fallback.json); final data jsonDecode(jsonString) as Listdynamic; return data.map((e) Mushroom.fromJson(e as MapString, dynamic)).toList(); } }5.3 页面销毁时取消正在进行的请求如果用户在加载过程中退出了页面而请求还在继续很可能会导致内存泄漏和回调报错。Dio支持取消请求final cancelToken CancelToken(); Futurevoid loadData() async { try { _mushrooms await _repository.fetchMushroomList(cancelToken: cancelToken); } on DioException catch (e) { if (e.type DioExceptionType.cancel) { // 请求被取消无需处理 } else { _errorMessage e.toString(); } } } override void dispose() { cancelToken.cancel(页面销毁); super.dispose(); }这个细节很多教程不会提但确实是生产级App必须处理的。6. 从训练营DAY3延伸后续还能做什么这次的项目只是开了个头蘑菇百科这种数据驱动型应用在后面有很自然的演进路径详情页开发列表点击跳转蘑菇详情展示更完整的形态特征、生长环境、分布区域。搜索与筛选按“可食用/有毒”“颜色”“季节”做多维过滤锻炼Dio组合请求参数的能力。数据库集成如果要做收藏、离线浏览可以引入sqflite或者drift把网络数据和本地数据库打通。分页加载真实接口一般不会一把梭返回全量数据用ScrollController监听滚动实现上拉加载更多这个能力在Day4大概率会用到。状态管理升级数据交互变复杂后先用Provider等逻辑再稠密一些再上Bloc或Riverpod都是合理的进阶方向。我个人倾向于在训练营阶段多折腾几层把网络层、数据层、UI层彻底分开后面加功能时就特别省事。另外调试阶段有个体验很棒的组合Dio的LogInterceptor加Flutter的debugPrint再配合鸿蒙的HiLog日志工具基本能做到请求链路全程可见。建议你花点时间把日志规范起来比如统一前缀、区分Debug/Release级别这对后期排查线上问题帮助特别大。这次DAY3的内容就到这里营里的同学沿着这个项目再自己扩展几个功能会有更深的体会。