如果让我用一个词形容刚开始学概率的人最需要的工具,我会毫不犹豫地说:可视化计算器。列表法求概率这类题在中小学阶段非常常见,看起来没有复杂公式,但真让学生完整列出两枚骰子、两组小球或几次抽取的全部等可能结果时,出错率往往高得吓人。正好这期 HARMONYOS 应用实例 268,我想和大家一起做一个非常简单但实用的工具:用列表法自动生成二维结果表格,同时完成命中事件的高亮显示和概率计算。
这个应用的核心思路并不复杂:用户输入两个组的结果选项,应用自动把两组结果做笛卡尔积展开,形成一张教科书里常画的交叉表。然后用户选择“全部样本”“两张结果相同”“和等于某数”“差的绝对值等于某数”等判定方式,表格里符合条件的结果会瞬间被高亮,下方的统计栏同步给出命中数量、总样本数和概率值。不管是老师课堂上演示,还是学生课后自己验证答案,都省去了手画表格的功夫。
如果你正在学 HarmonyOS 应用开发,想找一个兼顾页面布局、状态管理、动态列表增删和简单算法的综合练手项目,这个实例刚好合适。它不涉及复杂的网络请求,也不依赖后端数据库,所有逻辑都能在一个页面内跑通,非常适合作为初学者从“会写控件”过渡到“能完整实现一个工具型应用”的桥梁。下面我从产品设计思路开始,把从数据模型到页面排布再到工程调试的完整过程拆开讲一遍。
1. 列表法在概率题中的真实结构:它不过是一张二维交叉表
很多人第一次接触列表法时,会把注意力全放在“列表怎么画”上,反而忽略了它的数学结构。列表法真正处理的场景是:一次试验分成前后两个步骤,每一步的结果数量有限且互相独立,求某个事件发生的概率。例如先抽一张卡片,再掷一次骰子;或者连续抽两次球,记录每次颜色。这时把所有可能结果按行和列展开,就构成了一张二维表。
1.1 等可能结果数组的笛卡尔积,就是表格的内容
概率论里管这种展开方式叫笛卡尔积。第一组有 m 个等可能结果,第二组有 n 个等可能结果,那么整个试验的样本空间大小就是 m 乘以 n。表格里的每个格子表示“第一步出现某个结果,同时第二步出现另一个结果”的一个组合。比如两次掷硬币,第一组是 {正, 反},第二组也是 {正, 反},列表得到四个格子:正正、正反、反正、反反,每个格子概率相等且互不重叠。
这一点也正是列表法能求概率的根本原因:总数是所有格子的数量,分子则是符合条件的目标格子数量。概率就是两者之比,不需要任何复杂的排列组合公式。应用变成代码后,也是这个逻辑:两个可编辑的选项列表对应行和列,循环拼接字符串就是格子内容,用一个判断函数逐个格子筛选目标结果。
1.2 手写列表真正让人崩溃的地方
我见过不少学生,公式背得滚瓜烂熟,但一遇到“两个事件有先后顺序”的题目就开始迷。一个经典场景是掷两枚骰子,求点数之和为 7 的概率。学生知道要和为 7,但要把 36 种组合全部写对并不容易。有人遗落(1,6),有人把(3,4)写成两遍,还有些人对“第一枚 2,第二枚 5”和“第一枚 5,第二枚 2”是不是同一个结果纠结很久。
手写列表还有一个隐性成本:一旦某个选项输入错误,整张表要重画一遍。当第一组结果有 8 个、第二组也有 8 个时,64 个格子已经让横线和竖线容易交叉错乱,错位检查反而比计算本身还耗时。这个痛点很适合用程序解决,因为字典序展开、无重复枚举和高亮筛选正是计算机擅长的事情。
1.3 最小可用的功能边界:需要和不需要做什么
动手写代码前,我先给自己列了一个很克制的能力清单:
- 支持第一组选项和第二组选项的动态增删,元素内容可自定义
- 点击按钮后生成二维结果区域,行列首格显示分组标题
- 用户选择一种判定模式,目标格子用明显颜色高亮
- 同步显示符合条件格子数、总格子数和概率值
- 对空选项、重复选项做提示,避免输出无意义结果
反过来,我也明确划掉了不少“看上去有用但会拖慢进度”的功能,比如动画播放随机试验过程、自定义条件表达式、多步骤概率树等。第一版的核心价值是稳定、直观地把列表法呈现出来,功能越多越容易偏离教学场景。一个两阶段列表工具,能覆盖到的题型已经足够广:掷骰子求和、不同颜色球抽取、正反硬币组合、数字卡片配对等,全都能装进来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据处理层设计:先写命中逻辑,再想页面
界面永远比数据模型更容易让人上头。很多初学者习惯先拖几个按钮,再考虑数据结构,结果做一会儿发现功能没法实现,推倒重来。我的做法相反,任何涉及计算的工具型应用,都先把核心数据结构和判定函数写清楚,页面只是把计算结果“展示”出来。
2.1 样本集合的数据结构:字符串数组是不够的
最直觉的数据结构是两组字符串数组,比如 ['1', '2', '3', '4', '5', '6']。这种设计在生成表格时很简单,但一旦进入动态增删环节就会有隐患。用户在界面上删掉一行时,ForEach 循环需要用唯一的 key 定位每条数据。如果数组里存的是纯字符串,而用户两个选项内容一样,界面上会出现两条内容相同但本质不同的记录,删除时组件就分不清该删谁。
所以我给每组选项定义了一个简单对象:
typescript复制interface OptionItem {
id: number;
label: string;
}
id 在新增选项时自增并且永不重复,label 才是用户看到的内容。ArkUI 的循环渲染用 id 作为 key,增删时组件能准确识别变化,页面刷新不会出现复用错乱。这比把数组下标当 key 要可靠得多,尤其当用户删除中间一行时,下标会整体前移,如果 key 是下标,界面上的输入框内容可能被错误地搬到另一行。
typescript复制@State firstOptions: OptionItem[] = [
{ id: 1, label: '1' },
{ id: 2, label: '2' },
{ id: 3, label: '3' },
{ id: 4, label: '4' },
{ id: 5, label: '5' },
{ id: 6, label: '6' }
];
@State secondOptions: OptionItem[] = [
{ id: 101, label: '1' },
{ id: 102, label: '2' },
{ id: 103, label: '3' },
{ id: 104, label: '4' },
{ id: 105, label: '5' },
{ id: 106, label: '6' }
];
两组选项的 id 故意从不同起点开始计数,避免后续合并或调试时出现 id 混淆。
2.2 事件判定函数:把条件从页面交互中解耦出来
列表法里“求某个事件的概率”,关键在“某个事件”如何被描述。我最初想把条件做成自由文本输入框,让用户自己写一个判断表达式。后来发现这件事在 ArkTS 里并不可行:它不像网页 JavaScript 那样可以执行动态拼接的字符串代码,而且让用户输入任意表达式,必然会出现各种解释不清的边界错误。
我换了一种更工程化的方案:预设常见判定模式。第一版支持四种:
- 全部样本:没有筛选条件,所有格子都命 中
- 两侧结果相同:第一个选项和第二个选项内容完全一致
- 和等于指定数字:将两个选项的值转成数字后相加
- 差的绝对值等于指定数字:适用“点数和差为多少”这类题
判断函数只依赖行选项文本、列选项文本、判定模式和目标数值,不关心页面上的控件长什么样,因此可以直接抽出为一个独立方法:
typescript复制function checkHit(aLabel: string, bLabel: string, mode: string, target: string): boolean {
if (mode === 'all') {
return true;
}
if (mode === 'equal') {
return aLabel.trim() === bLabel.trim();
}
const na = Number(aLabel.trim());
const nb = Number(bLabel.trim());
if (Number.isNaN(na) || Number.isNaN(nb)) {
return false;
}
const tv = Number(target);
if (mode === 'sum') {
return na + nb === tv;
}
if (mode === 'diff') {
return Math.abs(na - nb) === tv;
}
return false;
}
这个函数的返回值决定了每个格子是否高亮。把条件逻辑集中在函数里,页面渲染时只是反复调用它,后续增加“积等于多少”这种题目时,只需要增加一个分支,不用改动表格渲染逻辑。计算概率按钮的核心逻辑同样简单:先清点每组有效选项的数量,得到总样本数,然后两层循环遍历所有组合,累计命中数量,最后把命中数量除以总样本数。
typescript复制private calcProbability(): void {
const setA = this.cleanOptions(this.firstOptions);
const setB = this.cleanOptions(this.secondOptions);
if (!setA || !setB) {
return;
}
const total = setA.length * setB.length;
let hit = 0;
for (const labelA of setA) {
for (const labelB of setB) {
if (checkHit(labelA, labelB, this.conditionMode, this.targetValue)) {
hit++;
}
}
}
this.totalCount = total;
this.hitCount = hit;
this.probabilityText = total === 0 ? '无有效样本' : (hit / total).toFixed(4);
}
2.3 输入数据要先做清洗,重复选项在列表法里是致命的
列表法对“等可能结果”有严格要求:同一个试验结果不能在集合里出现两次。假如第一组选项输入成 [1, 2, 2, 3, 4, 5, 6],看起来多了一个 2,第一组出现了 7 个元素,但字典序展开后会有两行一模一样的“数字 2”作为行标题,总格子数变成 42,而不是课本中骰子应有的 36。概率的分母错了,后面一切计算都将失真。
因此在进入判断逻辑之前,必须对用户输入的数据做一次清洗。我在 cleanOptions 方法中做了三件事:去掉 label 首尾空格,扔掉空字符串,最后用 Set 去重并保留第一出现顺序。
typescript复制private cleanOptions(options: OptionItem[]): string[] | null {
const result: string[] = [];
const seen = new Set<string>();
for (const item of options) {
const label = item.label.trim();
if (label.length === 0) {
continue;
}
if (seen.has(label)) {
this.tipMessage = `选项“${label}”重复,请删除多余项`;
return null;
}
seen.add(label);
result.push(label);
}
if (result.length === 0) {
this.tipMessage = '每个组至少需要一个有效选项';
return null;
}
return result;
}
有一次我在真机上测试,连续点击“新增选项”后,故意在两行里输入相同内容,表格没有立即报错,但总样本数明显比手算多。发现问题后我加上了这个清洗逻辑,把错误扼杀在结果展示前。界面上的重复项提示非常必要,因为如果只默默去重,用户根本不知道自己算错了原因。
3. 页面布局的核心不是输入控件,而是二维结果网格
工具型应用最忌讳把界面做成“控件堆叠”。把两组选项和计算按钮全部堆在页面顶部,会挤占结果区域的空间,手机上根本看不全。我最后定下的排布是上下两段:上半部分放可折叠的输入区,下半部分用大面积空间展示表格结果。
3.1 动态选项编辑区:用 id 管理增删,用复制数组触发刷新
每组选项的动态编辑是页面里交互最密集的地方。每个选项行都由一个输入框和一个删除按钮组成,底部有一个“+ 添加选项”按钮。这里最关键的是数组更新方式。
在 ArkUI 的 @State 中,直接执行 this.firstOptions.push(...) 或 this.firstOptions[i].label = value 不一定能触发界面刷新,因为 @State 对深层数组元素的变化监听有局限。更稳妥的做法是每次修改都形成一个新数组实例,再赋回给 @State 变量。比如删除一行时:
typescript复制private removeFirstItem(id: number): void {
this.firstOptions = this.firstOptions.filter(item => item.id !== id);
}
private addFirstItem(): void {
this.firstOptions = [...this.firstOptions, { id: this.nextId++, label: '' }];
}
修改某个输入框的内容时,不能直接改数组里的对象,而是先复制数组,替换对应 id 的对象,再重新赋值:
typescript复制private updateFirstLabel(id: number, value: string): void {
this.firstOptions = this.firstOptions.map(item =>
item.id === id ? { id: item.id, label: value } : item
);
}
这样每次赋值都会得到一个新数组引用,ArkUI 能明确感知状态变化并触发对应组件刷新。我建议在实际项目中固化这种写法,即使某些场景下直接修改数组也能生效,也不要依赖那种非标准行为,毕竟真机系统和 API 版本升级后表现可能不一致。
界面里每行输入框的 ForEach key 生成器直接返回 item.id.toString(),删除选项时组件能正确定位。如果你手抖用数组下标做 key,删除第二行时,第三行的输入框内容会被组件复用,看起来就像“删错了行”,这种 bug 非常难排查。
3.2 结果区不用 Grid 组件,两层 ForEach 反而更合适
列表法展示的区域天然是二维表格,许多人第一反应是用 Grid 组件。但 Grid 组件适合做九宫格或信息流,行列等高宽的表格布局用它并不方便,尤其还需要第一列显示行标题、第一行显示列标题时,Grid 的数据模型需要额外维护行列首格信息,反而绕路。
我的实现方式是外层 Row 套内层 ForEach,整体包在一个可纵向滚动的 Scroll 容器中。每个候选格子的宽度固定,行标题和列标题也占同样的宽度,这样视觉上就是一张完整的交叉表。为了不让整个页面把所有格子一次画出来导致卡顿,我在外层 Scroll 中限制最大高度,让表格内容超出后自然滚动。
typescript复制Scroll() {
Column({ space: 4 }) {
// 表头行:左上角空格 + 列标题
Row({ space: 4 }) {
Text('结果')
.width(CELL_WIDTH)
.height(CELL_HEIGHT)
ForEach(this.secondOptions, (item: OptionItem) => {
Text(item.label)
.width(CELL_WIDTH)
.height(CELL_HEIGHT)
.backgroundColor('#F1F3F5')
.textAlign(TextAlign.Center)
}, (item: OptionItem) => item.id.toString())
}
// 每个行标题 + 这一行所有格子
ForEach(this.firstOptions, (rowItem: OptionItem) => {
Row({ space: 4 }) {
Text(rowItem.label)
.width(CELL_WIDTH)
.height(CELL_HEIGHT)
.backgroundColor('#E9ECEF')
.textAlign(TextAlign.Center)
ForEach(this.secondOptions, (colItem: OptionItem) => {
Text(this.cellContent(rowItem.label, colItem.label))
.width(CELL_WIDTH)
.height(CELL_HEIGHT)
.backgroundColor(
checkHit(rowItem.label, colItem.label, this.conditionMode, this.targetValue)
? '#FFD43B'
: '#FFFFFF'
)
.textAlign(TextAlign.Center)
}, (item: OptionItem) => item.id.toString())
}
}, (item: OptionItem) => item.id.toString())
}
}
.padding(12)
.backgroundColor('#F8F9FA')
表头位于顶部,具有明显的背景色;左侧列是行标题,颜色和表头稍作区分。命中的格子用醒目的黄色高亮,没有命中的格子保持白色。这样整张表哪些格子属于目标事件,扫一眼就能看清。
单元格内容我原本打算显示为 rowItem.label + '-' + colItem.label,后来发现显示成“3-4”这种字符串虽然直观,但在格子较小的时候阅读不便。最后我改成直接显示两个 label 用逗号拼接的紧凑文本,如果 label 本身很短(如“红”和“1”),页面可读性会好很多。
3.3 概率结果区:不能让用户自己数格子
列表法的教学场景里,学生最需要的是“数出命中格子数”这一步。有些学生手写表格后,拿铅笔一个个圈出符合条件的格子,圈完之后还得小心翼翼数数,经常数着数着被旁边格子的颜色干扰。应用端把这步自动化,既可以降低验证成本,也能帮助学生对照自己手算的结果找到遗漏。
结果区我放在表格区域上方,设计成三个独立统计卡片并排展示:命中格子数、总样本数、概率值。命中数显示类似“8”,总数类似“36 / 种”,概率显示四位小数并同时转换为百分比,例如“0.2222(22.22%)”。在初次进入页面时,概率默认不显示或显示为“请点击计算”,防止用户误以为页面自带的默认数据已经计算过。每次计算完成后再填充变化,如果用户改了选项或判定模式,结果区域会自动清空待计算状态,避免旧结果误导判断。
这个细节来自我的一次实际使用经历:我给朋友演示这个应用,输入骰子两组数据后,页面已经高亮了和等于 7 的格子,但下方的概率还停留在上一次“和为 6”的 13.89%。如果只看概率栏,会得到完全错误的结论。所以我给选项编辑、条件变更的每个入口都加上 clearResult() 调用,保证结果区始终与当前输入一致。
4. 真机运行与 HarmonyOS 4.2 开发调试中的实际问题
这个实例功能不复杂,我原本预计写代码的时间远大于调试时间,结果真机调试图省事还是遇到了几个坑。下面集中讲讲那些不踩不知道的细节。
4.1 Select 组件的选项值变化没有触发条件刷新
我的判定模式用 Select 组件展示,下拉菜单里的选项是写死的“全部样本”“相同”“和等于”“差绝对值”。Select 选中项通过 onSelect 回调拿到索引,我再用索引映射到对应模式字符串。早期实现时,我直接在 onSelect 中修改 this.conditionMode,但忘了调用 clearResult(),结果每次切换条件只改变高亮,统计数字不变。
后来我在所有能影响判定结果的状态入口都统一塞进了 resetAndRefresh() 方法。这个方法负责三件事实:更新条件模式或目标值,清空旧统计结果,立即调用一次全表刷新函数。否则就会出现页面数据和状态不同步的隐蔽 bug,光看代码逻辑完全没问题,只有真实操作时才会发现。
4.2 输入框里输入数字,键盘弹出时把结果表格顶出屏幕
为了让用户方便输入“和等于几”的目标值,我把 TextInput 的 type 设成了 InputType.NUMBER_DECIMAL。但这带来一个副作用:键盘弹出时,如果页面里包含 Scroll 区和多行输入组件,系统自动避让键盘后,表格区域会被压缩得非常小。用户输入完数字,键盘一收,表格还没刷新,看起来好像白写了一样。
应对方法有两个思路:一是把输入目标值的 TextInput 移到页面最顶部,这样键盘弹出时遮住的是下方表格,输入完手指往上滑就能看见;二是在键盘弹出期间不强制刷新结果,而是在计算按钮点击之后再做滚动定位。对这类单页工具应用,我选择让输入区整体保持紧凑,输入目标行放在选项区最后,用户输入完直接点计算,视觉路径更连贯。
4.3 开启无线调试把应用装到 HarmonyOS 4.2 真机上
我在模拟器里调试完主要功能后,决定换到真机上跑一遍。HarmonyOS 4.2 版本支持无线调试,但需要做几步设置。
首先,手机进入“设置”的“关于手机”,连续点击版本号,直到系统提示已处于开发者模式。然后进入“系统和更新”中的“开发人员选项”,打开“USB 调试”。如果不想一直插着数据线,可以同时打开“无线调试”。打开无线调试后,系统会显示一个 IP 地址和端口号,比如 192.168.1.23:5555。
在 DevEco Studio 中,配置远程设备时填入这个 IP 和端口。首次连接需要手机弹窗确认,勾选允许后,会建立调试通道。如果命令行工具比较顺手,也可以直接敲:
bash复制hdc list targets
hdc connect 192.168.1.23:5555
连接成功后,DevEco Studio 的 Run 按钮会自动编译 HAP 包并安装到手机上。实测下来,这种无线调试方式对局域网稳定性有一定要求,偶尔会出现安装超时或连接中断。遇到这种情况不要马上怀疑应用代码有 bug,先看 DevEco 的 Device 面板连接状态,断开重连一次往往就好了。在 4.2 上跑完真机后,我明显感觉页面中两个 ForEach 嵌套渲染在 6x6 格子下毫无压力,即使加到 12x12 也就是 144 个轻量组件,流畅度依然没有问题。
4.4 导出 HAP 时的手写签名问题
开发阶段用 DevEco 自动签名会直接生成临时证书,真机运行没有问题。但要分享给别人安装,需要导出 HAP 包。如果你不是企业开发者账号,首次创建签名配置时有一些坑:项目标识、证书指纹、Profile 描述文件必须全部匹配,否则安装时会提示“安装失败原因:签名校验失败”。我遇到过一次 profile 文件的 bundleName 与实际包名不一致,排查了二十分钟才发现只是配置时手误填错了包名。
对教学类共享场景,一个更轻量的办法是让接收方也用 DevEco 打开源码工程,由 DevEco 自动给对应设备生成签名后运行,这样不涉及证书的手工配置。虽然步骤对小白来说多了一点,但比到处传 HAP 更省心。
5. 让这个“最小工具”变成能进课堂的教学交互件
第一版功能的代码量并不大,核心页面一个 .ets 文件基本能装下所有逻辑。但它天然具备教学属性,在实际课堂场景打磨后可以往下延伸出不少有价值的功能。这里挑三个我觉得最值得扩展的方向。
5.1 从两步试验延伸到三步动态树图
列表法只适用于“两步试验”这种场景。一旦变成“先抽颜色、再抽数字、最后掷硬币”这种三步事件,二维表就变成三维立方体,任何平面表格都无法直接呈现。但三道程序的规律是相通的:每一步相当于在已有路径上增加一条分支。可以把当前列表页里的结果看作“树图某一层的节点”,下一层选项的笛卡尔积结果作为新的行数据源,从而画出一棵动态展开的概率树。这个扩展难度不小,但对教学过程很有价值,能让学生直观看到“树图”和“列表法”实际上指向同一个计算逻辑。
5.2 把当前结果保存成题目快照
老师备课的时候,同一道题可能需要微调数字再给学生布置。如果应用支持把选项组、判定模式、目标值打包成一条“题目链接”,通过分享卡片或二维码传给其他设备,就能大大减少重复录入选项的工作量。HarmonyOS 的跨端分享能力很适合做这个,用户只需记录一行 JSON 字符串,接收端解析后自动恢复页面状态。我试过把序列化格式设计得非常简单,直接存 firstOptions、secondOptions、conditionMode、targetValue 四个字段,四五个不同的场景就能快速切换。
5.3 高亮动画与计数器联动,解释“频率接近概率”
概率教学中最容易产生误解的点是“概率等于频率”。课堂上很多学生做十几次实验,得到的结果和理论概率差很远,就会开始怀疑公式。如果把高亮格子用一种脉冲动画表现,每闪一次对应一次模拟抽取,连续动画几十次、几百次,累计频率逐渐趋向概率值,这个现象会比任何文字更有说服力。第一版只做了静态高亮,后期完全可以在命中格子闪烁同时让命中计数器逐次增加,让列表从“计算结果”升级为“模拟试验”。教学场景下的应用,向来是“直接给答案不如让学习者看见答案是怎么逼近的”。
我开始做这个实例时,预期只是一个普通的小工具 Demo,但实际完成后最深的感受是:像列表法求概率这种很小的数学问题,也值得用完整的产品化思维去设计。数据结构要不要解决重复项、表格布局怎么在手机上容纳 36 个格子、输入状态变化后结果区如何防止误导,这些小决定叠加起来,决定了一个演示应用能否真正被老师放心带上讲台。如果你也想复刻这期 HARMONYOS 应用实例 268 的完整开发过程,最有效的顺序是先照着文章把数据模型和概率判定函数跑通,再填充页面布局,最后再考虑真机调试和分享分发。代码里真正重要的核心逻辑不过几十行,但围绕它做出来的可靠性处理,才是那八千行工程价值所在。
