2020年8月10日 星期一

SpriteOverEvent 之靈異現象

重現方式

直接上 SSCCE:

public class SpriteTestEP extends DrawComponent implements EntryPoint {
	private RectangleSprite red = new RectangleSprite(200, 201, 100, 50);
	private RectangleSprite none = new RectangleSprite(200, 201, 300, 50);

	public SpriteTestEP() {
		red.setFill(RGB.RED);
		none.setFill(Color.NONE);

		addSprite(red);
		addSprite(none);

		addSpriteOverHandler(new SpriteOverHandler() {
			@Override
			public void onSpriteOver(SpriteOverEvent event) {
				log("Over : " + who(event.getSprite()));
			}
		});
		addSpriteOutHandler(new SpriteOutHandler() {
			@Override
			public void onSpriteLeave(SpriteOutEvent event) {
				log("Out : " + who(event.getSprite()));
			}
		});
	}

	private String who(Sprite sprite) {
		return (sprite == red ? "red" : "none");
	}

	public static native void log(Object object) /*-{
		console.log(
			@java.lang.String::valueOf(Ljava/lang/Object;)(object)
		);
	}-*/;

	@Override
	public void onModuleLoad() {
		//無關緊要,純粹 follow GXT 習慣  XD
		Viewport vp = new Viewport();
		vp.add(this);
		RootPanel.get().add(vp);
	}
}

操作步驟:

  1. 游標進入紅色區塊
  2. 慢慢水平移動滑鼠,直到離開紅色區塊

理論上 console 的輸出應該依序是:

  • (N) Over : red
  • Out : red
  • (N) Over : none

(這裡可以先爆雷:只要 none.setFill() 給一個不是 Color.none 的顏色,就會看到這個正常的輸出結果)

但實際上的輸出依序是:

  • (N) Over : red
  • Over : none
  • Out : none
  • (N) Over : none

對,完全沒有出現 Out : red… (所以 GF-Draw 就爆炸了),而且為什麼會有 Out : none?滑鼠根本沒離開過 none 的範圍阿?

要招喚出這個靈異現象,需要滿足下列條件,缺一不可:

  1. red 必須優先於 noneaddSprite()
  2. 兩個 sprite 必須緊密相連。
    • 在上頭的 case 當中,nonered 的右邊,所以 none 的 x 座標(300)必須等於 red 的 x 座標(100)加上 red 的寬度(200)
  3. none 的 fill 必須是 Color.none
    • 其實還有 stroke 的哏,不過有點複雜,這裡略過不談。

原因分析

那麼,source code 怎麼說呢?負責炸出 SpriteOverEvent 的 method 是 onMouseMove(),裡頭長這樣:

  public void onMouseMove(Event event) {
    if (handlerManager != null && handlerManager.getHandlerCount(SpriteOverEvent.getType()) > 0) {
      PrecisePoint point = getEventXY(event);
      SpriteList<Sprite> sprites = surface.sprites;
      for (int i = sprites.size() - 1; i >= 0; i--) {
        Sprite sprite = sprites.get(i);
        if (!sprite.isHidden() && sprite.getBBox().contains(point)) {
          ensureHandler().fireEvent(new SpriteOverEvent(sprite, event));
          lastSprite = sprite;
          return;
        }
      }
    }
    if (lastSprite != null) {
      ensureHandler().fireEvent(new SpriteOutEvent(lastSprite, event));
      lastSprite = null;
    }
  }

從第一段 if block 當中我們可以看到:

  • bbox 是否包含 event 發生點來判斷是哪個 sprite 要炸 event,而且找到、炸完就 return
  • surface.sprites 的尾巴往前找,也就是說:如果有 n 個 sprite 的 bbox 都符合,會判定給最後加到 DrawComponent 上的 sprite
    • 以程式行為來看(aka 沒有追 source code… [逃]),越晚加到 DrawComponent 上的 sprite 會在越上面,這樣的寫法或許合理… 嗎?

所以… 是的,source code 可以解釋頭兩個要件:當兩個 sprite 緊密相連時,就會發生炸 event 時的座標同時落在兩個 sprite 的 bbox 內。但因為 none 最後加入,所以就認為是 none 炸的(然後這時候 lastSprite 會是 none)。

