NoiseFloor 发表于 2021-11-21 17:58

unity中优雅高效绑定UI组件的一种实现方案

在Unity的游戏项目开发中,客户端同学总是会在界面组件绑定上需要花费大量的精力。其中,界面迭代修改频繁、交互同学协同修改界面容易出现纰漏、手写编码绑定组件变量等等问题则占用了很大的部分工作量。
本文主要介绍一种可拓展、代码生成、操作便捷高效及自带犯错警告的组件绑定方案。
<hr/>前言:

在项目的开发过程中,客户端复杂界面的开发及迭代需要对界面上的组件进行功能性的绑定,大量的组件绑定操作占用了开发同学大量工作时间。另外交互同学不时需要对prefab文件协同修改调优,在这个过程中容易因为误删组件或者GameObject对象造成引用丢失,从而引起运行时的错误。解决这些问题则是本篇讨论的重点。
这次我来介绍继承UnityEngine.Object类对象引用的一种绑定方案:RuntimeComponents。
目标功能:

◇ 绑定操作快捷高效
◇ 实现所有继承UnityEngine.Object的组件、资源的引用绑定,如Component、Sprite、Texture等
◇ 自动化导出生成绑定后的Lua代码
◇ 引用丢失警告提示
◇ 导出引用类型
◇ 协同友好
方案操作说明:

1. 选中任意GameObject,添加RuntimeComponents脚本


2. 操作区域说明
◇ 区域【1】监听拖动操作,将Hierarchy中需要绑定的对象拖到该区域
将自动查找所有组件并罗列显示,顺势拖动选中绑定的组件
◇ 或者可以通过单击【2】区域添加按钮手动添加一个绑定组件,在自行赋值操作
◇ 通过【3】可将当前绑定的代码自动复制到剪切板上
◇ 如果需要生成但文件则可以通过【4】导出视图单脚本代码文件


3. 拖动绑定动图演示
4. 当有些组件需要迭代更新时则可以在重新引用目标对象的同时手动修改绑定组件,修改动图演示如下
5. 当组件对象被绑定后,在Hierarchy列表中,绑定组件所在的GameObject对象左侧则将会绘制黄色的星星,以此提醒该组件已经被引用,删除或者移动操作时需要格外关注:
6. 在拖放绑定时组件的名字会自动尝试以驼峰的方式读取GameObject名字并设定变量名,基于可能会有一些不太规范的GameObject对象名存在,绑定的同学可以按规范修改该组件的别名
代码导出操作说明

1. 通过prefab上的CopyCodeForLua按钮将已绑定的组件代码复制到剪贴板中,随后手动将代码复制到界面逻辑中


2. 将手动复制的代码不应随意修改,直接复制到界面初始化处即可


3. 通过Generate Widget Script导出界面脚本


◇ 选择导出目录


◇ 得到自动导出的Widget脚本


实现思路:

1. 常见的几种绑定方式
◇ 开发人员通过 Transform.Find来查找目标组件,这样的方法运行时耗时,一旦界面元素位置发生变动,出错是必然的
void Bind()
{
    var button = transform.Find("button").GetComponent<Button>();
}◇ 通过公开变量的方式来引用组件对象
public class Test : MonoBehaviour
{
    public Button button;
    public Text text;
}◇ 通过引用gameObject对象进而通过GetComponent获取组件对象
public GameObject[] m_gameObjects;
void Bind()
{
    var button = m_gameObjects.GetComponent<Button>();
    var text = m_gameObjects.GetComponent<Text>();
}

当然可能还有其他的方式,在此就不在一一列举。

2.精确确定目标组件
以上罗列的几种绑定方式,最终都无法避免的要通过GetComponent函数来获取目标组件,所以如果可以直接获取目标组件的引用,那么在绑定阶段则可以直接转换类型,那么将绑定组件限定为Object类的子类对象,进而可以实现一下思路
public UnityEngine.Object[] m_objects;
void Bind()
{
    var button = m_objects as Button;
    var text = m_objects as Text;
}当然,如果只是以上的思路,还不足以引用确定类型,在组件的Inspector上赋值得到的只是GameObject的引用


进而接下来的思路就是要将GameObject上的目标组件,如Text、Button等引用添加到m_objects数组中,期望结果如下图:


当然,由于原生unity的序列化引用方式无法获取某个GameObject上绑定的某个脚本对象,所以我们尝试编写Editor拓展工具
将GameObject上的目标组件引用缓存到引用数组中

3. 编码思路
1)使用【UnityEditorInternal.ReorderableList】来绘制引用数组,以下是伪代码:
◇ 增加只在编辑器下生效的绑定组件别名
public class RuntimeComponents : MonoBehaviour
{
#if UNITY_EDITOR
    private string[] m_aliases;
#endif

    private UnityEngine.Object[] m_objects;
    private int m_index;
    public void Reset()
    {
      this.m_index = 0;
    }
    public object CurrentObject()
    {
      if (m_index + 1 <= m_objects.Length)
      {
            return m_objects;
      }
      else
      {
            return null;
      }
    }
    public UnityEngine.Object[] Objects
    {
      get { return m_objects; }
    }
}◇ 定义一个Object对象的组件描述类,将Object对象上获取到的所有组件类型与目标Object对应缓存
public class ItemInfo

{
    public string[] types = { };
    public Type[] components = { };
    public int index = 0;
    public GameObject gameObject;
    public UnityEngine.Object getValue()
    {
      var t = components;
      if (t == typeof(GameObject))
      {
            return gameObject;
      }

      return gameObject.GetComponent(t);
    }
}◇ 构建或者更新指定Object对象绑定的组件对象
private ItemInfo newItemInfo(UnityEngine.Object ob, ItemInfo info = null)
{
    if (info == null)
    {
      info = new ItemInfo();
    }
    if (ob != null)
    {
      var list = new List<string>();
      var types = new List<Type>();
      if (ob is GameObject)
      {
            var go = ob as GameObject;
            info.index = addComponentsTypes(go, null, list, types);
            info.gameObject = go;
      }
      else if (ob is Component)
      {
            var co = ob as Component;
            info.index = addComponentsTypes(co.gameObject, co, list, types);
            info.gameObject = co.gameObject;
      }
      else
      {
            var t = ob.GetType();
            list.Add(t.Name);
            types.Add(t);
      }

      info.types = list.ToArray();
      info.components = types.ToArray();
    }
    else
    {
      info.types = new string { "none" };
      info.components = new Type { null };
    }
    return info;
}

private int addComponentsTypes(GameObject go, Component co, List<string> list, List<Type> types, int index = 0)
{
    //GameObject引用比较特殊,需要进行特殊处理
    list.Add("GameObject");
    types.Add(typeof(GameObject));
    var cs = go.GetComponents(typeof(Component));
    foreach (var t in cs)
    {
      list.Add(t.GetType().Name);
      types.Add(t.GetType());
    }

    if (co != null)
    {
      var t = co.GetType().Name;
      return list.IndexOf(t);
    }

    return index;
}◇ 阶段性成果,这样我们就可以通过增加Object对象之后选择其绑定的所有组件



4. 交互优化思路
◇ 阶段性成果,这样我们就可以通过增加Object对象之后选择其绑定的所有组件,但是这样的操作太过于繁琐。
既然是交互逻辑复杂,那么优化它就好!


◇ 增加拖动监听区域,监听拖动的对象,并可展开选择一个组件去绑定,以下是部分核心代码:
public void OnDragUpdate(string msg = null)
{
    Event e = Event.current;
    GUI.color = Color.green;
    //绘制一个监听区域
    var dragArea = GUILayoutUtility.GetRect(0f, 30f, GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true));
    var tips = getAreaTips(msg);
    GUIContent title = new GUIContent(tips);
    GUI.Box(dragArea, title);
    //绘制选定对象所有绑定的组件类型
    drawTypes();
    switch (e.type)
    {
      case EventType.DragUpdated:
      case EventType.DragPerform:
            var index = getContainsIndex(dragArea, e.mousePosition);
            if (index < -1)
            {
                break;
            }
            if (m_activeItemInfo == null)
            {
                newActiveItemInfo();
            }

            DragAndDrop.visualMode = DragAndDropVisualMode.Copy;
            if (e.type == EventType.DragPerform && index >= 0)
            {
                if (m_activeItemInfo != null && m_activeItemInfo.gameObject != null)
                {
                  addComponent(m_assetsList, m_activeItemInfo, index);
                }
                DragAndDrop.AcceptDrag();
            }
            e.Use();
            break;
      case EventType.DragExited:
            m_activeItemInfo = null;
            m_typeRects = new Rect;
            break;
      default:
            break;
    }
    GUI.color = Color.white;
}

