
简介一套基于Unity5引擎的C#多平台游戏开发实战源码面向希望系统学习Unity场景搭建、游戏逻辑编写与跨平台发布的初中级开发者。压缩包为7z格式约130MB内容涵盖场景文件、C#脚本、预设体、纹理音频模型资源以及Shader/Material渲染配置和开发文档目录结构贴近Unity工程。已有491人学习下载。通过研读源码可理解Unity组件化架构与MonoBehaviour生命周期方法Awake、Start、Update等的调用时机掌握C#类、继承、接口在游戏逻辑中的实际运用并了解面向iOS、Android、Windows、Mac发布时的输入适配、性能优化与分辨率处理。初学者可修改脚本参数快速积累实战经验有经验者也能从预设体复用与跨平台细节中获得新思路。1. Unity5实战为什么这套C#多平台源码值得先读后改很多人从网上下载《Unity5实战》源码包后第一反应是找到主场景、按 Play然后卡在 Windows 能跑、Android 贴图发紫、iOS 中文乱码这种平台差异上。实际上这套源码的价值不在某个玩法关卡而在于它用 C# 把输入、游戏流程、资源加载和平台差异做了分层让你能用同一套代码发布到 PC、Android 和 iOS。无论你是刚装了 Unity 想找一个完整项目练手还是写过几年 C# 打算转游戏开发照着这套源码敲一遍比连续看十遍教程管用。下面我按源码的阅读顺序把 C# 代码怎么组织、Build 参数怎么调、踩坑点在哪一处处讲清楚。2. Unity5 工程里的 C# 源码结构从脚本生命周期到输入层封装拿到一份 Unity5 源码包我习惯不急着看场景而是先过一遍 Assets/Scripts 目录下的 C# 文件。多平台游戏的骨架不是场景里的物体而是脚本生命周期的调用顺序和输入层的抽象方式。读懂了这两点后面改玩法才有安全感。2.1 读源码先读 MonoBehaviour 生命周期Awake、Start、Update 的执行时机Unity5 的 C# 脚本几乎都继承 MonoBehaviour引擎会在特定时机调用这些生命周期方法。源码里如果初始化代码放错了方法Windows 上可能正常因为编辑器下场景加载慢掩盖了竞态到了真机会随机崩。// GameManager.cs using UnityEngine; public class GameManager : MonoBehaviour { public static GameManager Instance { get; private set; } private GameState currentState GameState.Boot; void Awake() { if (Instance ! null Instance ! this) { Destroy(gameObject); return; } Instance this; DontDestroyOnLoad(gameObject); // 这里只做纯内存的初始化不做平台相关加载 currentState GameState.Boot; } void Start() { // 场景物体都 Awake 完之后再引用组件 PlayerInput.Instance.enabled true; } void Update() { switch (currentState) { case GameState.Boot: // 等待资源初始化完成再进入主菜单 break; case GameState.Playing: TickPlaying(); break; } } private void TickPlaying() { // 每帧玩法逻辑 } }这段代码是 Unity5 时代单例写法的标准模板。Awake 在场景加载时立即执行适合做单例分配和字段重置Start 在所有 Awake 之后执行适合获取其他对象的引用Update 每帧执行受 Time.timeScale 影响状态机的切换放在这里最直接。我把 DontDestroyOnLoad 放在 Awake 里保证跨场景不销毁这是跨平台游戏最常用的做法因为 Android 和 iOS 的 Activity 重建不会重复创建管理类。有一个细节容易忽略Unity5 的脚本执行顺序默认不固定多个脚本的 Awake 顺序由场景加载决定在 Android 上可能和 PC 上不同。如果源码里出现 A 脚本的 Awake 去读取 B 脚本的字段很有可能在某个平台失效。我一般会在 Project Settings Script Execution Order 里明确给核心脚本排序GameManager - PlayerInput - PlatformAdaptor。这样无论哪个平台初始化顺序都是稳定的。下表是生命周期方法的常用分工源码里如果看到违背这套分工的代码大概率就是隐患。方法调用时机适合做什么不适合做什么Awake场景实例化后立即调用单例、只读字段、默认值读取场景外资源、连接网络OnEnable每次对象激活时订阅事件、启动协程依赖顺序的初始化Start所有 Awake 之后Update 之前获取组件引用、加载数据频繁调用Update每帧一次输入检测、状态切换物理计算FixedUpdate固定时间步物理受力、Rigidbody 操作玩家输入读完生命周期再读源码里的单例和状态字段基本就能判断框架的健壮性。如果发现某个源脚本把 File.ReadAllText 放在 Awake 里那在 iOS 上很可能会因为文件系统未 Ready 而失败正确的做法是放到 Start 里并检查返回值。2.2 用 C# 封装输入层鼠标、键盘、触屏和手柄右摇杆的统一处理多平台游戏输入差异最大。Unity5 的 Input 类支持键鼠、触屏和手柄但轴名称和映射因平台而异。源码里如果输入逻辑散落在各个 Update 里换一个平台就要改几十处。所以我读源码时最关注有没有一个 InputService 类来统一封装。// InputService.cs using UnityEngine; public enum InputAxis { Horizontal, Vertical, LookX, LookY } public static class InputService { private static Vector2 primaryAxis; public static Vector2 GetAxis(InputAxis axis) { switch (axis) { case InputAxis.Horizontal: primaryAxis.x Input.GetAxisRaw(Horizontal); primaryAxis.y Input.GetAxisRaw(Vertical); break; case InputAxis.LookX: primaryAxis.x Input.GetAxisRaw(Mouse X) Input.GetAxisRaw(LookX); break; case InputAxis.LookY: primaryAxis.y Input.GetAxisRaw(Mouse Y) Input.GetAxisRaw(LookY); break; } return primaryAxis; } }注意 LookX 并不是 Unity 默认的轴需要在 Edit Project Settings Input Manager 里手动添加。默认只有 Horizontal 和 Vertical 对应左右上下方向键和手柄左摇杆鼠标 X 和 Y 是单独的。手柄右摇杆在很多平台映射到“4th axis”或“Joystick Axis 4”但不同驱动下极性不一样所以我在封装时会同时累加鼠标和手柄的数值并把两个来源的平均值而不是相加传给相机否则同时操作鼠标和手柄时视角会猛跳。实际操作中我还见过源码里为了支持 PC、手机和手柄直接写了三套控制分支#if UNITY_STANDALONE float rotateX Input.GetAxis(Mouse X); #elif UNITY_ANDROID || UNITY_IOS float rotateX touchDelta.x; #else float rotateX Input.GetAxis(LookX); #endif这种写法能跑但每次增加一个平台就要再改一次调用处。更稳的做法是声明一个 InputAxis 枚举把平台差异收敛在 InputService 内部。这样 GameManager、角色控制器、武器瞄准只需要调用 InputService.GetAxis不需要知道当前是什么平台。触屏虚拟摇杆也建议走这个入口。我在做手游壳子时会把虚拟摇杆的拖拽量写回 InputService作为 Horizontal 轴的补充这样既能用物理手柄也能用屏幕摇杆。2.3 源码里最优先读的三个 C# 文件GameManager、PlayerInput、PlatformAdaptor读源码不要从 AssetStore 插件读起先读主流程。我建议优先看三个文件它们决定了整个项目的架构。用这个表来定位文件路径类名职责Assets/Scripts/Core/GameManager.csGameManager游戏状态切换、场景管理Assets/Scripts/Core/PlayerInput.csPlayerInput封装输入供玩法和 UI 调用Assets/Scripts/Platform/PlatformAdaptor.csPlatformAdaptor用条件编译隔离平台路径和 APIGameManager 我们已经看过PlayerInput 通常是一个单例 MonoBehaviour在 Start 里把 InputService 注册给角色控制器。PlatformAdaptor 是平台隔离的关键很多源码用条件编译宏来定义文件路径// PlatformAdaptor.cs public class PlatformAdaptor { #if UNITY_ANDROID public static string storagePath /sdcard/GameData/; #elif UNITY_IOS public static string storagePath Application.persistentDataPath /; #else public static string storagePath Application.dataPath /../../GameData/; #endif }这段代码展示了 Unity5 客户端最常见的平台差异处理在编译期决定路径。不要在运行时用 Application.platform 去拼路径因为 iOS 和 Android 的文件系统差异在 Unity5 时代很严格。Android 的 /sdcard 路径在 6.0 以上需要动态权限所以源码里如果看到写死的外置存储路径建议全部改成 Application.persistentDataPath这是跨平台最安全的位置。看完这三个文件你会发现整套源码的设计思路是玩法逻辑依赖 InputService 和 PlatformAdaptor 这样的抽象真正的平台代码都躲在宏后面。这正是 C# 高级编程里依赖倒置原则在 Unity5 项目里的朴素版本。后面第 6 章我会再展开接口化改造让这套源码能继续扩展到更多平台。3. C# 与 Build PipelineUnity5 多平台发布的分辨率、包体和条件编译Unity5 的发布不是简单点一下 Build 按钮。Windows、Android、iOS 三方要分别配置 Player Settings还要在 C# 代码里用条件编译控制不同平台的逻辑。这一章我讲 Build 脚本、分辨率适配和包体优化都是实际项目里一定会碰到的点。3.1 Build Settings 中的目标平台切换与 C# 条件编译符号Unity5 的编辑器可以随时切换 Build Target但每次手动切换再点 Build 很容易漏配置。我一般把 Build 过程写成一个 Editor 脚本放在 Assets/Editor 目录下这样版本可控也方便后面接自动化构建。// Editor/MultiPlatformBuilder.cs using UnityEditor; public static class MultiPlatformBuilder { public static void BuildAndroid() { string[] scenes { Assets/Scenes/Main.unity }; PlayerSettings.applicationIdentifier com.example.game; PlayerSettings.SetScriptingBackend(BuildTargetGroup.Android, ScriptingImplementation.Mono2x); PlayerSettings.Android.targetArchitectures AndroidArchitecture.ARMv7; BuildPipeline.BuildPlayer(scenes, Builds/Android/main.apk, BuildTarget.Android, BuildOptions.None); } public static void BuildiOS() { string[] scenes { Assets/Scenes/Main.unity }; PlayerSettings.SetScriptingBackend(BuildTargetGroup.iOS, ScriptingImplementation.Mono2x); BuildPipeline.BuildPlayer(scenes, Builds/iOS, BuildTarget.iOS, BuildOptions.None); } }逻辑说明BuildPipeline.BuildPlayer 在 Unity5 中会在批处理模式下返回 bool但这里写成 void 便于阅读。iOS 平台的输出是一个 Xcode 工程目录Android 输出 APK 文件两者差别很大。SetScriptingBackend 是 Unity5 新增的 API在 Mono 和 IL2CPP 之间选择。很多 Unity5 老源码默认用 Mono但 iOS 官方其实主推 IL2CPP所以这个设置要在 Player Settings 中确认。条件编译是跨平台开发的核心工具。Unity 会在编译期根据目标平台定义不同的宏// PlatformLogger.cs public static class PlatformLogger { public static void Log(string message) { #if UNITY_ANDROID Debug.Log(Android message); #elif UNITY_IOS Debug.Log(iOS message); #else Debug.Log(Desktop message); #endif } }逻辑说明这里把平台日志加上前缀在抓取真机日志时能一眼看出设备平台。UNITY_ANDROID 和 UNITY_IOS 是 Unity5 内置宏在 Unity5.0 以后统一使用 UNITY_IOS如果在老源码里看到 UNITY_IPHONE那是最早的命名最好统一改成 UNITY_IOS否则可能会出现两段都想编译的问题。补充一个参数点Unity5 的 Player Settings 里Android 包名和 iOS Bundle Identifier 必须单独设置。如果源码里只配了 Windows 的 Company Name 和 Product NameAndroid 会默认用 com.DefaultCompany.xxxiOS 审核时经常因为 Bundle ID 不符被拒。我一般在 Build 脚本里强制写入 applicationIdentifier避免人工忘记。3.2 分辨率适配Screen.SetResolution 与 Canvas Scaler跨平台发布最常见的视觉翻车是 UI 变形。PC 窗口可以随意拉伸手机必须锁定方向源码里的 Canvas 和 Camera 如果不做适配换设备就乱。// ResolutionManager.cs using UnityEngine; public class ResolutionManager : MonoBehaviour { [Header(PC窗口设置)] public int desktopWidth 1280; public int desktopHeight 720; void Awake() { #if UNITY_STANDALONE Screen.SetResolution(desktopWidth, desktopHeight, false); #elif UNITY_ANDROID || UNITY_IOS Screen.orientation ScreenOrientation.LandscapeLeft; #endif } }逻辑说明Screen.SetResolution 在 PC 上有效在移动端强行设置窗口会破坏系统 UI所以这里用宏区分。移动端用 Screen.orientation 告诉系统横屏还是竖屏。Unity5 的 uGUI 系统在 Unity 4.6 引入5.x 已经成熟Canvas Scaler 是适配 UI 的关键组件。在 Canvas 上设置 UI Scale Mode 为 Scale With Screen SizeReference Resolution 填 1334x750iPhone 横屏宽高比不同的设备会自动等比缩放。但是 Canvas Scaler 处理不了刘海屏和圆角区域所以源码里还要补一个 SafeArea 适配// SafeAreaAdaptor.cs using UnityEngine; public class SafeAreaAdaptor : MonoBehaviour { private Rect lastSafeArea; void Update() { Rect safe Screen.safeArea; if (safe ! lastSafeArea) { lastSafeArea safe; RectTransform rect GetComponentRectTransform(); rect.anchorMin safe.min; rect.anchorMax safe.max; } } }说明Screen.safeArea 是 Unity 2017 才开始提供的 APIUnity5 项目如果要用需要自己通过 Android 的 SafeArea 插件或者 iOS 的缺口 API 来读取。很多 Unity5 老源码没有这个物体顶部 UI 会被刘海遮住。如果项目要发布到 iOS 11这个适配绕不开。分辨率设置还有一个坑PC 上如果用全屏模式Unity5 会读取 Player Settings 里的默认分辨率不一定和你代码里 SetResolution 一致。我建议在 Window Player Settings 里关闭 Fullscreen Mode改成 Windowed再用运行时代码设置调试起来更顺手。3.3 包体优化Editor 脚本压缩资源与粒子特效内存泄露排查多平台包体大小直接依赖纹理和音频。Unity5 默认导入的图片是原始 RGBA32一张 2048x2048 的图就有 16MB几个角色下来 APK 瞬间破百兆。我习惯在源码工程里加一个 AssetPostprocessor拦截所有新导入的纹理。// Editor/TextureCompressor.cs using UnityEditor; public class TextureCompressor : AssetPostprocessor { void OnPreprocessTexture() { TextureImporter importer (TextureImporter)assetImporter; importer.textureType TextureImporterType.Sprite; importer.textureCompression TextureImporterCompression.CompressedHQ; importer.compressionQuality 50; importer.maxTextureSize 1024; } }逻辑说明AssetPostprocessor 会在资源导入的时候自动执行这里把纹理类型强制设为 Sprite压缩为 High Quality最大尺寸 1024。这样新导入的图不会原封不动进入包体。参数说明compressionQuality 为 50 是锐度与体积的折中如果某张图确实需要 2048可以在 Inspector 里单独覆盖但绝大多数游戏物体根本用不到。粒子特效内存泄露是 Unity5 手游经典难题。现象是每次放技能后内存都上涨越玩越卡最后闪退。原因有三个ParticleSystem 在 Stop 后没有 Clear粒子还要跑完生命周期特效的贴图没有用图集每份特效单独引用纹理会各自留在显存用 Instantiate 和 Destroy 频繁创建特效内存碎片化。对象池是标准解法public sealed class ParticlePool { private readonly ParticleSystem prefab; private readonly StackParticleSystem pool new StackParticleSystem(); public ParticleSystem Spawn(Vector3 pos) { ParticleSystem ps pool.Count 0 ? pool.Pop() : Object.Instantiate(prefab); ps.transform.position pos; ps.gameObject.SetActive(true); return ps; } public void Despawn(ParticleSystem ps) { ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); ps.gameObject.SetActive(false); pool.Push(ps); } }关键在 Despawn 里的 StopEmittingAndClear。如果只调用 Stop(true)粒子系统会立即停止发出新粒子但已经存在的粒子仍然会继续播放完生命周期这段时间里贴图和解算资源都占用着内存。加上 Clear 后粒子立即清空对象池才能真正回收。包体优化除了纹理还要注意音频。Unity5 默认导入的音频是未压缩 WAV一个 30 秒的音乐就是几 MB。在 Audio Importer 里把 Load Type 改成 StreamingCompression Format 选 VorbisQuality 调到 70包体能降一个量级。AssetBundle 也可以按场景拆分但那是 Unity5 中后期玩法这里不展开。4. 核心玩法 C# 实现速度系统、timeScale 与状态机多平台游戏的核心玩法代码和平台关系不大但 C# 写法的好坏会直接决定游戏体验。这一章我讲三个高频需求物体速度获取、暂停的时间缩放、游戏状态切换。这三个地方也是网上搜得最多的 Unity5 问题。4.1 Unity 物体速度怎么获取Rigidbody.velocity 与 Transform 差分网上搜“unity 物体速度怎么获取”第一答案基本都是 Rigidbody.velocity。但源码里的角色很多不是物理驱动而是 Transform.Translate这时候 Rigidbody 是空的必须用位移差分算速度。// SpeedTracker.cs using UnityEngine; public class SpeedTracker : MonoBehaviour { private Vector3 lastPosition; private float currentSpeed; void Start() { lastPosition transform.position; } void Update() { Vector3 delta transform.position - lastPosition; currentSpeed delta.magnitude / Time.deltaTime; lastPosition transform.position; Debug.Log(Speed: currentSpeed.ToString(F2) m/s); } public float GetSpeed() { return currentSpeed; } }逻辑说明每一帧用当前位置减去上一帧位置得到位移向量长度除以时间就是速度。这个方案对任何可移动物体都有效不管它是由动画根骨骼驱动、还是直接改 Transform。如果是物理驱动直接读 Rigidbody.velocity 更准确因为物理引擎在 FixedUpdate 阶段已经完成了力的积分Update 里读一次即可。两种方案不能混用否则同一帧既读物理速度又算位移差会得到两倍速度。参数说明Time.deltaTime 在 Timescale 为 0 时变成 0会算出无穷大速度。所以如果游戏里有暂停功能速度显示必须用 UnscaledDeltaTime而逻辑运算仍然用普通 deltaTime。另外如果用 Rigidbody.MovePosition 移动transform.position 和 velocity 之间有时间差差分值会有锯齿建议直接读 velocity 属性。有些源码还会把速度显示在 UI 上比如赛车游戏的仪表盘。读速度的组件和 UI 组件不要放在同一个 Update 里输入、物理、UI 三者的刷新频率不同强绑在一起会出现速度跳变。我通常把 SpeedTracker.GetSpeed() 按 0.5 秒采样一次传给 UI 更新。4.2 Time.timeScale 的陷阱暂停菜单与动画系统Unity Timescale 是一个经常被误解的字段。下面这两行是很多源码里的暂停写法Time.timeScale 0f; // 暂停 Time.timeScale 1f; // 恢复这几行在 PC 上看起来有效但实际会带来三个问题。第一timeScale 影响 Time.deltaTime游戏里的常规移动和物理全部停住但 Update 仍然每帧调用如果 Update 里还有 DoTween 或协程它们的默认计时器也会停。第二Animator 的动画速度直接受 timeScale 影响暂停后动画会冻结在某一帧恢复时不一定是衔接帧。第三协程 WaitForSeconds 不受真实时间影响timeScale 为 0 时会永久等待。正确的做法是把“时间缩放”和“UI 动画”分开处理。游戏世界的逻辑继续用 Time.deltaTime而 UI 菜单的动画用 Time.unscaledDeltaTime// PauseController.cs using UnityEngine; public class PauseController : MonoBehaviour { private bool isPaused; void Update() { if (Input.GetKeyDown(KeyCode.Escape)) { TogglePause(); } } public void TogglePause() { isPaused !isPaused; Time.timeScale isPaused ? 0f : 1f; // 菜单动画必须使用 unscaledDeltaTime UIMenuGroup.Instance.Show(isPaused); } }注意 Time.timeScale 只影响 Time.deltaTime、物理步进和一部分动画计时不会让 Input.GetKeyDown 失效所以 Update 里的暂停菜单可以在暂停状态下继续响应。但所有 UI Tween 动画必须设置 SetUpdate(UpdateType.UnscaledTime)否则菜单的弹出淡入效果会直接卡住。DOTween 的写法是menuPanel.GetComponentCanvasGroup().DOFade(1f, 0.3f).SetUpdate(true);SetUpdate(true) 就是告诉 Tween 使用真实时间。源码里如果看到暂停后 UI 仍然淡入大概率是漏了这个参数。还有一个坑在场景切换后恢复 timeScale。比如在暂停状态下点击“返回主菜单”主场景的 Awake/Start 会带上 timeScale0 执行导致新场景的动画和粒子全部不播放。我一般在主场景的 GameManager.Awake 里显式写 Time.timeScale 1f这也算源码里的常见兜底。另外Time.fixedDeltaTime 不受 timeScale 影响FixedUpdate 的调用频率不变但物理模拟不会推进所以 Rigidbody 在暂停时不会自己移动。4.3 用源码里的状态机管理游戏流程Unity5 源码里最常见的状态管理是 switch 枚举加 Update 轮询。玩法简单时没问题但多平台功能增多后每个 Update 都要跑一遍所有分支代码越来越难读。我在项目里会用表驱动的方式把状态进入时的动作集中注册using System; using System.Collections.Generic; public enum GameState { Boot, MainMenu, Playing, Paused, GameOver } public class GameStateMachine { private GameState current; private readonly DictionaryGameState, Action stateEnterActions new DictionaryGameState, Action(); public void AddStateEnter(GameState state, Action onEnter) { stateEnterActions[state] onEnter; } public void ChangeState(GameState newState) { if (current newState) return; OnExitState(current); current newState; if (stateEnterActions.TryGetValue(current, out Action action) action ! null) { action(); } Debug.Log(State: current); } }逻辑说明ChangeState 先调用 OnExitState防止两个状态同时处于激活。stateEnterActions 是一个字典每个状态的进入动作可以动态注册比如进入 MainMenu 时加载主菜单场景进入 Playing 时播放背景音乐。相比在 Update 里写 if (state GameState.Playing ...)这种写法更清晰也能让不同状态的行为字段内聚。参数说明Unity5 的 Mono 编译器对 C# 版本支持落后于现代很多新语法比如空合并运算符、字符串插值、表达式体方法在部分 5.x 版本上不认。上面我特意用 if (action ! null) 而不是 action?.Invoke()就是为了兼容 Unity 5.0~5.5 的老编译器。如果你的源码目标是 Unity 5.6 以上可以放心用 C# 6 特性但发布 iOS 时 IL2CPP 对某些 Lambda 表达式会有 AOT 限制遇到再处理。状态机怎么和前面的 InputService 结合在 GameManager 里持有一个 GameStateMachine 实例在 Boot 状态时注册 MainMenu 进入时开启 PlayerInput 监听。这样一来每个状态需要哪些输入就一目了然。想继续扩展比如增加剧情对话状态只需要新增一个 GameState.Cutscene注册进入/退出动作不需要动主循环。5. Unity5 跨平台源码避坑五个高频问题与现场排查跨平台源码跑起来不难难的是三个目标平台各自情况不同。下面五条是我在实际项目里踩过的每个都按现象、原因、解决方法三段写可以直接对着排查。5.1 PC 正常Android 贴图发紫甚至变黑现象同一套源码在 Windows 编辑器里显示正常打包到 Android 后地面和角色贴图变成紫色或者模型直接黑色。原因Unity5 的 Standard Shader 在移动端 OpenGL ES 上并不完全兼容。很多网上源码直接用 Standard Shader但 Android 设备特别吃纹理压缩格式如果 Player Settings 里没有把纹理压缩设置为 ETC2GPU 会读不到贴图显示默认的紫颜色。解决把材质 Shader 改成 Mobile/Diffuse 或 Legacy Shaders/Diffuse然后在 Build Settings 的 Player Settings 里把 Texture Compression 从 Automatic 改成 ETC2。ETC2 需要 OpenGL ES 3.0旧设备会回退到 RGBA16画质略降但至少不紫。改完一定要重新 Build单独在编辑器里把材质改成 Mobile/Diffuse 不重新打包是没用的。如果项目用了第三方 Shader要检查是否声明了 #pragma target 3.0否则有些指令移动端不支持。5.2 iOS 上读取 JSON 中文乱码现象从 StreamingAssets 加载文本或者 JSON中文变成乱码PC 上完全正常。原因iOS 文件系统默认按 UTF-8 解析但源码里的文本文件可能是 ANSI 或 GBK 保存的。Windows 记事本能读iOS 不认识。另一个原因是 StreamingAssets 在 iOS 上只能读且路径在不同平台前缀不一样用错路径也会读到空文件。解决统一用 UTF-8 无 BOM 保存所有文本资源。代码里读取时指定 Encoding.UTF8string json File.ReadAllText(path, Encoding.UTF8);如果是 AssetBundle 加载的 TextAsset需要导入时设置字符编码。注意 Android 上 Application.streamingAssetsPath 在压缩包内File.ReadAllText 读不出来要先用 UnityWebRequest 把文件下载到内存再解析。iOS 不用这一步直接 File.ReadAllText。这种情况下 PlatformAdaptor 的路径又派上了用场。5.3 手柄右摇杆在 Mac 和 Windows 上方向相反现象Windows 上推右摇杆视角向右转换到 Mac 手柄同样推右摇杆视角向左转。原因Unity5 的手柄映射在不同平台有差异右摇杆对应轴可能是 4th axis 或 Joystick Axis 4驱动层极性不一样引擎默认的 Input Axis 在 Mac 上是反向的。这不是游戏代码逻辑错是 Input Manager 映射的锅。解决在 Edit Project Settings Input Manager 里为各个平台单独建轴。比如创建一个 LookX_Windows 和 LookX_Mac在代码里用条件编译取反#if UNITY_STANDALONE_WIN float lookX Input.GetAxisRaw(LookX_Windows); #elif UNITY_STANDALONE_OSX float lookX -Input.GetAxisRaw(LookX_Mac); #endif如果源码不想写两套轴名也可以做运行时校准给玩家一个“反转右摇杆 Y/X”的开关。这个在赛车游戏和飞行游戏里很常见算是最稳妥的方案。真机测试时最好同时用 Windows 和 Mac 两个手柄各跑一遍不要只在编辑器测试。5.4 粒子特效内存频繁生成后内存不降现象游戏运行十分钟每用一次魔法后内存上涨 2~5MB切换场景也不降最终 iOS 闪退。原因特效粒子系统使用 Instantiate 创建不用时 Destroy但 ParticleSystem 的贴图和 Mesh 在 Unity5 中并没有立刻从显卡内存释放而且特效 Shader 引用的材质球如果没打进图集每份特效都保留独自的纹理引用。另一个常见原因是 ParticleSystem 在 Play 后从未 Stop粒子一直空跑。解决用对象池替代 Instantiate/Destroy并在回收时调用 Stop 和 Clearps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); ps.Clear();这句 Clear 是后悔药很多人漏掉。同时检查特效 Prefab 的材质球确保贴图使用图集不要用 Scene 里临时勾选的材质。如果内存仍然持续上涨用 Unity Profiler 的 Memory Profiler 抓取 Allocated Objects筛选 ParticleSystem 和 Texture2D就能确认谁没有被池化。5.5 下载的源码在更高版本 Unity 打开后脚本全部丢失现象用 Unity 2018 或 2022 打开 Unity5 项目Inspector 里一片 Missing Script预制体上的脚本引用全部变灰运行也报空引用。原因Unity 用 .meta 文件里的 GUID 来关联脚本。源码上传时如果把 .meta 文件删了或者新版本打开时重新生成了 GUID原有引用全部失效。这和 Unity5 本身无关但很常见尤其是从 GitHub 下载的源码经常不带 .meta。解决保留原始 .meta 文件不要用版本管理工具清理。如果已经丢了只能用插件或者手写工具根据脚本路径重新生成引用但场景里几十个对象要逐一绑定工作量很大。更简单的办法是直接用 Unity 5.6 打开不要强行迁到新版本。如果项目必须升级把脚本的 API 从 Unity5 风格改成新版本同时保留 .meta迁移才可能成功。6. 把源码变成自己的扩展框架IPlatformBridge 与三平台自动验证源码读完、踩坑排完最后一步是把它改造成你自己的框架。我一般会先把平台差异从宏里抽出来换成接口隔离再写一个自动验证脚本让三个平台的构建结果可复现。6.1 用接口隔离平台差异PlatformAdaptor 用宏切路径能解决问题但玩法代码要调用平台特有能力震动、Scheme 跳转、本地通知时宏会变得越来越难维护。C# 的做法是定义一个 IPlatformBridge 接口每个平台写一个实现类public interface IPlatformBridge { void Vibrate(); string GetPersistentPath(); } #if UNITY_ANDROID public class AndroidBridge : IPlatformBridge { public void Vibrate() { /* 调用 Android 原生震动 */ } public string GetPersistentPath() { return Application.persistentDataPath; } } #elif UNITY_IOS public class iOSBridge : IPlatformBridge { public void Vibrate() { /* Handheld.Vibrate() */ } public string GetPersistentPath() { return Application.persistentDataPath; } } #else public class DesktopBridge : IPlatformBridge { public void Vibrate() { /* PC 一般不做震动 */ } public string GetPersistentPath() { return Application.dataPath; } } #endif主逻辑只依赖 IPlatformBridge新增平台时只需要增加一个实现类不需要去改玩法代码。这是这套源码里最值得借鉴的部分。接口定义越精简越好不要放十几个方法否则每个实现类都写一堆空操作。6.2 三平台自动验证方法源码在编辑器里跑通不算完。我通常在构建后做冒烟测试在三个平台各自进入游戏跑 5 分钟检查启动日志、场景加载和关键数据是否正常。Unity5 没有内置的自动化 UI 测试但可以用批处理模式加日志if (DateTime.Now - startTime smokeSeconds) { Debug.Log(SMOKE PASS); EditorApplication.isPlaying false; }Android 和 iOS 需要真机Windows 可以直接命令行传参。这个脚本能捕捉到 PC 正常但移动端闪退的回归问题。我最大的教训是不要以为 Desktop 跑通就万事大吉iOS 的 ARM64 浮点行为和 Mono 差异很大Android 的纹理压缩更严格必须在真机跑一遍。调试时保留关键日志发布前再用无日志版本。希望帮到你。本文还有配套的精品资源点击获取