☰
Flutter应用迁移OpenHarmony:Dio网络层与跨平台实战复盘
2026/10/2 9:09:29 网站建设 项目流程

最近手头一个 Flutter 项目要往 OpenHarmony 设备上迁移,正好产品那边丢过来一个需求:做一个猫咪图库应用,要求一套代码同时跑在 Android、iOS 和开源鸿蒙上。这类需求以前听着像伪命题,但现在 OpenHarmony 官方维护了 Flutter 适配分支,配合 Dio 把网络层做好,是真的能落地的。这篇文章把我这次实战的完整过程复盘一遍,从 OpenHarmony 上 Flutter 开发环境怎么搭,到用 Dio 对接猫咪图片 API 实现列表、分页、缓存、详情页,再到跨平台适配中踩过的权限和组件通信的坑,都会讲到。适合手里有 Flutter 项目想在开源鸿蒙上跑起来、或者单纯想了解 OHOS 上 Flutter 开发流程的开发者参考。

1. 项目定位:为什么是 OpenHarmony + Flutter + Dio 这套组合

1.1 猫咪图库的本质是一个标准的内容消费应用

别被"猫咪图库"这个题材带偏了,这个应用拆开看就是一个标准的 Feed 流:海量图片数据、分页加载、缩略图网格、点击看大图、跨页面状态共享。真正有价值的东西不在 UI,而是一套稳定的数据获取与缓存链路。猫咪只是载体,换成壁纸、设计灵感、表情包,这套架构可以直接照搬。

我把需求收敛成四个点:首页随机的猫咪图片流,按数量触底加载更多;点击缩略图进入详情页看大图;支持简单收藏,收藏状态跨页面同步;在 OpenHarmony 真机上流畅运行。选这个题材有私心,公开猫咪图片 API 完全免费、数据量大、不涉及版权问题,拿来验证功能再合适不过。

核心要决策的就三件事:跨平台框架选型、网络层选型、渲染与数据流设计。三件事分别对应 Flutter、Dio 和缓存的取舍。

1.2 为什么不用原生 ArkTS 开发

OpenHarmony 的原生语言 ArkTS 配 ArkUI 这套声明式 UI 确实不错,跟 Flutter 的 Widget 树思路很像,但有个现实问题:如果业务代码已经在 Flutter 里写好了,从零用 ArkTS 重写一遍,等于把 Android 和 iOS 两个平台都丢下,只服务 OpenHarmony 一个生态。对大多数团队来说,这是最不划算的路径。

Flutter 在 OpenHarmony 上跑的意义在于"存量复用"。社区里已经积累了大量 Dart 包和 Flutter 组件,Dio、cached_network_image、provider 这些库在 OHOS 适配版 Flutter 上基本能直接用。再加上渲染引擎是自绘的 Skia/Impeller,不依赖系统 WebView 或原生控件,跨平台一致性比 React Native 那套桥接方案更可控。

ArkTS 也不是没用,后面做原生插件、权限配置、PlatformView 嵌入时,还是要写 ArkTS 代码。所以正确的姿势是:业务层全部 Flutter,系统能力层用 ArkTS 写插件,两边通过 MethodChannel/EventChannel 通信。这样既保住了跨平台能力,又拿到了 OpenHarmony 的系统能力。

1.3 Dio 为什么比 http 和 qio 更合适

Flutter 生态里网络库就那么几个选择。官方 http 包适合快速验证,但生产级应用用起来很别扭:没有拦截器、超时配置要靠手动封装、取消请求要自己管理 Completer,代码很容易写成一把梭。Dio 的出现基本就是为了解决这些问题,拦截器机制、连接超时与接收超时、取消令牌、FormData、自定义适配器,全都开箱即用。

还有一个新库 qio,性能数据确实好看,但生态成熟度跟 Dio 差一个量级,社区资料少,出了问题不太好查。我这次项目核心是图片列表,网络层最需要的三个能力是超时控制、日志拦截、统一错误处理,Dio 的 InterceptorsWrapper 一个机制全搞定。

能力Diohttp 包qio
拦截器完整,且支持异步需要手写初步支持
超时配置连接/接收分别配置仅 connectTimeout支持
取消请求CancelToken需要手写支持
错误类型统一DioException手动判断有异常体系
社区资料多,踩坑方案齐全官方轻量少

结论很明确:跨平台项目里网络层选 Dio,基本不需要犹豫。

2. 环境搭建:把 Flutter 开发环境接到 OpenHarmony 上

2.1 必须准备的工具链清单

