基于反射实现的零GC高效率Unity组件绑定

前言

我是狗猥,上一世,我使用传统方式绑定UI上的组件,却因xLua扩展代码太多撑爆丹田沦为废人,失去了争夺主程岗位的资格,最后在测试同学的讥笑声中饮恨西北。

再次睁开眼,我穿越回到了拼UI的那一天。重生归来,这一世我要设计一个船新的绑定方式,夺回本就属于我的一切!

今天要分享的是最近搞的一套组件绑定机制,采用面向数据编程的思维设计。虽然基于反射实现,但运行时没有GC,并且效率非常高。

这是在三星S24Ultra上用执行调用localEulerAngles一伯万次的耗时差别(Get快很多是因为做了值缓存),以及在Update中的GC Alloc。

本项目使用Unity 2021.3.37f1制作,完整工程的git地址和使用方法放在最后面了,嫌我啰嗦的同学可以直接跳转过去。

转载请注明出处:https://www.cnblogs.com/GuyaWeiren/p/18680576.html

设计思路

Transform组件为例,locanPositionlocalEulerAngleslocalScale都是开发中常用到的属性。

使用脚本做热更新时,传统的Wrapper会对每个属性的GetterSetter总共生成6个方法。如下是xLua对Transform生成的扩展:

static int _g_get_localPosition(RealStatePtr L) { ... }
static int _s_set_localPosition(RealStatePtr L) { ... }
static int _g_get_localEulerAngles(RealStatePtr L) { ... }
static int _s_set_localEulerAngles(RealStatePtr L) { ... }
static int _g_get_localScale(RealStatePtr L) { ... }
static int _s_set_localScale(RealStatePtr L) { ... }

显然,属性越多,生成的代码也越多。

于是我们可以对其进行一次抽象,引入中间件模板概念。

这三个属性有没有共同点呢?有的,都是Vector3类型的属性。对于这三个属性,可以定义一个使用Vector3为泛型的中间件,将其与属性关联起来。

class Middleware<T>
{
public T Get() { ... }
public void Set(T pValue) { ... }
}
class MiddlewareVec3 : Middleware<Vector3> { }

不管有多少个属性,只要是Vector3类型的,都可以用这一个中间件来描述,如图所示:

在Unity编辑器中,在GameObject上挂载绑定器脚本,指定要绑定的Component(或GameObject)和属性,在运行时将属性绑定在中间件上,就可以像调用属性一样调用中间件了。

这样不必对每个属性进行Wrap,只需要生成中间件的就行,Wrap代码量会减少很多。

中间件

除了GetSet方法,中间件还需要持有组件对象和属性的Delegate

// “Middleware”名称过于广泛,改用ComponentProperty说明它作用于组件的属性
public abstract class BaseComponentProperty<T>
{
private Object m_Target; // Component或GameObject
private Delegate m_Getter;
private Delegate m_Setter;
public T Get() => this.m_Getter?.Invoke();
public void Set(T pValue) => this.m_Setter?.Invoke(this.m_Taregt, pValue);
}

这里我对中间件的设计是和属性一一对应。虽然也可以设计成多个Setter以实现同时对多个组件属性赋值,但是后期维护可能会头大,比如改了某个UI布局的时候——“这个中间件到底对应哪几个Component?”

绑定器

每个绑定器可以绑定多个该GameObject上的Component,因此用一个数组存放绑定信息,每个数组项需要三个字段:

public class ComponentPropertyBind : MonoBehaviour
{
[SerializeField]
private BindInfo[] m_Infos; // 每一项就是一个绑定器
[Serializable]
public class BindInfo
{
public string ViewFieldName; // 视图类中的中间件变量名
public Component ComponentRef; // 组件引用
public string PropertyName; // 属性名称
}
}

考虑到在开发中预制件和代码随时会变更,如果使用引用,引用一旦丢失,将无法知晓它曾经是什么。因此采用字符串的形式,即使引用丢失,也能看到内容,便于做调整。

运行时的流程如下:

  1. Awake时向上找到视图类
  2. 反射从视图类中找到名称对应的中间件成员
  3. 反射找到组件的属性
  4. 对属性的GetterSetter生成对应的Delegate
  5. Delegate存入中间件

反射最慢的一步是GetFieldGetProperty,因此实际代码中需要做缓存。

Delegate数据

不采用PropertyInfo.Get/SetValue是因为它的返回值/参数都是object类型,如果我们绑定的是值类型属性,每次赋值或取值都会发生装拆箱的GC,这是不好的。并且这个方法的效率不高。

而使用Delegate.CreateDelegate可以生成MethodInfodelegate。它类似于C++的函数指针,速度非常快。并且它的参数可以使用泛型,直接杜绝了装拆箱:

Delegate.CreateDelegate(methodInfo, typeof(Action<int>)) as Action<int>

由于C#是强类型语言,而绑定的属性是在Unity编辑器里设置的,在编译期无法确定Delegate的泛型参数类型。虽然有dynamic关键字,但访问它会因为额外的类型判定逻辑产生GC,并且IL2Cpp不支持:Unity手册-脚本限制

