H5遊戲: 徒手擼一個資源加載器

前言

最近學習 H5 遊戲編程,Phaser 代碼裏面有如下的代碼, 就能加載圖片。

  this.load.image("phaser-logo-i""../images/logo.png");

也有專門的資源加載庫 PreloadJS[1]

兩者的原理都是相同的,請求到資源,然後利用URL.createObjectURL生成 url 地址,以便之後複用。

Phaser 資源加載器和 PreloadJS 都能進行資源記載,並通過一個 key 來使用。看起來很美好,但是當頁面再次刷新的時候,還是會發出請求,再次加載文件。

這裏就延伸到瀏覽器緩存了,比較理想的就是 Service Worker 啦,合理配置之後,可以不發出請求,離線依舊可以使用。

我這裏就是就是用的另外一種比較常見的緩存方案,indexedDB。

演示和源碼

const resourcesInfo = [{
    pre: ["promise"],
    key: "axios",
    ver: "1.2",
    url: "https://cdnjs.cloudflare.com/ajax/libs/axios/0.21.1/axios.min.js"
},{
    key: "mqtt",
    ver: "1.0",
    url: "https://cdnjs.cloudflare.com/ajax/libs/mqtt/4.2.6/mqtt.min.js"
},{
    key: "lottie",
    url: "https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.7.8/lottie.min.js"
},{
    key: "flv",
    url: "https://cdnjs.cloudflare.com/ajax/libs/flv.js/1.5.0/flv.min.js"
},
{
    key: "promise",
    url: "https://cdnjs.cloudflare.com/ajax/libs/promise-polyfill/8.2.0/polyfill.min.js"
}];


let startTime;

const rl = new ResourceLoader(resourcesInfo, idb);

rl.on("progress"(progress, info)=>{
    console.log("progress:", progress,  info);
});

rl.on("completed"(datas)=>{
    console.log("completed event:", datas);    
    console.log("total time:", Date.now() - startTime)
});

rl.on("loaded"(datas)=>{
    console.log("loaded event:", datas);    
    console.log("total time:", Date.now() - startTime)
});

rl.on("error"(error, info)=>{
    console.log("error event:", error.message, info);
});

r-loader 演示地址 [2]
r-loader 源碼 [3]

基本思路

這裏先看看幾個資源屬性,

我們來過一下流程:

  1. 檢查資源是否加載完畢
    如果記載完畢,直接觸發 completed 事件

  2. 從 indexedDB 載入緩存

  3. 檢查是否有可加載的資源
    如果有,進入 4
    如果沒有,檢查是否均加載完畢, 加載完畢觸發 complete 事件

  4. 檢查資源是否有前置依賴項 如果沒有,進入 5
    如果有,進入 3

  5. 檢查是否有緩存 如果有,更新內部狀態,觸發 progress 事件,如果加載完畢,直接觸發 completed 事件, 反之進入 3
    如果沒有,發起網絡請求, 下載資源,存入 indexedDB,更新內部狀態。觸發 progress 事件,如果加載完畢,直接觸發 completed 事件, 反之進入 3

備註:

實現 - 工具方法

工欲善其事,必先利其器。先擼工具方法。

我們需要網路請求,對象複製(不破壞傳入的參數),參數的合併,檢查 key 值等。

  1. 網絡請求
function fetchResource(url){
    return fetch(url, {
         method: "get",
         responseType: 'blob'
     }).then(res=>{
         if(res.status >= 400){
             throw new Error(res.status + "," + res.statusText);
         }
         return res;
     }).then(res=> res.blob());
 }
  1. 版本比較
function compareVersion(v1 = ""v2 = "") {
    if(v1 == v2){
        return 0;
    }
    const version1 = v1.split('.')
    const version2 = v2.split('.')
    const len = Math.max(version1.length, version2.length);

    while (version1.length < len) {
      version1.push('0')
    }
    while (version2.length < len) {
      version2.push('0')
    }
    for (let i = 0; i < len; i++) {
      const num1 = parseInt(version1[i]) || 0;
      const num2 = parseInt(version2[i]) || 0;
      if (num1 > num2) {
        return 1
      } else if (num1 < num2) {
        return -1
      }
    }
    return 0
}
  1. 對象複製
 function copyObject(obj){
    return  JSON.parse(JSON.stringify(obj));
 }
  1. 生成資源地址
function generateBlobUrl(blob){
   return URL.createObjectURL(blob); 
}
  1. 檢查 key, key 不能爲空,key 不能重複
