Flutter 跑在 OpenHarmony 上,不算新鲜事了,但真把页面转场这段做精细,能查到的资料还是稀碎。标题里的“15-16”不是 Flutter 的版本号,是我手头项目里两个业务页面的编号——15 号是商品列表页,16 号是商品详情页。最近把一个从安卓端迁过来的 Flutter 页面模块平移到了 OpenHarmony 设备上,结果发现问题全集中在转场这一环:同一条转场代码,在安卓模拟器上顺滑得跟德芙一样,到了 OpenHarmony 实机上就掉帧、闪白、动画路径跑偏。这篇文章就把我从 15 号页跳到 16 号页的完整转场实现、踩坑过程、最终方案一次说清楚,给正在做 Flutter on OpenHarmony 的开发者一份能直接参考的实操记录。
1. 这次的任务到底是什么:“15-16”是两个业务页面
1.1 先解释一下标题里的“15-16”
很多第一次看到这个标题的人会问:15-16 是版本号还是什么?都不是。在我负责的这个项目里,界面统一按业务模块编号管理,15 号页面是商品列表页,16 号页面是商品详情页。用户从列表页点进某件商品的详情页,这一步最简单的跳转,在原生安卓上可能是 Activity 之间调 startActivity,在 iOS 上是 pushViewController,而在我们的跨平台方案里,就是用 Flutter 的 Navigator 做一次带转场动画的路由切换。
这次任务的核心,就是把 15 到 16 这两个页面之间的切换动画,在 OpenHarmony 设备上做到跟主流移动平台接近的体验。听起来是件小事,但凡是做过跨平台适配的人都懂,越是这种基础能力,越容易被各种平台差异坑得体无完肤。
当时接手这个任务时,我自己的预期是:Flutter 框架已经把路由和动画封装得很好了,直接 push 一个 MaterialPageRoute,平台差异应该不大。但实际跑起来才发现,OpenHarmony 上的 Flutter 引擎适配分支跟标准安卓分支在渲染链路上确实有区别,有些动画效果的实现方式要跟着调整。
1.2 为什么非要纠结转场效果
有同学可能会说:不就是页面切过去吗,直接 Navigator.push 不完了?但在真实业务里,转场动画承担的信息量比你想象中大的多。从 15 列表页进入 16 详情页,用户的眼睛需要一个“位置感”:我在哪、我从哪来、接下来我要看什么。一个顺滑的右滑入场,配合列表里那张商品图在详情页里继续存在的共享元素过渡,能直接降低用户的理解成本。
反过来,如果转场生硬、跳变、掉帧,用户的直观感受就是“这个 App 卡”,再好的商品也卖不动。尤其是在 OpenHarmony 设备上跑 Flutter,同样的代码性能表现跟安卓端差异很大,动画一旦出现掉帧,观感会放大,用户的容忍度也低得多。
基于这个业务背景,我把这次 15-16 页面的转场目标定得很明确:第一,动画连续不掉帧;第二,页面转场方向符合平台直觉(进详情页从右边滑入,返回时跟着手势滑出);第三,列表页图片能无缝过渡到详情页大图;第四,所有代码能收进一个统一封装,后面 17、18 号页面直接复用。
1.3 适配 OpenHarmony 的第一原则:忘掉你对“流畅”的固有预期
先泼一盆冷水:OpenHarmony 设备上的 Flutter 渲染,不能直接用安卓/iOS 的预期来衡量。你这套代码在安卓上 120 帧稳如老狗,不代表在 OpenHarmony 实机上也能做到。
为什么?Flutter 的渲染在安卓上走的是 Skia/Impeller 那条链路,而在 OpenHarmony 的适配分支上,引擎做了不少针对性的改造,包括对图形栈、平台通道、VSync 信号的处理方式都有差异。你在transitionsBuilder里随便写一个复杂的动画组合,在安卓上可能分分钟吃满合成器,在 OHOS 上就不一定。
所以我的第一原则是:先做减法。动画效果能少用一层合成,就少用一层;能用官方提供的现成过渡组件,就不自己堆。宁可效果朴素一点,也要保证实机上稳定运行。
2. 先搞清楚 Flutter 路由转场到底在玩什么
2.1 页面转场的本质是 Route 之间的动画插值
很多人写转场只知道套用 PageRouteBuilder,但没想过它底层的运作方式。Flutter 的页面切换,本质上是 Route 对象在 Navigator 的栈里进进出出,转场动画就是这个进出过程中的“形态变化插值”。Navigator 会维护一个 Route 栈,新 Route 入栈时,旧 Route 并没有消失,而是继续留在栈里,只是被新页面盖住了。
转场动画做的事,就是在一定时长内,把新 Route 从某种状态(比如从屏幕右侧完全移出)插值到正常状态(完全占满屏幕),同时可能让旧 Route 做反向的配合动作。transitionsBuilder里的animation参数就是控制这个过程的,它的值从 0 到 1 线性变化,你可以把这个值理解成一个“进度条”:0 是动画刚开始,1 是动画结束。你在这个 builder 里返回的 widget,会根据这个进度不断重建,从而形成动画。
知道了这个原理,你就能理解为什么不要在transitionsBuilder里做耗时操作——这个函数会在动画的每一帧都被调用,任何重量级计算都会直接导致掉帧。
2.2 PageRouteBuilder 让你自己掌舵
Flutter 默认提供的MaterialPageRoute自带了一套符合 Material Design 规范的转场,在安卓上没问题,但在 OpenHarmony 上,这套默认转场的实际表现可能出现左右方向、曲线、时长跟预期不一致的情况。所以我的做法是直接用PageRouteBuilder接管整个转场过程。
PageRouteBuilder的核心就两个东西要重写:一个是transitionsBuilder,决定页面怎么进来;另一个是pageBuilder,决定页面长什么样。举个例子:
PageRouteBuilder( transitionDuration: const Duration(milliseconds: 300), reverseTransitionDuration: const Duration(milliseconds: 250), pageBuilder: (context, animation, secondaryAnimation) => const DetailPage(), transitionsBuilder: (context, animation, secondaryAnimation, child) { // 这里返回带动画包裹的 child }, );这里有几个参数值得展开。transitionDuration是进页动画时长,reverseTransitionDuration是返回动画时长。这两个值的设定直接决定手感:太短显得生硬,太长显得拖沓。在 15-16 这个场景里,我最终把进页设为 280ms,返回设为 220ms,总体感觉是“快进缓出”,更贴近用户对商品详情页的预期——进入时带着一点浏览心态,返回时用户往往已经做完了决定,所以返回要更快。
2.3 SecondaryAnimation 才是被忽视的细节
很多教程只讲animation,不讲secondaryAnimation,但 15-16 页面转场里,恰恰是secondaryAnimation决定了这个转场的质感。
secondaryAnimation是“前一个页面”的动画进度。当你从 15 推到 16 的时候,16 用自己的animation从右滑入,而 15 则可以借助secondaryAnimation向左边做一点点位移,或者轻微缩放,这样两层页面就不是生硬地“盖上”,而是有一种空间上的联动感。
我自己实测下来,给前一个页面加一个极其轻微的向左平移加缩小(大概从 1.0 缩到 0.96)看起来最舒服,既能体现出页面的层次,又不会让人觉得你在炫技。
transitionsBuilder: (context, animation, secondaryAnimation, child) { final slideIn = Tween<Offset>( begin: const Offset(1.0, 0.0), end: Offset.zero, ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOutCubic)); final scaleDown = Tween<double>( begin: 1.0, end: 0.96, ).animate(CurvedAnimation(parent: secondaryAnimation, curve: Curves.easeOutCubic)); return Stack( fit: StackFit.expand, children: [ ScaleTransition(scale: scaleDown, child: child), // 这里是上一页 SlideTransition(position: slideIn, child: child), // 这里是新页面 ], ); }注意,这个child是由框架根据场景自动分配的,Stack的层级也会按照新老页面的关系处理好。我在前一个页面(15 列表页)上加了ScaleTransition,在新页面(16 详情页)上加了SlideTransition。这里有个细节容易踩坑:Stack里两个child都叫child,哪个在上、哪个在下,取决于这个 builder 被调用时的上下文。我一开始写反了,造成的结果是新页面在下面、旧页面在上面滑进来,视觉上完全错乱。
3. 从 15 页到 16 页:一套可以直接抄走的转场实现
3.1 先把基础跳转搭好,别急着花里胡哨
在搞动画之前,我建议先把最朴素的Navigator.push跑通。OpenHarmony 上的 Flutter,导航栈的基本逻辑跟标准 Flutter 一致,但我在迁移时确实碰到过因为没适配好路由名导致的页面白屏。我全部采用具名路由的方式,在应用入口集中注册:
routes: { PageRoutes.productList: (context) => const ProductListPage(), PageRoutes.productDetail: (context) => const ProductDetailPage(), },这里我把路由的字符串常量收在了一个PageRoutes类里。为什么不用Navigator.push(MaterialPageRoute(...))?因为页面一多,散落在代码各处的页面构造会让路由管理和参数传递变得混乱,尤其是在 OpenHarmony 这种跨端场景下,后续要排查问题,集中式路由能省太多时间。
3.2 用 PageRouteBuilder 实现“右滑入、快速返回”
基础跳转没问题后,就开始接动画。我最终采用的方案是:新页面从右侧滑入 + 轻微淡入,返回时新页面快速向右滑出,同时旧页面慢慢恢复到原始大小和位置。
封装起来就是这样一个函数:
Route<T> buildSlideRoute<T>({ required Widget page, Duration duration = const Duration(milliseconds: 280), }) { return PageRouteBuilder<T>( transitionDuration: duration, reverseTransitionDuration: duration * 0.75, pageBuilder: (context, animation, secondaryAnimation) => page, transitionsBuilder: ( context, animation, secondaryAnimation, child, ) { final curvedAnimation = CurvedAnimation( parent: animation, curve: Curves.easeOutCubic, ); final curvedSecondary = CurvedAnimation( parent: secondaryAnimation, curve: Curves.easeOutCubic, ); final slideIn = Tween<Offset>( begin: const Offset(1.0, 0.0), end: Offset.zero, ).animate(curvedAnimation); final fadeIn = Tween<double>( begin: 0.6, end: 1.0, ).animate(curvedAnimation); final prevPageScale = Tween<double>( begin: 1.0, end: 0.95, ).animate(curvedSecondary); final prevPageX = Tween<double>( begin: 0.0, end: -24.0, ).animate(curvedSecondary); return Stack( fit: StackFit.expand, children: [ // 上一页的联动位移 + 缩放 Transform.translate( offset: Offset(prevPageX.value, 0.0), child: ScaleTransition( scale: prevPageScale, child: child, ), ), // 新页面右滑入 + 淡入 FadeTransition( opacity: fadeIn, child: SlideTransition( position: slideIn, child: child, ), ), ], ); }, ); }这里有一个值得说透的点:我用Transform.translate而不是SlideTransition去偏移动上一页。原因是我只让上一页向左位移 -24 个逻辑像素,同时配合缩放。SlideTransition的position是按比例算的,比如Offset(-0.03, 0),在不同分辨率下实际偏移像素不同,而Transform.translate是绝对像素偏移,更能保证视觉的一致性。这个细节在 OpenHarmony 不同分辨率实机上对比过,差异很明显。
用了FadeTransition把新页面的初始透明度调到 0.6 而不是 0,是因为在 OpenHarmony 上,如果从 0 透明度开始滑入,实机上容易感受到一次明显的背景闪白,透明度从 0.6 开始既能保留滑入的动感,又不会闪。这个值是实测调出来的。
调用方式也很简单:
Navigator.of(context).push( buildSlideRoute(page: const ProductDetailPage(productId: product.id)), );3.3 加一个 Hero 共享元素,转场立刻有灵魂
如果把 15 到 16 的转场只做成左右滑动,总觉得少了点什么。用户从列表点了一个商品,最自然的体验是:列表里那张商品小图,在进入详情页的时候能直接“飞”到详情页大图的位置上。这个效果在 Flutter 里用Hero组件实现。
列表页的缩略图:
Hero( tag: 'product_${product.id}', child: Image.network( product.thumbnailUrl, width: 100, height: 100, fit: BoxFit.cover, ), )详情页的大图:
Hero( tag: 'product_${product.id}', child: Image.network( product.coverUrl, width: double.infinity, height: 300, fit: BoxFit.cover, ), )Hero 的原理,是 Flutter 在路由转场时自动寻找两个路由的 Hero widget,只要tag一致,就会自动生成一个“从起点形态飞到终点形态”的动画。这一步在安卓上是开箱即用的,但在 OpenHarmony 上我遇到了问题(后面第 4 章会细说),关键点在于:Hero 动画依赖路由表里有对应的 Route,而且tag必须在同一个 Navigator 上下文中唯一。
加上 Hero 之后,配合前面自定义的 SlideTransition,15-16 的转场观感会上升一个层次:图片在飞、页面在滑、上一页在退让,三种动效同时进行。
3.4 别忘了处理页面间传参和返回刷新
只有转场形态还不够,15 到 16 之间必然有数据传递。我在实际项目中,15 号页面通过构造函数把productId传给 16 号页面。但这里有个细节:如果详情页里做了收藏、加购这类操作,返回列表页时列表状态需要刷新,这个刷新时机怎么处理?
用Navigator.push返回的是一个Future<T>,在 15 号页面可以这样接住返回值:
final result = await Navigator.of(context).push( buildSlideRoute(page: ProductDetailPage(productId: product.id)), ); if (result != null) { // 刷新列表:比如收藏状态变化、最近浏览记录更新 _refreshList(); }在 16 号页面,正常返回时用Navigator.pop(context, someResult)带回数据。这套“await + result”的模式在整个项目里统一适用,OpenHarmony 上行为跟标准 Flutter 一致,没有额外坑。
4. 真正折腾人的是这些坑:OpenHarmony 上的转场问题实录
4.1 白屏闪一下:Shader 预热和页面预构建
15-16 转场刚跑通时,在 OpenHarmony 实机上最明显的问题是:每次从列表页点进详情页,动画刚开始那一下,新页面区域会闪一道白屏,然后内容才慢慢出现。这个问题在安卓模拟器上几乎不可见,但实机上非常刺眼。
排查下来,原因来自两个方面。第一是页面首次构建的时间点:pageBuilder是在转场动画开始前调用的,如果详情页需要加载大量图片、复杂布局,首帧构建不够快,就会出现白屏。第二个方面是 Shader 编译:OpenHarmony 上的 Flutter 适配分支,对复杂 shader 的预热策略跟安卓不同,首次运行时,动画首帧涉及的新渲染效果需要即时编译 shader,就会卡出白屏。
我的解决办法分两步。一是对 16 详情页的首次构建做轻量化,列表页在用户点击前就预先创建好一个PageRoute对象(不 push 进去,只是预先构建页面的 widget),这样等动画开始时,页面的大部分 widget 树已经 ready。二是给 Hero 图片的加载做预取,在列表页网络数据返回后,主动调用precacheImage把详情页大图缓存下来:
precacheImage( NetworkImage(product.coverUrl), context, );这招立竿见影,16 号页面的首帧构建时间降了一半以上,闪白问题基本消失。
4.2 动画掉帧:别在 TransitionsBuilder 里做耗时操作
转场动画的每一帧,transitionsBuilder都会执行。我在前期调试时,为了让新页面进来时背景有一点高斯模糊效果,直接在transitionsBuilder里套了一个ImageFiltered。结果就是一进详情页,动画期间帧率直接掉到十几帧,滑动起来跟幻灯片似的。
原因很简单:ImageFiltered需要对每一帧做一次图像的模糊处理,这是非常重的 GPU 操作。在 OpenHarmony 低端设备上,这基本就是噩梦。把模糊去掉之后,帧率立刻恢复正常。
这种事后的顿悟让我总结了一条规矩:凡是在transitionsBuilder里引入额外合成层、模糊、阴影、大幅路径绘制,都要先在 OpenHarmony 实机上做帧率验证,不要想当然。如果要保留模糊效果,可以考虑用一张预先模糊好的静态图片作为背景,而不是每帧实时模糊。
4.3 返回手势冲突:与自带滑动返回并存
OpenHarmony 这个系统本身带有侧滑返回的手势交互。当用户从 15 进入 16 之后,在 16 页面从屏幕左边缘向右滑动,系统会触发返回手势。但问题在于,我们自定义的转场动画也走得是“返回时页面右滑”这条路,两者一叠加,出现了互相抢占手势的情况。表现为:用户侧滑返回时,动画时断时续,或者直接触发不了返回。
排查后发现,这个问题的根源不在 Flutter 侧,而在 OpenHarmony 的 Flutter 适配里,系统手势和 Flutter 手势竞争同一个事件序列。最直接的规避方案,是给详情页的这种自定义返回转场提供一个显式的返回按钮,同时弱化对手势返回的依赖。具体做法:在 16 号页面的根部包一层PopScope(旧版本里是WillPopScope),监听弹出动作,并把弹出时的转场动画统一到我们定义的reverseTransitionDuration上。
PopScope( canPop: false, onPopInvokedWithResult: (didPop, result) async { if (didPop) return; Navigator.of(context).pop(); }, child: Scaffold(...), )顺便说一句,如果你的 App 主要面向横屏设备,侧滑手势本身就不好触发,这种处理方式反而更符合横屏场景的交互习惯。
4.4 Hero 动画失效:图片中间态加载的真实原因
Hero 动画在 OpenHarmony 上第一次运行时会出现一个很怪的现象:图片从列表页飞向详情页的途中,会先消失一下,然后突然跳到目标位置。看起来像是 Hero 动画中断了。
查了半天,问题出在图片加载上。列表页用的是缩略图,详情页用的是大图,虽然都是同一个tag,但 Hero 动画期间 Flutter 会尝试把两个Image的图片帧保持同步。缩略图加载完了,大图还在网络下载中,Hero 动画在飞行途中,图片源突然从“已加载”变成“等待加载”,就会导致目标位置的图片内容瞬间消失。
解决办法,依然是在列表页预先加载详情页的大图资源。我之前提到的precacheImage,在这个场景下是必须的,不是可有可无的优化。如果图片已经缓存在磁盘或内存里,Hero 动画开始前,详情页的Image就能直接解码,中间态就不会断。
4.5 问题速查表
| 现象 | 根本原因 | 快速解法 |
|---|---|---|
| 转场起始闪白屏 | 页面首帧构建慢 / Shader 未预热 | 预构建 widget + precacheImage 预加载 |
| 动画掉帧严重 | transitionsBuilder 里有模糊、复杂绘制 | 移除重合成效果,改用预生成静态素材 |
| 侧滑返回与自定义转场冲突 | 系统手势和 Flutter 手势抢占 | 用 PopScope 接管返回,统一走自定义转场 |
| Hero 中途图片消失 | 详情页大图未加载完成 | 进 16 页前用 precacheImage 预取大图 |
| 转场方向反向 | Stack 中 新旧页面层级写反 | 检查 transitionsBuilder 里 child 的摆放顺序 |
| 返回动画生硬 | reverseTransitionDuration 未设置 | 单独设置返回时长,通常为进页的 0.7-0.8 倍 |
5. 这套方案还能怎么扩展:真正的生产级经验
5.1 把转场收编成一个统一封装
单一页面的转场做完不算完,项目里 17、18、19 号页面早晚都要用。我在这次实践中把转场逻辑抽成了一个独立的文件page_transitions.dart,里面统一导出buildSlideRoute这样一个工厂函数。后续新增页面,只要一行代码接入。
同时我在PageRoutes路由类里加了一个统一入口:
static Route<T> slideRoute<T>(Widget page) => buildSlideRoute<T>(page: page);这样业务代码里看不到任何PageRouteBuilder的细节,转场策略的调整只在一个文件里发生。比如某天产品想全局把转场时长从 280ms 改成 220ms,只需改一个常量。
5.2 低端机自动降级:动画也要看菜下饭
OpenHarmony 的设备覆盖范围比我们想象中大,既有性能不错的盒子,也有内存只有 2GB 的低端设备。我一律用同一套转场动画,在低端机上就会出现卡顿、掉帧,体验甚至不如没有动画。
后来我给路由封装里加了一个性能开关,通过一次简单的基准测试判断设备性能等级。性能等级低时,转场时长拉短、去掉 SlideTransition(只留 FadeTransition)、把上一页的缩放联动关掉。降级后的转场虽然看着朴素,但至少流畅,不会给人“系统反应迟钝”的印象。
这个策略强烈建议所有 Flutter on OpenHarmony 的项目都用上,因为同一套代码在不同性能档位的 OHOS 设备上表现差异实在太大。
5.3 最后的一点个人体会
这次 15-16 页面转场做下来,我最大的感受是:跨平台方案里,“写得出来”跟“跑得顺”是两回事。Flutter 的抽象层让同一套代码可以运行在多个平台,但平台底层的渲染链路、手势系统、合成策略各有各的脾气。如果你只盯着标准 Flutter 的 API 文档写代码,那在 OpenHarmony 上迟早要面对文档没有覆盖的边角问题。
我的建议是:拿到 OpenHarmony 实机后,第一时间把最基础的路由转场、图片加载、列表滚动、平台通道这几个链路全部验证一遍,再开始堆业务。就算是用这套看似简单的 15-16 转场,也会暴露出 Shader 预热、手势抢占、图片预取一堆问题,提前摸清脾性,后面做复杂页面心里才有底。
另外一个小技巧:调试转场动画时,打开 Flutter 自带的debugProfilePaints和debugPrintFrameRate,在 OpenHarmony 实机上盯帧时间曲线,比肉眼判断可靠得多。转场这类对性能敏感的代码,一定要以实机调试结果为准,模拟器上的结论不能作为交付依据。