资讯动态

安卓老虎机动画从零实现:帧级状态机与SurfaceView自绘实战

发布时间:2026/10/13 8:33:16 来源:尧图企业网站定制
简介面向安卓开发者的老虎机动画效果实战工程重点演示自定义视图与滚动动画的完整实现思路。项目以自绘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想要完全可控的「高速—匀速—缓停」三段节奏就上贝塞尔曲线插值器。下表可以直接抄场景插值器关键参数效果默认减速停止DecelerateInterpolatorfactor 2.0前快后慢尾段约 0.2 秒滑入机械齿轮回弹OvershootInterpolatortension 1.2 ~ 1.5停稳后反弹 6 ~ 10px 再归位抽奖卡轮节奏贝塞尔插值器控制点 (0.2, 1.0) (0.6, 1.0)中段保持高速末尾快速收口严格三段式自定义插值器分段速度函数生产环境最常用的方案DecelerateInterpolator 的 factor 不是越大越慢而是控制减速曲线的陡峭程度。factor1.0 时接近线性衰减factor2.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 用于把速度映射到 080 的透明度区间。注意残影绘制必须放在格子主体之前否则会把本体盖住。另有一种更轻量级的方案是画一条速度条——卷轴侧边一条渐变短线长度随速度变化成本低但直观有效适合风格偏简洁的产品。4.2 景深与遮罩上下渐隐盖住硬切边自绘卷轴最容易被看出破绽的地方是上下边界。格子滚出可视区时是硬切边一眼就能看出是个矩形窗口。我一般会在画完格子之后再叠两层从上到下的渐变色块颜色与背景一致从透明过渡到不透明把硬切边融进背景里。这里有一个层级顺序坑遮罩必须在格子之后画但高亮框和选中标记必须在遮罩之后画否则遮罩会把中线高亮也盖掉。层级顺序是背景 → 格子 → 遮罩 → 高亮框 → 前景装饰。如果发现遮罩把中线压暗了查一下是不是把高亮框画到了遮罩下面。4.3 高亮格与发光把玩家视线锁在停靠点停靠点是整个动画的视觉中心所以高亮框要做得足够明确。我习惯用一个独立的 indicator 变量表示高亮框的中心 Y 坐标每帧按滚动方向平滑过渡而不是直接等于中线。这样高亮框在滚动过程中会有轻微的「跟随感」比死死钉在中线上更耐看。发光效果不要每帧用模糊滤镜那玩意儿开销极高且早已被官方标记废弃。更常用的做法是预渲染一张圆角渐变的半透明位图停靠时直接叠在高亮框上视觉上接近发光实际成本不到 1ms。对比一下每帧实时模糊大约 35ms预渲染位图叠加不到 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 对象是最隐蔽的掉帧源现象滚动时掉帧集中在开始后 300msLogcat 里能看到频繁的 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 再归零。这样能模拟出机械齿轮卡位后的真实回弹幅度小、不扎眼但手感明显更「实」——这也是老虎机动画区别于普通列表滚动最微妙的一层质感。我每次交付这类动画都会把慢速调试模式保留下来看起来慢动作很傻但在调参和验收时真香。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