鸽巢原理模拟器:HarmonyOS状态管理与Canvas动画实战
1. 先搞懂鸽巢原理再谈模拟器设计做到HarmonyOS应用实例第116个我决定拿一个数学里出了名的废话定理来做点有意思的交互。鸽巢原理很多人中学就听过大意是把n1个东西放进n个抽屉那么至少有一个抽屉里至少有两个东西。听起来像废话但它在计算机领域、组合数学、数据处理里都是个极其锋利的工具——哈希冲突、生日悖论、排序下界证明背后都是它。鸽巢原理模拟器要做的事是把这句废话变成能亲手操作、眼睛看得到的实验拖动滑块设定鸽子数量和鸽巢数量应用随机把鸽子一只只塞进巢里然后告诉你哪只巢挤进了几只并且统计多次实验结果来证明那条必然性。这个项目适合两类人。一类是刚学HarmonyOS开发、想找一个不复杂但五脏俱全的练手应用的开发者——它涉及状态管理、Canvas绘制、定时器动画、边界数据处理都是一线开发里天天碰的东西另一类是有点好奇心的数学爱好者想看看抽象原理在屏幕上到底长什么样。我把这一类实例定位成用技术手段做一个能玩的教学工具代码量不大坑却不少写一篇文章把这些实操要点完整记录下来。1.1 数学原理与边界条件不要只记背n1只鸽子鸽巢原理最朴素的形式是若有n个巢、n1只鸽子必然存在一个巢里至少住着两只鸽子。这个结论本身不产生哪只巢只保证存在。但它有很多变体做模拟器之前必须想清楚支持几种判定方式。第一是经典判定鸽子数g大于巢数h那么至少有一个巢的鸽子数达到ceil(g/h)。比如15只鸽子和6个巢平均每个巢2.5只向上取整就是3所以至少有一个巢有3只鸽子。应用要在结果展示里明确标出哪个巢达到了这个上限让用户直观看到拥挤发生在哪里。第二是广义判定即使鸽子数可能小于或等于巢数也不代表不会出现拥挤。比如3只鸽子2个巢必然出现拥挤但3只鸽子放进5个巢随机分配时仍然有一定概率出现某个巢住了2只。这个模拟器不替用户决定是否存在拥挤而是把所有鸽子随机落位后在可视化结果中标注每个巢的实际入住数同时用拥挤标记标识出这个巢集是否违反了经典鸽巢约束。这是做工具和做练习题的关键差异——工具应该把判定交给逻辑把解释交给用户。第三是边界情况鸽子数为0时所有巢为空程序不能崩溃巢数为0时无论多少鸽子都无处可放这种输入必须拦下来。我实测过程中发现很多人自己写这类小工具时压根不会考虑这两个边界结果一划滑块就白屏后面会专门说这个问题。1.2 模拟器的功能定位从演示到实验最初我想得很简单就是把N只鸽子画进M个巢里。但做着做着发现如果只是静态画一张图那完全可以用PS出图何必写应用。于是我把功能往实验平台的方向拉参数自由调节鸽子总数1~50鸽巢总数1~20通过滑块实时调整。为什么要设成巢最多20因为屏幕画不下太多巢而且鸽巢数过多时经典鸽巢判定反而不容易触发演示效果会变差。随机落位点击分配按钮所有鸽子随机选择巢穴。这模拟的是任意一种分配方式都必然导致拥挤的数学事实。动画播放入巢过程鸽子不瞬间到位而是一只一只飞进巢里。这个过程既增加观赏性也把每只鸽子选一个巢的映射关系可视化出来——动画播完后拥挤巢穴会打上高亮边框。多轮统计连续跑100次随机分配统计出现拥挤巢的轮次占比。真实结果必然是100%这个统计用来强化鸽巢原理的必然性而且写统计代码的时候还能顺带练一下定时器和状态批处理。这四块功能加起来才称得上模拟器而不是画板。我在设计开始时列了一下页面结构顶部是参数控制区中间是画布展示区底部是统计输出区。数据流向是单向的参数 → 算法 → 结果对象数组 → Canvas重绘。1.3 技术选型的三个理由为什么用HarmonyOS做第一ArkTS的声明式UI非常适合这种状态驱动的可视化。鸽子动画、巢穴高亮、统计数字全都由几个状态变量派生出来。改一个滑块值所有依赖它的界面元素自动刷新不用像传统命令式UI那样到处手动调用刷新方法。这种开发模式和数据模型驱动视图的数学模拟器逻辑天然契合。第二Canvas绘制能力足够。HarmonyOS的Canvas组件提供完整的2D绘图API画椭圆鸽子身体、画线条巢穴支架、画弧线屋顶、填充高亮边框都能直接搞定不需要引入第三方图表库。对一个小工具来说自绘比引库更可控包体积也更小。第三分布式能力虽然这个场景用不上但HarmonyOS的跨端适配很省心。我做完之后顺手在平板和手机上各跑了一遍只要改一下断点布局、把Slider和按钮按宽度自适应排列小屏大屏都正常。如果用传统Android自定义View同样的适配工作要写更多代码。这是我选择这个平台做实例的最直接原因。2. 工程搭建与数据模型项目起步最容易踩坑的部分2.1 开发环境与工程入口开发这类应用你需要DevEco Studio我用的版本是4.0及以上然后创建Empty Ability工程选择Stage模型。工程入口是EntryAbility这个文件不用大改。真正要写代码的地方是pages/Index.ets页面。我不会在这个项目里拆多个页面——功能集中一个页面足够。组件拆分倒是做了两个一个PigeonHoleGrid子组件负责画巢一个StatisticPanel子组件负责显示统计结果。这样主页面代码不会膨胀到几百行读起来舒服。Stage模型下别忘了在module.json5里确认abilities配置正确。有个小坑如果你改了入口页面路径没有同步改module.json5里的mainElement配置模拟器会直接白屏报错报错日志还不明显是经典的代码没问题但跑不起来。2.2 数据模型里状态管理比类设计更重要先定义数据结构。我有两个类// 鸽巢的运行时状态 class HoleState { holeId: number 0 count: number 0 pigeonIds: number[] [] isOverflow: boolean false } // 鸽子的动画状态 class PigeonState { id: number 0 x: number 0 y: number 0 targetX: number 0 targetY: number 0 isFlying: boolean false color: string #2E75B6 }这里有个在HarmonyOS开发里特别值得强调的点State装饰器只对直接赋值敏感对对象内部的属性变化不敏感。如果你写State holes: HoleState[]然后在代码里执行holes[0].count 3界面不会刷新。因为State监听的是数组引用变化不是数组元素内部数据的变化。正确的做法有两种一是每次都重新给数组整体赋值this.holes [...this.holes]或者干脆构造新数组替换旧数组二是用Observed和ObjectLink装饰器让子组件去监听嵌套对象变化。对这个小项目我选择第一种方案简单直接、可预期代码也直观。后面我会把分配逻辑和更新逻辑放在一起每次分配完都直接this.holes newHoles保证刷新。2.3 界面层级结构控制区、画布区、统计区页面结构用一个垂直Column包三个区域Column ├── 参数控制区Row │ ├── 鸽子数量文字 Slider1~50 │ ├── 鸽巢数量文字 Slider1~20 │ └── 运行按钮 / 重置按钮 ├── Canvas画布占剩余空间使用Flex weight └── 统计区Text Progress这个层级非常常规但关键细节在Canvas的尺寸适配。Canvas如果直接设置固定宽高在手机上会被截断如果设置layoutWeight(1)让它撑满剩余空间它的实际宽高要到onAreaChange回调里才能正确获取。我写了一个canvasWidth和canvasHeight变量在onAreaChange里更新再调用一次重绘方法。这个问题看着小但不处理的话画出来的鸽巢会偏在左上角一小块区域第一次遇到时排查了很久。3. 核心逻辑与动画可视化从算法到屏幕的完整链路3.1 随机分派算法简单但要保证可复现分配算法的核心只有几行private dispatch(): void { // 1. 生成每只鸽子的目标巢穴 const assignments: number[] [] for (let i 0; i this.pigeonCount; i) { const targetHole Math.floor(Math.random() * this.holeCount) assignments.push(targetHole) } // 2. 根据分配结果构造新的HoleState数组 const newHoles: HoleState[] [] for (let h 0; h this.holeCount; h) { const hole new HoleState() hole.holeId h hole.count 0 hole.pigeonIds [] hole.isOverflow false newHoles.push(hole) } for (let p 0; p assignments.length; p) { const holeIndex assignments[p] newHoles[holeIndex].count newHoles[holeIndex].pigeonIds.push(p) } // 3. 根据鸽巢原理判定哪些巢属于必定拥挤 const threshold Math.ceil(this.pigeonCount / this.holeCount) for (let h 0; h newHoles.length; h) { if (newHoles[h].count threshold threshold 2) { newHoles[h].isOverflow true } } // 4. 整数组替换触发State刷新 this.holes newHoles this.assignments assignments }第1步生成的assignments数组是关键它不仅决定最终分布还驱动动画动画播放时每只鸽子飞向assignments[i]对应的巢穴。所以动画和结果共用同一份数据不会出现动画飞过去的结果和统计结果不一致的问题。这里有一个值得注意的数学细节threshold的计算是向上取整。比如11只鸽子放5个巢ceil(11/5)3判定标准是至少有一个巢达到3只。有时候大家直觉会认为平均2.2只所以至少2只但鸽巢原理的严格形式要求向上取整否则判定会不准确。模拟器采用了严格判定显示拥挤阈值3只这样用户看到数字时能理解并验证。这个细节也是我写代码前先做数学推导的原因——工具类应用逻辑正确性比样式炫酷更重要。3.2 用Canvas把鸽巢画出来细节全在坐标计算里画布绘制主要分三层巢穴层、鸽子层、高亮层。每次重绘先把画布清空然后依次画三样东西。巢穴坐标计算是第一个难点。假设画布宽W、高H鸽巢数holeCount巢穴区域高度取画布高度的65%上方留出空间给鸽子飞行动画每个巢洞直径holeDia由水平方向均分决定holeDia min(W / (holeCount 1), 80)1是为了留左右边距巢穴中心holeCenterY H * 0.6每个巢的x坐标是holeX[i] W/2 (i - (holeCount-1)/2) * (holeDia gap)这样所有巢水平居中鸽子坐标也依赖巢穴坐标。初始时所有鸽子集中在画布左侧的一个待分配区里动画开始后第i只鸽子的目标坐标是第assignments[i]个巢的巢口位置。绘制鸽子用简单的椭圆加翅膀弧线private drawPigeon(ctx: CanvasRenderingContext2D, p: PigeonState): void { // 身体 ctx.beginPath() ctx.ellipse(p.x, p.y, 10, 7, 0, 0, Math.PI * 2) ctx.fillStyle p.color ctx.fill() // 翅膀小弧线 ctx.beginPath() ctx.arc(p.x - 2, p.y - 4, 8, Math.PI * 0.9, Math.PI * 0.1) ctx.strokeStyle #555555 ctx.lineWidth 2 ctx.stroke() // 尾巴 ctx.beginPath() ctx.moveTo(p.x 6, p.y) ctx.lineTo(p.x 13, p.y - 3) ctx.lineTo(p.x 13, p.y 3) ctx.closePath() ctx.fillStyle #A0522D ctx.fill() }画巢穴时巢体是一个半圆弧加底部线条巢口上方画一排小圆孔代表这个巢还有几个空位。巢穴是否拥挤通过高亮框和顶部颜色条区分。拥挤的巢用橙红色边框正常巢用灰色边框视觉对比非常明显。3.3 动画时序别让定时器和状态打架飞行动画的核心是一个定时器每16毫秒约60fps触发一次位置更新。每只鸽子的坐标按线性插值向目标点移动private startAnimation(): void { if (this.timer ! -1) { clearInterval(this.timer) } // 初始化每只鸽子的起点和终点 this.pigeonStates [] for (let i 0; i this.pigeonCount; i) { const p new PigeonState() p.id i p.x 30 Math.floor(i / 8) * 24 p.y 20 (i % 8) * 18 p.targetX this.holeX[this.assignments[i]] 8 p.targetY this.holeY[this.assignments[i]] - 15 p.isFlying true this.pigeonStates.push(p) } this.timer setInterval(() { let allArrived true for (let p of this.pigeonStates) { if (!p.isFlying) continue const dx p.targetX - p.x const dy p.targetY - p.y const dist Math.sqrt(dx * dx dy * dy) if (dist 2) { p.x p.targetX p.y p.targetY p.isFlying false } else { // 每次移动剩余距离的20%形成先快后慢的缓动效果 p.x dx * 0.2 p.y dy * 0.2 allArrived false } } // 在State声明的canvas重绘标记上取反触发Canvas重绘 this.animFrame this.animFrame ? false : true if (allArrived) { clearInterval(this.timer) this.timer -1 this.isRunning false } }, 16) }关键技巧在this.animFrame this.animFrame ? false : true。在ArkUI里Canvas不会自动重绘你需要显式触发一次状态更新让渲染管线调用CanvasRenderingContext2D的重绘方法。我起初直接把绘画逻辑放在定时器的回调里执行画面确实动了但每次重绘都要手动调用ctx.clearRect并且整屏重画在低端设备上有轻微闪烁。后来改成只更新状态、由渲染管线统一驱动重绘闪烁问题基本消失。说到底动画的实现方式是个选择题数据驱动推荐还是命令式绘制兜底。小项目用数据驱动代码更整洁也更好维护。3.4 实验统计把必然变成数据多轮实验功能实现起来不复杂但有个边界值得记录连续跑100次实验时定时器动画还在播放中用户又点了运行按钮新旧动画会互相打断。我在按钮点击入口加了一个状态判断startExperiment() { if (this.isRunning) { // 如果正在播放动画先强制停止并清理定时器 if (this.timer ! -1) { clearInterval(this.timer) this.timer -1 } } // 重置本轮的分配结果 this.dispatch() this.startAnimation() }统计逻辑本身很简单跑100次分配不看动画直接算结果。每次dispatch()后检查holes中是否有任意一个isOverflow为true。由于鸽巢原理保证必然有统计结果永远是100%。我特意在统计面板上加了句文案多轮实验命中率100%这不是巧合是鸽巢原理的铁律。这个文案既点题也把数学结论直接传递给用户。4. 完整核心代码主页面骨架与关键方法4.1 主页面组件结构Entry Component struct PigeonHoleSimulator { State pigeonCount: number 12 State holeCount: number 5 State holes: HoleState[] [] State pigeonStates: PigeonState[] [] State animFrame: boolean false State experimentResult: string State isRunning: boolean false private settings: { canvasWidth: number canvasHeight: number holeX: number[] holeY: number[] } { canvasWidth: 0, canvasHeight: 0, holeX: [], holeY: [] } private assignments: number[] [] private timer: number -1 private experimentRuns: number 0 private experimentHits: number 0 build() { Column({ space: 12 }) { // 参数控制区 Row({ space: 12 }) { Text(鸽子: ${this.pigeonCount}) .fontSize(16) Slider({ value: this.pigeonCount, min: 1, max: 50, step: 1 }) .layoutWeight(1) .onChange((value: number) { this.pigeonCount value this.resetSimulation() }) Text(鸽巢: ${this.holeCount}) .fontSize(16) Slider({ value: this.holeCount, min: 1, max: 20, step: 1 }) .layoutWeight(1) .onChange((value: number) { this.holeCount value this.resetSimulation() }) Button(分配) .onClick(() this.startExperiment()) Button(重置) .onClick(() this.resetSimulation()) } .padding(12) // 画布区 Canvas(this.canvasCtx) .layoutWeight(1) .width(100%) .onAreaChange((oldV: Area, newV: Area) { this.settings.canvasWidth Number(newV.width) this.settings.canvasHeight Number(newV.height) this.recalculateHolePositions() this.renderAll() }) // 统计区 Row({ space: 12 }) { Text(当前拥挤阈值: ${Math.ceil(this.pigeonCount / this.holeCount)}只) Text(多轮命中率: ${this.experimentResult}) } .padding(12) } .width(100%) .height(100%) } private canvasCtx: CanvasRenderingContext2D new CanvasRenderingContext2D() }Canvas组件的使用要特别提醒CanvasRenderingContext2D必须声明为类成员变量并且初始化如果你在build()里new一个局部变量画布是空白一片的。这是我一开始常犯的错误后来养成了Canvas上下文一定是成员属性的习惯。4.2 巢穴位置计算与重绘private recalculateHolePositions(): void { const w this.settings.canvasWidth const h this.settings.canvasHeight this.settings.holeX [] this.settings.holeY [] const holeDia Math.min(w / (this.holeCount 1), 80) const gap 8 const totalWidth this.holeCount * holeDia (this.holeCount - 1) * gap let startX (w - totalWidth) / 2 for (let i 0; i this.holeCount; i) { this.settings.holeX.push(startX i * (holeDia gap) holeDia / 2) this.settings.holeY.push(h * 0.65) } } private renderAll(): void { const ctx this.canvasCtx const w this.settings.canvasWidth const h this.settings.canvasHeight ctx.clearRect(0, 0, w, h) // 画所有巢穴 for (let i 0; i this.holeCount; i) { this.drawHole(i, this.holes[i]) } // 画所有鸽子 for (let p of this.pigeonStates) { this.drawPigeon(ctx, p) } }巢穴的绘画方法里我特意让每个巢的颜色深浅随鸽子数量变化鸽子越多的巢颜色越偏橙色视觉上有渐变压力感。这样不看数字光看颜色就能知道哪个巢最挤。4.3 完整判定与统计核心private runExperiment(): void { if (this.holeCount 0) return this.experimentRuns 0 this.experimentHits 0 const rounds 100 for (let r 0; r rounds; r) { this.dispatch() const hasOverflow this.holes.some(hole hole.isOverflow) this.experimentRuns if (hasOverflow) { this.experimentHits } } this.experimentResult ${this.experimentHits}/${this.experimentRuns} (${(100 * this.experimentHits / this.experimentRuns).toFixed(0)}%) }这个循环跑得非常快100次几乎瞬间完成。但要注意每次dispatch()都会触发一次State的数组替换如果在动画播放中执行会打乱画面。我做了一个小让步实验统计使用纯粹的数学计算不重新渲染画面等统计结束只更新底部文字的Text。这样既保证了性能又不会影响正在播放的动画。5. 我踩过的坑和排查实录5.1 State不刷新问题——对象引用没变是最大元凶现象点击分配按钮底部统计数字变了但Canvas上的分布纹丝不动。排查过程先在按钮回调里打了日志确认dispatch()执行了再在renderAll()里打日志确认重绘方法也执行了。那问题就出在renderAll()读取的数据上——我怀疑holes数组里的count字段没更新。日志证实了猜想holes[0].count在dispatch()后确实是新值但Canvas里画图用的还是旧逻辑——原来我在renderAll()里遍历的是this.holes而这个数组因为同一个对象引用被修改没有触发State的更新机制渲染管线拿到的还是旧数据。解法在dispatch()的最后不要修改原数组的元素而是构造一个全新的数组赋值给this.holes。修改后问题直接消失。这个坑在ArkTS开发里太经典了值得单独拎出来说State监听的是引用不是深拷贝。要修改数组里的对象就把整个数组替换掉要修改对象里的字段就把整个对象替换掉。这个习惯养成了后面做大项目能省掉很多调试时间。5.2 Canvas上下文初始化空指针现象首次进入页面时画布区域一片空白CanvasRenderingContext2D报错cannot read property clearRect of undefined。排查过程我一开始在aboutToAppear()里就调用renderAll()但那个时候Canvas组件还没挂载完成CanvasRenderingContext2D拿到的上下文是空的。解法在onAreaChange回调里再调用第一次重绘。onAreaChange会在Canvas组件完成布局后触发那时候上下文已经可用了。如果你想更保险一点可以在onPageShow和onAreaChange里都挂一个重绘调用但实测下来onAreaChange足够。5.3 边界输入0只鸽子和0个巢现象把鸽子数量滑块拖到0当时设置的最小值是0应用直接白屏。原因Math.ceil(0 / holeCount)结果是0threshold 2的条件不满足逻辑本身不崩溃但画图时drawPigeon的循环里鸽子数量为0时虽然没问题可巢穴数量为0时recalculateHolePositions会除零——totalWidth 0 * holeDia ...理论上还为0但循环不执行问题不大。真正崩溃出现在Slider的step: 1设置下用户连续点击减号到0holeCount变成0后Math.floor(Math.random() * this.holeCount)变成了Math.random() * 0也就是0这个看起来不崩溃但分配逻辑里所有鸽子都进入第0个巢而第0个巢根本不显示画面就空了。解法参数校验放在所有入口位置。写了一个validateParams()方法在dispatch()和startAnimation()里第一行就检查if (this.holeCount 0) return。鸽子数可以允许为0显示空巢但鸽巢数必须至少为1。同时把滑块的min改为1从源头阻止这个问题。5.4 不同屏幕尺寸下布局错乱现象在手机上正常在平板上巢穴画得巨大且偏左。原因巢穴直径固定为80像素平板宽度1200像素时5个巢只用了不到一半宽度画布空荡荡而手机宽度360像素时刚好合适但鸽子数量加到50时巢和鸽子重叠严重。解法把巢穴直径改成动态计算const holeDia Math.min(w / (this.holeCount 1), 80)同时把鸽子的初始位置也做了网格化约束每行最多8只行数根据Canvas高度动态分配。这样在手机和平板上都能获得合理布局。5.5 定时器泄漏现象反复点击分配后动画越来越卡CPU占用升高。原因如果在上一次动画还没结束时再次点击分配旧的setInterval没有被clearInterval两个定时器同时跑都在更新鸽子坐标。解法在startAnimation()入口处先判断并清理旧定时器。这是非常基础的防抖思路但很多人写动画时容易忽略。我的习惯是所有定时器相关的操作写入一个统一的启动方法里开头三行固定为清理、重建、初始化。写在最后的小经验做到第116个实例我最大的体会是好用的教学工具往往不是把原理直接印在屏幕上而是让用户自己去撞见原理。鸽巢原理模拟器不直接告诉你必然有一个巢至少两只而是让你反复随机分配然后眼睁睁看着每一轮都出现拥挤——这种撞见比背定理深刻得多。如果你也想复刻这个项目我的建议是先照着文章里的数据模型搭好骨架把核心逻辑跑通再去打磨动画细节。动画是锦上添花逻辑正确才是基石。代码本身不难难的是把边界条件想周全、把状态管理用对。有空的话你还可以把模拟器扩展成鸽巢原理的反向验证模式——给定拥挤阈值询问最少需要多少只鸽子才能保证必然拥挤那又是另一个有意思的小功能了。