EaselJS 源碼分析系列--第二篇

第一篇 中我們大致分析了從: 創建舞臺 -> 添加顯示對象-> 更新顯示對象 的源碼實現

這一篇將主要分析幾個常用顯示對象自各 draw 方法的實現

讓我們看向例子 examples/Text_simple.html

這個例子中使用了三個顯示對象類 Bitmap 、Text 、 Shape

Bitmap draw

以下例子中添加了一個 image

var image = new createjs.Bitmap("imagePath.png");
stage.addChild(image);

當調用 stage.update 後,會調用顯示對象的 draw 方法,如果是 Container 類,則繼續遞歸調用其 draw 方法

這樣所有 stage 舞臺上的顯示對象的 draw 方法都會被調用到,注意 canvas 的上下文對象 ctx 參數都會被傳入

分兩步:

  1. 如果 DisplayObject 類內有緩存,則繪製緩存

  2. 如果沒有緩存則循環顯示列表調用每個 child 的 draw 方法 child 都爲 DisplayObject 實例,還判斷了 DisplayObject 實例的 isVisible 如果不可見則不繪製

// Container 類 源碼 160 - 176 行
p.draw = function(ctx, ignoreCache) {
	if (this.DisplayObject_draw(ctx, ignoreCache)) { return true; }
	// 用 slice 的原因是防止繪製過程中 children 發生變更導致出錯
	var list = this.children.slice();
	for (var i=0,l=list.length; i<l; i++) {
		var child = list[i];
		if (!child.isVisible()) { continue; }
		
		// draw the child:
		ctx.save();
		child.updateContext(ctx);
		child.draw(ctx);
		ctx.restore();
	}
	return true;
};

child 就爲一個 Bitmap 對象

直接看 Bitmap 類實現的 draw 方法如下:

// Bitmap 類 源碼 142-159
p.draw = function(ctx, ignoreCache) {
	if (this.DisplayObject_draw(ctx, ignoreCache)) { return true; }
	var img = this.image, rect = this.sourceRect;
	if (img.getImage) { img = img.getImage(); }
	if (!img) { return true; }
	if (rect) {
		// some browsers choke on out of bound values, so we'll fix them:
		var x1 = rect.x, y1 = rect.y, x2 = x1 + rect.width, y2 = y1 + rect.height, x = 0, y = 0, w = img.width, h = img.height;
		if (x1 < 0) { x -= x1; x1 = 0; }
		if (x2 > w) { x2 = w; }
		if (y1 < 0) { y -= y1; y1 = 0; }
		if (y2 > h) { y2 = h; }
		ctx.drawImage(img, x1, y1, x2-x1, y2-y1, x, y, x2-x1, y2-y1);
	} else {
		ctx.drawImage(img, 0, 0);
	}
	return true;
};

就三步:

  1. 有緩存則繪製緩存

  2. 如果有 rect 限制,有目標尺寸 rect 限制,則繪製成 rect 尺寸,調用 canvas 的 drawImage 原生方法並傳入目標尺寸

  3. 如果沒有 rect 限制,則直接調用 canvas 的 drawImage 原生方法

先不管 ctx.drawImage(img, x1, y1, x2-x1, y2-y1, x, y, x2-x1, y2-y1); 這一句,

具體語法可以查詢 https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/drawImage

注意: ctx.drawImage(img, 0, 0); 後兩個參數值是圖象的 x, y 座標,

都傳了 0 好傢伙直接 "hardcode" 了,繪製時不用考慮圖像的位置嗎?

都畫在了 0, 0 位置畫在左上角?

這不科學,如果用戶指定了圖像位置比如 x = 100, y = 80 那怎麼辦?

如果我來實現,直覺上就會想要把此處改爲 ctx.drawImage(img, 0 + x, 0 + y);

但 EaselJS 並沒有,但卻又能正常工作?,先擱置,繼續往下看就會明白

Text draw

繪製文本

創建一個文本 txt

txt = new createjs.Text("text on the canvas... 0!", "36px Arial", "#FFF");

直接看向 Text 的 draw 實例方法:

// Text 類 源碼 208 - 217 行
p.draw = function(ctx, ignoreCache) {
	if (this.DisplayObject_draw(ctx, ignoreCache)) { return true; }

	var col = this.color || "#000";
	if (this.outline) { ctx.strokeStyle = col; ctx.lineWidth = this.outline*1; }
	else { ctx.fillStyle = col; }
	
	this._drawText(this._prepContext(ctx));
	return true;
};

