【open harmony/harmonyos】HarmonyOS 应用中的数据模型分层:以星图节点 Store 为例 【open harmony/harmonyos】HarmonyOS 应用中的数据模型分层以星图节点 Store 为例前言 在 HarmonyOS / OpenHarmony 应用开发中很多初学项目会直接把数据、交互和 UI 写在同一个页面里。小功能这样写没有问题但当页面开始出现节点、连线、选中状态、缩放、旋转、删除、生成等逻辑时如果还全部堆在组件中代码会很快变乱。我的项目星图 Xingtu是一个 3D 知识星图应用里面涉及节点管理连线管理节点选中连线模式3D 相机状态关键词生成星图节点删除与关系清理这篇文章就以XingtuGraphStore为例分享如何在 ArkTS 项目中做一个清晰的数据模型分层。✨一、为什么要抽出 Store星图页面中有很多 UI 组件星图场景组件节点组件顶部 HUD底部导航节点详情弹层新增节点弹层词图生成弹层这些组件都可能需要读取或修改图谱数据。如果每个组件都自己维护一份状态会出现很多问题数据不同步删除节点后连线残留选中状态混乱组件之间传参复杂测试困难所以项目把图谱相关逻辑集中到XingtuGraphStore。二、Store 的核心状态XingtuGraphStore中保存了图谱运行所需的核心状态exportclassXingtuGraphStore{currentTab:starMap|elements|graphs|minestarMap;nodes:XingtuNode[] [];edges:XingtuEdge[] [];selectedNodeId:string|nullnull;linkingSourceId:string|nullnull;camera:CameraStatedefaultCamera(); }这些状态可以分为三类图谱数据nodes、edges交互状态selectedNodeId、linkingSourceId视角状态camera把它们放在同一个 Store 中可以让图谱逻辑保持完整。三、节点新增新增节点时Store 会创建节点、加入数组并自动选中新节点。addNode(draft: XingtuNodeDraft): XingtuNode {constnode: XingtuNode createNodeAtFront(this.camera, draft);this.nodes [...this.nodes, node];this.selectedNodeId node.id;returnnode; }这里有两个设计点使用createNodeAtFront让新节点生成在当前视角前方新增后自动选中方便用户继续编辑或连接UI 层只需要调用store.addNode(...)不用关心节点 id、位置和选中状态怎么处理。四、节点选择节点选择逻辑很简单selectNode(nodeId: string |null): void {this.selectedNodeId nodeId; } selectedNode(): XingtuNode |null{returnthis.nodes.find((node: XingtuNode) node.id this.selectedNodeId) ??null; }一个方法负责写入选中 id一个方法负责返回完整节点对象。这样弹层组件可以直接拿到node: this.selectedNode()而不是自己在 UI 层遍历节点数组。五、节点连接图谱应用中比较重要的是节点连接。Store 中把连接分成两步startLink(nodeId:string):void{this.linkingSourceId nodeId;this.selectedNodeId nodeId; }finishLink(targetId: string): XingtuEdge |null{if(!this.linkingSourceId ||this.linkingSourceId targetId) {returnnull; }constexists: boolean this.edges.some((edge: XingtuEdge) (edge.fromId this.linkingSourceId edge.toId targetId) || (edge.fromId targetId edge.toId this.linkingSourceId) );if(exists) {this.linkingSourceId null;returnnull; }constedge: XingtuEdge { id: edge-${edgeSequence}, fromId:this.linkingSourceId, toId: targetId };this.edges [...this.edges, edge];this.linkingSourceId null;this.selectedNodeId targetId;returnedge; }这样 UI 层的点击逻辑会非常清晰没有连线起点点击节点就是选中有连线起点点击另一个节点就是完成连线六、删除节点时维护数据一致性删除节点不能只删节点还要删除相关边。deleteNode(nodeId: string): void {this.nodes this.nodes.filter((node: XingtuNode) node.id ! nodeId);this.edges this.edges.filter((edge: XingtuEdge) edge.fromId ! nodeId edge.toId ! nodeId );if(this.selectedNodeId nodeId) {this.selectedNodeId null; }if(this.linkingSourceId nodeId) {this.linkingSourceId null; } }这段逻辑体现了 Store 的价值。如果删除逻辑散落在 UI 组件里很容易只删了节点、忘记删边导致图谱出现脏数据。七、相机状态也属于图谱模型这个项目中图谱不只是数据还有视角。所以相机状态也放在 Store 中updateCamera(deltaYaw: number, deltaPitch: number): void {this.camera { yaw:this.camera.yaw deltaYaw, pitch: clampPitch(this.camera.pitch deltaPitch), distance:this.camera.distance, scale:this.camera.scale }; }缩放也是一样updateScale(nextScale: number): void {this.camera { yaw: this.camera.yaw, pitch: this.camera.pitch, distance: this.camera.distance, scale: Math.max(0.6, Math.min(2.2, nextScale)) }; }这样星图场景组件只负责把触摸变化转换成deltaYaw、deltaPitch或scale不负责管理相机内部细节。八、投影节点对 UI 友好UI 不应该直接处理复杂 3D 坐标所以 Store 提供了投影后的节点projectedNodes(viewport: ViewportSize): ProjectedNode[] {returnthis.nodes .map((node: XingtuNode)projectNode(node, this.camera, viewport)) .sort((left: ProjectedNode, right: ProjectedNode)right.depth - left.depth); }这样场景组件拿到的已经是screenX、screenY、scale、opacity可以直接用于渲染。这就是分层的好处Store 负责数据计算Scene 负责布局渲染Node 负责单个节点显示九、关键词生成星图Store 还负责把用户输入的关键词转换成图谱。generateWordMap(themeTitle:string,rawWords:string): number { const words:string[] this.parseWordList(rawWords); const normalizedTheme:string themeTitle.trim();if(words.length0normalizedTheme.length0) { return0; }letcenterTitle:string normalizedTheme;letorbitWords:string[] words;if(centerTitle.length0) { centerTitle words[0]; orbitWords words.slice(1); } const centerNode: XingtuNode createNodeAtPosition({...}, {x: 0,y: 0,z: 40 }); const generatedNodes: XingtuNode[][centerNode]; const generatedEdges: XingtuEdge[][]; orbitWords.forEach((word:string,index:number) { const position this.createOrbitPosition(index,orbitWords.length); const node: XingtuNode createNodeAtPosition({...},position); generatedNodes.push(node); generatedEdges.push(this.createEdge(centerNode.id,node.id)); }); this.nodes generatedNodes; this.edges generatedEdges; this.selectedNodeId centerNode.id; this.camera defaultCamera(); return generatedNodes.length; }这个方法不仅生成数据还处理了体验状态替换当前节点和连线默认选中中心节点重置相机返回生成数量UI 层根据返回数量决定是否切回星图页面。十、测试 Store 比测试 UI 更稳定数据模型分层后可以直接测试 Store。it(removes edgeswhena node is deleted,0,(){ const store newXingtuGraphStore(false); const first store.addNode({title: A,note: ,tags: [] }); const second store.addNode({title: B,note: ,tags: [] }); store.startLink(first.id); store.finishLink(second.id); store.deleteNode(first.id); expect(store.nodes.length).assertEqual(1); expect(store.edges.length).assertEqual(0); expect(store.nodes[0].id).assertEqual(second.id); });这类测试不依赖页面渲染运行更快也更容易定位问题。十一、分层后的组件职责最终项目中的职责大致是XingtuGraphStore节点、边、相机、选中、生成逻辑XingtuGraphMath旋转、投影、坐标计算XingtuScene星图场景渲染与触摸事件XingtuSceneNode单个节点视觉XingtuNodeSheet节点详情和操作XingtuCreateNodeSheet新增节点表单XingtuGenerateWordMapSheet词图生成输入这种结构比较适合持续扩展。后续如果要加搜索、保存、多图谱、AI 推荐关系都可以继续围绕 Store 扩展而不是把页面组件越写越重。十二、总结 这篇文章以星图项目为例介绍了 HarmonyOS / OpenHarmony 应用中的数据模型分层思路。核心经验是不要把复杂业务逻辑都写进 UI 组件用 Store 管理节点、连线、选中、相机等核心状态UI 组件通过方法调用修改数据Store 对外提供适合 UI 使用的数据结果图谱关系逻辑要集中处理避免数据不一致数据层逻辑适合写单元测试对于 ArkTS 应用来说清晰的数据模型分层不只是代码好看更重要的是让项目后续能继续长大。✨