ARTICLE DETAIL

资讯详情

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

Android Canvas游戏开发实战:从零复刻抖音潜艇游戏

Android Canvas游戏开发实战:从零复刻抖音潜艇游戏 1. 项目缘起一个非科班程序员的“不务正业”事情得从一个深夜刷抖音的瞬间说起。当时抖音上那个“潜艇大挑战”的小游戏正火玩法简单却上头控制一艘小潜艇在随机生成的管道障碍中穿梭点击屏幕上升松开则下降坚持得越久分数越高。作为一个半路出家的Android开发者我的第一反应不是“这游戏真好玩”而是“这东西我能不能自己做一个” 这个念头一旦冒出来就再也压不下去了。它不仅仅是一个兴趣使然的练手项目更成了我后来面试旅程中最硬核、也最生动的“作品集”。我大学学的是机械工程编程完全是靠网上教程和一本本“从入门到放弃”的书自学的。在准备转行找第一份Android开发工作的那段时间里我深知自己的简历在“教育背景”和“实习经历”上毫无优势。海投的简历大多石沉大海偶尔得到的面试机会也常常在“讲讲你做过最复杂的项目”这个问题上卡壳——之前照着教程做的“天气预报App”、“记事本App”实在太单薄了缺乏让人眼前一亮的深度和思考。于是“手撸一个抖音同款小游戏”的想法从一个消遣变成了一个战略目标。我告诉自己这个项目必须达到几个标准第一核心玩法要完整复现手感要接近第二不能只停留在UI模仿要深入游戏循环、碰撞检测、物理模拟等“里子”第三要能应对面试官从任何角度UI、性能、架构、扩展性的深挖。这个项目就是我为自己打造的一份能开口说话的“非科班”简历。2. 核心玩法实现从Canvas绘图到游戏循环“潜艇大挑战”的核心视觉呈现是一个持续向右滚动的场景潜艇位于屏幕左侧固定位置障碍物管道从右侧不断生成并向左移动。这决定了技术选型自定义View Canvas绘制是最直接、性能也相对可控的方案避免了过度复杂的游戏引擎引入。2.1 游戏世界的构建自定义View与绘制逻辑我创建了一个继承自View的GameView类并在onDraw(Canvas canvas)方法中完成所有元素的绘制。class GameView(context: Context, attrs: AttributeSet) : View(context, attrs) { private var submarine: Submarine // 潜艇对象 private val obstacles mutableListOfObstacle() // 障碍物列表 private val paint Paint().apply { isAntiAlias true } // 画笔 private var gameThread: Thread? null private var isRunning false override fun onDraw(canvas: Canvas) { super.onDraw(canvas) // 1. 绘制背景例如渐变色或静态图 drawBackground(canvas) // 2. 绘制所有障碍物 obstacles.forEach { it.draw(canvas, paint) } // 3. 绘制潜艇在最上层 submarine.draw(canvas, paint) // 4. 绘制分数、状态等UI drawUI(canvas) } }这里的关键是绘制顺序。必须先画背景再画障碍物最后画潜艇这样才能确保潜艇显示在障碍物之前符合视觉逻辑。Paint对象的isAntiAlias抗锯齿开启能让绘制的图形边缘更平滑虽然轻微消耗性能但对这种小游戏视觉提升明显值得。2.2 让世界动起来游戏循环与线程控制静态画面没有意义游戏的核心是“循环”。我采用了一个独立的线程作为游戏线程来控制游戏的刷新与逻辑更新。private fun startGameLoop() { isRunning true gameThread Thread { val targetFPS 60 // 目标帧率 val targetTimePerFrame 1000 / targetFPS // 每帧理想耗时毫秒 while (isRunning) { val frameStartTime System.currentTimeMillis() // 1. 更新游戏状态位置、碰撞、分数 updateGameLogic() // 2. 通知View重绘 postInvalidate() // 3. 计算本次循环耗时并进行帧率控制 val frameTime System.currentTimeMillis() - frameStartTime if (frameTime targetTimePerFrame) { Thread.sleep(targetTimePerFrame - frameTime) } } } gameThread?.start() }为什么是60FPS这是人眼感觉流畅的阈值也是大多数屏幕的刷新率。帧率控制Thread.sleep是为了避免游戏线程空转疯狂消耗CPU资源。如果不做控制在性能好的设备上游戏循环会跑得飞快导致逻辑更新和绘制频率远超需要不仅费电还可能因为逻辑更新太快导致游戏难度失控。这里的一个踩坑点是postInvalidate()必须在UI线程调用而我们的游戏线程是子线程所以不能直接调用invalidate()必须用postInvalidate()。这是Android UI线程安全的基本要求。2.3 潜艇的操控触摸事件与物理模拟潜艇的上升下降通过触摸事件触发。我重写了onTouchEvent方法override fun onTouchEvent(event: MotionEvent): Boolean { when (event.action) { MotionEvent.ACTION_DOWN - { submarine.isRising true // 按下潜艇获得上升力 return true } MotionEvent.ACTION_UP - { submarine.isRising false // 松开上升力消失 return true } } return super.onTouchEvent(event) }但这只是输入。要让潜艇有“重量感”和“惯性”需要简单的物理模拟。我在Submarine类中定义了速度和加速度class Submarine(var x: Float, var y: Float) { var velocityY 0f // Y轴速度正数向下负数向上 val gravity 0.5f // 模拟重力加速度 val liftForce -12f // 上升时的力负值表示向上 var isRising false fun update() { // 应用重力始终向下 velocityY gravity // 如果用户正在触摸施加上升力 if (isRising) { velocityY liftForce } // 根据速度更新位置 y velocityY // 简单的边界检测防止飞出屏幕 y y.coerceIn(minY, maxY) } }这个模拟虽然简单但包含了经典力学的基本思想力改变速度速度改变位置。gravity是持续向下的力liftForce是用户触发的反向力。通过调整这两个参数可以极大地改变游戏的手感。liftForce值太大会让潜艇过于灵敏太小则感觉“很沉”。我花了大量时间反复调试这两个参数并让不同朋友试玩才找到一个大多数人都觉得舒适的值。这是面试中可以重点聊的“产品思维”和“用户体验优化”细节。3. 碰撞检测游戏逻辑的核心与性能权衡碰撞检测的准确性和性能直接决定了游戏的可玩性和代码质量。障碍物管道通常由上、下两部分组成中间有缝隙。潜艇需要从这个缝隙中穿过。3.1 实现精确的矩形碰撞检测最直观的方法是使用矩形Rect或RectF进行检测。每个游戏元素潜艇、管道上下部分都可以用一个矩形边界来表示。data class Obstacle(var gapX: Float, var gapY: Float, var gapWidth: Float, var gapHeight: Float) { // 获取上管道的矩形 fun getTopRect(): RectF { return RectF(gapX, 0f, gapX pipeWidth, gapY) } // 获取下管道的矩形 fun getBottomRect(): RectF { return RectF(gapX, gapY gapHeight, gapX pipeWidth, screenHeight.toFloat()) } fun checkCollision(submarineRect: RectF): Boolean { return submarineRect.intersect(getTopRect()) || submarineRect.intersect(getBottomRect()) } }在游戏循环的updateGameLogic()中对每个障碍物进行检测private fun updateGameLogic() { submarine.update() val subRect submarine.getBoundingRect() // 获取潜艇的边界矩形 obstacles.forEach { obstacle - obstacle.move(speed) // 障碍物向左移动 if (obstacle.checkCollision(subRect)) { // 发生碰撞游戏结束 triggerGameOver() return } // 如果障碍物成功移出屏幕左侧则移除并加分 if (obstacle.isOffScreen()) { obstacles.remove(obstacle) score } } // 定时生成新的障碍物... }RectF.intersect(RectF)方法用于判断两个矩形是否相交这是Android SDK提供的非常高效的方法。这里的一个优化点是不要为每一帧的每一个障碍物都创建新的RectF对象而应该在Obstacle和Submarine内部复用RectF实例只更新其left, top, right, bottom的值。对象创建和垃圾回收GC在频繁调用的游戏循环中可能引起卡顿。3.2 从矩形到像素更精确的检测及其代价矩形检测效率高但不够精确。潜艇和管道都不是严格的矩形会有圆角。当潜艇“擦着”管道边角过去时矩形检测可能会误判为碰撞即“假阳性”影响游戏体验。更精确的方法可以是圆形碰撞检测如果潜艇头尾是半圆或者像素级碰撞检测。以圆形为例fun checkCircleCollision(submarineCenterX: Float, submarineCenterY: Float, submarineRadius: Float): Boolean { // 检测与上管道下边缘的距离 // 这是一个简化示例实际需要计算点到矩形最近边的距离 // 如果距离小于半径则碰撞 }或者可以定义一个Bitmap的碰撞遮罩通过比对像素透明度来判断。但这两种方法计算量都远大于矩形检测。在面试中我这样阐述我的选择对于“潜艇大挑战”这种快节奏、风格较卡通的游戏玩家对“像素级精确”的碰撞并不敏感反而对流畅度60FPS极其敏感。因此我选择了性能最优的矩形检测并通过将潜艇的碰撞矩形稍微“内缩”即做得比视觉图形小一圈来抵消“假阳性”问题提升玩家体验。这种在性能与效果之间做权衡并有具体实施方案的思考是面试官非常看重的。4. 障碍物生成与无限循环的关卡游戏的持久可玩性依赖于随机、合理且可持续的障碍物生成逻辑。4.1 随机生成算法的设计障碍物的核心参数是缝隙的垂直位置gapY和缝隙的高度gapHeight。gapHeight需要固定以保证游戏难度一致。gapY则需要在一定范围内随机。private fun generateNewObstacle() { val minGapFromTop 100f // 缝隙距离屏幕上边缘的最小距离 val maxGapFromTop screenHeight - gapHeight - 100f // 距离下边缘的最小距离 val randomGapY (minGapFromTop..maxGapFromTop).random() val newObstacle Obstacle( gapX screenWidth.toFloat(), // 从屏幕右侧外生成 gapY randomGapY, gapWidth pipeWidth, gapHeight GAP_HEIGHT ) obstacles.add(newObstacle) }但纯粹的随机可能生成“不可能通过的”障碍序列比如一个缝隙在顶部下一个立刻在底部。因此需要加入平滑性限制例如限制相邻两个缝隙中心点的最大垂直变化距离。var lastGapCenterY screenHeight / 2f // 上一个缝隙的中心Y坐标 fun generateSmoothObstacle() { val maxChange 200f // 相邻缝隙中心最大变化量 val targetCenterY lastGapCenterY (-maxChange..maxChange).random() val clampedCenterY targetCenterY.coerceIn(minGapFromTop gapHeight/2, maxGapFromTop - gapHeight/2) val newGapY clampedCenterY - gapHeight / 2 // ... 创建障碍物 lastGapCenterY clampedCenterY }这样生成的障碍物序列既有随机性又保证了玩家操作的连续性不会出现过于反人类的难度跳跃。4.2 对象池模式性能优化的关键实践随着游戏进行obstacles列表会不断添加新对象移除旧对象。频繁的对象创建和销毁会触发垃圾回收GC可能导致游戏出现瞬间卡顿。对象池Object Pool模式是解决这个问题的经典方案。我实现了一个简单的障碍物对象池class ObstaclePool(private val initialSize: Int) { private val available StackObstacle() private val inUse mutableSetOfObstacle() init { for (i in 0 until initialSize) { available.push(Obstacle()) } } fun obtain(): Obstacle { val obstacle if (available.isEmpty()) { Obstacle() // 池空了不得已新建一个 } else { available.pop() } inUse.add(obstacle) return obstacle.apply { reset() } // 取出时重置状态 } fun recycle(obstacle: Obstacle) { if (inUse.remove(obstacle)) { available.push(obstacle) } } }在游戏逻辑中不再new Obstacle()而是从池中obtain()当障碍物移出屏幕后不是直接丢弃而是调用recycle()将其放回池中。这个优化带来的效果是立竿见影的在长时间游戏后内存曲线变得非常平稳GC次数大幅减少。在面试中介绍这一点能立刻体现出你对性能问题的敏感度和解决实际问题的能力。5. 项目架构与代码组织从“能跑”到“可维护”随着功能增加计分、音效、游戏状态管理、难度递增所有代码堆在GameView里会变成一团乱麻。我着手进行重构引入了一个简单的状态模式和分层架构。5.1 游戏状态管理定义游戏状态枚举并让GameView的行为依赖于当前状态sealed class GameState { object Ready : GameState() // 准备中 object Running : GameState() // 进行中 object Paused : GameState() // 暂停 object GameOver : GameState() // 结束 } class GameEngine { var currentState: GameState GameState.Ready private set fun start() { if (currentState GameState.Ready) { currentState GameState.Running // 启动游戏循环、重置分数等 } } fun pause() { /* ... */ } fun resume() { /* ... */ } fun gameOver() { /* ... */ } fun reset() { /* ... */ } }GameView的onDraw和游戏循环的逻辑都会根据GameEngine.currentState来决定绘制什么、更新什么。例如在GameOver状态游戏循环停止逻辑更新但onDraw仍然会绘制最后一帧的画面和“游戏结束”的UI。5.2 模块化拆分将代码按职责拆分到不同类中GameEngine游戏大脑管理状态、分数、难度系数协调其他模块。PhysicsWorld物理模拟模块独立计算潜艇位置、速度处理重力与升力。这样未来如果想调整物理参数或增加风力等效果只需修改这个类。ObstacleManager障碍物管理模块包含对象池、生成算法和碰撞检测的主循环。它向GameEngine报告碰撞事件。Renderer或保留在GameView中专责绘制它从GameEngine、PhysicsWorld等模块获取数据进行渲染。理想情况下GameView只应持有Renderer和输入事件监听。注意对于一个小游戏引入完整的MVP/MVVM可能过度设计。但进行适度的模块拆分是展示你代码组织能力和软件设计思想的关键。在面试中你可以画一个简单的模块依赖图说明它们之间如何通过接口或事件进行通信这比说“我用了MVC”更有说服力。6. 面试中的项目阐述如何把“玩具”讲出“工业级”分量这个项目最终成为了我所有技术面试的“定海神针”。我总结了一套阐述它的方法让面试官能快速抓住重点。第一步一句话定义项目价值。“这是一个为了深入理解Android图形系统、自定义View、游戏循环原理及性能优化而独立复现的抖音热门小游戏。它帮助我跨越了‘应用开发’与‘互动内容实现’之间的鸿沟。”第二步按“需求-设计-实现-优化-复盘”的结构展开。需求与目标不只是复现玩法更要探究其技术实现并作为综合性的能力证明。核心技术选型与理由为什么用Canvas而不是SurfaceView或游戏引擎为什么选择矩形碰撞检测讲清楚权衡过程。详细实现亮点游戏循环与帧率控制解释子线程更新、主线程绘制的模型以及手动控制帧率避免空转的考量。物理手感调优分享调试重力、升力参数的过程体现对细节和用户体验的关注。碰撞检测的演进从基础矩形检测谈到对精确度的思考以及最终通过“内缩碰撞框”的实用解决方案。性能优化实践重点讲对象池模式的应用。给出优化前后的内存曲线对比如果有录屏或数据更好说明它对减少GC、保障流畅度的意义。架构与可维护性介绍如何从一团糟的代码重构出状态模式和初步的模块化结构。说明每个模块的职责和交互方式。第三步主动暴露问题与思考。这是展现你深度的地方。我会主动说“这个项目目前还有几个可以优化点第一是音效系统目前是简单的MediaPlayer在快速连续播放时可能有延迟未来可以考虑使用SoundPool第二是所有的绘制都在主线程虽然目前60FPS很稳定但如果效果更复杂可能需要考虑SurfaceView甚至OpenGL ES第三游戏状态保存和恢复我没做这是一个产品化必须考虑的功能。” 这展示了你的前瞻性和持续学习的态度。第四步关联面试问题。当面试官问及基础问题时我可以自然地联系回项目“说一下Android的绘制流程”- “在我的游戏里onDraw被游戏循环频繁调用我深刻理解了measure、layout、draw的流程以及为什么复杂布局不适合放在自定义View里频繁重绘。”“Handler机制是怎样的”- “我的游戏循环线程需要通知主线程重绘就用到了View.postInvalidate()其内部就是通过Handler将重绘消息放到主线程消息队列的。”“如何避免内存泄漏”- “在游戏结束时我必须确保游戏线程被正确中断isRunning false并且清空所有回调防止GameView被持有无法释放。”“有做过性能优化吗”- “对象池就是最直接的例子。另外在onDraw中避免创建新对象如new Paint()使用静态变量或复用对象。”这个从兴趣出发的项目最终证明了一个精心打磨的、有深度的个人项目其说服力远超一堆平淡无奇的商业项目经历。它不仅仅展示了你的编程技能更展示了你的学习能力、解决问题的思路、对技术的热情和把想法落地的执行力。对于非科班出身的开发者来说这就是你最强的“破局”武器。
返回列表