在Unity中获取所有具有特定接口的组件

12

我的场景中有一些实现了接口ISaveableGameObjects。在我的脚本中,我想要找到所有这些接口并将它们存储起来。稍后我可以循环遍历它们并调用它们实现的方法SaveData()

我当前的解决方案是寻找这些接口:

    List<ISaveable> saveables = new List<ISaveable>();
    MonoBehaviour[] sceneObjects = FindObjectsOfType<MonoBehaviour>();
    
    for (int i = 0; i < sceneObjects.Length; i++)
    {
        MonoBehaviour currentObj = sceneObjects[i];
        ISaveable currentComponent = currentObj.GetComponent<ISaveable>();

        if (currentComponent != null)
        {
            saveables.Add(currentComponent);
        }
    }

这段代码可以正常运行,但有没有更好的方法?我不想在场景中搜索每个 MonoBehaviour 然后尝试获取其接口组件。

10个回答

12

Linq 简化过滤器

您可以使用 Linq OfType 进行过滤。

ISaveable[] saveables = FindObjectsOfType<MonoBehaviour>(true).OfType<ISaveable>().ToArray();

当然,首先还需要找到所有的对象。

请注意,这暴露了关于未激活游戏对象或禁用行为的FindObjectsOfType的一般限制。(更新:在新的Unity版本中,可选的bool参数使得包括未激活/禁用组件成为可能。)

现在也可以看到Object.FindObjectsByType(仍然限于UnityEngine.Object


遍历层次结构

您可以扩展它以遍历场景中所有根级对象。这是有效的,因为GetComponentsInChildren确实也适用于接口!

var saveables = new List<ISaveable>();
var rootObjs = SceneManager.GetActiveScene().GetRootGameObjects();
foreach(var root in rootObjs)
{
    // Pass in "true" to include inactive and disabled children
    saveables.AddRange(root.GetComponentsInChildren<ISaveable>(true));
}

如果更有效率 - 是的,不是,可能,我不知道 - 但它也包括不活动和禁用的对象。

而且,可以扩展它以遍历使用多个已加载场景的对象。

var saveables = new List<ISaveable>();
for(var i = 0; i < SceneManager.sceneCount; i++)
{
    var rootObjs = SceneManager.GetSceneAt(i).GetRootGameObjects();
    foreach(var root in rootObjs)
    {
        saveables.AddRange(root.GetComponentsInChildren<ISaveable>(true));
    }
}

一开始就不要使用接口

这种替代方案与this answer有些相似,但它有一个巨大的缺陷:您需要一个特定的接口实现才能使其工作,这无效了接口的整个概念。

因此,从评论中也可以看出一个重要问题:为什么要使用接口?

如果它只会用于MonoBehaviour,那么您应该使用抽象类

public abstract class SaveableBehaviour : MonoBehaviour
{
    // Inheritors have to implement this (just like with an interface)
    public abstract void SaveData();
}

这已经完全解决了使用FindObjectsOfType的问题,因为现在你可以简单地使用

SaveableBehaviour[] saveables = FindObjectsOfType<SaveableBehaviour>();

但是你仍然可以更进一步:为了实现更简单和更高效的访问,您可以使它们完全自行注册,而无需使用管理器或 Singleton 模式!为什么类型不应该直接处理其实例呢?

public abstract class SaveableBehaviour : MonoBehaviour
{
    // Inheritors have to implement this (just like with an interface)
    public abstract void SaveData();

    private static readonly HashSet<SaveableBehaviour> instances = new HashSet<SaveableBehaviour>();

    // public read-only access to the instances by only providing a clone
    // of the HashSet so nobody can remove items from the outside
    public static HashSet<SaveableBehaviour> Instances => new HashSet<SaveableBehaviour>(instances);

    protected virtual void Awake()
    {
        // simply register yourself to the existing instances
        instances.Add(this);
    }

    protected virtual void OnDestroy()
    {
        // don't forget to also remove yourself at the end of your lifetime
        instances.Remove(this);
    }
}

这样你就可以简单地继承

public class Example : SaveableBehaviour
{
    public override void SaveData()
    {
       // ...
    }

    protected override void Awake()
    {
        base.Awake(); // Make sure to always keep that line

        // do additional stuff
    }
}

你可以通过以下方式访问该类型的所有实例

HashSet<SaveableBehaviour> saveables = SaveableBehaviour.Instances;
foreach(var saveable in saveables)
{
    saveable.SaveData();
}

1
好的,很棒!我认为 GameObject.FindObjectsOfType<MonoBehaviour>() 比从 GameObject.FindObjectsOfType<Transform>() 开始更自然。 - Fattie
第一个解决方案与问题中提供的解决方案并没有太大区别。也许稍微好看一些。但是,如果担心搜索整个场景、存储它以及过滤输出的效率问题,那么这段代码也会执行相同的操作。第二个解决方案可能是另一种选择,但也可能不是,因为我们不知道为什么首先要使用接口。你能避免使用它吗?根据情况而定,可能可以也可能不行。 - Joseph
1
@Joseph,但是你在回答中所做的与我的第二个建议相同,那么为什么要点踩呢?你提到的问题我在回答中也已经提到了。 - derHugo
除了搜索场景之外,没有任何方法可以搜索场景 :) 这些问题本质上涉及习语、效率的细微差别以及处理游戏引擎中显然高度动态的场景内容(特别是由于脚本执行顺序而在帧间更改的麻烦问题)。 - Fattie
为什么要有接口呢?因为C#不支持多重继承,而那些MonoBehaviours已经从类中继承了。 - John Stock
@JohnStock 争论的内容是:为什么不直接继承一个通用的抽象类,而是继承自MonoBehaviour?我知道C#是如何工作的;当然,这取决于具体的使用情况=>“如果它只会用于MonoBehaviour”,这就是我在句子中的开头部分。 - derHugo

