前言

最近在做 Flutter 鸿蒙适配的时候,遇到一个很常见的需求:下拉刷新。翻了一圈发现 flutter_refresh 这个库挺合适的,关键是它是纯 Dart 实现,理论上不需要任何适配就能跑在鸿蒙上。

实际测试下来确实如此,这篇文章就记录一下整个过程,踩过的坑也一并分享。


请添加图片描述

一、先搞清楚一个问题:为什么这个库能直接跑在鸿蒙上?

在开始之前,我觉得有必要先聊聊这个问题,因为很多人可能会疑惑:一个没有 ohos/ 目录的库,怎么就能在鸿蒙上运行了?

答案其实很简单:库和应用是两码事

我们来看一下 flutter_refresh 的目录结构:

flutter_refresh-master/
├── lib/
│   └── src/
│       ├── refresh.dart
│       ├── refresh_child.dart
│       ├── refresh_controller.dart
│       └── refresh_widget.dart
├── example/
│   ├── ios/
│   └── lib/
└── pubspec.yaml

注意看,lib/ 目录下全是 .dart 文件,没有任何原生代码。这意味着什么?

意味着这个库只是提供了一堆 Dart 代码,至于这些代码最终跑在 iOS 还是鸿蒙上,完全取决于你的应用有没有对应的平台目录。

打个比方:库就像一本菜谱,只告诉你怎么做菜;而 ios/ohos/ 这些目录就像是不同的厨房。菜谱本身不需要厨房,但你要做菜,就得有个厨房。

所以当你的应用项目里有 ohos/ 目录时,Flutter 会把库的 Dart 代码编译进去,然后在鸿蒙的"厨房"里运行。


二、看看库的核心实现

在用一个库之前,我习惯先翻翻它的源码,了解一下大概的实现思路。这样遇到问题的时候心里有底。

2.1 Refresh 组件的定义

打开 lib/src/refresh.dart,可以看到 Refresh 组件的定义:

class Refresh extends StatefulWidget {
  final RefresherCallback? onHeaderRefresh;
  final RefresherCallback? onFooterRefresh;
  final RefreshController? controller;
  final ScrollController? scrollController;
  final RefreshScrollViewBuilder? childBuilder;
  final ScrollPhysics? physics;
  final Widget? child;

这里定义了几个关键属性。onHeaderRefresh 就是下拉刷新的回调,onFooterRefresh 是上拉加载的回调。两个都是可选的,你可以只用其中一个,也可以两个都用。

childBuilderchild 二选一,用来指定要包裹的滚动组件。childBuilder 更灵活一些,因为它会把 controllerphysics 传给你,让你自己决定怎么用。

2.2 滚动物理效果的处理

库里有一段代码挺有意思的:

static ScrollPhysics createScrollPhysics(ScrollPhysics? src) {
  ScrollPhysics physics = const AlwaysScrollableScrollPhysics()
      .applyTo(const BouncingScrollPhysics());
  if (src != null) {
    return physics.applyTo(src);
  }
  return physics;
}

这段代码的作用是创建一个统一的滚动物理效果。BouncingScrollPhysics 是 iOS 风格的弹性滚动,AlwaysScrollableScrollPhysics 确保即使内容不足一屏也能滚动。

为什么要这样做?因为下拉刷新需要用户能够"过度滚动"(overscroll),也就是滚动到顶部之后还能继续往下拉。如果没有这个物理效果,用户拉不动,自然也就触发不了刷新。

这个设计让库在 iOS、鸿蒙上都能有一致的体验,不会因为平台默认的滚动行为不同而出问题。

2.3 刷新状态的管理

再看看 refresh_controller.dart

enum RefreshState { drag, ready, loading, complete, error }

class RefreshWidgetController extends ChangeNotifier {
  double _value = 0;
  RefreshState _state = RefreshState.drag;

库定义了 5 种刷新状态:

