聽皮皮一句勸zIndex 的水太深,你把握不住!


前言

本文基於 Cocos Creator 2.4.5 撰寫。

🎉 普天同慶

來了來了,《源碼解讀》系列文章終於又來了!

👾 溫馨提醒

本文包含大段引擎源碼,使用大屏設備閱讀體驗更佳!

Hi There!

節點(cc.Node)作爲 Cocos Creator 引擎中最基本的單位,所有組件都需要依附在節點上。

同時節點也是我們日常開發中接觸最頻繁的東西。

我們經常會需要「改變節點的排序」來完成一些效果(如圖像的遮擋)。

A Question?

😕 你有沒有想過:

節點的排序是如何實現的?

Oops!

🤯 我在分析了源碼後發現:

節點的排序並沒有想象中那麼簡單!

😹 渣皮語錄

聽皮皮一句勸,zIndex 的水太深,你把握不住!


正文

節點順序 (Node Order)

🤔 如何修改節點的順序?

首先,在 Cocos Creator 編輯器中的「層級管理器」中,我們可以隨意拖動節點來改變節點的順序。

拖動排序

🤨 但是,在代碼中我們要怎麼做呢?

我最先想到的是節點的 setSiblingIndex 函數,然後是節點的 zIndex 屬性。

我猜大多數人都不清楚這兩個方案有什麼區別。

那麼接下來就讓我們深入源碼,一探究竟!

siblingIndex

「siblingIndex」即「同級索引」,意爲「同一父節點下的兄弟節點間的位置」。

siblingIndex 越小的節點排越前,索引最小值爲 0,也就是第一個節點的索引值。

需要注意的是,實際上節點並沒有 siblingIndex 屬性,只有 getSiblingIndexsetSiblingIndex 這兩個相關函數。

注:本文統一使用 siblingIndex 來代指 getSiblingIndexsetSiblingIndex 函數。

另外,getSiblingIndexsetSiblingIndex 函數是由 cc._BaseNode 實現的。

💡 cc._BaseNode

大家對這個類可能會比較陌生,簡單來說 cc._BaseNodecc.Node 的基類。

此類「定義了節點的基礎屬性和函數」,包括但不僅限於 setParentaddChildgetComponent 等常用函數...

📝 源碼節選:

函數:cc._BaseNode.prototype.getSiblingIndex

getSiblingIndex() {
  if (this._parent) {
    return this._parent._children.indexOf(this);
  } else {
    return 0;
  }
},

函數:cc._BaseNode.prototype.setSiblingIndex

setSiblingIndex(index) {
  if (!this._parent) {
    return;
  }
  if (this._parent._objFlags & Deactivating) {
    return;
  }
  var siblings = this._parent._children;
  index = index !== -1 ? index : siblings.length - 1;
  var oldIndex = siblings.indexOf(this);
  if (index !== oldIndex) {
    siblings.splice(oldIndex, 1);
    if (index < siblings.length) {
      siblings.splice(index, 0this);
    } else {
      siblings.push(this);
    }
    this._onSiblingIndexChanged && this._onSiblingIndexChanged(index);
  }
},

[源碼] base-node.js#L514: https://github.com/cocos-creator/engine/blob/2.4.5/cocos2d/core/utils/base-node.js#L514

🕵️‍ 做了什麼?

扒拉源碼後發現,siblingIndex 的本質其實很簡單。

那就是「當前節點在父節點的 _children 屬性中的下標(位置)」。

getSiblingIndex 函數返回的是「當前節點在父節點的 _children 屬性中的下標(位置)」。

setSiblingIndex 函數則是設置「當前節點在父節點的 _children 屬性中的下標(位置)」。

💡 cc._BaseNode.prototype._children

節點的 _children 屬性其實就是節點的 children 屬性。

children 屬性是一個 getter,返回的是自身的 _children 屬性。

另外 children 屬性沒有實現 setter,所以你直接給 children 屬性賦值是無效的。

zIndex

