如何设计一个较通用的UI框架
前言
本文会浅谈UI框架的设计和关注要点,如果对UGUI基础理论和性能优化感兴趣,可以参考之前的文章,如果想要了解更多可以查看本文下面的推荐文章。
为什么需要UI框架,其设计思想是什么
试想一下如果没有一个统一的UI框架,所有的业务程序员会如何进行UI的开发——一个人一个策略,组件各种方式搜集,写法千差万别,轮子任意造,维护成本巨大,整体可读性极差,也就是所谓的“混乱”。
我曾经问过大佬们为什么需要设计框架,收到了这样一个答案——解耦。让每个模块的职责单一化,让各个模块整合起来能实现我们想要的功能的便是框架。而UI框架里面的一个解耦要点就是数据和逻辑的分离和组合。
总结一下便是说通过约束性设计和标准化的管理来防止随着业务需求增加的同时,代码的混乱度增加,以及随之而来的性能问题,巨大的维护成本。
我把这个问题抛给DeepSeek,其回答有一句我认为是比较精辟的:
框架的本质是通过约束创造自由——看似限制了编码方式,实则通过规范化设计让开发者能更专注于业务开发和创新。如同交通规则的存在不是为了限制驾驶,而是为了让所有车辆能高效安全地到达目的地。
如何设计一个较通用的UI框架

这里要说一下,在本人目前接触到的各个UI框架中,有的确实能说设计的不错,功能很强,但即使如此,也不能绝对的说某一个就是最好的。因为根据不同的项目需求,不同的业务场景,不同的团队情况,都有不同的UI框架的需求。有的框架可能在某个项目中有着巨大的作用,但可能换一个地方,其各种功能根本用不上,其设计便存在了巨大的冗余之处。
但即使如此,我们还是可以总结经验,提炼出一个较为通用的UI框架设计思路。接下来会就各个功能点来进行阐述。
可供参考结构



组件绑定
页面逻辑执行脚本获取对应的依赖UI组件的方式有很多种,最简单的直接拖动当然是可以的,不过成熟的框架往往都会提供更加便捷的组件绑定方式,而如果是使用Lua进行开发的话则更加重要。
鄙人有幸见识过通过改写LuaBehaviour的内部逻辑,支持拖动脚本组件到对应View层的变量上的做法,这种做法不能说有点问题吧,只能说极其糟糕。它利用的是Unity的序列化在编辑器的可视化交互,这样做貌似直观,清晰,但随之而来的问题也比较严重。
那么一个好的组件绑定工具应该注重哪些点呢
- 规划化命名。 工具绑定命名的规范化也就是组件命名的规范化,这是“强制性”的让使用者按照固定的规范进行命名的,才成跑通自己功能的形式。 如果对命名规范没有要求,各种五花八门的命名和随意拖拽,就会导致无法快速查找和定位。
- 使用便捷。 比较理想的形式是当我们按照规范进行命名后,就可以一件实现绑定了。
- 维护成本较低 需求变动时带来大量UI改动的时候,只需要修改对应的命名,再正常跑一遍流程,就可以很快完成修正和绑定。
- 对于Item的组件绑定的支持。 在书写工具的时候,不应当只考虑一个页面的绑定,对于列表页,或者复杂的列表页,还需要考虑如何如何绑定Item的。
而如果是只采用上面的“拖动”方式,业务内容一多,上面的问题想必
层级划分问题