  • drag:用户正在拖动,还没到触发刷新的阈值
  • ready:拖动距离够了,松手就会刷新
  • loading:正在刷新中
  • complete:刷新完成
  • error:刷新出错

这个状态机的设计让刷新过程的每个阶段都可控。比如你可以根据不同状态显示不同的提示文字:“下拉刷新” → “松开刷新” → “刷新中…” → “刷新完成”。

2.4 默认的刷新指示器

refresh_child.dart 里定义了默认的刷新指示器:

class DefaultRefreshChild extends StatefulWidget {
  final RefreshWidgetController controller;
  final Widget icon;
  final bool up;
  final bool showLastUpdate;

这个组件会根据刷新状态显示不同的 UI。icon 是那个箭头图标,up 控制箭头方向(下拉刷新朝下,上拉加载朝上),showLastUpdate 控制是否显示上次刷新时间。

如果你觉得默认的样式不好看,完全可以自己写一个替换掉。


三、项目准备

3.1 创建一个支持鸿蒙的 Flutter 项目

如果你的项目是新建的,用下面这个命令可以直接创建带鸿蒙支持的项目:

flutter create --platforms=ohos my_app

这里 --platforms 参数指定了要支持的平台,ohos 就是鸿蒙。执行完之后,项目里会自动生成 ohos/ 目录。

如果是老项目想加鸿蒙支持,也简单,在项目根目录执行:

flutter create --platforms=ohos .

注意最后那个点,表示在当前目录操作。执行完就会多出一个 ohos/ 目录,里面的结构大概是这样的:

ohos/
├── AppScope/
│   ├── app.json5
│   └── resources/
│       └── base/
│           ├── element/
│           │   └── string.json
│           └── media/
│               └── app_icon.png
├── entry/
│   ├── src/
│   │   └── main/
│   │       ├── ets/
│   │       │   ├── entryability/
│   │       │   │   └── EntryAbility.ets
│   │       │   └── pages/
│   │       │       └── Index.ets
│   │       ├── resources/
│   │       └── module.json5
│   ├── build-profile.json5
│   └── oh-package.json5
├── build-profile.json5
├── hvigorfile.ts
└── oh-package.json5

这个结构是鸿蒙应用的标准结构。简单解释一下几个关键目录:

  • AppScope/:应用级别的配置,包括应用名称、图标等。app.json5 里定义了 bundleName(包名)、版本号等信息。

  • entry/:应用的入口模块。鸿蒙应用可以有多个模块,但至少要有一个 entry 模块。

  • entry/src/main/ets/:这里放的是 ArkTS 代码。EntryAbility.ets 是应用的入口 Ability,Index.ets 是首页。Flutter 鸿蒙版会在这里加载 Flutter 引擎。

  • entry/src/main/module.json5:模块配置文件,定义了模块的能力(Ability)、权限等。

  • build-profile.json5:构建配置,指定签名、编译选项等。

  • oh-package.json5:依赖管理文件,类似 npm 的 package.json。

3.2 鸿蒙签名配置

鸿蒙应用需要签名才能安装到真机上。打开 ohos/build-profile.json5,可以看到签名相关的配置:

{
  "app": {
    "signingConfigs": [
      {
        "name": "default",
        "type": "HarmonyOS",
        "material": {
          "certpath": "",
          "storePassword": "",
          "keyAlias": "",
          "keyPassword": "",
          "profile": "",
          "signAlg": "SHA256withECDSA",
          "storeFile": ""
        }
      }
    ],
    "products": [
      {
        "name": "default",
        "signingConfig": "default"
      }
    ]
  }
}

开发阶段可以用自动签名,在 DevEco Studio 里配置一下就行。如果是命令行开发,需要手动配置证书路径。

3.3 引入 flutter_refresh

打开 pubspec.yaml,在 dependencies 下面加上:

dependencies:
  flutter:
    sdk: flutter
  flutter_refresh:
    path: ../flutter_refresh-master

这里我用的是本地路径引用,因为我是直接下载的源码。如果你是从 pub 上装的,写法会不一样,不过原理是一样的。

加完之后执行一下:

flutter pub get

看到 Got dependencies! 就说明依赖拉取成功了。


四、开始写代码

4.1 创建主入口

先把 main.dart 的基本结构搭起来:

import 'package:flutter/material.dart';
import 'package:flutter_refresh/flutter_refresh.dart';

void main() {
  runApp(const MyApp());
}

这两行 import 分别引入了 Flutter 的 Material 组件库和 flutter_refresh 库。runApp 是 Flutter 应用的入口,传入根组件启动应用。

4.2 创建应用根组件

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Refresh Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
      ),
      home: const RefreshDemo(),
    );
  }
}