依然先判斷緩存

文本默認爲黑色

如果有 outline 則 lineWidth 被設置限制寬度,用顯示文本週邊的框

調用 this._drawText(this._prepContext(ctx));

_prepContext 存着的上下文中預設的默認樣式

Text 的 _drawText 方法是真正執行繪製文本的的邏輯(支持換行)

// Text 類 源碼 339 - 390 行
p._drawText = function(ctx, o, lines) {
	var paint = !!ctx;
	if (!paint) {
		ctx = Text._workingContext;
		ctx.save();
		this._prepContext(ctx);
	}
	var lineHeight = this.lineHeight||this.getMeasuredLineHeight();

	var maxW = 0, count = 0;
	var hardLines = String(this.text).split(/(?:\r\n|\r|\n)/);
	for (var i=0, l=hardLines.length; i<l; i++) {
		var str = hardLines[i];
		var w = null;
		
		if (this.lineWidth != null && (w = ctx.measureText(str).width) > this.lineWidth) {
			// text wrapping:
			var words = str.split(/(\s)/);
			str = words[0];
			w = ctx.measureText(str).width;
			
			for (var j=1, jl=words.length; j<jl; j+=2) {
				// Line needs to wrap:
				var wordW = ctx.measureText(words[j] + words[j+1]).width;
				if (w + wordW > this.lineWidth) {
					if (paint) { this._drawTextLine(ctx, str, count*lineHeight); }
					if (lines) { lines.push(str); }
					if (w > maxW) { maxW = w; }
					str = words[j+1];
					w = ctx.measureText(str).width;
					count++;
				} else {
					str += words[j] + words[j+1];
					w += wordW;
				}
			}
		}
		
		if (paint) { this._drawTextLine(ctx, str, count*lineHeight); }
		if (lines) { lines.push(str); }
		if (o && w == null) { w = ctx.measureText(str).width; }
		if (w > maxW) { maxW = w; }
		count++;
	}

	if (o) {
		o.width = maxW;
		o.height = count*lineHeight;
	}
	if (!paint) { ctx.restore(); }
	return o;
	};

步驟:

  1. paint 爲 false 即沒有傳 ctx 時 僅用於測量文本的尺寸,並不實際繪製到舞臺上

  2. 通過 String(this.text).split(/(?:\r\n|\r|\n)/); 這一句將通過回車與換行符得到多行文本

  3. 循環分解出的文本數組,ctx.measureText 測量文本寬度後判斷是否大於 lineWidth 如果加上後面一斷文本大於,則需新啓一行

  4. 調用 _drawTextLine() 方法繪製文本

調用 canvas 真實 api ctx.fillText 繪製文本

// Text 類 源碼 399 - 403 行
p._drawTextLine = function(ctx, text, y) {
	// Chrome 17 will fail to draw the text if the last param is included but null, so we feed it a large value instead:
	if (this.outline) { ctx.strokeText(text, 0, y, this.maxWidth||0xFFFF); }
	else { ctx.fillText(text, 0, y, this.maxWidth||0xFFFF); }
};

發現沒有 ctx.fillText 處傳的 x 還是 hardcode 硬編碼 0 而座標 y 還是相對座標,都繪製到 canvas 上了還不是絕對座標能行嗎?

不科學啊

是時候探究了!

updateContext

draw 方法內使用的座標都是硬編碼或相對座標,但又可以如期繪製正確的絕對座標位置

是時候看一下之前遺留的 updateContext 方法了

還記得第一篇中 stage.update 內 draw 方法前的一句 this.updateContext(ctx);

實際上最終調用的是 DisplayObject 類的 updateContext 實例方法如下:

// DisplayObject.js 源碼 787-810 行
p.updateContext = function(ctx) {
	var o=this, mask=o.mask, mtx= o._props.matrix;
	
	if (mask && mask.graphics && !mask.graphics.isEmpty()) {
		mask.getMatrix(mtx);
		ctx.transform(mtx.a,  mtx.b, mtx.c, mtx.d, mtx.tx, mtx.ty);
		
		mask.graphics.drawAsPath(ctx);
		ctx.clip();
		
		mtx.invert();
		ctx.transform(mtx.a,  mtx.b, mtx.c, mtx.d, mtx.tx, mtx.ty);
	}
	
	this.getMatrix(mtx);
	var tx = mtx.tx, ty = mtx.ty;
	if (DisplayObject._snapToPixelEnabled && o.snapToPixel) {
		tx = tx + (tx < 0 ? -0.5 : 0.5) | 0;
		ty = ty + (ty < 0 ? -0.5 : 0.5) | 0;
	}
	ctx.transform(mtx.a,  mtx.b, mtx.c, mtx.d, tx, ty);
	ctx.globalAlpha *= o.alpha;
	if (o.compositeOperation) { ctx.globalCompositeOperation = o.compositeOperation; }
	if (o.shadow) { this._applyShadow(ctx, o.shadow); }
};

o._propssrc/easeljs/geom/DisplayProps.js DisplayProps 類的實例

DisplayProps 主要負責了顯示對象的以下屬性操作

visible、alpha、shadow、compositeOperation、matrix

mtx= o._props.matrix 在 DisplayObject 實例屬性 _props 對象中得到 matrix

updateContext 就是在上下文中應用不同的 matrix 實現上下文中的“變幻”

首先就是對 mask 遮罩的處理,遮罩是通過繪製 Graphics 後對上下文進行 ctx.clip 實現的

如果存在 mask 當前顯示對象有遮罩,通過 mask.getMatrix 把遮罩的 matrix

ctx.transform 將上下文變化至遮罩所在的“狀態”

繪製遮罩 mask.graphics.drawAsPath(ctx)

還原矩陣 mtx.invert(); 回到當前顯示對象的“狀態”

至此 mask 部分處理完畢

回到當前顯示對象的 getMatrix 獲取矩陣後應用矩陣變化

getMatrix 做了兩件事

  1. 如果顯示對象有明確指定的 matrix 則應用 matrix
  2. 如果沒有明確指定,則將顯示對象的,x,y,scaleX, scaleY, rotation, skewW, skewY, regX, regY 合到 matrix上
// DisplayObject.js 源碼 1020-1024 行
p.getMatrix = function(matrix) {
	var o = this, mtx = matrix || new createjs.Matrix2D();
	return o.transformMatrix ?  mtx.copy(o.transformMatrix) :
		(mtx.identity() && mtx.appendTransform(o.x, o.y, o.scaleX, o.scaleY, o.rotation, o.skewX, o.skewY, o.regX, o.regY));
};

注意這一句 mtx.appendTransform(o.x, o.y, o.scaleX, o.scaleY, o.rotation, o.skewX, o.skewY, o.regX, o.regY))

就是將當前顯示對象的變幻屬性合到矩陣中

得到新的 matrix 後調用 ctx.transform(mtx.a, mtx.b, mtx.c, mtx.d, tx, ty); 實現一系列變化

至於 src/easeljs/geom/Matrix2D.js 矩陣類

平時在 css 中的使用的變化 scale, rotate, translateX, translateY 最後都是矩陣變幻實現的

矩陣變幻的好處一次可以實現多種變化,只是不那麼直觀

至於矩陣爲什麼可以實現變幻,我這小學數學水平可講不清楚,推薦 3blue1brown 的視頻看完肯定會醍醐灌頂

我的總結是矩陣實現的是對座標軸的線性變幻,直接將座標軸原點變幻到繪製點!!

所以在具體 draw 繪製時 x,y 座標可以硬編碼或使用相對座標,因爲 draw 之前已經使用矩陣把整體座標軸變幻到位了

繪製完後又會重置回來開始新的對象的變幻

Shape draw

Shape 類代碼非常少,實現繪製的是 Graphics 類

Shape 只是作爲 Graphics 實例的載體

使用 shape.graphics 屬性即可訪問

Shape.js 源碼 106-110 行
p.draw = function(ctx, ignoreCache) {
	if (this.DisplayObject_draw(ctx, ignoreCache)) { return true; }
	this.graphics.draw(ctx, this);
	return true;
};

Graphics

矢量圖形類 Graphics 在 src/easeljs/display/Graphics.js

通常 Graphics 用於繪製矢量圖形

可單獨使用,也可以在 Shape 實例內調用