OpenHarmony 上的 Flutter 开发不是装个官方 Flutter SDK 就能干的,它有一套独立的分支工具链。我当时准备的东西是这样:DevEco Studio(用来编译和运行 ohos 侧工程)、OpenHarmony SDK、hdc 调试工具,以及 OpenHarmony 团队维护的 Flutter SDK 适配版。

这个适配版 SDK 跟谷歌官方 Flutter SDK 是分开管理的,要在 GitHub 上找 OpenHarmony 对应的 flutter_flutter 仓库 ohos 分支,还有配套的 Dart SDK。这里有个容易踩的坑:不要用官方flutter pub get拉完依赖就往 OHOS 上跑,很多原生插件在 ohos 目录里还没有对应实现,会直接编译失败。正确方式是先把 OHOS 分支的 Flutter SDK 配好,再用它来创建和构建项目。

版本管理上我吃过亏,Flutter 适配分支、Dart SDK、DevEco Studio 三者要尽量保持配套,最好直接参照 DevEco Studio 里提示的兼容版本。如果混搭,编译时经常出现奇怪的 native 符号缺失错误,排查起来很费时间。

2.2 从零创建一个支持 ohos 平台的项目

环境配好后创建工程很简单,关键是在flutter create时把平台参数带全。我用的命令大致是这样:

flutter create --platforms android,ios,ohos cat_app

创建完会发现项目根目录多了一个ohos文件夹,这就是 OpenHarmony 原生工程壳子,结构跟 Android 的android目录类似。第一次接触可能会觉得这个目录多余,但 Flutter 在 OHOS 上的运行机制决定了必须有这个壳来做平台层引导。

之后用 DevEco Studio 打开ohos目录,等待 Gradle 同步完成。这里注意,不是所有 Gradle 任务都能在命令行里直接跑,有部分操作必须靠 DevEco Studio 的侧栏工具完成,比如签名配置。真机调试前还要确保设备已开启开发者模式,并且用hdc list targets能看到设备。

我在这一步卡过最久的是 hdc 和 adb 的混淆。OpenHarmony 用的是 hdc,不是 adb,如果习惯性敲adb devices会发现设备永远连不上。确定 hdc 能识别设备后,再回 Flutter 工程执行flutter run -d <设备ID>就能跑起来。

2.3 网络权限:OpenHarmony 的权限声明和 Android 完全不同

这是新手最容易忽略的点。Android 里网络权限写在AndroidManifest.xml,OpenHarmony 里则是ohos目录下的module.json5。如果不声明ohos.permission.INTERNET,应用可以正常启动,但所有网络请求都会静默失败,图片区域一片空白。

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

权限配好之后还要确认应用是否开启了 HTTPS 强制校验。默认情况下 OpenHarmony 对明文 HTTP 的管控跟 Android 9+ 类似,如果 API 域名不是 HTTPS,需要在网络配置文件里放行。我这次用的猫咪图片 API 是 HTTPS,所以只加了 INTERNET 权限就通了,但如果接内网地址或者测试环境 HTTP 接口,这一步必踩。

3. 数据层设计:Dio 封装与猫咪图片 API 对接

3.1 先定义数据模型,别把网络响应直接塞给 UI

很多 Flutter 新手习惯把 Map 直接丢到 Widget 里,网络层和 UI 层耦合在一起,后期分页、收藏、缓存全都会变得很难维护。我第一步永远是建模型。猫咪图片接口返回的 JSON 长这样:

[ { "id": "abc123", "url": "https://cdn2.thecatapi.com/images/abc123.jpg", "width": 1024, "height": 768 } ]

对应的 Dart 模型就很简单:

class CatItem { final String id; final String url; final int width; final int height; CatItem({required this.id, required this.url, required this.width, required this.height}); factory CatItem.fromJson(Map<String, dynamic> json) { return CatItem( id: json['id'] as String, url: json['url'] as String, width: json['width'] as int? ?? 0, height: json['height'] as int? ?? 0, ); } }

模型里把宽高提前解析出来有实际意义:网格布局需要根据图片宽高比预留占位,避免图片加载过程中列表疯狂跳动。如果等图片下载完才知道宽高,用户的浏览体验会非常差。

3.2 Dio 实例的初始化参数不是随便填的

我用的是 TheCatAPI 的免费搜索接口,地址是https://api.thecatapi.com/v1/images/search。这个接口支持limit和page参数,正好配合分页。Dio 实例初始化时,我把超时时间、基础 URL、公共请求头都放在 BaseOptions 里:

final dio = Dio(BaseOptions( baseUrl: 'https://api.thecatapi.com/v1', connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10), headers: { 'x-api-key': const String.fromEnvironment('CAT_API_KEY'), }, ));