private void drawTypes()
{
    if (m_activeItemInfo != null)
    {
      GUILayout.BeginVertical();
      var types = m_activeItemInfo.types;
      for (var i = 0; i < types.Length; i++)
      {
            var r = GUILayoutUtility.GetRect(0f, 30f, GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true));
            m_typeRects = r;
            GUI.color = r.Contains(Event.current.mousePosition) ? Color.green : Color.white;
            GUI.Box(r, types);
            Repaint();
      }
      GUILayout.EndVertical();
    }
}◇ Yes!这样绑定的过程舒畅多了~



5. 交互友好相关
◇ 仅实现上述的功能,绑定的时候是爽了,但面对结构复杂的界面时,同学如果都不知道哪个对象被绑定过,那么在交互协同或者开发同学许久之后再打开界面,对界面的记忆已模糊,如此这般,事情就大条了!
◇ 将在Hierarchy中显示的对象一颗醒目的黄星标识一下:

public class RuntimeComponents : MonoBehaviour
{
#if UNITY_EDITOR
    private string[] m_aliases;
#endif

    private UnityEngine.Object[] m_objects;
    private int m_index;

    public void Reset()
    {
      this.m_index = 0;
    }

    public object CurrentObject()
    {
      if (m_index + 1 <= m_objects.Length)
      {
            return m_objects;
      }
      else
      {
            return null;
      }
    }

    public UnityEngine.Object[] Objects
    {
      get { return m_objects; }
    }

#if UNITY_EDITOR

   
    private static void load()
    {
      UnityEditor.EditorApplication.hierarchyWindowItemOnGUI += OnHierarchyWindowItemOnGUI;
    }

    private static void OnHierarchyWindowItemOnGUI(int instanceID, Rect selectionRect)
    {
      var obj = UnityEditor.EditorUtility.InstanceIDToObject(instanceID) as GameObject;
      if (obj == null)
      {
            return;
      }
      foreach (var c in m_runtimeComponents)
      {
            if (c.Used(obj))
            {
                var r = new Rect(selectionRect);
                r.x = 34;
                r.width = 80;
                GUIStyle style = new GUIStyle();
                style.normal.textColor = Color.yellow;
                style.active.textColor = Color.red;
                if (style != null && obj != null)
                {
                  GUI.Label(r, "★", style);
                }
            }
      }
    }

    private readonly static List<RuntimeComponents> m_runtimeComponents = new List<RuntimeComponents>();

    private void OnEnable()
    {
      m_runtimeComponents.Add(this);
    }

    private void OnDisable()
    {
      m_runtimeComponents.Remove(this);
    }

    private bool Used(GameObject go)
    {
      if (m_objects == null)
      {
            return false;
      }

      for (var i = 0; i < m_objects.Length; i++)
      {
            var o = m_objects;
            if (o != null)
            {
                if (o is GameObject)
                {
                  if (o == go)
                        return true;
                }
                else if (o is MonoBehaviour)
                {
                  var oo = o as MonoBehaviour;
                  if (oo.gameObject == go)
                        return true;
                }
            }
      }
      return false;
    }

#endif
}◇ 这样看到黄色的标识,内心应该会有一点波澜了吧。

6. 防止迷糊蛋的优化思路
◇ 即使有了一个上述醒目的标识,可能还是有一些小迷糊蛋会错过这个细节,删除了引用的组件,进而造成运行时的错误情况。
◇ 针对这种情况,在修改这保存Prefab时检测弹窗提醒一下。并且贴心的将该错误窗口设定为不可关闭!
public class RuntimeComponentsWindow : EditorWindow

{
   
    private static void load()
    {
      PrefabStage.prefabSaved -= OnPrefabInstanceUpdated;
      PrefabStage.prefabSaved += OnPrefabInstanceUpdated;
      PrefabUtility.prefabInstanceUpdated -= OnPrefabInstanceUpdated;
      PrefabUtility.prefabInstanceUpdated += OnPrefabInstanceUpdated;
   }
   
   //检测及显示逻辑
   ...

