Cocos Creator 新资源管理系统剖析【二:资源管线与资源下载】

【本文参与征文活动】

AssetManager内置了3条管线,普通的加载管线、预加载、以及资源路径转换管线,最后这条管线是为前面两条管线服务的。

    // 正常加载
    this.pipeline = pipeline.append(preprocess).append(load);
    // 预加载
    this.fetchPipeline = fetchPipeline.append(preprocess).append(fetch);
    // 转换资源路径
    this.transformPipeline = transformPipeline.append(parse).append(combine);

启动加载管线【加载接口】

接下来我们看一下一个普通的资源是如何加载的,比如最简单的cc.resource.load,在bundle.load方法中,调用了cc.assetManager.loadAny,在loadAny方法中,创建了一个新的任务,并调用正常加载管线pipeline的async方法执行任务。

注意要加载的资源路径,被放到了task.input中、options是一个对象,对象包含了type、bundle和__requestType__等字段

    // bundle类的load方法
    load (paths, type, onProgress, onComplete) {
        var { type, onProgress, onComplete } = parseLoadResArgs(type, onProgress, onComplete);
        cc.assetManager.loadAny(paths, { __requestType__: RequestType.PATH, type: type, bundle: this.name }, onProgress, onComplete);
    },
    
    // assetManager的loadAny方法
    loadAny (requests, options, onProgress, onComplete) {
        var { options, onProgress, onComplete } = parseParameters(options, onProgress, onComplete);
        
        options.preset = options.preset || 'default';
        let task = new Task({input: requests, onProgress, onComplete: asyncify(onComplete), options});
        pipeline.async(task);
    },

pipeline由两部分组成 preprocess 和 load。preprocess 由以下管线组成 preprocess、transformPipeline { parse、combine },preprocess实际上只创建了一个子任务,然后交由transformPipeline执行。对于加载一个普通的资源,子任务的input和options与父任务相同。

    let subTask = Task.create({input: task.input, options: subOptions});
    task.output = task.source = transformPipeline.sync(subTask);

transformPipeline管线【准备阶段】

transformPipeline由parse和combine两个管线组成,parse的职责是为每个要加载的资源生成RequestItem对象并初始化其资源信息(AssetInfo、uuid、config等):

  • 先将input转换成数组进行遍历,如果是批量加载资源,每个加载项都会生成RequestItem
  • 如果输入的item是object,则先将options拷贝到item身上(实际上每个item都会是object,如果是string的话,第一步就先转换成object了)
    • 对于UUID类型的item,先检查bundle,并从bundle中提取AssetInfo,对于redirect类型的资源,则从其依赖的bundle中获取AssetInfo,找不到bundle就报错
    • PATH类型和SCENE类型与UUID类型的处理基本类似,都是要拿到资源的详细信息
    • DIR类型会从bundle中取出指定路径的信息,然后批量追加到input尾部(额外生成加载项)
    • URL类型是远程资源类型,无需特殊处理
