【open harmony/harmonyos】HarmonyOS 应用中的数据模型分层:以星图节点 Store 为例
【open harmony/harmonyos】HarmonyOS 应用中的数据模型分层:以星图节点 Store 为例
前言 🧠
在 HarmonyOS / OpenHarmony 应用开发中,很多初学项目会直接把数据、交互和 UI 写在同一个页面里。
小功能这样写没有问题,但当页面开始出现节点、连线、选中状态、缩放、旋转、删除、生成等逻辑时,如果还全部堆在组件中,代码会很快变乱。
我的项目 星图 Xingtu 是一个 3D 知识星图应用,里面涉及:
- 节点管理
- 连线管理
- 节点选中
- 连线模式
- 3D 相机状态
- 关键词生成星图
- 节点删除与关系清理
这篇文章就以 XingtuGraphStore 为例,分享如何在 ArkTS 项目中做一个清晰的数据模型分层。✨
一、为什么要抽出 Store
星图页面中有很多 UI 组件:
- 星图场景组件
- 节点组件
- 顶部 HUD
- 底部导航
- 节点详情弹层
- 新增节点弹层
- 词图生成弹层
这些组件都可能需要读取或修改图谱数据。
如果每个组件都自己维护一份状态,会出现很多问题:
- 数据不同步
- 删除节点后连线残留
- 选中状态混乱
- 组件之间传参复杂
- 测试困难
所以项目把图谱相关逻辑集中到 XingtuGraphStore。
二、Store 的核心状态
XingtuGraphStore 中保存了图谱运行所需的核心状态:
export class XingtuGraphStore {
currentTab: 'starMap' | 'elements' | 'graphs' | 'mine' = 'starMap';
nodes: XingtuNode[] = [];
edges: XingtuEdge[] = [];
selectedNodeId: string | null = null;
linkingSourceId: string | null = null;
camera: CameraState = defaultCamera();
}
这些状态可以分为三类:
- 图谱数据:
nodes、edges - 交互状态:
selectedNodeId、linkingSourceId - 视角状态:
camera
把它们放在同一个 Store 中,可以让图谱逻辑保持完整。
三、节点新增
新增节点时,Store 会创建节点、加入数组,并自动选中新节点。
addNode(draft: XingtuNodeDraft): XingtuNode {
const node: XingtuNode = createNodeAtFront(this.camera, draft);
this.nodes = [...this.nodes, node];
this.selectedNodeId = node.id;
return node;
}
这里有两个设计点:
- 使用
createNodeAtFront让新节点生成在当前视角前方 - 新增后自动选中,方便用户继续编辑或连接
UI 层只需要调用 store.addNode(...),不用关心节点 id、位置和选中状态怎么处理。
四、节点选择
节点选择逻辑很简单:
selectNode(nodeId: string | null): void {
this.selectedNodeId = nodeId;
}
selectedNode(): XingtuNode | null {
return this.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) {
return null;
}
const exists: 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;
return null;
}
const edge: XingtuEdge = {
id: `edge-${++edgeSequence}`,
fromId: this.linkingSourceId,
toId: targetId
};
this.edges = [...this.edges, edge];
this.linkingSourceId = null;
this.selectedNodeId = targetId;
return edge;
}
这样 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[] {
return this.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.length === 0 && normalizedTheme.length === 0) {
return 0;
}
let centerTitle: string = normalizedTheme;
let orbitWords: string[] = words;
if (centerTitle.length === 0) {
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 edges when a node is deleted', 0, () => {
const store = new XingtuGraphStore(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 应用来说,清晰的数据模型分层不只是代码好看,更重要的是让项目后续能继续长大。✨

更多推荐


所有评论(0)