function validateKey(resources){
    let failed = false;
    // 空key檢查
    const emptyKeys = resources.filter(r=> r.key == undefined || r.key == "");
    if(emptyKeys.length > 0){
        failed = true;
        console.error("ResourceLoader validateKey: 資源都必須有key");
        return failed;
    }
    // 資源重複檢查
    const results = Object.create(null);
    resources.forEach(r=>{
        (results[r.key] = results[r.key]  || []).push(r);
    });   

    Object.keys(results).forEach(k=>{
        if(results[k].length > 1){
            console.error("key " +  k + " 重複了," + results[k].map(r=>r.url).join(","));
            failed = true;
        }
    });    
    return failed;
}

實現 - 消息通知

我們的資源加載是要有進度通知,錯誤通知,完畢通知的。這就是一個典型的消息通知。

class Emitter {
    constructor() {
        this._events = Object.create(null);
    }

    emit(type, ...args) {
        const events = this._events[type];
        if (!Array.isArray(events) || events.length === 0) {
            return;
        }
        events.forEach(event => event.apply(null, args));
    }

    on(type, fn) {
        const events = this._events[type] || (this._events[type] = []);
        events.push(fn)
    }

    off(type, fn) {
        const events = this._events[type] || (this._events[type] = []);
        const index = events.find(f =f === fn);
        if (index < -1) {
            return;
        }
        events.splice(index, 1);
    }
}

實現 - 緩存管理

我們爲了方便擴展,對緩存管理進行一次抽象。
CacheManager 的傳入參數 storage 真正負責存儲的, 我這裏使用一個極其輕量級的庫 idb-keyval[4]。更多的庫可以參見 IndexedDB[5]。

class CacheManager {
    constructor(storage) {
        this.storage = storage;
        this._cached = Object.create(null);
    }

    load(keys) {
        const cached = this._cached;
        return this.storage.getMany(keys).then(results ={
            results.forEach((value, index) ={
                if (value !== undefined) {
                    cached[keys[index]] = value;
                }
            });
            return cached;
        })
    }

    get datas() {
        return this._cached;
    }

    get(key) {
        return this._cached[key]
    }

    isCached(key) {
        return this._cached[key] != undefined;
    }

    set(key, value) {
        return this.storage.set(key, value);
    }

    clear() {
        this._cached = Object.create(null);
        // return this.storage.clear();
    }

    del(key){
        delete this._cached[key];
        // return this.storage.del();
    }
}

實現 - Loader

Loader 繼承 Emitter,自己就是一個消息中心了。

/ status: undefined loading loaded error
class ResourceLoader extends Emitter {
    constructor(resourcesInfo, storage = defaultStorage) {      
    }
    
    // 重置
    reset() {      
    }
    
    // 檢查是否加載完畢
    isCompleted() {       
    }
    
    // 當某個資源加載完畢後的回調
    onResourceLoaded = (info, data, isCached) ={  
    }
    
    // 某個資源加載失敗後的回調
    onResourceLoadError(err, info) {     
    }
    
    // 進行下一次的Load, onResourceLoadError和onResourceLoaded均會調用次方法
    nextLoad() {      
    }
    
    // 獲取下載進度
    getProgress() {    
    }
    
    // 獲取地址url
    get(key) {        
    }
    
    // 獲得緩存信息
    getCacheData(key) {        
    }
    
    // 請求資源
    fetchResource(rInfo) {    
    }  
    
    // 某個資源記載失敗後,設置依賴他的資源的狀態
    setFactorErrors(info) {    
    }
    
    // 檢查依賴項是不是都被加載了
    isPreLoaded(pre) {      
    }
    
    // 查找可以加載的資源
    findCanLoadResource() {    
    }
    
    // 獲取資源
    fetchResources() { 
    }
    
    // 準備,加載緩存
    prepare() {      
    }
 
    // 開始加載
    startLoad() {
    }
}

簡單先看一下骨架, startLoad 爲入口。其餘每個方法都加上了備註。很好理解。Loader 類的全部代碼

const defaultStorage = {
    get: noop,
    getMany: noop,
    set: noop,
    del: noop,
    clear: noop
};

// status: undefined loading loaded error
class ResourceLoader extends Emitter {
    constructor(resourcesInfo, storage = defaultStorage) {
        super();
        this._originResourcesInfo = resourcesInfo;
        this._cacheManager = new CacheManager(storage);
        this.reset();
    }

    reset() {
        const resourcesInfo = this._originResourcesInfo;
        this.resourcesInfo = resourcesInfo.map(r => copyObject(r));
        this.resourcesInfoObj = resourcesInfo.reduce((obj, cur) ={
            obj[cur.key] = cur;
            return obj;
        }{});
        // 已緩存, 緩存不等已加載,只有調用URL.createObjectURL之後,纔會變爲loaded
        this._loaded = Object.create(null);
        this._cacheManager.clear();
    }

    isCompleted() {
        return this.resourcesInfo.every(r => r.status === "loaded" || r.status === "error");
    }

