如何实现一套可灵活复用的 Unity3D View 层组件机制

基于 Prefab 的复用机制的局限性

在 Unity3D 中,如果你想复用一个 GameObject ,官方推荐的方式是使用 Prefab 的方式,但是 Prefab 的方式有一个致命的缺陷就是 Unity3D 没有支持嵌套 Prefab ,导致基于 Prefab 方式的复用非常的有限,基本上只能制作单个复用项。我们以 UGUI 为例,来说明基于 Prefab 的复用机制的局限性,并提供一种基于脚本的更灵活的复用机制。
在游戏 UI 中,我们经常遇到有下面的设计:

两个图标组件都是相同大小的,比如都是 100x100 像素大小的,并且都有两个层次,一个紫色的背景图片,一个稍微小一圈的图标。在这种需求下面,我们可以很轻松的使用 Prefab 的方式来实现这个需求。在 Prefab 上面挂在一个脚本,然后根据需要,通过这个脚本来更改背景图片和图标图片。
然后,需求变化了。在另外一个页面上,我们要显示相同的图标组件,但是它的大小要比之前的更大,比如是 200x200 像素大小的。这种情况下我们仍然可以使用 Prefab 的方式,不过就要做更多一些的修改,需要根据情况设置 RectTransform 的大小。
随着游戏的研发,需求又变化了。有一些按钮上要显示如下所示的提示小红点:

这个时候,使用 Prefab 的方式我们就有点吃力了,我们需要在这个 Prefab 上面添加这个小红点,并通过脚本控制它是否显示出来,同时还要关心他们在不同的图标大小时候,显示的位置是正确的。

很不幸,需求继续变化着,我们又看到了下面这些设计图:

逻辑进一步复杂了,我们感觉到如果基于同样一个Prefab,已经有点力不从心了,要维护很多的逻辑,并且要添加很多冗余的 GameObject 。但是,如果拆分成多个 Prefab ,我们就要维护多套本来是同一个模式的组件,并且随着游戏的研发,很有可能这个模式会发生微调,比如在背景层上面的小图标要更小一点,这个时候你就需要更改所有的 Prefab 组件,这简直是一个噩梦。
事情并没有结束,上面的这个有多个按钮的工具条也在多个页面复用,只是按钮之间的间距和大小会有所不同。如果采用 Prefab 的形式,也就意味着这个工具条也是一个 Prefab ,而工具条内部的元素也是 Prefab ,也就是所谓的嵌套 Prefab 。目前 Unity3D 并没有支持嵌套 Prefab ,类似这样的需求如果单纯使用 Prefab 实现,那会是一个地狱。虽然在 AssetStore 上面有一些插件支持嵌套 Prefab ,但是根据我的使用经验,非常的不灵活,而且还经常出现丢失 Prefab 引用的情况。大家可以自行试用和判断。
我们来总结一下,之所以上述的需求单纯使用 Prefab 很难实现,归根结底是因为 Prefab 不支持类似于面向对象技术中的继承和组合,所以 Prefab 的灵活性完全满足不了实际的开发需求。
那么,有什么好的办法可以提供一套可灵活复用,并且支持继承与组合的机制吗?下面我提供一种基于脚本的复用机制。

基于脚本的复用机制

