1 缘起
(资料图片)
在UI架构圈,MVC是一个著名的小团伙,其中M是被偷窥重度成瘾者,不过我们一般称之为观察者,毕竟读书人的事,怎么能叫偷呢对吧。
M喜欢散播自己的花边小新闻,屁大点儿事,都会翻译成小道消息重大事件散播出去。VC则是俩狗仔,不管M出了啥事,总能第一时间闻到消息,摇旗呐喊。
这种玩法同时满足了M和狗仔们的大多数需求,因此很快在UI架构圈流行开来。去年我用unity3d设计过一版UI框架,也是基于MVC模式。
本来这样也挺好,M出自己的名,狗仔追自己的星。但M太秀了,有时候一股脑爆出一大波新闻,让狗仔们忙得应接不暇,有时候又波澜不惊,让狗仔们急得抓耳挠腮。
2 取经MVC模式中,M抛出的事件默认都是同步处理的。这带来了一些问题:如果同一帧内收到并处理太多事件的话,可能由于处理时间太长,导致游戏卡顿。
最近的学习研究chrome浏览器的一些架构设计,发现跟unity3d游戏设计有一些相似相溶的地方,随手做了一番对比:
Unity3d | Chrome |
---|---|
消息队列 | 消息队列 |
定时任务 | setTimeout(), 延迟队列 |
观察者 | MutationObserver |
Task/Coroutine | Promise |
async/await | async/await |
游戏渲染帧 | requestAnimationFrame() |
早期的js对dom事件的支持并不友好,只能通过setTimeout()轮询处理。这种方式简单粗暴,但也有一些问题:如果轮询间隔时间过短,则大部分情况下CPU在空转;而如果轮询间隔时间过长,则dom事件会得不到及时响应。
2000年的时候js引入了Mutation Event,采用观察者模式,当dom发生变化时立刻触发相应事件。Mutation Event属于同步回调模式,解决了实时响应的问题,但也为页面展示带来了性能隐患。当我们使用js大量修改dom结构时,会频繁抛出事件,如果所有的dom事件都同步处理,势必会导致页面卡顿。
也因此,Mutation Event被反对使用,并逐渐从web标准事件中被移除。dom4之后推荐使用MutationObserver,其作出的改进其实主要就一点:把所有的dom事件统一放入到微任务消息队列中,在每一帧的结尾一次性触发。通过异步回调解决性能问题,通过微任务解决实时性问题。
3 消息队列chrome中的异步任务体系都是基于消息队列完成的,包括dom事件、fetch网络请求、promise异步任务等等。
js中的异步任务还细分为了宏任务与微任务,其中只有MutationObserver 和promise是微任务,其它的像setTimeout()之类的都是宏任务。
宏任务由宿主(比如chrome中的window对象)维护,但js引擎需要有自己的异步任务体系,因此引入了微任务队列。
后端也有消息队列,比如Kafka、RocketMQ等,但与chrome把消息队列主要用于异步任务处理不同,后端消息队列最重要的作用主要有三:定序 (Ordering),分离式数据库 (Unbundling Database)、重放 (Replay)。以首字母可简记为:OUR。
我们在做unity3d框架设计的时候,其实也有大量使用消息队列用于处理异步任务。
比如处理网络协议:通常的做法是单独起一个网络线程,接收网络协议,并把它们放到消息队列中,然后由主线程按顺序异步处理这些网络协议。
再比如Coroutine库:可以把所有的协程对象按顺序放入到一个消息队列中,每一帧按顺序遍历它们,并调用它们的next()方法。
这中间还诞生过一个很有趣的技巧:放置于消息队列中的消息,往往对先后顺序有要求,但对处理时间并不敏感。如果发现当前帧已经占用了太长的时间,可以直接跳出当前帧处理过程,把剩余的消息推迟到下一帧去处理。即无需每一帧都清空当前消息队列,从而可以缓解游戏卡顿的问题。我把这个小技巧称为分帧。
4 缘落但UI框架遇到的问题又略有不同。游戏中有很多非常复杂的UI,比如背包,在加载结束之后,需要按需初始化大量的控件。初始化控件的逻辑是游戏业务逻辑,而不是框架底层逻辑,因此很难完全封装在框架中。另外,初始化过程通常是在一帧内完成的,数量越多越有可能导致游戏卡顿。
针对这个问题,通用优化方案是将这些控件预先拖到一个MonoBehaviour脚本中,从而在加载的时候同步完成初始化过程。这个方案不是本文的重点,因此不再更多展开。另外,这个方案只能解决静态控件的初始化问题,对于背包这种需要根据服务器协议初始化大量动态控件的复杂UI起不到优化效果。
基于前述消息队列和分帧的思想,再参考MonoBehaviour的Start()方法可以配置成协程,我这里提一个新的优化思路:把UI框架中用于控件初始化的OnLoaded()方法设计为协程,这样当遇到大量控件需要初始化的时候,可以支持分帧处理,从而缓解因为大量初始化动态控件导致的卡帧。
这个设计会带来一些新的问题,其中一个是:很多UI事件会有先后顺序要求,比如UI框架中的OnOpened()方法应该在OnLoaded()方法之后回调。但将OnLoaded()方法修改为协程后,就可能发生OnLoaded()方法未完全回调完成,就回调到了OnOpened()方法的情况。这需要进一步调整UI框架逻辑,以约束UI事件之间的先后顺序关系。
5 缘尽相逢是缘,相知是福,再不关注,就真的缘尽了老板。
5 References本文提到的UI框架位于unicorn库中,unicorn-examples示例代码
18 | 宏任务和微任务:不是所有任务都是一个待遇