前言
預備
不知道你有沒有想過,假如把游戲世界比作一輛汽車,那么這輛“汽車”是如何啟動,又是如何持續運轉的呢?
如題,本文的內容主要為 Cocos Creator 引擎的啟動流程和主循環。
而在主循環的內容中還會涉及到:組件的生命周期和計時器、緩動系統、動畫系統和物理系統等...
本文會在宏觀上為大家解讀主循環與各個模塊之間的關系,對于各個模塊也會簡單介紹,但不會深入到模塊的具體實現。
因為如果把每個模塊都“摸”一遍,那這篇文章怕是寫不完了。
Go!
希望大家看完這篇文章之后能夠更加了解 Cocos Creator 引擎。
同時也希望本文可以起到“師傅領進門”的作用,大家一起加油修行鴨~
另外《源碼解讀》系列(應該)會持續更新,如果你想要皮皮來解讀解讀引擎的某個模塊,也歡迎留言告訴我,我...我考慮下哈哈哈~
本文以 Cocos Creator 2.4.3 版本為參考。
正文
啟動流程
index.html
對于 Web 平臺 index.html 文件就是絕對的起點。
在默認的 index.html 文件中,定義了游戲啟動頁面的布局,加載了 main.js 文件,并且還有一段立即執行的代碼。
這里截取文件中一部分比較關鍵的代碼:
- // 加載引擎腳本
- loadScript(debug ? 'cocos2d-js.js' : 'cocos2d-js-min.ec334.js', function () {
- // 是否開啟了物理系統?
- if (CC_PHYSICS_BUILTIN || CC_PHYSICS_CANNON) {
- // 加載物理系統腳本并啟動引擎
- loadScript(debug ? 'physics.js' : 'physics-min.js', window.boot);
- } else {
- // 啟動引擎
- window.boot();
- }
- });
上面這段代碼主要用于加載引擎腳本和物理系統腳本,腳本加載完成之后就會調用 main.js 中定義的 window.boot()
函數。
對于原生平臺,會在 {項目目錄}build\jsb-link\frameworks\runtime-src\Classes\AppDelegate.cpp
文件的 applicationDidFinishLaunching()
函數中加載 main.js 文件。(感謝請容我安眠大佬的補充)
代碼壓縮
腳本文件名中帶有 -min
字樣一般代表著這個文件內的代碼是被壓縮過的。
壓縮代碼可以節省代碼文件所占用的空間,加快文件加載速度,減少流量消耗,但同時也讓代碼失去了可閱讀性,不利于調試。
所以開啟調試模式后會直接使用未經過壓縮的代碼文件,便于開發調試和定位錯誤。
main.js
window.boot()
對于不同平臺 main.js 的內容也有些許差異,這里我們忽略差異部分,只關注其中關鍵的共同行為。
關于 main.js 文件的內容基本上就是定義了 window.boot()
函數。
對于非 Web 平臺,會在定義完之后直接就調用 window.boot()
函數,所以 main.js 就是他們的起點。
而 window.boot()
函數內部有以下關鍵行為:
-
定義
onStart
回調函數:主要用于加載啟動場景 -
cc.assetManager.init(...)
:初始化 AssetManager -
cc.assetManager.loadScript(...)
:加載 src 目錄下的插件腳本 -
cc.assetManager.loadBundle(...)
:加載項目中的 bundle -
cc.game.run(...)
:啟動引擎
這部分的代碼就不貼了,小伙伴們可以看看自己的項目構建后的 main.js 文件。
cc.game
cc.game
對象是 cc.Game
類的一個實例,cc.game
包含了游戲主體信息并負責驅動游戲。
說人話,cc.game
對象就是管理引擎生命周期的模塊,啟動、暫停和重啟等操作都需要用到它。
CCGame.js:https://github.com/cocos-creator/engine/blob/2.4.3/cocos2d/core/CCGame.js
run()
cc.game.run()
函數內指定了引擎配置和 onStart
回調并觸發 cc.game.prepare()
函數。
- run: function (config, onStart) {
- // 指定引擎配置
- this._initConfig(config);
- this.onStart = onStart;
- this.prepare(game.onStart && game.onStart.bind(game));
- }
傳送門:https://github.com/cocos-creator/engine/blob/2.4.3/cocos2d/core/CCGame.js#L491
prepare()
cc.game.prepare()
函數內主要在項目預覽時快速編譯項目代碼并調用 _prepareFinished()
函數。
- prepare(cb) {
- // 已經準備過則跳過
- if (this._prepared) {
- if (cb) cb();
- return;
- }
- // 加載預覽項目代碼
- this._loadPreviewScript(() => {
- this._prepareFinished(cb);
- });
- }
傳送門:https://github.com/cocos-creator/engine/blob/2.4.3/cocos2d/core/CCGame.js#L472
對于快速編譯的細節,可以在項目預覽時打開瀏覽器的開發者工具,在 Sources 欄中搜索(Ctrl + P) __quick_compile_project__
即可找到 __quick_compile_project__.js
文件。
_prepareFinished()
cc.game._prepareFinished()
函數的作用主要為初始化引擎、設置幀率計時器和初始化內建資源(effect 資源和 material 資源)。
當內建資源加載完成后就會調用 cc.game._runMainLoop()
啟動引擎主循環。
- _prepareFinished(cb) {
- // 初始化引擎
- this._initEngine();
- // 設置幀率計時器
- this._setAnimFrame();
- // 初始化內建資源(加載內置的 effect 和 material 資源)
- cc.assetManager.builtins.init(() => {
- // 打印引擎版本到控制臺
- console.log('Cocos Creator v' + cc.ENGINE_VERSION);
- this._prepared = true;
- // 啟動 mainLoop
- this._runMainLoop();
- // 發射 ‘game_inited' 事件(即引擎已初始化完成)
- this.emit(this.EVENT_GAME_INITED);
- // 調用 main.js 中定義的 onStart 函數
- if (cb) cb();
- });
- }
傳送門:https://github.com/cocos-creator/engine/blob/2.4.3/cocos2d/core/CCGame.js#L387
對于 _prepareFinished()
內調用的 _setAnimFrame()
函數這里必須提一下。
_setAnimFrame()
cc.game._setAnimFrame()
內部對不同的游戲幀率做了適配。
另外還對 window.requestAnimationFrame()
接口做了兼容性封裝,用于兼容不同的瀏覽器環境,具體的我們下面再說。
這里就不貼 _setAnimFrame()
的代碼了,有需要的小伙伴可自行查閱。
傳送門:https://github.com/cocos-creator/engine/blob/2.4.3/cocos2d/core/CCGame.js#L564
_runMainLoop()
cc.game._runMainLoop()
這個函數的名字取得很簡單直接,攤牌了它就是用來運行 mainLoop()
函數的。
讓我們瞧瞧代碼:
- _runMainLoop: function () {
- if (CC_EDITOR) return;
- if (!this._prepared) return;
- // 定義局部變量
- var self = this, callback, config = self.config,
- director = cc.director,
- skip = true, frameRate = config.frameRate;
- // 展示或隱藏性能統計
- debug.setDisplayStats(config.showFPS);
- // 設置幀回調
- callback = function (now) {
- if (!self._paused) {
- // 循環調用回調
- self._intervalId = window.requestAnimFrame(callback);
- if (!CC_JSB && !CC_RUNTIME && frameRate === 30) {
- if (skip = !skip) return;
- }
- // 調用 mainLoop
- director.mainLoop(now);
- }
- };
- // 將在下一幀開始循環回調
- self._intervalId = window.requestAnimFrame(callback);
- self._paused = false;
- }
傳送門:https://github.com/cocos-creator/engine/blob/2.4.3/cocos2d/core/CCGame.js#L612
通過以上代碼我們可以得知,_runMainLoop()
主要通過 window.requestAnimFrame()
接口來實現循環調用 mainLoop()
函數。
window.requestAnimFrame()
window.requestAnimFrame()
就是我們上面說到的 _setAnimFrame()
內部對于 window.requestAnimationFrame()
的兼容性封裝。
對前端不太熟悉的小伙伴可能會有疑問,window.requestAnimationFrame()
又是啥,是用來干嘛的,又是如何運行的?
window.requestAnimationFrame()
簡單來說,window.requestAnimationFrame()
用于向瀏覽器請求進行一次重繪(repaint),并在重繪之前調用指定的回調函數。
window.requestAnimationFrame()
接收一個回調作為參數并返回一個整數作為唯一標識,瀏覽器將會在下一個重繪之前執行這個回調;并且執行回調時會傳入一個參數,參數的值與 performance.now()
返回的值相等。
performance.now()
的返回值可以簡單理解為瀏覽器窗口的運行時長,即從打開窗口到當前時刻的時間差。
MDN 文檔:https://developer.mozilla.org/zh-CN/docs/Web/API/Performance/now
回調函數的執行次數通常與瀏覽器屏幕刷新次數相匹配,也就是說,對于刷新率為 60Hz 的顯示器,瀏覽器會在一秒內執行 60 次回調函數。
對于 window.requestAnimationFrame()
的說明到此為止,如果想要了解更多信息請自行搜索。
MDN 文檔:https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestAnimationFrame
小結
畫一張圖來對引擎的啟動流程做一個小小的總結叭~
主循環
經歷了一番波折后,終于來到了最期待的引擎主循環部分,話不多說,我們繼續!
cc.director
cc.director
對象是導演類 cc.Director
的實例,引擎通主要過 cc.director
對象來管理游戲的邏輯流程。
CCDirector.js:https://github.com/cocos-creator/engine/blob/2.4.3/cocos2d/core/CCDirector.js
mainLoop()
cc.director.mainLoop()
函數可能是引擎中最關鍵的邏輯之一了,包含的內容很多也很關鍵。
現在讓我們進入 mainLoop()
函數內部來一探究竟吧!
這里我選擇性剔除掉了函數中一些的代碼,還搞了點注釋:
- mainLoop: function(now) {
- // 計算“全局”增量時間(DeltaTime)
- // 也就是距離上一次調用 mainLoop 的時間間隔
- this.calculateDeltaTime(now);
- // 游戲沒有暫停則進行更新
- if (!this._paused) {
- // 發射 'director_before_update' 事件
- this.emit(cc.Director.EVENT_BEFORE_UPDATE);
- // 調用新增的組件(已啟用)的 start 函數
- this._compScheduler.startPhase();
- // 調用所有組件(已啟用)的 update 函數
- this._compScheduler.updatePhase(this._deltaTime);
- // 更新調度器(cc.Scheduler)
- this._scheduler.update(this._deltaTime);
- // 調用所有組件(已啟用)的 lateUpdate 函數
- this._compScheduler.lateUpdatePhase(this._deltaTime);
- // 發射 'director_after_update' 事件
- this.emit(cc.Director.EVENT_AFTER_UPDATE);
- // 銷毀最近被移除的實體(節點)
- Obj._deferredDestroy();
- }
- // 發射 'director_before_draw' 事件
- this.emit(cc.Director.EVENT_BEFORE_DRAW);
- // 渲染游戲場景
- renderer.render(this._scene, this._deltaTime);
- // 發射 'director_after_draw' 事件
- this.emit(cc.Director.EVENT_AFTER_DRAW);
- // 更新事件管理器的事件監聽(cc.eventManager 已被廢棄)
- eventManager.frameUpdateListeners();
- // 累加游戲運行的總幀數
- this._totalFrames++;
- }
傳送門:https://github.com/cocos-creator/engine/blob/2.4.3/cocos2d/core/CCDirector.js#L843
接下來我們來對主循環中的關鍵點一一進行分解。
ComponentScheduler
cc.director
對象中的 _compScheduler
屬性 是 ComponentScheduler
類的實例。
大多數小伙伴可能對于 ComponentScheduler
這個類沒有什么印象,我來簡單解釋一下。
將 ComponentScheduler
的名字直譯過來就是“組件調度器”,從名字上就可以看出,這個類是用來調度組件的。
說人話,ComponentScheduler
類是用來集中調度(管理)游戲場景中所有組件(cc.Component
)的生命周期的。
文字不夠直觀,看完下面這張圖大概就懂了:
component-scheduler.js:https://github.com/cocos-creator/engine/blob/2.4.3/cocos2d/core/component-scheduler.js
startPhase
- // 調用新增的組件(已啟用)的 start 函數
- this._compScheduler.startPhase();
組件的 start
回調函數會在組件第一次激活前,也就是第一次執行 update
之前觸發。
在組件的一生中 start
回調只會被觸發一次,onLoad
和 onEnable
也一樣。
只不過 onLoad
和 onEnable
是由 NodeActivator
類的實例來管理的:
onLoad
會在節點激活時就觸發onEnable
會在組件被啟用時觸發
而 start
則會等到下一次主循環 mainLoop()
時才觸發。
NodeActivator
NodeActivator
類主要用于啟用和禁用節點以及身上的組件。
cc.director
對象中就擁有一個實例 _nodeActivator
,游戲中所有節點的啟用和禁用都需要通過它來操作。
像這樣:cc.director._nodeActivator.activateNode(this, value);
node-activator.js:https://github.com/cocos-creator/engine/blob/2.4.3/cocos2d/core/node-activator.js
updatePhase
- // 調用所有組件(已啟用)的 update 函數
- this._compScheduler.updatePhase(deltaTime);
組件的 update
函數在每一幀都會被觸發一次。
lateUpdatePhase
- // 調用所有組件(已啟用)的 lateUpdate 函數
- this._compScheduler.lateUpdatePhase(deltaTime);
組件的 lateUpdate
函數會在 update
和調度器 cc.Scheduler
更新之后被觸發。調度器的更新內容包括緩動、動畫和物理等,這一點下面會展開。
ParticleSystem
BTW,粒子系統組件(cc.ParticleSystem
)就是在 lateUpdate
回調函數中進行更新的。
CCParticleSystem.js:https://github.com/cocos-creator/engine/blob/2.4.3/cocos2d/particle/CCParticleSystem.js
Tips
請謹慎使用 update
和 lateUpdate
回調,因為它們每一幀都會被觸發,如果 update
或 lateUpdate
內的邏輯過多,就會使得每一幀的執行時間(即幀時間 Frame time)都變長,導致游戲運行幀數降低或出現不穩定的情況。
注意這不是不讓你用,該用還得用,只是不要濫用,不要啥玩意都往里邊賽~
Scheduler
cc.director
對象的 _scheduler
屬性為 cc.Scheduler
類的實例。
cc.Scheduler
是負責觸發回調函數的類。
Scheduler.js:https://github.com/cocos-creator/engine/blob/2.4.3/cocos2d/core/Scheduler.js
你絕對猜不到下面這一行看起來如此平平無奇的代碼執行之后會發生什么。
- // 更新調度器(cc.Scheduler 類實例)
- this._scheduler.update(this._deltaTime);
cc.director.mainLoop()
中使用 _scheduler.update()
函數來分發 update,在調度器(cc.director._scheduler
)內部會根據優先級先后觸發各個系統模塊和組件計時器的更新。
系統模塊
調度器的更新會先觸發以下系統模塊的更新:
- ActionManager
- AnimationManager
- CollisionManager
- PhysicsManager
- Physics3DManager
- InputManager
以上這些模塊都以 cc.director._scheduler.scheduleUpdate()
的方式注冊到調度器上,因為這些模塊每一幀都需要進行更新。
除了 InputManager
以外的模塊的優先級都為 cc.Scheduler.PRIORITY_SYSTEM
,也就是系統優先級,會優先被觸發。
ActionManager
ActionManager
即動作管理器,用于管理游戲中的所有動作,也就是緩動系統 Action
和 Tween
(其實它們本質上是同一種東西)。
CCActionManager.js:https://github.com/cocos-creator/engine/blob/2.4.3/cocos2d/actions/CCActionManager.js
AnimationManager
AnimationManager
即動畫管理器,用于管理游戲中的所有動畫,驅動節點上的 Animation
組件播放動畫。
animation-manager.js:https://github.com/cocos-creator/engine/blob/2.4.3/cocos2d/animation/animation-manager.js
CollisionManager
CollisionManager
即碰撞組件管理器,用于處理節點之間的碰撞組件是否產生了碰撞,并調用相應回調函數。
CCCollisionManager.js:https://github.com/cocos-creator/engine/blob/2.4.3/cocos2d/collider/CCCollisionManager.js
PhysicsManager
PhysicsManager
即物理系統管理器,內部以 Box2D
作為 2D 物理引擎,加以封裝并開放部分常用的接口。同時 PhysicsManager
還負責管理碰撞信息的分發。
CCPhysicsManager.js:https://github.com/cocos-creator/engine/blob/2.4.3/cocos2d/core/physics/CCPhysicsManager.js
Physics3DManager
Physics3DManager
即3D 物理系統管理器,Cocos Creator 中的 3D 物理引擎有 Cannon.js
和 Builtin
可選,Physics3DManager
給它們封裝了統一的常用接口。
physics-manager.ts:https://github.com/cocos-creator/engine/blob/2.4.3/cocos2d/core/3d/physics/framework/physics-manager.ts
InputManager
InputManager
即輸入事件管理器,用于管理所有輸入事件。開發者主動啟用加速度計(Accelerometer)之后,引擎會定時通過 InputManager
發送 cc.SystemEvent.EventType.DEVICEMOTION
事件(默認間隔為 0.2 秒)。
CCInputManager.js:https://github.com/cocos-creator/engine/blob/2.4.3/cocos2d\core\platform\CCInputManager.js
組件計時器
相信大多數小伙伴都使用過組件的 schedule()
和 scheduleOnce()
接口,主要用來重復執行或定時執行函數。
實際上,cc.Component
的 schedule()
接口依賴的也是 cc.Scheduler
類,具體使用的也是 cc.director
對象中的 _scheduler
實例。
組件的 schedule()
接口在 cc.director._scheduler.schedule()
接口之外加了一層封裝,以組件自身作為 target
,這樣一來組件內的定時任務就與組件生命周期綁定,當組件被銷毀時定時任務也會被移除。
而 scheduleOnce()
接口則是在組件的 schedule()
接口之外又加了一層封裝,固定只會在指定時間后執行一次。
CCComponent.js:https://github.com/cocos-creator/engine/blob/2.4.3/cocos2d/core/components/CCComponent.js
[文檔] 使用計時器:http://docs.cocos.com/creator/manual/zh/scripting/scheduler.html
另外我還注意到,有不少小伙伴還不是很清楚組件的計時器和 setTimeout()
、setInterval()
之間的區別和用法,那就趁這個機會簡單講一下吧~
setTimeout & setInterval
setTimeout()
和 setInterval()
都是由瀏覽器或 Node.js 這類 runtime 所提供的接口。
setTimeout()
接口用于設置一個定時器,該定時器在定時器到期后執行一個函數或指定的一段代碼。setInterval()
接口用于重復調用一個函數或執行一個代碼段,在每次調用之間具有固定的時間延遲。
再補充一個小知識:
在瀏覽器中 setTimeout()
和 setInterval()
的最小延時(間隔)是 4ms。
如果是未激活(后臺)的標簽頁(tab),最小延時(間隔)則加長到 1000ms。
舉個栗子
假如我在當前標簽頁設置了一個每 500ms 輸出一個 log 的定時器,當我切換到別的標簽頁之后,那么這個定時器就會變成每 1000ms 才輸出一個 log。
像這樣,感興趣的話可以自己去試試:
- setInterval(() => {
- console.log(new Date().getTime());
- }, 500);
- // 模擬輸出
- // 標簽頁在前臺
- // 1604373521000
- // 1604373521500
- // 1604373522000
- // 切換到別的標簽頁后
- // 1604373523000
- // 1604373524000
- // 1604373525000
區別 & 用法
組件的計時器依賴于引擎的 mainLoop()
和組件自身,如果引擎被暫停,那么組件的計時器也會被暫停,如果組件或組件所在的節點被銷毀了,那么計時器也會失效。
setTimeout()
和 setInterval()
都依賴于當前所處的 window
對象,也就是說只要當前瀏覽器標簽頁不關閉,setTimeout()
和 setInterval()
都還是會執行的。
當你需要在組件內部定時或重復執行某一函數或操作某個節點,那么可以使用組件的計時器。
讓我們想象一個場景:
在當前場景中的某個腳本內使用 setInterval()
來重復移動場景中的某個節點,當我們切換場景后會發生什么?
當定時器再次調用回調嘗試移動節點的時候,會無法找到目標節點而報錯,因為節點已經跟著之前的場景一起被銷毀了,而定時器還在繼續執行。
這種情況下使用組件的計時器就不會有這種問題,因為計時器會隨著組件的銷毀而被清除。
而當我們需要執行一些與游戲場景沒有關聯的事情的時候,就可以考慮使用 setTimeout()
或 setInterval()
。
當然能用組件計時器的話最好還是用組件計時器啦~
小結
依然還是畫一張圖來小小總結一下 Scheduler
。
總結
關于引擎的啟動流程和主循環就解讀到這里啦。
最后的最后,還是畫張圖來做一個最后的總結~
以上就是解讀CocosCreator源碼之引擎啟動與主循環的詳細內容,更多關于CocosCreator源碼解讀的資料請關注服務器之家其它相關文章!
原文鏈接:https://www.cnblogs.com/ifaswind/p/cocos-creator-source-launch-and-main-loop.html