Cocos Creator 源码解读:siblingIndex 与 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, 0, this);
    } 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(32, 0);
  }

}

场景一 (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 的水太深,你把握不住!


传送门

微信推文版本

个人博客:菜鸟小栈

开源主页:陈皮皮

Eazax Cocos 游戏开发工具包


更多分享

《Cocos Creator 性能优化:DrawCall》

《在 Cocos Creator 里画个炫酷的雷达图》

《用 Shader 写个完美的波浪》

《在 Cocos Creator 中优雅且高效地管理弹窗》

《JavaScript 内存详解 & 分析指南》

《Cocos Creator 编辑器扩展:Quick Finder》

《JavaScript 原始值与包装对象》

《Cocos Creator 源码解读:引擎启动与主循环》


公众号

菜鸟小栈

我是陈皮皮,一个还在不断学习的游戏开发者,一个热爱分享的 Cocos Star Writer。

这是我的个人公众号,专注但不仅限于游戏开发和前端技术分享。

每一篇原创都非常用心,你的关注就是我原创的动力!

Input and output.

你可能感兴趣的:(Cocos Creator 源码解读:siblingIndex 与 zIndex)