因此考虑使用泛型类来存放Delegate

internal interface IGetterSetter<T>
{
T Get(object pInvoker);
void Set(object pInvoker, T pValue);
}
// TComponent是组件,TValue是属性的类型
// 如Image的sprite属性,对应GetterSetter<Image, Sprite>
public class GetterSetter<TComponent, TValue> : IGetterSetter<TValue> where TComponent : Object
{
private Func<TComponent, TValue> m_GetterDelegate;
private Action<TComponent, TValue> m_SetterDelegate;
public GetterSetter(PropertyInfo pInfo)
{
this.m_GetterDelegate = (Func<TComponent, TValue>)Delegate.CreateDelegate(typeof(Func<TComponent, TValue>), pInfo.GetGetMethod());
this.m_SetterDelegate = (Action<TComponent, TValue>)Delegate.CreateDelegate(typeof(Action<TComponent, TValue>), pInfo.GetSetMethod);
}
}
// 在绑定时动态生成一个GetterSetter实例:
var t = typeof(GetterSetter<,>)MakeGenericType.(typeof(TComponent), typeof(TValue));
this.m_GetterSetter = Activator.CreateInstance(t, propertyInfo) as IGetterSetter<TValue>;

事件类属性

Button.onClick这类事件,同样可以根据参数类型设计中间件:

public abstract class BaseComponentEvent<T>
{
private IAddRemove<T> m_AddRemove = null;
public void AddListener(UnityAction<T> pCallback) => this.m_AddRemove?.AddListener(base.m_Target, pCallback);
public void RemoveListener(UnityAction<T> pCallback) => this.m_AddRemove?.RemoveListener(base.m_Target, pCallback);
}

AddRemove和上面的GetterSetter类似,是对AddListenerRemoveListener的泛型封装。

需要注意的是无参事件,在C#中System.Void是不允许作为泛型参数的,因此要单独实现:

public class ComponentEventVoid
{
private IAddRemove m_AddRemove = null;
public void AddListener(UnityAction pCallback) => this.m_AddRemove?.AddListener(base.m_Target, pCallback);
public void RemoveListener(UnityAction pCallback) => this.m_AddRemove?.RemoveListener(base.m_Target, pCallback);
}

目前只设计了无参事件和单参事件的绑定。有多参需要请自行修改代码。

AOT代码生成

GetterSetter的泛型实例是运行时动态生成的,因此直接使用IL2Cpp打包后运行会报错。

简单来说就是如果代码中没有GetterSetter<Transform, Vector3>类型,那么编译后的C++代码中也没有。因为Unity编辑器模式是JIT的,这个报错只有打包运行后才会出现:

ExecutionEngineException: Attemping to call method '...' for witch no ahead of time (AOT) code was generated.

于是需要硬编码类型,让AOT编译器能够检测到它们:

[Preserve]
private class GetterSetterWrapTypes
{
private GetterSetter<GameObject, Transform> __GS_0__;
private GetterSetter<GameObject, int> __GS_1__;
...
}

通过反射找到所有的组件,整理出它们的属性的类型,生成对应的GetterSetterAddRemove成员。

直接生成cs文件会导致Unity生成很多未使用成员警告,因此这里我选择用System.Reflection.Emit生成一个dll文件。再使用Preserve标签和link.xml保证它不被代码裁剪忽略。

使用方法

完整项目的GitHub:https://github.com/RenChiyu/ComponentBind

为了提高自己的英语姿势水平,在尝试使用全英文编码,如果出现语法错误请勿大声嘲笑。

工作流程如下:

  1. 创建一个基于BaseView的视图类
  2. 在视图类中添加中间件成员
  3. 在编辑器选中视图根节点,挂载视图类
  4. 选中需要绑定的组件的GameObject,挂载ComponentPropertyBinderComponentEventBinder
  5. 在绑定器中选择中间件成员,组件和属性或引用
  6. 保存

可以参考TestPanel.cs,以及场景中以#开头命名的节点。

如果使用IL2Cpp,需要在打包前调用ComponentBindAOTCodeGenerator.Execute()或点击菜单 -> TooSimpleFramework -> ComponentBind -> Generate AOT Code

这里只实现了比较简陋的功能,没有做一个中间件对应多个绑定器的判定。

实际使用中可以再写一个工具,根据GameObject的名称自动设置要绑定的组件和属性或事件和视图类中的成员代码。每个人有每个人的做法,一键绑定工具就不公开了,具体实现留给读者。

后记

马上要过年了,大家2024过得好吗?在这里提前祝大家春节遇快,阖家欢洛,万似如意!

2024年我的简历上多了从研发到上线的项目经历,某四字战棋手游,具体名字就不说啦。还是小有成就感的。

但是去年最大的成就感是通过控制饮食将体重从118公斤降到了93公斤,继续加油昂。大家在工作的时候也要注意劳逸结合,身体才是革命的本钱。

很惭愧,就做了一点微小的工作,谢谢大家。

posted @   GuyaWeiren  阅读(47)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!
点击右上角即可分享
微信分享提示