MaterialApp 是 Material Design 风格应用的根组件。debugShowCheckedModeBanner: false 去掉右上角那个碍眼的 DEBUG 标签。useMaterial3: true 启用 Material 3 设计语言,界面会更现代一些。

4.3 创建演示页面

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

  
  State<RefreshDemo> createState() => _RefreshDemoState();
}

因为下拉刷新涉及到数据变化,所以用 StatefulWidget。如果用 StatelessWidget,数据变了 UI 也不会更新。

4.4 定义状态变量

class _RefreshDemoState extends State<RefreshDemo> {
  int _itemCount = 10;

_itemCount 用来控制列表显示多少条数据。下拉刷新的时候,我们会重置这个值,模拟"刷新"的效果。

变量名前面的下划线 _ 表示这是一个私有变量,只能在当前文件内访问。这是 Dart 的命名约定。

实际项目中,这里可能是一个 List<T>,存放从接口拉回来的数据模型。

4.5 构建列表项

Widget _itemBuilder(BuildContext context, int index) {
  return ListTile(
    leading: CircleAvatar(child: Text('${index + 1}')),
    title: Text('Item $index'),
    subtitle: const Text('下拉刷新 / 上拉加载更多'),
  );
}

这个方法负责构建每一行的 UI。ListView.builder 会调用它来生成列表项,index 是当前项的索引。

ListTile 是 Material Design 的列表项组件,自带 leading(左侧图标)、title(标题)、subtitle(副标题)等插槽,用起来很方便。

CircleAvatar 是圆形头像组件,这里用来显示序号。${index + 1} 是字符串插值,把数字转成字符串。加 1 是因为索引从 0 开始,但显示给用户看的序号一般从 1 开始。

4.6 实现下拉刷新的回调

重点来了,这是下拉刷新的核心逻辑:

Future<void> onHeaderRefresh() {
  return Future.delayed(const Duration(seconds: 2), () {
    setState(() {
      _itemCount = 10;
    });
  });
}

几个关键点:

返回值必须是 Future。库内部会等这个 Future 完成才收起刷新动画。看一下库的源码就知道为什么:

Future<void> loading(ScrollMetrics metrics) {
  changeState(RefreshState.loading);
  dynamic result = callback();
  // ...
  result.whenComplete(() {
    changeState(RefreshState.drag);
  });
  return result;
}

库调用你的回调之后,会用 whenComplete 监听 Future 完成,然后才把状态改回 drag。如果你返回的不是 Future,whenComplete 会立刻执行,动画瞬间就结束了。

Future.delayed 是模拟网络请求。实际项目中,这里应该是调接口的代码:

Future<void> onHeaderRefresh() async {
  try {
    final response = await api.fetchList(page: 1);
    setState(() {
      _dataList = response.data;
    });
  } catch (e) {
    // 处理错误,比如显示 Toast
    print('刷新失败: $e');
  }
}

setState 触发 UI 更新。这是 Flutter 状态管理的基础。调用 setState 之后,Flutter 会重新执行 build 方法,用新的数据渲染 UI。

4.7 组装 Refresh 组件


Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: const Text('Flutter Refresh 示例'),
      backgroundColor: Theme.of(context).colorScheme.inversePrimary,
    ),
    body: SafeArea(
      child: Refresh(
        onHeaderRefresh: onHeaderRefresh,
        childBuilder: (BuildContext context,
            {ScrollController? controller, ScrollPhysics? physics}) {
          return ListView.builder(
            controller: controller,
            physics: physics,
            itemBuilder: _itemBuilder,
            itemCount: _itemCount,
          );
        },
      ),
    ),
  );
}

Scaffold 是 Material Design 的页面脚手架,提供了 appBarbodyfloatingActionButton 等常用布局插槽。

SafeArea 很重要,它能避免内容被刘海屏、底部导航栏、状态栏等遮挡。鸿蒙设备的屏幕形态比较多样,有的有刘海,有的有挖孔,加上这个保险一点。

childBuilder 的参数里,controllerphysics 必须传给子列表。这是个容易踩的坑。