「zIndex」是「用來對節點進行排序的關鍵屬性」,它決定了一個節點在兄弟節點之間的位置。

zIndex 的值介於 cc.macro.MIN_ZINDEXcc.macro.MAX_ZINDEX 之間。

另外,zIndex 屬性是在 cc.Node 內使用 Cocos 定製版 gettersetter 實現的。

📝 源碼節選:

屬性: cc.Node.prototype.zIndex

// 爲了減少篇幅,已省略部分不相關代碼
zIndex: {
  get() {
    return this._localZOrder >> 16;
  },
  set(value) {
    if (value > macro.MAX_ZINDEX) {
      value = macro.MAX_ZINDEX;
    } else if (value < macro.MIN_ZINDEX) {
      value = macro.MIN_ZINDEX;
    }
    if (this.zIndex !== value) {
      this._localZOrder = (this._localZOrder & 0x0000ffff) | (value << 16);
      this.emit(EventType.SIBLING_ORDER_CHANGED);
      this._onSiblingIndexChanged();
    }
  }
},

[源碼] CCNode.js#L1549: https://github.com/cocos-creator/engine/blob/2.4.5/cocos2d/core/CCNode.js#L1549

🕵️ 做了什麼?

扒拉源碼後發現,zIndex 的本質其實也很簡單。

那就是「返回或設置節點的 _localZOrder 屬性」。

🧐 沒那麼簡單!

有趣的是,在 getter 中並沒有直接返回 _localZOrder 屬性,而是返回了 _localZOrder 屬性右移(>>)16 位後的數值。

setter 中設置 _localZOrder 屬性時也並非簡單的賦值,又是進行了一頓位操作:

這裏我們以二進制數的視角來分解該函數內的位操作。

  1. 通過 & 0x0000ffff 取出原 _localZOrder 的「低 16 位」;
  2. 將目標值 value「左移 16 位」;
  3. 將左移後的 value 作爲「高 16 位」與原 _localZOrder 的「低 16 位」合併;
  4. 最後得到一個「32 位的二進制數」並賦予 _localZOrder

😲 嗯?

慢着!_localZOrder 又是幹啥用的?咋這麼繞!

別急,答案在後面~

排序 (Sorting)

細心的朋友應該發現了,siblingIndex 和 zIndex 的源碼中都沒有包含實際的排序邏輯。

但是它們都有一個共同點:「最後都調用了自身的 _onSiblingIndexChanged 函數」。

_onSiblingIndexChanged

📝 源碼節選:

函數:cc.Node.prototype._onSiblingIndexChanged

_onSiblingIndexChanged() {
  if (this._parent) {
    this._parent._delaySort();
  }
},

🕵️ 做了什麼?

_onSiblingIndexChanged 函數內則是調用了「父節點」的 _delaySort 函數。

_delaySort

📝 源碼節選:

函數:cc.Node.prototype._delaySort

_delaySort() {
  if (!this._reorderChildDirty) {
    this._reorderChildDirty = true;
    cc.director.__fastOn(cc.Director.EVENT_AFTER_UPDATE, this.sortAllChildren, this);
  }
},

🕵️ 做了什麼?

一頓操作順藤摸瓜後發現,真正進行排序的地方是「父節點」的 sortAllChildren 函數。

💡 盲生,你發現了華點!

值得注意的是,_delaySort 函數中的 sortAllChildren 函數調用不是立即觸發的,而是會在下一次 update(生命週期)後觸發。

延遲觸發的目的應該是爲了避免在同一幀內的重複調用,從而減少不必要的性能損耗。

sortAllChildren

📝 源碼節選:

函數:cc.Node.prototype.sortAllChildren

