我是在批改概率作业时突然冒出这个想法的——学生做“连续两次摸球,不放回,求两次都摸到红球的概率”这类题,十个里有六七个会漏分支或者把乘法算错。树状图明明是概率启蒙阶段最好用的工具,孩子们却总觉得“画图麻烦”。于是我想,能不能做一个HarmonyOS应用,把树状图求概率的过程直接画出来,让学生用手指点一点、拖一拖就能看清每次分支怎么走、概率怎么乘。
这个实例做完之后,我把它编号为个人HarmonyOS应用实例第267个。它的核心能力很简单:输入一个分步随机试验,比如抛两次硬币、掷两次骰子、从袋子里连续摸球,应用自动在画布上画出一棵完整的树状图,每一条边标注对应概率,每个叶子节点自动算出这条路径的联合概率。同时支持点击节点、在线修改分支名称和概率,实时重算整棵树的概率分布。对HarmonyOS开发者来说,它是一份非常好的ArkUI + Canvas + 递归算法的综合练习;对数学老师或家长来说,它是课堂上可以直接打开使用的概率演示工具。这篇文章不打算贴全量工程代码,只把最核心的设计决策和实现手法拆开讲,顺便把我调试时踩到的坑一并交代。
1. 场景复盘:为什么概率题需要树状图而不是公式硬算
1.1 树状图在概率教学中的不可替代性
概率题入门最大的障碍,不是乘法或加法公式本身,而是“到底有哪几种情况”。像“先后抛两枚硬币”这种简单模型,学生凭直觉也能列出正正、正反、反正、反反四种。可一旦变成“掷一颗骰子,如果是偶数就再抛一次硬币”,或者“袋子里4个球分两色、无放回连续取两次”,靠空想列表就很容易漏项。
树状图的天然优势在于它把“试验步骤”变成了“树的层”。每一步随机结果对应一层分支,从根到叶子的每一条路径就是一种完整的基本事件,不存在排列组合时的重复或遗漏。更重要的是,树状图把“概率乘法公式”从抽象符号变成了视觉动作:沿着边往下走,每走一段就乘一次边上写的概率,走到叶子得到的结果就是该路径的概率。学生能直观看出“第一次摸到红球、第二次也摸到红球”这条事件链是怎么一步一步变窄的。
但我以前用PPT画树状图也非常痛苦:节点一多要手动对齐,改一个概率整张图画全要重排。所以当我把这个教学需求迁移到HarmonyOS应用时,第一个要求就是:树要自动画,分支要自动排,概率要自动算。
1.2 我为什么最终选了HarmonyOS + Canvas这条技术路线
其实做树状图可视化有很成熟的Web方案,ECharts的tree图随口就能引进来,甚至用PPT自带的SmartArt也能画个大概。但我想做的是深度参与HarmonyOS开发的案例,不是单纯做一个网页再套个WebView。从应用属性看,树状图教学工具特别适合平板场景——HarmonyOS的平板横屏时宽度足够,学生可以同时看树形结构和概率分布,还能和老师通过分布式流转把同一张画布投到教室大屏。
技术路线方面,我用ArkUI的Canvas而不是一堆Row/Column嵌套组件来画树。原因后面细说,先给结论:树状图的连线、分支、节点圆角矩形,本质上是几何图元操作;如果全部用弹性布局组件搭,遇到几十个叶子节点时布局代码会非常绕,交互高亮路径也很麻烦。Canvas把树的绘制收敛到一套坐标系里,所有节点坐标和连线都能用同一个布局算法统一算出,后续加缩放手势、加路径动画都更容易。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型解构:组件递归与Canvas绘图的边界
2.1 为什么没有用嵌套组件来做树结构
第一次动手时,我的第一版其实是用组件的:每个节点用一个Column包住,子节点用Row横向排开,整个树递归下去,理论上也能渲染。但很快发现三个问题:一是节点多时最外层的宽度怎么撑开很难控制,如果某一层有6个分支,而另一层只有2个分支,每层必须单独计算宽度占比,否则节点会重叠;二是节点之间的连线没有对应的组件,必须用Stack叠着放一条绝对定位的线,而绝对定位坐标又依赖每个节点的测量结果,等于绕了一圈还是得写布局算法;三是点击某个节点时,要沿父链高亮所有祖先节点,用组件递归时子向父通信很麻烦,状态得一层一层传。
Canvas方案就清爽很多。节点坐标、父子连线、文本标签都只是绘制命令,点按事件可以统一监听;拿到点击位置后,逆推出命中哪个节点,再决定要不要高亮路径。实时交互和绘制逻辑彻底解耦,所以我最终选定:@State treeData存放整棵树的树形数据,CanvasRenderingContext2D负责把树画出来,节点命中和编辑操作直接改treeData再触发重绘。
2.2 Canvas绘图的核心结构与刷新机制
在ArkUI中,Canvas的使用比Web Canvas多一些规矩。常规写法是:
typescript复制private settings: RenderingContextSettings = new RenderingContextSettings(true)
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
build() {
Column() {
Canvas(this.context)
.width('100%')
.height('70%')
.onReady(() => {
this.drawTree()
})
}
}
注意这个onReady只在画布就绪时触发一次,并不是每次状态变化都会自动触发。如果像传统“声明式UI思维”一样天真地以为@State treeData变了,画布就会自动重绘,那第一次跑通后会发现:数据改了,界面纹丝不动。正确做法是自己维护一个轻量刷新信号,比如用一个@State treeVersion: number做计数器,每次修改树结构后把treeVersion++,然后在类似onAppear的位置或按钮回调里调用this.drawTree()。
更好的做法是用Canvas的onReady里把画布尺寸缓存下来,再定义一个redraw()方法,在任何操作函数的最后调用。只要保证redraw()执行时读取的是最新treeData,重绘就没问题。实践下来这是个“画布命令式刷新”和“UI声明式更新”的配合问题,理解了这一点,后面很多卡壳就迎刃而解。
2.3 数据结构和状态管理的坑
ArkTS对强类型要求比较严格,我早期用一个匿名Object存储树节点,写起来轻松,但后续访问字段时类型报错频繁,而且嵌套深的属性修改后不一定触发UI更新。后来老老实实设计了类:
typescript复制class TreeNode {
id: string
name: string
x: number
y: number
prob: number
edgeProb: number
children: TreeNode[]
parentId: string
}
这里的edgeProb表示“从父节点走到自己这条边的概率”,prob表示“从根节点累积到自己的总概率”。布局时使用的x、y是缓存坐标,并不从组件系统里获取。
状态管理方面,ArkUI的@State默认只能观察到第一层属性的变化。如果直接把@State tree: TreeNode扔给子组件,子组件改了某个叶子节点的name,父组件很可能感知不到。为了避免这条最经典的坑,我处理得非常保守:任何编辑操作都基于完整的树对象重新生成一个根节点引用。例如给节点添加子节点时,先递归复制树,再在节点children中加入新节点,最后整体赋值给treeData。用一点内存换来稳定可预期的状态刷新,在这种教学工具场景下是完全值得的。
3. 核心实现全景:从一棵树到一个概率值
3.1 树节点数据结构与初始化
树状图求概率应用最基础的需求是“预设几个常见试验”,例如抛两次硬币、掷两次骰子、袋中摸球等。每个试验都可以初始化成一棵树。以“抛两次硬币”为例:
- 根节点:试验开始
- 第一层:第一次抛硬币,两个子节点分别是“正”“反”,每条边概率 1/2
- 第二层:第二次抛硬币,每个第一层节点下又挂“正”“反”,每条边概率 1/2
初始化时,我写了一个通用的构建函数。为了演示方便,这里用伪代码风格给出“抛两次硬币”的构建逻辑:
typescript复制function createCoinTree(): TreeNode {
const root = new TreeNode('root', '开始', 1.0, 1.0)
root.children = ['正', '反'].map((side1, index1) => {
const node1 = new TreeNode(`${index1}`, side1, 0.5, 0.5)
node1.children = ['正', '反'].map((side2, index2) => {
const node2 = new TreeNode(`${index1}-${index2}`, `${side1}→${side2}`, 0.5, 0)
return node2
})
return node1
})
return root
}
构造函数第三个参数是父节点走到自己的概率edgeProb,第四个参数是累乘后的总概率prob。叶子节点的总概率需要在初始化和每次修改后统一计算,不能只在节点创建时算,因为一旦中间某条边的概率被用户手动修改,所有后代概率都要跟着变。
3.2 分层布局算法:叶子坐标先定,父节点取均值
树状图画得好不好看,布局算法占一半。横轴方向如果简单按“深度等分”处理,兄弟节点多的层和少的层铁定重叠。我的做法是经典的自底向上布局:
- 先递归遍历整棵树,找出所有叶子节点。
- 把叶子节点按照从左到右的顺序放在画布的水平等分点上,保证最左边叶子到最右边叶子均匀铺满。
- 从叶子层往回推,父节点的x坐标取所有子节点x坐标的平均值。这样父节点天然居中于它的孩子,且兄弟子树之间不会交叉。
- y坐标按深度值计算,比如
y = 节点深度 * 层高 + 顶部边距。
核心递归代码近似如下:
typescript复制function layout(node: TreeNode, depth: number, leafIndex: { value: number }) {
node.y = depth * levelHeight + topMargin
if (node.children.length === 0) {
node.x = (leafIndex.value + 0.5) * canvasWidth / totalLeafCount
leafIndex.value++
return
}
node.children.forEach(child => layout(child, depth + 1, leafIndex))
node.x = node.children.reduce((sum, child) => sum + child.x, 0) / node.children.length
}
这里需要先计算totalLeafCount,才能知道叶子等分间距。我用两次遍历:第一次数叶子总数,第二次真正分配坐标。如果整个树只有一层或深度很小,坐标分布也仍然成立。这个算法本身很简单,但它是树状图不重叠最关键的保障,值得单独拎出来讲。
3.3 画布绘制:圆角矩形、贝塞尔曲线和文本
得到所有节点的坐标后,绘制阶段就变成了一个纯粹的Canvas API调用问题。每个节点画一个圆角矩形作为视觉容器,矩形中间写上节点名称;父节点到子节点之间画一条带落差的曲线或折线,边上写“概率 x/z”。
绘制节点的核心方法类似:
typescript复制context.beginPath()
context.moveTo(fromX, fromY)
context.bezierCurveTo(
(fromX + toX) / 2, fromY,
(fromX + toX) / 2, toY,
toX, toY
)
context.stroke()
用贝塞尔曲线而不是直线,好处是当树比较深时,相邻分支的连线不会显得生硬,视觉上更容易追踪一条路径。但是要注意:曲线占比不要过大,否则节点密集时曲线会侵占旁边节点的空间。我把贝塞尔的控制点放在水平中点,垂直方向保持不变,正好形成一条平滑的S形连接线。
边上的概率文字我记得放在曲线中点向上偏移的位置,并用小一号的字号绘制。当一个父节点有6个子节点时,六条曲线的概率文字很容易重叠,我实测后的经验是:概率文字只显示在曲线前半段,即靠近父节点的位置,或者干脆统一在分支线段的1/3处绘制,整棵树会清爽很多。
3.4 叶子概率的累计与展示
每一个节点的总概率要遵守乘法原理。计算时从根节点开始:
typescript复制function calculateProb(node: TreeNode, accumulatedProb: number) {
node.prob = accumulatedProb * node.edgeProb
node.children.forEach(child => calculateProb(child, node.prob))
}
根节点的边缘概率是1.0,所以第一层子节点累乘后就是自己的边缘概率。叶子节点的prob就是最终联合概率。应用界面下方还会显示“穷举事件各结果统计”,把每一片叶子的名称和概率列成表格,并验证所有叶子概率之和是否为1。如果用户手改概率导致总和不为1,界面会立刻高亮提示,这个实时反馈对教学特别有用。
4. 让案例可交互:预设场景与动态调整
4.1 预设案例库怎么组织
为了快速演示,我在应用首页放了一排“示例试验”按钮,内置预设案例用表格维护最方便:
| 案例名称 | 分支结构 | 概率分布 | 适用教学点 |
|---|---|---|---|
| 抛两次硬币 | 第一层2分支,第二层2分支 | 正正/正反/反正/反反各1/4 | 独立事件、等可能 |
| 掷两次骰子 | 第一层6分支,第二层6分支 | 36种等可能结果 | 基本事件总数计数 |
| “有放回”摸球 | 3红2蓝,连续两次 | 红红/红蓝/蓝红/蓝蓝 | 放回独立实验 |
| “不放回”摸球 | 3红2蓝,连续两次 | 第二次概率受第一次影响 | 条件概率直观理解 |
点击任意预设案例,程序会删除旧树并初始化新树,触发一次重绘。此时所有布局、概率计算逻辑都不用改,只需要传入不同的构建函数,所以后续扩展新案例成本很低。我把预设案例构造成一个数组,数组中每一项只是{ title: string, buildTree: () => TreeNode },点击事件用统一的加载函数处理。
4.2 交互式添加分支和修改权重
如果仅仅支持预设案例,它还只是一个“演示器”,不是“学习工具”。我的目标是让学生自定义情景,比如“袋子里有3个红球、2个蓝球、1个绿球,连续抽两次,第一次抽到红球后不放回,第二次抽到蓝球的概率是多少”。这种问题如果预设案例库没有,学生就会被卡住。
所以我又做了两个编辑入口:单击已有节点后,底部弹出操作面板,可以修改该节点的名称和边概率,也可以给该节点增加一个子分支。增加子分支时,默认会给当前节点的现有子节点们平均重新分配概率,新增节点平分一份概率。这样能保证同一父节点下的所有边概率之和始终为1,省去学生自己反复调配的麻烦。
修改权重时,我把概率输入设计成“分子/分母”两个独立数字,而不是一个浮点数输入框。原因是初学者对“改成0.333”远没有对“改成1/3”直观,而且使用整数分式还能避免把概率调成循环小数后的视觉困惑。每次修改后,会触发整棵树的概率重算和画布重绘,所有后代节点的prob同步更新。
4.3 分数形式展示与约分
概率教学中分数比小数更好懂,所以界面默认使用分数显示每条边的概率和叶子节点概率,比如“1/2”“2/5”而不是“0.5”“0.4”。实际数据存储还是用浮点数或分子分母对,展示前再转成字符串。
在“不放回摸球”这类动态概率场景中,第二次分支的概率可能是“3/4”或“2/4”,必须约分才能显示成“1/2”。这里用了经典辗转相除法:
typescript复制function gcd(a: number, b: number): number {
return b === 0 ? a : gcd(b, a % b)
}
function formatProb(numerator: number, denominator: number): string {
const g = gcd(numerator, denominator)
const n = numerator / g
const d = denominator / g
return d === 1 ? `${n}` : `${n}/${d}`
}
在树的状态数据里,我会额外保存每个节点的概率分子和分母,而不仅仅是浮点数。如果只存浮点数,组合概率时需要手动通分或者从浮点反推分数,很容易产生0.30000000000000004这样的诡异结果。这部分是我做了几版之后才改好的,也提醒了所有要做数学工具的朋友:涉及分数展示的最好存分数运算,浮点只做最终校验用。
5. 真机调试与数据校验:我踩过的几个硬坑
5.1 hdb连接不上真机怎么排查
我在这个项目前期使用模拟器开发,一切正常,一上HarmonyOS 4.2真机就遇到了连接问题。DevEco Studio有时显示“device not found”,这时需要用到基于HarmonyOS的调试桥hdb。常用的排查流程如下:
- 在真机上打开“开发者选项”,确认允许USB调试。
- 用数据线连接电脑,执行
hdb devices,看设备是否被列出。 - 如果没列出,检查驱动以及是否被其他调试工具占用端口。
- 如果列出但DevEco Studio仍不识别,尝试先执行
hdb kill再执行hdb start,然后重新拔插USB线。
我遇到的情况是电脑同时开了多个终端占用hdb进程,导致IDE识别不到设备。杀掉多余进程后,真机列表立刻恢复正常。从此我习惯在调试前先把不相关的hdb端口占用的进程清掉。
5.2 树状图渲染性能与滚动冲突
双骰子案例需要画36个叶子节点,加上中间层节点,总共节点数量超过了40个,对Canvas来说本身压力不大。真正的问题是如果把Canvas放在Scroll容器里,手势会冲突:用户想滚动查看下方概率表格时,手指在Canvas上滑动却被识别成画布上的点击/拖拽。解决方法是区分容器的滚动区域:上面Canvas固定高度并内部设置最小可点击区域,下方结果列表独立Scroll;Canvas的高度根据树的深度动态计算,如果很深就干脆不做手势拖拽,只提供缩放按钮。
我在树状图比较深的时候遇到过绘制性能下降,主要问题不是Canvas绘制本身,而是我每次重绘前用context.clearRect()清屏,但没有清干净边缘,多次操作后画布上残留了之前绘制的浅色痕迹。后来统一在redraw()方法一开始执行:
typescript复制context.clearRect(0, 0, canvasWidth, canvasHeight)
context.fillStyle = '#ffffff'
context.fillRect(0, 0, canvasWidth, canvasHeight)
再绘制所有元素,画布就不会出现“鬼影”。这个小问题排查起来花了我半小时,实际上就是清屏没清全。
5.3 概率值不等于1的排查思路
在交互编辑模式下,用户可能给同一父节点下加了一堆子节点,然后逐个输入概率,很容易出现所有子节点概率之和不是1的情况。为了提升容错性,我做了两个层面的处理:一是修改单个概率时,检测同层兄弟节点的概率之和;二是点击“校验”按钮时,遍历所有内部节点,如果发现子节点概率和不等于1,就在节点上方画一个红色警告三角标,并显示差额。
有一次用户反馈:不管怎么改,叶子节点算出来的总概率始终显示为0。我排查后发现是因为节点树里存在没有父节点引用的游离节点——布局算法遍历不到它们,但可视化绘制时路径图还显示着旧数据。最终我给每次重新数据构建后增加了一个“孤儿检测”方法,检查每个节点是否都能从根节点通过children链访问到,如果发现游离节点,先在数据层删除它,再进入绘图函数。这个处理虽然看起来和概率无关,但恰恰是很多动态增删节点工具容易漏掉的脏数据问题。
6. 从树状图到概率思维的更多玩法
6.1 把静态树变成实验模拟器
现在这个工具可以画出精确的树状图,算出理论概率。但我进一步构思了一个更受欢迎的扩展:给每个叶子节点加一个“按概率模拟”按钮,让应用在后台把当前案例重复模拟多次(比如500次或2000次),每模拟一次就沿着树从根节点按照边概率随机走到叶子,最后在底部用一个柱状图展示模拟频率。学生可以直接看到:树状图上的理论概率是1/4,实验500次后频率可能落在0.24到0.27之间,多模拟几次就越来越接近25%。
这个扩展其实不复杂,树的节点结构已经包含边概率,只要每次从根节点按概率随机选择下一层,走到叶子记录一次即可。它把“概率”从静态计算变成了动态实验,对学生理解大数定律很有帮助。我目前已经加入了最基础的500次模拟入口,后续还可以把动画过程画出来:直接用高亮颜色一条一条描边当前路径,形成粒子流动的视觉效果。
6.2 作为ArkUI学习的综合案例价值
如果你正在系统学习HarmonyOS应用开发,我真心建议拿这个树状图项目练手。它看起来不大,却把很多基础且重要的知识点串联了起来:@State和状态更新时机、Canvas画布的自绘与刷新、递归数据结构、动态数组增删、数学计算与格式化、真机hdb调试,还有最容易被忽略的界面滚动区域划分。完成这个项目后,再去做更复杂的自定义绘图应用会顺手很多,因为你已经理解了“命令式绘制与声明式状态需要自行同步”这一核心矛盾。
我见过很多初学者尝试花里胡哨的动画项目,结果因为数据结构和绘制没有解耦,越写越乱。树状图求概率这个案例的好是从根上就给了一个很清楚的树形数据模型,剩下的绘制只是把模型可视化。只要数据模型稳定,无论你是用Canvas画还是换成文字节点布局,都可以快速迁移。
6.3 后续接入题目识别与自动建树
这个工具下一步我心里比较明确的路线是:接入端侧OCR能力,让学生用摄像头拍一道概率大题的文字描述,应用自动识别出“几次”“几种颜色”“是否放回”等关键信息,然后自动生成对应的树状图。这个想法在HarmonyOS上并不遥远,很多端侧大模型和OCR能力都已经有成熟接口,核心工作还是在“自然语言到树状结构”的语义解析。等真正做完,它就不再只是教学演示工具,而是能帮学生从文字题直接跳转到可视化概率树的智能学习助手。
不过这部分工程量还比较大,我现在的树状图应用还在小范围课堂试用的打磨阶段。即便不做OCR,手动配置分支和概率也已经能覆盖绝大多数古典概型问题。课堂教学时最常被学生追问的一句是:“老师,如果第一次摸到红球之后不放回,第二次袋子里的球数会变,这个树上怎么体现?”这个问题在树状图上其实非常直观——同一层的第二次摸球分支,如果挂在不同颜色的第一次节点后面,后面那层的概率文字会显示“2/4”或“3/4”,学生一眼就看到条件的变化。这种“看得见条件变化”的价值,正是树状图求概率这个题材值得做成应用的原因。
我在实际开发中最深的一个体会是:不要急着写界面,先把树模型的逻辑想透。树状图求概率看起来是个绘图问题,本质却是递归数据结构的设计问题。节点坐标可以算错再调,状态刷新慢一点也可以忍,但只要树本身的数据关系是清晰的,所有功能都只是往这棵树上添枝加叶。如果你也要写类似的教学可视化工具,建议先花一个晚上把树的数据结构、遍历顺序、概率累积的递归函数写在纸上,再打开DevEco Studio,后面会顺手非常多。