function parse (task) {
    // 将input转换成数组
    var input = task.input, options = task.options;
    input = Array.isArray(input) ? input : [ input ];

    task.output = [];
    for (var i = 0; i < input.length; i ++ ) {
        var item = input[i];
        var out = RequestItem.create();
        if (typeof item === 'string') {
            // 先创建object
            item = Object.create(null);
            item[options.__requestType__ || RequestType.UUID] = input[i];
        }
        if (typeof item === 'object') {
            // local options will overlap glabal options
            // 将options的属性复制到item身上,addon会复制options上有,而item没有的属性
            cc.js.addon(item, options);
            if (item.preset) {
                cc.js.addon(item, cc.assetManager.presets[item.preset]);
            }
            for (var key in item) {
                switch (key) {
                    // uuid类型资源,从bundle中取出该资源的详细信息
                    case RequestType.UUID: 
                        var uuid = out.uuid = decodeUuid(item.uuid);
                        if (bundles.has(item.bundle)) {
                            var config = bundles.get(item.bundle)._config;
                            var info = config.getAssetInfo(uuid);
                            if (info && info.redirect) {
                                if (!bundles.has(info.redirect)) throw new Error(`Please load bundle ${info.redirect} first`);
                                config = bundles.get(info.redirect)._config;
                                info = config.getAssetInfo(uuid);
                            }
                            out.config = config;
                            out.info = info;
                        }
                        out.ext = item.ext || '.json';
                        break;
                    case '__requestType__':
                    case 'ext': 
                    case 'bundle':
                    case 'preset':
                    case 'type': break;
                    case RequestType.DIR: 
                        // 解包后动态添加到input列表尾部,后续的循环会自动parse这些资源
                        if (bundles.has(item.bundle)) {
                            var infos = [];
                            bundles.get(item.bundle)._config.getDirWithPath(item.dir, item.type, infos);
                            for (let i = 0, l = infos.length; i < l; i++) {
                                var info = infos[i];
                                input.push({uuid: info.uuid, __isNative__: false, ext: '.json', bundle: item.bundle});
                            }
                        }
                        out.recycle();
                        out = null;
                        break;
                    case RequestType.PATH: 
                        // PATH类型的资源根据路径和type取出该资源的详细信息
                        if (bundles.has(item.bundle)) {
                            var config = bundles.get(item.bundle)._config;
                            var info = config.getInfoWithPath(item.path, item.type);
                            
                            if (info && info.redirect) {
                                if (!bundles.has(info.redirect)) throw new Error(`you need to load bundle ${info.redirect} first`);
                                config = bundles.get(info.redirect)._config;
                                info = config.getAssetInfo(info.uuid);
                            }

                            if (!info) {
                                out.recycle();
                                throw new Error(`Bundle ${item.bundle} doesn't contain ${item.path}`);
                            }
                            out.config = config; 
                            out.uuid = info.uuid;
                            out.info = info;
                        }
                        out.ext = item.ext || '.json';
                        break;
                    case RequestType.SCENE:
                        // 场景类型,从bundle中的config调用getSceneInfo取出该场景的详细信息
                        if (bundles.has(item.bundle)) {
                            var config = bundles.get(item.bundle)._config;
                            var info = config.getSceneInfo(item.scene);
                            
                            if (info && info.redirect) {
                                if (!bundles.has(info.redirect)) throw new Error(`you need to load bundle ${info.redirect} first`);
                                config = bundles.get(info.redirect)._config;
                                info = config.getAssetInfo(info.uuid);
                            }
                            if (!info) {
                                out.recycle();
                                throw new Error(`Bundle ${config.name} doesn't contain scene ${item.scene}`);
                            }
                            out.config = config; 
                            out.uuid = info.uuid;
                            out.info = info;
                        }
                        break;
                    case '__isNative__': 
                        out.isNative = item.__isNative__;
                        break;
                    case RequestType.URL: 
                        out.url = item.url;
                        out.uuid = item.uuid || item.url;
                        out.ext = item.ext || cc.path.extname(item.url);
                        out.isNative = item.__isNative__ !== undefined ? item.__isNative__ : true;
                        break;
                    default: out.options[key] = item[key];
                }
                if (!out) break;
            }
        }
        if (!out) continue;
        task.output.push(out);
        if (!out.uuid && !out.url) throw new Error('unknown input:' + item.toString());
    }
    return null;
}

RequestItem的初始信息,都是从bundle对象中查询的,bundle的信息则是从bundle自带的config.json文件中初始化的,在打包bundle的时候,会将bundle中的资源信息写入config.json中。

经过parse方法处理后,我们会得到一系列RequestItem,并且很多RequestItem都自带了AssetInfo和uuid等信息,combine方法会为每个RequestItem构建出真正的加载路径,这个加载路径最终会转换到item.url中。

function combine (task) {
    var input = task.output = task.input;
    for (var i = 0; i < input.length; i++) {
        var item = input[i];
        // 如果item已经包含了url,则跳过,直接使用item的url
        if (item.url) continue;

        var url = '', base = '';
        var config = item.config;
        // 决定目录的前缀
        if (item.isNative) {
            base = (config && config.nativeBase) ? (config.base + config.nativeBase) : cc.assetManager.generalNativeBase;
        } 
        else {
            base = (config && config.importBase) ? (config.base + config.importBase) : cc.assetManager.generalImportBase;
        }

        let uuid = item.uuid;
            
        var ver = '';
        if (item.info) {
            if (item.isNative) {
                ver = item.info.nativeVer ? ('.' + item.info.nativeVer) : '';
            }
            else {
                ver = item.info.ver ? ('.' + item.info.ver) : '';
            }
        }

        // 拼接最终的url
        // ugly hack, WeChat does not support loading font likes 'myfont.dw213.ttf'. So append hash to directory
        if (item.ext === '.ttf') {
            url = `${base}/${uuid.slice(0, 2)}/${uuid}${ver}/${item.options.__nativeName__}`;
        }
        else {
            url = `${base}/${uuid.slice(0, 2)}/${uuid}${ver}${item.ext}`;
        }
        
        item.url = url;
    }
    return null;
}