这里有个细节,API Key 不要硬编码到源码里,Flutter 支持--dart-define=CAT_API_KEY=xxx在构建时注入。代码仓库被拉出去的时候,Key 不会跟着泄露。另外connectTimeout和receiveTimeout我故意分开设置,因为图片弱网场景下连接很慢但接收一旦开始就应该稳定,两者混用一个值容易误判超时。

Dio 默认的响应类型是 JSON,但我在做图片接口时主动指定了List类型的响应数据,因为接口返回的是数组而不是对象。这一步不做的话,resp.data会被解析成List<dynamic>,后面强转Map会直接抛类型错误。

3.3 拦截器:日志、错误统一处理和重试策略

Dio 的拦截器是它最值钱的设计。我写了一个简单的拦截器,把请求方法和 URL 打出来,错误时把状态码和错误信息也打出来,开发阶段排查问题能省一半时间:

dio.interceptors.add(InterceptorsWrapper( onRequest: (options, handler) { debugPrint('[CatApp] ${options.method} ${options.uri}'); handler.next(options); }, onResponse: (response, handler) { debugPrint('[CatApp] ${response.statusCode} ${response.realUri}'); handler.next(response); }, onError: (DioException e, handler) { debugPrint('[CatApp] error: ${e.type} ${e.message}'); handler.next(e); }, ));

handler.next一定不能漏。漏掉之后请求会一直挂在拦截器里,页面转圈转半天。重试逻辑我没有单独引入 retry 库,而是在业务层 catch 到DioExceptionType.connectionTimeout和receiveTimeout后做一次重试,图片列表场景偶尔一次超时是正常的,直接重试比弹错误提示框体验好得多。

3.4 分页策略与图片缓存

猫咪图片列表我用的是最经典的 page + limit 策略:

Future<List<CatItem>> fetchCats({int page = 1, int limit = 30}) async { final resp = await dio.get('/images/search', queryParameters: { 'limit': limit, 'page': page, 'has_breeds': false, }); return (resp.data as List) .map((e) => CatItem.fromJson(e as Map<String, dynamic>)) .toList(); }

细节在 UI 层,后面会说。图片缓存这里,不要用Image.network裸奔,它没有任何本地持久化能力,每次滑回来都要重新下载。我用的是cached_network_image包,它内部做了内存和磁盘两级缓存。同一个 URL 第二次展示时直接从磁盘读,滚动列表的体验完全不一样。

4. UI 层实现:图库页面与关键交互

4.1 首页网格布局别用 ListView,用 GridView.builder

图片流用列表还是网格?我选了网格。猫图比例接近方形,三列网格一屏能展示九张,信息密度比单列大得多。代码上用GridView.builder配合SliverGridDelegateWithMaxCrossAxisExtent,这样在不同屏幕宽度下都能自动调整列数,平板和手机不用写两套布局:

GridView.builder( controller: _scrollController, gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: 180, childAspectRatio: 1, crossAxisSpacing: 4, mainAxisSpacing: 4, ), itemCount: _cats.length + 1, itemBuilder: (context, index) { if (index == _cats.length) { return const Center(child: CircularProgressIndicator()); } return CatThumbnail(cat: _cats[index]); }, )

这里itemCount比数据多 1,最后一项显示加载指示器。触底加载时不用额外加底部空隙,结构上很干净。

4.2 下拉刷新与触底加载的连带问题

下拉刷新直接用 Flutter 官方的RefreshIndicator,套在GridView外面就行,没有太多技术含量。真正麻烦的是触底加载和刷新并发时的数据竞争。我见过很多项目触底加载时会重复请求同一页,或者刷新过程中还在加载下一页,导致列表出现重复图片。

解决办法是老一套但很有效:用_isLoading标志位加锁。触底回调里如果_isLoading为 true 就直接 return,请求开始前置 true,请求结束无论成功失败都置 false。刷新时把页码归 1,清空列表,再重新拉第一页。

_scrollController.addListener(() { if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { _loadMore(); } });

触底判断的阈值我留了 200 像素,在图片还没滑到底的时候就提前加载,减少用户看到加载圈的频率。

4.3 图片加载与缓存:细节决定流畅度

缩略图不要直接加载原图。猫咪图片接口返回的url是原始高清图,直接用CachedNetworkImage加载的话,三列网格同时下载十几张几 MB 的原图,内存和流量都扛不住。我做了一层裁剪,缩略图统一拉到 300 像素宽:

