Cocos Creator 源碼解讀:引擎啟動與主循環

前言

預備

不知道你有沒有想過,假如把遊戲世界比作一輛汽車,那麼這輛「汽車」是如何啟動,又是如何持續運轉的呢?

如題,本文的內容主要為 Cocos Creator 引擎的啟動流程主循環

而在主循環的內容中還會涉及到:組件的生命周期和計時器、緩動系統、動畫系統和物理系統等…

本文會在宏觀上為大家解讀主循環與各個模組之間的關係,對於各個模組也會簡單介紹,但不會深入到模組的具體實現。

因為如果把每個模組都「摸」一遍,那這篇文章怕是寫不完了。

Go!

希望大家看完這篇文章之後能夠更加了解 Cocos Creator 引擎。

同時也希望本文可以起到「師傅領進門」的作用,大家一起加油修行鴨~

另外《源碼解讀》系列(應該)會持續更新,如果你想要皮皮來解讀解讀引擎的某個模組,也歡迎留言告訴我,我…我考慮下哈哈哈~

本文以 Cocos Creator 2.4.3 版本為參考。


正文

啟動流程

index.html

對於 Web 平台 index.html 文件就是絕對的起點。

在默認的 index.html 文件中,定義了遊戲啟動頁面的布局,並且還有一段立即執行的程式碼。

這裡截取文件中一部分比較關鍵的程式碼:

// 載入引擎腳本
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() 函數。

🧵 程式碼壓縮

腳本文件名中帶有 -min 字樣一般代表著這個文件內的程式碼是被壓縮過的。

壓縮程式碼可以節省程式碼文件所佔用的空間,加快文件載入速度,減少流量消耗,但同時也讓程式碼失去了可閱讀性,不利於調試。

所以開啟調試模式後會直接使用未經過壓縮的程式碼文件,便於開發調試和定位錯誤。

main.js

window.boot()

對於不同平台 main.js 的內容也有些許差異,這裡我們忽略差異部分,只關注其中關鍵的共同行為。

關於 main.js 文件的內容基本上就是定義了 window.boot() 函數。

💡 對於非 Web 平台,會在定義完之後直接就調用 window.boot() 函數,所以 main.js 才是他們的起點。

window.boot() 函數內部有以下關鍵行為:

  1. 定義 onStart 回調函數:主要用於載入啟動場景
  2. cc.assetManager.init(...):初始化 AssetManager
  3. cc.assetManager.loadScript(...):載入 src 目錄下的插件腳本
  4. cc.assetManager.loadBundle(...):載入項目中的 bundle
  5. cc.game.run(...):啟動引擎

這部分的程式碼就不貼了,小夥伴們可以看看自己的項目構建後的 main.js 文件。

cc.game

cc.game 對象是 cc.Game 類的一個實例,cc.game 包含了遊戲主體資訊並負責驅動遊戲。

說人話,cc.game 對象就是管理引擎生命周期的模組,啟動、暫停和重啟等操作都需要用到它。

CCGame.js://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));
}

傳送門://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);
    });
}

傳送門://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();
    });
}

傳送門://github.com/cocos-creator/engine/blob/2.4.3/cocos2d/core/CCGame.js#L387

💡 對於 _prepareFinished() 內調用的 _setAnimFrame() 函數這裡必須提一下。

_setAnimFrame()

cc.game._setAnimFrame() 內部對不同的遊戲幀率做了適配。

另外還對 window.requestAnimationFrame() 介面做了兼容性封裝,用於兼容不同的瀏覽器環境,具體的我們下面再說。

這裡就不貼 _setAnimFrame() 的程式碼了,有需要的小夥伴可自行查閱。

傳送門://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;
}

傳送門://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 文檔://developer.mozilla.org/zh-CN/docs/Web/API/Performance/now

回調函數的執行次數通常與瀏覽器螢幕刷新次數相匹配,也就是說,對於刷新率為 60Hz 的顯示器,瀏覽器會在一秒內執行 60 次回調函數。

對於 window.requestAnimationFrame() 的說明到此為止,如果想要了解更多資訊請自行搜索。

MDN 文檔://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestAnimationFrame

小結

畫一張圖來對引擎的啟動流程做一個小小的總結叭~

主循環

經歷了一番波折後,終於來到了最期待的引擎主循環部分,話不多說,我們繼續!

cc.director

cc.director 對象是導演類 cc.Director 的實例,引擎通主要過 cc.director 對象來管理遊戲的邏輯流程。

CCDirector.js://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++;
}

傳送門://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://github.com/cocos-creator/engine/blob/2.4.3/cocos2d/core/component-scheduler.js

startPhase

// 調用新增的組件(已啟用)的 start 函數
this._compScheduler.startPhase();

組件的 start 回調函數會在組件第一次激活前,也就是第一次執行 update 之前觸發。

在組件的一生中 start 回調只會被觸發一次,onLoadonEnable 也一樣。