load管线【加载流程】

load方法做的事情很简单,基本只是创建了新的任务,在loadOneAssetPipeline中执行每个子任务

function load (task, done) {
    if (!task.progress) {
        task.progress = {finish: 0, total: task.input.length};
    }
    
    var options = task.options, progress = task.progress;
    options.__exclude__ = options.__exclude__ || Object.create(null);
    task.output = [];
    forEach(task.input, function (item, cb) {
        // 对每个input项都创建一个子任务,并交由loadOneAssetPipeline执行
        let subTask = Task.create({ 
            input: item, 
            onProgress: task.onProgress, 
            options, 
            progress, 
            onComplete: function (err, item) {
                if (err && !task.isFinish && !cc.assetManager.force) done(err);
                task.output.push(item);
                subTask.recycle();
                cb();
            }
        });
        // 执行子任务,loadOneAssetPipeline有fetch和parse组成
        loadOneAssetPipeline.async(subTask);
    }, function () {
        // 每个input执行完成后,最后执行该函数
        options.__exclude__ = null;
        if (task.isFinish) {
            clear(task, true);
            return task.dispatch('error');
        }
        gatherAsset(task);
        clear(task, true);
        done();
    });
}

loadOneAssetPipeline如其函数名所示,就是加载一个资源的管线,它分为2步,fetch和parse:

  • fetch方法用于下载资源文件,由packManager负责下载的实现,fetch会将下载完的文件数据放到item.file中
  • parse方法用于将加载完的资源文件转换成我们可用的资源对象
    • 对于原生资源,调用parser.parse进行解析,该方法会根据资源类型调用不同的解析方法
      • import资源调用parseImport方法,根据json数据反序列化出Asset对象,并放到assets中
      • 图片资源会调用parseImage、parsePVRTex或parsePKMTex方法解析图像格式(但不会创建Texture对象)
      • 音效资源调用parseAudio方法进行解析
      • plist资源调用parsePlist方法进行解析
    • 对于其它资源
      • 如果uuid在task.options.__exclude__中,则标记为完成,并添加引用计数
      • 否则,根据一些复杂的条件来决定是否加载资源的依赖
var loadOneAssetPipeline = new Pipeline('loadOneAsset', [
    function fetch (task, done) {
        var item = task.output = task.input;
        var { options, isNative, uuid, file } = item;
        var { reload } = options;
        // 如果assets里面已经加载了这个资源,则直接完成
        if (file || (!reload && !isNative && assets.has(uuid))) return done();
        // 下载文件,这是一个异步的过程,文件下载完会被放到item.file中,并执行done驱动管线
        packManager.load(item, task.options, function (err, data) {
            if (err) {
                if (cc.assetManager.force) {
                    err = null;
                } else {
                    cc.error(err.message, err.stack);
                }
                data = null;
            }
            item.file = data;
            done(err);
        });
    },
    // 将资源文件转换成资源对象的过程
    function parse (task, done) {
        var item = task.output = task.input, progress = task.progress, exclude = task.options.__exclude__;
        var { id, file, options } = item;

        if (item.isNative) {
            // 对于原生资源,调用parser.parse进行处理,将处理完的资源放到item.content中,并结束流程
            parser.parse(id, file, item.ext, options, function (err, asset) {
                if (err) {
                    if (!cc.assetManager.force) {
                        cc.error(err.message, err.stack);
                        return done(err);
                    }
                }
                item.content = asset;
                task.dispatch('progress', ++progress.finish, progress.total, item);
                files.remove(id);
                parsed.remove(id);
                done();
            });
        } else {
            var { uuid } = item;
            // 非原生资源,如果在task.options.__exclude__中,直接结束
            if (uuid in exclude) {
                var { finish, content, err, callbacks } = exclude[uuid];
                task.dispatch('progress', ++progress.finish, progress.total, item);
    
                if (finish || checkCircleReference(uuid, uuid, exclude) ) {
                    content && content.addRef();
                    item.content = content;
                    done(err);
                } else {
                    callbacks.push({ done, item });
                }
            } else {
                // 如果不是reload,且asset中包含了该uuid
                if (!options.reload && assets.has(uuid)) {
                    var asset = assets.get(uuid);
                    // 开启了options.__asyncLoadAssets__,或asset.__asyncLoadAssets__为false,直接结束,不加载依赖
                    if (options.__asyncLoadAssets__ || !asset.__asyncLoadAssets__) {
                        item.content = asset.addRef();
                        task.dispatch('progress', ++progress.finish, progress.total, item);
                        done();
                    }
                    else {
                        loadDepends(task, asset, done, false);
                    }
                } else {
                    // 如果是reload,或者assets中没有,则进行解析,并加载依赖
                    parser.parse(id, file, 'import', options, function (err, asset) {
                        if (err) {
                            if (cc.assetManager.force) {
                                err = null;
                            }
                            else {
                                cc.error(err.message, err.stack);
                            }
                            return done(err);
                        }
                        
                        asset._uuid = uuid;
                        loadDepends(task, asset, done, true);
                    });
                }
            }
        }
    }
]);

