一、三份硬编码的税

先看重构前的账本。每接入一个带可编辑属性的官方插件,需要改三个地方:

位置 干什么 举例(图片插件)
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) { /* 记录,不向上抛 */ }
}

四个细节都有出处:

  1. no-op 跳过——值全相同就不开事务,避免空转污染历史(第 09 篇讲文本同步时会看到同一个哲学);
  2. 一个 updateBlock 操作、一个事务——原子性、校验(props 会过第 05 篇的三道关卡)全部继承;
  3. notifyPropsChange 而非结构通知——属性变更不递增渲染版本号,列表不重建(这个双通道设计的完整故事是第 08 篇的主角);
  4. 按 RFC-0004 的数据流设计,属性变更经由事务后,撤销/重做、持久化、剪贴板应当自动获得——呼应第 04 篇的"撤销单位 = 事务";同时沿用那篇的披露:直连事务管理器的提交路径,其入史接线仍在待办清单上。

旧的 updateImageLayout / updateCardLayout / updateCalloutVariant 三个方法如今都退化为一行委托——通用路径铺好之后,专属方法只剩历史包袱的价值。

六、更大的图景:静态编译平台上,元数据驱动几乎是唯一解

属性引擎值得放进更大的语境里看。第 05 篇讲过"Schema 即 UI 单一来源"(菜单聚合),本篇是同一条路线的延伸(属性面板生成)。在 Web 平台,元数据驱动 UI 只是一种优雅选择——反正组件可以动态拼;但在 ArkUI 的静态编译世界里,它近乎唯一解

  • 组件不能运行时动态加载(第 06 篇讲过渲染分类拆分的同一约束);
  • 通用渲染路径 + 数据驱动,是"插件自定义 UI"在静态平台上唯一不需要改框架代码的形态;
  • 声明全部落在编译期可检查的 BlockSpec 字段里,propName 必须引用 propSchema 已声明的 key——声明本身的合法性也被类型系统看住。

这给"框架先行"提供了一个可复制的判断标准:**当平台砍掉了动态性,就把动态性转移到数据里。**属性引擎、菜单聚合、渲染分类,三件事共享同一个思想内核。

七、小结

  1. 从三处硬编码到零行 UI:插件声明属性元数据,框架拥有呈现权;插件的 UI 成本从线性增长变为常数。
  2. 两种声明对应两类场景AttributeSpec 管单属性多值(Callout 变体),AttributeGroupSpec 管多属性原子组合(图片布局)——组合是白名单预设,不是笛卡尔积,荒谬搭配从声明层就不存在。
  3. 引擎无状态:声明在 SchemaRegistry、当前值在块 props,88 行纯查询;-1 表示"无预设激活"是合法状态而非异常。
  4. 变更走通用写路径:no-op 跳过 + 单操作单事务 + 属性级通知;旧专属方法退化为委托。
  5. 诚实披露:引擎类已就位,演示组件暂以内联判定逻辑运行,接线待统一;layoutOptions 与 attributeGroups 新旧并存于迁移期。

下一篇进入渲染层——notifyPropsChange 与结构通知的双通道设计、ForEach 的 key 陷阱,以及那个"修好闪烁就丢样式"的真机悬案。

下一篇:《鸿蒙编辑器框架的渲染层:注册表分发、局部刷新与 ForEach 的坑》——RenderCategory 四类分发、RendererDiff 六列表的精确受影响集合,以及真机上用闪烁换来的两条通知通道。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