Download流程的處理由Downloader這個pipe負責(downloader.js),Downloader提供了各種資源的“下載”方式——即如何獲取文件內容,有從網絡獲取、從磁盤獲取,不同類型的資源在不同的平台下有不同的獲取方式。
-
比如腳本在原生平台使用require方法獲取,而在H5平台則使用動態添加的 <script> HTML標簽,指定src進行加載。
-
又比如json在原生平台使用jsb.fileutils進行加載,而在H5平台則使用XMLHttpRequest從網絡下載。
-
Downloader處理
Downloader的handle接收一個item和callback,根據item的type在this.extMap中獲取對應的downloadFunc,交由downloadFunc下載,根據下載結果調用callback。同時有一個並發限制,默認最多同時下載64個資源,超過的會進入隊列,等待前面的資源加載完成后再依次進行加載。如果item的ignoreMaxConcurrency為true則無視該並發限制。downloadFunc接受一個item和一個callback,如果是同步下載,需要返回downloadFunc的返回值,而異步下載則返回undefined或不返回。
Downloader.prototype.handle = function (item, callback) {
var self = this;
var downloadFunc = this.extMap[item.type] || this.extMap['default'];
var syncRet = undefined;
if (this._curConcurrent < cc.macro.DOWNLOAD_MAX_CONCURRENT) {
this._curConcurrent++;
syncRet = downloadFunc.call(this, item, function (err, result) {
self._curConcurrent = Math.max(0, self._curConcurrent - 1);
self._handleLoadQueue();
callback && callback(err, result);
});
// 當downloadFunc是同步執行的,會返回非undefined的syncRet
if (syncRet !== undefined) {
this._curConcurrent = Math.max(0, this._curConcurrent - 1);
this._handleLoadQueue();
return syncRet;
}
}
else if (item.ignoreMaxConcurrency) {
syncRet = downloadFunc.call(this, item, callback);
if (syncRet !== undefined) {
return syncRet;
}
}
else {
this._loadQueue.push({
item: item,
callback: callback
});
}
};
Downloader的this.extMap記錄了各種資源類型的下載方式,所有的類型最終都對應這6個下載方法,downloadScript、downloadImage(downloadWebp)、downloadAudio、downloadText、downloadFont、downloadUuid,它們對應實現了各種類型資源的下載,通過Downloader.addHandlers可以添加或修改任意資源的下載方式。
-
downloadScript
如果是微信或者原生平台,只是對腳本進行require(CommonJS模塊化規范),這里主要是web平台的處理,原生平台的處理在后面統一介紹,web平台是通過創建一個script的HTML標簽,指定標簽的src,添加事件監聽,通過這種HTML的方式下載腳本,使其生效。
function downloadScript (item, callback, isAsync) {
if (sys.platform === sys.WECHAT_GAME) {
require(item.url);
callback(null, item.url);
return;
}
// 創建一個script標簽元素,並指定其src為我們的源碼路徑
var url = item.url,
d = document,
s = document.createElement('script');
s.async = isAsync;
s.src = urlAppendTimestamp(url);
function loadHandler () {
s.parentNode.removeChild(s);
s.removeEventListener('load', loadHandler, false);
s.removeEventListener('error', errorHandler, false);
callback(null, url);
}
function errorHandler() {
s.parentNode.removeChild(s);
s.removeEventListener('load', loadHandler, false);
s.removeEventListener('error', errorHandler, false);
callback(new Error('Load ' + url + ' failed!'), url);
}
// 添加加載完成和錯誤回調
s.addEventListener('load', loadHandler, false);
s.addEventListener('error', errorHandler, false);
d.body.appendChild(s);
}
當cc.game.config['noCache']為true時,urlAppendTimestamp會在url的尾部添加當前的時間戳,這會導致每次加載資源時由於url不同,不會直接使用瀏覽器的緩存,而是重新獲取最新的資源,接下來的各種下載函數中也有urlAppendTimestamp。
-
downloadImage
downloadWebp和downloadImage都是用於下載圖片資源,downloadWebp只是判斷了cc.sys.capabilities.webp是否為true,如果為false表示當前的環境不支持webp,如果支持則直接調用downloadImage進行下載。downloadImage中引入了2個概念,imagePool和crossOrigin,imagePool是一個JS.Pool,它的get方法會返回一個Image對象。如果是非https下的跨域請求,下載失敗時會使用不跨域的方式再請求一次。
由於瀏覽器同源策略,凡是發送請求url的協議、域名、端口三者之間任意一與當前頁面地址不同即為跨域,以下為跨域的詳細描述表格。在web端,使用webgl模式無法直接使用跨域圖片,需要服務器配合設置Access-Control-Allow-Origin(Canvas模式允許使用跨域圖片)。
當我們訪問跨域資源的時候,能否正確加載圖片取決於圖片服務器是否開啟了跨域支持(Access-Control-Allow-Origin: *),比如 http://tools.itharbors.com/res/logo.png 這個資源的服務器開啟了跨域支持,所以可以正確加載,不需要調整客戶端加載的代碼。
那么downloadImage為什么要在設置crossOrigin加載失敗之后,將crossOrigin設置為null再加載一次呢?因為關閉crossOrigin之后雖然可以加載,但無法准確地捕獲錯誤。在測試中,如果服務器沒有開啟跨域支持,通過將crossOrigin設置為null確實可以下載到圖片,然而在webgl初始化該圖片時會報錯。
function downloadImage (item, callback, isCrossOrigin, img) {
if (isCrossOrigin === undefined) {
isCrossOrigin = true;
}
var url = urlAppendTimestamp(item.url);
img = img || misc.imagePool.get();
if (isCrossOrigin && window.location.protocol !== 'file:') {
img.crossOrigin = 'anonymous';
} else {
img.crossOrigin = null;
}
if (img.complete && img.naturalWidth > 0 && img.src === url) {
return img;
} else {
function loadCallback () {
img.removeEventListener('load', loadCallback);
img.removeEventListener('error', errorCallback);
callback(null, img);
}
function errorCallback () {
img.removeEventListener('load', loadCallback);
img.removeEventListener('error', errorCallback);
// Retry without crossOrigin mark if crossOrigin loading fails
// 如果加載失敗,重試的時候img.crossOrigin被置為null
// Do not retry if protocol is https, even if the image is loaded, cross origin image isn't renderable.
// 如果是https就不重試了,因為就算加載了到了圖片也無法渲染
if (window.location.protocol !== 'https:' && img.crossOrigin && img.crossOrigin.toLowerCase() === 'anonymous') {
downloadImage(item, callback, false, img);
} else {
callback(new Error('Load image (' + url + ') failed'));
}
}
// 設置src開始加載圖片
img.addEventListener('load', loadCallback);
img.addEventListener('error', errorCallback);
img.src = url;
}
}
-
downloadFont
downloadFont的本質也是通過添加HTML標簽,通過div、style標簽來實現字體的加載。通過item的name、srcs或name、url、type進行加載。
function _loadFont (name, srcs, type){
// 創建一個類型為text/css的style標簽
var doc = document,
fontStyle = document.createElement('style');
fontStyle.type = 'text/css';
doc.body.appendChild(fontStyle);
// 構建並設置fontStyle的textContent屬性
var fontStr = '';
if (isNaN(name - 0)) {
fontStr += '@font-face { font-family:' + name + '; src:';
}
else {
fontStr += '@font-face { font-family:\'' + name + '\'; src:';
}
if (srcs instanceof Array) {
for (var i = 0, li = srcs.length; i < li; i++) {
var src = srcs[i];
type = Path.extname(src).toLowerCase();
fontStr += 'url(\'' + srcs[i] + '\') format(\'' + FONT_TYPE[type] + '\')';
fontStr += (i === li - 1) ? ';' : ',';
}
} else {
type = type.toLowerCase();
fontStr += 'url(\'' + srcs + '\') format(\'' + FONT_TYPE[type] + '\');';
}
fontStyle.textContent += fontStr + '}';
// 添加一個試用該字體的div
//<div style="font-family: PressStart;">.</div>
var preloadDiv = document.createElement('div');
var _divStyle = preloadDiv.style;
_divStyle.fontFamily = name;
preloadDiv.innerHTML = '.';
_divStyle.position = 'absolute';
_divStyle.left = '-100px';
_divStyle.top = '-100px';
doc.body.appendChild(preloadDiv);
}
function downloadFont (item, callback) {
var url = item.url,
type = item.type,
name = item.name,
srcs = item.srcs;
if (name && srcs) {
if (srcs.indexOf(url) === -1) {
srcs.push(url);
}
_loadFont(name, srcs);
} else {
type = Path.extname(url);
name = Path.basename(url, type);
_loadFont(name, url, type);
}
if (document.fonts) {
document.fonts.load('1em ' + name).then(function () {
callback(null, null);
}, function(err){
callback(err);
});
} else {
return null;
}
}
-
downloadAudio
downloadAudio位於audio-downloader.js中,它會根據item的useDom選項決定使用哪種聲音下載方式:
function downloadAudio (item, callback) {
// 瀏覽器不支持音效
if (formatSupport.length === 0) {
return new Error('Audio Downloader: audio not supported on this browser!');
}
item.content = item.url;
// 如果指定了useDom或者不支持WebAudio,會自動幫我們切換成DomAudio
if (!__audioSupport.WEB_AUDIO || (item.urlParam && item.urlParam['useDom'])) {
loadDomAudio(item, callback);
} else {
loadWebAudio(item, callback);
}
}
loadWebAudio會使用cc.loader.getXMLHttpRequest下載資源,在onLoad回調中使用sys.__audioSupport.context["decodeAudioData"]()進行解碼。
而loadDomAudio則是通過aduio這個HTML標簽進行加載和監聽。
-
downloadText
文本的下載分2中方式,如果是原生平台,會使用jsb.fileUtils.getStringFromFile從磁盤中直接獲取,如果是其他普通,會使用cc.loader.getXMLHttpRequest下載。
在Creator2.x之后,這段判斷被移到了engine目錄的jsb目錄下,Creator直接在構建時使用合適的代碼,而不是在函數執行中去判斷當前是哪種平台。
if (CC_JSB) {
module.exports = function (item, callback) {
var url = item.url;
var result = jsb.fileUtils.getStringFromFile(url);
if (typeof result === 'string' && result) {
return result;
} else {
return new Error('Download text failed: ' + url);
}
};
} else {
var urlAppendTimestamp = require('./utils').urlAppendTimestamp;
module.exports = function (item, callback) {
var url = item.url;
url = urlAppendTimestamp(url);
var xhr = cc.loader.getXMLHttpRequest(),
errInfo = 'Load ' + url + ' failed!',
navigator = window.navigator;
xhr.open('GET', url, true);
if (/msie/i.test(navigator.userAgent) && !/opera/i.test(navigator.userAgent)) {
// IE-specific logic here
xhr.setRequestHeader('Accept-Charset', 'utf-8');
xhr.onreadystatechange = function () {
if(xhr.readyState === 4) {
if (xhr.status === 200 || xhr.status === 0) {
callback(null, xhr.responseText);
} else {
callback({status:xhr.status, errorMessage:errInfo});
}
}
};
} else {
if (xhr.overrideMimeType) xhr.overrideMimeType('text\/plain; charset=utf-8');
xhr.onload = function () {
if(xhr.readyState === 4) {
if (xhr.status === 200 || xhr.status === 0) {
callback(null, xhr.responseText);
} else {
callback({status:xhr.status, errorMessage:errInfo});
}
}
};
xhr.onerror = function(){
callback({status:xhr.status, errorMessage:errInfo});
};
}
xhr.send(null);
};
}
-
downloadUuid
Creator中的資源都會有它的uuid,都會調用該方法進行下載。而uuid資源可能以2種形式存在,第一種是單獨的json文件,比如一個prefab或spriteFrame資源,都有自己的json文件。而另一種則是打包資源,所謂的Pack就是將多個json文件合並為一個json文件,把各個json文件中的json對象組合到一個json數組中,從而達到減少IO的作用。downloadUuid方法會使用PackDownloader進行下載,如果下載失敗則使用json的下載方式,也就是downloadText。
function downloadUuid (item, callback) {
var result = PackDownloader.load(item, callback);
if (result === undefined) {
return this.extMap['json'](item, callback);
} else if (!!result) {
return result;
}
}
PackDownloader的load方法實現如下,根據uuidToPack中的uuid取出packUuid,如果packUuid不存在,則說明這個uuid沒有被打包,直接使用json的方式加載即可。接下來再根據globalUnpackers[packUuid]取出unpacker,調用unpacker.retrieve(uuid)解析出json並返回。
load: function (item, callback) {
var uuid = item.uuid;
var packUuid = uuidToPack[uuid];
if (!packUuid) {
// 返回undefined以讓調用者知道它未被識別。
// 不返回false,因為改變返回值類型可能會導致jit失敗,盡管返回undefined可能有相同的問題
return;
}
// 一個uuid有可能被重復打包到多個json文件中,《從編輯器到運行時》一章會介紹這種情況如何產生
if (Array.isArray(packUuid)) {
// 這里會遍歷多個Pack,從中選擇狀態最接近加載完成的Pack(誰先加載完用誰)。
packUuid = this._selectLoadedPack(packUuid);
}
// 取出unpacker,如果加載完成了,從unpacker中取出對應uuid的json對象返回。
var unpacker = globalUnpackers[packUuid];
if (unpacker && unpacker.state === PackState.Loaded) {
var json = unpacker.retrieve(uuid);
if (json) {
return json;
} else {
return error(uuid, packUuid);
}
} else { // 其他情況為未加載完成
// unpacker為空則創建一個
if (!unpacker) {
if (!CC_TEST) {
console.log('Create unpacker %s for %s', packUuid, uuid);
}
unpacker = globalUnpackers[packUuid] = new JsonUnpacker();
unpacker.state = PackState.Downloading;
}
// 如果正在加載中或未加載,會走_loadNewPack也就是cc.loader.load,但cc.loader中規避了重復加載。
this._loadNewPack(uuid, packUuid, callback);
}
// 返回null,讓調用者知道它正在異步加載
return null;
}
接下來我們進一步了解一下PackDownloader這個類做了什么?Pack又是什么?globalUnpackers和packIndices又是什么?
- PackDownloader
- initPacks接受packs變量進行初始化,packIndices變量引用了packs,遍歷packs來初始化uuidToPack,建立了uuid到pack的映射。
- _loadNewPack根據packUuid調用cc.AssetLibrary.getLibUrlNoExt(packUuid) + '.json';獲取packUrl,並調用cc.loader.load加載json文件,加載完成后調用 _doLoadNewPack以及callback。
- _doLoadNewPack根據packUuid從globalUnpackers中取出unpacker,並返回unpacker.retrieve(uuid)
PackDownloader做的事情主要是對Json文件的解析、管理和獲取。在某些情況下多個json文件會被打包成一個json文件,如AnimationClip文件,在編輯器制作的時候每個動畫都是一個Clip文件(json文件),而在打包之后這些Clip會被合並成一個新的json文件(這樣做的目的是節省IO),這就是Pack。
當我們發布項目時Creator自動幫我們進行合並,多個json對象組成一個數組對象,packIndices記錄了每個packUuid對應的一組uuid(也就是一個pack文件中合並了哪些文件),每個文件的uuid對應這個json數組對象的下標。packIndices[packUuid]的下標1是該packUuid對應合並后的json數組下標1這個json對象的uuid。
每個Clip都有一個uuid,通過uuidToPack的索引獲取這個Clip對應的packUuid,也就是合並Json的uuid,這個uuid會對應一個JsonUnpacker,JsonUnpacker會將合並后的json進行解析並緩存,同時保持一個映射,在這里就是每個Clip的uuid對應的json對象。
// 初始化Packs,這里傳入的packs是一個二維數組,首先它是一個uuids的數組,一組uuid被視為一個pack,packs就是一組pack
// 每個uuids都是一個數組,記錄了這個pack中合並的所有uuid。
initPacks: function (packs) {
packIndices = packs;
for (var packUuid in packs) {
var uuids = packs[packUuid];
for (var i = 0; i < uuids.length; i++) {
var uuid = uuids[i];
// the smallest pack must be at the beginning of the array to download more first
// 最小的pack必須放在數組的前面,以便下載更多的包。
var pushFront = uuids.length === 1;
// map - uuidToPack, key - uuid, value - packUuid (如果已存在該key,value會添加到數組中)
pushToMap(uuidToPack, uuid, packUuid, pushFront);
}
}
},
// 加載一個新的Pack時會調用該方法,根據packUuid去獲取url,並立即下載(ignoreMaxConcurrency為true)
_loadNewPack: function (uuid, packUuid, callback) {
var self = this;
var packUrl = cc.AssetLibrary.getLibUrlNoExt(packUuid) + '.json';
cc.loader.load({ url: packUrl, ignoreMaxConcurrency: true }, function (err, packJson) {
if (err) {
cc.errorID(4916, uuid);
return callback(err);
}
var res = self._doLoadNewPack(uuid, packUuid, packJson);
if (res) {
callback(null, res);
} else {
callback(error(uuid, packUuid));
}
});
},
// 當一個Pack加載完之后,會回調該方法
_doLoadNewPack: function (uuid, packUuid, packJson) {
var unpacker = globalUnpackers[packUuid];
// double check cache after load
// 只要unpacker的狀態不是PackState.Loaded,進行解析並切換狀態
if (unpacker.state !== PackState.Loaded) {
unpacker.read(packIndices[packUuid], packJson);
unpacker.state = PackState.Loaded;
}
return unpacker.retrieve(uuid);
},
// 遍歷多個packUuid,只要找到第一個狀態為PackState.Loaded的unpacker
// 找不到則找一個最接近PackState.Loaded的unpacker
_selectLoadedPack: function (packUuids) {
var existsPackState = PackState.Invalid;
var existsPackUuid = '';
for (var i = 0; i < packUuids.length; i++) {
var packUuid = packUuids[i];
var unpacker = globalUnpackers[packUuid];
if (unpacker) {
var state = unpacker.state;
if (state === PackState.Loaded) {
return packUuid;
} else if (state > existsPackState) {
existsPackState = state;
existsPackUuid = packUuid;
}
}
}
return existsPackState !== PackState.Invalid ? existsPackUuid : packUuids[0];
},
- globalUnpackers
- globalUnpackers根據packUuid為索引,保存着JsonUnpacker對象。
- JsonUnpacker記錄了jsons和state,關鍵的read方法和retrieve方法的職責是解析json數據以及根據key從jsons中查詢信息。
JsonUnpacker.prototype.read = function (indices, data) {
var jsons = typeof data === 'string' ? JSON.parse(data) : data;
if (jsons.length !== indices.length) {
cc.errorID(4915);
}
for (var i = 0; i < indices.length; i++) {
var key = indices[i];
var json = jsons[i];
this.jsons[key] = json;
}
};
JsonUnpacker.prototype.retrieve = function (key) {
return this.jsons[key] || null;
};
這里傳入的data是一個數組json對象,indices是一個uuid數組,read的職責就是將indices[i]作為uuid,對應的jsons[i]作為json對象,記錄到this.jsons這個容器中,那么后面的retrieve就可以用uuid來獲取對應的json對象了。
- 關於packIndices
- 在AssetLibrary的init方法中,調用了PackDownloader.initPacks(options.packedAssets);
- 項目發布時會生成一個巨大的settings.js文件,該文件內容如下圖所示,其中的packedAssets就是我們的packIndices。
- 例如圖中的key 01204b0d7可以在發布后的res/import/01目錄中找到01204b0d7.json,這個文件是一個有5個對象的json數組,他們的uuid分別為0、205、207、1、473。