文件下载

当要下载一个文件时,有2个问题需要考虑:

  • 该文件是否被打包了,比如由于勾选了内联所有SpriteFrame,导致SpriteFrame的json文件被合并到prefab中
  • 当前平台是原生平台还是web平台,对于一些本地资源,原生平台需要从磁盘读取
    // packManager.load的实现
    load (item, options, onComplete) {
        // 如果资源没有被打包,则直接调用downloader.download下载(download内部也有已下载和加载中的判断)
        if (item.isNative || !item.info || !item.info.packs) return downloader.download(item.id, item.url, item.ext, item.options, onComplete);
        // 如果文件已经下载过了,则直接返回
        if (files.has(item.id)) return onComplete(null, files.get(item.id));

        var packs = item.info.packs;
        // 如果pack已经在加载中,则将回调添加到_loading队列,等加载完成后触发回调
        var pack = packs.find(isLoading);
        if (pack) return _loading.get(pack.uuid).push({ onComplete, id: item.id });

        // 下载一个新的pack
        pack = packs[0];
        _loading.add(pack.uuid, [{ onComplete, id: item.id }]);
        let url = cc.assetManager._transform(pack.uuid, {ext: pack.ext, bundle: item.config.name});
        // 下载pack并解包,
        downloader.download(pack.uuid, url, pack.ext, item.options, function (err, data) {
            files.remove(pack.uuid);
            if (err) {
                cc.error(err.message, err.stack);
            }
            // unpack package,内部实现包含2种解包,一种针对prefab、图集等json数组的分割解包,另一种针对Texture2D的content进行解包
            packManager.unpack(pack.packs, data, pack.ext, item.options, function (err, result) {
                if (!err) {
                    for (var id in result) {
                        files.add(id, result[id]);
                    }
                }
                var callbacks = _loading.remove(pack.uuid);
                for (var i = 0, l = callbacks.length; i < l; i++) {
                    var cb = callbacks[i];
                    if (err) {
                        cb.onComplete(err);
                        continue;
                    }

                    var data = result[cb.id];
                    if (!data) {
                        cb.onComplete(new Error('can not retrieve data from package'));
                    }
                    else {
                        cb.onComplete(null, data);
                    }
                }
            });
        });
    }
Web平台的下载

