FlexUI 自定义组件命名插槽(Named Slots)设计方案
目标读者:需要为 FlexUI 自定义组件(原生渲染路径)实现
slot="button"命名插槽能力的开发者。 关联文档:flexui-component-extension-guide.md(自定义组件扩展指南)、flexui-engine.md、flexui-core.md
1. 背景与现状
1.1 场景
自定义组件(如 collapsible-view)需要把调用方传入的子节点按插槽名分发到组件内部的不同位置:
<collapsible-view>
<view slot="button">
<!-- 自定义展开按钮 -->
</view>
<view>
<!-- 默认内容 -->
</view>
</collapsible-view>
1.2 现状:不支持命名插槽(结论)
对 slot="button" 按数据流逐层排查,原生渲染链路只支持单默认插槽,不支持命名插槽分发:
| 层 | 位置 | 支持 | 说明 |
|---|---|---|---|
| 模板编译 | toolkit/flexui-template-compiler/src/parse/tag.rs、stringify/tag.rs |
✅ | 解析 slot 属性,生成 elem.slot = 'button'(proc_gen_wrapper.ts:1103) |
| 核心框架 | packages/flexui/src/element.ts:265-315 |
✅ | slot setter 运行完整分配逻辑(SlotMode Single/Multiple/Dynamic、containingSlot、slotNodes),回调后端 setSlot(element.ts:275) |
| 原生 JS 后端 | flexui-backend/.../NativeBackendElement.ts:1028-1031 |
❌ | setSlot/setSlotName/setSlotElement/setInheritSlots 全部是 no-op 空实现;NativeBackendShadowRoot.createComponent 忽略 _slotMode 参数(NativeBackendShadowRoot.ts:80) |
| C++ DOM/渲染 | flexui-engine/dom/(无 slot 代码)、custom_ts_view.cc |
❌ | CustomTsView 只有一个 contentHandle_ + contentNode_,子节点按序 InsertChild(custom_ts_view.cc:105),无 slot 概念 |
| ArkTS 渲染 | FlexUICustomComponentView.ets:9 |
❌ | childSlot 是单一 NodeContent;struct 里 ContentSlot(childSlot) 或 ForEach(children) 全量渲染(FRBadgeView.ets:80-87) |
根因:glass-easel 在 JS 层已经把插槽分配算好了(containingSlot/slotNodes/_$slotName 都在维护),但原生后端拒绝消费 —— 四个回调接口被置空,且 elem.slot = 'button' 走属性 setter、不写 attributes(element.ts:265-315 vs 2511-2545),所以 slot 名在原生链路中完全不可见(buildProps 里不会有 slot prop,NativeBackendElement.ts:1557)。
实际表现:<view slot="button"> 会作为普通子节点进入唯一的 childSlot,只能出现在"默认内容区",组件内部无法区分插槽。
1.3 设计目标
在不改核心框架(packages/flexui)、不改模板编译器的前提下,为自定义组件(CApi 原生渲染路径)提供:
- 命名插槽:
<view slot="button">的内容可被自定义组件按名取用,渲染到指定位置。 - 默认插槽:无
slot属性(或slot="")的子节点进默认内容区,行为与现状一致。 - 向后兼容:未声明插槽的旧组件(BadgeView 等)零改动,行为不变。
- 动态重分配:子节点插槽变化(运行期)时,渲染层跟随重路由。
1.4 非目标(第一版)
- 动态 slot(
SlotMode.Dynamic):运行期通过setSlotElement绑定任意 slot 名。第一版只支持模板写死的静态命名插槽。 <slot>标签 + slotValues(<slot name="x" value="...">作用域插槽):涉及 slot 值传递,后续单独评估。- 非 CApi 路径(
enableArkCApi=false或 Android):列为可选增强(见 §9 里程碑 M5)。 - 组件间数据读取("从子组件获取信息"若指读取子组件状态):属于组件通信范畴(事件 + properties),与内容分发语义不同,不在此方案内。
2. 核心设计决策
| # | 决策 | 理由 |
|---|---|---|
| D1 | slot 分配在 JS 层完成(复用 glass-easel 现有逻辑),原生层只消费"分配结果" | element.ts 的 slot 机制已在运行,零新增分配逻辑;避免在 C++/ArkTS 重实现一套 |
| D2 | 分配结果以 __slotMap prop 挂在父节点(自定义组件节点) 下发:{ slotName: [childDomId...] } |
信息聚合一处;slot 重分配只更新父节点一个 prop;与现有 props 通道(createNodeWithId / updateNode)天然兼容 |
| D3 | C++ CustomTsView 按 slot 名维护多个 contentNode_(默认 '' = 现有 contentNode_),子节点挂载时按 childTag → slotName 路由 |
渲染通道改动最小;每个命名槽对应一个 ArkUI 容器节点 |
| D4 | ArkTS 侧为命名槽创建多个 NodeContent,注入 FlexUICustomComponentView.slotContents: Map<string, NodeContent> |
ContentSlot 是 ArkTS 声明式挂载点,组件 struct 按名取用,等价于 <slot name="button"> 占位 |
| D5 | 插槽声明驱动:ComponentAdapter.slots(JS)+ FlexUIComponentExtension.slotDeclarations(ArkTS)声明组件支持的 slot 名 |
ArkTS 侧据此预创建 NodeContent;struct 里 ContentSlot 数量编译期确定(ArkTS 声明式限制) |
| D6 | __slotMap 走 UpdateCustomTsProps 的 C++ 白名单通道(fr_view_manager.cc:694-696) |
该通道已是"CustomTsView 收到 C++ 侧 props"的唯一路径,仅需在条件里追加一个 key |
3. 总体架构
HXML: <collapsible-view><view slot="button">X</view><view>Y</view></collapsible-view>
│
▼ [模板编译] 已支持:elem.slot = 'button'(proc_gen_wrapper.ts:1103)
▼ [核心框架] 已支持:element.slot setter 完成分配,回调 backend.setSlot(element.ts:275)
│
▼ [JS 层 flexui-backend] —— 本次新增(§4.1)
│ setSlot(name) 记录 slotName(原 no-op)
│ 父节点聚合 __slotMap = { 'button': [3], '': [4] }
│ FlexUIBridge.updateNode(parentId, pId, { __slotMap: JSON })
│
▼ [C++ 层] —— 本次新增(§4.3)
│ UpdateCustomTsProps 白名单放行 __slotMap(fr_view_manager.cc:694-696)
│ CustomTsView::SetViewProp 解析 → childSlotMap_(childTag → slotName)
│ OnChildInsertedImpl 按 slotName 路由到对应 contentNode_(多槽容器)
│
▼ [ArkTS 层] —— 本次新增(§4.4)
│ createRenderViewForCApi 按 slotDeclarations 预建多个 NodeContent
│ FlexUICustomComponentView.slotContents: Map<string, NodeContent>
│ struct: ContentSlot(slotContents.get('button')) ← 命名槽占位
└─ 上屏渲染
4. 分层设计
4.1 JS 层(flexui-backend)
文件:flexui-engine/driver/js/packages/flexui-backend/src/native-backend/NativeBackendElement.ts
4.1.1 setSlot 实现(原 no-op,NativeBackendElement.ts:1028)
/** slot 名。由 glass-easel 的 element.slot setter 在插槽分配/变化时回调(element.ts:275) */
slotName: string = ''
setSlot(name: string): void {
if (this.slotName === name) return
const old = this.slotName
this.slotName = name
// 已挂载(有父节点)时,通知父节点重算 slotMap 并下发
if (this.parentDomNodeId !== undefined && this._isMounted) {
this._notifyParentSlotMapChanged()
}
}
_isMounted/parentDomNodeId是现有字段(appendChild 时维护,NativeBackendElement.ts:93-121)。setSlot可能在节点挂载之前被调用(模板创建节点时即设置 slot 属性),此时只记录slotName,挂载时由父节点统一重算。
4.1.2 父节点聚合 slotMap
在父节点(NativeBackendElement)上新增:
/** 是否声明了插槽的自定义组件(ComponentAdapter.slots 非空) */
private get _isSlotHost(): boolean {
const adapter = getComponentAdapter(this.tagName)
return !!(adapter && adapter.slots && adapter.slots.length > 0)
}
/** 重算本节点的 __slotMap:slotName → 子节点 domNodeId 数组(保序) */
private _computeSlotMap(): Record<string, number[]> {
const map: Record<string, number[]> = {}
for (const child of this._childOrder) { // 现有保序子节点数组
const name = child.slotName || ''
;(map[name] ??= []).push(child.domNodeId)
}
return map
}
/** 子节点集合/slot 变化后调用:仅对插槽宿主下发 */
private _syncSlotMap(): void {
if (!this._isSlotHost || this.domNodeId === undefined) return
FlexUIBridge.updateNode(this.domNodeId, this.parentDomNodeId ?? 0, {
__slotMap: JSON.stringify(this._computeSlotMap()),
}, this.nativeName, this.tagName, this._context?.rootViewId)
}
调用点(3 处,全部在现有方法内追加一行):
| 调用点 | 现有方法 | 时机 |
|---|---|---|
| 子节点挂载后 | appendChild(~line 465 createNodeWithId 之后) |
子节点进入 C++ 树后下发 |
| 子节点移除后 | removeChild |
从 slotMap 剔除 |
| 子节点 slot 变化 | setSlot → _notifyParentSlotMapChanged |
运行时重分配 |
private _notifyParentSlotMapChanged(): void {
const parent = this._context?.findElementByDomNodeId(this.parentDomNodeId)
if (parent) parent._syncSlotMap()
}
findElementByDomNodeId为NativeBackendContext现有接口(NativeBackendContext.ts:253)。
仅当 adapter 声明了
slots才计算下发,避免给普通 view/text 节点产生__slotMap噪音。未声明时slotName恒为'',等价现状。
4.1.3 组件声明扩展
flexui-engine/driver/js/packages/flexui-backend/src/adapters/ComponentAdapter.ts:
export interface ComponentAdapter {
// ...现有字段
/** 声明的命名插槽名列表(不含默认槽 '')。声明后该组件启用 __slotMap 下发 */
slots?: string[];
}
注册示例(flexui-component-ext/.../components.js):
registerComponentAdapter('collapsible-view', {
name: 'CollapsibleView',
slots: ['button', 'header'],
// ...
});
4.2 协议:__slotMap
形态:父节点 props 里的一个 JSON 字符串 key。
// __slotMap 值(JSON 序列化后的字符串)
{"button":[3,7],"header":[9],"":[4,5,6]}
- key = slot 名,
""表示默认槽; - value = 该槽位下的子节点
domNodeId数组,保持子节点顺序; - 整体
JSON.stringify后作为单个 prop 传递(与现有 props 通道兼容,C++ 侧FRValueUtils::GetString可直接读取)。
下发通道:FlexUIBridge.updateNode(id, pId, props, name, tagName, rootId)(FlexUIBridge.ts:492-508),复用现有 queue + flushQueue 批量机制。
4.3 C++ 层
文件:flexui-engine/framework/ohos/src/main/cpp/impl/renderer/native/
include/renderer/components/custom_ts_view.hsrc/components/custom_ts_view.ccsrc/uimanager/fr_view_manager.cc
4.3.1 props 白名单放行(fr_view_manager.cc:694-696)
UpdateCustomTsProps 中把 __slotMap 加入 C++ 侧 SetProp 白名单:
if (key == FRNodeProps::VISIBILITY || key == FRNodeProps::TRANSFORM || key == FRNodeProps::OVERFLOW ||
key == "native-scroll-ohos" || key == "attachedtowindow" || key == "detachedfromwindow" ||
key == "__slotMap") { // ← 新增
customTsView->SetProp(key, prop_it->second);
}
该函数是 CustomTsView 所有 props(初始创建 + 增量更新)通往 C++ 自身处理的唯一通道,追加白名单即可全覆盖。ArkTS 侧
updatePropsForCApi收到的 props 里也会带__slotMap,FlexUIRenderBaseView.setProp未识别时走super忽略,无害。
4.3.2 CustomTsView 多槽容器
custom_ts_view.h 新增成员:
// childTag → slotName 路由表("__slotMap" 解析结果)
std::unordered_map<uint32_t, std::string> childSlotMap_;
// slotName → 内容容器节点;"" 键指向现有 contentNode_(默认槽)
std::unordered_map<std::string, std::shared_ptr<ArkUINode>> slotContentNodes_;
// slotName → NodeContent handle(ArkTS 预建,CreateArkUINodeImpl 时注册)
std::unordered_map<std::string, ArkUI_NodeContentHandle> slotContentHandles_;
custom_ts_view.cc 改动:
-
CreateArkUINodeImpl:注册默认槽后,遍历slotContentHandles_为每个命名槽创建StackNode容器并OH_ArkUI_NodeContent_AddNode(复用现有 contentNode_ 的创建参数:SetPosition(0,0)、SetAlignment(TOP_START)、SetHitTestMode(NONE))。 -
SetViewProp(现在处理native-scroll-ohos)新增分支:
if (propKey == "__slotMap") {
const std::string &json = FRValueUtils::GetString(propValue);
// 解析 {"button":[3,7],"":[4,5,6]} → childSlotMap_[3]="button" ...
ParseSlotMap(json, childSlotMap_);
// 对 __slotMap 里出现、但 slotContentHandles_ 缺失的 slot 名,回调 ArkTS 动态创建
EnsureSlotContents(tag_);
// 已挂载子节点按新表重路由
ReRouteChildren();
return true;
}
OnChildInsertedImpl(custom_ts_view.cc:100-106)按 slot 路由:
void CustomTsView::OnChildInsertedImpl(std::shared_ptr<BaseView> const &childView, int32_t index) {
BaseView::OnChildInsertedImpl(childView, index);
const std::string &slotName = LookupSlotName(childView->GetTag()); // childSlotMap_,缺省 ""
auto it = slotContentNodes_.find(slotName);
if (it == slotContentNodes_.end()) {
it = slotContentNodes_.find(""); // 兜底默认槽
}
it->second->InsertChild(childView->GetLocalRootArkUINode(), index);
}
-
OnChildRemovedImpl(custom_ts_view.cc:108-111)同理按 slot 路由RemoveChild。 -
ReRouteChildren():__slotMap更新时,对children_里已挂载的子节点做 diff —— 旧槽移除、新槽按序插入。slot 变化低频,第一版全量重挂即可(先RemoveChild全部,再按childSlotMap_顺序InsertChild),后续可优化为增量 diff。 -
EnsureSlotContents(uint32_t tag):对childSlotMap_中出现但slotContentHandles_缺失的 slot 名,回调 ArkTSgetSlotContentForCApi(rootTag, tag, slotName)获取ArkUI_NodeContentHandle并缓存。第一版可只支持声明内的 slot 名(slotDeclarations),未声明名一律落入默认槽并打警告日志。
4.3.3 默认回退
__slotMap 缺失 / 子节点 slot 名未知时,一律路由到默认槽(""),与现状行为完全一致。
4.4 ArkTS 层
文件:flexui-engine/framework/ohos/src/main/ets/renderer_native/
NativeRenderBuilderForCApi.ets(接口类型)NativeRenderImpl.ets(createRenderViewForCApi)uimanager/FRViewManager.ets(createRenderViewForCApi)components/custom/FlexUICustomComponentView.ets
4.4.1 接口扩展(NativeRenderBuilderForCApi.ets:12)
export interface CreateRenderViewResultForCApi {
frameNode: FrameNode | null
childSlot: NodeContent
/** 命名插槽 → NodeContent;"" 键对应 childSlot。旧组件可为空对象 */
slotContents?: Record<string, NodeContent>
}
4.4.2 FlexUICustomComponentView 增加 slotContents
export class FlexUICustomComponentView extends FlexUIRenderBaseView {
childSlot: NodeContent | null = null
/** 命名插槽内容挂载点:slot 名 → NodeContent("" 键 = childSlot,兼容旧代码) */
slotContents: Map<string, NodeContent> = new Map()
// ...
}
4.4.3 创建流程(NativeRenderImpl.ets:659-685 / FRViewManager.ets:290-317)
// NativeRenderImpl.createRenderViewForCApi
let childSlot: NodeContent = new NodeContent()
let slotContents: Record<string, NodeContent> = { '': childSlot }
// 按扩展声明的 slots 预建 NodeContent(slotDeclarations 来自 FlexUIComponentExtension)
for (const slotName of this.getSlotDeclarations(params.viewName)) {
slotContents[slotName] = new NodeContent()
}
let view = viewManager.createRenderViewForCApi(params.tag, params.viewName, childSlot, slotContents)
// ...
return { frameNode: builderNode.getFrameNode()!, childSlot: childSlot, slotContents: slotContents }
// FRViewManager.createRenderViewForCApi
createRenderViewForCApi(tag: number, viewName: string, childSlot: NodeContent,
slotContents?: Record<string, NodeContent>): FlexUIRenderBaseView | null {
// ...
if (renderView) {
const custom = renderView as FlexUICustomComponentView
custom.childSlot = childSlot
if (slotContents) {
custom.slotContents = new Map(Object.entries(slotContents)) // 兼容 ArkTS 限制,转 Map
}
}
}
4.4.4 组件 struct 使用方式(以 collapsible-view 为例)
@Component
export struct FRCollapsibleView {
@ObjectLink renderView: FRCollapsibleViewView // extends FlexUICustomComponentView
@ObjectLink children: FlexUIObservedArray<FlexUIRenderBaseView>
build() {
Column() {
// 命名槽:按钮区(等价 <slot name="button">)
if (this.renderView.slotContents.get('button')) {
ContentSlot(this.renderView.slotContents.get('button')!)
}
// 默认槽:正文区(等价 <slot>)
if (this.renderView.childSlot) {
ContentSlot(this.renderView.childSlot)
}
// 非 CApi 兜底:children 数组全量渲染(与 FRBadge 现状一致,FRBadgeView.ets:83-87)
ForEach(this.children, (item: FlexUIRenderBaseView) => {
buildFlexUIRenderView(item, null)
}, (item: FlexUIRenderBaseView) => item.tag + '')
}
.applyRenderViewBaseAttr(this.renderView)
}
}
slotContents用Map+.get()规避arkts-no-props-by-index(10605029)与对象字面量规则。struct 里ContentSlot数量由声明驱动、编译期确定 —— 这正是"插槽声明"必要性的来源。
4.4.5 扩展声明(FlexUIComponentExtensions.ets)
// flexui-engine/.../flexui/FlexUIComponentExtension.ets 增加可选字段
export interface FlexUIComponentExtension {
// ...现有字段
/** viewName → 命名插槽名列表;用于 ArkTS 预建 NodeContent */
slotDeclarations?: Map<string, string[]>;
}
// flexui-component-ext/.../FlexUIComponentExtensions.ets
function createCollapsibleViewExtension(): FlexUIComponentExtension {
const renderViews = new Map<string, FRRenderViewCreator>();
renderViews.set('CollapsibleView', (ctx): FRCollapsibleViewView => new FRCollapsibleViewView(ctx));
return {
name: 'flexui-component-ext:collapsibleview',
jsBundlePath: 'flexui_component_ext/components.js',
renderViews: renderViews,
slotDeclarations: new Map([['CollapsibleView', ['button']]]),
renderBuilder: getCollapsibleViewWrappedBuilder(),
};
}
5. 数据流时序
5.1 首次渲染(静态插槽)
1. 模板执行:创建 <view slot="button"> 节点 → elem.slot = 'button'
└─ element.ts:275 → backend.setSlot('button') → 记录 slotName(节点可能未挂载)
2. appendChild 挂载子节点 → createNodeWithId 进 C++ 树
└─ 父节点 _syncSlotMap() → updateNode(parent, { __slotMap: '{"button":[3],"":[4]}' })
3. C++ UpdateCustomTsProps → SetProp('__slotMap') 白名单放行
└─ CustomTsView::SetViewProp 解析 → childSlotMap_ → EnsureSlotContents + ReRouteChildren
4. 子节点 OnChildInsertedImpl → LookupSlotName(childTag) → 路由到对应 contentNode_
5. ArkTS:slotContents['button'] ContentSlot 渲染按钮区,childSlot 渲染默认区
5.2 运行期 slot 变化(静态模板下不触发,动态场景预留)
elem.slot = 'new' → setSlot('new') → _notifyParentSlotMapChanged → 父节点 _syncSlotMap
→ updateNode(__slotMap 新值) → C++ 解析 → ReRouteChildren() 重挂载
6. 兼容性
| 场景 | 行为 |
|---|---|
旧自定义组件(BadgeView,不声明 slots) |
JS 不计算 __slotMap;C++ childSlotMap_ 空;全部子节点进默认槽。零改动、行为不变 |
声明了 slots 但调用方没写 slot 属性 |
__slotMap = {"":[全部子节点]},等价现状 |
声明了 slots 且写了未声明槽名(如 slot="footer") |
第一版:落入默认槽 + C++ 警告日志;M5 后支持动态创建 |
| 非 CApi 路径(ForEach(children)) | 第一版与现状一致(全量渲染);M5 按 slotName 分组(见 §9) |
| 嵌套自定义组件 | 各自独立 __slotMap,无冲突 |
7. 边界情况
- 子节点 slot 属性在挂载前设置:
setSlot只记录,挂载时父节点统一重算(顺序无关)。 - 多个子节点同名插槽:
__slotMapvalue 为数组,保序路由。 - 文本节点 / 虚拟节点:
slotName恒'',进默认槽(虚拟节点不产生 C++ 节点,天然跳过)。 - 子节点被
Move2RenderView移动:走removeChild+appendChild,父节点 slotMap 随之重算。 __slotMap到达晚于子节点挂载:子节点暂进默认槽,SetViewProp('__slotMap')后ReRouteChildren()重路由。deleteProps携带__slotMap(slotMap 清空):SetProp('__slotMap', Null)→ 清空childSlotMap_,全部回默认槽。- 自定义组件无子节点:
__slotMap = {},各 ContentSlot 为空,组件仍正常渲染自身内容。 - 宿主带任意属性(如
collapsed-height):__slotMap是 JS 侧仅结构性变化(子节点增删/slot 重分配)才下发的粘性 ext 属性,而普通属性随每次flush()全量下发且不含__slotMap。曾因UpdateDomNodes的 ext diff 把缺失的__slotMap判为 DELETE 并SetExtStyleMap覆盖抹掉,导致 ArkTS 只收到setProp('__slotMap', null)、onSlotMapParsed不再执行(且 C++ 槽路由丢失)。修复:UpdateDomNodes保留 ext 中已有的__slotMap(update_ext 缺失时回填),并从 ext_delete 中剔除__slotMap(只响应 JS 显式 deleteProps);ArkTS_parseSlotMapProp空值分支也调用onSlotMapParsed(空Map)保持钩子契约。真机回归场景见demos/ascf-ai-demo/.../collapsibleview-demo/index.hxml场景 1(宿主带collapsed-height+<text slot="button">)。
8. 验证方案
8.1 JS 单测(flexui-backend,Jest)
flexui-engine/driver/js/packages/flexui-backend/src/native-backend/__tests__/slot.test.ts:
setSlot记录 slotName;未挂载时不崩溃;- 父节点
_computeSlotMap分组保序、JSON 序列化正确; - 挂载/移除/slot 变化时
FlexUIBridge.updateNode被调用且__slotMap参数正确; - 未声明
slots的组件不产生__slotMap。
8.2 C++ 编译验证(强制)
改完 custom_ts_view.cc/.h、fr_view_manager.cc 后必须执行:
./build.sh build-engine # 输出 BUILD SUCCESSFUL 即通过
8.3 真机端到端(collapsible-view demo)
entry页加<collapsible-view><view slot="button">X</view><view>Y</view></collapsible-view>(注意 Scroll 下只能一个子组件,多卡片需包 Column);./build.sh dev安装启动;- hilog 快照抓取(
-t app | grep flexui),确认:- JS:
updateNode ... __slotMap日志; - C++:
SetViewProp __slotMap/ReRouteChildren/InsertChild slot=button日志; - ArkTS:
slotContents size=2注入日志; - 画面:按钮区与默认区分别渲染在预期位置。
- JS:
8.4 回归
- BadgeView 旧 demo 行为不变(无
__slotMap日志、子节点进默认槽)。
9. 实施步骤(里程碑)
| 里程碑 | 内容 | 涉及文件 | 验证 |
|---|---|---|---|
| M1 | JS 层:setSlot 实现、slotName 字段、父节点 _computeSlotMap/_syncSlotMap、ComponentAdapter.slots |
NativeBackendElement.ts、ComponentAdapter.ts |
Jest 单测 |
| M2 | ArkTS 层:slotContents 注入、CreateRenderViewResultForCApi 扩展、FlexUIComponentExtension.slotDeclarations、struct 示例 |
FlexUICustomComponentView.ets、NativeRenderImpl.ets、FRViewManager.ets、FlexUIComponentExtension.ets |
build-hap 编译 |
| M3 | C++ 层:CustomTsView 多槽容器 + __slotMap 解析 + 路由 + 白名单 |
custom_ts_view.cc/.h、fr_view_manager.cc |
build-engine + 真机 |
| M4 | collapsible-view demo + 端到端验证 + 本设计落地文档更新 | flexui-component-ext/、entry/ |
真机 hilog + 画面 |
| M5(可选) | 非 CApi 路径按 slot 分组(onChildInsertedForCApi 携带 childSlotName,ArkTS 侧 children 分组);未声明 slot 名动态创建 NodeContent |
NativeRenderImpl.ets、FRViewManager.ets、FlexUIRenderBaseView.ets |
真机回归 |
10. 风险与权衡
| 风险 | 影响 | 缓解 |
|---|---|---|
NAPI 传递 Map<string, NodeContent> 受限 |
ArkTS↔C++ 返回结构复杂化 | 用 Record<string, NodeContent>(napi object)传递,ArkTS 侧转 Map |
ArkTS 声明式 ContentSlot 数量编译期确定 |
动态 slot 名无法预置挂载点 | 声明驱动(D5):struct 只为声明过的 slot 写 ContentSlot;未声明名回退默认槽 |
ReRouteChildren 全量重挂性能 |
大子节点树 + 高频 slot 变化时开销 | slot 变化低频(静态插槽不触发);后续可改增量 diff |
__slotMap 全量 JSON 下发 |
大子节点树时序列化开销 | 仅在子节点集合/slot 变化时触发,非每帧;可后续压缩为差量 |
| 需要 C++ 改动 | 编译/回归成本 | 改动集中(2 个文件 + 白名单 1 行),M3 独立验证 |
11. 参考文件
| 文件 | 作用 |
|---|---|
packages/flexui/src/element.ts(:265-315, :2511-2545) |
glass-easel slot 分配与 backend 回调契约 |
packages/flexui/src/slot_mode.ts、shadow_root.ts |
SlotMode 与 slot 树维护 |
flexui-engine/driver/js/packages/flexui-backend/src/native-backend/NativeBackendElement.ts(:1028-1031, :280-472, :1547-1586) |
后端 no-op 现状、appendChild、buildProps |
flexui-engine/driver/js/packages/flexui-backend/src/native-backend/FlexUIBridge.ts(:448-526) |
createNodeWithId / updateNode 协议 |
flexui-engine/driver/js/packages/flexui-backend/src/adapters/ComponentAdapter.ts |
适配器声明(新增 slots) |
flexui-engine/.../src/components/custom_ts_view.cc/.h |
CustomTsView 单槽现状与改造点 |
flexui-engine/.../src/uimanager/fr_view_manager.cc(:367-395, :662-702) |
UpdateProps / UpdateCustomTsProps 白名单 |
flexui-engine/.../NativeRenderImpl.ets(:659-685)、FRViewManager.ets(:290-317) |
CApi 视图创建与 childSlot 注入 |
flexui-component-ext/(FlexUIComponentExtensions.ets、FRBadgeView.ets、BadgeViewRenderBuilder.ets) |
扩展机制示例与 struct 用法 |
docs/learn/flexui-component-extension-guide.md |
自定义组件扩展端到端流程 |
12. 实施落地记录(M1-M4)
本节记录实际落地时与设计文档的差异、已修复的基线问题,以及后续维护注意事项。代码已按此实现,若后续修改请同步更新本节。
12.1 已实现(对照里程碑)
| 里程碑 | 落地状态 | 关键文件 |
|---|---|---|
| M1 JS 层 | ✅ setSlot 记录 slotName;父节点 _isSlotHost/_computeSlotMap/_syncSlotMap;挂载/移除/移动/insertBefore/slot 变化 5 个调用点;ComponentAdapter.slots |
NativeBackendElement.ts、ComponentAdapter.ts、__tests__/slot.test.ts(7 用例) |
| M2 ArkTS 层 | ✅ slotContents 注入链(10 个文件);CreateRenderViewResultForCApi.slotContents;FlexUIComponentExtension.slotDeclarations |
FlexUICustomComponentView.ets、NativeRenderImpl.ets、FRViewManager.ets、NativeRenderContext.ets、FlexUIRootView.ets、NativeRenderProvider.ets、FlexUIEngine.ets、FlexUIEngineContextImpl.ets、FlexUIAPIProvider.ets、NativeRenderBuilderForCApi.ets |
| M3 C++ 层 | ✅ CustomTsView 多槽容器 + __slotMap 解析 + 子节点路由 + 白名单放行;createRenderViewForCApi 读取 slotContents 传入 |
custom_ts_view.h/.cc、fr_view_manager.cc |
| M4 样例 | ✅ collapsible-view 组件(命名插槽 button + 默认插槽 + 折叠交互)+ collapsibleview-demo 卡片 |
flexui-component-ext/src/main/ets/collapsibleview/、components.js、index.ets、EntryAbility.ets、demos/ascf-ai-demo/.../collapsibleview-demo/、entry/pages/Index.ets |
12.2 与设计文档的差异
__slotMap用 JSON 字符串传递(与 §4.2 一致),C++ 侧手写轻量解析器(ParseSlotMap,custom_ts_view.cc:183)—— 项目无 JSON 库,格式固定({"name":[id,id],"":[id]})故未引入依赖。slotDeclarations传递链比设计文档更长:FlexUIComponentExtension→FlexUIAPIProviderBase.getSlotDeclarations()(基类默认 null)→FlexUIEngine内部 provider 合并 →FlexUIEngineContextImpl收集 →NativeRenderProvider→NativeRenderImpl→NativeRenderContext(经FlexUIRootView构造与checkAndGetViewManager两处)→createRenderViewForCApi预建NodeContent。均采用**可选构造参数(默认 null)**避免破坏既有调用点。ReRouteChildren用AddChild追加而非InsertChild(-1):ArkUINode::InsertChild底层insertChildAt对负 index 行为未定义,AddChild语义明确(追加到末尾)。CustomTsView构造函数新增slotContents参数(unordered_map<string, ArkUI_NodeContentHandle>),命名槽容器在CreateArkUINodeImpl时统一创建(CreateSlotContentNode抽取公共创建逻辑,默认槽与命名槽共用)。- JS 侧
setSlot通知条件为parentDomNodeId !== 0(不检查_isMounted):appendChildCREATE 路径只设_justCreated不设_isMounted(batch splitting 设计),挂载后运行期 slot 重分配需要父节点立即收到通知。 - 命名插槽容器点击走 C++ 回调转发(
TRANSPARENT+RegisterClickEvent),声明式 onClick 无法命中插槽内容:插槽内容是原生节点(NodeContent 承载),触摸事件被 ContentSlot 内部宿主视图消费,声明式父组件的 onClick(如 collapsible-view 头部折叠切换)收不到点击。实测ContentSlot组件不支持hitTestBehavior(ContentSlotAttribute无此属性),且仅靠容器ARKUI_HIT_TEST_MODE_TRANSPARENT也无法让事件穿透到声明式父组件(真机验证失败)。最终方案:命名插槽容器(CreateSlotContentNode(handle, namedSlot, slotName),命名槽传true)设为TRANSPARENT(自身参与命中测试)并注册点击手势(RegisterClickEvent+SlotClickDelegate),点击经ts_render_provider_ref_调 ArkTSonSlotClickedForCApi→NativeRenderProvider→NativeRenderImpl→FRViewManager→FlexUICustomComponentView.onSlotClicked(slotName)(虚方法,组件覆写处理,如 collapsible-view 切换折叠)。默认槽保持NONE以零影响既有组件(BadgeView 等)。
12.3 修复的基线问题(非本方案引入,但阻塞了验证)
| 问题 | 位置 | 修复 |
|---|---|---|
new NativeBackendElement() 无参构造违反构造器签名,ts-jest 类型检查报 TS2554,全部 JS 单测无法运行 |
NativeBackendElement.ts:361(ScrollView wrapper 创建) |
改为 new NativeBackendElement(FlexUIBridge.createDomNodeId()),删除重复的 domNodeId 赋值(行为不变) |
splice.test.ts 9 个用例断言 _isMounted === true,但当前实现 appendChild 有意不设 _isMounted(batch splitting 注释说明) |
splice.test.ts:66-70 等 |
未修复(属测试与实现的既有脱节,与本方案无关;修复需同步更新测试意图或实现注释,建议单独提交) |
CSSManager.test.ts 1 个 line-height 断言失败 |
CSSManager.test.ts |
未修复(纯 CSS 解析断言,与本方案无关) |
12.4 验证状态
- ✅ M1:
slot.test.ts7/7 通过;全量回归 78/88(10 个失败均为 §12.3 基线问题) - ✅ M2:
HarCompileArkTS通过(build-engine 内) - ✅ M3:
./build.sh build-engine→ BUILD SUCCESSFUL in 1 min 10 s(ArkTS + C++ + HAR 打包全链路) - ✅ M4(组件侧):
collapsible-view组件 + 注册 + builder 合并已完成;collapsibleview-demo卡片源码已交付 - ⏳ M4(卡片产物):
collapsibleview-demo的index.pack.js未生成 —— skills(hxml → pack.js)编译链不在现有 build 命令中(build-demo仅编译元服务页面 pages/packageDetail,pkg-skills.sh仅打包部署)。真机验证前需先跑 skills 编译流程(flexui 模板编译链 + webpack),或按既有 skills(如 badgeview-demo)的产物生成方式补齐 - ⏳ 真机验证:
./build.sh dev后 hilog 抓__slotMap/InsertChild slot=/slotContents.size日志
12.5 已知限制(与 §1.4 非目标一致)
- 仅支持静态命名插槽(模板写死
slot="name");运行期动态 slot 重分配(setSlotElement)在 native 路径仍为 no-op。 - 未声明
slotDeclarations的命名槽名(__slotMap中出现但 ArkTS 未预建 NodeContent)回退默认槽并打警告日志(EnsureSlotContentNode)。 - 非 CApi 路径(
enableArkCApi=false)的ForEach(children)仍全量渲染,未按 slot 分组(M5 范围)。