恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
安卓老虎机动画实战:自定义View滚动、对齐与性能优化
首页
资讯中心
/
安卓老虎机动画实战:自定义View滚动、对齐与性能优化
安卓老虎机动画实战:自定义View滚动、对齐与性能优化
发布时间:2026/10/14 16:44:11
简介一套面向安卓开发者的老虎机动画效果案例资源通过自定义控件WheelView展示滚动滚轮、随机停止及平滑旋转的交互实现覆盖自定义View绘制、属性动画调节与触摸事件处理适合需要掌握自定义视图、动画机制和屏幕适配技巧的中级开发者。资源内含完整Android工程以WheelView为切入点演示ObjectAnimator/ValueAnimator控制旋转角度与时间函数同时给出基于pt单位的适配策略解决不同分辨率下控件大小与视觉一致性的问题。压缩包共1887个文件以flat、xml、java、class及dex等为主包含源码、绘制资源与编译产物整体大小仅15.62MB便于直接导入工程分析与调试。已有1039人学习下载可从中获取动画设计思路、缓存与硬件加速优化手段以及滑动速度检测、松手停止等交互细节有效提升安卓UI交互开发能力。1. 为什么安卓老虎机动画效果是抽奖场景里最难做“耐看”的东西用户在积分抽奖页面上最紧张也最兴奋的瞬间是几列数字或图标先猛地冲出去、高速翻滚一阵、再一列一列停下来的那几秒。安卓老虎机动画效果做到位用户会觉得“这轮子真的转了挺久”做到位差第一眼炫、第二眼假甚至停在两格中间整场抽奖的紧张感瞬间塌掉。这里面的技术内核其实不复杂一组可以无限循环的内容配合一个随时间变化的滚动偏移量再加上对停止时机的精确控制。真正难的是把“动画结果早在滚动开始之前就已经定好”这件事藏住同时让每一帧的手感都对。这篇文章按我实际落地的顺序来写先拆动画原理再讲自定义 View 的绘制和运动曲线然后是随机结果与多列编排最后是高频踩坑和验证方法。新手可以跟着从单列滚动跑起来熟手可以直接拿到曲线参数、对齐公式和几类典型事故的解法。2. 拆开原理滚动、绘制、停止一条流水线怎么骗过眼睛2.1 老虎机动画就是三件事内容循环、偏移更新、停止对齐把效果拆到底你会发现所有老虎机风格动画都只有三个自由度内容序列、滚动偏移量、时间函数。内容序列通常就是 0~9 十个数字或者十来个业务图标滚动偏移量决定当前这一帧“第几格出现在哪个位置”时间函数决定偏移量随时间怎么变化也就是加速和减速曲线。很多人在这里犯的第一个错是把这三件事混成一件去实现。比如直接用 RecyclerView 滚一个长列表来冒充老虎机结果列表要无限循环、要精确停到指定格、要在停止时完成格子对齐这三个需求没有一个是 RecyclerView 设计时优先考虑的最后代码里到处都是补丁。其实每帧要做的事只有两步根据偏移量算出当前应该画哪几个元素然后用画布把它们画在对应位置。所谓“滚动”只是偏移量在连续变化并没有真的在搬运长列表。2.2 为什么我通常不选 RecyclerView而是直接自定义 ViewRecyclerView 是为“有限条目、用户手势滚动、回收复用”设计的。老虎机动画恰好相反滚动完全由程序控制速度要可变速、停止位置要精确到像素、视觉上还要能无限循环。用 RecyclerView 做第一问题出现在高速滚动时它要不断 bind、layout 可见 item三个卷轴并排时主线程绑定压力很大中低端机上必掉帧。第二问题是对齐滚动停止后要让某个 item 的中心正好压在卷轴中线上你得去算每个 item 的边界、snap 逻辑、滚动偏移补偿写了一堆代码结果碰到 GridLayoutManager 还不好控制。第三问题是想在滚动过程中给不同格加高亮、放大、透明度变化RecyclerView 的 item 层级反而碍事。所以我的常规做法是单列内容少、可视区窄的场景直接自定义 View 绘制只有列数特别多、每格内容还很复杂时才去考虑 SurfaceView 独立绘制线程。自绘看起来像是“绕了远路”实际代码量更小因为你要控制的变量只有 offsetY 一个。2.3 动画方案横评自绘、Lottie、GIF、SurfaceView 各适合什么方案实现成本停止位置控制高帧率滚动手感适用场景自定义 View ValueAnimator中完全可控中高取决于绘制量3~6 列、内容是文字或简单图标最常见Lottie / 动效文件低几乎不可控高动画文件内部处理一次性的演示动画不需要接业务结果GIF / 视频低不可控受帧率影响埋点展示位不做交互不推荐SurfaceView 线程高完全可控最高列数很多、每格带实时模糊或高光性能要求极高实际项目里 90% 的“老虎机动画效果”需求用第一行就能覆盖。Lottie 的问题不是画不出来而是它无法回答“用户抽中的是 37第四列应该停在哪个图标上”这个问题。动效文件再漂亮接业务结果时还是得回到代码层控制绕了一圈不值当。3. 从零手写单列滚动绘制代码和运动曲线都是可抄的3.1 单列滚动的核心绘制代码越短越好定义 View 时我习惯把所有状态都放在同一个类里内容列表、偏移量、动画引用。下面这段是单列滚动最核心的部分可以直接抄进项目里跑通。// SlotRollView.java —— 一个极简且完整的单列滚动 View public class SlotRollView extends View { private final Paint paint new Paint(Paint.ANTI_ALIAS_FLAG); private final ListString symbols new ArrayList(); private float itemHeight; // 每格高度用 dp 转 px private float offsetY 0f; // 正向偏移表示内容向上滚动 public SlotRollView(Context context, AttributeSet attrs) { super(context, attrs); itemHeight dp(56); // 每格 56dp视觉上比较接近卡片宽度 paint.setTextSize(sp(24)); paint.setColor(Color.parseColor(#262626)); paint.setTypeface(Typeface.create(sans-serif-medium, Typeface.BOLD)); for (int i 0; i 9; i) symbols.add(String.valueOf(i)); } Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); int total symbols.size(); if (total 0) return; // 给偏移量取模保证无论滚多少圈都不产生大数浮点误差 float cycle total * itemHeight; float off offsetY % cycle; if (off 0) off cycle; int baseIndex (int) (off / itemHeight); float startOffset off - baseIndex * itemHeight; Paint.FontMetrics fm paint.getFontMetrics(); float textBase itemHeight / 2f - (fm.ascent fm.descent) / 2f; // 可视区只需要画 n2 格画满整页反而是浪费 int need (int) (getHeight() / itemHeight) 2; for (int i 0; i need; i) { int idx (baseIndex i) % total; float top i * itemHeight - startOffset; if (top getHeight() || top itemHeight 0) continue; canvas.drawText(symbols.get(idx), getWidth() / 2f, top textBase, paint); } } private float dp(float v) { return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, v, getResources().getDisplayMetrics()); } private float sp(float v) { return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, v, getResources().getDisplayMetrics()); } }这段代码最关键的是取模那一步。offsetY只做累加不做清理时间久了会变成几百万像素的浮点数精度开始丢失就是后面踩坑章节会提到的“滚久了错半格”的前兆。绘制时把偏移量限制在一个循环周期内再反推 baseIndex就彻底解掉了这个问题。文字基线用fm.ascent fm.descent取中比直接减一半高度更稳不同字体下都不会偏。3.2 加速与减速让滚动有“先冲出去、高速跑一段、再被按住”的手感直线匀速滚动看起来非常像“后台数据在播放”完全没有老虎机该有的随机感。我常用的曲线是分三段前 15% 加速冲出、中段高速匀速、最后 25% 明显减速直到停住。下面这段是实际可用的插值器已经去掉了速度断层的坑。// 自定义时间插值器返回值是 0~1 的“路程比例” ValueAnimator anim ValueAnimator.ofFloat(0f, 1f); anim.setDuration(2800); anim.setInterpolator(new TimeInterpolator() { Override public float getInterpolation(float t) { // 前 0.2 为加速段速度从 0 线性升到某个值 // 0.2~0.7 匀速段0.7~1.0 减速段 float logical; if (t 0.2f) { logical 10f * t * t; // 加速 } else if (t 0.7f) { logical 4f * t - 0.4f; // 匀速 } else { float u (t - 0.7f) / 0.3f; logical 2.4f 4f * u - 2f * u * u; // 减速到零 } return logical / 4.4f; } }); anim.addUpdateListener(a - { float f (float) a.getAnimatedValue(); offsetY startOffset distancePx * f; invalidate(); });这里的logical是一个“虚拟路程”最后统一除 4.4 归一化保证 1 秒结束时空格正好走完需要走的距离。三个参数是手感的关键加速段时间越短“冲出去”的爆发感越强但太短会像被踢了一脚匀速段占 50%~60%占比少了脖子看着疲惫多了用户失去期待减速段最后 20%太短就是“突然刹死”太长就有犹豫感这和列与列之间的停止节奏还不一样单独调。3.3 单列跑通后多列不是复制三遍而是要管理三个独立状态多列最简单的做法是用三个 SlotRollView 放在 LinearLayout 里每列一个独立动画。刚开始确实省事但做到停止错峰时就不好办你想让第二列晚 180ms 启动动画它得知道当前列此刻的偏移量还要保证三列最后每个结果都是服务端返回的。我的习惯是在单 View 里维护ListColumnData每列有自己的偏移量和动画引用这样绘制一次循环就能把所有列画完停止时机也用一个列表统一管理。// 伪代码多列场景的结构 // 每列独立保存 offset 和动画状态 class ColumnState { float offsetY; ValueAnimator animator; ListString items; }改为多列后onDraw里外层循环遍历每一列内层循环画该列的元素列号决定 x 坐标。这种结构下每列停止后的回调可以精确到“某一列停在第几个 item”业务层拿到这个回调再触发下一列的动画就能做出一列一列停下来的效果。4. 随机结果与停止位置结果先定死动画只是反推4.1 先决结果再滚动运气发生在动画外动画只负责表演老虎机动画最忌讳的做法是“动画先跑停下来时再取随机数”因为停止瞬间的撞击感、对齐偏移、甚至轻震动都必须在真正停下之前安排好。更有问题的做法是让客户端自己生成随机结果如果这个动画后面接的是积分结算、库存扣减就会出现“动画停的是 A服务端结算的是 B”的翻车现场。正确的业务顺序是用户在服务端完成抽奖拿到一个明确结果比如第三列停在“7”然后前端根据这个 7 反推出需要滚动多少像素、多少圈数再启动动画。动画过程中不管哪一帧被用户打断或系统回收结果都不会变因为结果早就被定死了。演示模式下才允许本地 Random但也建议用固定流程走一遍养成习惯后不会在接后端时漏改。4.2 从结果索引反推滚动距离必须按“中线对齐”计算停止对齐是老虎机动画最容易出偏差的地方很多“停下来差半格”的 bug 都来自没有用中线格子参与计算。下面是正确的反推思路。public void rollTo(int resultIndex, int extraLoops, long durationMs) { if (isRolling) return; int total symbols.size(); float cycle total * itemHeight; float startOffset offsetY; // 归一化当前偏移得到顶部格子下标 float normalized startOffset - (float) Math.floor(startOffset / cycle) * cycle; int baseIndex (int) (normalized / itemHeight) % total; // 计算当前中线位置的下标以及和目标下标的差 int halfIdx (int) (getHeight() / 2f / itemHeight); int currentCenter (baseIndex halfIdx) % total; int distance (resultIndex - currentCenter total) % total extraLoops * total; float endOffset startOffset distance * itemHeight; // 动画结束后强制归位避免浮点误差停在半格 animator ValueAnimator.ofFloat(0f, 1f).setDuration(durationMs); animator.setInterpolator(new SlotInterpolator()); animator.addUpdateListener(a - { offsetY startOffset (endOffset - startOffset) * (float) a.getAnimatedValue(); invalidate(); }); animator.addListener(new AnimatorListenerAdapter() { Override public void onAnimationEnd(Animator animation) { offsetY endOffset; invalidate(); } }); animator.start(); }这里有两个要点。第一distance的结果要加extraLoops * total目的是保证动画至少转三圈否则如果用户抽中的恰好就是当前中间格distance 会等于 0画面直接不动用户体验断裂。第二动画结束监听里强制offsetY endOffset这一步相当于给浮点误差兜底。ValueAnimator 最后一帧的返回值可能比目标偏移小 0.0 几像素不加这行视觉上有时会停在两格之间。4.3 多列停止节奏不是同时停而是“犹豫式错峰停”老虎机效果让人上头的关键从来不是单列停得多准而是“第一列停了第二列还在跑第三列像是不想认命”的那种节奏。第一列停在最终结果不代表另外两列已经注定它们还在滚用户会抱着“也许后面会变”的心态看下去这时第二列、第三列才依次揭晓。我的参数基准是列间隔 160~220ms总动画时长控制在 2600~3200ms 之间。三列场景里第一列 0ms 启动动画即可第二列延迟 180ms第三列延迟 360ms 左右。每一列启动前先读到当前时间戳并计算出剩余路程避免用“固定延时”导致第二列都停了第三列还在跑很久。列序启动延迟目标停格节奏作用第一列0ms结果最终值确立“结果感”先打消一半猜疑第二列160~220ms结果最终值制造“还有变动”的错觉第三列320~440ms结果最终值拉满最后停顿的紧张感同时每列停止时播放短促的“咔嗒”音用 SoundPool 而不是 MediaPlayer后者加载和延迟都太明显轻震动用 40~60ms 的波形即可太长会让人感觉手机坏了。5. 避坑排查滚动卡顿、残影、停止错位和点击误触5.1 高速滚动时画面残影和撕裂现象滚动速度起来后卷轴两侧出现淡淡的重影某些系统上还会看到内容“错开半截”。原因绘制时 Canvas 没有裁剪到固定区域或者 View 被设置了半透明背景导致系统对多个图层做混合运算GPU 资源被耗尽另一个高发原因是每帧 invalidate 了整栏数据结构系统在每帧都重新生成硬件 Layer重影就出来了。解决给整个卷轴容器设置setLayerType(View.LAYER_TYPE_HARDWARE, null)并在onDraw开头用canvas.clipRect(0, 0, getWidth(), getHeight())强制裁剪。如果内容只是数字和文字别给文字加阴影阴影才是残影的最大来源。5.2 动画长时间运行后开始卡顿掉帧现象刚开始几圈很流畅跑到第 15 次或第 20 次时明显掉帧进入动画后半段更明显。原因最常见的是每格符号用的是大图资源每帧drawBitmap时都在做缩放采样GPU 每帧都要重新生成缩场纹理第二常见的是onDraw里做字符串拼接或者 Log 输出瞬间拉高主线程耗时。解决把图标资源提前用BitmapFactory按屏幕像素采样一次存成Bitmap缓存绘制时直接 draw 缓存对象。另外给 SlotRollView 设置setWillNotDraw(false)的默认值是无关的真正有效的是保证 Canvas 绘制区域不超过 View 尺寸别给整页动画 key 打 Layered type。5.3 停止时总停在格线中间差那么半格现象动画结束后目标格子没有对齐中线视觉上明显偏上半格或下半格。原因这类问题九成不是动画曲线的问题而是结束偏移的计算基准错了。常见错法是拿“第一个可见 item”当结果 item忘记把中线换算进公式另一类是 ValueAnimator 结束后没有强制赋予最终值最后一帧的浮点值偏小几千分之一像素叠加上 itemHeight 比较小时就肉眼可见。解决按上面 4.2 的逻辑先算出当前中线下标再反推目标偏移。动画结束回调里强制offsetY endOffset这行几乎是“后悔药”后续不会再看到停在格线中间的情况。5.4 滚动未结束用户又点了抽奖结果和动画全乱现象滚动过程中用户连续点击抽奖按钮动画被反复打断最终状态有时是 A 列停到了 B 结果有时是三列同时停。原因没有做动画状态锁按钮没有在滚动期间禁用动画对象也没有做cancelRoll()逻辑旧动画还在更新偏移量新动画又写了同一个字段。解决用一个实例变量isRolling做守护启动动画时置 true动画真正结束回调里置 false。期间按钮监听器对isRolling做检查isRollingtrue时直接 return。如果有“重抽”需求不要直接调动画方法而是先cancel()之后把 offset 取整归位再计算新目标值。5.5 滚了很多局之后偏移量累积出精度错位现象单列 10 个数字每格 56dp跑了上百次滚动画后每次停止位置都会慢慢偏离最终停到两格之间。原因offsetY 持续累加long-running 后位移值可能会达到数百万像素。float 精度在 2^24 左右开始丢精度正好是几百万像素这个量级这时候误差开始大于一问格的几分之一。解决每次动画结束后把 offsetY 取模到一个完整周期内再赋回去。// 动画结束后做一次“归零”防止 float 精度丢失形成累计误差 float cycle symbols.size() * itemHeight; offsetY offsetY % cycle; if (offsetY 0) offsetY cycle;这个操作等价于“把内容重新复位后再接着滚”用户视觉上完全无感但对稳定停止位置非常重要。加上这一步之后连续滚 200 次也几乎不会出现第二次半格问题。6. 高阶优化用帧耗时做验证结束手感里的最后一把火6.1 用 Choreographer 做一帧通用测试比肉眼看靠谱动效调完别急着交工写一个极简的帧计时器连续跑 50 次动画统计掉帧次数。我在改任何曲线参数之前都会先做一次这个测试拿数据说话凭感觉调参最后会在多机型上翻车。// 一次简单的帧耗时监听 final long[] lastTime {0}; Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() { Override public void doFrame(long frameTimeNanos) { if (lastTime[0] ! 0) { long costMs (frameTimeNanos - lastTime[0]) / 1_000_000; if (costMs 18) { // 超过 18ms 就算掉帧帧连续三次就该回去看绘制 Log.d(SlotAnim, drop frame: costMs); } } lastTime[0] frameTimeNanos; Choreographer.getInstance().postFrameCallback(this); } });断帧超过 3 次/秒时一般问题都出在绘制层先检查上面 5.2 提到的纹理和阴影只有个位数掉帧时说明动画曲线或者回调节奏需要微调不一定要动重绘代码。6.2 交付前跑一遍完整验证清单我的习惯是每次客户端迭代动效相关代码都缩到三个真机上跑机型组合固定一台低端、一台中端、一台高刷新率。清单常年就这几项检查项通过标准连续 50 次滚动后停止位偏差不超过 1dp动画期间快速点抽奖按钮按钮失效或出现“再来一次”确认不打断动画切后台再回来动画状态可恢复不出现空白格开发者选项关闭动画时不崩溃结果仍能正确显示最后一列“关闭动画”是个容易被忽略的坑系统把 Animator 时长缩放到 0 时ValueAnimator 会直接跳到结束回调此时必须确保停止位的对齐逻辑还在 onAnimationEnd 里执行而不是只依赖动画监听。我现在的习惯是任何一次动画参数调整都先跑一遍帧计时再改一键参数然后再测一次。动效这件事没有一次调对的数据才是唯一靠谱的校准工具。希望这些从绘制到对齐再到排错的实践经验能帮你少走我当初绕过的几段弯路。祝复现顺利不祝你这个项目一次跑通希望帮到你。本文还有配套的精品资源点击获取