web平台的download实现如下:

  • 用一个downloaders数组来管理各种资源类型对应的下载方式
  • 使用files缓存来避免重复下载
  • 使用_downloading队列来处理并发下载同一个资源时的回调,并保证时序
  • 支持了下载的优先级、重试等逻辑
    download (id, url, type, options, onComplete) {
        // 取出downloaders中对应类型的下载回调
        let func = downloaders[type] || downloaders['default'];
        let self = this;
        // 避免重复下载
        let file, downloadCallbacks;
        if (file = files.get(id)) {
            onComplete(null, file);
        }
        // 如果在下载中,添加到队列
        else if (downloadCallbacks = _downloading.get(id)) {
            downloadCallbacks.push(onComplete);
            for (let i = 0, l = _queue.length; i < l; i++) {
                var item = _queue[i];
                if (item.id === id) {
                    var priority = options.priority || 0;
                    if (item.priority < priority) {
                        item.priority = priority;
                        _queueDirty = true;
                    } 
                    return;
                }
            } 
        }
        else {
            // 进行下载,并设置好下载失败的重试
            var maxRetryCount = options.maxRetryCount || this.maxRetryCount;
            var maxConcurrency = options.maxConcurrency || this.maxConcurrency;
            var maxRequestsPerFrame = options.maxRequestsPerFrame || this.maxRequestsPerFrame;

            function process (index, callback) {
                if (index === 0) {
                    _downloading.add(id, [onComplete]);
                }
                if (!self.limited) return func(urlAppendTimestamp(url), options, callback);
                updateTime();

                function invoke () {
                    func(urlAppendTimestamp(url), options, function () {
                        // when finish downloading, update _totalNum
                        _totalNum--;
                        if (!_checkNextPeriod && _queue.length > 0) {
                            callInNextTick(handleQueue, maxConcurrency, maxRequestsPerFrame);
                            _checkNextPeriod = true;
                        }
                        callback.apply(this, arguments);
                    });
                }

                if (_totalNum < maxConcurrency && _totalNumThisPeriod < maxRequestsPerFrame) {
                    invoke();
                    _totalNum++;
                    _totalNumThisPeriod++;
                }
                else {
                    // when number of request up to limitation, cache the rest
                    _queue.push({ id, priority: options.priority || 0, invoke });
                    _queueDirty = true;
    
                    if (!_checkNextPeriod && _totalNum < maxConcurrency) {
                        callInNextTick(handleQueue, maxConcurrency, maxRequestsPerFrame);
                        _checkNextPeriod = true;
                    }
                }
            }

            // retry完成后,将文件添加到files缓存中,从_downloading队列中移除,并执行callbacks回调
            // when retry finished, invoke callbacks
            function finale (err, result) {
                if (!err) files.add(id, result);
                var callbacks = _downloading.remove(id);
                for (let i = 0, l = callbacks.length; i < l; i++) {
                    callbacks[i](err, result);
                }
            }
    
            retry(process, maxRetryCount, this.retryInterval, finale);
        }
    }

downloaders是一个map,映射了各种资源类型对应的下载方法,在web平台主要包含以下几类下载方法:

  • 图片类 downloadImage
    • downloadDomImage 使用Html的Image元素,指定其src属性来下载
    • downloadBlob 以文件下载的方式下载图片
  • 文件类,这里可以分为二进制文件、json文件和文本文件
    • downloadArrayBuffer 指定arraybuffer类型调用downloadFile,用于skel、bin、pvr等文件下载
    • downloadText 指定text类型调用downloadFile,用于atlas、tmx、xml、vsh等文件下载
    • downloadJson 指定json类型调用downloadFile,并在下载完后解析json,用于plist、json等文件下载
  • 字体类 loadFont 构建css样式,指定url下载
  • 声音类 downloadAudio
    • downloadDomAudio 创建Html的audio元素,指定其src属性来下载
    • downloadBlob 以文件下载的方式下载音效
  • 视频类 downloadVideo web端直接返回了
  • 脚本 downloadScript 创建Html的script元素,指定其src属性来下载并执行
  • Bundle downloadBundle 同时下载了Bundle的json和脚本

downloadFile使用了XMLHttpRequest来下载文件,具体实现如下:

function downloadFile (url, options, onProgress, onComplete) {
    var { options, onProgress, onComplete } = parseParameters(options, onProgress, onComplete);
    var xhr = new XMLHttpRequest(), errInfo = 'download failed: ' + url + ', status: ';
    xhr.open('GET', url, true);
    
    if (options.responseType !== undefined) xhr.responseType = options.responseType;
    if (options.withCredentials !== undefined) xhr.withCredentials = options.withCredentials;
    if (options.mimeType !== undefined && xhr.overrideMimeType ) xhr.overrideMimeType(options.mimeType);
    if (options.timeout !== undefined) xhr.timeout = options.timeout;

    if (options.header) {
        for (var header in options.header) {
            xhr.setRequestHeader(header, options.header[header]);
        }
    }

    xhr.onload = function () {
        if ( xhr.status === 200 || xhr.status === 0 ) {
            onComplete && onComplete(null, xhr.response);
        } else {
            onComplete && onComplete(new Error(errInfo + xhr.status + '(no response)'));
        }

    };

    if (onProgress) {
        xhr.onprogress = function (e) {
            if (e.lengthComputable) {
                onProgress(e.loaded, e.total);
            }
        };
    }

    xhr.onerror = function(){
        onComplete && onComplete(new Error(errInfo + xhr.status + '(error)'));
    };
    xhr.ontimeout = function(){
        onComplete && onComplete(new Error(errInfo + xhr.status + '(time out)'));
    };
    xhr.onabort = function(){
        onComplete && onComplete(new Error(errInfo + xhr.status + '(abort)'));
    };

    xhr.send(null);
    return xhr;
}
原生平台下载

