【天体运行模拟|01】HarmonyOS ArkTS 轨道模拟实战:用 Canvas 更新位置并控制时间倍率
在轨道模拟页面里,真正难的通常不是“画出几个圆”,而是让物理状态、定时推进、Canvas 绘制和页面生命周期保持同一个节奏。只要其中一层失控,就会出现一些很典型的问题:暂停后天体仍在移动,页面退出后定时器没有释放,调高倍率时轨迹突然发散,碰撞后的质量与速度不守恒,或者 Canvas 尚未拿到真实尺寸便按错误中心点初始化。
本文基于“天体运行模拟”的真实 HarmonyOS 工程展开。目标页面是 ExperimentSimPage.ets,应用包名为 com.jiaweikan.one13,工程面向 phone、tablet 与 2in1。页面已经实现双体、三体、黑洞、双星、星系碰撞、椭圆与逃逸轨道等场景,并用 ArkUI Canvas 绘制天体、轨迹和引力范围。下面不重新发明一套演示代码,而是沿着源码中的状态模型,拆解它如何推进位置、控制时间倍率,以及哪些边界决定了模拟能否稳定运行。

本文会解决五个具体问题:
- 如何用一个明确的
Body模型同时承载位置、速度、质量、半径和轨迹。 - 如何在每个模拟步长中先累计加速度,再统一更新速度与位置。
- 为什么倍率应该进入时间步长,而不是简单提高定时器调用频率。
- 如何把碰撞合并、越界清理和轨迹裁剪放进同一条更新链。
- 如何在 HarmonyOS 页面出现、暂停和消失时管理定时器,避免后台空转。
唯一复核标记:ORBIT-ONE13-CANVAS-TIMESCALE-20260726:倍率只改变模拟时间步长,页面生命周期负责启停,Canvas 只消费当前状态。
一、先划清边界:这是一套教学模拟,不是天文级数值计算
源码中的引力常量为 0.028,位置单位是 Canvas 像素,速度单位在界面上显示为 v,质量显示为 M。这些量没有映射到米、秒和千克,也没有使用真实天体尺度。因此它适合表达“质量、距离、速度如何共同改变轨迹”,不能被描述成高精度星历、航天器轨道预报或科研计算工具。
这个边界非常重要。教学模拟追求的是可观察、可交互和可解释;科研模拟追求的是误差控制、单位一致、积分方法与可重复性。当前工程选择了前者:
| 维度 | 当前实现 | 工程含义 |
|---|---|---|
| 空间 | Canvas 像素坐标 | 直接映射到屏幕,便于观察 |
| 时间 | 固定回调间隔 + 可变 dt |
倍率改变模拟推进量 |
| 引力 | 简化的两两作用 | 体现距离平方反比关系 |
| 碰撞 | 相交阈值后合并 | 避免天体持续重叠 |
| 轨迹 | 每个天体最多 72 个点 | 控制内存与绘制开销 |
| 稳定性 | 基于数量与最高速度的启发式分数 | 用于学习反馈,不是物理稳定判据 |
因此,文章后面提到“稳定”,指的是交互和数值表现稳定,而不是证明轨道满足严格的长期稳定条件。
二、状态模型要让计算层和绘制层说同一种语言
页面没有把天体拆成多个松散数组,而是用 Body 接口聚合每个天体的完整状态:
interface Body {
id: number
name: string
type: string
x: number
y: number
vx: number
vy: number
mass: number
radius: number
color: string
alive: boolean
trailX: number[]
trailY: number[]
}
这组字段可以分成四类职责:
x、y、vx、vy和mass服务于模拟推进。radius、color和type服务于碰撞判定与视觉表达。alive服务于延迟删除,避免在双重循环中直接改动数组长度。trailX、trailY保存有限历史,用来绘制轨迹。
“延迟删除”是这里很关键的选择。引力计算需要遍历天体对,如果碰撞发生时立刻 splice,后续索引会移动,容易漏算或访问错误对象。源码先把被吸收天体的 alive 设为 false,等本轮推进结束后统一过滤:
this.bodies = this.bodies.filter((body: Body) => body.alive)
this.bodyCount = this.bodies.length
这样,物理循环拥有稳定的索引空间,ArkUI 展示用的 bodyCount 也只在清理完成后更新。
三、Canvas 尺寸就绪后再初始化轨道中心
轨道初始位置依赖画布中心。页面为未就绪状态提供了回退值,但真正初始化发生在 Canvas.onReady:
Canvas(this.canvasCtx)
.width('100%')
.height(300)
.onReady(() => {
this.canvasWidth = this.canvasCtx.width
this.canvasHeight = this.canvasCtx.height
this.resetSystem()
})
对应的中心点函数如下:
private canvasCenterX(): number {
return this.canvasWidth > 0 ? this.canvasWidth / 2 : 180
}
private canvasCenterY(): number {
return this.canvasHeight > 0 ? this.canvasHeight / 2 : 130
}
这里体现了一个适合 HarmonyOS 多设备布局的原则:横向宽度交给布局系统,运行时从绘图上下文读取真实像素范围;业务模型不假设手机固定宽度。工程的 Canvas 高度仍固定为 300,这能保证页面结构稳定,但在平板和 2in1 大窗口上可能显得偏矮。若后续增强大屏体验,可以按窗口断点调整高度,同时继续让初始化逻辑只依赖真实 canvasWidth 与 canvasHeight。
需要注意,页面 aboutToAppear() 也调用了一次 resetSystem()。当 Canvas 还没有就绪时,这次重置使用回退中心;onReady() 会在拿到真实尺寸后再次重置。这个行为不会伪造尺寸,但会产生一次额外初始化和绘制。若页面性能分析发现首帧重复工作明显,可以把“加载路由参数”和“按画布尺寸创建场景”拆开,让真正的场景初始化只在尺寸可用后执行。
四、一帧模拟的正确顺序:累计力、更新速度、更新位置、清理对象
页面的核心更新函数是 stepSimulation()。它没有边计算某一对天体的力边立即移动对象,而是先创建加速度数组:
const g = 0.028
const dt = 0.72 * this.speed
const ax: number[] = []
const ay: number[] = []
for (let i = 0; i < this.bodies.length; i++) {
ax.push(0)
ay.push(0)
}
随后只遍历一次无序天体对,也就是 j = i + 1:
for (let i = 0; i < this.bodies.length; i++) {
const a = this.bodies[i]
if (!a.alive) continue
for (let j = i + 1; j < this.bodies.length; j++) {
const b = this.bodies[j]
if (!b.alive) continue
const dx = b.x - a.x
const dy = b.y - a.y
const distSq = Math.max(dx * dx + dy * dy, 90)
const dist = Math.sqrt(distSq)
const force = g / distSq
ax[i] += force * b.mass * dx / dist
ay[i] += force * b.mass * dy / dist
ax[j] -= force * a.mass * dx / dist
ay[j] -= force * a.mass * dy / dist
}
}
同一对天体的作用同时写入 i 与 j 的加速度,方向相反。这样既避免重复计算 (a,b) 与 (b,a),也让相互作用保持成对出现。
Math.max(..., 90) 是一个软化边界。两个中心点非常接近时,距离平方不会继续趋近于零,从而避免加速度瞬间爆炸。它不是严格的天体物理软化模型,但对触屏教学模拟非常实用:用户可以把天体放得很近,程序仍需要给出可控反馈,而不是在一帧内把对象甩出画面。
累计结束后,源码才统一积分:
body.vx += ax[i] * dt
body.vy += ay[i] * dt
body.x += body.vx * dt
body.y += body.vy * dt
这是半隐式欧拉形式:先更新速度,再用新速度更新位置。它实现简单,在相同计算量下通常比“先用旧速度更新位置”的显式欧拉更适合这类交互模拟。不过,当倍率增大、天体距离很近或质量过大时,误差仍会扩大。当前应用用倍率上限、距离下限和碰撞合并共同约束风险。