var g = new createjs.Graphics();
g.setStrokeStyle(1);
g.beginStroke("#000000");
g.beginFill("red");
g.drawCircle(0,0,30);

要實現 Grapihcs 繪製,就得組合一系列繪圖命令

一系列繪製命令被存儲在了 _instructions 數組屬性內

這些命令被稱爲 Command Objects 命令對象

源碼 1653 行 - 2459 行都是命令對象

命令對象分別都暴露了一個 exec 方法

比如 MoveTo 命令

// Graphics.js 源碼 1700 - 1702 行
(G.MoveTo = function(x, y) {
	this.x = x; this.y = y;
}).prototype.exec = function(ctx) { ctx.moveTo(this.x, this.y); };

比如圓形繪製命令

// Graphics.js 源碼 2292 - 2295 行
(G.Circle = function(x, y, radius) {
	this.x = x; this.y = y;
	this.radius = radius;
}).prototype.exec = function(ctx) { ctx.arc(this.x, this.y, this.radius, 0, Math.PI*2); };

下面是圓角矩形的也是在 Graphics 靜態方法

(G.RoundRect = function(x, y, w, h, radiusTL, radiusTR, radiusBR, radiusBL) {
		this.x = x; this.y = y;
		this.w = w; this.h = h;
		this.radiusTL = radiusTL; this.radiusTR = radiusTR;
		this.radiusBR = radiusBR; this.radiusBL = radiusBL;
	}).prototype.exec = function(ctx) {
		var max = (this.w<this.h?this.w:this.h)/2;
		var mTL=0, mTR=0, mBR=0, mBL=0;
		var x = this.x, y = this.y, w = this.w, h = this.h;
		var rTL = this.radiusTL, rTR = this.radiusTR, rBR = this.radiusBR, rBL = this.radiusBL;

		if (rTL < 0) { rTL *= (mTL=-1); }
		if (rTL > max) { rTL = max; }
		if (rTR < 0) { rTR *= (mTR=-1); }
		if (rTR > max) { rTR = max; }
		if (rBR < 0) { rBR *= (mBR=-1); }
		if (rBR > max) { rBR = max; }
		if (rBL < 0) { rBL *= (mBL=-1); }
		if (rBL > max) { rBL = max; }

		ctx.moveTo(x+w-rTR, y);
		ctx.arcTo(x+w+rTR*mTR, y-rTR*mTR, x+w, y+rTR, rTR);
		ctx.lineTo(x+w, y+h-rBR);
		ctx.arcTo(x+w+rBR*mBR, y+h+rBR*mBR, x+w-rBR, y+h, rBR);
		ctx.lineTo(x+rBL, y+h);
		ctx.arcTo(x-rBL*mBL, y+h+rBL*mBL, x, y+h-rBL, rBL);
		ctx.lineTo(x, y+rTL);
		ctx.arcTo(x-rTL*mTL, y-rTL*mTL, x+rTL, y, rTL);
		ctx.closePath();
	};

exec 方法纔是真正調用 canvas context 繪製的地方

G 就是 Graphics 的簡寫,在 250 行 var G = Graphics;

這些單獨的繪圖命令其實就是 G 的一些靜態方法,只是這些靜態方法又擁有各自不同的 exec 實例方法實現具體的繪圖

而 Graphics 的實例方法又會將繪圖命令 append 一個 “靜態方法的實例” 存儲數組內

比如 lineTo , 注意是 new G.MoveTo(x,y) 一個命令

// Graphics.js 源碼 469 - 471 行
p.moveTo = function(x, y) {
	return this.append(new G.MoveTo(x,y), true);
};

下面是 append 源碼,命令都存在 _activeInstructions 數組內

// Graphics.js 源碼 1024 - 1029 行
p.append = function(command, clean) {
	this._activeInstructions.push(command);
	this.command = command;
	if (!clean) { this._dirty = true; }
	return this;
};

再看通用的 draw 方法

// Graphics.js 源碼 434-440 行
p.draw = function(ctx, data) {
	this._updateInstructions();
	var instr = this._instructions;
	for (var i=this._storeIndex, l=instr.length; i<l; i++) {
		instr[i].exec(ctx, data);
	}
};

draw 內的主要邏輯就是用循環調用 _instructions 存儲的“命令對象”執行命令對象的 exec 方法

_instructions 的命令是通過 _updateInstructions 方法從 _activeInstructions 數組內複製的