原生平台的引擎相关文件可以在引擎目录的resources/builtin/jsb-adapter/engine目录下,资源加载相关的实现在jsb-loader.js文件中,这里的downloader重新注册了回调函数。

downloader.register({
    // JS
    '.js' : downloadScript,
    '.jsc' : downloadScript,

    // Images
    '.png' : downloadAsset,
    '.jpg' : downloadAsset,
    ...
});

在原生平台下,downloadAsset等方法都会调用download来进行资源的下载,在资源下载之前会调用transformUrl对url进行检测,主要判断该资源是网络资源还是本地资源,如果是网络资源,是否已经下载过了。只有没下载过的网络资源,才需要进行下载。不需要下载的在文件解析的地方会直接读文件。

// func传入的是下载完成之后的处理,比如脚本下载完成后需要执行,此时会调用window.require
// 如果说要下载的是json资源之类的,传入的func是doNothing,也就是直接调用onComplete方法
function download (url, func, options, onFileProgress, onComplete) {
    var result = transformUrl(url, options);
    // 如果是本地文件,直接指向func
    if (result.inLocal) {
        func(result.url, options, onComplete);
    }
    // 如果在缓存中,更新资源的最后使用时间(lru)
    else if (result.inCache) {
        cacheManager.updateLastTime(url)
        func(result.url, options, function (err, data) {
            if (err) {
                cacheManager.removeCache(url);
            }
            onComplete(err, data);
        });
    }
    else {
        // 未下载的网络资源,调用downloadFile进行下载
        var time = Date.now();
        var storagePath = '';
        if (options.__cacheBundleRoot__) {
            storagePath = `${cacheManager.cacheDir}/${options.__cacheBundleRoot__}/${time}${suffix++}${cc.path.extname(url)}`;
        }
        else {
            storagePath = `${cacheManager.cacheDir}/${time}${suffix++}${cc.path.extname(url)}`;
        }
        // 使用downloadFile下载并缓存
        downloadFile(url, storagePath, options.header, onFileProgress, function (err, path) {
            if (err) {
                onComplete(err, null);
                return;
            }
            func(path, options, function (err, data) {
                if (!err) {
                    cacheManager.cacheFile(url, storagePath, options.__cacheBundleRoot__);
                }
                onComplete(err, data);
            });
        });
    }
}

function transformUrl (url, options) {
    var inLocal = false;
    var inCache = false;
    // 通过正则匹配是不是URL
    if (REGEX.test(url)) {
        if (options.reload) {
            return { url };
        }
        else {
            // 检查是否在缓存中(本地磁盘缓存)
            var cache = cacheManager.cachedFiles.get(url);
            if (cache) {
                inCache = true;
                url = cache.url;
            }
        }
    }
    else {
        inLocal = true;
    }
    return { url, inLocal, inCache };
}

downloadFile会调用原生平台的jsb_downloader来下载资源,并保存到本地磁盘中

    downloadFile (remoteUrl, filePath, header, onProgress, onComplete) {
        downloading.add(remoteUrl, { onProgress, onComplete });
        var storagePath = filePath;
        if (!storagePath) storagePath = tempDir + '/' + performance.now() + cc.path.extname(remoteUrl);
        jsb_downloader.createDownloadFileTask(remoteUrl, storagePath, header);
    },
11赞

mark…

:+1:
对大佬讲解的资源加载流程、资源管理印象深刻。
给大佬倒茶:tea:

约起呀:grin:

请我下 doesn’t contain scene 是什么原因造成的?bundle加载的config.json信息 scenes场景都有 然后跳转loginScene没问题 跳转AttScene就报错doesn’t contain scene

先mark!

想问如何捕获 pipiline 下载文件 失败 ?