这几年开发思维上的一些转变
-
一些个人的想法和流水账。
菜鸟的思考
刚工作做Android APP的时候,可以说完全是一个菜鸟,做了几年后,摸到了一些架构方面的皮毛,也算稍微有些成长。
那时候对程序设计的一些认知,大概是知道整个软件系统是需要分层的,以便于解耦和复用。就APP来说,通常会分成通用基础库、通用业务层、应用层(即最终的APP)。通用基础库、通用业务层都包含若干个模块,项目需要用到哪些,就把对应的模块拿来拼装组合,应用层再根据项目需求做定制,一个项目做完后,再看看有哪些东西可以沉淀到底层,整合进去。
得益于包管理工具Maven和Gradle,这些底层模块都可以打包发布到私库上,APP中一行代码就能引用,使用和管理都很方便。
遇到的问题是,项目上做二次开发的人员经常来给研发反馈,“这个功能我想改一下,但是被你的代码\视图限制了,改不了”、“你写的功能完全满足不了需求,我只能重写”、“我用不着这个功能,能不能去掉”、“我想直接改你的源码,能不能把源码发我一份”、“你底层怎么有Bug,会不会写代码”...
总的来说都可以归于程序设计能力不扎实,对业务理解不透彻,以及妄图写出一套万能的业务层来解决未来所有的项目需求。按照开放封闭原则,软件实体应该对扩展开放,对修改封闭,现在想想,当时写的底层模块只想着要涵盖哪些业务,而忽略了扩展性。需求总是会变的,一个灵活易扩展的底层才是解决问题的正确思路。
对第三方库可以说是非常依赖,能用现成的绝不自己造轮子,比如网络请求、数据持久化、异步操作、依赖注入、图片加载等等,开新项目的第一件事就是在Gradle脚本里加上一大堆依赖项,至于对包大小的影响?基本上是无人关心,反正这种基础库也不会大到哪里去。
在当时(甚至现在)的APP开发中这是一个很常见的现象,Google下场推出Jetpack全家桶之后,也只是把这些库换成官方的了而已。面试中也经常考查对第三方库的熟悉程度。我自己负责过一段时间面试,对候选人基本上也是问这些,没办法,俺就这水平,更高深的也问不出来了。
好处自然是很明显的,省去了自己鼓捣轮子的时间,降低了开发门槛,今天学会这一套,明天就能来我司上班。这也是当时培训班越来越火爆的原因之一,只要会用就能干活,至于理论基础是否扎实,写出来的东西性能如何,很多情况下都不在乎。
但是如果抱有“我会用就行了,没必要搞懂原理”这样不求甚解的想法,自己的水平就很难提升了。特别是现在这个面试造火箭入职拧螺丝的环境下,很容易被别人卷死。
但我那时在优化方面也只是了解皮毛,只知道尽量避免内存泄漏,减少APP启动时的耗时操作,垃圾回收器怎么运作的知道个大概。资源这块只知道尽可能复用,尽量让美术切9图。
至于产品设计上没有学到太多,可能是没有遇到靠谱的产品经理。
项目管理方面,作为技术负责人,首先应该制定并执行好开发标准,确保团队中所有人写出来的代码、做出来的东西都是同一种风格,这样做对于开发和维护都有很大的帮助。有些标准甚至都不需要自己从零开始制定,直接在一些大厂公开的标准上修改即可,像之前我们用的是阿里的开发标准。
同时在开发方面还要有全局的掌控,例如团队中每个人做什么、怎么安排更合理、每个人负责模块的业务逻辑、大致实现方式(程序概要设计、详细设计)、哪些东西需要与别的部门对接、开发进度如何等等。
很可惜我并不是一个合格的技术负责人,对待组员太宽松,不怎么追进度,不会带动积极性。很多时候组员反馈太难的做不了的东西,都是拉过来自己做,而不是教给他们做的方法,确实这样可能会更快更好,但是长期下来自己累得要死,自己的水平越来越高,其他人的水平却没有得到提升,进入了恶性循环。当然跟当时其他人的工作态度也有一定的关系,随着几个大佬的离开,剩下的大部分人都是养老心态,难的不做新东西不学,教了也不太愿意听,这种环境下还是早点跑路为妙。后来在只有几个人的创业团队里,这些问题几乎不存在了。
一些思维转变
再后来因为一些原因,逐渐放弃Android转向Unity。踩过一些坑后意识到,性能这块要花的功夫比以前要多得多,特别是移动端,代码真不是随便写写就行的,一不小心写出来的东西就没法跑了。
性能相关
最基础的性能相关知识是必须要具备的,由于Unity的GC方式较为落后(现在已经有计划在新版本迁移到CoreCLR GC),稍有经验的Unity程序员都是谈GC色变——GC工作时很容易引起掉帧。所以像内存分配、值类型引用类型、拆装箱等等一定要搞清楚。对于哪些操作是“昂贵的”要有了解,比如常见的GetCompoent、GameObject.Find、反射调用等等。这样在写下代码之前,会有认知“这样写会增加GC负担,得换种方式”、“这个操作比较耗时,不能搞得太频繁”,从而写出高质量的代码,而不是在功能写完后才发现运行疯狂掉帧、内存暴涨,这时再优化就很困难了。
开发中对目标平台要有清晰的认知,了解各平台的限制。比如只准备上PC,那不在乎上面那段内容都没太大关系,代码写得再垃圾都是能勉强一战的。如果目标平台是移动端,除了代码上要注意,还要多多在真机上运行测试,编辑器能跑不代表真机能跑,很多疑难杂症都只会在真机上出现。如果目标平台是WebGL(小游戏),资源加载一定要用异步,不能使用线程等等。
如果是从PC端开发转向移动端开发,就要特别注意,已经见过一些不太好的例子:
-
状态机框架,使用反射来调用每个状态的生命周期函数,这种做法确实会更方便和解耦,但反射的性能是很差的,在移动端的表现不会太好,这时不能图自己用起来方便,应该改成直接调用或事件驱动;
-
事件系统,每种事件都用class定义,每当发送事件时就要new一个class,增加GC工作量,为什么不试试神奇的struct呢。
-
属性系统,设计上属性类中包含int、float等字段,均为不同类型下的属性值,为了方便,设值函数这样写:
public void SetValue(object obj) { if (obj is Int32) { intValue = (int) obj; } else if (obj is Single) { floatValue = (float) obj; } }
导致调用时发生装箱和拆箱。
上面三个例子,都是很底层的部分,在游戏中会频繁用到,底层的性能不做好,整个游戏的性能也就无从谈起了。
不仅是自己造的东西,第三方库也要谨慎使用。刚接触Unity的时候,我的思路还跟搞Android时差不多,有现成的为什么不用现成的呢,AssetStore上不是有一大堆吗?所以比较热衷于学习各种插件。但后来逐渐认识到,用的前提是这东西适用,比如它能用在哪些平台上?性能如何,是否有针对移动端优化?引入这个库会增加多少包大小?扩展性和兼容性如何,能在它基础上做改造吗?所以现在发现之前买过的一些插件反而用不上了,很多时候还是得自己造。
资源这块,在移动端上则是能省则省。UI图片能切9图的一定要切,能染色的就不要重复做多种颜色的素材,如果美术给出的素材不合理,程序一定要提出,而不能说美术给啥我就用啥。
在不影响功能的情况下,能压缩的图片一定要尽量压缩,导入选项里顺手调整一下压缩选项,或者做个导入预设。对于较大的图片或图集,压缩后能节省十多MB的运行内存甚至更多。
对于图集,也不是无脑打就行了,想想打图集的目的:节省存储空间、减少Draw Call,减少Draw Call这一点可以说是牺牲了部分内存占用换来的,加载图集中的一张图,整个图集都要加载到内存。所以打图集时应该是把可能会同时渲染的图片打在一起,同时要避免这张图集过大,用时加载,不用时卸载以释放内存。见过一些不好的例子,把不同功能模块用到的图片资源打在一起,导致刚进游戏就加载了多张很大的图集,内存占用飙升,这在小游戏平台是很致命的。
这些都只是偏基础的,其他的像渲染、美术这些方面的经验还不多,过几年再来补充吧。
单例模式
主要在于单例的生命周期管理。在Android中,用到的更多是依赖注入,要用到某个类的实例,注入一下就好了,不用太关心它何时创建何时销毁。Unity虽然也有依赖注入框架(VContainer之类),但目前个人用的不多。在Unity中使用单例遇到了哪些问题呢?一般来说,最传统也是最常见的单例写法大概是这样的:
public class Singleton<T> where T : class, new() { private static T _instance; private static readonly object LockObj = new(); public static T Instance { get { if (null == _instance) { lock (LockObj) { _instance ??= new T(); } } return _instance; } } }
MonoBehaviour单例大概是这样的:
public abstract class SingletonBehaviour<T> : MonoBehaviour where T : MonoBehaviour { public static T Instance { get; private set; } [Tooltip("保持不销毁")] public bool dontDestroyOnLoad = true; protected virtual void Awake() { if (Instance != null) { Destroy(gameObject); return; } Instance = this.GetComponent<T>(); if (dontDestroyOnLoad) gameObject.DontDestroyOnLoad(); } }
自动创建型的MonoBehaviour单例大概是这样的:
public abstract class SingletonAutoBehaviour<T> : MonoBehaviour where T : MonoBehaviour { protected static T _instance; public static T Instance { get { if (_instance == null) { GameObject go = new GameObject(); go.name = typeof(T).ToString(); _instance = go.AddComponent<T>(); } return _instance; } } }
当功能逐渐复杂时,整个游戏系统中通常会有许多单例,它们在许多地方都被调用。如果自动创建型单例(第一种和第三种)使用得很多的话,将会面临一个问题:每个单例到底是啥时候创建的?
你可能会说,“打个断点看一下不就知道了”,“这个问题很重要吗?”。确实,重点不在于单例是什么时候创建的,而在于单例的创建顺序是混乱的,这种情况下无法控制谁先创建谁后创建,很可能会扎堆创建,有些单例的创建可能还非常耗时。
这将引起一些致命的问题:单帧内初始化对象过多、耗时操作过多导致卡顿、刚进入游戏时就初始化了一些还用不着的东西、加载了一些还用不着的资源、拖慢游戏的启动速度等等。
相应的,单例的销毁也缺少统一的管理,切换场景时,一堆dontDestroyOnLoad的单例依然保留,假如需要退出到开始界面重新游戏,这一堆单例的状态都需要重置,稍有遗漏就很容易出现问题。
所以在新的框架中,我逐渐抛弃了这种“野生”的单例,改为统一管理。
模块间解耦合
“高内聚低耦合”的口号谁都会喊,但真要做到却没那么简单。记得还是个菜鸟的时候,我说出过“要改UI层,那逻辑层也不得不改啊”这种话,现在看来是非常搞笑的。像Android开发中经常可以看到MVC、MVP、MVVM、MVI这些架构思想,目的之一就是降低耦合度。
解耦的最大好处,就是在面对改动时可以不用“牵一发而动全身”,修改甚至删除掉某一个模块,对其他模块不会产生很大的影响。新手程序常犯的错误就是模块间严重互相依赖,比如直接在UI中写业务逻辑,业务逻辑反过来又需要UI。还见过一些不好的例子,整个游戏必须要等待所有UI初始化完才能正常游戏,UI一旦被销毁,游戏的业务逻辑都没法正常跑。
这些问题不仅仅是解耦方面,在职责划分方面也没有做好,违反了单一职责原则,UI层就应该只做UI的事情,图一时省事直接把业务逻辑写在UI中,只会给后续的扩展与维护带来更大的麻烦。
那么有哪些东西需要解耦?
从整个软件系统来看,首先分层是有必要的,至少通用的基础库需要分出来,可以用Assembly Definition把各个模块隔离开,例如:
|-- CommonLib 基础库模块目录 | |-- CommonLib.asmdef | |-- 代码文件... |-- Framework 框架层模块目录 | |-- Framework.asmdef | |-- 代码文件... |-- Game 游戏应用层模块目录 | |-- Game.asmdef | |-- 代码文件... `-- Editor 游戏编辑扩展模块目录 |-- Editor.asmdef |-- 代码文件...
这里Game依赖Framework,Framework依赖CommonLib,由于程序集的划分,低层模块将无法引用到高层模块,只会存在高层到低层的单向引用。在具体实现中,高层模块还可以不直接依赖低层模块中的具体实现,而是依赖抽象类与接口,进一步降低耦合。
在这基础上,继续划分成更细的维度(拆分出更多的模块)。回到有哪些东西需要解耦这个问题,个人的看法是,逻辑部分需要与表现部分解耦,比如Gameplay中有Gameplay的逻辑与表现,UI有UI的逻辑与表现(不知道Gameplay这个词是否恰当,总之这里是指与UI无关的玩法层面的东西)。
例如玩家角色与怪物战斗,玩家的各项属性值受到其穿戴装备的影响,战斗中各种数值的计算发生在Gameplay逻辑层,而玩家角色与怪物的显示在Gameplay表现层;
UI上打开背包,有哪些装备要显示、装备的各种属性、是否有可升级的装备等等,这些是从Gameplay逻辑层获取的,这些装备具体按哪种方式显示、点击之后有哪些交互,这些是在UI逻辑层处理的,而装备的实际显示、图标大小、属性文字、按钮摆放、交互动画等等,这些是在UI表现层的。
在这种划分下,逻辑层和表现层各司其职,这样就杜绝了上面提到的“在UI中写业务逻辑”的情况。
实际开发中,需要根据根据具体项目情况决定解耦的程度,比如Gameplay的逻辑和表现可能关联紧密,不是那么地好拆分,而UI逻辑和UI表现在相对简单的情况下,拆分可能会多出许多代码,增加工作量,这种情况下可以把Gameplay逻辑与表现和业务逻辑统一看作逻辑层,UI的逻辑与表现统一看作表现层。
那么这样逻辑层和表现层之间是否完全解耦了呢?有一种方法可以检验解耦是否做到位,把表现层删除,如果逻辑层不需要做太大调整甚至不调整就能正常跑,那就说明做对了。其实这也是很常见的需求,比如省电模式、息屏挂机,就是把表现层关掉,但不会影响到逻辑层的运行。
但由于逻辑层与表现层之间必然存在交互,交互过程可能还是会产生耦合,比较常见的交互实现方式一般有这些:
- 逻辑层模块与表现层模块互相依赖,通过函数的直接调用来实现双方的交互。
- 逻辑层模块与表现层模块依赖对方的抽象层,通过调用抽象类或接口来实现双方的交互。
1这种方式是最简便但耦合度也是最高的,存在直接的依赖,如果其中一方发生变化,另一方很可能要同步修改;2则使用抽象类和接口进行解耦,但缺点是需要维护更多的抽象类和接口,增加了开发难度。
个人认为,相较于上面两种方式,使用事件驱动的模块间交互可以更加优雅地解决问题。
事件驱动下的解耦合
这里的事件系统必须要具备一个重要特性:事件的接收者只需要订阅事件并做对该事件的处理,而不需要关心事件有没有人发送、发送者是谁;相应的,事件的发送者只需要在合适的时机发出事件,而不需要关心事件有没有人接收、接收者是谁。这个特性实现起来很简单,就不详细说明了。
在这个特性下,解耦的目标便自然达到了。逻辑层和表现层不再需要互相依赖,也不再需要依赖对方的抽象层,它们只需要依赖一个或多个事件定义层,事件定义层中只需要定义逻辑层与表现层中需要的事件、以及事件所引用到的数据结构(甚至数据结构可以放到通用的模型层),并且事件可以在多个功能中复用、组合,相对于上面的方式2,需要维护的内容大大减少。
并且由于发送者和接收者互不关心、互相没有感知,上面提到的“删掉表现层,逻辑层照常运行”的需求也自然而然地实现了——表现层被删除后,逻辑层不再接收到来自表现层的事件,带来的影响只是缺少了来自表现层的输入,逻辑层发往表现层的事件没有人接收了,逻辑层自身不会受到影响。
由于事件广播发出,逻辑层发出的事件可以被表现层的多处接收,逻辑层发出一个事件,表现层多处都可以做处理,从而解决“由于某些页面数据未刷新而导致显示不一致”的问题。
举个例子,背包功能需要能在背包一级页面显示背包内所有物品的简略信息(品质、图标、数量、等级等),点击某物品弹出二级页面(对话框形式)展示物品详情,点击二级页面中的强化按钮可以升级物品。
按上面的模式来设计:
-
数据模型层
- 背包物品相关数据模型定义
-
事件层
- 请求获取背包物品事件
- 请求获取物品详情事件
- 请求升级物品事件
- 物品发生变化事件
-
逻辑层
- 背包逻辑,实现对背包内物品的管理与物品升级
-
表现层
- 背包一级页面,以网格形式展示背包物品,展示每个物品的品质、图标、数量、等级
- 背包二级页面,展示物品详情,监听强化按钮的点击事件
其中事件层依赖数据模型层,逻辑层依赖事件层与数据模型层,表现层依赖事件层与数据模型层。
此时背包展示到升级物品、刷新页面的流程:
-
背包逻辑初始化时,监听 “请求获取背包物品事件”、“请求获取物品详情事件”、“请求升级物品事件”。
-
背包一级页面开启时,发出 “请求获取背包物品事件”,背包逻辑收到该事件后,返回当前背包内物品数据(这个的具体实现方式有很多,例如通过回调返回、通过委托返回、另外发出事件返回等)。背包一级页面收到该数据后,执行自身的展示逻辑将物品显示,随后监听 “物品发生变化事件”。
-
同理背包二级页面开启时,发出 “请求获取物品详情事件” 获取物品详情并展示,随后也监听 “物品发生变化事件”。
-
背包二级页面的强化按钮被点击时,发出 “请求升级物品事件”,背包逻辑收到该事件后,检查当前是否符合升级条件,执行物品强化升级逻辑,随后发出 “物品发生变化事件”,将物品变化的相关信息广播出去。
-
背包一级页面与背包二级页面将同时收到 “物品发生变化事件”,根据其中包含的物品变化信息刷新自身页面。
由于充分解耦,一个功能的逻辑层和表现层可以拆分给不同的开发人员,双方只要约定好数据和事件格式,就可以开始各自的开发,最后进行对接。开发过程中逻辑层和表现层都可以独立测试,而不需要等待对方开发完毕。
这种模式的缺点是不可避免地会定义大量的事件,对于事件的分类管理以及后续维护的要求较高;在设计时需要避免出现死循环,例如A事件的接收者发出了B事件,B事件的接收者收到后又发出了A事件;由于事件的频繁使用,事件系统的实现一定要做到高效、零GC。
-