写了两三年 App,第一次打开 Unity 的时候,说实话我有点懵:同样是写代码,怎么一切都不太对劲?对象不用 new,事件不用自己注册,UI 不是 View 而是组件挂在节点上,连坐标原点都在屏幕左下角。更要命的是,以前写的那些“界面刷新逻辑”在这里好像完全没用了——游戏不允许你只在数据变化时才更新,它每帧都在跑。
后来我意识到,从 App 开发切到 Unity 游戏开发,真正需要的不是重新学一遍语法,而是建立一张“认知地图”:把你已经知道的 Android、iOS 或小程序开发经验,映射到 Unity 的世界里,找出哪些是同构的、哪些是彻底不同的、哪些需要保留但换一套表达方式。这个映射过程做完,加上现在 AI 辅助工具已经能分担大量代码编写和排查工作,转型效率会高很多。这篇东西就是我个人这段时间实践下来的完整记录,写给同样想做这个切换的客户端开发者参考。
1. 认知地图:App 开发者的经验坐标如何映射到 Unity 的版图
1.1 一张表看清“同构点”和“不同构点”
先别急着去背 API。我建议把 App 开发里的核心模块一个个列出来,再看 Unity 里对应的东西是什么。下面这张表是我自己整理后一直在用的,基本框架没有变过:
| 你在 App 开发里熟悉的东西 | Unity 里的对应物 | 是否直接迁移 |
|---|---|---|
| Application 生命周期回调 | MonoBehaviour 的 Awake/Start/OnEnable | 否,触发时机完全不同 |
| 页面跳转 / 导航栈 | 场景切换、UI 面板控制 | 否,没有系统返回栈 |
| 数据变化后手动刷新 UI | 每帧 Update + 事件驱动 | 否,主循环模式是根本差异 |
| 依赖注入容器 | 场景引用、Prefab 绑定、ScriptableObject | 部分迁移,思路保留 |
| 网络层 / 数据层分离 | 同样适用,C# 里照样分层 | 是,完全保留 |
| 异步网络请求 | async/await 或协程 | 是,不过要遵守 Unity 的线程规则 |
| 图片加载 / 缓存 | Resources / Addressables / Sprite | 否,资产管线需要重新学 |
| MVVM / MVC 架构 | 可以继续用,但绑定方式不同 | 部分迁移 |
| 本地存储 | PlayerPrefs / ScriptableObject | 简单场景可用,复杂数据选其他方案 |
| 打包和发版 | Build Settings + 各种平台模块 | 部分迁移,坑更多 |
这张表的意义不是让你背下来,而是让你在遇到具体问题的时候有一个“定位坐标系”:这个东西我在 App 里是怎么做的?Unity 里对应的东西叫什么?两个方案的底层逻辑是否一致?大部分情况下,你会发现思路层面是通的,只是表达方式变了。
1.2 最根本的转变:主循环模型 vs 事件驱动模型
在我所有踩过的坑里,最深的一个就是“刷新模型”的差异。App 开发里,UI 通常是事件驱动的:用户点击了按钮,回调触发,网络数据回来,ViewModel 更新属性,页面局部刷新。没有事件就没有刷新,帧率是省出来的。
但游戏不是这样。游戏是主循环驱动的,每一帧都要重新计算、重新渲染,哪怕什么事情都没发生,Update 里面的逻辑照样要跑。我一开始写游戏逻辑时,习惯性地把状态变化放在事件回调里,只在“需要更新”的时候更新界面,结果角色动起来一顿一顿的。后来才意识到,游戏里的“界面”不是静态面板,而是每帧重绘的画布,你的逻辑必须配合这个节奏。
这不是技术差异,是思维方式的差异。App 开发者的很多优化直觉——减少刷新、按需加载、延迟绑定——在游戏里依然有价值,但必须建立在“每帧都在执行”这个前提之上。你要优化的不是“少跑一点”,而是“每次跑得足够快”。
1.3 能带过来的工程习惯:分层架构依然有效
好消息是,App 开发里磨练出来的工程能力大部分还是有效的。我最初担心 Unity 是个“什么都往场景里拖”的世界、代码架构会很随意,实际做完几个 Demo 之后发现,只要你严格把代码分成三个层次——数据层(负责存取和网络)、逻辑层(负责玩法规则)、表现层(负责场景和 UI)——整个工程依然是清晰可维护的。
唯一的区别是:App 里你用接口和依赖注入把各层串起来,Unity 里你更多的是通过组件引用来做同样的解耦。比如说,数据层需要一个网络服务的实例,App 里你会从容器里拿,Unity 里你可以在场景里放一个挂某个网络组件脚本的空节点,其他人通过 GetComponent 或者 inspector 拖引用去拿。
不要觉得用 GetComponent 就低人一等,也不要一上来就嫌弃这种模式。它只是表达方式不同,背后的依赖倒置原则是一样的。
2. 坐标系革命:从像素坐标思维到三维变换思维
2.1 App 里的坐标 vs Unity 的坐标
这个问题我做了差不多一周才彻底转变过来。App 开发里你可能天天和像素、dp、pt、锚点、安全区打交道,坐标系的原点大多在左上角,x 向右,y 向下。这个坐标系在 Unity 里完全不成立。
Unity 的坐标原点是世界空间的某个点,默认在场景正中央,x 向右、y 向上、z 向屏幕外。所有物体都在三维空间里有自己的坐标,它们之间的位置关系通过 Transform 组件维护。即便你做的是 2D 游戏,底层依然是三维坐标系,只不过把 z 轴冻结在一定位置而已。
更重要的是单位制。App 里 1 个像素或 1 个 dp 是没有物理意义的,Unity 里 1 个单位默认等于 1 米,物理系统就是按这个单位计算的。我见过有同学做 2D 物理游戏时想当然地写transform.position = new Vector3(100, 200, 0),结果两个物体的距离是几百“米”,重力加速度、碰撞检测全乱了套。建议新手无论如何先按 1 单位 = 1 米的物理直觉来设计尺寸,再做视觉上的缩放适配。
2.2 相机不是装饰品
App 开发里没有“相机”这个概念,你看到的页面就是屏幕上直接渲染出来的。Unity 里所有画面都通过相机才能显示。这个差别刚开始特别容易忽略,因为你新建场景的时候 Unity 已经给你放好了一个相机,你不修改它也能看到默认画面。但一旦你的游戏需要跟随角色移动、放大缩小、分屏或者做 UI 叠加,你必须理解相机的原理。
相机的核心是两种投影方式:正交投影和透视投影。2D 游戏一般用正交,3D 用透视。正交投影下物体的视觉大小不因距离而变化,透视投影下近大远小。UI 层级的显示也要通过 Canvas 的渲染模式来指定:Screen Space - Overlay 模式下 UI 永远在最上层并且不需要相机参与,Screen Space - Camera 和 World Space 模式则依赖相机来决定 UI 的呈现位置和大小。
我的建议是:第一天就把相机的属性面板打开,把 Clipping Planes、FOV、正交尺寸这几个参数全都亲手拖一遍,观察观察场景里物体视觉变化。这个环节搞懂了,后面 70% 的“看不见、显示错、位置不对”问题都能自己定位。
2.3 一个典型 bug:把屏幕坐标当世界坐标用
分享一个我真实排过的过程吧。有一次做一个塔防 Demo,需要在屏幕的某个像素位置摆放一个 UI 提示,我按照 App 的习惯,计算出像素坐标后直接赋值给物体的 Transform 位置,结果提示图标飞到了场景之外。
排查了半天才发现问题所在:UI 元素的坐标和世界物体的坐标是两套系统。UI 用的 RectTransform 有 anchor、pivot 这些概念,坐标参考的是 Canvas 坐标系;而场景物体的 position 是相对于世界原点。这两者要转换,通常需要 Camera.ScreenToWorldPoint 或 RectTransformUtility.ScreenPointToWorldPointInRectangle 这类 API。
这个 bug 给我的启发是:在 Unity 里不要默认某个坐标值可以到处用,写代码前先弄清楚这个坐标是屏幕像素、Canvas 坐标、局部坐标还是世界坐标。坐标系的错位是新手最容易踩、又最难自查的坑。
2.4 需要补的数学:够用就行,不用重学线代
听到“三维向量”“矩阵变换”就害怕的同学可以放心,日常做游戏用到的数学没有你想的那么难。我转型期间只复习了几个基础概念,就基本够用了:向量表示方向和位置,点乘算夹角和投影,叉乘判断左右和法线,插值 Lerp 做平滑移动。
举个例子:你想让角色朝某个目标点移动,不需要自己去算三角函数,直接用Vector3.MoveTowards或者Vector3.Lerp,内部帮你处理了。判断一个敌人是在玩家的左边还是右边,用一次叉积的正负就能得到,不用写条件枚举。
把这些高频数学操作整理成一个“常用数学速查表”,遇到不懂的翻译成大白话即可:Dot 就是两个向量有多“同向”,Cross 就是它们在三维空间构成了什么样的平面关系。0基础的线代不需要,但建议知道这些函数存在。
3. 场景、生命周期和主循环:Unity 如何接管你的游戏逻辑
3.1 MonoBehaviour 回调 vs 系统级生命周期
App 开发里你会在整体应用级别监听 onBackground、进入前台、退出等事件,在页面级别处理页面显示、隐藏。Unity 里也有对应的机制,但粒度完全不同:通过挂在 GameObject 上的组件(脚本),在组件被激活或对象被创建、销毁时自动触发一系列回调。
一个最常见的误解是混淆Awake和Start。Awake 在对象生成时立刻调用,无论脚本是否启用;Start 在对象被启用且第一次进入主循环前调用。如果你在 Awake 里访问其他可能还没初始化的组件,很容易拿到空引用。我的习惯是:Awake 里只做自初始化,比如获取自身组件引用、初始化私有变量;跨对象依赖放到 Start 里做。这样能避免一大批早期空引用问题。
再一个容易忽略的是OnEnable和OnDisable。对象反复启用禁用时它们会被反复调用,而 Awake/Start 只调用一次。如果你的对象有频繁“启用/隐藏”的需求,要把重新初始化的逻辑放在 OnEnable 而不是 Start 里,否则会出现在第一次禁用后重新启用时,状态没有恢复的诡异问题。
3.2 FixedUpdate、Update、LateUpdate:三个阶段各干各的
这是 Unity 主循环里最容易让 App 开发者懵圈的三兄弟。简单说:FixedUpdate 以固定的物理步长调用,适合写物理相关的操作,比如移动刚体、施加力;Update 每帧调用一次,适合处理输入、普通游戏逻辑、UI 状态;LateUpdate 在 Update 之后执行,适合做相机跟随等需要基于最终状态的逻辑。
为什么要分三个?因为物理引擎需要确定的步长做碰撞检测和刚体模拟,如果像 Update 那样帧率忽高忽低,物理结果就会变得不稳定。相机跟随放在 LateUpdate 里,是为了确保所有角色都完成移动后,相机再根据最终位置去做插值,画面才不会抖动。
我转型初期最常犯的错是在 Update 里直接修改刚体的 velocity 或者用 transform.position 强行挪动刚体,导致物理表现很怪。后来强制自己遵守一个规则:走物理引擎的都用 FixedUpdate,读物理状态但只做视觉表现的都在 LateUpdate 读取。
3.3 场景、Prefab 和引用关系:类似依赖注入,但要换思路
App 开发里我们习惯用容器管理依赖,Unity 则是通过场景和 Prefab 的引用关系来组织对象的生态。你可以把场景看作一个预设好的“运行地图”,里面摆好了各种游戏对象,对象之间通过 inspector 拖拽建立引用。这种方式比代码里手动 new 显然直观得多,但也会带来维护上的隐患。
最头痛的问题就是“场景大爆炸”:所有对象都直接摆在场景里,依赖关系遍布,团队成员合并版本时处处冲突。我的转型体会是:能做成 Prefab 的尽量做成 Prefab,Prefab 本身作为可复用模板,通过嵌套 Prefab 组织复杂结构;场景里只放入口对象和全局管理器。这样即使有人改了某个 Prefab,场景层面的冲突面会小很多。
如果追求更高程度的解耦,可以引入 ScriptableObject 做纯数据配置,把玩法参数、角色属性、关卡配置全部独立成资产。这样调整数值不用改代码,策划和运营同学也能直接编辑配置,这一点其实比 App 开发的本地配置文件更顺手。
3.4 一帧只有 16.67 毫秒:为什么 Update 里不能写耗时逻辑
移动端 60 帧是基本目标,一帧只有约 16.67ms 的渲染和逻辑预算。如果 Update 里出现超过这个量的计算,帧率就会下来。这个约束,App 开发者在主线程里也会遇到,但是平时触达的频率没有游戏里这么高。
举几个我在代码里常见的问题:每帧去FindGameObjectWithTag、每帧去GetComponent、每帧创建临时 List 或者大量字符串拼接、每帧加载资源。这些在 App 开发里可能只是性能不佳,在游戏里直接就是掉帧。
正确做法是:能缓存引用就缓存引用,能避免分配就避免分配,能提前加载的资源绝不在 Update 里同步加载。这里有个非常实用的检查习惯:用 Profiler 跑一次真机 Profile,排在最前面的热点函数如果在 Update 里,那它就是需要优化处理的对象。不要凭感觉优化,要让数据说话。
4. 让 AI 干活:从提示词到可玩 Demo 的完整实践链路
4.1 我的 AI 协作流程和提示词模板
标题里说“结合 AI 开始实践”,这部分可能也是大家最感兴趣的。我先说明:我不建议把 AI 当成权威代码生成器,问一句“给我一个游戏”就照单全收。更好的定位是把它当成一个“经验很丰富但经常走神的结对编程伙伴”:给它明确约束,它给你高质量初稿,你再检查关键节点。
下面给出我实际用的一段提示词模板,基本能满足大多数小游戏脚本生成需求:
你是一名资深 Unity 开发者。我要实现一个小游戏:玩家控制底部挡板,接住从顶部掉落的圆球。每接住一个球加一分,漏掉一个球减一条命,三条命用完游戏结束。 技术约束: - 角色通过水平方向键或鼠标控制挡板移动 - 掉落物使用 Rigidbody 2D 和碰撞检测 - UI 用 uGUI,显示分数和生命值,放在 Screen Space - Overlay 的 Canvas 下 - 不要让物体穿过挡板,碰撞后销毁球并增加分数 - 代码写在 MonoBehaviour 组件里,禁止使用第三方插件 - 不要使用 Find 或 FindObjectOfType 获取引用,通过 inspector 拖拽或私有字段序列化 - 请提供完整可运行的 C# 脚本这个模板的要点在于:我明确规定了技术栈(Rigidbody 2D、uGUI)、禁止事项(Find、插件)、代码风格(序列化字段)、交付物(完整脚本)。AI 拿到这种输入后,生成结果比那种一句话提问要可靠得多。
4.2 生成之后必看的 6 个检查点
AI 生成代码的速度确实快,但我从实践中总结了一个约定:无论 AI 写得多好,以下六个检查点必须过一遍。
第一,是否在生命周期正确的位置执行逻辑。比如初始化放在了 Update 里,或者把每帧执行的操作放在了 Start 里。第二,是否频繁调用 GetComponent、Find 这类运行时查找。AI 最容易在 Update 里默认调用。第三,引用和空判断是否安全。AI 生成的代码经常假设目标对象一定存在,但你在场景里稍微调整结构就会出空引用。第四,物理相关操作是否走物理系统。第五,UI 坐标和世界坐标是否混淆。第六,通信方式是否合理,比如大量使用 SendMessage 或者全局静态变量来传数据。
我一般会让 AI 先自查一遍,并把它的自查结果放到一个 check 列表里,然后我自己再做代码走查。双重检查后,踩坑率能降低一大半。
4.3 我真实踩过的坑:AI 写了一版“只在变化时刷新”的代码
分享一个真实教训。有一次我让 AI 写一个“角色血量变化时更新血条”的功能,AI 生成的代码按照“当 HP 数值变化时再修改 UI 文本”的思路实现——在 Update 里比较当前值和上次值,发现不同才更新。这个逻辑放在 App 里没问题,甚至是很优秀的写法,但在游戏里有一个隐藏问题:血条如果是用于平滑动画,它需要在每帧推进动画;如果只在数值变化瞬间更新,动画会生硬跳变。
这是典型的 AI “综合了 App 开发经验”产生的输出,它不理解游戏视觉呈现里的连续变化需求。我后来在给 AI 的需求约束里加了“所有视觉表现应当考虑帧率连续性,不要在单一事件里直接跳变”这样的提示,同时自己检查时也多了根弦:拿到代码后,会先判断这段逻辑的运行模式是事件驱动还是帧驱动,两者是否匹配游戏的需求。
4.4 提示词工程经验:给 AI 输入约束,而不是让它自由发挥
提示词工程对于 Unity + AI 协作尤其重要。我总结了几个原则:第一,明确告诉 AI 目标平台,比如“移动端 Android”,它生成的代码会更注意内存占用。第二,明确 API 版本,Unity 的 API 在不同版本间有差异,AI 偶尔会输出已经不推荐使用的方法,让它根据某个具体版本来做会更安全。第三,让它给出简化实现的思路而不是过度设计。AI 有时候会把简单功能写得非常复杂,内部类一堆,不适合初学者抄作业。
如果你一开始不知道该怎么约束,可以先给 AI 一段你自己写的最小示例,让它在此基础上扩展。这种“few-shot”方式效果普遍更好,比你直接描述需求更可控。
5. 从 Demo 到可发布工程:资源和工程问题
5.1 图片和资源加载:从 URL/图片控件到 Sprite/图集/Addressables
做 App 的加载网络图片很成熟,一个图片控件加上 URL 就完事了。Unity 的资源载入情况要复杂很多:美术资源需要导入到工程里、设置导入格式、生成 Sprite 或图集,运行时还要考虑加载和释放的时机。
资源加载方式选择上,我建议小型项目可以直接用 Resources 文件夹,简单省事。项目规模上来后,强烈建议迁移到 Addressables,它支持异步加载、依赖管理、资源远程更新,整体更像 App 开发里的动态加载方案。Resource 文件夹有加载所有资源的限制,同时不利于热更和包体瘦身,不适合中大型项目。
图片素材的处理也是一门学问:单张图片直接挂在场景里没问题,但大量 UI 图片合到一张图集里,能显著降低 Draw Call。资源导入设置里的 Pixels Per Unit 决定了这个 Sprite 在场景里对应的世界单位大小,和物理系统的尺寸直接相关,这个参数也值得在新手期就搞清楚。
5.2 Prefab 和场景的版本管理:为什么 git 冲突比 App 难解
做过 App 团队协作的朋友都知道,代码合并最多就是改几个文件,冲突解决在编辑器里很直观。Unity 工程的文本里混着大量“场景内对象”。你每次在场景里拖一个对象、调一个参数,就是在改那个可能几千行的 YAML 文件,两个人同时动一个场景时冲突几乎难以避免。
我的处理经验是这样的:第一,场景文件尽量按模块拆开,别把整张地图放一个场景里。第二,公共的对象一定要抽成 Prefab,场景里只放 Prefab 实例,改 Prefab 比改场景安全得多。第三,检查 Path 分隔符。Unity 的序列化配置会根据引擎和编辑器版本产生兼容性上的差异,团队内最好约定统一版本,否则历史改动很难追溯。
如果你从第一天就按这个思路管理资源和场景,后续协作会好受非常多。大多数 Unity 新手项目最后变成“一个人改不动”的状态,一是因为没有这种拆分意识,二是场景内对象引用太混乱、不敢重构。
5.3 性能问题请提前看:Draw Call、GC 和 Profiler
App 性能优化的经验放在 Unity 里基本逻辑相通,就是具体指标不同。Unity 的性能热点主要在三个方面:Draw Call 数量、CPU 逻辑耗时、GC 引发的卡顿。
Draw Call 是渲染层面的核心指标,每帧 GPU 需要提交的绘制命令次数,越多性能越差。这里就要用到图集、合批、静态批处理等技术。GC 问题则和代码有关,比如每帧创建临时对象、拼接字符串、装箱操作都会引发垃圾回收,回收时会出现瞬时卡顿。
我的建议是做一个“性能检查清单”:每做完一个功能模块,真机跑一遍 Profiler,重点看 Update 耗时、GC Alloc 和 Draw Call 三个指标。不要等到最后才统一优化,那会无从下手。
5.4 打包与真机调试:IL2CPP、Gradle 版本这些问题
App 开发者以为“我会打 Android 包就万事大吉”,结果在 Unity 里遇到了新的一堆奇怪问题。Unity 默认的 Android 构建方式有两种:Mono 和 IL2CPP。IL2CPP 是把 C# 代码转成 C++ 再编译,包体更大、启动性能更稳,上架基本都选它。但 IL2CPP 的编译时间很长,调试时先用 Mono 模式能节省大量时间,正式发布前再切 IL2CPP。
构建过程中最常见的问题就是 Gradle 版本和依赖冲突。Unity 内置了一个 Gradle,但你如果接入了第三方 SDK,它们可能要求特定版本的构建工具,这时候需要在 Project Settings 里配置自定义 Gradle。这个流程和 App 开发里折腾 Gradle 的经验完全一致,只是入口不同。
我的建议:第一,正式版本用 IL2CPP + ARM64,分包时关注包体积。第二,遇到构建失败先看完整日志,不要只盯着最上面报错红字。第三,真机调试时 Unity 的日志系统和 App 的日志是联动的,要会看 Android Logcat 和 Profiler 的 Remote 模式。
6. 哪些 App 习惯可以直接保留,哪些必须硬改:我的经验清单
6.1 直接保留的习惯:数据协议、分层架构、日志监控、平台适配
转型并不意味着一刀切丢弃过去的经验。我在实践里发现以下这些习惯,在 Unity 里不仅有用,而且用得好会让你比很多“只做游戏”的开发者更有工程优势。
数据协议和网络层设计直接沿用了过来。游戏客户端也需要和服务端交互,登录、拉取配置、上报数据、实时状态同步,这些协议设计思路和 App 完全一致。UI 分层方面,你在 App 里沉淀的 MVVM 思想到了 Unity 里可以换一套实现但思路保留,数据驱动 UI 永远是对的。
日志和监控体系也一样重要。App 里你会埋点、上报 crash,Unity 里同样需要一套日志体系,至少把 Debug.Log 统一封装一下,方便线上开关和分级输出。平台适配层面,Unity 天然跨平台,但依然会有差异,你过去处理 iOS/Android 平台差异的经验,在这里换成处理 PC/移动端/WebGL 的差异,方法论是相通的。
6.2 必须硬改的习惯:每帧执行、手动管理生命周期、字符串拼接、对象池
有保留的,就一定有必须改掉的,否则会拖垮你的工程。第一个必须改的就是“只在变化时刷新”的思维,前面已经反复强调了,这里再给一个非常朴素的判断标准:如果一段逻辑里的结果会影响画面表现,默认它在每一帧都需要被执行或判断。
第二个是生命周期管理。App 里框架帮你管理 view 的创建和销毁,Unity 里对象是否销毁、何时销毁是要自己在代码里决定的。大量从 App 转过来的开发者习惯了不刻意做内存管理,到了 Unity 里会让内存无限增长。要养成主动控制生成和销毁的习惯,能复用的对象放对象池里,不用频繁创建销毁。
第三是字符串拼接这类小问题。在 App 里偶尔拼一下字符串没什么,在游戏每帧几百次拼接就会产生大量 GC。习惯性地用 StringBuilder,或者干脆把显示文本预先生成好,都是好做法。
6.3 个人体会:至少两个月的“双坐标系”并存阶段
坦率地说,从 App 开发转到 Unity,不是看几篇教程就能完成的切换。我个人的体会是,转型期会经历一个“双坐标系并存”的阶段:你的一部分直觉还停留在 App 世界里,遇到问题第一反应是找 Activity、找 ViewController;另一部分已经在学着用主循环、Transform 和组件去思考。这种分裂感刚开始会很难受,代码也会时而像 App 架构,时而像游戏脚本的混合体。
这个阶段基本会持续一到两个月。突破的关键在于:多做小游戏而不是啃大项目,把每一块新知识点用到一个 10 分钟就能跑起来的 Demo 里;多和 AI 配合出一些完整功能,再亲手拆掉重构一遍。等哪一天你接到新需求时,第一反应自然变成了“这个玩法该拆成哪些组件、哪些脚本挂在哪些对象上”,并且不再觉得这才是“正确答案”——那时候就算真正转过来了。
最后分享一个养成习惯。我每次遇到一个新概念,会先问自己三个问题:它主要解决什么问题?如果不用会怎样?怎么用最简单的方式验证它是对的?对于 App 转 Unity 也适用:每次多了解了相机、物理、主循环中的一项,都亲手做一个小验证,花不了多少时间,但认知地图会越来越清晰。