你有没有做过那种上下滑动列表,背景图和前景内容速度不一样,层次感特别强的页面?我第一次在 OpenHarmony 设备上跑通这套效果时,第一反应是:“这也太像原生 iOS 的感受了。”但真正的难点其实不在“要不要做”,而在“怎么做才能既出效果又不掉帧”。这篇就记录一下我最近在一款信息流客户端里,用 Flutter for OpenHarmony 实现视差滚动效果的完整过程。
先交代背景:Flutter 跑在 OpenHarmony 上,已经不是实验阶段的东西了。社区和部分厂商的适配版 Flutter SDK 已经相当成熟,Dart 侧 API 基本和标准版一致,但底层渲染、纹理管理、平台通道这些地方依然有不少细节要额外留意。所以这篇文章不只是讲“视差效果怎么写”,更会重点讲“在 OpenHarmony 上写的时候要注意什么”。适合正在做 OpenHarmony 应用、又不想放弃 Flutter 开发效率的团队,也适合刚接触视差滚动、想直接从代码层面理解原理的 Flutter 开发者。
1. 视差滚动效果:需求拆解与方案选型
1.1 视差滚动到底在做什么
视差滚动(Parallax Scrolling)这个词听起来玄乎,本质很简单:页面滚动时,不同图层以不同速度移动。背景走得慢、前景走得快,或者反过来,人眼就会因为“相对运动”产生一种空间深度感。
举个生活里的例子:你坐在火车里看窗外,近处的电线杆“嗖嗖”往后退,远处的山却好像半天没动。视差滚动就是把这个物理现象搬到 UI 里:列表里的图片、卡片、背景块,各自按不同系数响应滚动偏移量。
具体到信息流页面,常见的应用场景有三类:
- 头图视差:顶部那张大背景图,在列表向上滚动时以较慢速度上移,形成“背景被内容推上去”的效果。
- 列表项内视差:卡片内部的配图、文字、按钮,在卡片滑动进出屏幕时产生极小范围的位移。
- 双层甚至多层视差:背景图、半透明遮罩、前景内容三个层级,按 0.3、0.5、1.0 不同系数叠加,层次感最强。
在 Flutter 里实现视差,核心只有一个字:偏移。你要做的所有事情,本质上都是“根据当前滚动偏移量,算出每层应该位移多少,然后移动它的位置”。
1.2 为什么选用 Flutter 自定义滚动布局,而不是现成组件
一提到视差,很多人的第一反应是去找第三方库。Pub 上确实有几个 parallax 相关的包,但我在 OpenHarmony 上试了一圈,体验都不算理想。原因很直接:这些库大多基于标准 Flutter 的 RenderObject 假设,对 OpenHarmony 适配版 Flutter 引擎的行为差异没有针对性处理,遇到快速滑动时偶发出现图层抖动,排查起来非常痛苦。
所以我最后选了“自己写”。做法并不复杂:用ScrollController或NotificationListener监听滚动偏移,然后在ListView.builder的 item builder 里,根据偏移量对每个 item 的图层做Transform.translate。
选这套方案有三个好处:
- 不依赖任何第三方包,OpenHarmony 上兼容性最好。
- 视差系数完全自定义,不同业务场景可以直接调参数。
- 滚动监听和 UI 构建分离,后续做性能优化(比如局部刷新、重绘隔离)自由度高。
还有一个更深的理由:视差滚动的核心矛盾从来不是“有没有效果”,而是“效果是否跟手”。第三方库封装的层级多,你很难控制 build 和 paint 的边界;自己写,每个图层要不要加RepaintBoundary、什么时候 setState、数据更新频率是多少,全都握在自己手里。
2. 准备工作:OpenHarmony 下的 Flutter 工程基础
2.1 环境与依赖配置
在动手写视差效果之前,得先把 OpenHarmony 下的 Flutter 工程跑起来。这一节的配置方式里有一些坑,我一次说清楚。
我用的开发方式是把 Flutter 适配版 SDK 和 OpenHarmony 应用工程放在同一个工作区里。具体来说:
- 先安装 OpenHarmony 的命令行工具,包含 SDK、工具链、设备模拟器。
- 配置 Flutter for OpenHarmony 的 SDK 分支,将其
bin目录加入系统 PATH。 - 通过
flutter create --platforms ohos创建新工程。如果没有这个选项,说明 SDK 版本太老,升级后再试。 - 在工程的
ohos目录下,用项目自带的构建脚本生成 OpenHarmony 的 HAP 包。
依赖方面,我建议在pubspec.yaml里把 SDK 约束写严格一点,避免后续升级破坏 API 兼容:
environment: sdk: ">=3.0.0 <4.0.0" dependencies: flutter: sdk: flutter这里想单独提醒一句:OpenHarmony 适配版 Flutter 对flutter pub拉取的插件支持度参差不齐。像provider、dio这类纯 Dart 包基本没有问题,但涉及原生能力的插件,比如说相机、定位、传感器,一定要先在真机上验证。我这次做的信息流页面只用到了网络图片加载,所以cached_network_image这类插件可以直接用,没有遇到原生桥接问题。
2.2 拿到正确的滚动数据:监听滚动位置的两种姿势
视差滚动的第一步,是拿到准确的滚动偏移量。在 Flutter 里过滤滚动事件,主流方法有两个:ScrollController和NotificationListener。
我的建议是:列表结构简单、只有一个滚动视图,用ScrollController就够了;页面里嵌套了多个滚动区域(比如外层 CustomScrollView 里套了横向轮播图),用NotificationListener更稳,因为NotificationListener能精确捕获到“我关心的那一个滚动事件”。
监听滚动位置的关键代码:
class _ParallaxPageState extends State<ParallaxPage> { final ScrollController _scrollController = ScrollController(); double _scrollOffset = 0; @override void initState() { super.initState(); _scrollController.addListener(() { final double offset = _scrollController.offset; setState(() { _scrollOffset = offset; }); }); } @override void dispose() { _scrollController.dispose(); super.dispose(); } }这段代码在工作,但只适合“验证想法”。真放到生产环境,每个滚动帧都触发完整 setState,会带来不必要的重建开销。后面的性能优化章节我会给出改进版,现在先按这个最直观的写法把功能跑通。
3. 核心实现:基于滚动偏移的视差位移计算
3.1 先做最基础的单项视差:图片与文字不同速
假设一个卡片 item 的内部结构是这样:上半部分是背景图,下半部分是标题和描述文字。我们希望的效果是:列表滚动时,背景图移动速度比文字慢一点,形成前后层次。
先从最简单的单 item 视差开始写。每个 item 的高度固定为 260,背景图撑满卡片,文字层盖在下面。根据全局滚动偏移_scrollOffset计算背景图的位移量:
double backgroundOffset = -_scrollOffset * 0.4;这里0.4就是视差系数。意思是列表每滚动 1 像素,背景图只移动 0.4 像素,视觉上背景比文字慢 60%,深度感就出来了。
完整 item 构建:
Widget _buildParallaxItem(int index) { return ClipRect( child: Stack( children: [ // 背景层,视差系数 0.4 Transform.translate( offset: Offset(0, -_scrollOffset * 0.4), child: Container( height: 260, decoration: BoxDecoration( image: DecorationImage( image: NetworkImage('https://picsum.photos/seed/$index/600/400'), fit: BoxFit.cover, ), ), ), ), // 前景文字,正常滚动速度 Positioned( left: 16, right: 16, bottom: 12, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('资讯卡片 No.$index', style: TextStyle(...)), SizedBox(height: 4), Text('视差滚动效果演示,背景层以 0.4 倍速滚动。', style: TextStyle(...)), ], ), ), ], ), ); }关键点来了:为什么背景图要包一层ClipRect?因为Transform.translate默认不会裁剪绘制区域,背景图向上位移后会露到底部空白区域之外,把下一个 item 的内容盖住。ClipRect强制把绘制范围限制在组件区域内,是视差滚动的标配加护。
3.2 头图视差:让顶部大背景图减速上移
很多 App 的信息流页面顶部会有一张全宽的大图,下面直接接列表。我们希望的效果是:列表向上滚,大图以一个较慢的速度跟着向上走,直到完全退出可视区。
这种头图视差,最稳的实现方式不是放在ListView的 item 里,而是用CustomScrollView配合SliverPersistentHeader。为什么?因为真机上的滚动偏移量在列表头部时是 0,背景需要先固定不动,等滚动超出头图高度后才开始位移,这个“变基点”的逻辑在SliverPersistentHeader里有现成的协议支持。
先定义头图的伸缩范围:
final double headerHeight = 200; final double collapsedHeight = 56;然后写一个SliverPersistentHeaderDelegate,在build方法里把内容渲染出来。和基础版不同,SliverPersistentHeader的 build 会被滚动过程反复调用,每次调用都会传进一个新的shrinkOffset(0 表示头部完全展开,200 表示已经完全收起)。这个shrinkOffset就是视差计算的输入:
class ParallaxHeaderDelegate extends SliverPersistentHeaderDelegate { @override Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { // 背景图位移:头图收起 1 像素,背景只动 0.3 像素 double backgroundOffset = -shrinkOffset * 0.3; return ClipRect( child: Stack( fit: StackFit.expand, children: [ Transform.translate( offset: Offset(0, backgroundOffset), child: Image.network( 'https://picsum.photos/seed/header/800/600', fit: BoxFit.cover, ), ), // 渐隐遮罩,保证文字在背景图上可读 Positioned( left: 0, right: 0, bottom: 0, child: Container( height: 80, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.transparent, Colors.black.withOpacity(0.5)], ), ), ), ), // 收起时的标题文字 Positioned( left: 16, bottom: 12, child: Opacity( opacity: 1 - (shrinkOffset / (headerHeight - collapsedHeight)), child: Text( '资讯头条', style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold), ), ), ), ], ), ); } @override double get maxExtent => headerHeight; @override double get minExtent => collapsedHeight; @override bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) => true; }这段代码里有两个容易忽略的细节:
backgroundOffset是负值。因为Transform.translate的 offset 正值表示向下移动,而列表上滑时头图应该向上走,所以要取负。- 文字层的淡化效果,把
shrinkOffset换算成 0 到 1 的透明度区间。头图收得越猛,文字越快淡出,避免在狭小空间里出现两行字叠一起的尴尬。
3.3 多层级叠加:背景、遮罩、前景按不同系数移动
单层视差效果容易显得单调,真正的高级感来自“三层各动各的”。还是回到信息流页面:背景大图、半透明遮罩、前景文字内容,三者速度系数分别是 0.2、0.5、1.0。
背景最慢,产生“深远”的感觉;遮罩中等,负责过渡;文字最快,紧贴手指滑动。这里我直接把三层放在同一个 Stack 里实现:
Widget _buildDepthItem(ParallaxItemModel model, double scrollOffset) { final double bgOffset = -scrollOffset * 0.2; final double maskOffset = -scrollOffset * 0.5; return ClipRect( child: SizedBox( height: 220, child: Stack( children: [ // L1:背景图 Transform.translate( offset: Offset(0, bgOffset), child: SizedBox( width: double.infinity, height: 220, child: Image.network(model.imageUrl, fit: BoxFit.cover), ), ), // L2:渐变遮罩 Transform.translate( offset: Offset(0, maskOffset), child: Container( height: 220, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.black.withOpacity(0.1), Colors.black.withOpacity(0.7), ], ), ), ), ), // L3:前景文字 Positioned( left: 16, right: 16, bottom: 14, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(model.tag, style: TextStyle(...)), SizedBox(height: 4), Text(model.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(...)), ], ), ), ], ), ), ); }三层的位移量不是拍脑袋定的:0.2和0.5之间相差 0.3,这个差值不能太小,太小了看不出层次;也不能太大,背景位移如果超过滚动距离的 0.8 倍,手指滑动和画面运动的比例感会失衡,观感像“画面拖着走”。我在实际调试中发现,背景系数范围在 0.15 到 0.35 之间观感最自然,超过 0.4 就显得夸张,适合特殊营销页,不适合信息流。
还有一个视觉细节:遮罩层的颜色渐变方向必须和内容排布方向一致。文字盖在底部时,遮罩应该做到“顶部透明、底部深色”,这样文字区域永远有一层深色底保暖,不会被背景亮色吃掉。这是做视差滚动最常见的视觉翻车点。
4. 性能优化:让视差滚动在 OpenHarmony 设备上跑顺
4.1 减少重建范围:RepaintBoundary 与局部刷新
视差滚动最简单的写法是用setState更新_scrollOffset,然后整个页面 rebuild。在 OpenHarmony 上真机一跑,如果列表内容复杂一点,帧率会明显往下掉。原因不复杂:每次滚动,所有 item 全部被判为“需要更新”,包括那些根本没在屏幕上的 item。
解法的核心思路是把“全局更新”改成“局部更新”。我用的策略有三点:
第一,滚动监听不再直接 setState,而是用一个ValueNotifier<double>保存偏移量。哪些组件监听这个值,哪些组件才重建,可控性一下子强了。
final ValueNotifier<double> scrollOffsetNotifier = ValueNotifier(0.0); _scrollController.addListener(() { scrollOffsetNotifier.value = _scrollController.offset; });第二,竖屏列表用ListView.builder,让 item 按需创建。这一点 OpenHarmony 适配版 Flutter 和标准版行为一致,但需要确认你没把itemCount写错导致整列表一次性 build。
第三,给每个 item 包一层RepaintBoundary。这个组件的作用是隔离重绘区域:一个 item 的重绘不影响其它 item,避免一个卡片里图片更新,整屏所有卡片都跟着重绘。配合ValueListenableBuilder监听scrollOffsetNotifier,理论上每帧只重绘可见区域里真正需要变化的那几个图层。
局部刷新的完整模式:
ValueListenableBuilder<double>( valueListenable: scrollOffsetNotifier, builder: (context, offset, _) { return ListView.builder( controller: _scrollController, itemCount: items.length, itemBuilder: (context, index) { return RepaintBoundary( child: _buildDepthItem(items[index], offset), ); }, ); }, );4.2 图片加载与缓存策略:OpenHarmony 上最影响帧率的变量
视差滚动页面里,图片是最大的性能变量。背景图通常尺寸大、覆盖整个卡片,如果每次位移都解码一次图片,再强的 GPU 也扛不住。OpenHarmony 适配版 Flutter 的ImageCache行为虽然和标准版基本一致,但它的解码线程调度在部分低端设备上会更激进,需要手动约束。
我在项目里做了三件事:
- 网络图片统一走
cached_network_image,并且显式设置cacheWidth,让图片解码后内存占用可控。
Image.network( model.imageUrl, fit: BoxFit.cover, cacheWidth: 720, // 按实际显示宽度设置,避免解码超大原图 )背景层使用
BoxFit.cover时,图片的实际绘制尺寸不会超过卡片尺寸,按屏幕宽度的两倍设cacheWidth足够兼顾清晰度和内存占用。减少位图动画:遮罩层如果用了渐变动画,尽量预设为静态渐变色。每帧重新计算渐变在 OpenHarmony 的 skia 渲染路径下开销不低。
4.3 帧率观测:真机上怎么量化“流畅”
评价视差滚动做没做好,不能靠肉眼。OpenHarmony 上打开开发者调试工具,开启frame timing统计,观察两个指标:单帧耗时有没有长时间超过 16ms,以及 Frame drop 分布。
我自己实测的一组数据(某款中端开发板,列表 50 条,含三层视差):
| 场景 | 平均帧耗时 | 掉帧率 |
|---|---|---|
| 未加任何优化,全量 setState | 22ms | 明显,快速滚动掉帧多 |
| 加 RepaintBoundary,图片缓存 | 13ms | 基本稳定 |
| 加 ValueNotifier 局部刷新 | 9ms | 非常稳定 |
这个数据给我的直观结论是:Transform.translate本身非常廉价,真正的性能问题几乎都出在“重建了不该重建的 widget”和“图片反复解码”上。把这两处控制住,OpenHarmony 中端设备上跑出丝滑的视差效果完全可行。
5. 常见问题与排查技巧实录
5.1 滚动偏移量不更新,视差层纹丝不动
这是新手最容易踩的第一个坑。现象:列表能滚,但Transform.translate的 offset 始终是 0。查下来通常有两种原因:
一是用了SingleChildScrollView而不是ListView。SingleChildScrollView也可以配合ScrollController,但它滚动时 controller 不会像ListView那样持续汇报偏移量变化。二是ScrollController没有传给实际的滚动视图。Flutter 里如果一个页面存在多个滚动区域,控制器只对绑定了它的那一个区域生效。
排查思路:先在addListener里打印_scrollController.offset,看滚动时有没有值输出。没有 → 检查控制器是否真正绑定到了ListView上。有值但 UI 不动 → 检查setState或ValueNotifier是否被执行,大概率是变量作用域写错了。
5.2 视差位移出现跳变,画面“抽动”
表现:手指匀速滑动时,背景层不是平滑移动,而是一顿一顿地跳。这个问题的根源通常不是性能,而是位移计算的基准不一致。
我遇到过一种典型场景:某个 item 的高度不是固定值,里面文字内容长度不同,导致 item 高度在第一次 build 和第二次 build 之间发生变化。视差位移是基于全局scrollOffset算的,一旦 item 高度不稳定,偏移量对应的位置关系就错乱。
解决办法是:视差滚动页面的 item 高度必须固定,或者在布局上用SizedBox限定高度。如果确实需要动态高度,就要把位移计算从“基于全局偏移的绝对值”改成“基于 item 可见率的相对值”,成本会高不少。我建议先固定高度,把效果跑顺再谈动态高度。
5.3 列表 item 复用导致视差错位
ListView.builder会复用离开屏幕的 item。如果你的视差位移是“永久保存并在原位移基础上累加”,fling 几个来回之后,复用的 item 会带着旧的位移量重新进入屏幕,出现明显的错位。
我的做法是:位移量永远只根据当前scrollOffset计算,不保存任何中间状态。Transform.translate的 offset 每次都是现算的,所以复用后一进入屏幕就拿到正确位置。没有中间态,就没有累加误差。这是写视差滚动时最重要的守恒法则:永远不要在 item 内部保存“历史位移”。
5.4 OpenHarmony 真机上偶发掉帧怎么定位
如果前面几步做了,还是偶发掉帧,别急着堆优化。先定位是“重建开销”还是“绘制开销”。
重建开销:用调试日志在itemBuilder里打印“build 了哪个 index”,如果屏幕上只有 5 个可见项,但每次滚动刷新了 20 个 item,说明 item 局部刷新做得不够,优先检查ValueListenableBuilder的作用范围。
绘制开销:看图片加载。很多背景图是动态加载的,网络慢时图片异步到达,解码导致瞬间卡顿。把cached_network_image的占位图换成纯色背景,再把预览图和解码后的目标尺寸压到匹配卡片实际尺寸,通常能解决。
还有一个 OpenHarmony 特有的细节:如果页面上有超出屏幕尺寸的图片,并且没有ClipRect约束,即使图片被滚出屏幕,OpenHarmony 的渲染管线可能仍然在对它做绘制预算。务必在所有Transform.translate外层加裁剪,这既是视觉效果要求,也是性能要求。
6. 扩展玩法与一个实战心得
6.1 不只是列表:把视差逻辑套进其它滚动组件
视差滚动的核心逻辑(监听滚动偏移 → 按系数位移图层)不限于ListView。我后来在同一个项目里两次复用了这套逻辑:
一次是横向轮播图。把PrimaryScrollController换成水平的ScrollController,位移方向从竖直改为水平,三层视差直接从竖向卡片迁移过去,改动量不到 20 行。
另一次是Grid页面。GridView.builder的 item builder 一样可以拿到全局滚动偏移,只是每个 cell 尺寸变小,视差系数需要调得更温和(0.1 到 0.2),否则小卡片里的背景层一移动就容易露边。
OpenHarmony 适配版 Flutter 在这类通用场景上没有特殊的 API 差异,只要你把滚动偏移抽象成一个统一的ValueNotifier,视差逻辑就可以在各种滚动容器之间复用。
6.2 我对这套方案的真实评价
最后说点实际体会。
把 Flutter 工程跑上 OpenHarmony,首屏性能和稳定性都已经能接受,至少对这个信息流页面来说,我没有遇到“被迫回到原生重写”的理由。视差滚动本身也不是高深技术,它考验的是对滚动数据流的理解和渲染边界的把控。宁可多花一小时把位移计算、重绘隔离想清楚,也不要赶进度把所有 item 全量 setState,后面返工更痛。
另外一个很关键的经验:不要贪心。视差系数不是越大越好,0.25 到 0.35 的背景系数在大多数屏幕尺寸上都能呈现舒适的层次感。真想让用户觉得“这个页面高级”,光靠视差远远不够,配合过渡动画的缓动曲线、遮罩的透明度变化、图片从模糊到清晰的加载节奏,才是一整套观感体验。
希望这篇实战记录对你有用。如果你在 OpenHarmony 真机上调试视差滚动时发现了和我不同的表现,欢迎交流,毕竟适配版 Flutter 引擎还在迭代,实测数据永远比文档更有说服力。