ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

OpenHarmony上Flutter视差侧滑菜单开发实践:从环境搭建到手势动效

OpenHarmony上Flutter视差侧滑菜单开发实践:从环境搭建到手势动效 从入职第三个月接到OpenHarmony适配任务到折腾完整个视差侧滑菜单前后花了大概两周的业余时间。回头再看这套东西踩的坑、绕的弯、最后沉淀下来的方案确实值得整理成文。这篇内容既包含Flutter for OpenHarmony的环境搭建与工程初始化也覆盖了视差侧滑菜单从动效设计到多页面导航的完整实现思路适合准备入坑OpenHarmony的Flutter开发者或者是想在跨端平台上实现自定义复杂手势交互的同学参考。1. OpenHarmony上的Flutter环境比想象中多踩三个坑先说环境这块。很多人在OpenHarmony上跑Flutter卡住的第一关其实不是代码本身而是SDK版本匹配和工程初始化。我一开始天真地以为既然Flutter本身是跨平台的那OpenHarmony支持应该也就是装个SDK的事结果实际操作下来环境环节整整耗了一天。1.1 环境准备Flutter SDK、OpenHarmony SDK与DevEco Studio的三方配合搭建环境需要准备三个核心组件Flutter SDK这里不是普通的flutter sdk而是要使用OpenHarmony分支的版本华为在flutter_flutter仓库的OpenHarmony-SIG分支上维护了专门的适配代码。我用的版本是基于Flutter 3.7.x的ohos分支这个版本在界面渲染和平台通道方面相对稳定。OpenHarmony SDK通过DevEco Studio自带的SDK Manager下载建议直接使用官方推荐的最新稳定版我当时的版本是OpenHarmony 4.0。DevEco Studio华为的IDE主要用于工程编译、签名配置以及hap包生成调试。1.2 工程创建的两种方式常见的有两种方式第一种是纯命令行创建。在配置好Flutter的ohos环境变量后直接执行flutter create --platforms ohos工程会自动生成一个ohos目录这个目录就是OpenHarmony的原生工程壳子后续在DevEco Studio中打开即可。第二种是在DevEco Studio中手动创建OpenHarmony Empty Ability工程然后通过命令行在该目录下执行flutter create --platformsohos .。这种方式适合老工程集成Flutter的场景。我实际用的是第一种简单直接而且生成的目录结构更清晰。1.3 环境配置中最容易栽的三个坑坑一版本匹配问题。Flutter ohos分支对OpenHarmony SDK的版本有严格要求。如果你用的OpenHarmony SDK过新或者过旧编译时会出现各种奇怪的符号找不到错误。我当时用的Flutter ohos分支对OpenHarmony SDK 4.0的兼容性最好升级到4.1后反而出现了渲染层异常。坑二环境变量与网络源配置。由于ohos分支的Flutter引擎归档包托管在特定仓库需要配置FLUTTER_STORAGE_BASE_URL为华为镜像地址。这里要注意网络环境的变更会影响归档包下载一旦下载失败后续的所有步骤都无从谈起。坑三ohos目录生成不全。有时候执行flutter create --platformsohos后ohos目录下会缺少entry模块或者build-profile.json配置文件。遇到这种情况最有效的办法是删掉ohos目录重新生成不要手动补文件因为缺的配置文件之间往往存在关联关系手动补很容易漏。环境这一关过了之后后面写代码反而相对顺畅。2. 侧滑菜单的架构设计先想清楚再动手开始写代码前我在架构设计上纠结了很久。市面上的侧滑库很多但搬到OpenHarmony上很多纯Flutter插件虽然理论上是JS/Native无关的实际跑起来却会出现各种奇奇怪怪的手势冲突和渲染异常。更重要的是我要实现的不是普通的抽屉菜单而是带视差效果的联动动效现成库很难直接支持。2.1 为什么不直接用现成库这里要给各位一个中肯的建议如果你的需求只是能滑出菜单、能点菜单跳页面直接用flutter_inner_drawer这类库就够了省时省力。但如果你需要视差效果并且希望菜单滑动过程中每一层的速度各不相同还是建议自研。原因是视差效果本质上要求对多个层同时施加不同的位移变换现成库普遍只处理了内容层平移加菜单层淡入这种简单组合无法满足背景层慢速移动、菜单层中速移动、内容层快速移动并带圆角缩放这种多层差异化运动需求。2.2 三层结构菜单层、背景层、内容层整个侧滑菜单我拆成了三个层级菜单层真正的导航菜单宽度约占屏幕的75%包含用户头像、菜单项列表整体是一个Scaffold背景ListView。背景层菜单后方的装饰层通常是一张带暗色遮罩的图片用来增强视觉纵深。背景层在滑动时不跟随内容层完全同速而是保持一个更慢的移动速率。内容层当前页面内容也就是主界面。内容层在侧滑时会向右平移同时做轻微的缩放和圆角化处理。这三层用Stack组合在一起菜单层在最底下背景层在中间内容层在最上面。之所以把内容层放最上面而不是菜单层放最上面是因为内容层需要覆盖整个屏幕并且处理点击事件而菜单层在关闭状态下应该被完全遮挡。2.3 手势控制器与动画控制器的分工每个层级的位移值全部由同一个Animationdynamic派生而来但这个Animation本身不受GestureDetector直接驱动而是由一个AnimationController管理。手势层只负责监听用户拖拽把拖拽的距离和方向换算成AnimationController的value值动画层负责把value映射到不同层级的位移参数。这样分层的好处是状态管理清晰、动画值统一、后续如果要把手势替换成鼠标拖拽或键盘快捷键只需要改手势层动画层完全不用动。伪代码层面的结构大概是class ParallaxSideMenu extends StatefulWidget { // 是否展开、菜单项列表、当前页面索引 } class ParallaxSideMenuState extends StateParallaxSideMenu with SingleTickerProviderStateMixin { late final AnimationController _controller; late final Animationdouble _menuTween; late final Animationdouble _contentTween; late final Animationdouble _backgroundTween; }这套结构在后来的多页面导航整合中帮了大忙因为页面切换只需要改变内容层的Widget其他层完全不需要感知导航的发生。3. 视差动效的本质不同层用不同的速度移动视差效果听起来玄乎其实核心就一句话当手指拖动菜单时画面中的不同图层以不同的速度进行位移让用户产生纵深感和空间感。就像你坐在火车上看窗外近处的电线杆呼啦呼啦往后飞远处的山却在缓慢后退一样。3.1 数学建模三层的速度比例整个视差侧滑菜单中三层位移值的设计如下内容层位移progress * 内容层最大位移内容层最大位移等于菜单宽度的70%。菜单层位移progress * 菜单层最大位移菜单层最大位移等于菜单宽度即菜单层从屏幕外向左移动到最终位置。背景层位移progress * 背景层最大位移背景层最大位移是菜单宽度的40%。这样设置之后三层的位移速度天然就不一样。内容层移动最多、速度最快菜单层次之背景层移动最少、速度最慢。最终呈现出来的效果就是内容层被推走、菜单层被拉出来、背景层慢悠悠地跟在后边。在具体代码里由于背景层在最底部它还可以额外加一个透明度动画。初始Status为0时背景层完全不显示随着progress增大背景层的透明度从0变到1视觉上会更有层次感。3.2 Transform.translate和CustomPainter的取舍实现位移的方式有几种AnimatedContainer改margin、Transform.translate、Stack的Positioned动态改left值等。我最终选择了Transform.translate。原因很简单它不触发布局重建只做绘制层的变换。这在Flutter的性能优化中是非常重要的一点。Positioned动态改left值会触发Stack的布局更新虽然不至于卡顿但在菜单滑动过程中频繁改会引起额外的layout计算。Transform.translate则完全绕开了布局直接走RenderObject的绘制变换性能最好。内容层在移动的同时还需要做圆角效果和缩放效果。这里用Transform做缩放时要注意一个问题Transform.scale默认缩放中心是Widget的中心点而不是左上角需要在Matrix4中手动设置缩放原点。我当时写的是这样Transform( transform: Matrix4.identity() ..setEntry(3, 2, 0.001) ..translate(contentOffset) ..scale(scaleValue, scaleValue, 1), alignment: Alignment.centerLeft, child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(16 * progress), ), child: _currentPage, ), )这段代码里有几个细节值得展开说说。setEntry(3, 2, 0.001)是设置透视矩阵这个很多人一开始会漏掉。如果不加在缩放和位移同时发生时内容层边缘会出现明显的锯齿感加上之后视觉上会稍微柔和一些有点类似Android系统的窗口动画效果。alignment: Alignment.centerLeft是为了保证缩放和位移都是相对于内容层的左边缘进行的。因为内容层是被往右推的左边缘就是与菜单层接触的边缘以它为锚点做缩放感觉才是内容层被从左边挤压缩放。3.3 弹簧回弹用Curves模拟物理手感菜单滑到一半松手时需要自动判定是展开还是收回。常见的处理方式是判断进度是否超过50%然后让AnimationController正向或反向播放。但如果直接使用Curves.easeOut做动画收尾手感会很生硬。因为用户松手时菜单有一个初速度easeOut曲线抹掉了这个速度的惯性感让菜单位置在松手的瞬间突然减速。想要模拟物理手感就要用带一点弹簧效果的曲线。Flutter自带的Curves.easeOutBack能实现轻微过冲回弹的效果而且是在动画结束那一端产生回弹。但它的过冲幅度固定且偏大用在侧滑菜单上会觉得菜单甩过头了。我的做法是自定义一个曲线只做轻微过冲class ParallaxOvershootCurve extends Curve { final double overshoot; const ParallaxOvershootCurve(this.overshoot); override double transformInternal(double t) { if (t 0 || t 1) return t; return t (overshoot * (t - 1) * (t - 1) * t); } }这个公式是在标准线性插值的基础上加了一个过冲项过冲只在动画中间到结尾这段出现收尾时会回到1.0保证最终状态不与菜单完全展开/收回的状态偏差。3.4 为什么背景层要做单独的速度系数很多侧滑菜单只做两层视差即菜单层和内容层。但我在实际体验中加上了背景层后视觉深度一下子就出来了。背景层速度系数我给的是0.5也就是内容层位移100个像素时背景层只移动50个像素。这里有一个经验值背景层的位移系数不是越小越好太小了会让人觉得画面分层感太强好像三个图层是硬拼上去的。0.4到0.6之间是一个比较舒服的范围既看得出景深又不至于割裂。4. 从手势识别到动画联动让菜单跟手的关键细节架构和动效模型定好之后真正的难点在于手势识别和动画联动。这一步做得好不好直接决定菜单是像一块吸铁石还是像一块生锈的铁板。4.1 GestureDetector在手势竞技场中的策略Flutter在手势处理上使用手势竞技场机制多个手势同时出现在同一区域时系统会延迟判决直到确定哪个手势应该获胜。侧滑菜单需要监听的手势是水平方向的拖拽即onHorizontalDragUpdate。但如果内容页中有横向滚动的组件比如图片轮播图、横向列表就会与菜单手势产生竞争。最简单的处理方式是使用GestureDetector同时配置onHorizontalDragStart、onHorizontalDragUpdate和onHorizontalDragEnd然后利用behavior: HitTestBehavior.translucent让手势区域在透明区域也能响应。但如果页面中存在横向滚动组件这种简单的方案就不够了。我在实际项目中的做法是在内容层包裹一层GestureDetector的同时利用RawGestureDetector自定义手势识别器在onHorizontalDragUpdate中判断当前手势方向。如果手势方向是水平且内容层的水平滚动组件没有消费这个手势菜单手势才接管。具体实现依赖手势竞技场的GestureArenaManager不过写代码时不需要直接操作底层可以通过Listener监听PointerMoveEvent做首轮方向判断再决定是否让GestureDetector后续接管方向。这里我踩过一次坑如果直接用Listener拦截所有水平方向的事件页内的横向滚动组件会失灵或变得极度卡顿。因为Listener只做监听不参与竞技场判定会导致双光标同时处理继而产生跳变。最终的解决方案是用一个自定义的HorizontalDragGestureRecognizer在onDown时记录起点再根据移动偏移量动态判断是否为水平手势调用resolve主动声明胜利。4.2 手势拖拽与动画值映射逻辑菜单跟手的关键在于拖拽距离与动画进度之间要做到1像素都不差。具体实现逻辑是在onHorizontalDragStart时记录当前动画进度_startValue。在onHorizontalDragUpdate时通过details.delta.dx计算累计偏移量除以菜单宽度得到进度增量然后与_startValue相加并用clamp(0.0, 1.0)限制范围。最后把结果赋值给_controller.value。void _onHorizontalDragUpdate(DragUpdateDetails details) { final double delta details.primaryDelta! / widget.menuWidth; final double nextValue _startValue delta; _controller.value nextValue.clamp(0.0, 1.0); }注意这里用的是details.primaryDelta而不是details.delta.dx。primaryDelta在只有一个方向轴时更稳定不会出现因为轻微的Y轴移动导致数值抖动的问题。4.3 自动回弹松手后的物理判定在onHorizontalDragEnd中需要判断是继续展开还是收回。这里除了看进度是否过半还要考虑松手时的速度。如果只凭进度判断会出现一种情况用户快速向右甩了一下但只甩了30%的进度菜单却自动收回这不符合直觉。正确的做法应该是如果速度大于一个阈值就朝速度方向完成动画否则才按进度过半来判定。我当时设的阈值是500像素/秒对应代码是这样void _onHorizontalDragEnd(DragEndDetails details) { final double velocity details.primaryVelocity!; if (velocity 500) { _controller.forward(); } else if (velocity -500) { _controller.reverse(); } else if (_controller.value 0.5) { _controller.forward(); } else { _controller.reverse(); } }primaryVelocity是VelocityTracker估算的最后移动速度单位是像素/秒。这个阈值我调了很多次最后定在500左右。如果设成200菜单很容易在快速轻扫时意外展开设成800以上即便用户有明确展开意图也因为速度不够而收回去。这里还有个小细节AnimationController.forward()默认是从当前值开始动画所以从半途松手到完全展开这个过程中动画是从当前位置继续播放的这带来的效果是菜单会平滑地从半开状态继续打开非常自然。4.4 手势冲突的真实案例与横滑列表的竞争项目里有一个页面需要展示横向滚动的卡片列表这直接跟侧滑菜单手势撞了。实现上我使用了一个全局的GestureBinding.instance.pointerRouter来在PointerDownEvent时记录每个手势的入口然后配合GestureArenaManager在手指横向移动超过8像素时做出裁决。但实际上最稳妥、代码量最小的方案还是通过Notification来传递滚动状态当横向列表正在滚动时禁止菜单手势当列表滚动到最左端且继续右拉时才唤醒菜单手势。具体实现是监听ScrollStartNotification和ScrollUpdateNotification判断notification.metrics.extentBefore 0并且dragDetails.primaryDelta! 0时菜单手势才参与竞技场竞争。其他情况直接把手势让给列表。5. 侧滑菜单与多页面导航路由联动和手势冲突菜单只是一个容器真正的业务逻辑是菜单项点击后跳转到不同页面。这一部分涉及导航器选择、菜单状态同步、返回手势处理等多个模块的联动。5.1 Flutter的Navigator 1.0在OpenHarmony上的表现在OpenHarmony的Flutter适配中我最初用的是Navigator 1.0也就是最传统的方式Navigator.push来压栈页面。这种方式在单个页面内嵌侧滑菜单时问题不大。但后面发现当页面A带着侧滑菜单的主页push到页面B后页面B需要返回时系统的返回手势会把整个页面给pop掉而不是关闭侧滑菜单。这个交互在许多App里很常见需要菜单先生效。为了处理这种情况有两种路线第一种用PopScope来拦截返回手势在canPop为false时把拦截到的返回事件转成关闭菜单。这样菜单层和页面返回就不会冲突。第二种使用go_router这类声明式路由利用ShellRoute实现底部导航与侧滑菜单的整合。我最终采用的是go_router ShellRoute的方案。原因很简单go_router的状态可观察性更强可以方便地监听当前路由位置从而让菜单的高亮项跟着路由变化。而且ShellRoute天然适合外层菜单内部多页面的结构。5.2 菜单选中项与路由的联动整个菜单项点击后触发的是context.push(/second)或者context.go(/dashboard)。当路由变化后菜单内高亮项的更新不是靠回调而是靠监听路由状态。这里我没有引入Provider或Riverpod而是简单地在菜单组件内使用GoRouterState.of(context)或RouteObserver来监听路由变化再根据当前路径设置菜单项选中索引。媒体资源有限不必为了一个菜单联动引入完整的状态管理库。代码思路大致是class _ParallaxMenuContentState extends StateParallaxMenuContent { int _currentIndex 0; override void didChangeDependencies() { super.didChangeDependencies(); final location GoRouterState.of(context).uri.toString(); final index widget.items.indexWhere((item) location.startsWith(item.path)); if (index ! -1 index ! _currentIndex) { setState(() _currentIndex index); } } }这里有一个好处是菜单的选中状态不依赖页面回调即使菜单在滑动过程中页面被push进去又pop回来菜单的选中状态也会自动同步到当前路由。5.3 页面跳转后菜单的关闭时机还有一个值得思考的问题菜单收到点击事件后是先跳页面还是先关菜单或者同时进行如果先关菜单再跳页面用户会看到菜单关闭动画完成后页面才切换流畅度一般但逻辑清晰。如果同时进行菜单位置还没归位时页面内容已经切换视觉上会有一瞬间的混乱。我的选择是先启动菜单关闭动画在动画完成后的回调里再执行路由跳转。这种方案最稳。为了不让用户觉得跳转慢关闭动画持续时间刻意设置得短一些比如200毫秒比正常展开的300毫秒短。具体代码如下void _onMenuItemTap(int index) { widget.onMenuItemTap?.call(index); _controller.reverse().whenComplete(() { context.push(widget.items[index].path); }); }注意whenComplete在动画被中断时也会执行所以如果你在菜单关闭过程中又开了一次菜单这个回调可能提前触发。稳妥一点的写法是用TickerFuture.orCancel来捕获取消异常就不展开说了。5.4 左边缘滑出的额外手势支持除了从内容层上右滑打开菜单很多用户习惯从屏幕左边缘右滑来呼出菜单。这个交互在iOS上尤其常见。要支持左边缘滑出需要监听onHorizontalDragStart时判断手势的起始位置details.globalPosition.dx是否小于某个阈值比如24像素。如果小于就将该手势识别为打开菜单手势否则就交给内容层处理。但要注意这样会导致内容层内靠左区域的横向滑动变得不灵敏尤其是页面内的横向列表恰好贴着左边缘的话。我的处理方式是只在主页面允许边缘滑出二级页面禁用边缘呼出保留左边缘返回手势给系统的页面返回使用。6. 真机实测与性能调优OpenHarmony上的血泪经验代码全部写完、逻辑自测没问题之后真正的考验才开始真机性能。我手上跑的是OpenHarmony 4.0的开发板Flutter的ohos分支在渲染性能上和Android原生Flutter相比还是有一些差距尤其在低端设备上。6.1 帧率实测结果先上测试数据。在打开视差侧滑菜单并滑动过程中我通过Flutter的性能Overlay和DevEco Studio的Profiler同时观测最终数据如下设备类型RK3568开发板OpenHarmony 4.0屏幕分辨率720p。展开动画平均帧率55帧偶尔掉到40帧。收回动画平均帧率58帧基本稳定。拖动过程平均帧率50帧跟手程度可以接受。这个结果说实话并没有Android中端机上运行原生Flutter那么丝滑主要瓶颈在GPU的合成上。当三层同时做Transform变换时合成压力确实比较大。6.2 RepaintBoundary给每一层建一堵隔离墙性能优化中最立竿见影的手段是加RepaintBoundary。在菜单层、背景层、内容层这三层的外面各包一层RepaintBoundary好处是每一层可以独立进行图层光栅化。内容层的变化不会导致菜单层重绘。只要在滑动过程中这三个图层本身就是固定不变的那么重绘的只有Transform变换CPU和GPU的消耗都会大幅降低。实测加了三层RepaintBoundary后帧率稳定了约5~8帧而且在打开菜单后的静止状态下CPU占用率有明显下降。还有一个小优化点在_controller从0到1的过程中背景层和内容层的子组件其实并不需要更新。因此需要在builder中将repaint参数传给AnimatedBuilder而不是直接在build里只读取animation.value。如果AnimatedBuilder没有指定repaint它会默认监听动画的每一个帧导致每次动画帧都rebuild整个子树。指定repaint: _controller之后Builder只在动画值变化的时候重建这一项对性能提升帮助很大。6.3 OpenHarmony特有的问题内存与IAP兼容性在OpenHarmony上跑Flutter有几个跟平台相关的点需要特别留意。第一内存占用普遍偏高。flutter引擎在OpenHarmony上默认的heap size如果偏大开发板容易在菜单滑出、多页面跳转几次之后内存吃紧。建议在entry模块的module.json5中主动配置内存回收策略或者通过SystemMemoryInfo获取可用内存后动态调整页面缓存数量。第二如果有内购或者拉起系统支付的需求需要用OpenHarmony自己的IAP服务。在Flutter中拉起HarmonyOS的IAP需要写平台通道调用OpenHarmony的Purchase模块。这里不是简单调用一个插件就能完成需要同时在ohos的ets层写一个接收Flutter MethodCall的模块再调用系统的IAP SDK。Flutter侧统一用MethodChannel回调结果。6.4 调试与定位问题的工具组合OpenHarmony上的Flutter调试和Android上最大的区别是无法直接依赖Android Studio的布局检查工具。我实践中用的调试组合是Flutter Inspector通过flutter attach附加到已运行的应用上查看widget树和图层边界。DevEco Studio的HiLog查看OpenHarmony原生层的崩溃日志和平台通道日志。DevEco Profiler查看CPU、GPU、内存占用定位是否出现掉帧和内存泄漏。特别注意在OpenHarmony上flutter attach有时会出现DDS连接不上的问题。解决方案是在DevEco Studio中打开应用的调试模式后再执行attach或者在启动Flutter应用时加上--start-paused参数。最后关于侧滑菜单工程的扩展思路完成这个视差侧滑菜单后我的一个明显感受是真正有价值的不是菜单本身而是如何在跨平台应用上实现自定义手势动效这一整套方法和思维框架。环境告一段落之后我还在考虑几个扩展方向一是菜单项支持拖拽排序并同步到本地数据库二是优化菜单的沉浸式适配让状态栏与菜单背景融为一体三是把这套手势竞技场方案抽成通用手势库方便横滑列表与侧滑菜单在更多页面中共存。如果你刚好也在做Flutter for OpenHarmony的适配或者想实现一套复杂的自定义侧滑交互希望这篇实践记录能帮你少走一些弯路。尤其是环境配置和手势冲突处理这两个环节多花点时间在上面绝对值得。
返回列表