五、时间倍率应进入 dt,而不是无限压缩定时器间隔
控制栏中的倍率从 0.5x 开始循环增加,每次增加 0.5,达到 3.0x 后回到 0.5x:
this.ControlButton(`${this.speed.toFixed(1)}x`, () => {
if (this.speed >= 3) {
this.speed = 0.5
} else {
this.speed += 0.5
}
})
定时器间隔保持 16 毫秒,倍率只参与 dt:
private startTimer(): void {
this.stopTimer()
this.timerId = setInterval(() => {
this.stepSimulation()
this.updateDisplay()
this.drawCanvas()
this.persistLearningTime(false)
}, 16)
}
const dt = 0.72 * this.speed
这种设计比把 16 毫秒除以倍率更稳。原因有三点:
- UI 线程不需要在高倍率下承受更密集的回调。
- 绘制刷新频率基本不变,用户看到的是运动加快,而不是回调数量暴涨。
- 性能预算更容易估算,每次回调仍只执行一次计算、一次指标更新和一次绘制。
但也要看清它的代价:倍率越高,每一步跨越的模拟时间越长,积分误差也越大。当前上限 3.0 是交互层面的保护。如果未来要提供 10x、50x 等高速推进,更稳的做法不是继续放大单步 dt,而是在一次绘制前执行多个较小的物理子步。
例如,可以把“模拟倍率”和“绘制频率”解耦:
private advanceWithSubSteps(scale: number): void {
const subSteps = Math.max(1, Math.ceil(scale))
const stepScale = scale / subSteps
for (let index = 0; index < subSteps; index++) {
this.stepWithScale(stepScale)
}
}
这段是面向后续演进的方案,不是当前源码已有能力。它的目的,是在高倍率下保持较小积分步长,同时仍然只绘制一次。
六、启动、暂停和离页必须形成完整生命周期
定时任务最常见的错误,是每次点击启动都创建一个新计时器。页面通过“先停止、再启动”保证计时器只有一个:
private startSimulation(): void {
if (this.learningStartedAt === 0) {
this.learningStartedAt = Date.now()
}
this.isRunning = true
this.stopTimer()
this.drawCanvas()
setTimeout(() => {
if (this.isRunning) {
this.startTimer()
}
}, 120)
}
private stopTimer(): void {
if (this.timerId !== -1) {
clearInterval(this.timerId)
this.timerId = -1
}
}
暂停不仅停止计算,还结算学习时长:
private pauseSimulation(): void {
this.isRunning = false
this.stopTimer()
this.persistLearningTime()
}
页面消失时再次执行兜底清理:
aboutToDisappear(): void {
this.stopTimer()
this.persistLearningTime()
}
这条链路覆盖了三种入口:用户主动暂停、用户点击返回、系统触发页面离开。stopTimer() 是幂等的,即使连续调用也不会重复清理不存在的计时器。
源码在启动时延迟 120 毫秒再创建周期计时器,并在回调前复查 isRunning。这样,用户若在延迟窗口内迅速暂停,计时器不会被重新启动。需要进一步严谨时,还可以保存这个一次性 setTimeout 的 ID 并在离页时清理;当前实现依靠 isRunning 阻止周期计时器启动,但页面离开路径没有显式把 isRunning 设为 false。这是值得在后续代码修订中补上的生命周期细节。
七、碰撞合并要同时处理质量、动量、位置和视觉半径
当两个天体中心距离小于半径和的 0.78 倍时,源码触发合并:
if (dist < (a.radius + b.radius) * 0.78) {
this.mergeBodies(i, j)
}
合并时先选择质量更大的对象作为主体,再按总质量计算速度与位置:
const totalMass = primary.mass + other.mass
primary.vx =
(primary.vx * primary.mass + other.vx * other.mass) / totalMass
primary.vy =
(primary.vy * primary.mass + other.vy * other.mass) / totalMass
primary.x =
(primary.x * primary.mass + other.x * other.mass) / totalMass
primary.y =
(primary.y * primary.mass + other.y * other.mass) / totalMass
primary.mass = totalMass
速度使用质量加权平均,对应二维动量合并;位置也使用质量加权平均,避免合并后的中心突然跳到小天体位置。半径采用面积近似合并并设置上限:
primary.radius = Math.min(
34,
Math.sqrt(
primary.radius * primary.radius +
other.radius * other.radius
)
)
若任意一方是黑洞,合并结果继续保持黑洞类型与样式。最后只把另一方标记为死亡:
other.alive = false
这套处理没有模拟弹性碰撞、碎裂或能量辐射,但它满足当前交互目标:碰撞后对象数量减少,质量被保留,速度变化连续,画面不会长期叠着两个实体。
八、轨迹不是无限历史:每个天体最多保留 72 个采样点
开启轨迹后,每次更新都会追加当前位置:
if (this.showTrail) {
body.trailX.push(body.x)
body.trailY.push(body.y)
if (body.trailX.length > 72) {
body.trailX.shift()
body.trailY.shift()
}
}
固定上限同时解决两个问题。第一,内存不会随运行时间无限增长;第二,每帧绘制的线段数量有明确上界。假设有 n 个天体,轨迹绘制最多约为 71n 条线段,而不是与运行分钟数相关。
绘制时,源码还会根据轨迹点的新旧程度调整透明度,并根据速度调整线宽:
const speedMag = Math.sqrt(
body.vx * body.vx + body.vy * body.vy
)
ctx.lineWidth = Math.min(4, 1 + speedMag)
for (let i = 1; i < body.trailX.length; i++) {
ctx.globalAlpha =
i / body.trailX.length *
Math.min(0.9, 0.28 + speedMag * 0.22)
ctx.strokeStyle = body.color
ctx.beginPath()
ctx.moveTo(body.trailX[i - 1], body.trailY[i - 1])
ctx.lineTo(body.trailX[i], body.trailY[i])
ctx.stroke()
}
ctx.globalAlpha = 1
最后恢复 globalAlpha 很重要。Canvas 上下文是有状态的,如果不恢复,后续天体、标签或背景都可能继承上一条轨迹的透明度,出现“越画越淡”的视觉错误。
九、绘制层只读取状态,不反向修改物理模型
drawCanvas() 的职责顺序很清楚:
- 清空画布并绘制场景渐变。
- 按确定性坐标生成星点背景。
- 可选绘制引力范围。
- 绘制历史轨迹。
- 绘制当前天体。
ctx.clearRect(0, 0, w, h)
ctx.fillStyle = bg
ctx.fillRect(0, 0, w, h)
for (let i = 0; i < this.bodies.length; i++) {
if (this.showGravity) {
this.drawGravityRing(ctx, this.bodies[i])
}
}
for (let i = 0; i < this.bodies.length; i++) {
this.drawTrail(ctx, this.bodies[i])
}
for (let i = 0; i < this.bodies.length; i++) {
this.drawBody(ctx, this.bodies[i])
}
绘制函数没有修改位置、速度和质量。这个边界让暂停行为容易验证:只要不再调用 stepSimulation(),重复绘制同一状态也不会让轨道继续前进。
恒星使用径向渐变生成光晕,普通天体用高光渐变塑造球体,黑洞则叠加暗色核心、紫色范围与橙色环。它们都消费 Body 中的类型、颜色、半径和位置,因此新增场景时不需要复制一套 Canvas。

