做 HarmonyOS 这边玩 ArkUI 的朋友应该都有这种感觉声明式 UI 写界面很方便但一旦涉及自定义绘制、交互路径这类偏“图形学”的需求网上能直接抄的实战例子就少了一大截。我最近正好在做一个动态轨道生成的小项目核心玩法就是“点击延伸轨道”——屏幕上已经有了一条轨道你点一下轨道就从末端往随机方向长出下一段越点越长路径完全随机。这个需求听起来简单真正落地时要处理的问题却不少坐标怎么管理、随机方向怎么避免原地打转、点击命中的判定怎么写才不飘、节点多了怎么保证渲染不卡。这篇文章把我从零实现到优化完成的全过程拆开讲包括完整的 ArkTS 数据模型、Canvas 绘制逻辑、随机路径算法和几处我实际踩过的坑适合正在学 HarmonyOS 自定义绘制或者想给应用加一点“轨迹类”游戏交互的开发者参考。1. 项目整体思路与方案选型1.1 需求与场景分析先把这个“点击延伸轨道”的具体玩法说清楚。初始状态下屏幕中间有一条默认生成的短轨道比如由三个节点连成的折线。用户点击屏幕上任意位置时系统判断这次点击是否“有效”如果点击位置落在轨道末端的某个范围内轨道就朝某个新方向延伸出一段新路径如果点击位置离末端太远就提示无效不产生任何变化。每次延伸的方向是随机的但随机不是乱来——它要符合几条规则不能直接回头、不能射出屏幕边界、尽量让轨道看起来“自然”而不是下一秒就自己打结。这个系统能用的场景其实挺广的。最直接的是做成小游戏里的“通路铺设”玩家点击让轨道不断延伸碰到障碍或边界游戏结束。也可以拿来做“轨迹可视化”比如按时间顺序往地图上不断追加路径点。还可以用在创意画板里让用户每一次点击都“生长”出一段新的笔迹。我这次实现的是最通用的版本把轨道数据、绘制逻辑和交互判定拆开后续接任何玩法都能复用。1.2 方案选型为什么用 Canvas 加数据驱动渲染HarmonyOS 上做自定义绘制绕不开两条路一是用 ArkUI 的组件嵌套比如用一堆 Row、Column 拼出视觉轨道二是直接用 Canvas 自定义绘制。我一开始也犹豫过后来果断选了 Canvas原因有三个。第一轨道节点数量是持续增长的如果每个节点都对应一个 UI 组件节点一多组件树膨胀明显布局计算和渲染压力都很大。而 Canvas 本质是“画图”所有节点只对应一份数据数组画面上的复杂度不会直接变成组件的数量。第二轨道是连续折线视觉上要求节点之间严格相连、端点圆滑过渡。用组件拼接想做到节点坐标精确衔接非常麻烦每次新增节点都得重新计算所有组件的位置和角度。Canvas 里绘制折线就是一句moveTo加一句lineTo的事坐标直接写进路径干净利落。第三后续要做动画比如轨道延伸时的“生长”效果Canvas 配合定时器逐帧重绘最顺手。组件方案想做逐帧插值要么频繁改状态触发重建要么引入显式动画复杂度反而更高。我的整体架构是“数据加渲染”分离一个TrackNode数组保存轨道所有节点的坐标和方向信息点击事件只负责修改这套数据Canvas 只在数据变化时重新绘制。这样逻辑和视图完全解耦调试时打日志看数据就行了界面只是数据的投影。1.3 随机路径生成策略随机路径的核心是“下一段往哪走”的决策。直接Math.random()从四个方向里挑一个是最省事的玩法但会出现一个很丑陋的情况刚往右走完下一秒随机回左轨道当场折返重叠。我采用的策略是“合法方向池”机制。每个节点记录自己是从哪个方向来的延伸时先把来源方向剔除再从剩余方向中随机挑选。比如上一个方向是向右那新方向只能在上、下、左三个方向里选这样就能保证轨道不会在同一条线段上立刻折返。如果你希望轨道更平滑还可以给“继续直行”加一点权重让轨道有一定概率直着走有概率转弯。这个策略还连带解决了一个问题随机系统最怕“生成到一半发现无路可走”。如果允许折返轨道很容易陷入局部来回震荡禁止折返后只要画布还有空间系统基本能一直延伸下去。配合后面的边界约束和碰撞检测就形成了一套完整的随机路径生成闭环。2. 核心数据结构与算法设计2.1 节点与轨道的数据模型轨道在数据结构上就是一条折线因此最朴素也最可靠的模型是“节点数组”。我在项目里给每个节点定义了一个轻量接口interface TrackNode { x: number; y: number; // 表示该节点相对于上一节点的方向-1 表示起点 dir: number; // 0: 上, 1: 右, 2: 下, 3: 左 }只存坐标其实就够画线了为什么还要存方向两个原因。第一每次点击延伸时我要立刻知道轨道“当前末端是从哪个方向来的”这决定了合法方向池的构建。如果每次现算还要比较末端和倒数第二个节点的坐标差虽然也能算但平白多写几行逻辑。第二后续做动画时“轨道往哪个方向长出去”这个信息直接决定生长插值的起点和终点存下来可以省一次方向判断。轨道本身我直接用一个State trackNodes: TrackNode[]来管理初始节点在页面加载时生成。这里有个特别需要注意的 ArkUI 状态管理细节State修饰数组时直接this.trackNodes.push(newNode)是不会触发界面刷新的必须给数组整体赋一个新引用。实际写法我在后面“实操实现”章节具体讲。2.2 方向判定与去重逻辑方向判定用数字 0 到 3 表示上下左右每次延伸时按“来的方向反向剔除”规则选方向// 当前末端的方向 const opposite (this.trackNodes[this.trackNodes.length - 1].dir 2) % 4; // 候选方向排除来的方向 const candidates: number[] []; for (let d 0; d 4; d) { if (d ! opposite) { candidates.push(d); } } const chosen candidates[Math.floor(Math.random() * candidates.length)];举个例子末端节点是向右dir 1延伸出来的它的“来源方向”的反向是左dir 3那么候选方向就是上、右、下三个。这里我用了(dir 2) % 4这种取反写法是为了避免写满四个if判断也让逻辑更容易扩展到八方向。光有方向不撞墙还不够长距离随机很容易跟轨道自身相交。相交本身无害但视觉上很乱而且后续如果要做“碰到自己就结束”的玩法这一步就必须处理。我在生成新节点前额外检查新节点坐标是否已经落在已有点附近一个阈值内比如半径 40vp如果在就重选方向。重选几次都没合适方向就放弃本次延伸保证系统永远只生成有效节点。2.3 点击命中检测的数学原理说“点击延伸”其实点击区域不是整个屏幕而是轨道末端附近的一个圈。判定原理是两点距离公式const endNode this.trackNodes[this.trackNodes.length - 1]; const dx clickX - endNode.x; const dy clickY - endNode.y; const hit Math.sqrt(dx * dx dy * dy) HIT_RADIUS;这里有一个我实际调过的参数命中半径不能太小太小了用户得精确点中末端操作感很差也不能太大太大了整个屏幕都是“按钮”完全失去操作门槛。我最终取了 50vp这个值大概是屏幕宽度的十分之一手指点按的偏移基本都能覆盖又不至于误判。但只判定末端还不够。用户点击的有效坐标来自点击事件的event.x和event.y这两个值默认是相对当前组件的如果 Canvas 外层有 transform 或者 parent 偏移直接用它和节点坐标比较会出现整体漂移。我在项目里做了一次坐标归一化用canvas.getBoundingRect()拿到画布在页面中的实际位置再把点击坐标减去左上角偏移得到画布本地坐标后才参与命中判定。3. 基于 ArkUI 的实操实现3.1 工程初始化与页面骨架我用的是 DevEco Studio 创建的 Empty Ability 工程API 版本选的 HarmonyOS 5.x 的兼容版本。页面骨架非常简单一个全屏 Canvas 组件所有交互都绑定在它上面。Entry Component struct TrackGamePage { private settings: RenderingContextSettings new RenderingContextSettings(true); private canvasCtx: CanvasRenderingContext2D new CanvasRenderingContext2D(this.settings); State trackNodes: TrackNode[] []; State tipText: string 点击轨道末端延伸随机路径; build() { Stack() { Canvas(this.canvasCtx) .width(100%) .height(100%) .backgroundColor(#1a1a2e) .onReady(() { this.initTrack(); this.drawTrack(); }) .onClick((event: ClickEvent) { this.handleClick(event.x, event.y); }) Text(this.tipText) .fontSize(14) .fontColor(#cccccc) .position({ x: 20, y: 20 }) } .width(100%) .height(100%) } }这里有个新手容易犯的错CanvasRenderingContext2D需要在组件初始化时创建不能在onReady里才 new否则onReady里this.canvasCtx还是 undefined。页面里我直接创建实例算是稳妥做法。初始轨道的生成我放在initTrack()里逻辑是从画布中心偏左的位置开始连续生成三个方向随机的节点让用户一进来就看到一段现成的轨道。3.2 绘制轨道的核心代码绘制本身不复杂核心就是遍历节点数组把所有点连起来但有几个细节决定了画面质感。先看代码private drawTrack() { const ctx this.canvasCtx; const nodes this.trackNodes; ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight); if (nodes.length 2) { return; } // 画轨道线 ctx.lineWidth 10; ctx.lineCap round; ctx.lineJoin round; ctx.strokeStyle #00d4aa; ctx.beginPath(); ctx.moveTo(nodes[0].x, nodes[0].y); for (let i 1; i nodes.length; i) { ctx.lineTo(nodes[i].x, nodes[i].y); } ctx.stroke(); // 画节点圆点 ctx.fillStyle #ffffff; for (let i 0; i nodes.length; i) { ctx.beginPath(); ctx.arc(nodes[i].x, nodes[i].y, 4, 0, Math.PI * 2); ctx.fill(); } // 高亮末端 const end nodes[nodes.length - 1]; ctx.strokeStyle #ffcc00; ctx.lineWidth 3; ctx.beginPath(); ctx.arc(end.x, end.y, HIT_RADIUS, 0, Math.PI * 2); ctx.stroke(); }三个容易忽略的点第一lineCap和lineJoin一定要设成round否则折线拐角处会看到明显折痕轨道显得很“断裂”。第二末端高亮圈最好画在最后避免被轨道主线盖住。第三clearRect的宽高我用的是onReady时量出来的画布宽高不要用 100% 这种相对值参与计算。3.3 点击延伸的交互实现handleClick是整套逻辑的中枢。按“命中判断 → 生成候选方向 → 边界检查 → 写入节点 → 重绘”五步走private handleClick(x: number, y: number) { const end this.trackNodes[this.trackNodes.length - 1]; const dx x - end.x; const dy y - end.y; const dist Math.sqrt(dx * dx dy * dy); if (dist HIT_RADIUS) { this.tipText 点击末端高亮圈内才能延伸; return; } const step 80; // 每段轨道长度 const opposite (end.dir 2) % 4; const candidates: number[] []; for (let d 0; d 4; d) { if (d ! opposite) { candidates.push(d); } } // 随机打乱候选方向增加随机感 for (let i candidates.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [candidates[i], candidates[j]] [candidates[j], candidates[i]]; } let newDir -1; let newNode: TrackNode | null null; for (const d of candidates) { let nx end.x; let ny end.y; if (d 0) ny - step; if (d 1) nx step; if (d 2) ny step; if (d 3) nx - step; if (this.isOutOfBound(nx, ny)) continue; if (this.isColliding(nx, ny)) continue; newDir d; newNode { x: nx, y: ny, dir: d }; break; } if (newNode) { // 关键整体赋值触发 State 刷新 this.trackNodes [...this.trackNodes, newNode]; this.drawTrack(); this.tipText 轨道已延伸继续点击末端; } else { this.tipText 当前方向都被堵死了换个点击位置试试; } }这一段有非常多细节值得展开。第一个是“随机打乱候选方向”。如果直接取第一个候选方向虽然也是随机但轨道会明显偏好“上右下左”这个循环顺序观感不够自然。我洗牌后再遍历本质上等于做了一次带约束的随机抽样。第二个是isColliding它检查新节点是否离已有节点太近保证轨道不会疯狂重合。第三个也是最重要的是最后写入方式this.trackNodes [...this.trackNodes, newNode]。在 ArkUI 里State修饰的数组必须整体重新赋值才能触发界面更新直接 push 不会放进渲染队列这个坑几乎每个用 ArkUI 写动态列表的人都踩过。3.4 轨道延伸动画与细节修饰静态绘制能跑通之后我发现直接画出新轨道段稍微有点“生硬”用户一点轨道整段凭空出现缺少生长感。我加了一个简单的延伸动画每次点击后不立刻把新节点显示完整而是用一个setInterval逐帧沿着方向向量推进一个“生长点”。private startGrowAnimation(newNode: TrackNode, from: TrackNode) { let progress 0; const total 20; // 20 帧完成生长 this.growTimer?.clear(); this.growTimer setInterval(() { progress; if (progress total) { this.growTimer?.clear(); this.drawTrack(); return; } const ratio progress / total; // 在 from 和 newNode 之间插值绘制临时生长段 this.drawTrackWithGrowth(from, newNode, ratio); }, 16); }drawTrackWithGrowth是在完整绘制的基础上单独多画一段从from到插值点的线段。这里我强烈建议动画期间的临时画不要修改trackNodes只在绘制层做插值否则动画期间数据一变下一次点击的坐标判断会错乱。动画归动画数据归数据这是所有动态绘制系统都应该遵守的原则。除了生长动画我还加了一个细节轨道首尾各画一个小圆标记起点和终点终点用高亮色。这样用户一眼就能看出“当前该点哪里”不用猜。另外我把背景色设成了深色轨道用亮绿松石色节点用白色末端高亮用黄色深色背景下对比度足够视觉上也比白底黑线舒服很多。4. 常见问题与避坑实录4.1 边界检测与死路判定写随机路径系统最担心的事情是“生成到一半无路可走”。我最初没写边界检测轨道走到屏幕边缘后新节点直接画到画布外面画面一半在界内一半在界外非常难看。加上边界检测后又出现一个新问题四个方向如果只有一个是合法的但这条路径被已有节点占用了这次点击就会完全失败用户的延伸动作“落空”。我的处理办法是“有限重试加降级”。所谓重试就是在handleClick里遍历候选方向找到第一个合法方向就立即使用。如果四个方向全都不合法就返回失败并给用户提示而不是强行生成一个越界或重叠的节点。另外我在边界判定里预留了一个内缩边距比如 30vp轨道不会贴到画布边缘视觉上更从容也给后续玩法留出“边缘触碰即结束”的判定空间。一个容易忽略的边界坑是ArkUI 里 Canvas 的onReady回调里拿宽高是安全的但onClick里如果用户快速连续点击宽高还没初始化完就触发事件this.canvasWidth可能是 0。我加了个状态判断画布没就绪前直接忽略点击避免边界检测全部误判。4.2 状态刷新与 Canvas 重绘的性能问题节点数量到 100 个以上的时候我遇到过一次明显的卡顿。排查后发现两个原因一是每帧都全量clearRect加全量重绘随着节点增多绘制耗时线性上涨二是我的延伸动画用了State去驱动临时绘制状态导致每次插值都走了一遍完整的 UI 状态更新流程完全没必要。优化方案分三层。第一层动画插值期间的数据我放在普通成员变量里不放进State动画只操作 Canvas 本身不走 ArkUI 的渲染管线。第二层停止使用clearRect清全屏后整条重画改为绘制时用一个离屏 Canvas 缓存已经稳定的轨道每次只把新增的生长段画到主 Canvas 上。离屏 Canvas 在 ArkUI 里可以用创建一个不显示的 Canvas 组件实现或者用OffscreenCanvas能力做预渲染。第三层如果节点量继续暴增到千级可以考虑对轨道做抽稀每隔几个节点保留一个绘制点肉眼几乎看不出差别绘制耗时可降低一个数量级。给一个我自己实践下来的参考阈值纯 Canvas 绘制条件下几百个节点的折线在真机上没压力但如果节点里还包含方向权重计算、碰撞检测这类循环遍历每次点击都要扫一遍全部节点节点上千后就会有可感知的延迟。碰撞检测这块可以优化成“只查末端附近区域”不用全量遍历效率能提升很多。4.3 坐标偏移与不同设备适配我调试时最头疼的问题就是坐标漂移。设备从模拟器切到真机或者从竖屏变横屏点击位置和轨道末端总是对不上。查了很久发现根因是ClickEvent的坐标和 Canvas 自身坐标系的换算问题——Canvas 如果没有占满全屏或者页面外层有默认间距事件坐标会带上组件偏移。我的解决办法是写了一个toCanvasLocal(eventX, eventY)方法先拿 Canvas 组件相对页面的位置canvas.getBoundingRect()然后用点击坐标减去左上的x、y。注意getBoundingRect在页面滚动时也要实时更新否则滚动后依然会偏。另外不同设备的屏幕密度不一样我所有涉及间距和半径的参数都用了vp单位而不是裸的像素数字。HarmonyOS 的vp会自动做密度适配省掉了手动乘density的麻烦。还有一个小坑如果 Canvas 外层包了Stack且子组件用了position定位点击事件的坐标原点可能变成 Stack 的左上角而不是 Canvas 的左上角。这种场景就得把 Stack 的偏移一并算进去。我在项目里直接把提示文字也放进同一个 Stack用绝对定位保证 Canvas 就是底层全屏坐标换算最简单。4.4 状态刷新但界面不更新的排查思路这个问题单独拿出来说因为太典型了。我最早写this.trackNodes.push(newNode)然后调用drawTrack()数据确实变了但画面纹丝不动。后来确认原因State数组的深层变化不会触发 ArkUI 的更新机制必须对数组变量重新赋值。这个机制设计其实是为了性能避免对数组内每个元素做深度监听。但写代码时如果不了解这一点排查起来非常痛苦。排查思路分三步第一确认数据有没有变——打日志看trackNodes.length第二确认是不是 UI 没刷新——加一个无关紧要的State tipText变化如果 tipText 变了 track 没变说明是重绘或数据绑定问题第三确认绘制有没有执行——在drawTrack里加日志如果日志有输出但画面没变问题大概率在 Canvas 上下文的宽高或坐标系上。这套思路可以覆盖绝大多数“数据变了画面没变”的问题。5. 扩展玩法与后续演进方向5.1 从单路径到多轨道与游戏化当前版本是单条轨道无限延伸。稍微改一改就能变成很多玩法。一个方向是“多轨道并发”初始生成多条独立轨道每次点击离哪条轨道末端最近就让哪条延伸。这只需要把数据结构从单个TrackNode[]改成TrackNode[][]命中判定时遍历所有轨道的末端选距离最近且在命中半径内的那个。这个改动对绘制层非常友好因为绘制本身就是遍历轨道数组外圈多一层循环就行。另一个方向是做成“路径收集类”游戏。我在项目里预留了一个collectables概念在地图上随机生成一些收集点轨道延伸时如果新节点距离收集点足够近记录为“已收集”。这本质上又是一次距离判定完全可以复用已有的碰撞检测逻辑。收集点的数据结构和轨道节点可以共用一套坐标模型只是额外存一个collected布尔值。做游戏化时要注意一个问题随机方向生成时需要更严格的约束。游戏场景下轨道走到死路就是游戏结束所以方向生成策略要兼顾“短期的可玩性”和“长期的不死局”。我的建议是限制单条轨道总节点数或者像贪吃蛇一样轨道到达一定长度后自动消失尾部让系统始终运行在可控规模内。5.2 轨道数据持久化与分享轨道本质上是一串坐标非常适合序列化存储。我计划下一步把trackNodes序列化成 JSON 字符串存到 HarmonyOS 的轻量级偏好数据库里用户下次打开应用可以继续上次的轨道。代码很简单核心就是把数组转换一次const data JSON.stringify(this.trackNodes); // 写入 preferences读取时反序列化回TrackNode[]再重新绘制即可。这里有个兼容性细节JSON.parse回来的对象没有类型的运行时信息所以读取后最好再遍历一次把dir字段里缺省值补成 0避免老数据缺少字段导致方向计算异常。如果要跨设备分享可以再进一步把 JSON 字符串用二维码编码。HarmonyOS 有二维码生成组件轨道数据编码进去后另一台设备扫码解析直接恢复出整条轨道。这样“随机路径”就从一个本地小玩法变成一个可以传播的创作载体了。我试了一下100 个节点的坐标 JSON 大概 1.5KB二维码完全扛得住体验上非常流畅。5.3 接入碰撞反馈与手柄震动最后一个小建议也是我实际体验后觉得提升最大的每次成功延伸轨道时给一个轻量反馈。视觉上末端高亮圈闪一下听觉上加一个极短提示音如果设备支持震动配合一次几十毫秒的震动。为什么这个反馈重要因为点击延伸是一个“轻交互”用户每次点击获得的即时反馈越明确操作就越有节奏感。我加了反馈之后身边朋友试玩的第一反应都是“这个能舒压”说明这类小系统的核心体验在于“每一下都有回应”。震动接入在 HarmonyOS 里调用振动器能力就行但注意别每帧震动只在节点数据真正新增时触发一次。反馈的参数也要克制强度中等、时长不超过 50ms否则连续快速点击时容易变成“震动马达轰炸”。我个人在实际操作中的体会是这种“点击延伸轨道”式的系统看起来是个玩具但它把坐标管理、随机算法、命中判定、状态刷新、绘制性能这些 HarmonyOS 应用开发的硬骨头全部练了一遍。做完这个项目之后再去看 Canvas 相关的复杂需求心里基本都有底了。如果你也在折腾类似的动态绘制功能建议先把数据模型和逻辑判定彻底想清楚再去碰绘制代码。数据稳了画面只是时间问题。后续我还会继续迭代这个项目目前优先把多轨道模式和轨道分享二维码做出来到时候再更新一篇实战记录。