简介:《Unity5实战:使用C#和Unity开发多平台游戏》配套源码,面向初入Unity的游戏开发者及希望提升C#脚本能力的读者,展示跨平台游戏从场景搭建、组件交互到逻辑编写的完整路径。包体为7z压缩,约130.09MB,包含场景文件、C#脚本、预设体及Shader、Material配置等工程内容,部分章节附开发笔记,便于快速定位关键代码。已有490人学习下载,适合实践驱动式学习。研读源码可理解MonoBehavior生命周期中Awake、Start、Update、OnDestroy等方法调用时机,学习用类、继承、接口组织游戏逻辑;亦可参考iOS、Android、Windows等多平台发布时的输入适配、性能优化与分辨率适配处理。 前两年我还在用Unity5做项目的时候,接过一个需求:把一个2D休闲游戏同时发布到Android和iOS。当时我挺乐观的,毕竟Unity的招牌就是跨平台,C#写一套逻辑,源码也不用怎么动,Build Settings里切一下Target Platform,点个Build,不就成了吗。结果第一轮双端验收,我就在真机上被教育了:Android上跑得欢,iOS包启动就崩。这种“同一份源码,不同命运”的经历,做多平台游戏的人多少都遇到过。
这篇文章不打算讲那种“照着敲一遍就完事”的教程,我重点想聊的是:当你手里真的握着一套要用C#写、要跑多平台的Unity项目源码时,代码结构该怎么搭、C#的实战套路怎么用、平台差异要防在哪里、性能和内存的坑长什么样,以及那次把我折腾到凌晨的iOS崩溃排查全过程。不管你是准备把项目从单平台扩到多平台,还是已经被第一次双端构建折磨过,这篇应该都能给你一些能直接抄作业的东西。
1. 先搞清楚“多平台”到底多在哪,再动手写代码
1.1 别把“跨平台”理解成“一次编写,处处运行”
很多人对Unity多平台开发的第一印象是:写一套代码,Build菜单里选个平台,完事。这个印象在纯UI Demo或者极简原型阶段还勉强成立,一旦项目里出现文件系统、输入交互、性能调配、平台API这类东西,你就会发现所谓的“跨平台”其实拆开看是另一回事。
我习惯把多平台差异浓缩成三个维度:
- 硬件资源差异。手机GPU大多基于Tiled架构,桌面GPU是Immediate Mode渲染,同样一帧特效在PC上满帧,在手机上可能直接卡到个位数。更不用说内存带宽、CPU频率、发热降频这些因素,平台之间的“平均素质”都不一样。
- 交互方式差异。移动端是触摸,桌面端是鼠标键盘,主机端是手柄,哪怕只是做一个简单的“点击屏幕”操作,底层拿到的输入源也完全不同。
- 系统API与文件结构差异。文件路径大小写敏感不敏感、沙盒目录在哪、有没有统一的持久化路径、网络权限和相册权限怎么申请,每套系统都有自己的脾气。
这三点里,第一点和第三点最容易在项目后期引爆问题。特别是文件系统,你在Windows上写的路径用得很顺手,一到iOS上就因为大小写问题崩给你看。这件事我后面会完整复盘。
1.2 多平台开发的最低成本方案
我踩过几轮坑之后,总结出来的方案其实很简单:核心逻辑保持纯净,平台差异全部走抽象层。核心逻辑指的是游戏规则、数据模型、战斗计算这类不依赖任何设备特性的部分,它们应该尽可能写成纯C#类,不继承MonoBehaviour,不直接调用Unity API。平台相关的服务,比如文件读写、设备信息、网络状态,统一抽成接口,每个平台各写一套实现。
这样做的理由很实际:第一,纯C#类可以在编辑器之外做单元测试;第二,新增平台时你只需要新增一套平台实现类,不需要动游戏逻辑;第三,查找问题时范围被大大缩小——你只需要盯住Platform层,核心逻辑几乎不用怀疑。
有人可能会觉得这个结构初期有点“重”,小项目没必要。我的看法是,哪怕只是做一个接广告的小游戏,只要你想同时上Android和iOS,这个抽象层的成本也必须要付。否则后续每个平台差异点都散落在代码里,维护起来才是最贵的。
2. 源码分层:一套能支撑多平台的Unity工程长什么样
2.1 一个稳定可扩展的目录骨架
我在新项目里通常会先把Scripts目录按职责拆成四块,而不是一开始就按功能模块堆文件夹。因为按功能模块堆容易让平台代码、UI代码、逻辑代码混在一起,后期想抽离会非常痛苦。下面是我目前一直在用的结构:
Assets/ ├── Scripts/ │ ├── Core/ # 纯C#逻辑,不依赖Unity运行时 │ │ ├── GameManager.cs │ │ ├── DataModel/ │ │ └── EventSystem/ │ ├── Gameplay/ # MonoBehaviour、角色控制、关卡逻辑 │ ├── Platform/ # 平台差异封装 │ │ ├── IPlatformService.cs │ │ ├── Android/ │ │ └── iOS/ │ ├── UI/ # UI控制器和视图 │ └── Editor/ # 编辑器工具脚本,只放UNITY_EDITOR下 ├── Resources/ ├── Plugins/ # 原生SDK和原生插件 └── StreamingAssets/Core里的GameManager并不是挂场景里的MonoBehaviour,而是一个普通C#类,负责维护游戏状态和玩家数据。它不关心UI怎么显示,也不关心按钮怎么点击,只负责“规则”。这一层越干净,后面的跨平台适配就越省心。
Gameplay这一层才是真正和场景打交道的地方。MonoBehaviour主要出现在这里,生命周期、协程、物理碰撞都在这一层处理。UI层单独拆出来,是因为在Unity里UI的更新频率和游戏逻辑的更新频率通常不一样,而且UI经常要针对不同屏幕尺寸做单独适配。
2.2 平台服务封装示例
Platform目录是整个多平台方案的落地位置。我第一版写平台服务时,是从一个需求开始的:需要一个跨平台的存档路径,以及一个能区分不同设备的设备ID。于是先定义接口:
public interface IPlatformService { string GetSavePath(); string GetDeviceID(); }然后按平台分别实现:
#if UNITY_ANDROID public class AndroidPlatformService : IPlatformService { public string GetSavePath() { // Android的沙盒持久化目录 return Application.persistentDataPath + "/save"; } public string GetDeviceID() { return SystemInfo.deviceUniqueIdentifier; } } #endif #if UNITY_IOS public class IOSPlatformService : IPlatformService { public string GetSavePath() { return Application.persistentDataPath + "/save"; } public string GetDeviceID() { return SystemInfo.deviceUniqueIdentifier; } } #endif再挂一个简单的平台服务定位器,在游戏启动时按当前平台注册对应的实现:
public static class PlatformServiceLocator { public static IPlatformService Service { get; private set; } [RuntimeInitializeOnLoadMethod] private static void Initialize() { #if UNITY_ANDROID Service = new AndroidPlatformService(); #elif UNITY_IOS Service = new IOSPlatformService(); #else Service = new DesktopPlatformService(); #endif } }这段代码是Unity5时代就能跑的写法,没有依赖任何新特性,但它的价值不在代码本身,而在它把“平台差异”收敛到了一个目录里。后续无论加权限申请、加分享功能、加广告SDK,都在这一层做,游戏玩法层永远只认接口,不关心对面是Android还是iOS。
3. C#实战模式:单例、事件与协程别用错了地方
3.1 单例不是银弹
Unity开发者对MonoBehaviour单例应该都不陌生,网上大量教程都在用这种写法:
public class GameManager : MonoBehaviour { public static GameManager Instance { get; private set; } private void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); } }我承认这种写法在小项目里很方便,但在多平台项目里,我强烈建议把单例的数量控制在个位数以内,最好只有启动器、音频管理器这类全局对象。原因很简单:单例本质上就是隐式全局状态,而全局状态在多平台调试时非常难定位。同一个场景在Android上点过去正常,在iOS上点过去变量已经变成null,你很难确定是哪段代码在哪个时机把它改了。
如果一定要用,请至少保证OnDestroy里把Instance置空:
private void OnDestroy() { if (Instance == this) { Instance = null; } }这个细节能帮你省掉大量“切换场景导致单例残留”的问题。
3.2 事件驱动比Update轮询更适合游戏内通信
多平台游戏里,UI、角色、音频、存档模块之间经常要互相通知。新手最容易做的是写一个全局Update,每一帧检查某个道具数量有没有变化,变了就去刷新UI。这种轮询写法在PC上可能没感觉,在手机上就是白白消耗CPU和电量。
正确的做法是用事件。C#本身就有完善的event和Action机制,完全不需要引入额外框架。我平时会写这样一个简单的事件总线:
public static class EventBus { public static event System.Action<int> OnScoreChanged; public static void NotifyScoreChanged(int newScore) { OnScoreChanged?.Invoke(newScore); } }得分变化时调用NotifyScoreChanged,UI订阅OnScoreChanged事件,里外解耦,代码清晰。不要觉得事件会带来GC开销,只要不在一帧里频繁触发几千次,这种事件模式的性能完全够用,而且可维护性远高于一坨Update轮询。
3.3 协程是延迟逻辑的好朋友,但别滥用
Unity里协程适合做延时、动画序列、异步加载这类带时间轴的操作。比如新手引导里每两步之间等1.5秒、血条扣血时渐变播放,协程是最容易读懂的写法。
但要注意,协程不是线程,它仍然运行在主线程上,只是把逻辑拆成了多个片断,在一段时间内分片执行。如果协程里写了耗时同步操作,一样会卡主线程。真机上最常见的卡顿之一,就是某个协程里做了Resources.Load同步加载,加载一个大的Prefab就把帧率拉下来。耗时操作尽量用异步加载,加载大资源时可以考虑加载完成后回调事件,而不是让玩家盯着卡住的加载页。
4. 平台适配的三块硬骨头:宏、分辨率与输入习惯
4.1 条件编译宏:写一次,各自编译
Unity的#if指令是处理平台差异的基础工具。我最常用的几个平台宏是:
| 宏 | 对应平台 |
|---|---|
| UNITY_EDITOR | 编辑器环境 |
| UNITY_ANDROID | Android平台 |
| UNITY_IOS | iOS平台 |
| UNITY_STANDALONE_WIN | Windows独立平台 |
| UNITY_STANDALONE_OSX | macOS独立平台 |
条件编译的典型用途就是写平台专属代码。比如不同平台存档路径的拼接,直接写成这样:
public static string GetSavePath() { #if UNITY_ANDROID return Application.persistentDataPath + "/AndroidSave"; #elif UNITY_IOS return Application.persistentDataPath + "/iOSSave"; #else return Application.dataPath + "/DesktopSave"; #endif }需要提醒的是,宏判断在编译期生效,意味着你写了#else分支,在Android上编译时根本不会进入iOS分支的代码。这个特性既是好处也是风险:好处是平台专属代码不会互相干扰,风险是你可能写了很久的iOS逻辑,自己却从未在Android包里验证过。所以每次Build前,先确认当前Target Platform,再检查#if里的分支是否和你心里想的一致,这个习惯非常关键。
4.2 分辨率与SafeArea适配
多平台意味着设备屏幕比例千奇百怪,从iPad到带刘海的iPhone,从4:3的安卓平板到21:9的带鱼屏显示器都要跑。UI如果写死坐标,换一台设备就穿帮。Unity的Canvas已经支持屏幕自适应,但很多项目还是会忽略刘海屏的SafeArea。
我建议至少把顶部、底部安全区域适配做掉,方法是在UI根节点上加一个SafeArea组件,动态读取Screen.safeArea并设置锚点偏移。Unity5时代没有内置组件,但这个思路照样能自己实现:把Canvas根节点的RectTransform按safeArea的坐标偏移,内容全部放在这个根节点下,就能避开刘海和底部Home条。我见过太多游戏上线后被App Store审核以“UI被刘海遮挡”打回,这个问题一定要提前处理。
4.3 输入接口别写死
鼠标输入和触摸输入在Unity里API不同——Input.GetMouseButton在编辑器里好用,到了手机触摸屏上如果只监听鼠标事件,就会出现“点了没反应”的诡异表现。反过来,只监听Input.touches,在PC上调试又会很难受。
我的做法是做一个输入转换层,把所有输入统一转成GameEvent。触摸、鼠标点击、手柄确认都发同一个“确认”事件,游戏逻辑只认事件不认输入源。这样Windows上正常Debug,手机上也不会漏输入,后续接手柄或者加键盘快捷键都只需要多注册一个来源。这个成本不高,但能避免掉很多“为什么手机上没反应”的低级问题。
5. 性能与内存:真机上最容易翻车的几个热点
5.1 GC Alloc是持续卡顿的元凶
Unity里的GC是一个非常现实的话题。C#的自动内存管理在编辑器里看起来没什么,但手机上内存本来就紧张,如果代码里频繁产生垃圾,GC一触发,帧率就像被抽走一样突然掉下来。最典型的几个GC大户是:字符串拼接、LINQ、在Update里GetComponent、频繁创建和销毁临时对象。
我自己原先在伤害飘字里用过$"伤害:{value}",PC上完全没感觉,手机上一堆飘字同时出现,Profiler里GC Alloc直接飙到几百KB一帧。后来改成用StringBuilder复用或者用对象池缓存字符串,问题才消停。
5.2 对象池:别把Instantiate和Destroy当饭吃
Instantiate和Destroy是Unity里非常贵的操作,因为它们涉及原生对象的创建和销毁。频繁生成子弹、特效、敌人,每一帧new一个再Destory一个,手机上很快就会变得卡顿。
对象池的思路特别简单:准备一个栈,需要对象时从栈里拿,没有就实例化;用完不销毁,而是回收进栈。等到战斗结束或者切场景时一次性清空。一个通用对象池类并不复杂,但换来的性能提升非常明显。
5.3 Profiler是排查性能问题的唯一靠谱手段
我在性能问题上的经验是:不要猜,直接用Profiler看。Unity的Window → Profiler能告诉你每一帧的CPU消耗、GC Alloc、渲染耗时、内存占用。连接真机调试后,能看到手机上真实的瓶颈在哪里。
这里有一个新手容易忽略的点:Profiler的Deep Profile模式开销很大,会把帧率拉到很低,但它能精确告诉你每个函数的耗时。线上项目不建议默认开启,排查问题时开一下,定位完马上关掉。真机Profile需要项目启用了Development Build,这点要在Build设置里提前勾选,否则没法弹出连接选项。
6. 一次Android正常、iOS启动崩溃的完整排查记录
6.1 现象与初步定位
那次项目的崩溃现象非常典型:同一套Unity5工程,在Android上打包运行一切正常,切到iOS打包后,启动画面一闪就崩溃。第一个排查思路是看崩溃日志,Unity的崩溃日志在iOS上一般能从设备日志或者Xcode控制台里拿到。
日志里有一行很关键,大概意思是:在读取存档目录时找不到某个文件。因为启动流程里第一步就是读取存档,不存在就直接创建,但iOS上文件系统的表现和Android不一样,所以崩溃点被带出来了。
6.2 分平台二分法排查
确定是文件系统问题之后,我做了个简单的二分法:先把启动流程里所有初始化步骤注释掉,保留最小启动,发现一切正常;再逐步恢复,直到加载存档模块时必定崩溃;最后定位到是File.Exists判断了一个路径,但那个路径在iOS沙盒里根本不存在。
真正让我冒冷汗的是根因:存档文件名在Android下用小写写进去,在iOS启动时读取却用了大写。Windows和Android的文件系统默认对大小写不敏感,iOS的文件系统则严格区分大小写。这套代码在Windows上开发了几个月都没事,换了iOS立刻暴露。
6.3 根因与修复方案
修复本身很简单:统一大小写命名,用Path.Combine拼接路径,不手写字符串路径。但更重要的一件教训是:所有涉及文件操作的代码都要走PlatformService,不能在业务代码里直接调Application.persistentDataPath或者用字符串拼路径。这样即使路径规则变了,也只需要改一处平台实现,而不是去全局搜索所有出现过路径的地方。
我后来把这条写进了团队规范:业务代码里禁止出现裸写的路径字符串,禁止直接调用Application.persistentDataPath,必须通过平台服务层获取。这个规范听着很教条,但它是那次iOS崩溃换来的,值得遵守。
最后再分享一个小习惯:每次Build前,我一定会先在Build Settings里确认当前Target Platform是不是目标平台,然后跑一遍“按平台检查”的清单。清单内容包括:有没有未处理的#if分支,有没有裸路径字符串,UI有没有做SafeArea适配,有没有开启Development Build方便真机Profile。这些事都很琐碎,但正是这些琐碎的事,决定了一套源码是真的跨平台,还是只在你的开发机上能跑。
本文还有配套的精品资源,点击获取