尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Android赛艇游戏源码实战解析:从自定义View到碰撞检测

Android赛艇游戏源码实战解析:从自定义View到碰撞检测 1. 先聊清楚赛艇游戏这个项目到底值不值得做如果你正打算用 Android Studio 练手又不想做那种烂大街的待办事项 App 或者计算器那么赛艇这类竞速休闲游戏其实是个相当不错的切入点。它不涉及复杂的 3D 渲染不依赖物理引擎也不需要服务端架构但又能把自定义 View、触摸事件、帧动画、碰撞检测、生命周期管理这些 Android 开发的核心知识点全串起来。所以说白了做这个项目不是单纯为了做个游戏玩而是用最小的成本把客户端开发的主干技术栈过一遍。我把它分成几个模块来理解游戏主循环与控制、赛艇运动模型、动态水道的生成与碰撞、游戏状态管理、UI 布局与交互反馈。每一个模块互相独立又彼此耦合做的时候你自然就会体会到一个 App 的架构是怎么搭出来的尤其是当你想把代码写好而不是只是跑通时这中间的取舍特别有意思。这也正好回答了很多人反复问的问题拿到一份 Android 游戏源代码究竟该从哪看起我的建议永远是从玩法定义入手搞清楚玩家能做什么、系统怎么响应再去看渲染层最后看数据层。下面我会按照我实际组织和阅读这类代码的顺序把这个赛艇项目的源码拆开讲。2. 环境与工程骨架把 Android Studio 调到能舒服干活的状态真正动手啃这份源码之前我建议你先花十分钟把 Android Studio 的运行环境理顺。热搜词里一大堆人问android studio怎么设置中文android studio安装教程android studio如何连接小米手机说明很多人其实不是卡在代码逻辑上而是卡在开头的环境问题上这部分没弄好后面新项目一创建就会莫名其妙报错非常影响心态。2.1 环境准备中最容易被忽略的三个细节JDK 版本与 Gradle 的匹配关系。赛艇游戏这个项目通常不会用到特别新的 API但如果你本机装的是新版 JDK老的 Gradle 版本可能认不出来构建时直接抛一堆看不懂的异常。我这里给一个相对稳妥的组合JDK 17 搭配 Gradle 7.4 以上Android Gradle Plugin 用 7.4.x 或 8.x。如果你打开项目之后发现 gradle 一直卡在下载依赖十有八九是网络问题在国内网络环境下建议给 gradle-wrapper.properties 里的 distributionUrl 换成可访问的国内镜像地址。这一步没处理好极有可能让你误判为源代码有问题。Android SDK 与构建工具的缺失。打开一个新项目时如果本地没有对应版本的 Build ToolsAndroid Studio 会尝试在线下载这个过程偶尔会没有任何提示地失败。建议你提前在 SDK Manager 里装好三样稳定版本的 Platform、对应版本的 Build Tools、以及两个常用的 emulator 镜像。看完代码想直接在虚拟机上跑一跑的时候现找镜像包真的很耽误时间。一台不那么折腾的真机。热搜词里排前面的连接小米手机做游戏开发时同样建议优先用真机调试。虚拟机的渲染性能虽然这些年改善了很多但操控游戏的触摸手感、帧率稳定性和真实机器还是有差距。小米手机连接调试时记得打开开发者选项里的USB 调试和USB 安装部分机型还需要额外打开USB 调试安全设置才能让 Android Studio 顺利安装应用。另外某些品牌的手机默认开启了仅充电模式插上线后要手动下拉通知栏改成传输文件。2.2 工程目录怎么组织才不凌乱看源代码的时候我习惯先展开项目的 java 目录看看包结构是否清晰。一个比较舒服的赛艇游戏工程应该长这样activity/放 MainActivity 和 SettingsActivity负责创建游戏窗口和读取配置。view/放自定义 View比如 GameView、BoatView、WaterView。model/放纯数据类比如 Boat、Obstacle、ScoreManager。render/放绘制相关的工具类比如 WaterRenderer、ParticleEffect。utils/放密度转换、随机数生成、时间工具等辅助代码。如果你拿到的源码把几百行代码全部堆在一个 MainActivity 里面那也能跑但可读性会比较差。对于想通过源码学习的人来说我个人建议先按这个思路自己重构成小模块遇到一线大厂怎么干活类的问题时答案其实都是这个方向——单一职责分层明确。我拿到的这份赛艇源码算中规中矩主要的游戏逻辑在 GameView 里数据抽到了模型层这个粒度对单人开发来说刚好不多不少。2.3 从 main 入口追踪代码执行路径很多刚接触源码的朋友最容易犯的一个错误一上来就到处点开文件看半天找不到核心逻辑在哪。正确做法是先找到 MainActivity 的onCreate看它 setContentView 里的那个布局或者直接 new 出来的 View 到底是什么然后再进到 GameView 的onDraw与SurfaceHolder.Callback方法游戏的心脏基本就藏在这里。对于赛艇游戏来说执行路径大概是这样的MainActivity 启动 - 加载布局 - GameView 被创建 - SurfaceCreated 里启动游戏线程 - 线程循环里执行 update 和 draw - onDraw 里按照优先级绘制水面、障碍物、赛艇、UI。这一条线理清楚之后后面所有具体的模块再往里填就非常自然了。换句话说看源码不是从第一行读到最后一行而是从入口 - 循环 - 分支层层追踪这样才能快速建立起全局认知。3. 核心玩法模块拆解从赛艇的运动模型开始3.1 赛艇不是普通物体速度模型、惯性、转向赛艇和赛车最大的不同在于它是在水面上跑的。水面会提供阻力桨叶入水提供推力切向转向时船身会伴随明显的侧滑这种惯性 延迟响应的感觉是让玩家觉得手感对味的关键。代码层面做这件事基本思路是用一个简单的速度向量来模拟public class Boat { private float x, y; // 当前位置 private float vx, vy; // 当前速度分量 private float heading; // 船头朝向弧度 private float speedCap; // 最大速度 private float drag; // 水面阻力系数 public void update(float throttle, float steer, float deltaTime) { // 推进力油门大小映射为沿船头方向的加速度 float accel throttle * 20f; float ax (float) Math.cos(heading) * accel; float ay (float) Math.sin(heading) * accel; // 转向改变船头朝向 heading steer * deltaTime * 2.2f; // 更新速度 vx ax * deltaTime; vy ay * deltaTime; // 阻力速度按比例衰减模拟水面的拖拽感 vx * (1f - drag * deltaTime); vy * (1f - drag * deltaTime); // 限制最大速度防止数值爆炸 float speed (float) Math.sqrt(vx * vx vy * vy); if (speed speedCap) { vx * speedCap / speed; vy * speedCap / speed; } x vx * deltaTime; y vy * deltaTime; } }这段代码看起来很简单但它背后藏着所有手感的秘密。推进力不等于即时速度玩家按下加速后船需要一小段时间才能滑起来松开后因为有 drag 的存在也不会立刻停住而是会沿着水面继续滑行一段距离。这种 delay 如果做过头了会觉得很肉做少了又会觉得像在开卡丁车调试的时候要反复微调的就是下面这几个参数参数作用调大效果调小效果throttle 映射系数决定推背感强弱起步很快容易撞岸加速很肉目标感弱drag模拟水面摩擦阻力松手就慢下来操控更跟手惯性漂移感强但容易失控steer 系数转向灵敏度转向灵活船头像电风扇大转弯慢适合长直线赛道speedCap最高速度赛道压力大爽感足节奏慢偏向休闲我建议刚开始做难度递进时把所有参数先设得保守一点等你把障碍物生成逻辑调明白了再逐步加大油门系数否则很容易出现船根本控制不住玩家五分钟就弃玩的情况。3.2 赛道的动态生成无尽模式的水道逻辑赛艇游戏如果做的是一个固定的赛道那么赛道数据可以用坐标点一次性写死。但市面上大多数休闲赛艇更偏向无尽跑分模式也就是说赛道要无限延伸不能一眼看到底。这时候最常用也最容易实现的做法是分段拼接把屏幕划分成若干段每段长度固定比如 1200px。当前段由一组赛道上沿点位 下沿点位描述赛艇始终被约束在这两条边界之间。当赛艇越过当前段尾后把这一段的坐标数组整体向后平移同时生成一段新的随机边界。听起来有点绕但你只要在脑子里想象成卷轴滚动就懂了。赛艇可以保持在屏幕相对中心的位置不动卷轴不断向后拉制造出船在向前飞驰的感觉。具体到代码实现就是维护一个ArrayListFloat的左边界和右边界每次取赛艇当前位置最接近的那一段做碰撞判定。public class TrackGenerator { private static final int SEGMENT_LENGTH 600; private static final int HISTORY_SEGMENTS 6; private static final int LOOKAHEAD_SEGMENTS 4; private ListFloat leftBounds new ArrayList(); private ListFloat rightBounds new ArrayList(); public void init(float screenWidth, float screenHeight) { // 用平滑曲线生成初始的一段不能上来就随机抖动 generateBaseSegment(screenWidth, screenHeight); } public void update(float boatY, float deltaTime) { // 当船行进到一定深度后从尾部移走旧段头部追加新段 while (leftBounds.size() HISTORY_SEGMENTS LOOKAHEAD_SEGMENTS) { appendNewSegment(); } } private void appendNewSegment() { // 核心新段的上一个节点必须和当前段的尾节点平滑衔接否则会出现台阶 float lastLeft leftBounds.get(leftBounds.size() - 1); float nextLeft lastLeft randomSmoothOffset(); leftBounds.add(nextLeft); float lastRight rightBounds.get(rightBounds.size() - 1); float nextRight lastRight randomSmoothOffset(); rightBounds.add(nextRight); // 强制保证航道宽度不小于某个阈值 float minWidth 200f; if (nextRight - nextLeft minWidth) { rightBounds.set(rightBounds.size() - 1, nextLeft minWidth); } } }写赛道生成最容易踩的坑就是台阶段。如果上一段的末尾宽度是 300px下一段随机出来只有 180px玩家直接撞墙毫无缓冲。解决办法无非两种要么让边界点每次只允许在小范围内平移要么在节点之间使用线性插值把斜率限制住。我实际调试时发现限制每次随机增量不超过 60~80px游戏体验最稳定。3.3 碰撞检测矩形还是圆形精度和性能怎么权衡碰撞检测作为游戏的核心判定模块设计上要考虑两点——准确率和开销。赛艇和障碍物比如浮标、礁石、水草之间的碰撞用矩形检测最简单public boolean checkCollision(RectF boatRect, ListObstacle obstacles) { for (Obstacle ob : obstacles) { if (boatRect.intersects(ob.getBounds())) { return true; } } return false; }但矩形有一个固有的缺点四个角落是虚的。如果赛艇的图像其实是个瘦长条矩形检测会让玩家觉得我明明没有碰到它怎么就算撞了。所以我在这个项目里用的是圆形碰撞 矩形粗筛的组合方法先判断两个包围盒是否相交如果不相交就直接跳过这是性能优化点如果相交再精确计算圆心距离是否小于半径之和。public boolean checkCircleCollision(float ax, float ay, float ar, float bx, float by, float br) { float dx ax - bx; float dy ay - by; float distSq dx * dx dy * dy; float radiusSum ar br; return distSq radiusSum * radiusSum; }这样做的优点非常明显圆形碰撞运算只有平方与加法完全没有三角函数单帧几千次检测在手机上也是毫秒级完成。手感上也会比矩形检测宽容很多玩家不会有那种明明差一个拳头却被判碰撞的憋屈感。碰撞触发之后的惩罚也不能千篇一律。如果所有障碍物都是一碰就死那游戏过于硬核不适合休闲玩家。我常见的设计是分两档小水草只减速并扣少量分数大型礁石直接让赛艇翻船。部分赛艇游戏还引入撞击后短暂无敌机制避免玩家刚复活又被第二个障碍物连续撞毙这种细节守卫的是玩家情绪建议一定保留。3.4 游戏状态机把开始、运行、暂停、结束的边界划清楚很多新手写游戏逻辑容易犯一个错误把状态判断像洋葱一样一层层 if 嵌套在更新函数里。比如 update 里先判断if (isRunning)然后在里面又判断if (paused)再在里面判断if (gameOver)。这种做法在逻辑简单时还行一旦加入了弹窗、复活、结算动画代码就彻底失控了。我习惯用枚举状态机来管理public enum GameState { READY, // 等待开始 RUNNING, // 正常进行 PAUSED, // 暂停 GAME_OVER, // 游戏结束 }update 方法里用一个 switch 分发只处理当前状态该做的事switch (state) { case READY: // 等待玩家点击赛艇在水面上轻微起伏 break; case RUNNING: boat.update(...); track.update(...); checkCollision(...); break; case PAUSED: // 什么都不动但 Android 要快速响应 onPause 事件 break; case GAME_OVER: // 播放一次沉船动画然后记录分数 break; }这样的状态机不但让逻辑清晰也方便后续给游戏加暂停弹窗倒计时复活等功能。你要知道在 Android 里还有一个更隐蔽的状态变化——应用生命周期带来的 onPause / onResume。如果游戏线程还在跑而 Activity 已经退到后台不仅会耗电还可能导致线程抛出异常。所以我的统一约定是Activity onPause 时如果游戏处于 RUNNING自动把它切到 PAUSEDonResume 后再由用户手动恢复。4. 绘制与动画让水动起来、船划起来4.1 自定义 View 与 SurfaceView 的选择赛艇游戏本质上是高频次刷新画面的应用帧率至少要跑到 30fps 以上才会让人觉得顺滑。这里有个绕不开的技术选型用普通自定义 View 配合invalidate()还是用 SurfaceView 配合独立绘制线程我的经验是简单动画用 View游戏这种高频重绘用 SurfaceView。原因在于invalidate()会引起系统在下一个垂直同步信号到来时重新执行onDraw()绘制工作是跑在 UI 线程上的。当游戏逻辑稍微复杂一点UI 线程一旦被阻塞画面就会掉帧甚至卡死。而 SurfaceView 可以直接在子线程里通过lockCanvas()拿到画布绘制操作不占用 UI 线程对游戏这种实时性要求高的场景明显更合适。public class GameView extends SurfaceView implements SurfaceHolder.Callback { private GameThread thread; private SurfaceHolder holder; public GameView(Context context) { super(context); holder getHolder(); holder.addCallback(this); } Override public void surfaceCreated(SurfaceHolder holder) { // 启动线程的真正时机是 surface 准备好后而不是 View 构造时 thread new GameThread(holder, context); thread.setRunning(true); thread.start(); } Override public void surfaceDestroyed(SurfaceHolder holder) { boolean retry true; thread.setRunning(false); while (retry) { try { thread.join(); retry false; } catch (InterruptedException ignored) { } } } class GameThread extends Thread { Override public void run() { while (running) { Canvas canvas null; long startTime System.nanoTime(); try { canvas holder.lockCanvas(); if (canvas ! null) { synchronized (holder) { update(); draw(canvas); } } } finally { if (canvas ! null) { holder.unlockCanvasAndPost(canvas); } } // 控制帧率这里按 60fps 来算 long frameTime (System.nanoTime() - startTime) / 1000000; if (frameTime 16) { try { Thread.sleep(16 - frameTime); } catch (InterruptedException e) { e.printStackTrace(); } } } } } }写线程循环时有几个小坑必须提一下lockCanvas()返回的 canvas 有可能为 null例如 surface 尚未准备好或已销毁所以一定要判空unlockCanvasAndPost(canvas)必须放在 finally 里否则一旦出现异常Surface 会一直处于锁定的状态后面的帧全部画不出来线程的setRunning(false)之后不能直接 kill要通过join()等待线程真正退出否则很容易在 activity 销毁时报Canvas: trying to use a recycled bitmap这类异常。4.2 水面波浪渲染不需要美术也能让画面有流动感水面的波浪效果是这个游戏的视觉灵魂。如果只是画一个纯色矩形当水面玩家第一眼就会觉得非常廉价。可如果为了水面效果好去接一套粒子系统对这个项目来说又太重了。折中方案是用多段正弦波叠加来模拟水面的起伏然后用 Canvas 绘制一条条带透明度的波纹线段。private void drawWater(Canvas canvas) { int width getWidth(); int height getHeight(); float baseline height * 0.75f; // 水面基准线 // 画背景渐变从浅蓝过渡到深蓝 LinearGradient gradient new LinearGradient( 0, baseline - 100, 0, height, Color.rgb(100, 180, 220), Color.rgb(20, 90, 150), Shader.TileMode.CLAMP); Paint bgPaint new Paint(); bgPaint.setShader(gradient); canvas.drawRect(0, baseline, width, height, bgPaint); // 画三条不同频率和相位的正弦波叠加出自然的水面波动感 Paint linePaint new Paint(); linePaint.setAntiAlias(true); linePaint.setStrokeWidth(3f); linePaint.setColor(Color.argb(120, 255, 255, 255)); for (int row 0; row 4; row) { Path path new Path(); float yBase baseline row * 26; path.moveTo(0, yBase); for (int x 0; x width; x 8) { // 时间偏移量 waterOffset 由 GameThread 通过 update 累加产生流动感 float y yBase (float) (Math.sin((x waterOffset) * 0.02f) * 8f Math.sin((x - waterOffset) * 0.01f 1.5f) * 5f); path.lineTo(x, y); } canvas.drawPath(path, linePaint); } }这段代码里最需要注意的变量是waterOffset它每帧都递增作用相当于把正弦波形整体平移人眼看起来就像水波在流动。叠加两条不同频率和相位的正弦波波纹就不会像复印机一样规律得假而是有了一丝自然水体的起伏变化。想要进一步增强水面效果还可以在绘制赛艇时把船下方的波浪顶点抬一点形成船压水浪的感觉这一步是锦上添花投入不大视觉提升却很明显。4.3 赛艇的绘制与划桨动画一张图循环播放还是纯代码绘制赛艇本身的绘制我推荐一个新手非常友好的思路用一张船体图片加上程序控制的旋转划桨动画则可以预先画好 3~4 帧按状态循环播放。public void draw(Canvas canvas, Paint paint) { canvas.save(); // 赛艇的图片中心点旋转到 heading 方向 canvas.translate(x, y); canvas.rotate((float) Math.toDegrees(heading)); // 画船体 canvas.drawBitmap(boatBitmap, -boatBitmap.getWidth() / 2f, -boatBitmap.getHeight() / 2f, paint); // 画船桨根据当前动画帧切换角度 float paddleAngle getPaddleAngle(); canvas.save(); canvas.rotate(paddleAngle, paddleOffsetX, paddleOffsetY); canvas.drawBitmap(paddleBitmap, paddleX, paddleY, paint); canvas.restore(); canvas.restore(); }绕着船中心旋转heading的好处是不需要额外做任何三角函数计算船的朝向天然正确。划桨动画我用了两帧交叉一帧桨在上方、一帧桨入水循环间隔约 200ms视觉上就能形成左一下右一下的划水节奏。如果你连船体图片也没有可以用Path画一个简易的流线型船身Path boatPath new Path(); boatPath.moveTo(-40, -12); boatPath.lineTo(30, -12); boatPath.quadTo(45, 0, 30, 12); // 船头圆弧 boatPath.lineTo(-40, 12); boatPath.close(); canvas.drawPath(boatPath, boatPaint);这种自绘派的好处是随代码走项目复制到哪里都不会丢资源缺点是细节表现力有限。有条件的建议还是准备一张透明背景的赛艇 PNG配合BitmapFactory加载到内存效果会好一大截。需要提醒的是加载大图之前一定要做尺寸压缩否则低端手机会直接被OutOfMemoryError砸得满头包这个我后面专门说。4.4 触控交互左右虚拟按键还是全屏拖拽触控方式直接决定了操作上限。我见过不少赛艇游戏用点屏幕左边向左转、点右边向右转的二分设计优点是上手零门槛但问题在于反馈比较钝转向只能是开关式没有办法模拟轻微压舵。我更推荐用水平滑动的偏移量来映射转向角度private float touchStartX 0; private boolean isTouching false; Override public boolean onTouchEvent(MotionEvent event) { switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: touchStartX event.getX(); isTouching true; return true; case MotionEvent.ACTION_MOVE: if (isTouching) { float deltaX event.getX() - touchStartX; // 映射到 -1f ~ 1f 的转向值100px 对应满舵 float steer deltaX / 100f; if (steer 1f) steer 1f; if (steer -1f) steer -1f; boat.setSteer(steer); } return true; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: isTouching false; boat.setSteer(0f); return true; } return super.onTouchEvent(event); }这样做的好处是操控精度高很多玩家可以通过手指偏移量来控制压舵幅度。刚开始可能不太习惯但玩半小时就会觉得这船想往哪走就往哪走。另一个做法——全屏拖拽控制船的绝对位置——则看起来简单但实际有隐患船的移动变成了完全跟手失去了惯性和水面的离心力手感这就像把赛车游戏做成了贪吃蛇我个人不太推荐。5. 从能跑到好玩难度曲线与视觉反馈的打磨5.1 无尽赛道的难度曲线怎么跳很多新手做出第一版游戏后都会发现一个尴尬的问题玩了两三分钟就腻了。原因是难度曲线没有设计好前期太无聊后期难度又突然陡增。对于无尽模式我总结了一个简单的节奏口诀前 10 秒让玩家熟悉操作10~30 秒开始出现障碍30 秒后逐级增加障碍密度和速度。代码里体现为public float getCurrentDifficulty(float elapsedTime) { if (elapsedTime 10f) return 0.2f; // 几乎无障碍 if (elapsedTime 30f) return 0.5f; // 中等密度 if (elapsedTime 60f) return 0.8f; // 明显变难 return 1f; // 最大难度 }然后用这个系数去控制障碍物生成概率、障碍物移动速度、航道最小宽度三个核心变量。要注意的是难度增长不要做成阶梯式跳跃应该用线性插值或者平滑曲线避免玩家在第十一秒突然觉得难度暴涨而弃游。float difficulty getCurrentDifficulty(elapsedTime); spawnRate 0.8f difficulty * 1.2f; obstacleSpeed 200f difficulty * 180f; trackMinWidth 320f - difficulty * 120f;5.2 速度感与反馈为什么你的游戏总觉得慢真跑起来的时候你可能会觉得赛艇速度明明调到了很大的值画面上却显得很慢。这是因为人眼对速度的判断依赖参照物单看一艘船在一个纯色背景上移动大脑完全没有速度感。解决办法主要有三个增加水面的高频波纹纹理让波纹随着船的前进快速向后滚动形成强烈的视差增加水花粒子在船尾不断喷出小水珠水珠飞溅得越快人的大脑就越会认定船在高速运动把背景分成前后两层远景的山或者云移动得慢近景的水波移动得快形成纵深视差。这三招加起来不需要把 scrool 速度改成离谱的数字玩家主观上的速度体验就会提升至少一倍。5.3 音效与振动容易被忽略但性价比最高的反馈一个平面的、无声无息的游戏和带上按键音、碰撞音、背景音乐之后的游戏玩家的体验差距比很多人想象中要大得多。Android 里播放音效最简单的做法是用SoundPool它在低延时方面表现比MediaPlayer好很多SoundPool.Builder builder new SoundPool.Builder(); builder.setMaxStreams(3); SoundPool soundPool builder.build(); AudioAttributes attributes new AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_GAME) .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) .build(); soundPool new SoundPool.Builder() .setAudioAttributes(attributes) .setMaxStreams(3) .build(); int splashSound soundPool.load(context, R.raw.splash, 1);load是异步的所以建议在游戏启动阶段就预加载真正播放时直接play不要每次碰撞才去 load那会出现明显的延迟感。振动反馈可以用Vibrator配合VibrationEffectAndroid 11 以上需要申请VIBRATE权限在AndroidManifest.xml里加一行就行。实测下来轻微的碰撞振动比任何弹窗提示都更直接也是强烈建议保留的细节。6. 源码阅读后的重构建议与易踩坑清单6.1 拿到一份源码先别急着跑先做三件事第一件事把项目里的自定义类全部列出来画一张粗略的调用关系草图哪怕只画在草稿纸上都行。赛艇游戏的类一般不超过十个画完之后“文件迷宫”瞬间就通了。第二件事全局搜索TODO和日志输出看作者自己在哪些位置标了待办很多时候这些注释比代码更能透露作者当时遇到了什么问题。第三件事把游戏里所有常量集中到一个类比如GameConfig.java把所有魔法数speedCap、drag、spawnRate都抽出来然后前端加一个调试面板或者直接改常量值一边改一边跑能非常直观地感受每个参数对手感的影响。6.2 八个最常见的运行期崩溃坑我把开发这个项目时遇到过的、以及帮别人排查过的常见崩溃汇总成一个表供你对照排查崩溃现象根本原因解决方案真机运行时闪退日志里有 OOMBitmap 过大用 BitmapFactory.Options 的 inSampleSize 做采样压缩旋转屏幕后游戏重置Activity 重建导致状态丢失在 AndroidManifest 里固定竖屏或实现 onSaveInstanceState锁屏后恢复game thread 直接崩溃Surface 被销毁后再操作 Canvas在 surfaceDestroyed 里可靠停线程音量键调节时游戏变卡系统弹出了音量 UI 导致焦点变化在 onWindowFocusChanged 里做暂停处理多指触控导致转向紊乱没有做多指事件处理记录 activePointerId只跟踪第一根手指某些模拟器上画面全部拉伸变形没有处理屏幕适配采用基准分辨率 等比缩放绘制播放音效时崩溃SoundPool 未初始化完成就 playload 之后通过 OnLoadCompleteListener 再 play碰撞判定偶尔漏检赛艇移速过快一帧跨过了障碍物用“扫掠检测”或增大碰撞体半径阈值第三条尤其经典。很多人的 GameThread 里用了一个 while 循环surfaceDestroyed 时直接调thread.stop()或者干脆不处理结果就是在锁屏、切后台的时候随机崩溃。正确做法我在前面已经写了一定要setRunning(false)之后再join()。6.3 如何把这份源码扩展成你自己的东西看完别人的代码最有价值的动作不是读懂然后忘掉而是动手改造成一个属于自己的游戏。如果想要最简单地从赛艇游戏扩展出去建议按下面的顺序来加道具系统。在跑道上随机生成加速符文和减速陷阱吃到加速后短时间内把 speedCap 和 thrust 调高。这个需求直接复用碰撞检测只是多了一个道具枚举类型工作量不大但对可玩性的提升立竿见影。加计分与排行榜。用 SharedPreferences 保存最高分分数与赛艇前进距离挂钩每 100 米加 10 分如果有兴趣还能接入 LeaderBoard 一类的第三方服务。加 BOSS 关卡或者昼夜切换。每隔 1000 米切换一次天空盒色调和水面配色视觉变化会让人感觉内容一下子变多了实际工作量只是切换几个 Paint 的颜色而已。有一个常见的误区是觉得加的功能越多项目看起来就越牛。但我在实际开发中的体会是对于这份赛艇源码来说把手感调顺、把状态机写稳、把崩溃问题彻底解决比堆砌五个新功能更有价值。毕竟跑得稳、玩着舒服才是游戏留住玩家的根本功能再多玩起来卡顿或者动不动闪退一样留不住人。6.4 关于 Android Studio 日常使用的一些零碎经验最后再补几个和源码本身没多大关系、但开发过程中绕不开的 Android Studio 使用建议都属于比较冷门但实际有用的点Android Studio 里频繁弹出 Gradle 下载依赖卡住时优先检查项目的settings.gradle里有没有配置阿里云或腾讯云的仓库镜像这比把所有希望寄托在系统自带代理上要稳得多。用虚拟机调试时选择 x86 镜像会比 arm 镜像流畅不少前提是你的宿主机是 Intel 或 AMD 芯片如果是 Apple Silicon 芯片那 x86 镜像反而会因为模拟层效率低下而卡顿M1/M2 上直接用 arm64 镜像反而更稳。Logcat里的日志显示主线程忙不过来时不要急着喷代码慢先看一下是不是后台还有别的线程在频繁postInvalidate()或者写文件把无关的后台任务禁掉再测一次往往会豁然开朗。设置中文界面这个需求在Settings - Appearance Behavior - System Settings - Language and Region里改一次就行。但说实话做开发的话我反而建议保留英文界面因为绝大多数报错信息、日志关键字都是英文的界面换成中文只是心理上觉得亲切真排错的时候并不省事。当然这纯属个人习惯没有对错之分。7. 这份赛艇源码的最终定位与下一步该往哪走做完了以上这些拆解、重构和调试你已经不是一个能把这个项目跑起来的人而是能够说清这个项目每一行核心代码在干什么、为什么这么写、手感不好时调哪个参数的人。这份赛艇游戏源代码其真正的价值不在那些代码本身——那些随便搜一下都能找到——而在于它提供了一个低门槛、高覆盖的练手场景把 Android 开发里最常用、最关键的几个技术点全部串了起来。如果你玩过之后觉得意犹未尽下一步可以考虑把其中的 UI 换成 Compose用声明式界面重新实现一遍游戏菜单和结算面板这能让你快速摸到 Compose 和传统 View 体系在游戏场景中的差异也可以尝试把渲染部分切换到 OpenGL ES把波浪特效做成着色器效果会看到一个完全不同的性能世界再或者把本地排行榜升级成真实的对战联机理解一遍基础的数据同步与状态一致性。这些都是从会做项目到懂项目架构的必经之路。在 Android 游戏开发这条路上赛艇只是起点但它绝对是个好起点。
返回列表