ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

HarmonyOS 7 FloatView:闪控窗拖拽与边缘手势仲裁

HarmonyOS 7 FloatView:闪控窗拖拽与边缘手势仲裁 一张能拖动、能滚动、还能靠边收起的闪控窗真正麻烦的不是把窗口画出来而是同一根手指落下后谁有资格继续消费这段手势。本文记录 QuickNoteFloat 从“偶发跳窗”到稳定仲裁的完整过程。一、故障只在手指斜着移动时出现QuickNoteFloat 是一个悬浮便签顶部把手负责拖动窗口中间列表可以滚动窗口贴近屏幕边缘时还允许横向收起。最初的实现很直白顶部和列表各自注册PanGesture谁收到更新谁修改自己的状态。纵向滚动、纯横向拖动看起来都正常真正的问题出现在“从列表上方斜着划出去”这类不够标准的操作。测试同学录了 120 段手势序列。旧版本出现 23 次窗口与列表同时位移、8 次误触关闭、6 次在系统边缘区域抢走返回手势。更隐蔽的是onActionCancel系统取消触摸后窗口停在半拖拽位置下一次手势仍沿用上一次的起点于是便签像突然跳了一格。日志里没有崩溃也没有异常码只有两个都“合理”的更新列表消费了offsetY窗口又消费了offsetX。这说明问题不在某个回调而在回调之前没有做所有权决定。我的修复目标因此很明确一段手势从PENDING只能进入一个所有者直到结束或取消都不能换人。二、先把手势写成状态机我把任务编号固定为gesture_20261001_22页面名为GestureLabPage。窗口尺寸是 360 × 520 vp移动阈值 8 vp屏幕左右各保留 24 vp 的系统边缘保护区。状态流转如下IDLE → PENDING → WINDOW_DRAG | CONTENT_SCROLL → SETTLING → GESTURE_SETTLED按下时只记录起点不立即移动任何 UI累计位移超过 8 vp 后才判定方向。起点位于顶部把手并且横向位移占优所有权交给窗口。起点在内容区或纵向位移占优则交给列表。起点落入 24 vp 边缘保护区时本轮不允许进入窗口拖拽让系统手势优先。这段代码解决的是“两个组件在同一帧各自作决定”的问题。所有判断被收口到GestureOwnershipResolver组件只能读取结果不能私自切换所有者。exportenumGestureOwner{PENDINGPENDING,WINDOW_DRAGWINDOW_DRAG,CONTENT_SCROLLCONTENT_SCROLL,SYSTEM_EDGESYSTEM_EDGE}exportclassGestureOwnershipResolver{privateowner:GestureOwnerGestureOwner.PENDINGprivatereadonlytouchSlop:number8privatereadonlyedgeGuard:number24resolve(startX:number,startY:number,deltaX:number,deltaY:number,screenWidth:number):GestureOwner{if(this.owner!GestureOwner.PENDING)returnthis.ownerif(startXthis.edgeGuard||startXscreenWidth-this.edgeGuard){this.ownerGestureOwner.SYSTEM_EDGEreturnthis.owner}if(Math.hypot(deltaX,deltaY)this.touchSlop)returnthis.ownerconstfromHandlestartY56this.ownerfromHandleMath.abs(deltaX)Math.abs(deltaY)?GestureOwner.WINDOW_DRAG:GestureOwner.CONTENT_SCROLLreturnthis.owner}reset():void{this.ownerGestureOwner.PENDING}}resolve()可能被高频调用因此它一旦返回非PENDING就直接复用结果不再随着手指方向变化反复横跳。数据变化也只有一次owner从待定写入最终所有者。方法在每次onActionUpdate前执行结束和取消时必须调用reset()。正式项目还要结合实际把手高度与安全区域计算不能把 56 vp 当成所有设备的常量本文 Demo 用固定值是为了让测试序列可以稳定复现。三、拖动会话不能只保存两个坐标所有权唯一以后窗口仍偶尔在快速多指操作中跳动。原因是旧指针的更新晚到第一根手指取消后第二根手指已经开启新会话但队列里残留的回调仍能改坐标。只比较pointerId还不够因为系统可能复用编号。下面的FloatDragSession用递增的generation标识一次完整会话解决过期回调污染新拖拽的问题。它还把窗口位置夹在安全范围内不让右下角越出可视区域。exportinterfaceFloatPoint{x:number;y:number}exportclassFloatDragSession{privategeneration:number0privateactivePointer:number-1privateorigin:FloatPoint{x:0,y:0}begin(pointerId:number,current:FloatPoint):number{this.generation1this.activePointerpointerIdthis.origincurrentreturnthis.generation}update(pointerId:number,token:number,dx:number,dy:number,maxX:number,maxY:number):FloatPoint|undefined{if(pointerId!this.activePointer||token!this.generation)returnundefinedreturn{x:Math.max(0,Math.min(maxX,this.origin.xdx)),y:Math.max(0,Math.min(maxY,this.origin.ydy))}}invalidate():void{this.activePointer-1this.generation1}}begin()返回的 token 由当前手势闭包持有后续update()同时校验指针和代际任一不匹配都返回undefined。这样窗口坐标只会被当前会话修改。invalidate()在结束、取消、页面不可见和窗口销毁前执行既释放活动指针也让所有排队中的旧回调立即失效。正式产品还应把maxX/maxY从窗口尺寸与避让区实时计算旋转或折叠态变化时重新取值不能缓存启动时的屏幕大小。四、取消不是结束的别名旧代码把onActionCancel和onActionEnd都接到finishDrag()这正是半拖拽位置被保存的来源。正常结束代表用户确认了当前位置可以吸附并持久化取消代表系统中断本轮结果不应提交需要回滚到会话开始点。两者的业务语义不同。这段页面代码解决取消恢复、重复回调和生命周期清理。finishOnce保证结束逻辑只执行一次取消分支恢复起点正常结束才执行贴边计算。EntryComponentstruct GestureLabPage{Stateprivateowner:GestureOwnerGestureOwner.PENDINGStateprivatepoint:FloatPoint{x:28,y:146}StateprivatestateText:stringIDLEprivateresolver:GestureOwnershipResolvernewGestureOwnershipResolver()privatesession:FloatDragSessionnewFloatDragSession()privatestartPoint:FloatPoint{x:28,y:146}privatetoken:number0privatefinished:booleanfalseprivatefinishOnce(cancelled:boolean):void{if(this.finished)returnthis.finishedtruethis.pointcancelled?this.startPoint:this.snapToEdge(this.point)this.session.invalidate()this.resolver.reset()this.ownerGestureOwner.PENDINGthis.stateTextGESTURE_SETTLED}aboutToDisappear():void{this.finishOnce(true)}privatesnapToEdge(point:FloatPoint):FloatPoint{return{x:point.x180?12:348,y:point.y}}}实际的PanGesture在onActionStart中保存startPoint、调用begin()并把finished置为false更新时先经resolve()只有WINDOW_DRAG才写point。onActionEnd调用finishOnce(false)onActionCancel调用finishOnce(true)。aboutToDisappear()仍走取消分支这是为了处理页面退到后台、闪控窗被系统关闭等无结束回调的情况。重复调用不会再次吸附或覆盖已恢复的位置。项目结构也随职责拆开gesture/GestureOwnershipResolver.ets只判所有权gesture/FloatDragSession.ets管会话gesture/GestureRecovery.ets记录回滚点window/FloatWindowController.ets才负责窗口移动。页面不再持有系统窗口对象销毁顺序因此更清晰。五、用录制序列验证而不是靠手感我没有用“连续拖几次没问题”作为验收而是把原来的 120 段操作转换成起点、位移、时间戳、结束原因四元组逐段回放。每段结束后检查三个条件窗口和列表不能同时变化系统边缘起点不能进入WINDOW_DRAG取消后的位置必须等于会话起点。修复后的数据是冲突 23 → 0误关闭 8 → 0边缘逃逸 6 → 011 次取消全部恢复仲裁耗时 P95 为 5.6 ms。最后状态固定为GESTURE_SETTLED。5.6 ms 包含日志和测试采样并不是单次resolve()的纯函数耗时线上版本关闭详细采样后更低。手机页刻意把窗口尺寸、阈值、边缘保护区和任务编号都露出来方便和 HiLog 对照。真正产品里可以隐藏诊断卡只保留开发开关。红色批注指出顶部把手取得窗口所有权、内容区保持滚动所有权边缘 24 vp 留给系统。它不是为了装饰页面而是为了说明这次修复的边界。为了确认修复没有把“冲突”变成“迟钝”我又补了两组反向检查。一组把触摸阈值从 4、8、12、16 vp 依次回放4 vp 很容易被手指抖动触发12 vp 以上则会让窗口出现明显的跟手延迟8 vp 在当前设备密度和窗口尺寸下最稳。另一组只改变横纵位移比例观察所有权临界点。最后没有引入复杂角度扇区而是保留abs(dx) abs(dy)因为临界区越复杂越难向日志和测试解释。参数选择来自这组样本不应在其他产品里盲目复制。HiLog 也从逐帧打印改成事件日志。开始时记录pointerId、generation、起点和是否处于边缘区锁定时记录所有者与累计位移结束时只记录提交、回滚或拒绝。逐帧日志会改变性能特征还可能把 120 段序列产生的关键信息淹没。正式版只保留计数器并对坐标做取整避免诊断信息携带不必要的精细位置数据。在窗口控制器一侧我额外加了“单写者”约束。手势层不能直接调用系统窗口移动接口只能产生MoveIntent控制器在 UI 线程合并同一帧的意图并校验当前窗口仍然存活。这样即便页面生命周期与取消回调交错已经销毁的窗口也不会收到位置更新。这个约束没有出现在 Demo 的三段核心代码中却是把修复带回正式工程时最关键的一步状态机解决谁能写控制器解决写入对象是否仍有效。六、几个容易被忽略的边界第一方向锁定不能只看某一帧。低速手势的单帧位移很小必须用累计位移跨过 8 vp 后再判定。第二窗口进入贴边动画后要拒绝新位置提交否则动画和手势会同时写坐标可以允许新手势打断动画但要先读取动画当前值作为新的origin。第三列表处于顶部并不意味着下拉一定归窗口内容区的纵向操作仍应先交给滚动或刷新组件。第四onActionCancel可能来自系统抢占、窗口失焦或组件销毁不能假设之后还会收到onActionEnd。第五多设备下的边缘宽度和安全区域不同24 vp 是本次 Demo 的判定参数不是平台规范值。第六如果闪控窗支持无障碍拖动应提供按钮或键盘可完成的等价操作手势不应成为唯一入口。还有一个容易遗漏的状态同步点列表滚动到边界时不要在本轮手势中把所有权转交给窗口。看起来“继续拖窗口”很自然实际上会造成手指没有抬起、坐标参考系却突然切换最容易复现跳变。正确做法是保持CONTENT_SCROLL到本轮结束下一次按下再重新仲裁。类似地窗口吸附动画开始后也不把所有权交回内容区所有切换都必须以新的手势会话为边界。如果应用支持多个闪控窗每个窗口应拥有独立的 resolver 和 session不能把当前所有者做成进程级单例。全局单例会让 A 窗口的取消把 B 窗口的拖拽清空。窗口销毁时依次停止动画、失效会话、注销监听、释放窗口对象顺序反过来则可能在动画回调中访问已释放资源。这些边界没有增加页面功能却决定了修复能否从单窗口 Demo 安全扩展到产品。我还特意验证了横竖屏切换。切换发生在PENDING时直接取消本轮并用新尺寸重建判定参数发生在已经锁定的拖拽中也不尝试把旧坐标换算到新方向而是回滚后等待下一次按下。这样处理略显保守却能避免旋转动画、窗口布局和手势回调同时写位置。折叠设备姿态变化同理先让当前会话失效再更新可移动范围不能只修改maxX/maxY继续接收旧 token。对埋点也做了限流。我们只上传所有者、结束原因、是否回滚和耗时区间不上传完整轨迹同一窗口一分钟最多一条汇总。诊断需要知道冲突是否发生却不需要还原用户每个触点。把隐私边界和技术边界一起写进控制器后续即使打开线上采样也不会把调试阶段的逐帧日志原样带出去。这次改动没有增加复杂动画甚至没有换 UI。真正有效的是把隐含的竞争关系变成一套可观察、可回放的状态机先决定所有权再允许修改数据会话结束提交取消则回滚生命周期退出时主动失效。窗口不再“凭感觉”跟随手指日志也终于能解释每一次没有移动的原因。
返回列表