为什么?因为 Refresh 组件需要监听滚动事件来判断用户是不是在下拉。它通过 controller 来控制滚动位置,通过 physics 来实现弹性滚动效果。如果你不传这两个参数,它就监听不到,下拉刷新自然也就不生效了。

我第一次用的时候就忘了传,结果怎么拉都没反应,排查了半天才发现是这个问题。


五、运行到鸿蒙设备

代码写完了,接下来就是见证奇迹的时刻。

5.1 检查设备连接

先确认鸿蒙设备已经连上电脑,执行:

flutter devices

如果看到类似这样的输出,说明设备已经识别到了:

FMR0223825079397 (mobile) • FMR0223825079397 • ohos-arm64 • Ohos OpenHarmony-6.0.1.120 (API 21)

如果显示 Unauthorized,需要在设备上点一下"允许 USB 调试"的弹窗。有时候弹窗会被其他应用挡住,可以下拉通知栏看看。

如果设备完全没识别到,检查一下:

  • USB 线是不是数据线(有些线只能充电)
  • 设备的开发者选项是不是打开了
  • USB 调试是不是开启了
  • hdc 工具是否正常工作(可以执行 hdc list targets 检查)

5.2 运行项目

flutter run -v

-d 后面跟的是设备 ID,就是上一步看到的那个。

第一次运行会比较慢,因为要编译鸿蒙的 HAP 包。控制台会显示编译进度:

Launching lib/main.dart on FMR0223825079397 in debug mode...
start hap build...
Running Hvigor task assembleHap...

Hvigor 是鸿蒙的构建工具,类似 Gradle。它会编译 ArkTS 代码、打包资源、生成 HAP 文件。

耐心等一会儿,看到下面这样的输出就说明成功了:

✓ Built ohos/entry/build/default/outputs/default/entry-default-signed.hap.
installing hap. bundleName: com.example.newqwer
Syncing files to device FMR0223825079397...

HAP 是鸿蒙应用的安装包格式,类似 iOS 的 IPA。entry-default-signed.hap 表示这是 entry 模块的 debug 签名包。

这时候应用已经装到设备上并启动了。试试下拉,应该能看到刷新动画,2 秒后数据会"刷新"(虽然我们只是重置了数量,但效果是一样的)。

5.3 热重载

运行起来之后,修改代码保存,按 r 键可以热重载,改动会立刻生效,不用重新编译。这个功能在调试 UI 的时候特别好用。

如果热重载不生效(比如改了 State 的初始化逻辑),按 R 键热重启,会重新初始化整个应用。

5.4 查看日志

调试的时候经常需要看日志。Flutter 的 print 输出会显示在控制台里。如果想看更详细的鸿蒙系统日志,可以用 hdc 命令:

hdc hilog

这个命令会实时输出设备的系统日志,包括 Flutter 引擎的日志、崩溃信息等。


六、遇到的一些问题

6.1 Dart 版本兼容问题

如果库的 pubspec.yaml 里 SDK 约束太老,可能会报错:

This requires the 'super-parameters' language feature to be enabled.

这是因为代码里用了 super.key 这种新语法,但 SDK 约束还是老的。

解决办法是把库的 SDK 约束改成:

environment:
  sdk: ">=2.17.0 <4.0.0"

然后重新 flutter pub get

6.2 鸿蒙签名问题

如果运行时报签名错误,检查一下 ohos/build-profile.json5 里的签名配置是否正确。开发阶段建议用 DevEco Studio 配置自动签名,会省很多事。

6.3 设备连接不稳定

有时候设备会突然断开连接,重新插拔 USB 线通常能解决。如果还是不行,试试重启 hdc 服务:

hdc kill
hdc start

七、小结

整体来说,flutter_refresh 适配鸿蒙的过程还是比较顺利的,主要得益于它是纯 Dart 实现。

如果你也在做 Flutter 鸿蒙适配,选库的时候可以优先考虑纯 Dart 的库,能省不少事。那些带原生代码的库(比如有 ios/ 目录的),就需要额外写鸿蒙的原生实现了,工作量会大很多。

下一篇会继续讲上拉加载的实现,其实和下拉刷新差不多,换个回调就行。

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

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