我是在批改概率作业时突然冒出这个想法的——学生做“连续两次摸球,不放回,求两次都摸到红球的概率”这类题,十个里有六七个会漏分支或者把乘法算错。树状图明明是概率启蒙阶段最好用的工具,孩子们却总觉得“画图麻烦”。于是我想,能不能做一个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 c
