
最近帮朋友救火一个 uniapp 项目需求朴素得不能再朴素App 和小程序里都要有一个常驻的客服小球能拖着走别挡着正文内容点一下直接进客服会话。他们原来的做法是在每个页面底部各自塞了一份组件八个页面八份代码改个颜色要改八次有两个页面还忘了加用户反馈有的页面找不到客服。这类看着一句话、写起来一身坑的 uniapp 全局悬浮按钮需求几乎每个中大型项目都会撞上一次。这篇文章就把我从零实现可拖动全局悬浮按钮的完整思路摊开讲包含方案选型、坐标系换算、性能取舍、多端差异以及几个我踩过之后才明白的细节。适合已经会用 uniapp 写页面、但还没系统处理过全局浮层问题的同学也适合正在做客服入口、快捷回到顶部、活动浮球这类功能的人参考。1. 需求拆解与技术选型全局悬浮按钮到底难在哪1.1 把全局两个字拆开看很多人第一次接到这个需求脑子里想的是加个 fixed 定位的 div 不就完了。真要落地才发现全局这两个字在 uniapp 里是有分量的。它至少包含三层含义跨页面可见、跨 tabbar 可见、脱离单个页面的生命周期独立存在。麻烦点在于 uniapp 的App.vue没有模板渲染区。它只能写全局样式、全局生命周期钩子、全局状态初始化和一些应用级的逻辑你没法像写普通 Web 项目那样在根组件里塞一个视图节点然后让它一直挂着。这一条几乎决定了所有方案的走向——想要真正意义的全局视图在 uniapp 里必须换思路。除了可见性还有几个隐性约束容易被忽略。第一是不遮挡关键内容悬浮球不能压住底部输入框、不能压住键盘弹出区域、不能压住弹窗的确认按钮。第二是位置要有记忆用户把它拖到右下角切了个页面回来它跳到左上角体验会很割裂。第三是点击与拖动的边界要清晰手指稍微抖一下就被判定成拖动导致点了没反应这种投诉最难排查。我一般会在动手前先把这几个约束列成一张清单写完之后逐条对照验收。清单不复杂但能省掉大量返工。1.2 四种实现路径的取舍uniapp 里实现全局悬浮按钮主流就四条路各有各的脾气。我把它们拉平了对比一下方案实现方式优势代价适用端页面内组件每个页面模板里手动引一次逻辑最简单可控性最强重复代码多新增页面易漏全端全局注册 easycom注册成全局组件每页写一行标签引入成本极低样式统一依然要每页加一行全端自定义 layout 包裹用一个外层 view 包住页面内容一次编写天然全局影响布局与滚动改造老项目成本高全端原生子窗体App 端用原生视图层绘制真全局不受页面栈影响只能画简单图形文字交互能力弱App自定义 layout 那条路值得多说一句。它的思路是把每个页面的根节点改成一个布局组件悬浮按钮作为布局组件的子节点存在。听起来最优雅但代价是老项目要改所有页面的根节点结构而且一旦页面里有scroll-view或者需要整页滚动的场景布局层级就会打架。新项目可以这么设计老项目我一般不推荐动。原生子窗体这条路在 App 端确实能做到页面怎么切它都在但它的绘制能力和交互能力受限严重复杂动画、圆角阴影、点击热区都不好控制而且和小程序的实现方式完全不同等于要维护两套逻辑。除非是那种只需要一个静态图标入口的场景否则性价比不高。1.3 我最终选的是手写 touch 全局注册组件结论先给用全局注册的自定义组件每个页面模板里加一行标签内部用原生触摸事件手写拖动逻辑。理由有三条。第一跨端一致性最好。这份组件在小程序、App、H5 上跑的是同一套代码不需要为某个端单独写分支维护成本最低。第二可控粒度细。movable-view虽然省事但它的层级、惯性、阻尼参数在三个端的表现并不统一遇到要吸附到屏幕边缘这种需求还是要自己改。第三性能可控。手写拖动时我可以自己决定节流频率、用transform还是left/top这些直接影响滑动的跟手程度。至于movable-areamovable-view这套原生方案我并不是完全否定。如果需求只是能拖就行不要求边缘吸附、不要求位置记忆、不要求跨端像素级一致那它确实是最快的实现路径。但它有两个绕不开的问题一是在部分安卓小程序里movable-view的层级表现和新版基础库有关容易盖住原生组件或者被原生组件盖住二是拖动结束时拿到的坐标是相对movable-area的做全屏范围约束时还要额外换算。所以一旦需求稍微复杂一点手写反而更省心。2. 原理层坐标、事件、渲染三件事先搞明白2.1 触摸事件里的四种坐标别用错做拖动最基础的一课是分清触摸事件对象里那堆坐标字段。小程序和 uniapp 的触摸事件里touches数组的每个元素通常包含这几个值pageX/pageY相对整个文档的坐标页面滚动后会变化clientX/clientY相对可视区域的坐标页面滚动不影响screenX/screenY相对屏幕物理像素的坐标对于position: fixed的悬浮按钮必须用clientX/clientY。用pageX的话页面往下滚三百像素你按下按钮记录的基准点就偏了三百拖动会瞬间跳到奇怪的位置。我第一次写这个功能时就栽在这儿测试同学反馈往下滚一屏再拖就飞了查了半天才定位到坐标字段用错。还有个更隐蔽的坑不同端、不同机型上这几个字段的取值基准可能略有差异。所以我在实现时只用增量不用绝对值——记录按下时的坐标和按钮当前位置移动时算差值再叠加。这样即使某个端给的基准有点偏移也不影响最终结果因为差值是一致的。这一招在跨端项目里非常实用。另外要注意touchend事件。手指离开后touches数组是空的最后的位置要从changedTouches里取。如果拖动结束的吸附逻辑需要读坐标记得换成changedTouches[0]不然会报 undefined。2.2 可视区、安全区和那点高度差悬浮按钮的活动范围理论上是整个可视区域但真正能用的范围要扣掉几块地方顶部状态栏和自定义导航栏占的高度底部 tabbar 占的高度还有一些机型底部的小白条区域。这几个值都能从uni.getSystemInfoSync()里拿到。常用字段有windowWidth、windowHeight、statusBarHeight、screenHeight、safeArea。其中windowHeight是不含 tabbar 的可用高度screenHeight是整屏高度两者相减就能估算出底部 tabbar 加安全区占了多少。我在组件里做的约束比较简单左右贴边间距留 12rpx上下各留出一点安全余量拖动时用Math.min和Math.max把坐标夹在合法区间内。这样即使用户暴力拖拽按钮也不会跑到屏幕外面找不回来。看起来是小细节但真出过事故——有个版本没做边界约束用户把球拖到屏幕外重启应用才恢复客诉直接来了。2.3 拖动卡顿的根因在哪先说结论卡顿基本都来自两件事数据通信频率过高和触发了重排重绘。在 uniapp 编译到小程序时逻辑层和渲染层是分开的每次修改响应式数据都要跨线程通信一次。touchmove事件在手指快速滑动时每秒能触发六十次以上如果你每次都更新数据、每次都让视图层重渲染那通信量和渲染压力都会飙升表现出来就是球比手指慢半拍或者滑动一顿一顿的。对应的优化手段有三层。第一层是节流设置一个 16 毫秒左右的最小更新间隔把更新频率压到接近屏幕刷新率。第二层是用transform而不是left/toptransform走的是合成层不触发重排只做图层位移性能好得多。第三层是只更新必要字段不要把整个对象重新赋值。还有一个进阶做法是在微信小程序端用 WXS 响应事件把拖动逻辑放到渲染层执行彻底绕开通信。但 uniapp 里写 WXS 需要条件编译跨端维护成本高除非拖动体验是核心竞争力否则不建议为了这个引入。我实测下来的结论是只要做好前两层六十赫兹的节流配合transform在中端安卓机上拖动已经足够跟手普通悬浮按钮场景完全够用。3. 动手实现从目录结构到完整组件3.1 目录结构与全局注册目录规划上我把悬浮按钮放在components/float-ball/下用 easycom 规范命名这样任何页面不需要 import 就能直接使用。src/ components/ float-ball/ float-ball.vue pages/ index/index.vue mine/mine.vue static/ float-ball-icon.pngeasycom 的规则是组件路径符合components/组件名/组件名.vue就能自动引入。如果你想自定义匹配规则可以在pages.json里配置{ easycom: { autoscan: true, custom: { ^fb-(.*): /components/fb-$1/fb-$1.vue } } }配好之后页面里只用写float-ball /不需要任何 import 语句这就是全局组件在 uniapp 里最省事的落地方式。3.2 组件完整实现Vue3 写法下面是完整实现我拆开逐段说明。template view classfloat-ball :class{ is-dragging: dragging } :styleballStyle touchstart.stop.preventonTouchStart touchmove.stop.preventonTouchMove touchend.stop.preventonTouchEnd touchcancel.stop.preventonTouchEnd clickonClick image v-ificon classfloat-ball__icon :srcicon modeaspectFit / text v-else classfloat-ball__text{{ text }}/text /view /template script setup import { ref, computed, onMounted } from vue const props defineProps({ // 位置持久化的 key多个悬浮按钮时区分开 storageKey: { type: String, default: float_ball_pos }, // 按钮直径单位 rpx size: { type: Number, default: 104 }, // 贴边间距单位 rpx edgeGap: { type: Number, default: 16 }, // 上下安全余量单位 rpx verticalGap: { type: Number, default: 160 }, // 判定为拖动的位移阈值单位 px moveThreshold: { type: Number, default: 6 }, // 默认贴边方向left / right defaultSide: { type: String, default: right }, // 默认垂直位置比例0 到 1 defaultRatioY: { type: Number, default: 0.72 }, icon: { type: String, default: }, text: { type: String, default: 客服 } }) const emit defineEmits([click, drag-end]) const sys uni.getSystemInfoSync() const rpx2px (v) (v * sys.windowWidth) / 750 const sizePx ref(rpx2px(props.size)) const left ref(0) const top ref(0) const dragging ref(false) const animating ref(false) const hasMoved ref(false) let startClientX 0 let startClientY 0 let startLeft 0 let startTop 0 let lastUpdateTime 0 const ballStyle computed(() ({ width: sizePx.value px, height: sizePx.value px, transform: translate3d(${left.value}px, ${top.value}px, 0), transition: animating.value ? transform .26s cubic-bezier(.25, .8, .25, 1) : none })) function clamp(v, min, max) { return Math.min(Math.max(v, min), max) } function getBounds() { const gap rpx2px(props.edgeGap) const vGap rpx2px(props.verticalGap) return { minLeft: gap, maxLeft: sys.windowWidth - sizePx.value - gap, minTop: vGap, maxTop: sys.windowHeight - sizePx.value - vGap } } function restorePosition() { const saved uni.getStorageSync(props.storageKey) const bounds getBounds() if (saved typeof saved.ratioX number) { left.value clamp(saved.ratioX * sys.windowWidth, bounds.minLeft, bounds.maxLeft) top.value clamp(saved.ratioY * sys.windowHeight, bounds.minTop, bounds.maxTop) return } const gap rpx2px(props.edgeGap) left.value props.defaultSide right ? sys.windowWidth - sizePx.value - gap : gap top.value clamp(props.defaultRatioY * sys.windowHeight, bounds.minTop, bounds.maxTop) } function onTouchStart(e) { const t e.touches[0] || {} startClientX t.clientX startClientY t.clientY startLeft left.value startTop top.value hasMoved.value false dragging.value true animating.value false } function onTouchMove(e) { if (!dragging.value) return const t e.touches[0] || {} const dx t.clientX - startClientX const dy t.clientY - startClientY if (!hasMoved.value) { if (Math.abs(dx) props.moveThreshold Math.abs(dy) props.moveThreshold) { return } hasMoved.value true } // 节流约 60fps const now Date.now() if (now - lastUpdateTime 16) return lastUpdateTime now const bounds getBounds() left.value clamp(startLeft dx, bounds.minLeft, bounds.maxLeft) top.value clamp(startTop dy, bounds.minTop, bounds.maxTop) } function onTouchEnd() { if (!dragging.value) return dragging.value false if (!hasMoved.value) return const bounds getBounds() const centerX left.value sizePx.value / 2 const gap rpx2px(props.edgeGap) const targetLeft centerX sys.windowWidth / 2 ? gap : sys.windowWidth - sizePx.value - gap animating.value true left.value clamp(targetLeft, bounds.minLeft, bounds.maxLeft) uni.setStorageSync(props.storageKey, { ratioX: left.value / sys.windowWidth, ratioY: top.value / sys.windowHeight }) emit(drag-end, { left: left.value, top: top.value }) setTimeout(() { animating.value false }, 280) } function onClick() { // 有拖动位移时不触发点击避免误触 if (hasMoved.value) return emit(click) } onMounted(() { sizePx.value rpx2px(props.size) restorePosition() }) /script style scoped .float-ball { position: fixed; left: 0; top: 0; z-index: 999; display: flex; align-items: center; justify-content: center; border-radius: 50%; overflow: hidden; background-color: #ff6b35; box-shadow: 0 8rpx 24rpx rgba(255, 107, 53, 0.35); will-change: transform; transform: translate3d(0, 0, 0); } .float-ball.is-dragging { box-shadow: 0 12rpx 32rpx rgba(255, 107, 53, 0.45); } .float-ball__icon { width: 56%; height: 56%; } .float-ball__text { font-size: 26rpx; color: #ffffff; line-height: 1; } /style代码里有几处值得单独拎出来讲。left: 0; top: 0配合transform是这套方案的核心。如果用left/top做位移每一帧都会触发一次布局计算安卓中低端机上体感非常明显。改成定死原点、靠translate3d位移之后整个拖动过程只影响图层位置流畅度提升很直观。will-change: transform是给渲染层的一个提示让它提前把这个元素提升为独立合成层。加不加在高端机上区别不大但在中低端安卓机上能明显减少闪烁。transition只在吸附时开拖动过程中必须关掉。如果拖动时还带着过渡动画球会像拖着一根橡皮筋一样滞后于手指手感非常怪。我用animating这个状态位专门控制这件事。拖动阈值moveThreshold设为 6px。这个数字不是拍脑袋来的太小了手指自然抖动就会触发拖动判定点击失效太大了短距离拖动又会变成点击。6 到 10 像素之间是比较舒服的区间我一般取 6。3.3 Vue2 项目怎么改如果你的项目还是 Vue2改动量不大把script setup换成datamethods就行逻辑一模一样。export default { name: FloatBall, props: { storageKey: { type: String, default: float_ball_pos }, size: { type: Number, default: 104 }, edgeGap: { type: Number, default: 16 }, verticalGap: { type: Number, default: 160 }, moveThreshold: { type: Number, default: 6 }, defaultSide: { type: String, default: right }, defaultRatioY: { type: Number, default: 0.72 } }, data() { return { left: 0, top: 0, dragging: false, animating: false, hasMoved: false, sizePx: 0, sys: {} } }, computed: { ballStyle() { return { width: this.sizePx px, height: this.sizePx px, transform: translate3d(${this.left}px, ${this.top}px, 0), transition: this.animating ? transform .26s cubic-bezier(.25, .8, .25, 1) : none } } }, mounted() { this.sys uni.getSystemInfoSync() this.sizePx (this.size * this.sys.windowWidth) / 750 this.restorePosition() }, methods: { // 其余方法与 setup 版本一致把 ref 换成 this.xxx 即可 } }Vue2 转 Vue3 的项目要注意一点如果原来用了this.$refs去拿真实 DOM 做位置计算转过去之后script setup里拿不到组件实例的$el得改用getCurrentInstance()。不过我们这个方案从头到尾没依赖 DOM所以迁移很干净——这也是我坚持用纯数据驱动位移的原因之一。3.4 页面接入与生命周期联动组件写完之后页面里接入就是一行template view classpage view classcontent.../view float-ball storage-keyservice_ball clickgoService / /view /template script setup function goService() { uni.navigateTo({ url: /pages/service/chat }) } /script这里有个容易被忽略的点页面onShow时要不要重新读一次位置。如果用户在多页面之间来回切换每个页面重新挂载组件onMounted会重新读缓存位置是一致的不需要额外处理。但如果某个页面长期驻留比如 tabbar 页面用户在这个页面把球拖到左边切到别的页面去回来时这个页面并没有重新挂载位置也不会错——因为拖动时已经写进 ref 了。两种情况都能自洽。真正需要处理的是tabbar 页面的首次加载时机。tabbar 页面第一次点开才创建创建时机比其他页面晚如果缓存里有位置onMounted读出来直接生效视觉上不会有跳动。但如果加了transition首次挂载时就会看到球从原点滑到目标位置的动画。解决办法是在restorePosition之前把animating设成 false或者在onMounted里先关闭过渡再开启。我在代码里用的是animating初始值为 false所以没这个问题。4. 多端适配小程序、App、H5 各有什么脾气4.1 小程序端的层级与原生组件小程序端最大的特点是原生组件层级最高。video、map、canvas、textarea、live-player这些组件在某些基础库版本下会盖在所有普通视图之上无论你z-index调到多少都没用。悬浮球如果正好飘到视频区域上方就会被吃掉一部分。这个问题的标准解法是用cover-view和cover-image替代普通view和image。但cover-view的限制也不少只支持部分 CSS 属性不支持transform的部分值圆角、阴影的表现在不同版本上有差异。所以我的做法是——先用普通 view 实现遇到具体机型真被盖住了再用条件编译单独出一个小程序版本不要一上来就为了兼容把所有代码写成cover-view。!-- #ifdef MP-WEIXIN -- cover-view classfloat-ball :styleballStyle cover-image v-ificon :srcicon / /cover-view !-- #endif -- !-- #ifndef MP-WEIXIN -- view classfloat-ball :styleballStyle image v-ificon :srcicon modeaspectFit / /view !-- #endif --另外.stop.prevent在小程序端会编译成catch前缀这是阻止页面跟着拖动滚动的关键。如果你发现拖球的时候页面也在滚先检查是不是漏了.stop。还有一种情况是组件被包在scroll-view里那时候catchtouchmove只能阻止scroll-view内部的滚动页面级滚动要靠别的手段。4.2 App 端的页面类型差异App 端要区分vue页面和nvue页面。nvue用的是原生渲染引擎CSS 支持范围小很多position: fixed在nvue里的行为和普通vue页面不完全一致transform加transition的组合也可能表现不同。如果项目里有nvue页面建议给悬浮球的样式做一份nvue专用的条件编译版本或者干脆在nvue页面里不挂载悬浮球。还有一个 App 端特有的问题是弹窗遮挡。uni.showModal、uni.showToast这些原生弹窗的层级在原生层普通视图盖不住也不需要盖。但如果你用的是自定义弹窗组件那就得保证悬浮球的z-index低于弹窗否则球会飘在遮罩上面看起来非常突兀。我的处理方式是把悬浮球的z-index设在 999 这个量级自定义弹窗统一用 1000 以上团队里约定好就不会乱。4.3 H5 端的指针事件与滚动穿透H5 端的坑主要在两个地方。一是移动端三百毫秒点击延迟虽然现代浏览器加了viewport的widthdevice-width之后基本没有了但如果页面 meta 配得不对点击会有明显延迟。二是滚动穿透在弹窗打开时拖动悬浮球底下的页面可能跟着滚。H5 端还有一个隐患是桌面端浏览器的鼠标事件。如果项目要跑在 PC 浏览器上touchstart/touchmove是不触发的得兼容mousedown/mousemove/mouseup。不过 uniapp 在 H5 端会对 touch 事件做一层兼容处理大部分情况下鼠标拖拽也能生效。如果发现拖不动可以在manifest.json的h5配置里检查一下是否开启了相关兼容选项。5. 常见问题与排查实录5.1 按钮拖不动或者拖一半回弹拖不动通常有三个原因。第一是touchmove被父级拦截检查一下外层是不是有scroll-view或者别的组件catch了事件。第二是.prevent导致事件在部分端被吞掉可以试着去掉.prevent只保留.stop看是否恢复。第三是dragging状态没重置比如上次touchend没触发导致dragging一直是 false这种情况加个touchcancel监听就能兜住。拖一半回弹的典型原因是吸附逻辑写错。常见错误是touchend里读了e.touches[0]但这时候touches是空数组取到 undefined算出来的位置就乱了。正确做法是在touchmove里就把位置存好touchend里直接用存下来的值不要去读事件对象。5.2 位置记忆失效或者跨机型错位如果用的是绝对像素值存储位置换一台屏幕宽度不同的设备球就会跑到屏幕外或者位置很怪。解决办法就是我在代码里用的比例存储存left / windowWidth和top / windowHeight这两个 0 到 1 的比值恢复时再乘回当前设备的宽高。这样同一个账号在手机和平板上切换位置关系是一致的。还有一种情况是横竖屏切换。如果应用支持横屏屏幕尺寸变了之后之前存的比例值仍然有效但边界约束要重新算。我在getBounds()里每次都实时读取sys.windowWidth和sys.windowHeight就是为了避免缓存尺寸。如果应用会动态变化尺寸建议在onResize里重新调一次restorePosition()。5.3 问题速查表现象可能原因排查方向完全拖不动事件被父级 catch或 dragging 未置位检查.stop补touchcancel拖动时页面跟着滚缺.stop或组件在 scroll-view 内加.stop.prevent调整层级球比手指慢半拍未节流或用了 left/top 位移加 16ms 节流改 transform点击无反应位移阈值过小被判定成拖动调大moveThreshold拖到屏幕外回不来缺边界 clamp补Math.min/max约束被视频/地图盖住小程序原生组件层级最高条件编译改用 cover-view首屏出现滑动动画挂载时 transition 未关闭初始animating置为 false切页面后位置变了用了绝对像素存储改成比例存储这张表基本覆盖了我这两年在这个功能上遇到的所有问题遇到新情况可以往上加。6. 进阶状态管理、路由联动与性能收尾6.1 用全局状态控制显隐实际项目里悬浮球往往不是永远显示。比如登录页不显示、支付页不显示、某些活动页要换一个样式。最笨的办法是每个页面手动传v-if聪明的做法是用全局状态。uniapp 里可以用VuexVue2或者PiniaVue3也可以自己写一个极简的全局状态模块// store/floatBall.js import { reactive } from vue export const floatBallState reactive({ visible: true, icon: , text: 客服, handler: null }) export function showFloatBall(options {}) { floatBallState.visible true if (options.text) floatBallState.text options.text if (options.icon) floatBallState.icon options.icon if (options.handler) floatBallState.handler options.handler } export function hideFloatBall() { floatBallState.visible false }组件里读floatBallState.visible决定是否渲染页面onShow时调用showFloatBall()onHide时按需隐藏。这样一来控制逻辑集中在状态模块里页面只负责声明我现在要不要球。需要提醒的是位置缓存不要跟着显隐一起清。用户把球拖到某个位置切到登录页球隐藏了回来之后位置应该还在。所以hideFloatBall只改visible不动 storage。6.2 和路由栈联动的一个小技巧如果页面数量特别多每个页面加一行标签也嫌烦可以用路由拦截自动判断。uni.addInterceptor可以拦截navigateTo、switchTab这些跳转方法在里面读目标路径决定要不要给目标页面带上悬浮球参数。不过 uniapp 里页面组件挂载是编译期决定的拦截器只能控制显示什么状态没法动态往页面模板里插节点。所以这个技巧的实际用途是统一控制显隐策略而不是省掉那一行标签。真正能省掉标签的方式只有自定义 layout 或者原生子窗体前面已经分析过代价了。我的建议是页面数量在三十个以内老老实实每页加一行超过三十个再考虑 layout 方案而且要在项目初期就定下来中途改会造成大面积冲突。6.3 内存与性能的最后一点收尾组件里有几个地方需要清理不然长时间运行会有隐患。setTimeout返回的定时器要在组件卸载前清掉虽然我们只用它关一个过渡状态但页面频繁切换时累积起来不是小事。emit出去的drag-end事件如果绑了比较重的逻辑记得在页面onHide时解绑或者加状态判断。还有一个细节是uni.getStorageSync的调用频率。我在touchend里调一次频率很低没问题。但如果有人把它放到touchmove里做实时保存那就是灾难——同步读写本地存储是阻塞操作会直接把拖动卡死。这一点务必避免。最后说说视觉上的收尾。悬浮球加一个轻微的按下缩放效果手感会好很多用 CSS 的:active或者状态类都能实现。但注意不要在拖动过程中加缩放会让人感觉球在乱跳只在点击的瞬间做一次 0.94 的缩放就够了。这种小细节用户说不清但会直接影响这个东西做得细不细的判断。我个人在这个功能上最大的体会是真正难的不是拖动本身而是边界情况的处理。拖动逻辑二十分钟能写完但边界约束、点击判定、位置记忆、跨端差异这几块加起来占了八成的工作量。所以每次我在别的项目里复用这份组件第一件事就是把这几个边界条件重新过一遍确认没有遗漏。另外分享一个我常用的调试小技巧在开发阶段给球加一个半透明的调试边框把left、top的实时值用文字打在球旁边拖动的时候一眼就能看出坐标是不是在合理范围内。上线前用条件编译把这块去掉几行代码的事能省掉大量看起来没动但其实动了零点几像素的排查时间。