iT邦幫忙

2022 iThome 鐵人賽

DAY 24
1
Modern Web

30個遊戲程設的錦囊妙計系列 第 24

Trick 23: 大型垃圾不要丟,資源回收再利用

  • 分享至 

  • xImage
  •  

程式語言在某種分類上可以分為低階語言與高階語言。低階語言(如C語言)提供了從作業系統規畫一塊記憶體來用的功能,不過程式也要自己負責在記憶體不用時還回去給作業系統,才不會造成記憶體漏水(memory leak)。

記憶體管理

不同於低階語言,像JavaScript這種高階語言,記憶體的管理是由系統負責的,在新增物件時會從系統得到一塊記憶體,但在物件被丟棄之後,程式設計師只能相信系統會幫忙處理,但無法得知一段記憶體到底什麼時候會被系統回收。

系統回收記憶體的策略,是去檢查這個變數是不是能從JavaScript的核心尋著變數分支找得到。比如我們寫了一個app.ts,這個檔案會被當成一個模組放在核心的某個地方,所以這個模組永遠不會被系統回收。那麼在這個模組裏寫了一個變數並放入一個物件,那麼這個物件就可以從核心通過模組裏的變數找到,因此不會被系統回收。接著我們把這個變數設定為null,那原本放在該變數的物件就無法再從核心藉由任何途徑找到,於是這個物件就會被系統放入資源回收桶,等待下次回收車來的時候,將這段記憶體載回去給系統再利用。

這個記憶體回收系統感覺很棒呀!是很棒沒錯,記憶體能被回收再利用,那麼我們的遊戲就能一直擁有足夠的資源,保持運作的順暢。

不過問題就在於,我們不知道回收車哪時候才會來。

不管是以前的Flash還是現在的HTML5,網頁上的記憶體回收車,都是在CPU使用率低的時候才會開出來。也就是說,如果遊戲一直處在繁忙的狀態,那麼回收車就不會來,記憶體會越用越兇,即使程式設計師都做好記憶體回收的標記工作,但回收車不來,就只能乾等,最後資源越來越少,導致遊戲越跑越慢,直到最後系統受不了,直接來個網頁凍結(freeze)一秒,把回收車開出來,記憶體收一收,再讓網頁繼續。

沒有人會想要這樣的遊戲體驗吧,可是JavaScript就是不讓你自己管理記憶體呀!這怎麼辦?

自製資源回收池

系統不給?那就自己做!

要自製資源回收池,需要建立可回收物件的介面。

// 定義可回收物件的介面
interface IRecyclable {
    // 被回收時要呼叫的重置函式
	reset(): void;
    // 查看這物件是不是已毀滅,無法再利用
    destroyed: boolean;
}

接著就可以來設計資源回收池的類別。

/** 建立一個回收池
 * <T>是TypeScript宣告泛型的方法,
 * 可以在設計這個類別時,先假定有一個T的型別,
 * 在實際創建實體時,再動態指定T的真正型別,
 * 本文最後會再詳加說明。
 * 這裏的<T extends IRecyclable>表示這個回收池
 * 是專案設計給T這個型別,
 * 而T必須符合IRecyclable規定的介面。
 */
class RecyclePool<T extends IRecyclable> {
    // 被回收的物件列表
    items: T[] = [];
    // 從池裏拿一個出來用
    getInstance(): T {
        // 如果池裏還有剩的
        if(this.items.length) {
            // 從池子裏拿一個出來
            let instance = this.items.pop();
            // 如果這物件沒壞
            if (!instance.destroyed) {
                // 那重置一下物件,就可以給人用了
                instance.reset();
                return instance;
            }
        }
        // 如果池裏沒有堪用的物件就回傳null
        return null;
    }
    // 回收物件的函式
    recycle(item: T): void {
        // 若物件沒壞,而且還不在items陣列裏
        if (!item.destroyed && !this.items.includes(item)) {
            // 丟進回收池
            this.items.push(item);
        }
    }
}

有了上面對回收物及回收池的定義之後,接著來設計一個可回收的物件類別來測試。

我們來製造橘色彈的,這些橘色彈會在畫面上播放炸開的動畫。我們待會兒會每幾個毫秒就放一顆給他爆。

// 建立一個變數,用來記錄我們創建了幾枚炸彈
let bombCreated = 0;

// 可回收的炸彈類別,必須實作可回收物件的介面
class Bomb implements IRecyclable {
    // 建一個static,專門給Bomb用的回收池
    // 文章最後會對static再加說明
    private static pool = new RecyclePool<Bomb>();
    // 寫一個static函式,用來取出一個炸彈
    static getInstance(): Bomb {
        // 先去池裏看看有沒有
        let instance = Bomb.pool.getInstance();
        // 如果沒有就建一個新的
        if (!instance) {
            instance = new Bomb();
            instance.reset();
        }
        return instance;
    }
    
    // 為炸彈畫一個橘色圓,這裏使用的是CG的繪圖功能
    circle = draw.circle(0, 0, 15, { fillColor: 0xFF6600, lineThickness: 0 });

