简介:面向安卓开发者的老虎机动画效果实战工程,重点演示自定义视图与滚动动画的完整实现思路。项目以自绘WheelView控件模拟老虎机滚轮,利用ObjectAnimator、ValueAnimator等动画API实现平滑旋转与定点停止,并采用pt单位替代传统dp进行多屏适配,兼顾自定义测量策略与布局管理器;同时覆盖缓存策略、硬件加速开关、滑动速度检测与松手停止等交互细节,适合进阶学习控件定制与体验优化。资源共1887个文件,以flat、xml、png等资源文件、dex、class、jar等构建产物,以及java、gradle等工程源码与脚本为主,其中flat为编译后的资源文件、java为业务源码、jar为依赖库,压缩包仅15.62MB。已有1039人学习/下载,可从中获取完整项目结构、源码实现与适配方案,便于快速迁移到抽奖、游戏或娱乐互动模块,整体结构清晰,适合直接导入开发环境阅读。
1. 安卓老虎机动画效果:不是做游戏,是在做一台帧级状态机
「安卓老虎机动画效果」这六个字,乍一听像是一个游戏 Demo,实际上它是安卓开发里最典型的一类帧级动画需求:一串数字或图标在窗口里高速滚动、越滚越慢,最后精确停在一个指定格子上。这个效果在直播抽奖、电商大转盘、休闲游戏里天天都能见到,可很多团队一上来就用属性动画平移整块布局,结果要么停不在格子中线上,要么滚起来像放 PPT。
反直觉的结论是:老虎机卷轴的核心不是动画函数,而是一台帧级状态机。滚动只是状态切换的外壳,停靠对齐、速度曲线、循环取模才是真正需要花心思的地方。这个方向适合两类人:一类是做直播、电商、游戏业务的客户端开发,另一类是刚把自定义 View 画明白、想系统补一补按帧驱动绘制能力的安卓工程师。看完这篇文章,你能从零搭出一个可复用的卷轴渲染框架,并避开我踩过的绝大部分坑。
2. 选型与原理:为什么系统动画不适合直接做卷轴,自绘的三层结构
2.1 系统动画方案的边界:属性动画、RecyclerView 为什么翻车
先把最容易踩的弯路说清楚。直接拿 ObjectAnimator 平移一个 LinearLayout,滚动效果确实能出,但翻车点非常集中:动画的终点是「坐标」,而老虎机要的是「哪一格」。你必须在动画开始前把所有格子的 Y 坐标都算出来,再手动对齐,中间只要有一格高度带小数,停靠就会产生半格误差。这个误差用眼睛看不太明显,但截图逐帧对比时特别扎眼,观感就是「差点意思」。
RecyclerView 是第二条弯路。它确实复用了 Item 的 View 回收机制,滑动也顺滑,但停靠只能借助 SnapHelper。LinearSnapHelper 解决的是线性对齐,PagerSnapHelper 解决的是整页翻动,它们都无法表达「先高速、再减速、停稳后带一点机械回弹」的老虎机手感。更麻烦的是,你很难让 RecyclerView 在一段指定的滚动距离后精准停在第 N 格,又不能中途插入「卡轮」这种停顿。
所以常见的做法是放弃系统动画容器,直接自绘。自绘的优势不是画面更炫,而是每一帧的 offset、速度、停靠状态都由你控制,声音、震动、高亮框全部可以跟帧走。代价是线程模型和生命周期要自己管,这正是后文要展开的部分。
2.2 自绘卷轴的核心循环:测量、绘制、逻辑帧三分离
我一般会把自绘卷轴拆成三层,分别叫测量层、绘制层、逻辑层。测量层只算一次:已知格子高度 itemHeight、可见格数 visibleCount,那么可视区域高度就是 itemHeight × visibleCount,再加上上下遮罩的高度。这个值在 onMeasure 里算好,后面所有绘制都基于它,不要每帧重新测量。
绘制层只做一件事:根据当前滚动偏移 offset,计算出这一帧应该画哪些格子、每个格子画在哪个 Y 坐标,然后画上去。绘制层不推进 offset,也不判断是否该停,它只是渲染工。
逻辑层才是动画的脑子。它在每一帧里做三件事:根据速度和上一帧的时间差累加 offset;判断 offset 是否越界,越界就用取模把它拉回循环区间;检查是否到达停靠距离,到了就切状态、触发回调。把这三件事分开的好处是,逻辑层可以放到子线程或者 Choreographer 回调里,绘制层只负责消费 offset,两者解耦后不容易出现「绘制卡一下,逻辑也跟着停」的连带问题。
2.3 用 SurfaceView 搭一个最小卷轴 Demo:从 setContentView 到第一帧
下面给一个能直接跑通的最小实现,用的是 SurfaceView 加独立渲染线程。注意 SurfaceView 的绘制不走 onDraw,而是通过 lockCanvas 拿到画布,画完再 unlockCanvasAndPost 提交。代码里的注释标明了每个部分的作用。
class SlotSurfaceView(context: Context) : SurfaceView(context), SurfaceHolder.Callback { private var offset = 0f // 当前滚动偏移,单位:像素 private var speed = 0f // 当前速度,单位:像素/秒 private var running = false private var lastFrameNanos = 0L private val itemHeight = 120f // 单格高度,dp 转 px 后赋值 override fun surfaceCreated(holder: SurfaceHolder) { running = true lastFrameNanos = System.nanoTime() thread { renderLoop() } } override fun surfaceDestroyed(holder: SurfaceHolder) { running = false } private fun renderLoop() { while (running) { val now = System.nanoTime() val delta = (now - lastFrameNanos) / 1_000_000_000f // 秒 lastFrameNanos = now update(delta) // 逻辑层:推进 offset 并做循环 drawFrame() // 绘制层:按 offset 渲染格子 } } private fun update(delta: Float) { offset += speed * delta val totalHeight = itemHeight * 8 // 假设一共 8 个格子循环 if (offset >= totalHeight) offset -= totalHeight if (offset < 0f) offset += totalHeight } private fun drawFrame() { val canvas = holder.lockCanvas() ?: return canvas.drawColor(Color.DKGRAY) // 清屏,避免残影叠加 for (i in 0..4) { // 画 5 个可见格子 val y = i * itemHeight - offset val text = "${(i + 1) % 8}" canvas.drawText(text, 100f, y + itemHeight * 0.7f, textPaint) } holder.unlockCanvasAndPost(canvas) } }这段代码有个关键点:循环里不要用 Thread.sleep(16) 来限帧。sleep 只能保证不小于 16ms,设备刷新率不同时速度会忽快忽慢。正确做法是用 delta 做时间差累加,让 speed × delta 在任何刷新率下都得到一致的像素位移。实际项目中我还会在 surfaceChanged 里拿到屏幕密度,把 itemHeight 从 dp 换算成 px,避免不同分辨率下格子高不一样。
3. 把停靠做成「机械感」:对齐算法与插值器选择
3.1 停靠为什么不能直接定点:从「速度归零」到「逐格对齐」的距离预算
很多第一次写老虎机的人会这么做:滚动过程中不断判断当前格子编号,编号一到目标就立刻把速度清零。结果动画像被急刹车,而且大概率停在两格中间——因为停止时 offset 的余数不为零。正确做法是先算出从当前位置到目标格还要滚多远,再把这个距离交给动画系统去消费,动画结束时 offset 必须精确等于目标格的顶部坐标。
我一般会用 rollTo(targetIndex) 来做距离预算。核心逻辑是先算格子编号差,再用取模修正方向,保证动画走最短路径;最后把当前 offset 的余数也扣掉,避免卡在两格中间时终点偏半格:
fun rollTo(targetIndex: Int, totalCount: Int): Long { val currentIndex = (offset / itemHeight).toInt() var diff = targetIndex - currentIndex // 取模修正:让动画朝最近的方向滚,而不是傻傻绕一整圈 diff = ((diff % totalCount) + totalCount) % totalCount if (diff > totalCount / 2) diff -= totalCount // 最终距离 = 格子差 × 格子高 - 当前未滚完的余数 val distance = diff * itemHeight - (offset % itemHeight) return buildAnimation(distance) } private fun buildAnimation(distance: Float): Long { val duration = (abs(distance) / targetSpeed * 1000).toLong() startIndex = offset endIndex = offset + distance animatorStartNanos = System.nanoTime() return duration }参数说明:diff 是带符号的格子数,正数表示往下滚,负数表示往上滚;distance 算出来后可能是负数,buildAnimation 里用 abs 转成时长。targetSpeed 是期望的末段速度,单位是像素/秒,一般取 0.5 格到 1 格每秒,太小会显得拖沓,太大则缺少「滑入感」。
3.2 插值器参数:Decelerate、Overshoot 和自定义贝塞尔怎么选
距离算出来之后,滚动曲线决定手感。我的经验是分三种情况:普通停止用 DecelerateInterpolator,想要机械回弹用 OvershootInterpolator,想要完全可控的「高速—匀速—缓停」三段节奏就上贝塞尔曲线插值器。下表可以直接抄:
| 场景 | 插值器 | 关键参数 | 效果 |
|---|---|---|---|
| 默认减速停止 | DecelerateInterpolator | factor = 2.0 | 前快后慢,尾段约 0.2 秒滑入 |
| 机械齿轮回弹 | OvershootInterpolator | tension = 1.2 ~ 1.5 | 停稳后反弹 6 ~ 10px 再归位 |
| 抽奖卡轮节奏 | 贝塞尔插值器 | 控制点 (0.2, 1.0) (0.6, 1.0) | 中段保持高速,末尾快速收口 |
| 严格三段式 | 自定义插值器 | 分段速度函数 | 生产环境最常用的方案 |
DecelerateInterpolator 的 factor 不是越大越慢,而是控制减速曲线的陡峭程度。factor=1.0 时接近线性衰减,factor=2.0 时尾段明显更黏,视觉上就是最后一格慢慢滑进去。OvershootInterpolator 的 tension 超过 2.0 时回弹会显得卡通,做老虎机这种机械感场景,我一般压在 1.5 以内。
真正要抠的细节是「末段速度」。不管选哪个插值器,停靠前最后 200ms 的像素位移最好控制在半格以内。如果发现最后 200ms 还在以每帧 1/3 格的速度滑行,那就说明 duration 算短了,或者插值器在末段的斜率不够低。这里没有玄学,逐帧打印 offset 差值就能定位。
3.3 下注结果与动画的联动:先出结果再算距离,避免动画和结果打架
老虎机动画和普通列表滚动最大的不同是:结局不是随机出来的,而是先有结果、再演过程。因此我会把业务逻辑和动画逻辑彻底分开:点击开始后,先在逻辑层算出中奖格子索引,再把索引传给 rollTo() 生成动画。动画过程中不允许二次触发,否则会出现目标索引还没消费完、又被新目标覆盖的竞态问题。
实际操作里我会加一个 resultLock 标志位:动画未结束时,再次点击只做提示不启动新滚动;动画结束后通过回调把结果抛给 UI。时间上还有个容易被忽视的点:总时长不要少于 2.2 秒。玩家需要时间看清滚动过程,抽奖场景尤其明显——如果你的动画 0.8 秒就停了,站在运营视角看就是在浪费曝光机会。
4. 视觉增强:让卷轴看起来「快」和「深」的四个细节
4.1 模糊动态感:拖影与速度条的假象
真去给滚动中的格子做运动模糊是得不偿失的——单帧模糊可以,连续滚动时开销翻倍还不一定好看。更常见的做法是画残影:在高速滚动阶段,把当前格子的内容往上或往下多画几份,透明度递减,视觉上就有了一种「动态模糊」的假象。
private fun drawGhost(canvas: Canvas, baseY: Float, speed: Float) { if (speed < threshold) return val ghostCount = 3 val ghostAlpha = ((speed / maxSpeed) * 80).toInt().coerceIn(0, 80) for (i in 1..ghostCount) { ghostPaint.alpha = ghostAlpha / (i + 1) // 残影沿运动方向的反方向偏移 canvas.drawBitmap(itemBitmap, x, baseY - i * itemHeight / 4f, ghostPaint) } }参数说明:threshold 是启用残影的速度阈值,太低会导致停靠时也有残影,产生毛刺;maxSpeed 用于把速度映射到 0~80 的透明度区间。注意残影绘制必须放在格子主体之前,否则会把本体盖住。另有一种更轻量级的方案是画一条速度条——卷轴侧边一条渐变短线,长度随速度变化,成本低但直观有效,适合风格偏简洁的产品。
4.2 景深与遮罩:上下渐隐盖住硬切边
自绘卷轴最容易被看出破绽的地方是上下边界。格子滚出可视区时是硬切边,一眼就能看出是个矩形窗口。我一般会在画完格子之后,再叠两层从上到下的渐变色块,颜色与背景一致,从透明过渡到不透明,把硬切边融进背景里。
这里有一个层级顺序坑:遮罩必须在格子之后画,但高亮框和选中标记必须在遮罩之后画,否则遮罩会把中线高亮也盖掉。层级顺序是:背景 → 格子 → 遮罩 → 高亮框 → 前景装饰。如果发现遮罩把中线压暗了,查一下是不是把高亮框画到了遮罩下面。
4.3 高亮格与发光:把玩家视线锁在停靠点
停靠点是整个动画的视觉中心,所以高亮框要做得足够明确。我习惯用一个独立的 indicator 变量表示高亮框的中心 Y 坐标,每帧按滚动方向平滑过渡,而不是直接等于中线。这样高亮框在滚动过程中会有轻微的「跟随感」,比死死钉在中线上更耐看。
发光效果不要每帧用模糊滤镜,那玩意儿开销极高且早已被官方标记废弃。更常用的做法是预渲染一张圆角渐变的半透明位图,停靠时直接叠在高亮框上,视觉上接近发光,实际成本不到 1ms。对比一下:每帧实时模糊大约 3~5ms,预渲染位图叠加不到 1ms,在低端机上就是能不能保住 60 帧的区别。
5. 避坑与排查:帧率、闪白、GC 与多线程的 5 个实战记录
5.1 卡顿与 ANR:在 UI 线程跑循环是第一个翻车点
现象:动画开始后界面卡死几秒,随后弹出系统无响应对话框。
原因:把整个滚动循环写在了自定义 View 的 onDraw 里,或者为了精确控帧在 UI 线程写了 while(true)。onDraw 是 UI 线程的绘制回调,在这里消耗几十毫秒就会卡死整个界面。
解决:SurfaceView 方案把循环放进独立线程;如果坚持用自定义 View,就用 Choreographer.postFrameCallback 驱动,不要在 onDraw 里 sleep 或做耗时计算。判断标准很简单:onDraw 方法里只允许出现绘制指令,任何速度计算、距离预算、音效触发都必须挪到逻辑层。
5.2 从后台切回来闪白或黑屏:Surface 生命周期错位
现象:App 切到后台再回来,卷轴区域闪白或黑屏,有时候还会一直黑着不能恢复。
原因:Surface 在后台时被系统销毁了,但渲染线程还在往旧 Surface 上画;或者重新创建后第一帧没画,直接以默认黑色呈现。
解决:在 surfaceDestroyed 里停掉渲染循环,在 surfaceCreated 里重建第一帧。不要在 surfaceChanged 里做耗时初始化。另外 lockCanvas 之后要判空,拿到 null 说明 surface 不可用,直接退帧。这个坑排查起来很费时间,因为手动复现需要反复切换前后台,我一般会写一个自动切换的测试脚本连续跑 50 次。
5.3 GC 抖动:每帧 new 对象是最隐蔽的掉帧源
现象:滚动时掉帧集中在开始后 300ms,Logcat 里能看到频繁的 GC 日志。
原因:在绘制循环里每帧 new Paint、Rect、Matrix,甚至 new Bitmap,导致 GC 频繁触发,绘制线程停顿明显。
解决:所有绘制对象在初始化时创建并复用。特别注意 TextPaint、LinearGradient、RectF 这类对象,初始化一次,绘制期只改参数。如果要用字符串拼接显示数字,把数字转成预格式化的缓存布局,不要在 onDraw 里重新测量。排查 GC 分配点可以用 Android Studio 的 Memory Profiler 按分配次数排序,前几名基本就是元凶。
5.4 快停与慢停的手感差异:距离和时长的换算关系
现象:距离短时停得很突兀,距离长时又拖沓,明明用的是同一个插值器。
原因:用固定时长配合减速插值器时,滚动距离不同会导致末段速度完全不同。距离长的动画在末段速度仍偏高,所以显得拖沓;距离短的动画末段速度偏低,所以显得突兀。
解决:用「先定末段速度,反推时长」而不是「先定时长」。也就是 3.1 里 buildAnimation 的做法:duration = distance / targetSpeed。targetSpeed 是末段期望速度,比如 1 格每秒,这样无论滚动距离是 3 格还是 8 格,最后 200ms 的滑入速度保持一致,手感就统一了。这类问题最容易让人觉得是玄学,其实打开帧耗时日志逐帧看 offset 差值就能验证。
5.5 声音与动画错位:音效触发要放在逻辑层而不是绘制层
现象:听感上音效比画面早,卡轮停顿还没出现,咔嗒声已经响完。
原因:音效被放在 onDraw 或 drawFrame 里触发,绘制线程每帧都执行,且 SurfaceView 的绘制和实际显示存在一帧延迟,声音自然比画面早。
解决:把音效触发挪到逻辑层,以跨格为事件:逻辑层里这次 offset / itemHeight 的整数部分比上次大 1,就触发一次「滚动咔嗒」;距离停靠格小于 1/3 格时触发第二段「停靠音」。另外音效播放要传入独立的 streamId,避免同一时刻多个重音叠加。
6. 把动画效果做得「能收口」:验证方法、命中策略与一个阻尼技巧
先说验证方法。我会在渲染线程里用 System.nanoTime 包住 drawFrame,每帧结束把耗时写进一个环形缓冲,每 60 帧打印一次平均值和掉帧数。这个日志在真机调试时非常有用,只看平均帧率会被骗,掉帧分布比平均值更能说明问题。同时给动画加一个长按进入 0.2x 慢速回放的口子,用 0.2 倍速跑完整条滚动,逐帧盯着「高亮框中心是否精确停在目标格中线上」,这是检查对齐误差最直接的办法。
命中策略上还有一个小优化:不要用 offset 对齐,而是用「格子中线」对齐。逻辑层里维护一个 highlightCenter = offset % itemHeight,当滚动停止时让这个值等于 itemHeight / 2,也就是格子中线恰好在可视区中线。这样即使 itemHeight 带小数,也不会出现目标格停稳后略偏半像素的尴尬。
最后分享一个阻尼技巧。纯粹用 OvershootInterpolator 做回弹时,回弹幅度和衰减不可控,我通常会在动画结束后手动加一小段阻尼振动:停靠完成后让 offset 朝反方向偏移 maxRebound(比如 6px),然后每帧乘以 0.6 衰减,直到小于 0.5px 再归零。这样能模拟出机械齿轮卡位后的真实回弹,幅度小、不扎眼,但手感明显更「实」——这也是老虎机动画区别于普通列表滚动最微妙的一层质感。我每次交付这类动画,都会把慢速调试模式保留下来,看起来慢动作很傻,但在调参和验收时真香。希望帮到你。
本文还有配套的精品资源,点击获取