前阵子给一个做几何教学工具的朋友演示 HarmonyOS 应用开发,他提了个需求:能不能用手机上的 Canvas 做一个“点到直线距离”的动态证明,让垂线段最短这件事不再是课本上一句干巴巴的结论。于是就有了这个实例 202。整个项目不依赖复杂框架,核心就三件事:ArkUI Canvas 画点线、向量投影算垂足、手势拖拽加定时器驱动重绘。对刚接触 ArkTS 的开发者来说,这是一个把 UI 状态、Canvas 绘制和基础数学结合得很完整的练手项目;对做教育类应用的团队来说,这套交互思路也可以直接复用到其他几何可视化场景。
这篇文章我会把从数学原理到工程实现、再到真机调试的完整过程都写出来,包括几处常规文档里不会讲的坑。你如果正在学 HarmonyOS 的 Canvas 绘图,或者想做一个类似的数学可视化小工具,按这篇的操作顺序走一遍,基本能独立跑起来。
1. 这个示例要解决什么问题
先说清楚成品长什么样。打开应用后,屏幕上有一条由 A、B 两个端点决定的直线,还有一个可以拖动的点 P。系统会自动从 P 向直线作垂线,垂足记为 H,并实时显示 P 到直线的距离数值。除此之外,我还在直线上放了一个辅助点 Q,它可以在自动演示模式下滑动,每到一个位置就会画出一段 P 到 Q 的蓝色连线。因为 H 是垂足,只要 Q 没有和 H 重合,蓝色线段长度一定大于红色垂线长度,Q 滑到 H 附近时两者长度会无限接近。这个“看得见的比较”就是动态证明的核心。
1.1 为什么选“点到直线距离”这个题目
从教学角度看,点到直线距离是初中到高中几何里非常重要的概念,它建立在线段长度、直角三角形、坐标运算多个知识点之上。从 HarmonyOS 开发角度看,这个题目不会引入太复杂的图形,却刚好覆盖了 Canvas 绘制中最常用到的能力:画直线、画圆点、画虚线、写文字、处理触摸事件。如果再加入坐标轴和网格,视觉上会立刻有很强的专业感,适合作为教学演示而不是普通的练习 demo。
选这个题还有个好处,就是它的数学计算有简洁的解析解。只需要两个向量的点积,就能算出垂足坐标和距离值,不需要迭代、不需要解方程组。这意味着代码可以把重点放在 UI 交互和渲染逻辑上,不会被底层算法拖住,是对新手最友好的难度区间。
1.2 动态“证明”如何被展示出来
数学意义上的严格证明需要写推理过程,但可视化工具能做的是提供一个“可探索的直观证据”。我在应用里设置了三条观察路径:第一,拖动点 P 到直线附近任意位置,红色垂线长度始终是当前 P 到直线上所有点里最短的,这个结论由投影公式保证;第二,打开自动演示模式,Q 点从直线一端滑向另一端,蓝色线段长度会动态变化,但无论跳到哪个位置,界面上的数值都不会小于红色垂线距离;第三,当 Q 恰好滑到 H 时,两个点重合,蓝色距离与红色垂线距离相等。这个“无限接近又刚好相等”的动态过程,能让观者很自然地接受垂线段最短。
为了让对比更明显,我在页面下方放了一排只读的数字栏,分别显示当前 P 的坐标、垂足 H 的坐标、垂线距离 d 和当前辅助距离 |PQ|。Q 滑动时,|PQ| 的数值快速刷新,d 始终不变。第一次看到这种对比的测试者,几乎都会产生“哦,原来真的是它最短”的感觉。这个设计是我在实际演示中不断调出来的,初始版本只画线段没有数值,效果差很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数学原理与绘制方案
进入编码前,先花点时间把投影公式讲清楚。这个公式是整个应用的“发动机”,后面所有 Canvas 操作都围绕它展开。
2.1 投影法求垂足与距离
已知直线上两个点 A(x1, y1)、B(x2, y2),以及直线外一点 P(x0, y0)。我们想求 P 到直线 AB 的垂足 H,以及距离 d。常规做法是把直线 AB 看作一个向量方向,再把点 P 相对 A 的向量向 AB 方向做投影。公式如下:
首先计算向量 AB:dx = x2 - x1,dy = y2 - y1。然后计算点 P 在 AB 方向上的投影参数 t:
t = ((x0 - x1) * dx + (y0 - y1) * dy) / (dx² + dy²)
这个 t 的几何含义是:垂足 H 在 AB 方向上的位置,相对于 A 点的比例。t = 0 时 H 与 A 重合,t = 1 时 H 与 B 重合,t 在 0 和 1 之间说明垂足落在线段内部,t 小于 0 或大于 1 说明垂足落在线段延长线上。得到 t 之后,垂足坐标就是:
Hx = x1 + t * dx
Hy = y1 + t * dy
最后用勾股定理算出距离:
d = sqrt((x0 - Hx)² + (y0 - Hy)²)
很多初学者会犯一个错误:求“点到直线距离”时把 t 强制限制在 0 到 1 之间。这个 clamp 操作是求“点到线段距离”时才需要的。点到直线的垂足本来就可能落在延长线上,如果强行 clamp,d 就不再是点到直线的垂直距离了。我在代码里特意保留了完整的 t 值,只有在给用户展示“垂足落在线段内还是延长线上”时,才会额外做一个标记。
2.2 动态证明的点位设计:P、H、Q
整个画面里有三个关键角色:P 是被测点,H 是垂足,Q 是辅助对比点。H 不是给人拖的,它由 P 的位置实时计算出来。Q 则有两种驱动方式:一种是用户直接拖动,另一种是自动演示模式下由定时器控制沿直线滑动。
默认状态下,我把直线两端点和 P 都放在能让垂足落在线段内的位置,这样画出来的图形最干净。A 点默认在 (100, 500),B 点默认在 (500, 100),P 点默认在 (420, 420)。代入公式后,dx = 400,dy = -400,t 算出来正好是 0.5,所以垂足 H 在 (300, 300),距离约 169.7 像素。这个默认点位不是随手写的,是我特意挑出来的:t = 0.5 时 H 位于 AB 中点,图形左右对称,第一眼看上去最直观。
Q 点沿直线滑动,本质是让参数 t 从负值慢慢增加到超过 1。直线方程是以 A 为起点、AB 为方向向量的,Q 的坐标就写成:
Qx = x1 + tq * dx
Qy = y1 + tq * dy
其中 tq 是 Q 当前的参数。默认的自动演示范围是 -0.15 到 1.15,让 Q 稍微越过两端,这样用户能看到直线延长线上的情况,也能看到 P 到直线外一点的连线长度变化。
2.3 屏幕坐标与数学坐标的转换
Canvas 的坐标系原点是左上角,x 轴向右,y 轴向下。而我们在数学课本里通常画的坐标系 y 轴向上。这个差异不会影响投影公式的正确性,因为向量投影和垂直关系都是基于代数关系计算的,与坐标轴方向无关。如果你要做的是三角函数图像或抛物线这类对方向敏感的内容,才需要把 y 轴翻转。
我在这版应用里没有做翻转,直接使用 Canvas 的像素坐标。这样做的最大好处是触摸事件的坐标可以直接参与计算,不需要再做一次坐标变换,减少了一类很隐蔽的 bug。如果你希望以后扩展成教科书风格,注意一个原则:参与计算的所有 y 值都要统一使用同一个坐标系,不要在绘制时用翻转后的 y,计算时却用原始 y,混用必出问题。
3. 工程搭建与页面骨架
这一节从创建工程开始讲,已经熟练的读者可以直接跳到第 4 节看核心代码。
3.1 DevEco Studio 工程准备
应用基于 HarmonyOS 4.2 开发,我在本机使用的 DevEco Studio 版本是 4.0 之后的稳定版,SDK 选择 API 10 以上即可。创建工程时选择 Empty Ability 模板,语言会自动使用 ArkTS。工程名我建议命名为 PointLineDistance,包名可以按你自己的域名倒序填写,不涉及任何特殊权限。
创建完成后,首页在 entry/src/main/ets/pages/Index.ets。这个示例不需要申请任何敏感权限,也不需要配置网络,工程的默认配置就够用。唯一建议做的是在 module.json5 里把 Ability 的 orientation 锁为竖屏,因为横屏状态下布局调试起来会花额外时间,教学类应用竖屏观看也更自然。
3.2 页面布局与 Canvas 挂载
页面采用最简洁的上下结构:上方是一个占满剩余空间的 Canvas,下方是一个固定高度的信息栏。我建议先这样搭,不要一上来就加复杂的侧边栏或者悬浮按钮,布局越简单就越容易把问题定位到绘图逻辑上。
在 ArkUI 里创建一个 Canvas 需要两步:先声明一个 RenderingContextSettings 和 CanvasRenderingContext2D,然后在 build 方法里使用 Canvas 组件并把 context 传给它。代码如下:
typescript复制private settings: RenderingContextSettings = new RenderingContextSettings(true)
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
build() {
Column() {
Canvas(this.ctx)
.width('100%')
.layoutWeight(1)
.backgroundColor('#F7F9FC')
.onReady(() => {
this.initDefaultPoints()
this.drawScene()
})
.onTouch((event: TouchEvent) => {
this.handleTouch(event)
})
Row() {
Text(this.infoText)
.fontSize(15)
.fontColor('#2E3A46')
.padding(12)
}
.width('100%')
.justifyContent(FlexAlign.Start)
.backgroundColor('#FFFFFF')
}
.width('100%')
.height('100%')
}
注意第 3 行 RenderingContextSettings(true) 的参数表示开启抗锯齿。绘图类应用建议保持 true,否则线段边缘会有明显锯齿。Canvas 的 onReady 回调只会在组件首次布局完成后触发一次,我把初始化点坐标和第一次绘制都放在这里面,确保后续手势操作时 context 已经可用。
下方的 Text 绑定了一个字符串状态 infoText,每次计算完距离后我会更新这个字符串。有意思的地方是,Canvas 内容不会因为 @State 改变自动重绘,但普通 UI 组件会,所以把需要频繁变化的数值放在 Text 上,把静态几何图形放在 Canvas 里手动重绘,这种组合是 ArkUI 里比较实用的做法。
4. 核心代码:从直线到距离的动态绘制
这一节直接给出可以落地的核心代码,并按绘制顺序拆解每段逻辑。
4.1 数据结构与点位初始化
我定义了两个简单接口来管理二维坐标点,一个是直线端点,一个是被测点和辅助点。同时用一个布尔标记记录“直线是否退化成了点”,防止除零错误。
typescript复制interface Point2D {
x: number
y: number
}
private lineA: Point2D = { x: 100, y: 500 }
private lineB: Point2D = { x: 500, y: 100 }
private pointP: Point2D = { x: 420, y: 420 }
private pointQ: Point2D = { x: 180, y: 420 }
private footH: Point2D = { x: 0, y: 0 }
private lineDegenerate: boolean = false
private distance: number = 0
这里 pointQ 的默认值我放在了直线参数 tq = 0.2 对应的位置,也就是 (180, 420)。选这个默认值是有讲究的:Q 和 P 的 y 坐标相同,所以界面上一开始就会画出一条水平的蓝色辅助线,和红色的垂直距离形成非常直观的对比,用户一眼就能看出蓝色长度更大。
坐标点需要被拖动,所以紧接着写命中检测。四个点在触摸时都可能被选中,检测优先级也很有讲究。我给的判定顺序是 P、Q、A、B,因为在直线上 Q 和 A、B 可能重叠,如果优先命中端点,用户想拖 Q 时会很难受。候选点列表里用名称区分,选中后返回对应名称:
typescript复制private hitTest(x: number, y: number): string {
let threshold = 40
let result = 'NONE'
let candidates = [
{ name: 'P', p: this.pointP },
{ name: 'Q', p: this.pointQ },
{ name: 'A', p: this.lineA },
{ name: 'B', p: this.lineB }
]
for (let i = 0; i < candidates.length; i++) {
let item = candidates[i]
let d = Math.hypot(x - item.p.x, y - item.p.y)
if (d <= threshold) {
return item.name
}
}
return result
}
threshold 取 40 是我在真机上反复试出来的。手指的触摸面积远大于鼠标指针,如果在 30 以下,细小的目标点非常难拖;如果超过 50,两个点距离稍近时又容易误触。40 在大多数手机上表现不错。平板可以适当调到 50,这个参数建议按设备尺寸做可配置。
4.2 绘制坐标系网格与整条直线
为了让画面有“数学坐标纸”的感觉,我每一帧都会先画浅色网格和坐标轴。网格间距固定为 40 像素,颜色用非常浅的蓝灰色,避免喧宾夺主。绘制坐标轴的代码就是简单地画两条贯穿全屏的线,只是颜色更深一些。
接着是画直线。直线不能只画 A 到 B 的线段,否则拖动点 P 到端点附近时,观察者会误以为线只有这么长。我的做法是沿着 AB 方向把两端延长到画布外面,Canvas 绘制本身会做裁剪,多画的部分不会溢出。实现上要注意方向向量的归一化,否则延长距离会和线段长度强耦合:
typescript复制private drawFullLine(ctx: CanvasRenderingContext2D, a: Point2D, b: Point2D) {
let dx = b.x - a.x
let dy = b.y - a.y
let len = Math.hypot(dx, dy)
if (len < 1e-6) {
this.lineDegenerate = true
return
}
this.lineDegenerate = false
let ux = dx / len
let uy = dy / len
let extension = 2000
let startX = a.x - ux * extension
let startY = a.y - uy * extension
let endX = b.x + ux * extension
let endY = b.y + uy * extension
ctx.beginPath()
ctx.moveTo(startX, startY)
ctx.lineTo(endX, endY)
ctx.strokeStyle = '#3A6EA5'
ctx.lineWidth = 2
ctx.stroke()
}
extension 设为 2000,足够覆盖主流手机宽度的 Canvas。如果碰巧你的页面容器特别大,可以把 2000 改成一个更大的值,或者动态取 Canvas 宽高的两倍。不要用 Infinity,部分渲染管线会对无限值处理异常。
4.3 投影计算与垂足绘制
投影计算是每帧核心中的核心。我封装成一个 updateGeometry 方法,在每次点和直线变化后调用,统一更新 footH 和 distance:
typescript复制private updateGeometry() {
let ax = this.lineA.x
let ay = this.lineA.y
let bx = this.lineB.x
let by = this.lineB.y
let px = this.pointP.x
let py = this.pointP.y
let dx = bx - ax
let dy = by - ay
let lenSq = dx * dx + dy * dy
if (lenSq < 1e-9) {
this.lineDegenerate = true
this.distance = 0
return
}
let t = ((px - ax) * dx + (py - ay) * dy) / lenSq
this.footH.x = ax + t * dx
this.footH.y = ay + t * dy
this.distance = Math.hypot(px - this.footH.x, py - this.footH.y)
this.infoText = `直线: A(${Math.round(ax)},${Math.round(ay)}) B(${Math.round(bx)},${Math.round(by)})`
+ ` P(${Math.round(px)},${Math.round(py)})`
+ ` H(${Math.round(this.footH.x)},${Math.round(this.footH.y)})`
+ ` d=${this.distance.toFixed(1)}px`
}
注意这里没有对 t 做 0 到 1 的 clamp,因为整个演示的语义是“点到直线”的距离,不是“点到线段”。当用户把 P 拖到某个极端位置导致垂足落到 AB 延长线上时,UI 上仍然会正确画出垂足到 P 的垂线,只是这条垂线不再与 AB 线段相交,而是与它的延长线相交。这个细节值得在代码注释里写明。
画垂线时,我用红色加虚线表示从 P 到 H 的垂线段,这样视觉上就和直线本身的蓝色区分明显。再在 H 位置画一个小空心圆并标注“H”。绘制垂线代码:
typescript复制ctx.beginPath()
ctx.moveTo(this.pointP.x, this.pointP.y)
ctx.lineTo(this.footH.x, this.footH.y)
ctx.strokeStyle = '#E63946'
ctx.lineWidth = 3
ctx.setLineDash([8, 5])
ctx.stroke()
ctx.setLineDash([])
虚线画完后一定要记得把 setLineDash 重置为空数组,否则后面所有绘制都会带上虚线样式。这个坑我第一次写的时候踩到了,画完垂线再画 Q 点时,Q 的圆点外圈也变成了一截一截的,排查了很久才想起来是线段样式没有复位。
4.4 手势拖拽流程
触摸事件的处理分成三个阶段:按下时做命中检测,移动时更新坐标,抬起时复位拖拽状态。我使用一个成员变量 dragType 记录当前正在拖动的点名称:
typescript复制private dragType: string = 'NONE'
private handleTouch(event: TouchEvent) {
if (event.touches.length === 0) {
if (event.type === TouchType.Up) {
this.dragType = 'NONE'
}
return
}
let tx = event.touches[0].x
let ty = event.touches[0].y
if (event.type === TouchType.Down) {
this.dragType = this.hitTest(tx, ty)
}
if (this.dragType !== 'NONE' && (event.type === TouchType.Move || event.type === TouchType.Down)) {
if (this.dragType === 'P') {
this.pointP.x = tx
this.pointP.y = ty
} else if (this.dragType === 'Q') {
let t = this.getParamFromScreenPoint(tx, ty)
this.updateQByParam(t)
} else if (this.dragType === 'A') {
this.lineA.x = tx
this.lineA.y = ty
} else if (this.dragType === 'B') {
this.lineB.x = tx
this.lineB.y = ty
}
}
if (event.type === TouchType.Move || event.type === TouchType.Down || event.type === TouchType.Up) {
this.updateGeometry()
if (this.dragType === 'P' || this.dragType === 'A' || this.dragType === 'B') {
this.drawScene()
}
}
}
Q 点的拖动和普通点不一样,它不能随便移动到屏幕任意位置,而是必须被约束在直线上。即使手指偏移到直线之外,Q 仍然应该在直线上找到最接近手指的位置。所以这里调用 getParamFromScreenPoint,把屏幕坐标换算成直线参数 t,再更新 Q。换算本质上是把手指位置在 AB 方向上做一次投影,和求垂足的思路一致。
5. 交互与动效:让证明“动”起来
图形能拖动之后,这个应用只能算完成了一小半,动态证明的灵魂在于动效。这一节讲自动演示和状态刷新的实现细节。
5.1 Q 点沿直线自动滑行
自动演示模式是我给应用加的最核心功能。开启后,Q 点会自己沿着直线从一端滑到另一端,蓝色辅助线长度实时变化,用户不需要动手就能观察距离比较关系。实现用 setInterval 驱动,每 16 毫秒让参数 tq 增加一个固定步长,当 tq 超过 1.15 时重置到 -0.15 重新开始。
typescript复制private timerId: number = -1
private autoMode: boolean = false
private qParam: number = 0.2
private toggleAutoDemo() {
this.autoMode = !this.autoMode
if (this.autoMode) {
if (this.timerId !== -1) {
clearInterval(this.timerId)
}
this.timerId = setInterval(() => {
if (!this.autoMode) {
this.stopAutoDemo()
return
}
this.qParam += 0.012
if (this.qParam > 1.15) {
this.qParam = -0.15
}
this.updateQByParam(this.qParam)
this.updateGeometry()
this.drawScene()
}, 16)
} else {
this.stopAutoDemo()
}
}
private stopAutoDemo() {
if (this.timerId !== -1) {
clearInterval(this.timerId)
this.timerId = -1
}
}
步长 0.012 是我估出来的一个比较舒服的滑动速度。如果步长太大,Q 跳帧明显;太小则滑动速度太慢,观察者容易失去耐心。你也可以把步长做成和直线长度相关,让不同长度的直线滑动速度保持一致。
自动演示运行时用户仍然可以拖动 P 点,这是有意为之的。我的实测经验是,当 Q 滑到接近 H 的位置时,如果观察者顺手把 P 往远处拖,蓝色线段和红色垂线会同时变长,但蓝色始终领先一截,这个交互很容易让人联想到“对任意位置的 P,垂线段都是最短”。
5.2 绘制 Q 与辅助线
Q 点使用醒目的橙色实心圆,和 P 点的红色空心圆区分开。绘制辅助线 PQ 时采用较浅的蓝色,透明度也降低,让它不抢垂线的视觉优先级。我还加了两个标签:在线段 PQ 的中间显示实时长度,让用户不用看底部信息栏也能感受到数值变化。
在 Canvas 中显示文字时要注意两点。第一,ctx.font 必须设置字号和字体族,否则部分设备上的默认渲染会偏小;第二,fillText 的文字基准线是 alphabetic,如果不想手动调整 y 坐标,也可以给每个标签加一个小背景矩形,增加可读性。我的做法是给蓝色辅助线上的标签加一个白色半透明背景矩形,这样即使用户拖到文字和网格重叠的位置,数字仍然清晰可读。
typescript复制private drawLengthLabel(ctx: CanvasRenderingContext2D, x: number, y: number, text: string, color: string) {
ctx.font = '14px sans-serif'
let metrics = ctx.measureText(text)
let textWidth = metrics.width
let padding = 6
let rectX = x - textWidth / 2 - padding
let rectY = y - 10 - padding
ctx.beginPath()
ctx.fillStyle = 'rgba(255, 255, 255, 0.75)'
ctx.roundRect(rectX, rectY, textWidth + padding * 2, 28, 6)
ctx.fill()
ctx.beginPath()
ctx.fillStyle = color
ctx.fillText(text, x - textWidth / 2, y + 4)
}
roundRect 在 API 10 之后的 Canvas 2D 里可用。如果你在旧版本系统上测试发现没有这个方法,可以把背景矩形画成普通 fillRect,不影响功能。
5.3 状态刷新策略:哪些用 @State,哪些不用
很多新手会在 Canvas 绘图中把一个图形参数数组声明为 @State,期望拖拽时自动刷新,结果发现画布不更新,于是又去翻事件绑定。这里要说明一个关键点:Canvas 的 2D 内容本质上是过程式绘制,普通 @State 变化不会自动重绘 Canvas 上的图形。你需要主动调用绘制函数。
那 @State 还有用吗?有,而且很重要。页面下方的 Text 信息栏需要随拖拽实时刷新,这部分就必须用 @State 绑定一个字符串。我在项目里把 infoText 声明为 @State,在 updateGeometry 里更新它,Text 组件就能自动刷新。图形部分则维护在普通成员变量里,每次操作完成后手动调用 drawScene。
区分“需要状态驱动的 UI”和“需要手动绘制的 Canvas 内容”,是 ArkUI 混合开发的一个核心思路。不要试图让 Canvas 上的每个点都由状态系统驱动,那样性能开销大,思维负担也重。更合理的做法是:让状态去驱动文本、按钮这类声明式组件,用 CanvasRenderingContext2D 的绘制 API 去处理几何图形,两者通过一个统一的刷新入口协调。
6. 真机调试与常见问题实录
这部分内容来自我实际调试 HarmonyOS 4.2 时的经验。模拟器和预览器能跑通大部分功能,但 Canvas 的触摸体验必须在真机上验证。
6.1 开启无线调试并用 hdb 连接
HarmonyOS 4.2 开始,使用 DevEco Studio 进行无线调试比以前方便很多。我的做法是先把手机通过 USB 连接电脑,确认可以正常安装应用后,再开启无线调试。开启步骤是:设置 -> 关于本机 -> 连续点击版本号 7 次进入开发者模式,然后进入设置 -> 系统和更新 -> 开发人员选项,打开 USB 调试和无线调试。
无线调试打开后,手机会分配一个调试端口。比较常用的方法是查看开发人员选项里的“无线调试”页面,它会显示当前设备的 IP 和端口。然后在电脑终端使用 hdb 命令连接,hdb 是 DevEco Studio 自带的调试工具,路径一般在 DevEco Studio 的 command-line-tools/bin 目录下:
bash复制hdb connect 192.168.1.100:5555
hdb devices
如果连接成功,hdb devices 会列出这台设备。之后在 DevEco Studio 里点 Run,选择这台无线设备,就能像 USB 连接一样安装和调试应用。无线调试的意义在这类 Canvas 应用中尤其明显,因为你要频繁地拖动图形查看触摸反馈,拖着数据线操作很别扭。
我遇到过一次 hdb connect 一直提示认证失败的情况。检查后发现是手机上的无线调试端口变化了,原来记下的端口已经失效。解决方法是回到无线调试页面,重新确认当前 IP 和端口,再执行一次 connect。建议每次连接前都先看一眼端口号,不要盲目信任历史记录。
6.2 Canvas 不刷新或画面残留
开发过程中最容易遇到的问题就是拖动点之后,旧图形还留在画布上,新图形又画上去,整个画面一团糟。根本原因是没有在每帧重绘前清空画布。我在 drawScene 的开头固定加