// Graphics.js 源碼 1593-1627 行
p._updateInstructions = function(commit) {
	var instr = this._instructions, active = this._activeInstructions, commitIndex = this._commitIndex;
	debugger
	if (this._dirty && active.length) {
		instr.length = commitIndex; // remove old, uncommitted commands
		instr.push(Graphics.beginCmd);

		var l = active.length, ll = instr.length;
		instr.length = ll+l;
		for (var i=0; i<l; i++) { instr[i+ll] = active[i]; }

		if (this._fill) { instr.push(this._fill); }
		if (this._stroke) {
			// doesn't need to be re-applied if it hasn't changed.
			if (this._strokeDash !== this._oldStrokeDash) {
				instr.push(this._strokeDash);
			}
			if (this._strokeStyle !== this._oldStrokeStyle) {
				instr.push(this._strokeStyle);
			}
			if (commit) {
				this._oldStrokeStyle = this._strokeStyle;
				this._oldStrokeDash = this._strokeDash;
			}
			instr.push(this._stroke);
		}

		this._dirty = false;
	}
	// 如果 commit 了,把 _activeInstructions 當前命令集合清空,且遊標指向 this._instructions 的最後位置
	if (commit) {
		active.length = 0;
		this._commitIndex = instr.length;
	}
};

還有一些方法(如:beginStroke beginFill) 內調用了 _updateInstructions(true) 注意傳的是 true

比如:

p.beginStroke = function(color) {
	return this._setStroke(color ? new G.Stroke(color) : null);
};
p._setStroke = function(stroke) {
	this._updateInstructions(true);
	if (this.command = this._stroke = stroke) {
		stroke.ignoreScale = this._strokeIgnoreScale;
	}
	return this;
};

注意: 這裏收集的命令暫時不會被放到 this._instructions 數組內

直到有 append 方法執行過 dirty 爲 true 了 纔會把 stroke 命令添加到 this._instructions 數組

因爲沒有 append 任何實質的內容(圓,線,矩形等),則不需要執行 stroke ,beginFill 等命令,因爲無意義

p._updateInstructions 到底幹了啥?

主要是在 draw 之前不斷收集命令, 在多處都有調用 _updateInstructions

這些操作 commit 均爲 true 表明後面的繪製是新的開始,將之前的一系列繪製命令歸爲一個路徑繪製,下一個得新啓一個路徑繪製

使用 this._commitIndex 遊標重新指示命令數組內的位置

debugger 調試一下看看

在 easeljs-NEXT.js 的 5226 行加上 debugger

瀏覽器中打開 examples/Graphics_simple.html 文件,並打開瀏覽器調試工具

Graphics_simple.html 文件的 javascript 代碼內有一個 drawSmiley 方法

function drawSmiley() {
	var s = new createjs.Shape();
	var g = s.graphics;

	//Head
	g.setStrokeStyle(10, 'round', 'round');
	g.beginStroke("#000");
	g.beginFill("#FC0");
	g.drawCircle(0, 0, 100); //55,53

	//Mouth
	g.beginFill(); // no fill
	g.arc(0, 0, 60, 0, Math.PI);

	//Right eye
	g.beginStroke(); // no stroke
	g.beginFill("#000");
	g.drawCircle(-30, -30, 15);

	//Left eye
	g.drawCircle(30, -30, 15);

	return s;
}

很明顯通過 debugger 先調用了 setStrokeStyle

image

當調用 setStrokeStyle、beginStroke、beginFill 等都會執行 _updateInstructions 命令

當執行到 g.drawCircle(0, 0, 100); 此時命令纔會被一起收集順序如下

image

發現沒有,與我們在調用順序不一樣

g.setStrokeStyle(10, 'round', 'round');
g.beginStroke("#000");
g.beginFill("#FC0");
g.drawCircle(0, 0, 100); //55,53

BeginPath -> Circle -> Fill -> StrokeStyle

這是 Canvas 真正正常執行的順序

BeginPath 也在每次 dirty (append 方法導至 dirty 爲 true) 時添加,beginPath 當然是另啓一個新的路徑繪製了

image

小結

這一篇分析的是,最常用的三個顯示對象

下一篇分析另三個稍顯高級的顯示對象


博客園: http://cnblogs.com/willian/
github: https://github.com/willian12345/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章