基于脚本的复用机制,本质上是通过脚本之间的继承和组合的方式,来提供更多的灵活性。通过共同继承一个基类,可以达到修改基类来修改所有组件的共同行为的目的;通过不同部件之间的组合,可以达到快速嵌套复用已有组件的目的,同时又不会丢失每个子组件应有的可修改性。
下面我们来看一个具体的基于 UGUI 的实现例子。首先,有3个代码文件如下:

  1. UIView.cs 这是所有组件的基类,它是一个抽象范型类,规定了所有的组件至少应该有哪些通用的方法。

    public abstract class UIView<T> : BaseMonoBehaviour where T : UIView<T>
    {
        public abstract bool IsInited ();
    
        public abstract T Init ();
    
    }
    
  2. UIImageView.cs 这是一个负责显示 Image 的组件,只不过在这里封装了一层,在 Init 方法里面添加 UGUI 的 Image 组件。其继承于上面的 UIView。

    public class UIImageView : UIView<UIImageView>
    {
        protected Image m_image;
    
        public Image image
        {
            get
            {
                if (m_image == null)
                {
                    m_image = this.GetComponent<Image>();
                }
                return m_image;
            }
        }
    
        public override bool IsInited ()
        {
            return m_image != null;
        }
    
        public override UIImageView Init ()
        {
            m_image = this.gameObject.AddComponent<Image>();
    
            return this;
        }
    
    }
    
  3. UIBgIconView.cs 这个一个显示背景图片上面有一个图标的组件,并且通过 IconOffset 来支持调整图标相对于背景图片的大小和位置。本组件使用组合机制,背景图片组件和图标组件各为一个 UIImageView 组件。

    public class UIBgIconView : UIView<UIBgIconView>
    {
        protected UIImageView m_bg;
        protected UIImageView m_icon;
    
        public override bool IsInited ()
        {
            return m_icon != null && m_bg != null;
        }
    
        public override UIBgIconView Init ()
        {
            m_iconOffset = new RectOffset(5, 5, 5, 5);
    
            float bgWidth = this.rectTransform.rect.width;
            float bgHeight = this.rectTransform.rect.height;
    
            // init bg 
            GameObject bgObj = new GameObject("bg", typeof(RectTransform));
            bgObj.transform.SetParent(this.transform);
            m_bg = bgObj.AddComponent<UIImageView>();
            m_bg.Init();
            m_bg.rectTransform.anchorMin = new Vector2(0.5f, 0.5f);
            m_bg.rectTransform.anchorMax = new Vector2(0.5f, 0.5f);
            m_bg.rectTransform.anchoredPosition = new Vector2(0, 0);
            m_bg.rectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, bgWidth);
            m_bg.rectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, bgHeight);
    
            // init icon
            GameObject iconObj = new GameObject("icon", typeof(RectTransform));
            iconObj.transform.SetParent(this.transform);
            m_icon = iconObj.AddComponent<UIImageView>();
            m_icon.Init();
            m_icon.rectTransform.anchorMin = new Vector2(0.5f, 0.5f);
            m_icon.rectTransform.anchorMax = new Vector2(0.5f, 0.5f);
            m_icon.rectTransform.anchoredPosition = new Vector2(0, 0);
            UpdateIconSize();
    
            return this;
        }
    
        void UpdateIconSize ()
        {
            float iconWidth = m_bg.rectTransform.rect.width - m_iconOffset.left - m_iconOffset.right;
            float iconHeight = m_bg.rectTransform.rect.height - m_iconOffset.top - m_iconOffset.bottom;
    
            m_icon.rectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, iconWidth);
            m_icon.rectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, iconHeight);
        }
    
        protected RectOffset m_iconOffset;
    
        public RectOffset iconOffset
        {
            get
            {
                return m_iconOffset;
            }
    
            set
            {
                m_iconOffset = value;
                if (IsInited())
                {
                    UpdateIconSize();
                }
            }
        }
    
        public UIImageView bg
        {
            get { return m_bg; } 
        }
    
        public UIImageView icon
        {
            get { return m_icon; } 
        }
    

上述组件如何使用呢?只需要新建一个GameObject,并挂载上相应的脚本即可。比如,挂载上 UIBgIconView.cs ,你就可以像使用其他任何脚本一样修改其属性。

public class Test : MonoBehaviour 
{
    void Awake()
    {
        UIBgIconView bgiconView = this.transform.Find("Panel").GetComponent<UIBgIconView>();
        if (!bgiconView.IsInited())
        {
            bgiconView.Init();
        }

        bgiconView.icon.image.color = Color.red;
    }
}

需要注意的是,这种复用机制是在 Init 方法中通过脚本来动态创建 GameObject,并挂载上相应的UGUI的组件。而 Init 的方法的调用时机是暴露给外部的使用者的。或许有的读者会问,为什么不在 Awake 方法里面初始化呢?因为 Awake 方法是系统的默认事件回调,它的触发时机是由系统控制的,在某些情况下这会带来不确定性。所以,索性提供一个我们自己的 Init 函数,这样所有的行为都是可控的。
上面的示例只是这种基于脚本的复用机制的一个最基本的演示,大家可以继续用类似的方法去扩展跟多的组件,还是比较灵活的。这种方法的不便之处就是不如可视化操作直观,需要用代码创建 GameObject,而不是在 Editor 里面拖拽的方式。
就我个人而言,我现在越来越多的使用基于脚本的复用机制,因为它面对需求变更的时候实在是太灵活方便了,只需要更改基类就可以改变所有组件的行为,通过组件的组合,可以快速实现一个新的组件。这种灵活性,是使用 Prefab 所不能达到的。
最后,强烈推荐大家使用这种基于脚本的组件复用机制。也期盼 Unity3D 官方能够推出一套支持 Prefab 继承和组合的机制出来。