   //销毁检测重建
   private void OnDestroy()
   {
      m_window = null;
      if (m_components != null && m_components.Count != 0)
      {
         GetWindow<SceneView>().ShowNotification(new GUIContent("RuntimeComponents有空对象引用\n\n请检查!"));
         ShowWindow(m_root, m_components);
      }
   }
}◇ 如此这般,误删的朋友要么被逼疯,要么乖乖的把错误填上!


代码生成:

◇ 在上述绑定的流程顺利完成之后,接下来可以根据自身的需求,对组件数组中的对象进行遍历处理,然后生成相关代码即可,代码生成有多种方式。
◇ 这里贴一下笔者目前生成的部分lua代码结构
local t = unpack(args)
    ---@private
    ---@type UnityEngine.RectTransform
    self.transform = t.transform
    ---@private
    ---@type UnityEngine.GameObject
    self.gameObject = t.gameObject
    local c = t:GetComponent(typeof(RuntimeComponents))
    c:Reset()
    ---@type UnityEngine.UI.Button
    ---@private
    self.btnButton = c:CurrentObject ()
    ---@type UnityEngine.UI.Image
    ---@private
    self.imgImage = c:CurrentObject ()◇ 做法不一,就不展开说明了。
拓展:

◇ 比如我们会在一个Prefab中嵌入其他一些公共的界面,可以在代码生成阶段主动将界面代码构造出来。
以下构建了一个CommonView
local CommonView = require 'lua/ui/CommonView'
    local t = unpack(args)
    ---@private
    ---@type UnityEngine.RectTransform
    self.transform = t.transform
    ---@private
    ---@type UnityEngine.GameObject
    self.gameObject = t.gameObject
    local c = t:GetComponent(typeof(RuntimeComponents))
    c:Reset()
    ---@type UnityEngine.UI.Button
    ---@private
    self.btnButton = c:CurrentObject ()
    ---@type UnityEngine.UI.Image
    ---@private
    self.imgImage = c:CurrentObject ()
    ---@type UnityEngine.RectTransform
    ---@private
    self.commonView = CommonView(c:CurrentObject ())◇ 会对组件进行二次封装,以下对Button组件进行封装
local t = unpack(args)
    ---@private
    ---@type UnityEngine.RectTransform
    self.transform = t.transform
    ---@private
    ---@type UnityEngine.GameObject
    self.gameObject = t.gameObject
    local c = t:GetComponent(typeof(RuntimeComponents))
    c:Reset()
    ---@type BuildMyButton
    ---@private
    self.btnButton = BuildMyButton(c:CurrentObject ())◇ 可拓展相同类型的脚本数组形式的支持
◇ 还有更多的玩法,欢迎各位同行前来交流
总结

使用上述实现的RuntimeComponents来维护界面元素可以快速绑定组件、对组件的变更及层级的调整不会有较大的问题,有利于界面的快速编码和迭代RuntimeComponents引用关系丢失时有相关的警告处理流程,可以减少prefab在协同过程中产生的误删等导致引用丢失的问题,同时也可以在发现问题时及时更正,防止错误的雪球越滚越大绑定类型广泛,操作优雅高效代码生成器支持,拓展性强

mypro334 发表于 2021-11-21 18:06

收藏比点赞多[惊喜][惊喜][惊喜]

DomDomm 发表于 2021-11-21 18:14

[调皮]能给大家带来最实用的内容是我们奋斗的目标!

Doris232 发表于 2021-11-21 18:15

这套框架有做到不遗余力地增加其他工种的工作量吗

量子计算9 发表于 2021-11-21 18:17

[害羞]

mypro334 发表于 2021-11-21 18:26

一个ui绑定注册生成,支持节点及点击事件注册导出到lua就足够的事情。这东西对于策划美术交互都不友好,又是给程序弄,变复杂了,这明显就是过度开发,吃力不讨好

xiangtingsl 发表于 2021-11-21 18:36

提前规范 总比后期找bug好多了 这个我之前也有写过类似的

FeastSC 发表于 2021-11-21 18:39

不太好用的样子,可能是我没领略到精髓。[好奇]

kirin77 发表于 2021-11-21 18:43

好活,收藏了,有时间看[赞]

APSchmidt 发表于 2021-11-21 18:44

[语塞]不好用 学习成本大 操作麻烦 维护起来麻烦
页: [1] 2
查看完整版本: unity中优雅高效绑定UI组件的一种实现方案