只不過 onLoadonEnable 是由 NodeActivator 類的實例來管理的:

  • onLoad 會在節點激活時就觸發
  • onEnable 會在組件被啟用時觸發

start 則會等到下一次主循環 mainLoop() 時才觸發。

🥁 NodeActivator

NodeActivator 類主要用於啟用和禁用節點以及身上的組件。

cc.director 對象中就擁有一個實例 _nodeActivator,遊戲中所有節點的啟用和禁用都需要通過它來操作。

像這樣:cc.director._nodeActivator.activateNode(this, value);

node-activator.js://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://github.com/cocos-creator/engine/blob/2.4.3/cocos2d/particle/CCParticleSystem.js#L923

Tips

請謹慎使用 updatelateUpdate 回調,因為它們每一幀都會被觸發,如果 updatelateUpdate 內的邏輯過多,就會使得每一幀的執行時間(即幀時間 Frame time)都變長,導致遊戲運行幀數降低或出現不穩定的情況。

📢 注意這不是不讓你用,該用還得用,只是不要濫用,不要啥玩意都往裡邊賽~

Scheduler

cc.director 對象的 _scheduler 屬性為 cc.Scheduler 類的實例。

cc.Scheduler 是負責觸發回調函數的類。

Scheduler.js://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動作管理器,用於管理遊戲中的所有動作,也就是緩動系統 ActionTween(其實它們本質上是同一種東西)。

CCActionManager.js://github.com/cocos-creator/engine/blob/2.4.3/cocos2d/actions/CCActionManager.js

AnimationManager

AnimationManager動畫管理器,用於管理遊戲中的所有動畫,驅動節點上的 Animation 組件播放動畫。

animation-manager.js://github.com/cocos-creator/engine/blob/2.4.3/cocos2d/animation/animation-manager.js

CollisionManager

CollisionManager碰撞組件管理器,用於處理節點之間的碰撞組件是否產生了碰撞,並調用相應回調函數。

CCCollisionManager.js://github.com/cocos-creator/engine/blob/2.4.3/cocos2d/collider/CCCollisionManager.js

PhysicsManager

PhysicsManager物理系統管理器,內部以 Box2D 作為 2D 物理引擎,加以封裝並開放部分常用的介面。同時 PhysicsManager 還負責管理碰撞資訊的分發。

CCPhysicsManager.js://github.com/cocos-creator/engine/blob/2.4.3/cocos2d/core/physics/CCPhysicsManager.js

Physics3DManager

Physics3DManager3D 物理系統管理器,Cocos Creator 中的 3D 物理引擎有 Cannon.jsBuiltin 可選,Physics3DManager 給它們封裝了統一的常用介面。

physics-manager.ts://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://github.com/cocos-creator/engine/blob/2.4.3/cocos2d\core\platform\CCInputManager.js

組件計時器

相信大多數小夥伴都使用過組件的 schedule()scheduleOnce() 介面,主要用來重複執行或定時執行函數。

實際上,cc.Componentschedule() 介面依賴的也是 cc.Scheduler 類,具體使用的也是 cc.director 對象中的 _scheduler 實例。

組件的 schedule() 介面在 cc.director._scheduler.schedule() 介面之外加了一層封裝,以組件自身作為 target,這樣一來組件內的定時任務就與組件生命周期綁定,當組件被銷毀時定時任務也會被移除。

scheduleOnce() 介面則是在組件的 schedule() 介面之外又加了一層封裝,固定只會在指定時間後執行一次。

CCComponent.js://github.com/cocos-creator/engine/blob/2.4.3/cocos2d/core/components/CCComponent.js#L555

[文檔] 使用計時器://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

總結

🎉 關於引擎的啟動流程和主循環就解讀到這裡啦。

如果有遺漏或錯誤的地方,也歡迎大家提出來,畢竟熬夜寫文章精神恍惚漏了也是情有可原的對吧哈哈哈~

最後的最後,還是畫張圖來做一個最後的總結~(🤣 逐漸愛上畫圖~)


傳送門

微信推文版本

個人部落格:菜鳥小棧

開源主頁:陳皮皮

Eazax-CCC 遊戲開發腳手架

Eazax-CCC 示例在線預覽


更多分享

《為什麼選擇使用 TypeScript ?》

《高斯模糊 Shader》

《一文看懂 YAML》

《Cocos Creator 性能優化:DrawCall》

《互聯網運營術語掃盲》

《在 Cocos Creator 里畫個炫酷的雷達圖》

《用 Shader 寫個完美的波浪》

《在 Cocos Creator 中優雅且高效地管理彈窗》


公眾號

菜鳥小棧

😺我是陳皮皮,一個還在不斷學習的遊戲開發者,一個熱愛分享的 Cocos Star Writer。

🎨這是我的個人公眾號,專註但不僅限於遊戲開發和前端技術分享。

💖每一篇原創都非常用心,你的關注就是我原創的動力!

Input and output.