系列第 12 篇,适合有 Unity、C# 经验并准备扩展 UI 框架的开发者。目标是新增一个只读血条,不改Core、Navigator和生成器特判。示例沿用真实FUI接口,项目新增类、概念接口与未执行的测试都分别标明。
自定义 Element 的契约
先明确业务语义:战斗血量来自模型,血条只是展示,默认使用 OneWay。为了介绍扩展点而给血条增加“用户修改生命值”的反向通道,会让案例本身违背分层意图。下文是项目新增代码,沿用真实 FUI API,不是框架已有的 HealthBar 类型。
先写一个独立视觉组件。它不知道 ViewModel、Navigator 或绑定系统。将脚本保存为 HealthBar.cs,把 Image 拖到 fill 字段,Image 的显示类型设置为 Filled:
using UnityEngine;
using UnityEngine.UI;
public sealed class HealthBar : MonoBehaviour
{
[SerializeField] Image fill;
public float Value { get; private set; }
public void SetValueWithoutNotify(float value)
{
Value = Normalize(value);
if (fill != null) fill.fillAmount = Value;
}
public static float Normalize(float value)
{
if (float.IsNaN(value) || float.IsInfinity(value)) return 0f;
return Mathf.Clamp01(value);
}
}
这里的归一化是展示兜底,不是业务血量规则。最大生命值为零、无穷或非法输入如何记录,需要业务决定;本例用零显示,不能据此掩盖模型数据错误。
再写 HealthBarElement.cs。它只把视觉组件的能力转换成可绑定端点:
using FUI.Binding;
using FUI.Rendering.UGUI;
using UnityEngine;
[DisallowMultipleComponent]
[RequireComponent(typeof(HealthBar))]
public sealed class HealthBarElement : ComponentElement<HealthBar>
{
public BindableProperty<float> NormalizedValue { get; private set; }
protected override void OnInitialize()
{
base.OnInitialize();
NormalizedValue = CreateProperty(Component.Value,
(_, value) => Component.SetValueWithoutNotify(value));
}
}
ComponentElement 的 base.OnInitialize 会取得必需组件,缺失则抛 MissingComponentException,并登记组件引用的清理。CreateProperty 把 BindableProperty 登记到 ElementLifetime。因为没有用户输入事件,这个只读血条不需要自己加监听,也不用为了演示 TrackCleanup 而造一个事件。
ViewModel 使用与 Settings Sample 同样的可观察字段写法。下面保存为 HealthViewModel.cs,要求项目已接入 FUI 生成器;HealthPercent 属性由生成器产出:
using FUI.Binding;
using FUI.Presentation;
[ViewContract("HealthView")]
public partial class HealthViewModel : ViewModel
{
[ObservableProperty]
[Bind("Health", nameof(HealthBarElement.NormalizedValue))]
float healthPercent = 1f;
public void UpdateHealth(float current, float maximum)
{
var ratio = maximum > 0f ? current / maximum : 0f;
if (float.IsNaN(ratio) || float.IsInfinity(ratio)) ratio = 0f;
HealthPercent = System.Math.Max(0f, System.Math.Min(1f, ratio));
}
}
Prefab 的 Health 节点同时放 HealthBar 和 HealthBarElement,引用正确的填充 Image。把根页面按已有 Provider 的 AssetKey 规则登记为 HealthView,再走项目已初始化的 Route 打开流程;具体生成的 Routes 属性名应以生成文件为准。业务调用 UpdateHealth(35,100) 后预期显示 35%,不访问 GameObject。
不要只声明一个自动属性然后指望运行时修改总能被发现。案例同时用了 ObservableProperty,保证业务写入经过生成的通知属性。也不要直接改私有字段 healthPercent,绕开通知后画面不会凭空刷新。
一个容易漏掉的细节:BindableProperty 构造时保存初值,不立即执行回调。这里 Component.Value 是已有视觉状态,初值一致,所以可以作为端点初值;若你的组件自身初始字段与 Image 的实际填充值不一致,应先在视觉组件初始化时校准,而不是赌第一次绑定一定触发变化。
自己写框架时,很容易把“通用”理解成一个接口能处理所有东西。最后常见的结果是:IUIService 有几十个方法,所有类型都能注册,所有参数都是 object,遇到差异就加反射和回调。
真正可持续的通用性,不来自万能入口,而来自少数语义稳定、责任单一、可以独立验证的扩展点。
先给结论
UI 框架中最值得开放的通常是三类边界:
- Element:把具体控件能力适配成绑定端点;
- Provider:把资源获取与释放适配成 Lease;
- Transition/Presenter:把视觉过程与业务流程接入既有状态机。
扩展点必须遵守框架已有语义,不能获得修改 Navigator 内部集合、绕过 Lease 或自行决定生命周期的权限。
插件点与逃生口的区别
| 类型 | 特征 | 结果 |
|---|---|---|
| 插件点 | 输入输出明确、生命周期受控、可做契约测试 | 新实现仍保持框架推理能力 |
| 逃生口 | object、全局 ServiceLocator、任意回调 | 短期灵活,长期规则失效 |
判断一个扩展点是否健康,可以问:第三方实现出错时,框架能否知道它破坏了哪条契约?如果答案是否定的,它更像逃生口。
为什么需要 Element,而不是直接绑定 UGUI
若生成代码直接写:
UnityEngine.UI.Slider slider;
框架会把绑定语义和渲染控件绑定在一起。更换具体控件时,绑定代码就容易出现按控件类型判断的分支。不是说绑定到 UGUI 必然无法扩展,而是扩展成本会泄漏到生成与绑定层。TMP 与 Legacy 可以共享文本能力;UI Toolkit 则还需要另一套展示适配,不能仅换一个类名就宣称完成。
FUI 当前最小 IElement 只定义 Name 与 Parent,具体 Element 类型再暴露 Text、Value、事件等可绑定成员。UGUI 层使用包装器而不是让 Element 继承 Image、Button 等组件:
Element : MonoBehaviour, IElement
└─ RectTransformElement
├─ ComponentElement<TComponent>
├─ GraphicElement
└─ SelectableElement
这样绑定系统依赖 Element 暴露的属性与命令,Unity Component 仍只负责视觉和原生事件。
Element 应该多细
两个极端都不好:
- 每个 Unity 控件一个巨大接口,渲染细节泄漏;
- 一个
IElement.Set(string, object)万能接口,类型检查消失。
更稳妥的是按稳定能力形成层次:
TextElement
SelectableElement
InputFieldElement
DropdownElement
ComponentElement<TComponent>
公共绑定尽量依赖抽象 Element;只有确实需要某个后端独有能力时才依赖具体派生类。生成器按绑定目标的真实成员类型生成代码,不需要维护“支持控件名称列表”。
可输入控件才需要第二条方向
对可调音量滑块,用户输入需要通知模型,情况不同。FUI 当前 SliderElement 的关键顺序是:CreateProperty 回调调用 SetValueWithoutNotify;组件事件处理器先写 Value.Value,再调用 OnValueChanged.Invoke(value)。Command 用 Track 注册,原生监听用 TrackCleanup 移除。
以下摘录自当前 SliderElement,省略无关属性与完整类结构:
Value = CreateProperty(Component.value,
(oldValue, newValue) => Component.SetValueWithoutNotify(newValue));
OnValueChanged = Track(new Command<float>());
Component.onValueChanged.AddListener(OnSliderValueChanged);
TrackCleanup(() => Component?.onValueChanged.RemoveListener(OnSliderValueChanged));
// 独立方法,不是嵌在 OnInitialize 内的语句。
void OnSliderValueChanged(float value)
{
Value.Value = value;
OnValueChanged.Invoke(value);
}
“静默”指程序写组件不触发原生用户事件和业务 Command,不是说 BindableProperty.OnValueChanged 不通知。属性要通知,绑定才知道它改变了。还要测试越界值:若属性保存 2 而组件夹到 1,两个状态不一致;本例在 ViewModel 归一化,不能把所有数值约束都留给显示组件。
嵌套 View 为什么需要所有权边界
Prefab 中嵌套一个可复用组件时,父页面验证器可能继续递归,误把子 View 的 Element 当成自己的;父 BindingContext 也可能跨边界绑定内部节点。
FUI 当前运行时扫描的停止条件是节点存在实现 IContainerElement 的组件,不是去读 C# 的 ViewContract Attribute。InitializeElements 仍注册容器自身,但不再递归它的子节点;ViewValidator 也检查嵌套 View 是否被容器边界隔离。跨边界通信通过明确属性、命令或子 BindingContext,而不是依赖同名节点查找。
这能避免两个页面都有 Title 时发生隐式冲突。
动态列表为什么不应该塞进普通 Element
列表涉及 Item 创建、复用、可见范围、数据差量和 Cell 生命周期。把它简化成 IValueElement<IList<T>> 会丢失关键语义。
下面只是一种其他框架可考虑的概念接口,不是当前 FUI 的 IListViewAdapter API:
public interface IListViewAdapter<TItem>
{
void Reset(IReadOnlyList<TItem> items);
void Insert(int index, TItem item);
void RemoveAt(int index);
void Move(int from, int to);
}
这类 Reset/Move 接口不能直接当成 FUI 已有事件调用。当前 FUI 列表使用 Items、SelectedIndex、SelectedItem 与 TryGetBoundItemViewModel;RecyclingListElement 复用实例,ScrollListElement 组合滚动行为,文档明确本版本不提供视口虚拟化。未来扩展应沿现有数据与所有权契约推进,不要在生成器里添加特殊列表名称来假装性能问题解决了。
Provider 扩展点的边界
自定义 Provider 可以接 Resources、Addressables、AssetBundle 或测试内存对象,但必须满足:
- 成功返回完整 Lease;
- 失败不泄漏半成品;
- 取消后迟到结果会归还;
- Dispose 幂等;
- 不让业务层直接操作后端 Handle。
Provider 不应该:
- 修改 Navigator 历史;
- 决定页面 Layer;
- 调用 Binding/Presenter;
- 缓存页面业务状态。
资源实现可替换,导航语义必须稳定。
Transition 扩展点的边界
Transition 负责受 Navigator 管理的视觉过程。下面是通用化的概念接口;FUI 当前 ITransition 实际公开 IsPlaying、PlayAsync(CancellationToken) 与 Stop(),具体 View 由 ITransitionProvider.Get(IView) 选择:
public interface IViewTransition
{
ValueTask EnterAsync(UIView view, CancellationToken token);
ValueTask ExitAsync(UIView view, CancellationToken token);
}
它可以操作 CanvasGroup、Animator 或 Timeline,但不能自行把 Entry 标记为 Visible/Closed。状态提交仍由 Navigator 完成。
实现难点包括:
- Animator 状态名错误时不能永久等待;
- GameObject 禁用会让协程停止;
- 取消时需要决定跳到终点还是回滚起点;
- Time.timeScale=0 时是否使用 unscaled time;
- 销毁时等待器必须完成或取消。
这些都应形成 Transition 契约测试。
Presenter 为什么不是万能扩展点
Presenter 可以协调页面进入/退出、调用服务并更新 ViewModel,但不应直接拥有 View 的销毁权。
推荐依赖方向:
Navigator owns Presenter lifetime
Presenter depends on Services + ViewModel
BindingContext connects ViewModel + View
如果 Presenter 直接查找 Button、加载 Prefab、改历史栈,它就跨越了三条边界,框架很快失去一致性。
如何支持 UGUI 与 UI Toolkit
不要强行抽象所有视觉细节。共享的是:
- Route 与 ViewModel;
- Binding 描述和数据方向;
- Navigator 状态机;
- Provider/Lease 协议;
- Element 能力接口。
不共享的是:
- Transform/VisualTree 查找;
- Canvas sorting 与 PanelSettings;
- Animator/USS Transition;
- 具体控件事件。
这是可继续扩展的架构方向,不是宣称 FUI 已附带完整 UI Toolkit 后端。尤其本例 ViewModel 的 nameof(HealthBarElement.NormalizedValue) 指向项目的 UGUI 适配类型,这条具体声明仍有展示类型依赖。要跨展示层复用同一声明,需要抽取双方都能实现的端点契约,或分别提供 ViewContract/投影适配;只把 HealthBar 换成 VisualElement 不会自动完成迁移。
Source Generator 为什么不需要认识每一种 Element
错误做法是在生成器里维护 SliderElement、ToggleElement、HealthBarElement 的分支。那样每新增一个项目控件,都要修改并重新发布生成器。
FUI 的绑定声明给出目标 Element 路径和成员名,ContextInfoByAttributeGenerator.CreateTargetInfo 会寻找 nameof 调用与元素路径,读取成员访问左侧的类型符号、成员类型和 IBindableProperty 的泛型值类型,形成 ElementTargetDescriptor。这里需要的是可解析的 nameof(Element.Member) 形式,不是任意运行时拼出的成员名字。运行时 View 用路径和请求类型查询,未命中精确类型时再通过 IsAssignableFrom 匹配兼容基类端点。自定义 HealthBarElement.NormalizedValue 只要是合法的可绑定成员,就沿用同一条路径。生成器关注“成员是否兼容”,不关注“控件是不是官方内置”。
Prefab 中是否真的放置了唯一、类型正确的 HealthBarElement,则属于 Editor Validator 的事实边界。这样 Core、Generator 与项目扩展之间没有硬编码反向依赖。
常见坑点
坑一:公开 Navigator 内部 Entry
扩展可以任意改 State/Handle/Lease,所有不变量失效。只暴露只读上下文和受控操作。
坑二:ServiceLocator 作为唯一注入方式
依赖不可见且难测试。Route 工厂或项目级 Composition Root 应显式组装依赖。
坑三:为了兼容旧项目,核心接口全是 object
可以在边界提供适配器,不要让弱类型污染内部模型。
坑四:扩展点没有契约测试
接口编译通过不代表语义成立。尤其要测取消、重复 Dispose、静默赋值和异常回滚。
坑五:框架接管所有业务工具
红点、国际化、音频、网络不是 UI 导航核心。通过服务接入即可,不要让框架成为新的 GameManager。
如何判断一个新需求是否应进入框架
依次问:
- 它是否在三个以上页面重复出现?
- 它是否需要统一生命周期或所有权?
- 不统一会不会造成跨团队不一致?
- 能否定义稳定契约和失败语义?
- 是否能通过适配器留在业务层?
只有前四项大多为“是”,才值得进入框架核心。功能被多个项目使用,不等于必须写进同一个程序集。
可执行验证
- 同一组 Binding 测试可运行在 UGUI Element 和测试替身上。
- 自定义 Element 的静默赋值不触发用户事件。
- 自定义 Provider 任一步失败都不泄漏。
- Transition 取消、异常和对象销毁都能结束等待。
- 嵌套 View 不会被父 Validator 越界扫描。
- 第三方扩展无法直接修改 Navigator 内部状态。
- 移除某个可选渲染适配包后,核心程序集仍能编译。
这些是接入测试目标,不是已执行报告。第 13 篇继续讨论展示层替换,第 14 篇讨论验证,第 15 篇才收束到迁移。
错误示例:新增控件却修改了三处核心代码
下面是常见但不推荐的教学伪代码,不是 FUI 当前实现:
if (targetType == typeof(Slider)) EmitSliderBinding();
else if (targetType == typeof(Toggle)) EmitToggleBinding();
else if (targetType == typeof(HealthBar)) EmitHealthBinding();
// 业务又在页面打开后绕过绑定。
root.transform.Find("Health").GetComponent<HealthBar>()
.SetValueWithoutNotify(player.CurrentHp / player.MaxHp);
它在只有一个血条时很容易通过演示。后来项目加护盾、分段 Boss 血条或第三方控件,生成器就得一一升级;业务也开始各自查节点。某个 Prefab 改名,编译能过,进入特定战斗才失败。这里同时出现了“核心反向依赖项目类型”和“业务自行操作视觉对象”两类问题。
适配器并不是让一切自动正确,而是把差异收进 HealthBarElement。换视觉实现时只改这个项目适配,绑定语义、Navigator 和生命周期保持原样;若新增能力已经超出绑定端点契约,仍然需要评估框架扩展,不应为了标题里的开放封闭拒绝必要演化。
清理为什么要登记,而不是各自写 OnDestroy
Element.InternalInitialize 先创建 ElementLifetime,再初始化基础属性并调用 OnInitialize。成功后进入 Initialized;失败会释放已登记资源并重置状态。InternalRelease 先调用 OnRelease,finally 再释放 lifetime。这里的状态为 Uninitialized、Initializing、Initialized、Releasing,区别于绑定上下文自己的状态机。
ElementLifetime 反向遍历清理动作,每项分别 try/catch 记录错误,并清空列表。这个顺序让后登记的原生事件退订先执行,再清理 Command、Property,最后清空较早登记的 Component 引用。重复释放有幂等保护。
下面是错误的扩展写法,假设 CustomWidget 是项目的有事件组件:
protected override void OnInitialize()
{
base.OnInitialize();
Component.Changed += HandleChanged;
LoadRequiredStyle(); // 抛异常后,下方 OnRelease 不一定完成你的手写退订
}
void OnDestroy() => Component.Changed -= HandleChanged;
失败的是初始化事务,不一定马上销毁 GameObject。只依赖 OnDestroy 清理,就把错误窗口拖到对象最终销毁;如果对象保留用于再次尝试,还会叠加旧监听。应在成功注册监听后紧接着 TrackCleanup,再执行可能失败的步骤。若组件提供自定义 event add 且 add 本身可能部分成功后抛异常,还要由该组件契约定义如何恢复,框架无法替未知副作用保证回滚。
同样,CreateProperty 只清理被登记的属性。偷偷创建的 CancellationTokenSource、订阅或 Lease 不会因为放在 Element 字段里就被自动发现。Track 不是垃圾回收器,它是显式所有权登记。
最小验证:先测值域,再测真实绑定与释放
下面 NUnit 测试配合前面的 HealthBar.cs,可放入项目测试程序集。它只证明归一化规则,不证明 Prefab、生成代码与实际显示正确;本次未执行 Unity 测试。
using NUnit.Framework;
public sealed class HealthBarValueTests
{
[TestCase(-0.2f, 0f)]
[TestCase(0.35f, 0.35f)]
[TestCase(1.8f, 1f)]
public void NormalizesFiniteValues(float input, float expected)
{
Assert.That(HealthBar.Normalize(input), Is.EqualTo(expected));
}
[Test]
public void InvalidNumbers_UseExplicitDisplayFallback()
{
Assert.That(HealthBar.Normalize(float.NaN), Is.EqualTo(0f));
Assert.That(HealthBar.Normalize(float.PositiveInfinity), Is.EqualTo(0f));
}
}
第二层可以用真正的 BindableProperty 验证通知边界,无需伪造一个同名API:
using FUI.Binding;
using NUnit.Framework;
public sealed class ElementEndpointTests
{
[Test]
public void EqualValue_DoesNotWriteAgain_AndDisposeDetachesCallback()
{
var writes = 0;
var rendered = 1f;
var endpoint = new BindableProperty<float>(1f, (_, value) =>
{
writes++;
rendered = value;
});
Assert.That(writes, Is.EqualTo(0)); // 构造不执行回调
endpoint.Value = 0.35f;
endpoint.Value = 0.35f;
Assert.That(writes, Is.EqualTo(1));
Assert.That(rendered, Is.EqualTo(0.35f));
endpoint.Dispose();
endpoint.Value = 0.8f;
Assert.That(writes, Is.EqualTo(1));
}
}
最后一次赋值只是记录当前 Dispose 清空回调的实现行为,不是鼓励使用已释放属性:BindableProperty 没有因此变成“每次使用都抛 ObjectDisposedException”的对象。测试应提醒调用方不再持有和传播旧端点。
接着进行真正的最小闭环:
- 编译项目,确认 HealthViewModel 的可观察属性与绑定上下文生成,生成器没有为 HealthBar 加任何特判。
- 校验 Prefab:Health 名称、HealthBarElement 类型、必需组件、填充 Image 引用齐全。
- 用项目 Provider 打开 HealthView 对应 Route,调用 UpdateHealth(35,100),检查填充值为0.35;同值重复写入不多触发视觉更新。
- 关闭后重新打开,确认没有重复监听;若进入缓存,不要把关闭等同 Element 已销毁,绑定寿命与 Element 寿命要分别记录。
- 对带用户事件的另一个测试控件,在订阅后故意抛初始化异常,确认事件监听归零;再重复初始化,确认只剩一套监听。
- 在容器内另放同名 Health,确认父 View 不跨 IContainerElement 边界绑定子节点。
这些步骤包含失败场景,而不只是“进场景看一眼”。真正降低接入门槛的,是新开发者能照同一份契约验证扩展,而不是只有框架作者知道哪些回调不能碰。
为什么不直接用接口、继承或反射
手写业务直接持有 HealthBar 最短,适合一次性原型,但访问 GameObject 的能力也随之扩散。给每个控件定义 IHealthBar、IShieldBar 可以清楚表达能力,代价是接口与绑定适配数量增长,需要决定哪些能力足够稳定。反射加字符串最灵活,却把成员错误留到运行时,还要处理类型转换与清理。
FUI 的选择是最小 IElement 寻址协议,加具体端点类型与生成绑定。代价是必须有合规 Prefab、生成器能解析的声明,以及项目自己实现的视觉适配。它不把所有控件变成同一个接口,也不宣称完全禁止任何绕过;而是让正常业务路径只面对 ViewModel 与受控导航,展示细节留在少数适配器中。
从源码推断,这种设计同时服务于易用性、团队一致性和测试。扩展者仍需要懂组件事件和生命周期,但这份知识集中在一个可复用 Element 中,不需要每个业务页面重复掌握。新增血条不改核心,是分工清楚后的结果,不是单纯少改几个文件。
资料与源码索引
- FUI:Custom Elements
- FUI:Resources
- FUI:Presenter
- FUI:Element.cs
- FUI:ElementLifetime.cs
- FUI:RectTransformElement.cs
- FUI:SliderElement.cs
- FUI:View.Elements.cs
- FUI:ContextInfoByAttributeGenerator.cs
- FUI:BindableProperty.cs
先让最小血条显示35%,再测相同值、越界输入、关闭复用和初始化失败。把这些证据补齐,扩展才不只是一次演示。