// 爲了減少篇幅,已省略部分不相關代碼
sortAllChildren() {
  if (this._reorderChildDirty) {
    this._reorderChildDirty = false;
    // Part 1
    var _children = this._children, child;
    this._childArrivalOrder = 1;
    for (let i = 0, len = _children.length; i < len; i++) {
      child = _children[i];
      child._updateOrderOfArrival();
    }
    eventManager._setDirtyForNode(this);
    // Part 2
    if (_children.length > 1) {
      let child, child2;
      for (let i = 1, count = _children.length; i < count; i++) {
        child = _children[i];
        let j = i;
        for (;
          j > 0 && (child2 = _children[j - 1])._localZOrder > child._localZOrder;
          j--
        ) {
          _children[j] = child2;
        }
        _children[j] = child;
      }
      this.emit(EventType.CHILD_REORDER, this);
    }
    cc.director.__fastOff(cc.Director.EVENT_AFTER_UPDATE, this.sortAllChildren, this);
  }
},

[源碼] CCNode.js#L3680: https://github.com/cocos-creator/engine/blob/2.4.5/cocos2d/core/CCNode.js#L3680

>上半部分 (Part 1)

隨着一步步深入,我們終於來到了關鍵部分。

現在讓我們琢磨琢磨這個 sortAllChildren 函數。

進入該函數的前半段,映入眼簾的是一行賦值語句,將 _childArrivalOrder 屬性設(重置)爲 1

緊跟其後的是一個 for 循環,遍歷了當前節點的所有「子節點」,並一一執行「子節點」的 _updateOrderOfArrival 函數。

🤨 嗯?這個 _updateOrderOfArrival 函數又是何方神聖?

~_updateOrderOfArrival

📝 源碼節選:

函數:cc.Node.prototype._updateOrderOfArrival

_updateOrderOfArrival() {
  var arrivalOrder = this._parent ? ++this._parent._childArrivalOrder : 0;
  this._localZOrder = (this._localZOrder & 0xffff0000) | arrivalOrder;
  this.emit(EventType.SIBLING_ORDER_CHANGED);
},

🕵️ 做了什麼?

顯而易見的是,_updateOrderOfArrival 函數的作用就是「更新節點的 _localZOrder 屬性」。

🥱 該函數中同樣也使用了位操作:

同上,以二進制數的視角來進行分解這裏的位操作。

  1. 將父節點的 _childArrivalOrder(前置)自增 1,並賦予 arrivalOrder(如無父節點則爲 0);
  2. 通過 & 0xffff0000 取出當前節點的 _localZOrder 的「高 16 位」;
  3. arrivalOrder 作爲「低 16 位」與當前節點的 _localZOrder 的「高 16 位」合併;
  4. 最後得到一個新的「32 位的二進制數」並賦予當前節點的 _localZOrder 屬性。

🤔 看到這裏你是不是已經開始迷惑了?

別擔心,答案即將揭曉!

>下半部分 (Part 2)

sortAllChildren 函數的下半部分就比較好理解了。

基本就是通過「插入排序(Insertion Sort)」來「排序當前節點的 _children 屬性(子節點數組)」。

其中主要根據子節點的 _localZOrder 屬性的值來進行排序,_localZOrder 屬性值小的子節點排前面,反之排後面。

排序的關鍵 (Key of sorting)

🤔 分析完源碼後發現,節點的排序並沒有想象中那麼簡單。

我們可以先得出幾個結論:

  1. siblingIndex 是節點在父節點的 children 屬性中的下標;
  2. zIndex 是一個獨立的屬性,和 siblingIndex 沒有直接聯繫;
  3. siblingIndex 和 zIndex 的改變都會觸發排序;
  4. siblingIndex 和 zIndex 共同組成了節點的 _localZOrder
  5. zIndex 的權重比 siblingIndex 大;
  6. 節點的 _localZOrder 直接決定了節點的最終順序。

siblingIndex 如何影響排序 (How siblingIndex affects sorting)

我們前面有提到:

  • getSiblingIndex 函數「返回了當前節點在父節點的 _children 屬性中的下標(位置)」。
  • setSiblingIndex 函數「設置了當前節點在父節點的 _children 屬性中的下標(位置),並通知父節點進行排序」。

隨後在父節點的 sortAllChildren 函數中的上半部分,會以這個下標作爲節點 _localZOrder 的低 16 位。