4

你可以创建一个管理类,它持有一个ISaveable的集合/列表。

然后,通过在其Awake方法中设置Singleton值,将该管理类设置为单例。

class SaveManager(){
public static SaveManager Instance;
Awake(){
if (Instance == null)
   {
    Instance = this;
   }
}

List<ISaveable> SaveAbles;
public void AddSaveAble(ISaveAble saveAble){
//add it to the list.
}
}

在实现ISaveable接口的类的Start方法中,您可以使用单例将其添加到总列表中。

class Example : MonoBehaviour, ISaveable
{
void Start(){
SaveManager.Instance.AddSaveAble(this);
}
}

这样,每当一个SaveAble创建时,它都会通过manager的方法将自己添加到管理器中。

请注意,重要的是Singleton在Awake中设置,以便在Start中使用,因为Awake在生命周期中首先执行。


1
如果您选择这种方法,您还需要确保使用 OnDestroy() 实现从集合中删除。因此,我建议将 List 切换为 HashSet,以获得 O(1) 的删除时间。 - Foggzie
3
除了这些,你还在暗示你的接口必须以特定方式实现(这违背了接口的目的)。将其作为一个抽象基类更加合适,它可以扩展MonoBehaviour并确保添加和删除会发生。 - Foggzie
1
你的解决方案避免了通过 GameObject 进行搜索,这很有效;我觉得我在那个评论中表述得不太好。你拥有的不是单例模式,它只是一个全局引用;两者的区别在于,单例实现强制只能实例化一种类型的对象。你所拥有的并没有问题(我给了你 +1,因为它确实对 OP 有帮助)。我的意思是,如果你使用 MonoBehaviour 并结合特定代码,它就不会是“单例模式”。你将拥有一个静态引用来调用 Awake() 的第一个对象。 - Foggzie
很遗憾,这个答案在Unity中是完全错误的(而且根本不起作用),原因有很多。在ECS系统中没有“单例”..一个Unity场景只是像使用Photoshop一样。在Photoshop中,你有一张纸,上面有标记。想到单例或任何其他OO概念是毫无意义的。在Unity中,你有一个场景(“一个3D空间”),里面有“游戏对象”。想到单例或任何其他OO概念是毫无意义的。 - Fattie
对于编辑时间,您可以尝试使用以下方法:https://docs.unity3d.com/ScriptReference/Object.FindObjectsOfType.html - Doh09
显示剩余7条评论