CachedNetworkImage( imageUrl: cat.url, width: 180, height: 180, fit: BoxFit.cover, memCacheWidth: 360, memCacheHeight: 360, placeholder: (context, url) => Container( color: Colors.black12, child: const Center(child: CircularProgressIndicator(strokeWidth: 2)), ), errorWidget: (context, url, error) => const Icon(Icons.broken_image_outlined), )

注意memCacheWidth和memCacheHeight,这是很多项目忽略的性能开关。CachedNetworkImage默认按原始分辨率解码图片进内存,一张 2000x1500 的图解码后在内存里接近 12MB。把缓存宽高限制到 360,内存占用可以降到几十 KB 级别,滚动时的 GC 卡顿会明显减少。

4.4 详情页与路由状态:Navigator 切换页面会丢状态吗

详情页用Navigator.push跳转,传CatItem对象过去。这里要澄清一个常见困惑:Navigator.push默认不会销毁前一页的 State,页面只是被压到栈下面,状态还在。真正丢状态的是 Tab 切换或者使用了非标准的返回逻辑。

我的做法是在详情页里收藏猫图,这个状态存到全局的ChangeNotifier里,返回首页后首页通过ListenableBuilder自动更新 UI。路径是:用户在看大图时点收藏,返回列表页时缩略图右上角的小红心已经亮了。这个流程用Navigator.push完全可以做到,不需要任何特殊处理。

如果担心页面被系统回收,可以给首页 State 加AutomaticKeepAliveClientMixin,让列表在路由栈中保持存活。在图片列表这种高频滚动场景,这个 mixin 值得加。

5. 跨平台适配:OpenHarmony 系统级能力接入

5.1 Android 和 OpenHarmony 的权限模型差异

Flutter 层写 API 权限请求通常用 permission_handler 插件,但要清楚这个插件在 OpenHarmony 上支持得并不完整。OpenHarmony 的权限模型有自己的体系,运行时权限和安装时权限分类跟 Android 不完全一致,调用方式也不同。

这里给一个最实用的原则:Flutter 侧不要依赖任何平台特定的权限声明逻辑,权限配置全部下沉到 ohos 原生工程里完成。比如 INTERNET 这种基础权限,直接改module.json5,不经过 Dart 代码。涉及相机、地理位置等运行时权限,需要开发对应的 ArkTS 插件,用 Flutter 的标准插件机制暴露方法给 Dart 层。

5.2 PlatformView:在 OpenHarmony 上嵌入原生视图

跨平台应用偶尔需要嵌入原生控件,比如视频播放器、地图。Flutter 给这类需求提供了 PlatformView 机制。在 OpenHarmony 上,原生侧视图是用 ArkTS 实现的,再通过 Flutter 官方定义的 PlatformView 注册表暴露出来。

我在这个项目中先用一个简单的场景练了手,嵌入了一个原生按钮,目标是把收藏按钮替换成原生的点击样式。这本身没什么业务价值,但完整跑通了原生视图从注册、创建到 Flutter 侧引用的链路。关键步骤是:在 ohos 工程里实现一个继承自 PlatformView 的类,并注册到 PlatformViewRegistry 中。

实际测试中发现,OpenHarmony 的 PlatformView 性能和 Android 平台相当,没有明显的掉帧问题。但如果有图片列表这种高频滚动场景,嵌入原生视图的数量要控制,一次屏幕内的 PlatformView 最好不要超过个位数,否则合成压力会变大。

5.3 MethodChannel 与 EventChannel:Flutter 和 ArkTS 的双向通信

组件通信是这个项目的必修课。我从 Flutter 侧调原生获取设备信息时用了 MethodChannel,Dart 侧代码:

static const MethodChannel _channel = MethodChannel('cat_app/device'); final String device = await _channel.invokeMethod('getDeviceInfo');

对应的 ArkTS 侧要在 MainAbility 或者 PageAbility 里注册这个 channel,实现onMethodCall逻辑。这里有个坑:channel name 必须完全一致,包括包名形式的命名空间,Dart 侧写错一个字符,调用时就会报 MissingPluginException。

监听原生主动发来的数据,比如系统电量变化,就轮到 EventChannel 出场。Flutter 侧用receiveBroadcastStream订阅:

static const EventChannel _event = EventChannel('cat_app/events'); _event.receiveBroadcastStream().listen((event) { debugPrint('[EventChannel] $event'); }, onError: (e) => debugPrint('[EventChannel] error $e'));

EventChannel 是单向的流,原生侧负责 push,Dart 侧只负责监听。方向感一定要清晰,想从 Dart 往原生发指令时不要用 EventChannel,用 MethodChannel。

5.4 组件通信与状态共享:收藏功能的跨页面同步