十、添加天体时,把角度、倾角和切向速度一次转换好
用户可以选择恒星、行星、卫星、小行星或黑洞,并调整质量、半径、初速度、方向和轨道倾角。放置逻辑把角度从度转换为弧度:
const angle =
(this.paramValues[3] ?? 0) * Math.PI / 180
const tilt =
(this.paramValues[4] ?? 0) * Math.PI / 180
const speed = this.paramValues[2] ?? 0
出生位置分布在画布中心附近的不同半径上,初速度方向与半径方向垂直:
const x = cx + Math.cos(angle) * spawnRadius
const y = cy + Math.sin(angle) * spawnRadius * Math.cos(tilt)
const vx = -Math.sin(angle) * speed
const vy = Math.cos(angle) * speed
这并不是三维轨道投影。倾角只压缩了屏幕上的 y 分量,没有引入 z 坐标;它的作用是让二维轨道具有可观察的扁率变化。源码界面已经把参数命名为“轨道倾角”,文章必须同时说明它是二维视觉参数,避免把它包装成完整三维轨道模型。
paramValues 更新后通过展开赋予新数组:
this.paramValues[index] =
Math.round(value * 100) / 100
this.paramValues = [...this.paramValues]
这种写法让 ArkUI 更明确地感知数组引用变化,滑块显示和后续放置逻辑能读取到一致值。
十一、复杂度预算决定最多能放多少天体
引力计算需要两两遍历,时间复杂度为 O(n²);轨迹绘制约为 O(n × 72)。源码中的预置场景规模很小:常见场景只有 2 到 3 个天体,星系碰撞场景由两组各 8 个对象组成。这个规模在教学交互中可控,但自由宇宙允许用户持续添加天体,因此性能边界不能只看预置数据。
可以用一个简单表格估算每帧需要处理的天体对:
| 天体数量 | 两两组合数 |
|---|---|
| 8 | 28 |
| 16 | 120 |
| 32 | 496 |
| 64 | 2016 |
| 100 | 4950 |
当页面出现卡顿时,排查顺序应当是:
- 先记录天体数量,而不是先怀疑 Canvas API。
- 临时关闭轨迹和引力范围,区分计算开销与绘制开销。
- 统计单次
stepSimulation()和drawCanvas()的耗时。 - 检查是否意外启动了多个定时器。
- 再决定限制自由场景数量、降低轨迹点上限,或引入空间分区。
对当前产品而言,限制可添加天体数量通常比引入 Barnes-Hut 等复杂算法更合适,因为它更容易解释,也更符合学习应用的可控场景。
十二、setInterval(16) 是目标节奏,不等于严格 60 FPS
16 毫秒常被理解为约 60 帧每秒,但 JavaScript/ArkTS 定时器只能表达“尽早在此间隔后回调”,不能保证每次都精确执行。UI 线程繁忙、窗口切换或系统调度都可能让实际间隔变长。当前代码使用固定 dt,因此设备变慢时会表现为模拟整体变慢,而不是自动追赶真实时间。
这种选择对教学应用未必是坏事。固定步长让相同初始状态更容易产生相近结果,也避免一次长时间卡顿后用超大 dt 追赶,导致天体穿透或轨道爆炸。
如果产品目标变成“不同设备上按真实时间推进”,可以记录上一帧时间并计算实际间隔,但必须给 dt 设置上限:
const now = Date.now()
const elapsedMs = Math.min(now - this.lastFrameAt, 50)
this.lastFrameAt = now
const dt = elapsedMs / 16 * 0.72 * this.speed
这同样属于后续优化方案。引入前应先决定:需要确定性教学演示,还是需要贴近墙钟时间。两种目标不能混在一起。
十三、指标更新与结果页参数来自同一份 bodies
每次推进后,页面计算天体数量、系统质量、最高速度与启发式稳定性:
const v = Math.sqrt(b.vx * b.vx + b.vy * b.vy)
fastest = Math.max(fastest, v)
totalMass += b.mass
查看结果时,页面把当前状态整理成路由参数:
router.pushUrl({
url: 'views/experiment/ExperimentResultPage',
params: {
expId: this.expId,
expName: this.title,
bodyCount: this.bodies.length,
stabilityScore: Math.round(this.getStabilityScore()),
fastestSpeed: Number(this.getFastestSpeed().toFixed(2)),
totalMass: Math.round(this.getTotalMass()),
speedScale: Number(this.speed.toFixed(1))
}
})
这保证结果页不是读取另一套缓存快照,而是基于用户点击时的当前模型生成。需要警惕的是,displayValues 和结果页分别调用了相似的统计逻辑。后续若修改稳定性公式,应提取一个纯函数或统计对象,让控制栏与结果页共享同一计算结果,避免两处显示不一致。
十四、多设备适配要验证画布,不只验证页面外框
工程在 module.json5 中声明:
"deviceTypes": [
"phone",
"tablet",
"2in1"
]
页面根节点使用 width('100%')、height('100%'),Canvas 宽度也是 100%,指标区域和天体类型区域使用横向滚动,长标题设置单行省略。这些都是对窄屏有帮助的现有实现。
但是轨道页面的适配还有三项需要真实设备或预览器验证:
- Canvas 在窗口改变尺寸后,
canvasWidth和场景中心是否重新同步。 - 固定 300 高度在横屏小窗口与大屏窗口中是否仍有合适比例。
- 2in1 使用鼠标连续点击倍率与放置按钮时,是否会造成重复启动或天体瞬间堆叠。
尤其是窗口动态缩放。onReady 适合首次拿尺寸,但不能自动代表所有后续尺寸变化。若目标设备支持自由窗口,建议监听组件尺寸变化,并在尺寸变化时选择“整体平移缩放现有天体”或“重置场景”中的一种明确策略。
十五、验证轨道推进不能只看动画“像不像”
视觉上转了一圈并不能证明状态链正确。可以按下面的最小用例验证:
用例 A:暂停不漂移
- 启动稳定双体系统。
- 记录行星在 Canvas 上的大致位置。
- 点击暂停,等待 3 秒。
- 确认位置和轨迹点数量都不再变化。
用例 B:倍率只改变推进速度
- 重置同一场景。
- 分别以
0.5x、1.0x、2.0x运行相同墙钟时间。 - 确认倍率升高后轨道推进更快,但 UI 响应没有因回调频率增加而明显恶化。
用例 C:碰撞后数量与质量
- 放置两个距离很近的天体。
- 启动后等待合并。
- 确认天体数量减少一个。
- 对比合并前后系统总质量,允许显示取整,但不应凭空丢失质量。
用例 D:离页释放
- 启动模拟后立即返回。
- 多次进入和退出页面。
- 确认没有多个计时器叠加的加速现象,也没有退出后持续计算导致的发热。
用例 E:边界与轨迹
- 给一个小天体设置较高初速度。
- 观察其离开画布较远区域后被清理。
- 长时间运行并确认每个天体的轨迹长度不会无限增长。
十六、常见问题与定位顺序
| 现象 | 优先检查 | 处理方向 |
|---|---|---|
| 点击启动后速度越来越快 | 是否存在多个 setInterval |
启动前统一调用 stopTimer() |
| 暂停后仍在移动 | isRunning 与定时器状态是否一致 |
暂停和离页都执行清理 |
| 高倍率下轨道突然飞散 | dt 是否过大 |
限制倍率或增加物理子步 |
| 两个天体贴近后数值爆炸 | 距离平方是否接近零 | 保留软化下限并测试碰撞阈值 |
| 轨迹运行越久越卡 | 历史点是否无限增长 | 设置固定长度并成对裁剪 X/Y |
| 重进页面后中心偏移 | Canvas 尺寸是否已就绪 | 在 onReady 后按真实尺寸初始化 |
| 黑洞合并后样式变普通 | 类型继承逻辑缺失 | 合并时显式保留黑洞类型 |
| 结果页与顶部指标不一致 | 两处统计公式发生分叉 | 提取共享统计函数 |
| 平板画布过矮 | Canvas 高度固定 | 用窗口断点决定合理高度 |
| 页面退出后仍有耗电 | 周期任务未释放 | 在 aboutToDisappear 兜底停止 |
定位时先确认“状态是否继续变化”,再确认“Canvas 是否画错”。如果 bodies 数据已经错误,重绘只会忠实地把错误画出来;如果数据正确但画面错误,才需要检查上下文状态、透明度恢复、尺寸或裁剪。
十七、发布前的工程检查清单
Body的位置、速度、质量和轨迹字段均有明确类型。- Canvas 尺寸就绪后再按真实中心初始化场景。
- 每次启动前先停止旧定时器。
- 暂停、返回和页面消失都能终止周期任务。
- 时间倍率有上限,不通过压缩回调间隔实现。
- 两两作用只计算一次,并同时写入双方加速度。
- 近距离有软化边界,碰撞有明确合并阈值。
- 被吸收或越界天体先标记,再在循环后统一过滤。
- 轨迹长度有限,绘制后恢复 Canvas 全局状态。
- 结果指标与结果页参数来自同一份当前模型。
- phone、tablet、2in1 分别检查窄屏、横屏和可变窗口。
- 文章与应用说明不把教学演示描述成科研级轨道计算。
十八、总结:让时间推进、生命周期和绘制各守一条边界
这个轨道模拟页面最值得复用的,不是某个渐变颜色或某组初始参数,而是三层边界:
第一,stepSimulation() 只负责从当前状态推导下一状态;它先累计加速度,再更新速度和位置,最后处理轨迹与清理。第二,时间倍率进入 dt,定时器频率保持稳定,性能开销不会跟倍率一起线性增加。第三,Canvas 只消费当前状态,启动、暂停与离页则由页面生命周期管理。
当这三条边界成立后,双体、三体、黑洞和自由场景只是不同初始条件;轨迹、引力范围和数据面板只是不同观察方式。对于 HarmonyOS 5.0 及以上的 ArkUI 教学应用,这种结构比把计算、绘制和按钮事件揉在一起更容易验证,也更容易继续扩展多设备布局、性能采样和更高倍率的子步积分。
更多推荐




所有评论(0)