做好层级划分是为了处理多种功能的UI页面,这些页面有着不同的特性,比如有的页面需要全屏显示,有的页面需要弹出,有的页面需要遮挡住其他页面等等。我们通过给它们分类放在不同的层级,让它们按照我们指定的规则去进行显示,保证显示效果的同时,也能根据这个去规则化特效的层级问题,而如果是特效的话,我们一般会还会要求特效Shader支持stencil和clipRect。
对于2D页面和3D场景混合的页面,我们可以把3D场景渲染到一个RawImage上面,然后进行显示,这也是UIRoot里面SceneImage的作用。这样不用考虑3D模型和UI元素深度的冲突以及层级的问题,也能更好地调整效果。
本人曾经也思考过一个问题:First和Second层逻辑同时存在是否会显得冗余?在我看来并不会,通过规则化First层和Second层的具体逻辑可以使得层级分明逻辑清晰,比如我们可以规定全屏且参与栈打开的页面都必须是First层级的,其他的Pop页面都是Second的,我相信也是没有什么问题的;但一定得这么做吗?这就不一定了,毕竟可以还是可以通过其他参数或者情况的判定而实现。可能每个项目都会有不同的情况,诸如此类根据具体的情况和确切的需求来定即可。
通用页面底板的设计
在游戏设计的初始阶段,当美术统一化规范设计后,就可以设计出对应的UI通用底板了,从而减少UI的冗余拼接并确定同一的显示效果。在UI框架上体现出的便是相同类型的页面具备相同的底板,底板自带部分逻辑且支持通用。
这样做可以减少代码重复度,提高开发效率。
通用tips弹窗
通用的各种类型的弹窗也是游戏里面往往会用到的,设计好和实现好这一部分能剩下不少事情。一般我们的弹窗可能都会是一时间只能打开一个,但是游戏游戏逻辑是需要多弹窗同时存在的且下一弹窗可能通过上一弹窗进行打开,然后按照顺序关闭,这些其实也都并不复杂,根据需求来做即可。
至于这些弹窗的管理,不同于普通的页面。举个例子,我们可以通过一个单独的列表来管理当前的弹窗——MultiWindows。这些弹窗不会参与其他页面的任何打开逻辑,反之亦然。它们有着自己单独的逻辑。
打开UI卡顿和页面数据同步问题
首先要确定卡顿的形式,资源加载消耗或者页面Rebuild消耗,这些可以参考之前的文章。这里说一下另一种数据异步的问题。
当一个页面需要服务器实时去同步数据的时候,而页面则是需要及时打开的,这就会面对一个问题——页面打开了,但是服务器的回调还没发过来,也就是说没拿到数据。
针对这种问题,可能会考虑到,比如在游戏一开始的时候就把我们想要的数据从服务器那边申请下来,并进行缓存,这样我们后面要用的时候不就可以直接用了吗,就可以完美解决这样的问题了。但是如果这样的话,数据一多或者需要的地方多了,在登录的时候就会请求众多且可能冗余的数据。
还有一种方式就是先做默认的表现形式,就是页面打开的时候先检查数据,如果没有数据的话,就赋予默认的数据,让页面按照默认的形式显示获取加载中之类的表现形式,然后等数据到了的时候再去刷新一遍页面即可。
页面存储结构管理
我们在存储各个页面的数据的时候,往往需要多个容器存不同类型的数据。
比如我们可以有四个容器进行管理,
- PopWindows,用来状态我们所有打开着的UIStcheme。
- PopStack,用栈的形式来管理普通页面打开的顺序,并且以此为基准进行同一打层级中每个UI的层级排序。
- FullScreenWindows,用来存储全屏显示的UI。这只用于全屏的页面,同样是栈的形式来实现,实现全屏页面的栈打开和关闭。
- MultiWindows,用来存储弹窗页面的列表。区别于上面的任何一个逻辑,且支持多个同时存在。
通过这样各司其职的管理,可以有效地避免数据混乱,提高代码的可维护性。
逻辑和数据解耦
要实现这一点也与框架的选择有关系,比如MVC,MVVM这些其实都是为了解耦数据和逻辑。
那MVC举例,可以从三个部分理解,Model,View,Controller。
- Model:只负责存储数据。也就是单存的存储,它于任何数据处理逻辑以及显示效果没有任何一点关系。
- View:只负责显示效果。通过将逻辑处理完的数据显示出来。
- Controller:负责处理逻辑。它与任何数据的存储以及页面显示没有任何关系。
这其实就很可以提现上面所说的框架的思想————解耦。让每个模块的职责单一化,让各个模块整合起来能实现我们想要的功能。
还有一种实现形式:
有的项目会把主要的数据存储于其他地方(比如管理器),任何UI的数据都看做“临时的”,这样M层则不一定需要脚本化。如果采用这种形式,V层也没有脚本化的绝对的必要性,组件的绑定和获取以及页面的控制都可以通过一些工具进行绑定,C层可以直接使用,也就不用通过V层获取组件。它其实采取的也是MVC的思想,但使用这种形式的人可能是觉得原封不动的MVC有很多冗余的地方,写起来不方便或者看起来不是很简洁。
依我之见的话,这种形式它其实考虑就是两个方面:
- 不要V层脚本。V层里面如果不涉及到组件绑定的话,V层的控制是最少的,C层能直接拿到组件,还不如直接写在C层,减少跳转跳转,便捷开发,这种层度的耦合不会影响开发,反而更加直观;
- 选择性不要M层脚本。坚持如果UI需要数据可以从它处取,关于UI自身的数据往往具有很强的“临时性”,因此没有必要专门整个M层,通过C层区域化定义,关闭时置空,这种层度的耦合也不会影响开发,反而更加直观。
如果按照这个角度来考虑的话,个人认为根据数据情况来选择其实是可以的,传统的MVC在一些项目中很多情况的书写方式确实存在冗余时候,选择性的使用这种方式可能更加便捷好直观,重要的是核心的数据和逻辑还是分离开的,只要不出现把其他的数据存在对应UI里面(无论任何一个层)的情况,适当的变通也是可以的。
动画
动画的话,目前接触到的使用最多的还是DOTween,它可以让我们快速实现一些动画效果,使用起来方便,简单。
置于是使用其挂载式的组件脚本,还是通过代码去动态控制,这边一般会选择后者。因为前者十分起来对应的预制体节点,伴随着UI页面的多次迭代,这种维护和修改会比后者麻烦一些。而如果使用后者做动画的话,则是需要考虑tween的管理,不要“到处乱拉”。
适配
适配要注意的问题的话,比较常见的有:
- 适配刘海屏。这方面Unity官方貌似也是有插件的,可以快速调整。而如果是手动设置的话,就需要考虑多个机型的安全区进行多次测试进行调试。
- 适配不同分辨率屏幕。这个的话,根据不同项目可能有不同的表现形式,有的美术可能希望要原封不动的保留其本身的背景比例,空余的部分去加黑边。有的则可能会从出图上去出更大的图,然后去解决可能的适配填充拉伸问题。
当然,CanvasScaler的相关属性和Canvas的模式这些都需要考虑,不过只要整体方案确定了,这方面还是比较好调节的。
这方面感兴趣的可以参考Unity的官方文档和下面的文章链接。
页面之间的通信和嵌套
页面之间的通信主要有两种形式————消息事件和子页面传递。
前者不多赘述,这里说一下后者。
一个页面可能包含多个子页面,甚至它本身可能就是由多个子页面组成的,这种子页面我们称之为SubView或者说Widget。
为什么会有子页面?
- 分担页面逻辑。当一个页面过大,包含多重页面和多种逻辑的时候,如果就这样只放在一个页面里面的话,当页面打开的时候不仅UI预制体加载时间会过长,而且页面脚本逻辑也会因为冗长的逻辑代码导致可读性差,维护成本也随之上升。如果使用子页面进行分担的话,每个子页面模块就可以专注对应的功能,每个部分的功能也都清晰可见。
- 选择页面逻辑。当一个页面可以存在多个子页面,且这些子页面随着不同的情况,有着不同的表现形式。比如一个主菜单页面可能由多个子页面组成,当跳转不同的场景的时候可能需要显示不同的内容,也就是说不同的情况,可能显示多种搭配。这种情况的话,我们就可以选择性生成并且很好地适应需求变化。而不是杂糅在一个页面里面去处理。
嵌套之间的数据通过管理进行传递,往往主页面是包含子页面的脚本数据的,可以通过这种形式传递数据给子页面;而子页面通过缓存主页面脚本,也可以把想要的内容回传给主页面。即使这样的嵌套有多层,也可以通过这种形式传递实现功能。
而实际开发中,我们一般并不希望一个页面的垂直上面嵌套超过三层。因为过多的嵌套会也回导致代码的可读性差,维护成本也会随之上升。
不过有一个值得注意的点是 分子页面逻辑是为了主要处理页面的繁多,还是为了处理众多的逻辑决定了子页面的形式,也决定了数据的管理形式。
比如说,一个页面如果需要包含不同时出现的子页面,且页面复杂的时候,我们可能希望每个子页面都是一个单独的预制体和对应的脚本逻辑,其当然可以有父页面传递的通用数据,不过更多的还是管理自己的页面和自己的数据;
而一个页面如果不需要同时出现,页面也并不算复杂,但是每个部分的逻辑却相当多的时候,我们可能更倾向于针对于每个部分写功能脚本,然后传递通用model数据,就不用把页面单独拆出来。
此外对于子页面的生命周期管理,我们也需要注意,比如说子页面的生命周期是由父页面控制还是由子页面控制,这也会影响到子页面的设计。
所以到底是父页面统一管理还是子页面自行控制这也是个重点。
如果使用前者,确保生命周期同步,严格按照给定顺序执行的同时,经过统一处理可以有效防止内存泄露,但这种形式也增加了一部分的耦合。
后者这种松耦合的形式当然可以增加灵活性,但业务开发中也可能因此出现规范不统一的情况。
本人基本使用过的是前者,知道前者的好处,主要是方便管理和控制。