3

典型的现实世界解决方案:

using System.Linq;

public interface Automateme
{
    public void Automate();
}

那么...

void _do()
{
    Automateme[] aa = GameObject
    .FindObjectsOfType<MonoBehaviour>()
    .OfType<Automateme>()
    .Where(a => ((MonoBehaviour)a).enabled)
    .ToArray<Automateme>();

    if (aa.Length == 0)
    {
        Debug.Log($"Automator .. none");
        return;
    }
    Debug.Log($"Automator .. " + aa.Length);

    foreach (Automateme a in aa)
    {
        Debug.Log($"Automator {a.GetType()} {((MonoBehaviour)a).gameObject.name}");
    }

    // do something to one of them randomly, for example! ->
    aa.AnyOne().Automate();
}

一个要点是,你几乎肯定需要将对象作为MonoBehavior使用,因此使用@derHugo首先提出的FindObjectsOfType<MonoBehaviour>()方法是明智的(并且不会更加低效/高效)。
一些要点。
  • 当然,在某些情况下,你根本不会按照这个问题所要求的去做,你只需“保持列表”即可轻松考虑到正在考虑的项目。 (这将根据情况而异,您只想要活动的项目吗?您是否想包括在其首帧上升时的项目等等)。整个问题的重点是针对(许多,典型的)需要即时获取它们且缓存无关的情况。

  • 令人困惑的是,Unity开发具有巨大的领域范围。一些项目类型可能在整个场景中只有十几个游戏对象;其他项目类型可能在场景中有10,000个游戏对象。具有2D物理的游戏与具有3D物理的游戏非常不同,渲染方法与着色器方法也非常不同。自然地,对于像这样的任何“风格”问题,如果您从头脑中的某个“领域”出发,答案可能会非常不同。

  • 关于FindObjectsOfType和类似调用,值得记住的是,Unity的效率已经从早期大大提高了。过去,“你会告诉新的Unity程序员的第一件事”是“不要每帧都搜索场景!!!天哪!你会变成1fps!”当然,在某些特定的领域,这仍然是正确的,也许是许多领域。但是(十年来)它通常是完全错误的。Unity对场景对象的缓存和处理现在非常快速(您无法编写更快),并且如前面所述,您通常只谈论一小部分项目。整个Unity范例效率变化的一个很好的例子是“池化”。(任何不像我这么老的游戏程序员都可以查找一下那是什么 :))在Unity早期,您会惊慌失措地将所有东西都放入池中,我的上帝,我可能有三个坦克,获取一个池库!事实上,我太老了,我写了一篇最受欢迎的池化文章之一,每个人都用了一段时间(已经删除)。现在,Unity对预制件或屏幕外模型的实例化处理非常快速和聪明,您可以更多地说他们提供了内置的池化,而现在根本没有必要从机枪等普通子弹中池化物品。无论如何,这个例子的重点只是要更全面地理解这些天(非常古老的)关于不搜索场景的“标准建议”。

回到最初的问题:

我不想在场景中搜索每个Monobehaviour,然后尝试获取其接口组件。

在游戏引擎中,搜索场景中的每个对象的唯一方法就是搜索场景中的每个对象。因此,无法避免这种情况。当然,可以“保持列表”,但这样做有两个缺点:(A)在某些/许多情况下,在游戏架构中这并不可行/明智;(B)Unity已经非常擅长保持游戏对象/组件的列表,这是帧引擎存在的全部原因。请注意,如果它是一个物理游戏,那么整个PhysX系统正在以惊人的、前沿的方式“每帧查找物体”,使用数学上的怪异空间哈希等处理与场景中每个物理多边形的平方或更糟糕的复杂度相关的问题!与此相比,担心从包含50个项目的列表中提取3个项目(这些项目已经由帧引擎完美地散列和管理了效率)是错误的。


1
只需记得在您的文件中添加 "using System.Linq;",他的代码就可以正常工作。 - Shachar Oz
好的,添加了! - Fattie