🧐 所以我們可以這樣理解:

siblingIndex 是元素下標,在排序過程中,其決定了 _localZOrder 的「低 16 位」。

zIndex 如何影響排序 (How zIndex affects sorting)

我們前面有提到:

  • zIndexgetter「返回了 _localZOrder 的高 16 位」。
  • zIndexsetter「設置了 _localZOrder 的高 16 位,並通知父節點進行排序」。

🧐 所以我們可以這樣理解:

zIndex 實際上只是一個軀殼,其本質是 _localZOrder 的「高 16 位」。

_localZOrder 如何決定順序 (How _localZOrder works)

父節點的 sortAllChildren 函數中根據子節點的 _localZOrder 大小來進行最終排序。

我們可以將 _localZOrder 看做一個「32 位二進制數」,其由 siblingIndex 和 zIndex 共同組成。

但是,爲什麼說「zIndex 的權重比 siblingIndex 大」呢?

因爲 zIndex 決定了 _localZOrder 的「高 16 位」,而 siblingIndex 決定了 _localZOrder 的「低 16 位」。

所以,只有在 zIndex 相等的情況下,siblingIndex 的大小纔有決定性意義。

而在 zIndex 不相等的情況下,siblingIndex 的大小就無所謂了。

🌰 舉個栗子

這裏有兩個 32 位二進制數(僞代碼):

  • A: 0000 0000 0000 0001 xxxx xxxx xxxx xxxx
  • B: 0000 0000 0000 0010 xxxx xxxx xxxx xxxx

由於 B 的「高 16 位」(0000 0000 0000 0010)比 A 的「高 16 位」(0000 0000 0000 0001)大,所以無論他們的「低 16 位」中的 x 是什麼,B 都會永遠大於 A。

實驗一下 (Experiment)

我們可以寫個小組件來測試下 siblingIndex 和 zIndex 對於 _localZOrder 的影響。

📝 一頓打碼:

const { ccclass, property, executeInEditMode } = cc._decorator;

@ccclass
@executeInEditMode
export default class Test_NodeOrder extends cc.Component {

  @property({ displayName: 'siblingIndex' })
  get siblingIndex() {
    return this.node.getSiblingIndex();
  }
  set siblingIndex(value) {
    this.node.setSiblingIndex(value);
  }

  @property({ displayName: 'zIndex' })
  get zIndex() {
    return this.node.zIndex;
  }
  set zIndex(value) {
    this.node.zIndex = value;
  }

  @property({ displayName: '_localZOrder' })
  get localZOrder() {
    return this.node._localZOrder;
  }

  @property({ displayName: '_localZOrder (二進制)' })
  get localZOrderBinary() {
    return this.node._localZOrder.toString(2).padStart(320);
  }

}

>場景一 (Scene 1)

在 1 個節點下放置了 1 個子節點。

🖼 子節點的排序信息:

zIndex 0

一般來說,由於節點的 _childArrivalOrder 是從 1 開始的,並且在計算時會先自增 1

所以子節點的 _localZOrder 的「低 16 位」總會比其 siblingIndex 大 2 個數。

>場景二 (Scene 2)

在 1 個節點下放置了 1 個子節點,並將子節點的 zIndex 設爲 1

🖼 子節點的排序信息:

zIndex 1

可以看到,僅僅將節點的 zIndex 屬性設爲 1,其 _localZOrder 就高達 65538

🔠 大概的計算過程如下(極爲抽象的僞代碼):

1. zIndex = 1 = 0b0000000000000001
2. siblingIndex = 0
3. arrivalOrder = 1 + (siblingIndex + 1)
4. arrivalOrder = 0b0000000000000010
5. _localZOrder = (zIndex << 16) | arrivalOrder
6. _localZOrder = 0b00000000000000010000000000000000 | 0b0000000000000010
7. _localZOrder = 0b00000000000000010000000000000010 = 65538

📝 繼續簡化後的僞代碼:

_localZOrder = (zIndex << 16) | (siblingIndex + 2)

💡 By the way

當一個節點沒有父節點時,它的 arrivalOrder 永遠是 0

其實此時它是啥已經不重要了,畢竟沒有父節點的節點本來就不可能會被排序。

>場景三 (Scene 3)

在同 1 個節點下放置了 6 個子節點,將所有子節點的 zIndex 都設爲 0

🎥 各個子節點的排序信息:

zIndex 0 & siblingIndex 0~5

>場景四 (Scene 4)

在同 1 個節點下放置了 6 個子節點,將這 6 個子節點的 zIndex 設爲 05

🎥 各個子節點的排序信息:

zIndex 0~5

可以看到,zIndex 的值會直接體現在 _localZOrder 的「高 16 位」;每當 zIndex 增加 1_localZOrder 就會增加 65537

所以說 siblingIndex 怎麼可能打得過 zIndex

>場景五 (Scene 5)

在同 1 個節點下放置了 6 個子節點,將這 6 個子節點的 zIndex 設爲 05

🎥 修改第 6 個子節點的 siblingIndex04,其排序信息:

zIndex 5 & siblingIndex 0~4

可以看到,此時無論我們怎麼修改第 6 個子節點的 siblingIndex,它都會自動變回 5(也就是同級節點中的最大值)。

因爲這個子節點的 zIndex 在其同級節點之中有着絕對的優勢。

~不太對勁 (Something wrong)

😲 這裏有一個看起來不太對勁的現象!

比如,當我們把 siblingIndex5 修改爲 0 時,_localZOrder 也相應從 327687 變成 327682;但是當 siblingIndex 自動變回 5 時,_localZOrder 也還是 327682,並沒有變回 327687

🤔 爲什麼會這樣?

原因其實很簡單:

當我們修改節點的 siblingIndex 時會觸發排序,排序過程中會「根據節點當前時刻的 siblingIndex 和 zIndex 生成新的 _localZOrder」;

最後在父節點的 sortAllChildren 函數中會根據子節點的 _localZOrder 來對 _children 數組進行排序,此時「子節點的 siblingIndex 也會被動更新」,「但是 _localZOrder 卻沒有重新生成」。

但是,由於 zIndex 存在「絕對優勢」,這種“奇怪的現象”其實並不會影響到節點的正常排序~

總結 (Summary)

分析完源碼後,我們來總結一下。

在代碼中修改節點順序的方法主要有兩種:

  1. 修改節點的 zIndex 屬性
  2. 通過 setSiblingIndex 函數設置

無論使用以上哪種方法,最終都會「通過 zIndex 和 siblingIndex 的組合作爲依據來進行排序」。

在多數情況下,「修改節點的 zIndex 屬性會使其 setSiblingIndex 函數失效」。

這無形中增加了編碼時的心智負擔,也增加了問題排查的難度。

引擎內的用法 (Usage in engine)

出於好奇,我在引擎源碼中搜了搜,想看看引擎內部有沒有使用到 zIndex 屬性。

結果是:只有幾處與「調試」相關的地方使用到了節點的 zIndex 屬性。

Usage in engine

例如:預覽模式下,左下角的 Profiler 節點。

Profiler Node

以及碰撞組件的調試框等等,這裏就不在贅述了。

建議 (Suggestion)

所以,爲了避免一些不必要的 BUG 和邏輯衝突。

我的建議是:

「少用甚至不用 zIndex,而優先使用 siblingIndex 相關函數。」

🥴 聽皮皮一句勸,zIndex 的水太深,你把握不住!


公衆號

菜鳥小棧

😺 我是陳皮皮,一個還在不斷學習的遊戲開發者,一個熱愛分享的 Cocos Star Writer。

🎨 這是我的個人公衆號,專注但不僅限於遊戲開發和前端技術分享。

💖 每一篇原創都非常用心,你的關注就是我原創的動力!

Input and output.


本文分享自微信公衆號 - Creator星球遊戲開發社區(creator-star)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

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