01204b0d7.json文件對應的內容在格式化查看工具中打開如下所示,正好是一個擁有5個對象的json數組,第一個對象是Array、后面是4個Object對象。而上圖對應的packedAssets下的01204b0d7對象數組為這個json數組的uuid,按下標一一對應。

-
原生Downloader處理
在原生平台下會執行jsb-loader.js下的內容,對於字體、音效、腳本和圖片使用新的下載方法。
// 字體使用了empty
function empty (item, callback) {
return null;
}
// 下載腳本直接使用require即可
function downloadScript (item, callback) {
require(item.url);
return null;
}
// 聲音不需要下載,聲音的加載流程包含了下載
function downloadAudio (item, callback) {
return item.url;
}
// 圖片分3種情況,textureCache中緩存直接使用、遠程圖片使用jsb.loadRemoteImg、本地圖片使用textureCache的addImageAsync方法加載。
function loadImage (item, callback) {
var url = item.url;
var cachedTex = cc.textureCache.getTextureForKey(url);
if (cachedTex) {
return cachedTex;
} else if (url.match(jsb.urlRegExp)) {
jsb.loadRemoteImg(url, function(succeed, tex) {
if (succeed) {
tex.url = url;
callback && callback(null, tex);
} else {
callback && callback(new Error('Load image failed: ' + url));
}
});
} else {
var addImageCallback = function (tex) {
if (tex instanceof cc.Texture2D) {
tex.url = url;
callback && callback(null, tex);
}
else {
callback && callback(new Error('Load image failed: ' + url));
}
};
cc.textureCache._addImageAsync(url, addImageCallback);
}
}
在項目發布時,會根據發布平台生成最終的執行代碼。構建原生平台時Creator1.x會指定engine/jsb目錄下的腳本,而Creator2.x指定的是engine/bin目錄下的jsb腳本。