猫咪收藏功能涉及三个页面:列表页、详情页、收藏页。状态不能散落在单个 Widget 里,我用了一个全局的FavoritesStore,继承ChangeNotifier:

class FavoritesStore extends ChangeNotifier { final Set<String> _ids = {}; bool has(String id) => _ids.contains(id); void toggle(String id) { if (_ids.contains(id)) { _ids.remove(id); } else { _ids.add(id); } notifyListeners(); } }

这个 store 在应用启动时创建,通过构造参数注入给首页和详情页。收藏操作后调用notifyListeners(),所有监听了这个 store 的 Widget 都会重建。这种轻量级状态管理方案在 Flutter 里的好处是:不引入多余概念,调试时直接看调用栈就能定位问题。

在试过 Riverpod 和 Bloc 之后,我反而觉得 ChangeNotifier 在这个体量的项目里最顺手。复杂状态管理方案本身也是一种负担,猫咪图库这种单数据源场景根本用不上那么多高级特性。

6. 实战中的常见问题与排查技巧

6.1 编译和打包阶段的坑

OpenHarmony 的 Flutter 项目编译失败,排在第一位的原因就是 SDK 版本不匹配。我遇到过一次 Flutter 适配版和 DevEco Studio 内置的 ArkTS SDK 版本冲突,报错信息指向的是main gradle plugin,实际问题是 gradle wrapper 版本和插件不兼容。排查时先看ohos/build.gradle里的依赖版本,再看 local.properties 里的 SDK 路径。

打包时如果遇到 Java 异常和could not close input stream这类错误,通常不是代码问题,而是 Gradle 缓存损坏。先执行./gradlew clean,再重新构建。这种缓存类问题在 CI 机器上尤其常见,本地一次成功但打包机必失败时,优先怀疑缓存而不是代码。

6.2 图片加载失败,先查权限再查域名

我调试过程中图片大面积加载不出来的根因是module.json5里漏了 INTERNET 权限。这个问题的特征是日志里 Dio 请求已经发出去了,但响应直接抛 SocketException。如果你看到Failed host lookup,基本可以断定是网络权限或者 DNS 问题。

还有一种情况是 HTTPS 证书校验失败,如果 API 的证书链不完整,Dio 会直接拒绝连接。开发阶段可以临时在 Dio 的 HttpClientAdapter 里关闭证书校验,但生产环境绝对不能这么做,否则会引入中间人攻击风险。

6.3 列表滚动卡顿,先查图片解码再查 PlatformView

图片列表滚动的卡顿,绝大部分不是 CPU 问题,而是图片解码占用的内存峰值太高。前面说的memCacheWidth参数,建议所有图片组件都配置上。另外不要在大列表里给每张图片都套 Hero 动画,Hero 会阻止图片在滚动中被回收,内存压力会持续累积。

如果滚动时掉帧特别严重,可以用 Flutter DevTools 的 Performance 面板看 Raster 线程耗时。Raster 线程满则说明图片合成压力大,优先降低图片解码尺寸;UI 线程满则说明 Widget 重建频繁,检查是不是 store 通知触发了整个列表重建。

6.4 Navigator 切换后状态丢失的根因和解决

如果你发现Navigator.push回来之后列表位置丢失或者状态重置,大概率不是路由本身的问题,而是页面在路由栈里被回收了。排查思路是这样:给页面 State 加上AutomaticKeepAliveClientMixin,并让wantKeepAlive返回 true。如果加了还丢,检查是不是父级用了会销毁子树的结构。

另外注意,用TabBar+TabBarView时,默认切换 Tab 会销毁不可见页面的 State。如果收藏页和首页之间是 Tab 关系,也需要AutomaticKeepAliveClientMixin或者改用IndexedStack保持页面存活。这是我实际做完收藏同步功能后才知道的细节,文字描述起来很轻巧,但调试时特别容易忽略。

这个项目做完,我最大的感受是 OpenHarmony 上的 Flutter 已经从"能跑 Hello World"走到了"能跑完整业务"的阶段。整套开发流程,从环境搭建、工程创建、Dio 网络层封装到权限和原生通信,本质上跟 Android 平台的 Flutter 开发差不了太多,真正需要额外花时间的反而是平台适配细节。

最后分享一个自己的习惯:每次准备在 OHOS 上做新功能前,先翻一遍 OpenHarmony flutter_flutter 仓库的 issue 列表,看看有没有人已经踩过同类的坑。跨平台开发到最后,比的不是谁 API 用得熟,而是谁对平台差异心里有数。猫咪图库只是一个小小的起点,换成生产级的业务应用,这套方法论照样能打。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询