鸿蒙编辑器框架的属性引擎:声明元数据,UI 自动生成
一、三份硬编码的税
先看重构前的账本。每接入一个带可编辑属性的官方插件,需要改三个地方:
| 位置 | 干什么 | 举例(图片插件) |
|---|---|---|
BlockActionBottomMenu.ets |
硬编码一个 UI 分支:渲染这个插件的属性按钮 | if (type === 'image') { 渲染四个布局按钮 } |
PlaygroundEditor |
硬编码一个路由分支:把按钮点击分发到对应处理 | handleBlockTypeSelect() 里加 image 分支 |
PlaygroundController |
写一个专属变更方法 | updateImageLayout(blockId, widthMode, aspectRatio) |
三个地方写的是同一件事的三种转译:UI 长什么样、事件怎么路由、属性怎么改。Callout 插件再来一遍,Card 再来一遍。RFC-0004 算了一笔账:后续排队的还有 Table、Mermaid、Bookmark、Formula、AI、Code——重复代码随插件数量线性增长,而且每一份都是"UI 耦合插件细节"的违规接触:Action Sheet 本不该知道图片有两个属性。
重构后的目标形态是一条管道:
之前:Plugin → 手写 Action Sheet 分支 → 手写按钮 → 手写控制器方法
之后:Plugin → 属性元数据声明 → Attribute Engine → Action Sheet 自动生成
插件声明元数据,框架拥有呈现。插件的 UI 成本从 O(1) 的三处硬编码,变成 O(0) 的零行 UI 代码。
二、两种声明,覆盖两类需求
属性引擎的类型设计只有两个核心接口(schema/AttributeSpec.ts),对应两种截然不同的编辑场景:
场景一:一个属性,多个取值。 Callout 的 variant 有四个语义值(info / success / warning / error),用户点选其一。这是 AttributeSpec:
export interface AttributeSpec {
propName: string; // ★ 必须引用 propSchema 已声明的 key
displayName: string; // Action Sheet 里的分区标题
type: AttributeType; // 'enum' | 'boolean' | 'number' | 'string'
options?: AttributeValueOption[]; // enum 的候选项(值 + 标签 + 图标)
}
Callout 插件的真实声明(第 06 篇见过,这里看属性侧):
attributes: [{
propName: 'variant',
displayName: '变体',
type: 'enum',
options: [
{ value: 'info', label: '提示', icon: '💡' },
{ value: 'success', label: '成功', icon: '✅' },
{ value: 'warning', label: '警告', icon: '⚠️' },
{ value: 'error', label: '错误', icon: '❌' },
],
}],
场景二:几个属性,绑死为一组。 图片的布局由 widthMode(half/full)和 aspectRatio(3:4 / 4:3 / 2:1)两个属性共同决定。如果让用户分别切换,会出现"全宽 + 3:4 竖图"这种没人设计过的中间态——两个属性的合法组合只有四个预设。这是 AttributeGroupSpec:
export interface AttributeGroupSpec {
groupName: string;
displayName: string; // 分区标题
propNames: string[]; // 一起变的属性
combinations: AttributeGroupCombination[]; // 预设组合
}
一个容易被忽略的设计要点:组合是"白名单预设",不是"笛卡尔积"。widthMode 两种取值 × aspectRatio 三种取值,理论上六种组合,实际只声明四种——{half, 2:1}(半宽超宽横幅)这种荒谬组合从声明的层面就不存在。属性白名单(第 05 篇的 values)管单个属性的合法性,组合白名单管属性搭配的合法性,两层各司其职。用户看到的永远是设计过的选项,校验器拦住的一切越界都不需要 UI 操心。
顺带一个迁移期的诚实观察:ImageBlockSpec 里 layoutOptions(旧机制)与 attributeGroups(新机制)同时存在——类型注释明确写着前者将被后者取代。框架迁移的老原则又一次生效:新旧并存,每一步可运行。
三、引擎本体:88 行的无状态查询层
AttributeEngine 全部代码做的事,用注释里的一句话说就是"查询与匹配",而且无状态:
export class AttributeEngine {
// 所有状态都在 SchemaRegistry(声明)和块自己的 props(当前值)里,
// 引擎不缓存、不拷贝任何东西
hasAttributes(blockType): boolean // 有没有可编辑属性
getAttributes(blockType): AttributeSpec[] // 单属性声明列表
getAttributeGroups(blockType): AttributeGroupSpec[] // 属性组声明列表
isAttributeOptionActive(currentProps, attr, option): boolean {
return currentProps[attr.propName] === option.value; // 单值比对
}
isGroupCombinationActive(currentProps, combination): boolean {
for (const key of Object.keys(combination.values)) { // 逐 key 比对
if (currentProps[key] !== combination.values[key]) return false;
}
return true;
}
getActiveCombinationIndex(currentProps, group): number // 当前激活的预设下标,-1 表示无
}
"无状态"在这里是个郑重的设计承诺,不是随手一说:引擎不持有任何块的引用、不缓存任何查询结果,每次调用都是"读声明 + 比当前值"的纯函数。它因此天然规避了一整类问题——属性被事务改掉之后引擎里的缓存变陈旧、块被删之后引擎里的引用悬空。状态只有两处:SchemaRegistry 管声明(第 05 篇),块的 props 管当前值(第 02 篇),引擎只是两者之间的桥。
另一个细节:getActiveCombinationIndex 返回 -1 表示"没有预设处于激活态"。这不是异常——用户完全可能通过别的方式(比如 AI 写入)把属性改成非预设组合,UI 此时应表现为"全部按钮未选中",而不是崩溃或强行归一。查询层不评判状态合法性,只如实报告。(真正的非法值早在事务校验层就被白名单拦下了,能到达 UI 的 props 一定满足 propSchema,只是未必命中某个组合预设。)
四、消费端:一个通用渲染路径替换三个硬编码面板
Action Sheet(BlockActionBottomMenu.ets)在属性引擎接入后的文件头注释里,有一句 PR 级别的战报:
Attribute sections are now generated automatically from BlockSpec attributes and attributeGroups. Plugins only declare metadata; the framework owns presentation. The three hardcoded panels (image/card/callout) have been replaced by a single unified rendering path.
组件通过 @Prop 接收纯数据——blockAttributes: AttributeSpec[]、blockAttributeGroups: AttributeGroupSpec[]、currentProps——加上一个回调 onAttributeChange(props)。渲染循环遍历声明:每个 AttributeSpec 生成一组分段按钮(option 激活态由当前 props 比对决定),每个 AttributeGroupSpec 生成一行预设芯片。**组件里没有任何 if (type === 'image')。**用户点任何按钮,统一的 onAttributeChange 带着目标 props 冒泡,UI 对"这是哪个插件的什么属性"保持无知。
这里要做一个如实的工程现状披露:框架侧的 AttributeEngine 类已就位,但演示层的菜单组件目前内联了与引擎相同的判定逻辑(私有的 isGroupCombinationActive 等),尚未改为调用引擎实例——全文检索 AttributeEngine 只有它自己的定义文件一个引用。功能上无差(逻辑一致),工程上属于"引擎与首个消费者的接线"待办。把它写出来,是因为这个状态本身就是框架开发的真实纹理:先立契约(类型 + 引擎),消费端逐步切换,比"UI 和引擎同时改、一次上线"风险小得多。
五、变更回流:一条通用的写路径
用户点选之后发生什么?控制器提供的是一个通用方法,不再有 per-plugin 变体:
updateBlockAttributes(blockId: string, props: Record<string, PropValue>): void {
const block = this.document.getBlock(blockId);
if (block === undefined) return;
let hasChange = false; // 先比对:全部相同就不开事务
for (const key of Object.keys(props)) {
if (block.props[key] !== props[key]) { hasChange = true; break; }
}
if (!hasChange) return;
const tx = this.txnManager.begin(); // 单个 updateBlock 操作,单事务
tx.addOperation({ type: 'updateBlock', id: blockId, patch: { type: block.type, props } });
try {
this.txnManager.commit(tx);
this.notifyPropsChange(); // 属性级通知(不触发结构重建)
this.notifyDocumentChange();
} catch (_err) { /* 记录,不向上抛 */ }
}
四个细节都有出处:
- no-op 跳过——值全相同就不开事务,避免空转污染历史(第 09 篇讲文本同步时会看到同一个哲学);
- 一个
updateBlock操作、一个事务——原子性、校验(props 会过第 05 篇的三道关卡)全部继承; notifyPropsChange而非结构通知——属性变更不递增渲染版本号,列表不重建(这个双通道设计的完整故事是第 08 篇的主角);- 按 RFC-0004 的数据流设计,属性变更经由事务后,撤销/重做、持久化、剪贴板应当自动获得——呼应第 04 篇的"撤销单位 = 事务";同时沿用那篇的披露:直连事务管理器的提交路径,其入史接线仍在待办清单上。
旧的 updateImageLayout / updateCardLayout / updateCalloutVariant 三个方法如今都退化为一行委托——通用路径铺好之后,专属方法只剩历史包袱的价值。
六、更大的图景:静态编译平台上,元数据驱动几乎是唯一解
属性引擎值得放进更大的语境里看。第 05 篇讲过"Schema 即 UI 单一来源"(菜单聚合),本篇是同一条路线的延伸(属性面板生成)。在 Web 平台,元数据驱动 UI 只是一种优雅选择——反正组件可以动态拼;但在 ArkUI 的静态编译世界里,它近乎唯一解:
- 组件不能运行时动态加载(第 06 篇讲过渲染分类拆分的同一约束);
- 通用渲染路径 + 数据驱动,是"插件自定义 UI"在静态平台上唯一不需要改框架代码的形态;
- 声明全部落在编译期可检查的
BlockSpec字段里,propName必须引用propSchema已声明的 key——声明本身的合法性也被类型系统看住。
这给"框架先行"提供了一个可复制的判断标准:**当平台砍掉了动态性,就把动态性转移到数据里。**属性引擎、菜单聚合、渲染分类,三件事共享同一个思想内核。
七、小结
- 从三处硬编码到零行 UI:插件声明属性元数据,框架拥有呈现权;插件的 UI 成本从线性增长变为常数。
- 两种声明对应两类场景:
AttributeSpec管单属性多值(Callout 变体),AttributeGroupSpec管多属性原子组合(图片布局)——组合是白名单预设,不是笛卡尔积,荒谬搭配从声明层就不存在。 - 引擎无状态:声明在 SchemaRegistry、当前值在块 props,88 行纯查询;
-1表示"无预设激活"是合法状态而非异常。 - 变更走通用写路径:no-op 跳过 + 单操作单事务 + 属性级通知;旧专属方法退化为委托。
- 诚实披露:引擎类已就位,演示组件暂以内联判定逻辑运行,接线待统一;
layoutOptions与attributeGroups新旧并存于迁移期。
下一篇进入渲染层——notifyPropsChange 与结构通知的双通道设计、ForEach 的 key 陷阱,以及那个"修好闪烁就丢样式"的真机悬案。
下一篇:《鸿蒙编辑器框架的渲染层:注册表分发、局部刷新与 ForEach 的坑》——RenderCategory 四类分发、RendererDiff 六列表的精确受影响集合,以及真机上用闪烁换来的两条通知通道。
更多推荐





所有评论(0)