1
如何使用全局状态,例如一个ISaveable列表,并在每个可保存对象的start()期间添加对它的引用?然后您可以简单地迭代该数组,在每个ISaveable引用上调用SaveData()

这类似于此答案,但需要在接口的所有实现中显式实现该方法... - derHugo
可以有一个实现ISaveable接口的抽象类,其中包含将ISaveable添加/删除到静态列表中的功能。然后,子类从该抽象类继承,并且只需为所有子类执行一次,如果需要,可以覆盖它。 - Everts

0
请注意,这种解决方案效率非常低,但是您可以使用LINQ过滤实现您接口的组件。在这种情况下,MyInterface是我们要查询的接口。
MyInterface[] myInterfaces = GameObject.FindObjectsOfType<MonoBehaviour>()
    .OfType<MyInterface>()
    .ToArray();

关于“不高效”的小注释:由于Unity已经优化了它们的对象查询方法,因此这个调用并没有那么糟糕。问题在于LINQ会分配大量的GC,这可能会导致帧率下降,这就是为什么你永远不应该在每帧基础上运行这些“重”操作。

在启动时运行查询或进行明显的密集操作(保存/加载)是完全可以接受的。


1
值得注意的是,它并不是那么低效。 (A) 在许多情况下,场景中实际上并没有那么多对象。(Unity很令人困惑:域可以有很大的差异。在某些游戏范例中,你可以拥有数千个对象,而在其他范例中只有少数几个。) (B) Unity对游戏对象的哈希现在非常快速。(C) 不要忘记Unity中的一般初学者警告:“不要每帧搜索整个场景!”只适用于“每帧”。如果每隔几分钟或更长时间需要“搜索整个场景”,这完全没有问题。这是许多情况下的解决方案 - Fattie
1
当然没问题!顺便说一下,我在你的好答案中添加了另一个例子。显然,在这个网站上,您可以撤消、删除等任何您不喜欢的编辑!干杯!赏金即将到来。 - Fattie
1
请点击此处查看更简单的linq用法 ;) - derHugo
1
啊,我以为你发表评论是要求编辑代码@derHugo。如果你愿意的话,我可以再次将其删除。编辑:我不太确定你为什么要查看所有答案,让这个问题复活,并推广你自己的答案。大多数这些答案已经两年了,虽然它们可以被改进,但它们本身肯定是有效的答案。 - Ian H.
1
只是为了那些在这里搜索的人,抛开习语不谈,在过滤器链的最前面放置“MonoBehavior”通常很方便,因为这样你就可以确定它们是脚本而不是组件 - 只是供参考。 - Fattie
显示剩余3条评论

0

这是对你的方法进行改进,以便获取场景中实际上所有具有你接口的 MonoBehavior。

void Start()
{
    var myInterface = typeof(MyInterface);
    var monoBehavior = typeof(MonoBehaviour);
    var types = Assembly.GetExecutingAssembly()
        .GetTypes()
        .Where(p => myInterface.IsAssignableFrom(p) && monoBehavior.IsAssignableFrom(p));

    var myInstance = new List<MyInterface>();

    foreach (var type in types)
    {
        myInstance.AddRange(FindObjectsOfType(type).Select(x => x as MyInterface));
    }
}

在这里,你不需要遍历场景中的所有GameObject,但你会进行更多的FindObjectsOfType调用,并且你必须进行类型转换。但不再需要GetComponent。

所以取决于你的情况,但如果你将反射部分缓存(在加载时刻),它可以避免你实现Manager模式,同时也可以保持接口架构。

否则,你可以查看观察者模式,这是一种在不同类类型之间管理调用的不同方式。 https://sourcemaking.com/design_patterns/observer