但是… 但是… 但是…

這完全無法解釋為什麼為什麼 none 有設 fill 顏色就會沒事阿阿阿阿…

(至少 bbox 的生成過程找不到需要參考 fill 的部份)

另一個問題點是… 光看這段也無法解釋正常狀況下的 SpriteOutEvent 是誰炸的。能肯定的是,在上頭的情境當中,絕對不會是 ouMouseMove() 炸的,因為一定能在 surface.sprites 當中找到值,程式根本不會去跑第二個 if block。

另一個會炸 SpriteOutEvent 的地方是 onMouseOut(),裡頭長這樣:

  public void onMouseOut(Event event) {
    if (lastSprite != null) {
      ensureHandler().fireEvent(new SpriteOutEvent(lastSprite, event));
      lastSprite = null;
    }
  }

這樣有點眉目了,在滑鼠跨過兩個 sprite 的邊界時,DrawComponent 一定有收到 Event.ONMOUSEOUT。那就透過 override 以及一些手段,來觀察:

  • x:event 發生時在 DrawComponent 上的 x 座標
  • type:browser event 是 ONMOUSEMOVE 或是 ONMOUSEOUT
  • element:產生 event 的 DOM element(製表時是註記為程式內的對應變數名)
  • event:GXT 產生 SpriteOverEvent 或是 SpriteOutEvent

如果 none.setFill() 給正常顏色的正常狀況下:

x type element event
299 move red over
300 out red out
300 move none over
301 move none over

嗯… 一切看起來頗合理。

如果 none.setFill() 設定為 Color.NONE,那情況是:

x type element event
299 move red over
300 move red over
301 out red out
301 move bg over

browser 一直要到 x = 301 的時候才由 redONMOUSEOUT?然後那個 bg 是什麼鬼???

答:bgDrawComponent 的 background… [眼神死]

到底發生什麼事情?

簡單地說,就是視覺上看不到的 element(例如 none),就無法讓 browser 觸發任何 event。DrawComponent 有個預設白色的 background,所以實際觸發(browser)event 的是它。那麼如果再搞個 DrawComponent.setBackground(Color.NONE) 呢?還是會炸出 browser event,因為底下還有一個 API 完全無法控制、寬度高度都給 100%<rect>,GXT 大概就是為了避免沒東西可以觸發 event 所以而寫死、確保一定有的 DOM element?很有趣的事,這個 <rect> 根本沒有給 fill attribute… #$%&*@!!!

回頭解釋 GXT 層級的 event,一切變得很合理。x = 300 時,對 browser 而言是 redONMOUSEMOVE,但是對於 GXT 的機制來說卻會被判別是 noneSpriteOverEvent(理由前段已詳述)。x = 301 時,GXT 要用 lastSpriteSpriteOutEvent,但是 x = 300 時lastSprite 指定為 none,所以才會產生帳面上十分矛盾的結果。

結語

又再次證實了寫 SSCCE 的重要。

一開始有寫一個 GF 層級的 SSCCE。但是為了排除 GF-Draw 有 bug 的可能性(因為 GF-Draw 有再蓋一層 event handling 機制,而且 GF-Draw 基礎之一的 LayerSprite 預設有個 fill = Color.NONERectangleSprite 當 background,所以很容易就會湊滿觸發條件),所以只好再寫一個 GXT 版本,寫完發現程式行為跟程式碼對不起來,只好這麼一路追到 browser 行為上頭… [眼神死]

其實一直都知道「看不到的 element 不會炸 event」這個 browser 哏,因為大概十年前有被雷到,但是寫 GWT 不太需要理會這件事情,而且 DrawComponent 產生的 DOM 是 SVG,說不定 SVG 有 SVG 的玩法(看起來並沒有 XD),也就完全沒有往這方向去想。

結果就是十幾個小時的青春歲月就這樣沒了… [淚目]

所以這也依然是一個「如果在職場上一定直接避開觸發條件不深究」的 case。

好了,現在可以(暫時地)安心睡覺,不會擔心這個偽靈異現象在哪天具現化還逃不掉…

沒有留言:

張貼留言