    // 被回收時要呼叫的重置函式
    reset(): void {
        // 將circle重新設定為一開始的狀態
        this.circle.scale.set(1);
        this.circle.alpha = 1;
    }
    // 一個唯讀的屬性,查看這物件是不是已毀滅,無法再利用
    get destroyed(): boolean {
        // 如果圖形因某些不明原因壞了,就不要再回收這個物件了
        return this.circle.destroyed;
    }

    // 建構子
    constructor() {
        // 在新增Bomb時,將bombCreated加一
        bombCreated++;
        // 在控制台列印出來
        console.log(`第${bombCreated}枚新炸彈被造出來了!`);
    }

    // 開始炸彈的動畫
    start(): void {
        // 在畫面上隨機選一點放炸彈
        this.circle.x = rng.nextBetween(40, 600);
        this.circle.y = rng.nextBetween(40, 440);
        // 用tween物件產生動畫,並在動畫結束時回收
        new TWEEN.Tween(this.circle)
            .to( // 設定動畫的目標
                {
                    alpha: 0,   // 不透明度要變化到0
                    scaleX: 2,  // x放大到2
                    scaleY: 2,  // y放大到2
                },
                1000
            )
            .onComplete(() => { // 設定動畫完成後要做的事
                // 把我拿去回收
                Bomb.pool.recycle(this);
            })
            .start(); // 開始播放
    }
}

// 設定每120毫秒放一顆炸彈
setInterval(function () {
    Bomb.getInstance().start();
}, 120);

由以上的示範程式中發現,無論畫面上炸了幾千顆橘色彈,整個系統總共只建構了九枚全新彈,不賴吧!

CG示範專案
專案中有使用到《tween.js》函式庫,這是用來讓物件的屬性進行數值變化的好用函式庫。


註解

泛型(Generics)

在設計函式的時候,有時我們想設定回傳物件的型別要和傳入的參數一樣,但是我們又希望參數能接受很多種型別,那可能可以這樣寫。

function add(value1: number|string, value2: number|string): any {
    return value1 + value2;
}

不過這樣寫的話,參數的型別在函式回傳的時候中就遺失了,我們會無法確定回傳出來的東西到底是什麼。

為了更清楚地定義函式,我們可以使用泛型來動態定義參數與回傳值的型別。

function add<T>(value1: T, value2: T): T {
    return value1 + value2;
}

我們宣告add<T>的時候,就是告訴函式的使用者『你在用這個函式時,可以決定T是什麼,而函式回傳的值也一樣限制要是T這個型別。』

// 我們限制T一定要是一個number
// 因此除了傳進去的參數一定要是數字
// 也同時保證了傳出來的值也是數字
let result1 = add<number>(1, 2);
console.log("type of result1 = " + (typeof result1));
// 上面那一行會列印出 > type of result1 = number

// 同一個函式,但這次我們限制T一定要是一個string
let result2 = add<string>("haska ", "rocks");
console.log("type of result2 = " + (typeof result));
// 上面那一行會列印出 > type of result2 = string

用類似方法,我們也可以在類別或介面上上加上泛型,並且還能用extends來縮小T型別的適用範圍。

/** 定義RecyclePool的類別,而且可以用T來實作相關的函式或屬性
 * 其中T一定要是一個符合IRecyclable或IRecyclable延伸出來的類別
 */
class RecyclePool<T extends IRecyclable> {
    items: T[] = [];
    getItem(index: number): T {
        return this.items[index];
    }
}

泛型主要應用在彈性需求較高的函式庫設計上,更進一步的說明可以參考TypeScript官網對Generics的介紹

static(靜態變數)

在類別(class)裏面寫靜態變數(static),可以在不建立類別實體時,直接調用這些靜態變數或函式。

比如說我們有下面這個類別。

class Robot {
    // 靜態變數,可用 Robot.firstRule 調用
    static firstRule = "機器人不得傷害人類";
    // 靜態函式,可用 Robot.pickName() 調用
    static pickName(): string {
        // 隨機選一個名字
        return "Robert_" + Math.round((Math.random() * 99999));
    }
    
    // 使用靜態函式幫這個實體取一個名字
    name = Robot.pickName();
    // 把參數給的句子說出來
    say(something: string): void {
        console.log(something);
    }
}

其中的name是一個Robot的屬性,say()則是Robot可以用的函式,要使用這兩個屬性及函式,需要先有一個Robot的實體才行。

let johnnyFive = new Robot();
johnnyFive.say("我的名字是" + johnnyFive.name);

但是靜態屬性或函式就不一樣了。靜態屬性及函式是屬於Robot這個類別的,所以想要調用其中的靜態屬性,就要用 Robot.firstRule 這種寫法。

johnnyFive.say(Robot.firstRule);

上一篇
Trick 22: 遊戲的正義由數字保安來維護
下一篇
Trick 24: 重覆播放的環境音同時有三百個會怎樣
系列文
30個遊戲程設的錦囊妙計32
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言