前言
我是狗猥,上一世,我使用传统方式绑定UI上的组件,却因xLua扩展代码太多撑爆丹田沦为废人,失去了争夺主程岗位的资格,最后在测试同学的讥笑声中饮恨西北。
再次睁开眼,我穿越回到了拼UI的那一天。重生归来,这一世我要设计一个船新的绑定方式,夺回本就属于我的一切!
今天要分享的是最近搞的一套组件绑定机制,采用面向数据编程的思维设计。虽然基于反射实现,但运行时没有GC,并且效率非常高。
这是在三星S24Ultra上用执行调用localEulerAngles
一伯万次的耗时差别(Get快很多是因为做了值缓存),以及在Update中的GC Alloc。
本项目使用Unity 2021.3.37f1制作,完整工程的git地址和使用方法放在最后面了,嫌我啰嗦的同学可以直接跳转过去。
转载请注明出处:https://www.cnblogs.com/GuyaWeiren/p/18680576.html
设计思路
以Transform
组件为例,locanPosition
、localEulerAngles
和localScale
都是开发中常用到的属性。
使用脚本做热更新时,传统的Wrapper会对每个属性的Getter
和Setter
总共生成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代码量会减少很多。
中间件
除了Get
和Set
方法,中间件还需要持有组件对象和属性的Delegate
。
// “Middleware”名称过于广泛,改用ComponentProperty说明它作用于组件的属性
public abstract class BaseComponentProperty<T>
{private Object m_Target; // Component或GameObjectprivate 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; // 属性名称}
}
考虑到在开发中预制件和代码随时会变更,如果使用引用,引用一旦丢失,将无法知晓它曾经是什么。因此采用字符串的形式,即使引用丢失,也能看到内容,便于做调整。
运行时的流程如下:
- 在
Awake
时向上找到视图类 - 反射从视图类中找到名称对应的中间件成员
- 反射找到组件的属性
- 对属性的
Getter
和Setter
生成对应的Delegate
- 将
Delegate
存入中间件
反射最慢的一步是GetField
和GetProperty
,因此实际代码中需要做缓存。
Delegate数据
不采用PropertyInfo.Get/SetValue
是因为它的返回值/参数都是object
类型,如果我们绑定的是值类型属性,每次赋值或取值都会发生装拆箱的GC,这是不好的。并且这个方法的效率不高。
而使用Delegate.CreateDelegate
可以生成MethodInfo
的delegate
。它类似于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
类似,是对AddListener
和RemoveListener
的泛型封装。
需要注意的是无参事件,在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__;...
}
通过反射找到所有的组件,整理出它们的属性的类型,生成对应的GetterSetter
和AddRemove
成员。
直接生成cs文件会导致Unity生成很多未使用成员警告,因此这里我选择用System.Reflection.Emit
生成一个dll文件。再使用Preserve
标签和link.xml
保证它不被代码裁剪忽略。
使用方法
完整项目的GitHub:https://github.com/RenChiyu/ComponentBind
为了提高自己的英语姿势水平,在尝试使用全英文编码,如果出现语法错误请勿大声嘲笑。
工作流程如下:
- 创建一个基于
BaseView
的视图类 - 在视图类中添加中间件成员
- 在编辑器选中视图根节点,挂载视图类
- 选中需要绑定的组件的
GameObject
,挂载ComponentPropertyBinder
或ComponentEventBinder
- 在绑定器中选择中间件成员,组件和属性或引用
- 保存
可以参考TestPanel.cs
,以及场景中以#开头命名的节点。
如果使用IL2Cpp,需要在打包前调用ComponentBindAOTCodeGenerator.Execute()
或点击菜单 -> TooSimpleFramework -> ComponentBind -> Generate AOT Code
。
这里只实现了比较简陋的功能,没有做一个中间件对应多个绑定器的判定。
实际使用中可以再写一个工具,根据GameObject
的名称自动设置要绑定的组件和属性或事件和视图类中的成员代码。每个人有每个人的做法,一键绑定工具就不公开了,具体实现留给读者。
后记
马上要过年了,大家2024过得好吗?在这里提前祝大家春节遇快,阖家欢洛,万似如意!
2024年我的简历上多了从研发到上线的项目经历,某四字战棋手游,具体名字就不说啦。还是小有成就感的。
但是去年最大的成就感是通过控制饮食将体重从118公斤降到了93公斤,继续加油昂。大家在工作的时候也要注意劳逸结合,身体才是革命的本钱。
很惭愧,就做了一点微小的工作,谢谢大家。