EaselJS 原始碼分析系列--第二篇

2023-07-16 06:00:12

第一篇 中我們大致分析了從: 建立舞臺 -> 新增顯示物件-> 更新顯示物件 的原始碼實現

這一篇將主要分析幾個常用顯示物件自各 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

當呼叫 setStrokeStyle、beginStroke、beginFill 等都會執行 _updateInstructions 命令

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

發現沒有,與我們在呼叫順序不一樣

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 當然是另啟一個新的路徑繪製了

小結

這一篇分析的是,最常用的三個顯示物件

下一篇分析另三個稍顯高階的顯示物件


部落格園: http://cnblogs.com/willian/
github: https://github.com/willian12345/