为什么会这样呢?无论如何,您都在使用具有多个类型的相同的FindObjectsOfType..可能甚至更昂贵;)并且随着许多类型实现接口,情况可能会变得更糟。 - derHugo
由于您增加了转换的数量,Linq函数的OfType尝试将场景中的所有单体强制转换为实现接口的单体。因此,您在场景中拥有的单体越多,就会越频繁地执行这个无用的操作。 - Flavien Marianacci
除此之外,请注意,通常情况下,你极不可能想要一种在启动时缓存所有感兴趣的项目的方法。几乎所有游戏场景都是完全动态的,你希望在“给定时刻”拥有所有“坦克或死亡射线”的接口...在几乎所有游戏中,坦克和死亡射线随着游戏的进行而来去!因此,你不可避免地需要一个即时解决方案,而不是在启动时缓存的解决方案... - Fattie
关于这个答案末尾的句子,建议“或者只是使用观察者模式”。也就是说,“保持项目列表”。值得记住的是,整个游戏引擎的核心不过是一个带有许多装饰的大而全的观察者模式 :) 当然,显然,与这个问题的要点形成对比,你可以保留一个列表。例如,我可能会问一个关于在某些特定情况下不使用PhysX的问题,会有各种各样的答案,最后一个人可能会说“哦,或者使用PhysX!”... - Fattie
1
@FlavienMarianacci 我觉得你的解决方案很棒;)不过我有一些担忧:如果有多个程序集并且类型分散在它们之间怎么办?或者,如果我有一个带有接口的SDK程序集,并且这段代码将在这个SDK程序集中运行,而这个程序集实际上不知道实现接口的类型是什么?此外,您还需要再次进行过滤...目前我猜想,如果它具有某些派生类型(例如public class SomeSaveable : BaseSaveable{} public class BaseSaveable : ISaveable{}),则会重复获取相同的引用。 - derHugo
显示剩余19条评论

-1

你可以通过替换来改进代码

ISaveable currentComponent = currentObj.GetComponent<ISaveable>();

使用

ISaveable currentComponent = currentObj as ISaveable;

-1
在我的情况下,我正在使用这个实现:
    MonoBehaviour[] saveableScripts = (MonoBehaviour[])FindObjectsOfType(typeof(MonoBehaviour));
    foreach (var enemy in saveableScripts)
    {
        if (enemy is ISaveable == false)
            continue;

        // you code ...
        saveables.Add(currentComponent);
    }

在单行代码中实现基本相同的操作,请参见此处 ;) - derHugo

-1

我同意使用像Doh09和其他人建议的ISaveable管理器的想法,以解决这个用例。但是按照你的问题所问,你可以按以下方式遍历整个场景层次结构或多个场景的层次结构:

public class Foo : MonoBehaviour
{
    List<ISaveable> saveables;
    void RefreshSaveables()
    {
        saveables = new List<ISaveable>();
        for (var i = 0; i < SceneManager.sceneCount; ++i)
        {
            var roots = SceneManager.GetSceneAt(i).GetRootGameObjects();
            for (var j = 0; j < roots.Length; ++j)
            {
                saveables.AddRange(roots[j].GetComponentsInChildren<ISaveable>());
            }
        }
    }
}

这种方法的优点是,您不需要保存场景中的每个MonoBehaviour以便以后过滤它 - 但我仍然认为最好让每个ISaveable在Start上注册自己,并让管理器调用它们的Save方法。

那么为什么您要再次引入管理器类呢?这个答案与这个答案有何不同之处...除了它抹掉了查找禁用或非活动组件的优势。 - derHugo
在Unity(或任何游戏引擎、任何ECS系统)中,这是完全错误的,原因我在FlavienMarianacci答案下的长评论中解释了。 - Fattie

-2
一种方法是使用Unity容器(尽管略微陈旧)。通过配置将所有依赖项注册到容器中。可以将所有实现ISaveable接口的实例注册到Unity容器,然后在代码中解析它们。
示例:
Web.config中的Unity容器:
<container name=“GameObjectContainer”>
    <register type=“ISaveable” mapTo=“Example1” />
    <register type=“ISaveable” mapTo=“Example2” />
</container>

ISavable的定义:

public interface ISavable
{
    void SaveData();
}

可保存的实现:

public class Example1 : ISavable
{
    public void SaveData()
    {
    }
}

public class Example2 : ISavable
{
    public void SaveData()
    {
    }
}

请注意,UnityContainer != Unity3D ;) - derHugo

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接