    onResourceLoaded = (info, data, isCached) ={
        console.log(`${info.key} is loaded`);
        const rInfo = this.resourcesInfo.find(r => r.key === info.key);
        rInfo.status = "loaded";

        this._loaded[rInfo.key] = {
            key: rInfo.key,
            url: generateBlobUrl(data)
        };

        this.emit("progress", this.getProgress(), rInfo);
        if (!isCached) {
            const info = {
                data,
                key: rInfo.key,
                url: rInfo.url,
                ver: rInfo.ver || ""
            };
            this._cacheManager.set(info.key, info);
        }
        this.nextLoad();
    }

    nextLoad() {
        if (!this.isCompleted()) {
            return this.fetchResources()
        }
        this.emit("completed", this._loaded);
        // 全部正常加載,才觸發loaded事件
        if (this.resourcesInfo.every(r => r.status === "loaded")) {
            this.emit("loaded", this._loaded);
        }
    }

    getProgress() {
        const total = this.resourcesInfo.length;
        const loaded = Object.keys(this._loaded).length;
        return {
            total,
            loaded,
            percent: total === 0 ? 0 : + ((loaded / total) * 100).toFixed(2)
        }
    }

    get(key) {
        return (this._loaded[key] || this.resourcesInfoObj[key]).url;
    }

    getCacheData(key) {
        return this._cacheManager.get(key)
    }

    fetchResource(rInfo) {
        return fetchResource(`${rInfo.url}?ver=${rInfo.ver}`)
            .then(blob => this.onResourceLoaded(rInfo, blob))
            .catch(error => this.onResourceLoadError(error, rInfo));
    }

    onResourceLoadError(err, info) {
        const rInfo = this.resourcesInfo.find(r => r.key === info.key);
        rInfo.status = "error";

        console.error(`${info.key}(${info.url}) load error: ${err.message}`);
        this.emit("error", err, info);

        // 因被依賴,會導致其他依賴他的資源爲失敗
        this.setFactorErrors(info); 
        this.nextLoad();
    }

    setFactorErrors(info) {
        // 未開始,pre包含info.key
        const rs = this.resourcesInfo.filter(r => !r.status && r.pre && r.pre.indexOf(info.key) >= 0);
        if (rs.length < 0) {
            return;
        }
        rs.forEach(r ={
            console.warn(`mark ${r.key}(${r.url}) as error because it's pre failed to load`);
            r.status = "error"
        });
    }

    isPreLoaded(pre) {
        const preArray = Array.isArray(pre) ? pre : [pre]
        return preArray.every(p => this._loaded[p] !== undefined);
    }

    findCanLoadResource() {
        const info = this.resourcesInfo.find(r => r.status == undefined && (r.pre == undefined || this.isPreLoaded(r.pre)));
        return info;
    }

    fetchResources() {
        let info = this.findCanLoadResource();
        while (info) {
            const cache = this._cacheManager.get(info.key);

            // 有緩存
            if (cache) {
                const isOlder = compareVersion(cache.ver, info.ver || "") < 0;

                // 緩存過期
                if (isOlder) {
                    console.warn(`${info.key} is cached, but is older version, cache:${cache.ver} request: ${info.ver}`);
                } else {
                    console.log(`${info.key} is cached, load from db, pre`, info.pre);
                    this.onResourceLoaded(info, cache.data, true);
                    info = this.findCanLoadResource();
                    continue;
                }
            }
            console.log(`${info.key} load from network ${info.url}, pre`, info.pre);
            info.status = "loading";
            this.fetchResource(info);
            info = this.findCanLoadResource();
        }
    }

    prepare() {
        const keys = this.resourcesInfo.map(r => r.key);
        return this._cacheManager.load(keys);
    }

    startLoad() {
        const failed = validateKey(this.resourcesInfo);
        if (failed) {
            return;
        }
        if (this.isCompleted()) {
            this.emit("completed", this._cacheManager.datas);
        }
        this.prepare()
            .then(() => this.fetchResources())
            .catch(err=> this.emit("error", err));
    }
}

問題和後續

  1. 語法過於高級,需要使用 babel 轉換一下。

  2. fetch 狀態碼的處理,用 XMLHttpRequest 替換方案。

  3. indexedDB 不被支持的妥協方案。

  4. ......

寫在最後

如果你覺得不錯,你的一讚一評就是我前行的最大動力。

或者添加我的微信 dirge-cloud,一起學習。

參考資料

[1]

PreloadJS: https://github.com/CreateJS/PreloadJS

[2]

r-loader 演示地址: https://xiangwenhu.github.io/rloader/

[3]

r-loader 源碼: https://github.com/xiangwenhu/rloader

[4]

idb-keyval: https://github.com/jakearchibald/idb-keyval

[5]

IndexedDB: https://developer.mozilla.org/zh-CN/docs/Web/API/IndexedDB_API

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/huwew4tR2rFrLalMFTQKDw