1. 一张图片的“查看大图”需求,为什么不能直接抄作业
做 Flutter 开发的朋友应该都有这种经历:产品经理丢过来一个需求,说“图片列表点开,放大缩小、双指缩放、双击放大,跟微信一样就行”,你觉得这功能不是有 InteractiveViewer 吗?套一下不就完事了?
在普通 Flutter 工程里,确实可以这么干。但一旦把工程迁移到 OpenHarmony 上跑,事情就没那么简单了。我这次是在 OpenHarmony 的 Flutter 工程里做图片预览和手势缩放,最开始也是想用 InteractiveViewer 凑合,但实际跑起来发现几个很现实的问题:手势响应不够跟手、缩放手感僵硬、双击放大逻辑要自己写一堆边界判断,最麻烦的是在 OpenHarmony 上,部分手势事件的回调表现和 Android 端存在细微差异,同样的代码在两端手势体验完全不同。
后来我换成了 PhotoView 这个老牌图片预览库,整体体验才算是稳住了。这篇文章就完整记录一下我在 Flutter for OpenHarmony 上集成 PhotoView 的整个过程,包括工程配置、核心代码、踩坑记录和性能优化。如果你正好也在 OpenHarmony 上做 Flutter 开发,这篇应该能帮你少走不少弯路。
先交代一下我的环境:OpenHarmony 4.0 Release 版本,Flutter 版本是 3.7.12 的 OpenHarmony 分支,开发工具是 DevEco Studio 4.0,测试设备是 RK3568 开发板。这套组合是目前 OpenHarmony 上 Flutter 开发相对主流的搭配,后面说的所有问题都是在这个环境下复现和解决的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. OpenHarmony 上的 Flutter 生态,和普通 Flutter 差在哪
2.1 为什么 OpenHarmony 的 Flutter 不能直接用 pub 上的包
先说个大前提。OpenHarmony 上的 Flutter 并不是 Google 官方在维护,而是 OpenHarmony 社区在维护一个独立的 Flutter 分支,目前主要支持到 Flutter 3.7.x 版本。这就导致了一个很现实的问题:pub.dev 上很多 Flutter 包,在 OpenHarmony 上是不一定直接可用的。
原因主要在三层:
第一层,纯 Dart 实现的包基本没问题。这类包只依赖 Flutter 框架自身的能力,不涉及原生平台通道,比如 photo_view 这个包,它的核心手势逻辑全部是 Dart 实现的,底层没有调用 Android/iOS 的原生 API,所以理论上是可以直接跑的。
第二层,依赖了 dart:io、dart:ui 等底层库的包,需要看 OpenHarmony 的 Flutter 引擎是否完整支持这些 API。大部分是支持的,但有些边缘 API 可能有差异。
第三层,依赖 Android/iOS 原生代码的包,比如调用了 MethodChannel、PlatformView 的包,这些在 OpenHarmony 上基本都要等社区适配,或者你自己用 OpenHarmony 的 API 重新实现。
PhotoView 属于第一层,这是我能顺利集成的前提。但也正因为它是纯 Dart 实现,手势处理完全依赖 Flutter 的手势竞技场机制,所以它在 OpenHarmony 上的手感问题,本质上就是 Flutter 引擎在 OpenHarmony 上的手势事件处理差异问题,这一点后面会详细展开。
2.2 工程初始化时最容易忽略的配置
在 OpenHarmony 上新建 Flutter 工程,官方推荐的方式有两种:一种是用 DevEco Studio 直接新建 OpenHarmony 工程,然后手动集成 Flutter 的 SDK;另一种是从 Flutter 的 OpenHarmony 分支模板创建。我用的方式是后者,但不管哪种方式,有几个配置是必须确认的。
首先,Flutter SDK 必须使用 OpenHarmony 分支的版本,不能用 Google 官方的版本。这个分支的仓库地址是 gitee 上的 openharmony-sig/flutter_flutter,拉下来之后要切换到对应的 openharmony 分支。我当时用的 3.7.12 版本,对应的是 3.7.12-ohos 这个 tag。
其次,工程里的 ohos 目录是 OpenHarmony 的原生工程目录,oh-package.json5 文件里需要声明对 Flutter 的依赖。这里有个坑,直接写在 dependencies 里的 @ohos/flutter_ohos 版本必须和你的 Flutter SDK 版本严格匹配,否则编出来的包运行时会崩,而且崩得很莫名其妙。
最后,build-profile.json5 里的签名配置要正确。OpenHarmony 的调试签名需要在 DevEco Studio 里自动生成,如果签名不对,应用装上之后可能无法正常调用部分系统能力。这个问题我在后面做图片选择器的时候踩过一次,到时候细说。
2.3 RK3568 设备树选择的问题
热词里有朋友问 OpenHarmony 的 RK3568 有很多设备树到底怎么选,这个我在调测试机的时候也遇到了。DevEco Studio 安装 OpenHarmony 系统镜像到 RK3568 开发板时,会涉及到选择正确的设备树文件。如果你是拿市面上通用的 RK3568 开发板来跑,一般选 rk3568_standard 这个默认配置就够用了。但如果你用的是定制板,最好跟硬件厂商确认一下具体用的是哪个设备树,否则可能出现屏幕不亮、触摸不响应的问题——我当时遇到过一次触摸完全没反应的,后来换了一个设备树就正常了。
这跟 Flutter 开发的关系在于:如果你设备树的触摸驱动不对,Flutter 层的手势事件就根本收不到,你还以为是自己代码问题,排查了半天手势逻辑,最后发现是系统层就没把触摸事件上报上来。所以如果发现所有手势都没响应,先检查系统层的触摸是否正常,再做应用层排查。
3. 为什么选 PhotoView:跟 InteractiveViewer、手写手势的对比
3.1 InteractiveViewer 的局限
Flutter 官方自带 InteractiveViewer,有人可能会问:有现成的为什么不用?我最初的方案就是用它,但实际体验下来有几个痛点:
双击放大逻辑需要自己写。 InteractiveViewer 本身不提供双击缩放能力,你需要在外面再包一层 GestureDetector 监听双击,然后通过 TransformationController 去控制缩放矩阵。这本身不算复杂,但问题在于缩放动画要自己做,如果你直接用 AnimatedContainer 或者手动插值,很容易出现动画卡顿或者缩放手感不自然的问题。
图片边界回弹效果缺失。 微信那种松手回弹的效果,InteractiveViewer 本身是不带的。它默认是硬边界,拉到边缘就停住了,没有那种“橡皮筋”的感觉。要实现回弹,你需要自己监听边界状态,然后写回弹动画,这个工作量其实不小。
和列表滚动手势的冲突处理。 在图片列表页,点击图片进入大图预览,预览页需要支持左右滑动切换图片,同时也要支持双指缩放。InteractiveViewer 和 PageView 组合使用时,手势冲突是一个经典难题。缩放状态下,PageView 不应该响应横向滑动;非缩放状态下,PageView 应该接管横向滑动。这个逻辑在 InteractiveViewer 里需要你用 onInteractionStart 和 onInteractionUpdate 自己判断缩放比例,然后动态调整 PageView 的 physics,处理起来非常繁琐,而且很容易出现手势断触的 bug。
3.2 PhotoView 解决了哪些实际问题
PhotoView 这个包做的事情就是把这些全部封装好。它内部基于 InteractiveViewer 实现,但在其上封装了完整的手势体系:
- 支持单击、双击、长按等手势的独立回调,不会互相干扰
- 内置双击放大、双击缩小、双指缩放三种交互模式,可以通过
scaleStateChangedCallback监听状态切换 - 支持缩放边界控制和回弹效果,
scaleBoundaries参数可以限制最大最小缩放比例 - 自带
PhotoViewGallery,专门解决多图预览时PageView和缩放手势的矛盾 - 支持
loadingBuilder,可以自定义加载中的占位视图
最关键的是,PhotoView 的手势冲突处理是经过大量用户验证的,在普通 Android 上已经非常成熟。在 OpenHarmony 上虽然不能保证 100% 一致,但整体框架是对的,我们只需要针对 OpenHarmony 的特殊情况做微调。
3.3 手写 GestureDetector 为什么不推荐
我自己早年做过一版手写的手势缩放,就是 GestureDetector 加 onScaleStart、onScaleUpdate,配合 Matrix4 做变换。单看缩放本身,其实不难,代码量也就几十行。但一旦加入双击动画、边界回弹、多图滑动、双击前后状态切换这些维度,工程量立刻膨胀到几百上千行,而且每个交互细节都要反复调试。
更麻烦的是,手势竞技场(Gesture Arena)的竞争关系——GestureDetector 同时包含 onTap、onDoubleTap、onScaleStart 时,双击和缩放手势之间的判定逻辑很容易出错。比如你快速按了两下,系统怎么区分这是双击还是缩放起点?这个判定顺序如果处理不好,用户操作起来就会有一种“手势很黏”或者“反应慢半拍”的感觉。
所以我的结论很直接:如果团队有充足的时间去做交互打磨,可以手写;但如果你是想快速交付一个稳定可用的图片预览功能,直接用 PhotoView 改造成本最低。
下表是我做决定时参考的对比情况:
| 对比项 | InteractiveViewer | PhotoView | 手写 GestureDetector |
|---|---|---|---|
| 双击缩放 | 需自己实现 | 内置 | 需自己实现 |
| 边界回弹 | 需自己实现 | 内置(部分版本支持) | 需自己实现 |
| 多图切换 | 需自己配合 PageView | 内置 PhotoViewGallery | 需自己配合 PageView |
| 手势冲突处理 | 需自己判断 | 内置 | 需自己处理 |
| 开发成本 | 中 | 低 | 高 |
| 维护成本 | 中 | 低 | 高 |
| OpenHarmony 兼容性 | 基础可用 | 需微调 | 需大量调试 |
4. PhotoView 集成实战:依赖配置、核心代码、运行调试
4.1 依赖拉取和版本锁定
在 OpenHarmony 的 Flutter 工程里,pubspec.yaml 中添加依赖和普通 Flutter 工程是一样的。我用的版本是 photo_view: ^0.14.0,这个版本对应 Flutter 3.7.x 不会有兼容性问题。
yaml复制dependencies:
flutter:
sdk: flutter
photo_view: ^0.14.0
添加完之后,执行 flutter pub get。这里有个 OpenHarmony 上特有的问题:如果你的 Flutter SDK 是 OpenHarmony 分支,pub 镜像源默认可能指向的是社区定制的镜像。如果 flutter pub get 很慢或者超时,可以在环境变量里配置镜像源。我当时是把 PUB_HOSTED_URL 和 FLUTTER_STORAGE_BASE_URL 指向了国内镜像,速度才正常。
另外一个需要注意的点是:不要升级依赖去用最新版的 photo_view。 最新的版本可能依赖了较新的 Flutter API,而这些 API 在 OpenHarmony 的 Flutter 3.7.12 分支上不一定存在。我见过有人直接拉取 photo_view: 0.15.x,结果编译报了一堆错,原因是某个内部方法签名变了。锁版本的时候,先确认你本地的 Flutter SDK 版本,再决定用哪个版本的依赖。
4.2 基础使用:单图预览页
先写一个最基础的图片预览页,单张图片的查看大图功能。代码如下:
dart复制import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart';
class ImagePreviewPage extends StatelessWidget {
final String imageUrl;
const ImagePreviewPage({Key? key, required this.imageUrl}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: PhotoView(
imageProvider: NetworkImage(imageUrl),
// 背景色,默认是黑色,做图片预览时建议保持黑色
backgroundDecoration: const BoxDecoration(color: Colors.black),
// 初始缩放比例
initialScale: PhotoViewComputedScale.contained,
// 最大缩放倍数
maxScale: PhotoViewComputedScale.covered * 4.0,
// 最小缩放倍数
minScale: PhotoViewComputedScale.contained * 0.8,
// 启用缩放动画,双击缩放时的过渡更平滑
enableRotation: false,
// 缩放状态变化回调,可以用来控制UI显隐
scaleStateChangedCallback: (state) {
debugPrint('scale state: $state');
},
// 加载中的占位
loadingBuilder: (context, event) {
if (event == null) return const SizedBox.shrink();
return const Center(
child: CircularProgressIndicator(
color: Colors.white,
),
);
},
// 错误占位
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Text(
'图片加载失败',
style: TextStyle(color: Colors.white),
),
);
},
),
),
);
}
}
这几行代码的核心价值在于:PhotoView 内部把 InteractiveViewer 全部包好,你只需要配置参数即可。initialScale 用的 PhotoViewComputedScale.contained 表示图片按比例完整显示在屏幕内,PhotoViewComputedScale.covered 表示图片铺满屏幕,这两个枚举是 PhotoView 比较重要的概念。
实际操作中,加载网络图片时要注意,OpenHarmony 的 Flutter 引擎对网络图片的证书校验可能存在差异,如果碰到 HTTP 的图片地址加载不了,多半是网络安全配置问题,需要去 ohos 原生工程里配置网络权限和允许的域名。这个后面单独说。
4.3 多图预览:PhotoViewGallery 的正确用法
图片预览最常用的场景还是多图左右滑动,PhotoViewGallery 就是干这个的。我之前一度想用 PageView 自己嵌套多个 PhotoView,结果在缩放状态下左右滑动的手势冲突处理让我折腾了很久。改成 PhotoViewGallery 之后,代码复杂度瞬间降下来:
dart复制import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart';
class GalleryPreviewPage extends StatefulWidget {
final List<String> imageUrls;
final int initialIndex;
const GalleryPreviewPage({
Key? key,
required this.imageUrls,
required this.initialIndex,
}) : super(key: key);
@override
State<GalleryPreviewPage> createState() => _GalleryPreviewPageState();
}
class _GalleryPreviewPageState extends State<GalleryPreviewPage> {
late PageController _pageController;
late int _currentIndex;
// 记录当前页面的缩放比例,用于控制PageView的滑动
double _currentScale = 1.0;
@override
void initState() {
super.initState();
_currentIndex = widget.initialIndex;
_pageController = PageController(initialPage: widget.initialIndex);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
/// 核心:PhotoViewGallery
/// 这里的关键配置是 [pageController] 和 [onPageChanged]
PhotoViewGallery.builder(
scrollPhysics: _currentScale > 1.0
? const NeverScrollableScrollPhysics()
: const BouncingScrollPhysics(),
pageController: _pageController,
itemCount: widget.imageUrls.length,
onPageChanged: (index) {
setState(() {
_currentIndex = index;
});
},
// 缩放到非1.0时禁用PageView滑动,避免手势冲突
onPageScrollStateChanged: (state) {
if (state == PageScrollState.drag) {
setState(() {
_currentScale = 1.0;
});
}
},
builder: (context, index) {
return PhotoViewGalleryPageOptions(
imageProvider: NetworkImage(widget.imageUrls[index]),
initialScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 4.0,
minScale: PhotoViewComputedScale.contained * 0.8,
// 重点:通过这里实时感知缩放比例
scaleStateChangedCallback: (state) {
_currentScale = state == PhotoViewScaleState.original
? 1.0
: 2.0; // 或者用 controller 拿具体值
},
loadingBuilder: (context, event) {
if (event == null) return const SizedBox.shrink();
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
},
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Text(
'加载失败',
style: TextStyle(color: Colors.white),
),
);
},
);
},
),
// 顶部返回按钮
Positioned(
top: MediaQuery.of(context).padding.top + 8,
left: 8,
child: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
),
// 底部页码指示
Positioned(
bottom: 32,
left: 0,
right: 0,
child: Text(
'${_currentIndex + 1} / ${widget.imageUrls.length}',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 16),
),
),
],
),
);
}
}
这里有一个很关键的设计要解释一下:_currentScale 变量和 scrollPhysics 的组合。 当用户放大图片后,如果没有在缩放状态下禁用 PageView 的滑动,你会发现一个很尴尬的交互:你正双指放大一张图到 4 倍,想仔细看某个细节时,手指稍微一偏,页面就滑到下一张图了。体验非常差。
我的做法是:通过 scaleStateChangedCallback 感知缩放状态,一旦图片处于放大状态,就把 PageView 的 scrollPhysics 切换为 NeverScrollableScrollPhysics,禁用滑动。当用户双击或者捏合回到原始比例后,再恢复滑动。这个逻辑实现起来非常简单,但体验提升非常明显。
不过在 OpenHarmony 上有个细节要注意:scaleStateChangedCallback 在部分版本上可能回调不及时,导致切换物理效果时有一点延迟。我在实测中遇到过一次:双击放大动画还没走完,scrollPhysics 已经切换,导致动画被新建的 PageView 物理效果打断。解决方案是加一个 Timer 做延迟,或者直接用转换矩阵来判断,后面在踩坑部分细聊。
4.4 本地图片和资源图片的适配
OpenHarmony 的 Flutter 工程里加载本地图片,路径规则和普通 Flutter 工程一样,但有一点坑:如果你把图片放到了 ohos 目录的原生资源里,Flutter 侧的 Image.asset 是访问不到的。Flutter 只能访问自己在 assets 目录中声明的资源。
我实际处理的时候,发现外部传入的图片文件路径(比如通过 OpenHarmony 的图库选择器拿到的文件路径),不能直接传给 FileImage 去加载。原因是 OpenHarmony 的文件存储路径和 Android 不完全一样,需要通过 ohos 原生侧把文件拷贝到 Flutter 可访问的缓存目录,或者用平台通道把文件字节流传给 Flutter 侧。
如果你只是做纯 Flutter 的 Demo,把图片放到工程 assets 目录里,然后这样写就够了:
dart复制PhotoView(
imageProvider: AssetImage('assets/images/sample.jpg'),
)
但如果是要调用 OpenHarmony 的系统图库选图,然后预览选中的图片,就得走平台通道了。这个往回我在“桥接层设计”那节里专门讲。
5. 手势体验调优:OpenHarmony 上影响手感的三个关键点
5.1 触摸采样率和帧率:不是代码问题,是系统问题
在 RK3568 开发板上跑 Flutter 的 PhotoView,第一个直观感受就是:缩放手感比手机上“肉”。原因很简单,RK3568 是开发板级别的 SoC,GPU 和触摸控制器都比主流手机差一截。
我先用 adb shell getevent 看了一下触摸事件的上报频率,发现这个开发板的触摸采样率不高,大概只有 60Hz 左右,而目前主流手机一般有 120Hz 到 240Hz。这就意味着,你在 OpenHarmony 的开发板上做缩放时,系统本身采集到的触摸点就比手机上稀疏,PhotoView 处理捏合手势时,缩放矩阵的更新频率也会受影响。
这个问题的本质很难从应用层完全解决,但可以做的优化是:减少缩放过程中不必要的 build。PhotoView 内部在缩放手势中会频繁调用 notifyListeners,如果页面其他组件同时也在 setState,那么单帧的 UI 工作量会显著增加。
我的优化方案是:在缩放手势进行时,暂停页码指示器和其他 UI 的刷新。 具体的实现是,监听 scaleStateChangedCallback,一旦缩放比例变化,就不再去 setState 页码信息,等手势结束了再恢复。这样虽然每帧只省了一点工作量,但连续缩放的时候,整体流畅度提升还是比较明显的。
5.2 双击缩放的动画曲线:微调后体感更好
PhotoView 内置的双击缩放动画是线性插值,具体来说,它会用 AnimationController 在 200 毫秒内从当前比例过渡到目标比例。在 Android 上这个默认表现还不错,但在 OpenHarmony 上我总觉得动画后半段有点“飘”。
原因是 OpenHarmony 的 Flutter 引擎在 vsync 回调的稳定性上还不如 Android,如果某一帧卡顿,线性动画就会显得一顿一顿的。我发现改成 Curves.easeOutCubic 曲线之后,动画开始快、结束慢,即便中途丢了一帧,整体感觉也没那么突兀。
修改方式有两种:一种是在 PhotoView 外面包 PhotoViewGestureDetector 手动接管双击逻辑;另一种更简单,直接用 Hero + PhotoView 时,在页面过渡动画完成后再触发双击,效果会自然很多。我实际采用的是:保持 PhotoView 默认逻辑不变,但给页面加了一个小的 fade 过渡,从相册列表点进来时,图片先快速淡入,然后用户再双击缩放,整体交互节奏就不会显得卡顿。
5.3 Hero 动画的坑:PhotoView 和普通图片的 Hero Tag 配置
做图片列表到预览页的转场动画,最自然的方式是用 Hero。但 PhotoView 在 Hero 动画上有个坑:PhotoView 内部用的是 ImageProvider 加载图片,而列表页同时有一个 Image.network 也在用同一个 URL,两边都用同一个 Hero tag 时,动画过程中会出现图片闪一下或者位置对不上的情况。
这个问题的原因在于:Hero 动画默认会对两个页面各自生成快照,如果两端的分辨率或者图片加载方式不一致,快照就会不同。解决方式是确保 Hero 包裹的组件在两端都是同一类型:
dart复制// 列表页
Hero(
tag: imageUrl,
child: Image.network(imageUrl),
)
// 预览页
Hero(
tag: imageUrl,
child: PhotoView(
imageProvider: NetworkImage(imageUrl),
),
)
但这样写在 OpenHarmony 上实测有一个问题:Hero 动画从普通 Image 过渡到 PhotoView,会因为 PhotoView 内部的 InteractiveViewer 多了一层 Transform,导致动画过程中图片出现位移偏差。更稳妥的做法是:列表页的图片也用一个缩放组件包起来,或者更简单——在 Hero 动画期间临时禁用 PhotoView 的手势交互,等动画结束再启用。
我当时采用的方案是:预览页的 Hero 子组件不用 PhotoView,而是一个普通的 Image,等 Hero 动画结束后,再通过 Navigator 的回调把页面内容替换成 PhotoView。但这会导致首帧闪烁,所以后来换了个思路:用 PageRouteBuilder 自定义转场动画,不用 Hero。效果更可控,闪烁问题也没有了。
6. 加载大图的内存治理:OOM 的一次完整复盘
6.1 问题现场:RK3568 上加载 8000x6000 图片直接崩溃
图片预览功能做完之后,我拿一张 8000x6000 分辨率、约 15MB 的 JPG 图片做测试,结果在 RK3568 上 图片一加载就闪退,Logcat 里打印了 OutOfMemoryError。
用 dart:ui 的 instantiateImageCodec 接口解码这张图片时,如果目标解码尺寸是原始尺寸,那内存占用就是 8000 * 6000 * 4 字节 = 192MB。而 RK3568 的设备内存一般是 2GB 到 4GB,OpenHarmony 系统本身占掉一部分,Flutter 引擎再占一部分,留给 Dart heap 的可用内存非常有限,一次性吃 192MB 直接崩。
这个问题在普通 Android 上其实也存在,但 Android 的图片加载框架(比如 cached_network_image + flutter_image_compress)会做采样降采样,而 PhotoView 本身是不做图片解码优化的,它只是拿到 ImageProvider 之后交给 Flutter 引擎解码。所以解码大图的内存压力要由你自己控制。
6.2 解决方案:用 OpenHarmony 原生能力做缩略图,Flutter 只预览降采样图
我的思路是:不在 Flutter 侧解码大图,而是先通过 OpenHarmony 原生侧的图片接口生成一张适合屏幕分辨率的缩略图,然后把缩略图传给 Flutter 预览。 这样 PhotoView 加载的内存压力就完全可控了。
具体做法是写一个平台通道:
java复制// ohos 侧代码(ArkTS 示例)
import image from '@ohos.multimedia.image';
import fs from '@ohos.file.fs';
function createThumbnail(uri: string, targetWidth: number, targetHeight: number): string {
// 打开原始图片
let file = fs.openSync(uri, fs.OpenMode.READ_ONLY);
let imageSource = image.createImageSource(file.fd);
// 设置降采样参数
let options = {
sampleWidth: targetWidth,
sampleHeight: targetHeight,
};
let pixelMap = imageSource.createPixelMap(options);
// 编码为 JPEG 文件,放到缓存目录
let cachePath = getContext().cacheDir + '/thumb_' + Date.now() + '.jpg';
let outFile = fs.openSync(cachePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
let encoder = image.createImagePacker();
encoder.packToFile(pixelMap, outFile.fd, { format: 'image/jpeg', quality: 90 });
// 返回缩略图路径
return cachePath;
}
Flutter 侧通过 MethodChannel 调用这个接口:
dart复制import 'package:flutter/services.dart';
class ThumbnailHelper {
static const MethodChannel _channel = MethodChannel('ohos/thumbnail');
static Future<String> getThumbnail({
required String uri,
required int targetWidth,
required int targetHeight,
}) async {
final String path = await _channel.invokeMethod('createThumbnail', {
'uri': uri,
'width': targetWidth,
'height': targetHeight,
});
return path;
}
}
然后在预览页把降采样后的路径交给 PhotoView:
dart复制final thumbPath = await ThumbnailHelper.getThumbnail(
uri: originalUri,
targetWidth: (MediaQuery.of(context).size.width * 2).round(),
targetHeight: (MediaQuery.of(context).size.height * 2).round(),
);
PhotoView(
imageProvider: FileImage(File(thumbPath)),
)
这样解码下来的图片内存占用大约在 1080P * 2 倍率 * 4 字节,也就是 1600 * 1200 * 4 = 7.68MB 左右,内存完全在安全范围内。虽然预览的是降采样图,但屏幕分辨率本身有限(RK3568 一般是 1080P 或 2K),肉眼看质量差别很小。
6.3 原图查看怎么办:分片加载和渐进式显示的取舍
有的朋友会问:那用户想看原图细节怎么办?缩略图一放大就糊了。
这是图片预览里典型的“内存和画质”矛盾。目前主流做法有三种方向:
- 限制最大的预览分辨率:这个方案最简单直接。系统最大允许解码到屏幕宽高的 4 倍左右,超过这个范围再放大就用插值,画面会有点糊,但内存安全。
- 瓦片加载:类似于地图的显示方式,把大图切成多个小块,只加载当前可视区域的小图块。这个在 Flutter 生态里还没有特别成熟的开源方案,需要自己实现,成本很高。
- 分阶段加载:先快速显示缩略图,然后后台加载原图,原图加载完成后替换。但如果原图尺寸超内存限制,替换时还是会崩溃。
我的建议是:在开发板场景下,方案 1 是性价比最高的。真要追求原图细节,需要考虑扩展内存或者做原生 View 介入,这就是另一个量级的工程了。
7. 控制栏、页码、缩略图列表的联动设计
7.1 顶部控制栏的显隐切换:避免 setState 抖动
图片预览页的顶部返回按钮和底部页码信息,一般会加一个“点击图片隐藏控制栏”的交互。但如果控制栏的显隐是通过 setState 来实现的,每次点击图片都会重建整个页面。在 OpenHarmony 上,页面重建的代价比 Android 上更高,容易出现短暂的白屏或者闪烁。
我的做法是:把控制栏封装成独立的 AnimatedOpacity + IgnorePointer,通过 ValueNotifier 控制显隐,而不是用 setState。这样控制栏显隐只会单独更新这部分组件,不会触发整个页面重建。
dart复制class _GalleryPreviewPageState extends State<GalleryPreviewPage> {
// 用 ValueNotifier 控制控制栏显隐
final ValueNotifier<bool> _showControls = ValueNotifier(true);
// 点击图片切换显隐
void _toggleControls() {
_showControls.value = !_showControls.value;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
PhotoViewGallery.builder(
...
// 关键:在 PhotoView 上套一层 GestureDetector 捕获单击
builder: (context, index) {
return GestureDetector(
onTap: _toggleControls,
child: PhotoViewGalleryPageOptions(...),
);
},
),
// 控制栏
ValueListenableBuilder<bool>(
valueListenable: _showControls,
builder: (context, show, child) {
return AnimatedOpacity(
opacity: show ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: IgnorePointer(
ignoring: !show,
child: child,
),
);
},
child: Stack(
children: [
// 顶部返回按钮
Positioned(...),
// 底部页码
Positioned(...),
],
),
),
],
),
);
}
}
这里有个细节要注意:PhotoView 内部已经处理了单击、双击、缩放的区分,GestureDetector 的 onTap 不会和双击冲突,因为 PhotoView 的双击是它内部的手势识别器在竞技场中优先赢得胜利,而 GestureDetector 的 onTap 会在双击判定失败后才会触发。
7.2 页码指示器在缩放状态下的更新策略
页码指示器的常见实现是:onPageChanged 时 setState 更新 _currentIndex。但如果你在缩放图片时触发 onPageChanged(比如横向滑动切换),会导致整个页面 rebuild,进而可能打断 PhotoView 的手势状态。
我在 OpenHarmony 上遇到过这样一个情况:页面从第 2 张滑到第 3 张,页码刚更新完,手指还在屏幕上,结果因为 rebuild 导致 PhotoViewGallery 内部的 PageController 重新初始化,画面闪了一下。
解决方式是:页码指示器单独抽取出来,用 ValueNotifier<int> 来管理,只更新指示器自身。
dart复制final ValueNotifier<int> _pageIndexNotifier = ValueNotifier(0);
// onPageChanged 时只更新 ValueNotifier
onPageChanged: (index) {
_pageIndexNotifier.value = index;
}
// 指示器部分
ValueListenableBuilder<int>(
valueListenable: _pageIndexNotifier,
builder: (context, index, child) {
return Text(
'${index + 1} / ${widget.imageUrls.length}',
style: const TextStyle(color: Colors.white, fontSize: 16),
);
},
)
这样做的好处是:页码变了,只有那个 Text 会重建,整个 PhotoViewGallery 零感知。
7.3 缩略图列表的悬浮交互:滚动时缩小/放大
产品问卷里还提过一个需求:预览页底部悬浮一个横滑缩略图列表,左右切换时预览页也跟着切换。这个功能实现起来不复杂,但要注意和 PhotoView 手势的冲突。
我的做法是:缩略图列表放在底部一个独立的 SizedBox 里,横向 ListView,点击某个缩略图时,通过 _pageController.animateToPage 切换预览页。反过来,预览页滑动时,缩略图列表也要自动滚动到当前项。
这里有一个比较隐蔽的坑:如果缩略图列表和预览页都用同一个 PageController,在预览页缩放状态下两个组件会互相干扰。我最终放弃共用一个 controller 的方案,改用独立的 controller,通过监听对方的回调去同步位置。虽然代码多一点,但明确知道哪有可能会出问题,排查起来也快。
8. 桥接层设计:Flutter 调用 OpenHarmony 图库的完整链路
8.1 为什么需要平台通道
OpenHarmony 的图库选择器是原生能力,Flutter 侧没有直接的插件可以调用。热词里也有朋友在问“flutter如何调用鸿蒙的图库”,这个确实需要手写桥接。
要实现的交互链路是:
- Flutter 侧发起“从图库选择图片”的请求
- 调用平台通道,唤起 OpenHarmony 的 PhotoViewPicker
- 用户在图库里选中一张或多张图片
- 原生侧拿到选中图片的 URI,回传给 Flutter
- Flutter 侧把 URI 交给 PhotoView 预览
8.2 原生侧实现(ArkTS 代码)
typescript复制// ohos 侧代码
import photoAccessHelper from '@ohos.file.photoAccessHelper'
import { BusinessError } from '@ohos.base'
let phAccessHelper = photoAccessHelper.getPhotoAccessHelper(context)
async function pickImages(): Promise<Array<string>> {
let options = new photoAccessHelper.PhotoSelectOptions()
options.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE
options.maxSelectNumber = 9
try {
let result = await phAccessHelper.select(options)
return result.photoUris
} catch (error) {
let err = error as BusinessError
console.error(` pickImages failed, code: ${err.code}, message: ${err.message}`)
return []
}
}
然后在 DevEco Studio 的工程里注册平台通道:
typescript复制import { MethodChannel } from '@ohos/flutter_ohos'
export default class MainAbility extends Ability {
onWindowStageCreate(windowStage: window.WindowStage): void {
windowStage.loadContent('pages/Index', (err) => {
if (err.code) {
return
}
// 注册通道
let channel = new MethodChannel('ohos/gallery')
channel.setMethodCallHandler((call) => {
if (call.method === 'pickImages') {
pickImages().then((uris) => {
call.result(uris)
})
}
})
})
}
}
8.3 Flutter 侧调用和错误处理
dart复制class GalleryService {
static const MethodChannel _channel = MethodChannel('ohos/gallery');
static Future<List<String>> pickImages() async {
try {
final List<dynamic> result = await _channel.invokeMethod('pickImages');
return result.cast<String>();
} on PlatformException catch (e) {
debugPrint('pick images failed: ${e.message}');
return [];
}
}
}
调用时:
dart复制final uris = await GalleryService.pickImages();
if (uris.isEmpty) {
// 用户取消或者选择失败
return;
}
// 生成缩略图
final thumbs = <String>[];
for (final uri in uris) {
final thumb = await ThumbnailHelper.getThumbnail(
uri: uri,
targetWidth: 1080,
targetHeight: 1920,
);
thumbs.add(thumb);
}
// 跳转到预览页
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => GalleryPreviewPage(
imagePaths: thumbs,
initialIndex: 0,
),
),
);
这个桥接层当时让我踩了一个签名问题:OpenHarmony 的权限 ohos.permission.READ_IMAGEVIDEO 需要在 module.json5 里声明,而且必须在 DevEco Studio 里配置好签名,否则运行时会直接抛 SecurityException。我把这个写在后面避坑里。
9. 一连串的坑:OpenHarmony 上 PhotoView 的排错实录
9.1 包拉不下来和版本冲突
热词里有人提到“flutter各个版本不对导致依赖包下不下来”,我在 OpenHarmony 上集成 PhotoView 时,第一步就遇到这个问题。因为我本地的 Flutter SDK 是 3.7.12 的 OpenHarmony 分支,但一开始 pubspec.yaml 里写的 photo_view 依赖是 ^0.15.0,flutter pub get 解析出来的最新版本要求 Flutter >= 3.10。OpenHarmony 分支的 3.7.12 根本不满足,直接报版本冲突。
排查链路其实不难:报错信息里会明确告诉你哪个包需要哪个 Flutter 版本,但关键是你要能理解“OpenHarmony 的 Flutter 版本不能直接和 pub.dev 的版本要求对应”这件事。你可以通过 flutter --version 查看 SDK 版本,然后到 pub.dev 的 photo_view 页面查看它各版本的 flutter 约束,选择一个兼容的版本锁定。
9.2 双击放大后立刻弹回原始比例
这是我在 OpenHarmony 上遇到的一个很有意思的问题:双击放大后,画面会以极快的速度弹回到原始比例,看起来就是闪了一下。
排查思路:
-
先看是不是
scrollPhysics切换导致的。我在 4.3 节里的代码中,_currentScale一旦大于 1.0,就会把PageView的scrollPhysics改为NeverScrollableScrollPhysics。但onPageScrollStateChanged里,当状态变为drag时,我又把它重置回 1.0。如果双击时触发了一次drag状态,就会导致_currentScale被重置,PhotoView 内部还没完成双击动画,外部却在重建PageView的物理效果,于是画面弹回。 -
再排查 PhotoView 自身的
scaleStateChangedCallback。我发现 OpenHarmony 上scaleStateChangedCallback的触发时机比 Android 上要早一些。Android 是在手势结束后回调,OpenHarmony 是在手势过程中的某个时机回调。如果你在这个回调里做了setState,就会打断动画。 -
最终解决:把这些状态判断全部从
scaleStateChangedCallback里移出来,改用PhotoViewController监听矩阵变化,并且延迟 100ms 再做物理切换。
9.3 热重载后预览页白屏
热词里还有一个频率很高的问题:“flutter热重载后浏览器没更新”。这虽然说的是浏览器场景,但 OpenHarmony 上也遇到了类似的诡异问题:编辑完代码后点热重载(Hot Reload),预览页变成白屏,日志没有任何报错。
排查发现:热重载后,PhotoView 内部的 TransformationController 可能因为旧的 state 没有完全清理,导致矩阵计算异常,画面渲染为空白。这个问题的根因不是 PhotoView 本身,而是 Flutter 引擎在 OpenHarmony 上的热重载机制还不完善,某些状态没有正确恢复。
解决方案很简单:遇到白屏就 shift + r 做热重启(Hot Restart)。热重启会重新执行整个初始化流程,白屏问题就不会出现。这个坑严格来说不算 bug,但你在开发过程中要记得“白屏不是代码问题,是热重载机制问题”,别在上面浪费时间。
9.4 权限申请和签名问题导致图库选择失败
前面提到调用图库选择器需要 ohos.permission.READ_IMAGEVIDEO 权限。我一开始在 module.json5 里只写了权限声明,没有配置签名,运行时直接报权限不足。后来在 DevEco Studio 里配置了自动签名,重新安装后权限弹窗就正常了。
这里要说一下:OpenHarmony 的权限机制和 Android 不太一样,有些权限是 install 时自动授予的,有些需要运行时动态申请。READ_IMAGEVIDEO 属于用户授权类型权限,需要在页面里动态申请。我当时用了 abilityAccessCtrl 来做授权,代码大致如下:
typescript复制import abilityAccessCtrl, { Permissions } from '@ohos.abilityAccessCtrl'
let atManager = abilityAccessCtrl.createAtManager()
let permissions: Array<Permissions> = ['ohos.permission.READ_IMAGEVIDEO']
let result = await atManager.requestPermissionsFromUser(context, permissions)
if (result.authResults[0] !== 0) {
// 用户拒绝了权限
}
9.5 图片左右滑动时出现明显的掉帧
多图预览左右滑动的流畅度,和单图缩放一样重要。我在 RK3568 上测试时,图片从第 1 张滑到第 2 张的转场过程中,有明显的掉帧,尤其是在图片未加载完成时,会出现一行一行的“撕裂”感。
排查思路:
- 先确认是 GPU 层的问题还是图片加载的问题。通过
DevEco Studio自带的性能分析工具(HiChecker 或者 gpu profiler)抓取了一帧的耗时,发现大部分时间花在图片解码上。 PhotoViewGallery默认会缓存所有页面的图片,在滑动到下一张之前,下一张已经开始加载。但 NetworkImage 的加载在 OpenHarmony 上默认不是内存缓存的,每次滑回来都要重新网络请求。- 解决方案:给图片加载加一个内存缓存。我用的是
cached_network_image包,但发现这个包在 OpenHarmony 上依赖了 sqflite 做磁盘缓存,而 sqflite 在 OpenHarmony 上还需要额外适配。
最终我的做法是:自己实现了一个简单的内存缓存,用 ImageProvider 的 obtainKey + loadImage 做了一层包装,代码量不大,但效果立竿见影。
10. 性能数据:RK3568 上 PhotoView 的实际表现
写到这里,给大家一组实测数据,方便大家评估在 OpenHarmony 上使用 PhotoView 的性能预期。
| 测试项目 | 结果 |
|---|---|
| 设备 | RK3568 开发板,4GB RAM |
| 屏幕 | 1080P,60Hz |
| 图片规格 | 4000x3000 JPEG,约8MB |
| 缩略图规格 | 1080x810 JPEG |
| PhotoViewGallery 首帧加载耗时(缩略图) | 约 500ms |
| 双击放大动画耗时 | 约 200ms,微卡顿可接受 |
| 单图双指缩放跟手度 | 良好,轻微延迟 |
| 多图切换流畅度 | 可接受,偶有掉帧 |
| 内存占用(3张缩略图缓存) | 约 80MB |
| 内存占用(3张原图缓存) | 约 350MB(不推荐) |
从数据可以看出,只要走缩略图方案,内存是完全可控的。原图方案在 4GB 内存的开发板上基本不可行,除非你的设备内存更大或者做分片加载。
还有一个值得说的点:OpenHarmony 的 Flutter 引擎在纹理上传(texture upload)这部分效率比 Android 略低。同一张图在 Android 上解码并上传 GPU 可能需要 80ms,在 RK3568 上可能需要 120ms。这就是为什么你在 OpenHarmony 上滑动图片时能明显感觉到“重”一些。这个没法靠业务代码解决,只能等引擎优化,我们能做的就是尽量缩小单张图片的分辨率,减小纹理上传的压力。
11. 后续可扩展的方向
图片预览和手势缩放做完之后,有几个方向是可以继续深入的。
第一个方向是图片编辑能力的扩展。PhotoView 已经处理了平移和缩放,你可以基于它的矩阵状态,把裁剪、旋转、标注这些能力叠加进去。比如在缩放到某个比例后,用户可以点一个“裁剪”按钮,把当前 Matrix 变换后的可视区域截取出来,另存为一张新图。这在 OpenHarmony 上的原生侧做比较容易,Flutter 侧需要把矩阵信息传给原生侧,然后原生侧的 PixelMap 按照矩阵做一次区域提取。
第二个方向是手势和系统导航手势的冲突处理。OpenHarmony 的系统导航栏支持侧滑返回,如果你的图片预览页是全屏的,那么用户在图片上从屏幕左缘向右滑动时,系统会优先响应侧滑返回,而不是图片的平移。这个问题的解决方案是在 Flutter 侧禁用系统的边缘手势,或者通过原生侧设置全屏模式。但要注意,禁用侧滑返回之后,你就需要提供一个明确的返回按钮,否则用户会迷路。
第三个方向是多设备适配。OpenHarmony 不仅仅是手机和平板,还跑在开发板、电视、甚至车载设备上。不同设备的触摸能力差异很大(电视甚至没有触摸屏,只能用遥控器模拟焦点移动),PhotoView 的触摸手势在遥控器模式下是无法使用的。如果你的应用要覆盖这些设备,就需要在 PhotoView 之上再加一层“焦点 + 按键控制缩放”的适配逻辑。这个工作量不小,要根据实际产品需求评估是否要做。
我在 RK3568 上做完图片预览之后,最大的一个体会是:OpenHarmony 的 Flutter 生态和 Android 的差距,主要体现在底层引擎的稳定性和辅助库的适配度上,而不是 Flutter 框架本身。绝大多数纯 Dart 的包,只要不涉及平台通道,在 OpenHarmony 上跑起来都不成问题。PhotoView 就是一个典型案例——它核心逻辑全是 Dart,移植成本很低,我们做的工作更多是围绕内存、手势冲突、UI 联调这些外围层面。
如果你正在做类似的功能,建议先从官方 Demo 跑通,再逐步加复杂度。我见过太多人一上来就想实现多图、缩略图、编辑、滤镜全上,结果第一步就栽在图片加载上。按“单图预览 → 多图切换 → 手势调优 → 系统能力桥接”这个顺序来,每一步都能稳扎稳打,最终交付的质量会高很多。
最后再分享一个小技巧:如果你在 OpenHarmony 上调试 PhotoView 的手势问题,不要只盯着 Flutter 侧的 log,可以用 DevEco Studio 的 HiLog 把系统层的手势事件和 Flutter 层的打印放在同一条时间线上对比。我当时定位“双击后回弹”的问题,就是靠这种方式发现系统层在某个时刻上报了一个 cancel 事件导致 PhotoView 内部状态重置的。这种跨层定位的速度,远比自己瞎猜要快得多。
