1. 这个模拟器要解决什么问题:从一道经典算法题说起
鸽巢原理,很多人第一次听到它是在高中的抽屉原理课上,第二次是在大学组合数学教材里。但真正让开发者记住它的,往往是面试题里那道"给 n 个苹果放进 m 个抽屉,怎样放才能保证某个抽屉里至少有 k 个苹果"的脑筋急转弯。讲道理归讲道理,真让一个刚入门 HarmonyOS 开发的人把鸽巢原理做成一个可交互的模拟器,反而比原理本身更考验工程能力。
我最初做这个项目,是因为某次内部技术分享会上,一位前辈提到:"抽象的原理如果没有可视化的呈现,新手记不住,老手懒得看。"于是我就想着,为什么不直接用 ArkUI 写一个鸽巢原理模拟器呢?一方面能让自己把状态管理、动画衔接、自定义绘制这些 HarmonyOS 应用开发的基础再过一遍;另一方面,模拟器跑起来之后,苹果"掉进"抽屉的那一瞬间,比任何公式推导都直观——你能亲眼看到"必然存在一个抽屉装了至少两个苹果"这件事是怎么发生的。
这个模拟器适合谁?两类人。第一类是正在学 HarmonyOS 应用开发、想找一个不烂大街的练手项目的开发者;第二类是教离散数学或信息学竞赛的老师,课堂上用真机演示比 PPT 里放几张静态图有说服力得多。文章后面所有代码和思路都基于 ArkTS 语言和 ArkUI 声明式开发范式,我用的开发环境是 DevEco Studio 对应 HarmonyOS 4.x 的 API 版本,模拟器和真机都验证过,但核心逻辑不锁死任何特定版本,后续 API 有变化也能轻松迁移。
说实话,这个项目难度不高,属于"中等偏下"——真正花时间的地方不在鸽巢原理本身,而在于:苹果的布局怎么算、拖拽和动画怎么不打架、重置状态时内存里那一堆对象怎么干净利落地清理掉。这些细节才是写一个"能拿得出手"的模拟器和写一个"能跑"的 Demo 之间的差距。
2. 核心需求拆解:先想清楚再动手写代码
2.1 模拟器的三个核心场景
既然是鸽巢原理模拟器,那首先要搞清楚"演示什么"。我梳理下来,至少要覆盖三个经典场景:
- 基础演示:抽屉数 > 苹果数时,随便放也能保证至少有一个空抽屉。这是理解"存在性"的起点。
- 核心定理演示:苹果数 > 抽屉数时,至少有一个抽屉会装不少于 2 个苹果。鸽巢原理最简单的形态。
- 推广形态:苹果数 > 抽屉数 × (每个抽屉最多装 k-1 个) 时,必有一个抽屉至少装 k 个苹果。这个可以在参数面板里添加"每抽屉容量上限"的调节。
这三个场景对应到界面上,就是两组可调的滑块:抽屉数量和苹果数量。每次调整完参数,系统要自动重新分配苹果,并高亮显示"满足鸽巢结论的那个抽屉"。
2.2 用户操作流程设计
模拟器的交互不能复杂,否则观众注意力全在操作上,而没在看原理。我的设计是:
- 启动应用,默认 5 个抽屉、6 个苹果,打开就能演示"6 个苹果放进 5 个抽屉,必有一个抽屉至少 2 个苹果"。
- 拖动滑块改变参数,抽屉和苹果区域实时重绘。
- 点击"自动分配"按钮,系统随机放置苹果。
- 点击"高亮验证"按钮,系统找出满足条件的抽屉并高亮边框,同时显示文字说明。
- 点击"重置"按钮,所有状态清空,回到默认参数。
有人可能会问,为什么不直接永远自动分配?因为教学场景中,演示者往往想先让学生猜一猜"哪个抽屉会中招",手动分配和随机分配之间的切换,能制造一种"看似随机,实则必然"的戏剧感。对,这个项目本质上是给教学用的,交互节奏很重要。
2.3 状态模型设计
在动手写 UI 之前,我先把状态模型定下来,这是整个项目最值得琢磨的地方。
// 模拟器的核心状态模型 class PigeonholeState { // 抽屉数量 drawerCount: number = 5; // 苹果数量 appleCount: number = 6; // 每个抽屉里的苹果数 distribution: number[] = []; // 当前高亮的抽屉索引 highlightedDrawer: number = -1; // 当前解释文案 explanationText: string = ''; // 动画进行中标记 isAnimating: boolean = false; }distribution数组是核心中的核心:每个抽屉的苹果数量都记录在这里,UI 层所有的渲染都从这个数组驱动。没经验的人容易犯的错误是,把苹果坐标、抽屉坐标、动画状态全都塞进这个模型里,结果改一个参数就牵一发动全身。我这里刻意保持模型"最小化"——只有业务状态,没有渲染状态。坐标和动画参数在组件内部另行管理。
2.4 为什么不采用 Canvas 全量绘制
初期我纠结过一个问题:苹果和抽屉完全用 Canvas 画,还是用 ArkUI 基础组件加自定义绘制混搭?
纯 Canvas 的优点是自由度极高,可以画出非常细腻的苹果、木质抽屉纹路,动画效果也流畅。但缺点是:命中检测、拖拽、点击区域都要手动算,代码量大,调试麻烦,而且状态和绘制逻辑耦合度高。用 ArkUI 基础组件(比如Stack+Image+Text)的好处是布局、事件、动画系统现成可用,开发效率高很多。实际项目里我的选择是混搭:抽屉主体用容器组件 + 圆角边框绘制,苹果用Emoji文本符号或者自定义Shape绘制,既保证开发效率,又保留视觉自定义空间。这个决定省了我不少事。
3. 界面布局与视觉呈现:让数学题像游戏一样直观
3.1 整体布局结构
界面布局我采用的是"上下功能区 + 中部核心演区"的结构,参考了教学类 App 的通用设计语言:
┌─────────────────────────────────┐ │ 标题区:鸽巢原理模拟器 │ ├─────────────────────────────────┤ │ 参数控制区:抽屉数量滑块 │ │ 苹果数量滑块 │ │ 按钮组 │ ├─────────────────────────────────┤ │ 演示区: │ │ 抽屉区(一排抽屉) │ │ 苹果池(待分配的苹果) │ │ 说明文字区 │ └─────────────────────────────────┘顶部标题区我用了一个带渐变背景的Column容器,上面放应用名称,下面放一句话副标题"当数量超过容器,必有重复的必然",这行字算是我对整个项目的概括。
参数控制区用了Row+Column嵌套,滑块和数值显示并排,当前值用Text实时刷新。按钮组包含三个Button,分别是"自动分配"、"高亮验证"、"重置",我用的是不同的按钮类型区分主次——自动分配是主要操作,用ButtonType.Capsule高亮样式;高亮验证是辅助操作,用默认样式;重置则是普通文本按钮放在最右侧。
3.2 自定义抽屉组件
抽屉是整个界面最重要的视觉元素,我单独写了一个DrawerView组件:
@Component export struct DrawerView { @Prop drawerIndex: number; @Prop appleCount: number; @Prop isHighlighted: boolean; build() { Column({ space: 4 }) { // 苹果数量显示 Text(`${this.appleCount}`) .fontSize(16) .fontWeight(FontWeight.Bold) // 苹果堆叠区 Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center, alignItems: ItemAlign.End }) { ForEach(this.createAppleArray(this.appleCount), (item: number) => { Text('🍎') .fontSize(20) .margin({ left: 2, right: 2 }) }, (item: number, index: number) => `${this.drawerIndex}-${index}`) } .height(60) .width('100%') // 抽屉槽位绘制 Divider() .strokeWidth(3) .color(this.isHighlighted ? '#E84026' : '#8B7355') } .padding(8) .backgroundColor(this.isHighlighted ? '#FFF3E9' : '#FDF8F2') .borderRadius(8) .border({ width: this.isHighlighted ? 2.5 : 1, color: this.isHighlighted ? '#E84026' : '#D4C5A9' }) .animation({ duration: 300, curve: Curve.EaseOut }) } private createAppleArray(count: number): number[] { let arr: number[] = []; for (let i = 0; i < count; i++) { arr.push(i); } return arr; } }这个组件本身不关心苹果数组的业务逻辑,只根据传入的appleCount渲染苹果图标。高亮状态由父组件传入,高亮时边框、背景色、抽屉分割线的颜色都会变化,配合animation属性产生平滑过渡效果。
3.3 苹果堆叠区自适应逻辑
抽屉里的苹果数量可能从 0 到好几十,如果数量多,一行放不下怎么办?我用Flex的wrap属性让苹果自动换行,行高固定为 60。但这里有隐藏问题:当苹果数超过 20 个时,换行会挤占抽屉本身的高度,视觉上就显得很乱。
我的处理方式是给抽屉容器设定一个最大高度,当苹果数量超过阈值(比如 15)时,不再增加容器高度,而是把苹果的fontSize从 20 缩小到 14。这里做一次动态调整即可,不用为每一个数量级做适配:
private getAppleFontSize(count: number): number { if (count <= 8) return 24; if (count <= 15) return 18; return 13; }不过这种压缩方案只适合演示场景,真想要精致感觉,可以用 Canvas 绘制苹果并做等比例缩小排列,那个留作后续优化方向。
3.4 底部说明文字区
很多人做教学类 App 会忽视文字说明区,觉得那是画蛇添足。但鸽巢原理这种内容,光看图不一定能生成严格的语言描述。我的做法是在演示区下方放一个Text组件,根据当前状态动态生成三种文案:
- 当
appleCount <= drawerCount时:"当前苹果数不大于抽屉数,可能存在空抽屉,也可能刚好放满。" - 当
appleCount > drawerCount时:"根据鸽巢原理,至少存在一个抽屉,其中苹果数 ≥ 2。" - 当设置了推广模式(容量上限 < 苹果数)时:"根据推广的鸽巢原理,至少存在一个抽屉,其中苹果数 ≥ ⌈苹果数 ÷ 抽屉数⌉。"
文案要严谨但不能太学术,毕竟用户可能是中学生。我会用"抽屉"而不是"鸽巢",因为国内教材里"抽屉原理"这个叫法更普及。当然,应用标题保留"鸽巢原理",毕竟听起来更有算法的味道。
4. 核心算法实现:自动分配、高亮判定与重置逻辑
4.1 均匀随机分配法
自动分配逻辑的核心目标:让每个抽屉的苹果数随机,但总数必须等于设定的appleCount。
最简单的实现方式是"每次随机选一个抽屉放一个苹果",循环appleCount次。这种方法的优点是代码简单,缺点是最终的分配结果偏向均匀——因为随机选抽屉时,前面的苹果对后面的选择没有约束,导致某些抽屉过多或过少的极端情况概率不高。不过作为演示来说,均匀一点反而更好看,太极端了反而不像随机。
private randomDistribute(count: number): number[] { let dist: number[] = new Array(this.drawerCount).fill(0); for (let i = 0; i < count; i++) { // 生成一个 0 到 drawerCount-1 的随机索引 let idx = Math.floor(Math.random() * this.drawerCount); dist[idx] += 1; } return dist; }这里有个小坑:Math.random()是伪随机,在演示时要避免出现"每次都差不多"的观感。解决方法是每次点击自动分配时,先根据当前时间戳做一个随机种子重置。ArkTS 里没有像 JS 那样直接暴露seedrandom之类的库,所以简单做法是:
// 每次分配前先调用一次随机,消耗掉固定序列的前几个值 for (let i = 0; i < 3; i++) { Math.random(); }这个技巧源自游戏开发里的"洗随机序列"思路,实测下来观感上确实更随机一些。
4.2 高亮判定逻辑:找出"必然存在"的抽屉
高亮判定的核心是根据鸽巢原理找到那个"至少装了 k 个苹果的抽屉"。原理很简单:如果抽屉总数是d,苹果总数是a,那么至少有一个抽屉装了Math.ceil(a / d)个苹果。高亮时,我只需要找到第一个苹果数等于(或超过)Math.ceil(a / d)的抽屉即可。
private identifyHighlightDrawer(dist: number[]): number { const d = dist.length; const a = dist.reduce((sum, val) => sum + val, 0); // 计算理论下界 const threshold = Math.ceil(a / d); // 找到第一个满足条件的抽屉 return dist.findIndex(count => count >= threshold); }如果要演示强鸽巢原理(a > d * (k - 1)),那么阈值会变为k。我设计了一个"单抽屉容量上限"的滑块,默认值是 2,用户调到 3 就切换成强鸽巢原理的演示模式。高亮判定的逻辑不变,只是threshold的来源不同。
4.3 重置状态时最容易踩的坑
reset逻辑看似简单:把distribution清空、highlightedDrawer设为 -1、文案清空。但真机上反复测试时,我发现了一个细节问题——如果当前有过高亮状态,直接重置会导致 UI 层的高亮动画和重置动画冲突,表现为"抽屉先闪一下红色再变回来"。解决方法是重置时先关闭高亮,延迟 300ms(等动画结束)再重新填充分布:
private handleReset() { // 先清掉高亮,让动画走完 this.state.highlightedDrawer = -1; this.state.explanationText = ''; setTimeout(() => { // 再设置默认参数并重新分配 this.state.appleCount = 6; this.state.drawerCount = 5; this.state.distribution = this.randomDistribute(this.state.appleCount); }, 350); }这是典型的"状态时序"问题,声明式 UI 框架尤其敏感——因为你并不直接控制渲染过程,所有状态都是通过响应式系统"推"到 UI 上的,抢跑很容易导致中间态。
4.4 参数联动:滑块改动时自动重新分配
当用户拖动"抽屉数量"滑块时,合理的做法是立即重新生成分布数组,但保留当前苹果数量;拖动"苹果数量"滑块时同理。有一种做法是等用户松手后再刷新,利用slider组件的onChange事件可以拿到每次变化的值,但我实测发现连续快速拖动时,频繁重建数组会导致轻微的界面掉帧。
优化方案是引入"防抖"思想——只在停止滑动 200ms 后触发重分配:
.onChange((value: number) => { this.state.drawerCount = Math.round(value); // 防抖:清理之前的定时器并重新计时 clearTimeout(this.debounceTimer); this.debounceTimer = setTimeout(() => { this.redistribute(); }, 200); })这个 200ms 的间隔对人眼来说几乎无感,但能显著减少无效的重新渲染次数,提升真机运行的流畅度。
5. 动画与交互细节:苹果落袋、抽屉高亮、拖拽调整
5.1 用AnimateTo实现苹果"落袋"动画
苹果数量变化时,如果只是生硬地刷新 UI,演示效果大打折扣。我希望能看到苹果从上方"掉进"抽屉的过程。这需要用到 ArkUI 的animateTo函数,结合位移属性实现。
基础的动画思路是:苹果从统一高度(比如 -40vp)开始,通过translate属性在 300ms 内移动到目标位置。但苹果是动态生成的,每个苹果的起始位置并不一样,让它们"排队"落袋的效果更好。
我改造了DrawerView,给它增加一个isNewApple的标记,新生成的苹果初始translate.y = -20,然后通过onAppear事件触发动画:
Text('🍎') .fontSize(this.getAppleFontSize(this.appleCount)) .translate({ y: this.isEntering ? -20 : 0 }) .opacity(this.isEntering ? 0 : 1) .animation({ duration: 300, delay: index * 30, // 逐个延迟,形成排队效果 curve: Curve.Friction })index * 30这个细微的延迟量是视觉记忆点——苹果不是同时"砸"进抽屉,而是像排队进坑一样逐个落位,观众的视线会被引导着数清楚每个抽屉里到底有几个苹果。Curve.Friction是渐入渐出的摩擦曲线,比默认的Curve.EaseInOut更适合物体下落这种物理感场景。
5.2 高亮抽屉时让整个卡片"呼吸"
高亮抽屉不能只是换一个边框颜色,那样观感太"板"。我加了一个呼吸动画:高亮时抽屉卡片的背景色在#FFF3E9和#FFE0D0之间循环变化,同时缩放比例从 1.0 到 1.02 微微放大。
实现方式是通过一个@State布尔值和定时器驱动,高亮时启动一个 600ms 循环的setInterval切换状态,取消高亮时清掉定时器:
@State private isBreathing: boolean = false; private startBreathing() { clearInterval(this.breathTimer); this.isBreathing = true; this.breathTimer = setInterval(() => { this.isBreathing = !this.isBreathing; }, 600); } private stopBreathing() { clearInterval(this.breathTimer); this.isBreathing = false; }组件的build方法里根据isBreathing切换背景色和缩放值,用animation属性把变化平滑掉。注意:定时器一定要在aboutToDisappear(组件销毁生命周期)里清理掉,否则页面退出后定时器还在跑,轻则内存泄漏,重则日志里狂报"JS Error"。
5.3 拖拽手动调整苹果数:真正自由度的实现
光靠滑块调整参数,演示者会觉得很"被动"——明明想手动把一个抽屉里的苹果拖到另一个抽屉里,却做不到。所以我还实现了直接拖拽调整:长按某个抽屉,会出现一个小的控制面板(上下箭头),点击箭头增减该抽屉的苹果数。
这个交互做起来比想象中麻烦。点击事件没法直接拿到底层抽屉索引,需要通过@Event机制回调到父组件。我采用的方式是:DrawerView内部放两个透明的Button叠加层,分别显示在左上角和右上角,点击后通过onChangeAppleCount回调到父组件。
@Event onChangeAppleCount: (drawerIndex: number, delta: number) => void = () => {};增减时父组件更新distribution数组中的对应项,并做边界检查(不能小于 0)。这种"细粒度干预"让我在演示时可以说:同学们,这个抽屉现在有 1 个苹果,我手动再加一个,你们看是不是另一个抽屉必然少了?这个功能把模拟器从"自动演示工具"升级成了"手动探究工具",教学价值提升明显。
5.4 横竖屏适配
默认情况下,模拟器是竖屏设计的。但教学演示经常要投屏到电视或智慧屏上,这时横屏布局更有优势——抽屉可以一排排多列显示,苹果数量也更大。
我的适配策略是使用GridRow和GridCol响应式布局,在横屏时参数控制区和演示区分栏显示,竖屏时上下堆叠。这个适配如果用传统的方式写要写两套布局,ArkUI 里用GridRow的断点机制可以优雅解决:
GridRow({ columns: { sm: 1, lg: 2 }, gutter: 16 }) { GridCol({ span: { sm: 1, lg: 1 } }) { this.buildControlPanel() } GridCol({ span: { sm: 1, lg: 1 } }) { this.buildDemoArea() } }实测下来,这套布局在手机和平板上表现都不错,横屏时演示区更宽,视觉冲击力更强。
6. 性能优化与内存管理:细节决定演示成败
6.1 苹果图标用 Emoji 还是自定义绘制
我最初用系统 Emoji"🍎"当作苹果图标,因为不需要任何图片资源,实现成本几乎为零。但真机测试时发现,同一套代码在部分设备上 Emoji 的渲染风格不同——有的设备偏红,有的偏粉,视觉风格不统一。这让我犹豫要不要干脆改用自定义绘制。
最终我留了两种方案:默认用 Emoji,同时在代码里预留了Appearance主题切换,高精度模式时通过 Canvas 绘制一个圆润的红色苹果图形(用Path绘制苹果轮廓 + 高光)。Canvas 版苹果代码如下:
private drawApple(ctx: CanvasRenderingContext2D, x: number, y: number, radius: number) { // 苹果主体 ctx.beginPath(); ctx.arc(x, y, radius * 0.8, 0, Math.PI * 2); ctx.fillStyle = '#DC3023'; ctx.fill(); // 底部凹陷 ctx.fillStyle = '#A02A1A'; ctx.beginPath(); ctx.arc(x, y + radius * 0.5, radius * 0.25, 0, Math.PI * 2); ctx.fill(); // 高光 ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.beginPath(); ctx.arc(x - radius * 0.25, y - radius * 0.25, radius * 0.2, 0, Math.PI * 2); ctx.fill(); }虽然手绘苹果可以用Path做得更精细,但考虑到这是教学工具,重点是数量不是苹果长得好不好看,我给的建议是:演示场景直接用 Emoji 即可,真正追求统一视觉风格时再上 Canvas。
6.2 大数量级(超过 50 个苹果)的滚动优化
当苹果数调到 60 以上,演示区一屏放不下时,必须引入滚动。我的解决方案是把抽屉区域放进Scroll组件里,每个抽屉之间用固定间距,保证滚动平滑:
Scroll() { Column({ space: 12 }) { ForEach(this.drawerList(), (item: DrawerItem) => { DrawerView({ drawerIndex: item.index, appleCount: item.count }) }, (item: DrawerItem) => `${item.index}`) } .padding(12) }这里要注意的是,ForEach的 key 生成器必须稳定且唯一,我用的是抽屉索引字符串。如果误用了随机数或者不稳定的对象引用,ArkUI 会在数据更新时发生组件复用错乱,导致界面出现"幽灵抽屉"之类的诡异问题。这个坑我在调试时踩过一次——现象是拖动滑块后,某几个抽屉的苹果数半天不刷新,日志也没报错,最后发现 key 字段用了Math.random(),哭笑不得。
6.3 状态变量过多时的内存优化
我最初把distribution、highlightedDrawer、explanationText、isBreathing全部定义为@State变量。后来发现一个性能问题——当distribution数组变化时,所有依赖它的组件都会重新渲染,而我其实只需要更新苹果图标区域。ArkUI 的响应式系统是按"状态变量"粒度追踪依赖的,状态变量变化时所有使用了该变量的组件都会刷新。
优化策略是:将不需要响应式跟踪的数据(比如drawerCount和appleCount之间的临时计算)放到普通成员变量里,不标记@State。只有 UI 真正需要跟随变化的数据(如distribution)才标记为@State。这个细节在苹果数 50 以下时感知不明显,到上百个时差异就出来了——未优化版本偶尔掉帧,优化后丝般顺滑。
6.4 定时器和异步任务的清理
模拟器里至少有三个定时器/异步任务:滑块防抖定时器、呼吸动画定时器、可能存在的入场动画延迟。这几个定时器如果不在页面销毁时清理,极容易造成"页面返回后动画还在跑"或者"参数被上一个页面的定时器污染"的问题。
我的方案是在aboutToDisappear生命周期里统一清理:
aboutToDisappear() { clearTimeout(this.debounceTimer); clearInterval(this.breathTimer); this.isAnimating = false; }如果后续版本加入了异步网络请求(比如云同步演示数据),一定要配合CancellationToken或者标志位来阻止组件销毁后的状态更新,否则会抛出"Can't perform this state modification on a disposed component"之类的错误——这也是 HarmonyOS 应用开发里新手最常踩的坑之一。
7. 测试与真机调优:从模拟器到实际设备的落差
7.1 模拟器和真机的主要差异
项目开发前期我主要在 DevEco Studio 内置模拟器上跑,运行流畅,动画顺滑。但换到真机之后马上暴露了一个问题:模拟器上 60fps 的动画在真机上会偶发掉到 30fps 左右,尤其是高亮呼吸动画和苹果入场动画同时播放时。排查后发现,模拟器的图形渲染走的是宿主机 GPU 虚拟化,性能指标和真实 CPU/GPU 调度不能直接类比;真机上 ArkUI 的渲染线程和 UI 线程是分离的,频繁的animation属性切换会造成额外的组合成本。
优化方式是:把高亮呼吸动画和苹果入场动画拆到不同的渲染层级。高亮呼吸动画只作用于抽屉卡片的背景和边框,不触发子组件(苹果列表)重绘;苹果入场动画只作用在苹果Text组件自身的translate和opacity。通过update周期的分离,两个动画互不干扰,真机帧率恢复到接近满帧。
7.2 手势冲突:点击和拖拽同时存在时如何处理
模拟器里有按钮点击、滑块拖动、抽屉点击(增减苹果)三种手势。ArkUI 的手势系统默认对轻扫(swipe)和点击(tap)有区分,但滑块拖动和抽屉点击容易冲突——因为你可能在滑块上误触发了抽屉的点击事件。
解决方式是通过gesture和priorityGesture设置手势优先级。我给滑块设置了priorityGesture(GestureType.Swipe),这样当手势在滑块区域发起时,优先响应滑动,抽屉点击不会触发。同时给抽屉点击加上GestureMask.IgnoreInternal,避免内部滑动干扰。
7.3 系统字体缩放对布局的影响
这个坑是我在测试时意外发现的:当用户把系统字体大小调到最大时,应用内所有Text组件的字体都会放大,导致抽屉的高度和宽度失去平衡,苹果图标被挤得变形。
解决方案是:涉及布局尺寸的文本组件固定字号,不随系统字体缩放,同时给关键文本加上maxFontScale限制(ArkUI 从某个版本开始支持maxFontScale属性,可直接限制最大缩放倍数)。这不算最佳实践——无障碍场景确实需要字体缩放——但对一个教学演示工具来说,布局稳定比什么都重要,我宁愿提供大字号模式让用户手动切换,也不希望布局因为系统设置崩掉。
8. 代码工程结构:一个清晰可扩展的模拟器应该怎么组织
直接贴完整代码会占用太多篇幅,我这里只讲工程组织的重心,完整代码在文末以伪代码的形式提供了核心片段。
8.1 目录结构
整个项目在entry/src/main/ets/pages下主要包含四个文件:
PigeonholeSimulatorPage.ets:主页面,负责页面布局和状态管理components/DrawerView.ets:抽屉组件,负责单个抽屉的渲染和交互components/ApplePoolView.ets:待分配苹果池组件models/PigeonholeState.ets:状态模型定义
理论上可以把所有逻辑写在PigeonholeSimulatorPage一个文件里,但后续想扩展功能(比如支持更多原理变体)时,单文件维护成本会急剧上升。拆分成组件和模型之后,每个文件的职责清晰,改其中一个不影响其他部分。
8.2 关键代码段:状态管理主逻辑
@Entry @Component struct PigeonholeSimulatorPage { @State state: PigeonholeState = new PigeonholeState(); private debounceTimer: number = -1; private breathTimer: number = -1; /** * 重新分配苹果 */ private redistribute() { if (this.state.drawerCount <= 0 || this.state.appleCount <= 0) { return; } this.state.distribution = this.randomDistribute(this.state.appleCount); this.state.highlightedDrawer = -1; this.state.explanationText = ''; } /** * 高亮验证 */ private handleVerify() { const idx = this.identifyHighlightDrawer(this.state.distribution); if (idx >= 0) { this.state.highlightedDrawer = idx; this.state.explanationText = this.buildExplanationText(); this.startBreathing(); } } /** * 生成解释文案 */ private buildExplanationText(): string { const d = this.state.drawerCount; const a = this.state.appleCount; const threshold = Math.ceil(a / d); return `当前有 ${a} 个苹果、${d} 个抽屉,` + `根据鸽巢原理,至少有一个抽屉里苹果数不少于 ${threshold} 个。` + `高亮区域就是满足条件的抽屉。`; } build() { Column() { this.buildHeader() this.buildControlPanel() this.buildDemoArea() this.buildExplanationBar() } .width('100%') .height('100%') .backgroundColor('#F7F3EC') } }这段代码基本体现了整个项目的主干逻辑。核心就是distribute和verify两个动作,以及围绕它们状态流转。
8.3 状态变更时组件如何联动刷新
ArkUI 的响应式原理是:状态变量发生修改时,依赖它的 UI 组件会自动刷新。在这个项目中,distribution数组是唯一驱动抽屉区域渲染的变量,highlightedDrawer驱动高亮效果。当distribution变化时,DrawerView的appleCount属性跟随更新,苹果图标数量变化;当highlightedDrawer变化时,对应索引的抽屉组件的isHighlighted变为true,触发边框和背景色的动画变化。
这个机制看着简单,但实际使用时要注意:如果你在一个数组对象里修改了一个元素的属性,ArkUI 不一定能检测到变化(除非用@Observed和@ObjectLink装饰器)。我在最开始的版本里错误地直接修改了distribution[0],结果界面完全没有刷新。后来改成重新创建数组(this.state.distribution = [...newArr]),问题立刻解决。
这个坑值得单独提出来:声明式 UI 框架的数据追踪是"引用级"的,修改引用内部属性必须主动替换引用或使用观测装饰器。类似问题在 Vue 3 的开发里也很常见,本质上是响应式代理的边界问题。
9. 推广模式与进阶玩法:从基础演示到深度探究
9.1 强鸽巢原理演示模式
基础模式的结论是"至少有一个抽屉有 ≥2 个苹果",但鸽巢原理的推广形态是:如果苹果总数大于抽屉数乘以每个抽屉的最大容量减一,那么至少有一个抽屉会超过最大容量。这个模式适合讲"极端必现"的思想。
我在参数面板里增加了一个"每抽屉容量上限"的数字选择器,默认为"不限"(基础模式),用户可以调整为 2、3、4 等值。当选择上限为 3,且苹果数大于(抽屉数 * 2)时,模拟器会以"强鸽巢"逻辑来判定并高亮。此时文案也会自动切换:
- 普通模式:"至少有一个抽屉苹果数 ≥ 2"
- 强鸽巢模式:"拥有 N 个抽屉、每个抽屉最多放 K-1 个苹果时,放 M 个苹果必有一个抽屉超过 K-1"
这个模式的引入让模拟器可以覆盖更复杂的数学课堂场景,而且代码改动量很小——核心判定阈值计算从Math.ceil(a/d)换成maxCapacity + 1即可。
9.2 竞赛演示模式:连续多次自动分配并统计
还有一个我实验过、使用频率很高的功能:自动批量分配。点击一次按钮,系统自动执行 100 次随机分配,统计每个抽屉"被高亮"的次数,然后显示一张条形分布图。这个功能把鸽巢原理从"单次演示"升级成了"统计实验"——学生可以看到,虽然每次随机分配不同,但总有一个抽屉里的苹果数突破阈值,而且高亮位置是均匀随机的。这个设计和"必然性存在,但具体位置不确定"的数学本质形成了巧妙的呼应。
实现思路是:在handleVerify的内部加一个循环,每次都记录高亮索引到一个counts[]数组,100 次后用一个BarChart(或简单的自定义Flex条形图)渲染。我在实现时没有引入额外的图表库,而是用Column+CanvasRect手绘了一个简易柱状图,效果足够清晰。
9.3 与数学解释联动:对比、反证与极限情形
我还在说明文字区做了一个小增强——当用户拖动滑块使参数满足鸽巢原理的前提时,底部除了显示结论,还会附带一段简短的反证解释:
假设每个抽屉最多放 1 个苹果,那么 6 个苹果最多只能放进 5 个抽屉。但这里一共有 6 个苹果,所以假设不成立。
文字不长,但对理解"为什么必然"很有帮助。后来我又加了一个"极限情形"切换:当苹果数刚好等于抽屉数时,高亮验证会显示"当前恰好放满,也可能出现空抽屉,但不存在必现"的提示。这类边界处理让模拟器的表达更严谨,教学价值更高。
10. 从模拟器到通用框架:这套逻辑还能迁移到哪些场景
做完这个项目后,我发现它的很多设计思路并不局限于鸽巢原理。以下是我觉得可以迁移的方向:
- 其他组合数学原理:容斥原理、狄利克雷抽屉原理的多种形式、Ramsey 理论小规模演示。核心都是"对象数量 vs 容器数量"的对比,状态模型几乎可以直接复用。
- 通用分布可视化工具:比如把苹果换成任务、抽屉换成资源,变成资源分配演示器。这在教操作系统(进程调度、内存分页)时也有类似需求。
- 教学类 App 的通用框架:参数滑块 + 随机演示 + 高亮验证 + 说明文字,这套交互范式可以用在物理(力学受力分析)、化学(分子碰撞)、生物(遗传概率)等多个学科中。
- 游戏里的生成器调试:把"苹果放抽屉"换成"物品放入背包",可以调试"随机分配是否均匀"的问题——原理一样,界面不一样而已。
这种架构上的"可迁移性"是我做项目时比较看重的。宁可初始开发时多花一点时间把状态和 UI 解耦,也不要图快写死代码,因为后者的重构成本远超想象。
11. 写在最后:这个模拟器教会我的三件事
项目做到这里,我感触最深的反而不是鸽巢原理本身——那毕竟是几百年前就被证明了的定理,而是把一条定理做成一个可交互应用时遇到的那些工程问题。总结下来有三点。
第一,状态模型的边界设计决定项目的天花板。这个项目的核心不过是一个distribution数组,但所有交互、动画、验证、统计都是围绕着它的读和写展开的。如果一开始把坐标、动画状态、临时标记全部塞进去,后面做呼吸动画、批量分配、强鸽巢模式时一定会被严重拖累。
第二,动画是数学原理的翻译器,不是装饰品。很多教学类应用把动画做得花里胡哨,反而分散注意力。鸽巢原理的核心视觉逻辑是"苹果数量的堆积过程"和"高亮抽屉的必然突显",我做的动画都是围绕这两点设计的——入场动画帮观众数清苹果,高亮呼吸帮观众锁定结论,没有一个是多余的。
第三,真机测试永远应该早于"觉得做完了"的那一刻。不少开发者(包括以前的我)喜欢在模拟器上把所有功能验证完再上真机,结果上真机后各种问题——帧率、字体缩放、手势冲突、渲染层级——全部暴露。这次我提前做了几轮真机适配,省去了很多返工。
最后分享一个小技巧:如果要在课堂上演示,记得先把应用调到"演示模式"——关闭滑块面板,只保留"重新随机"和"高亮验证"两个按钮,这样学生注意力不会被参数控件带走。演示模式下默认参数改成 5 个抽屉 6 个苹果,恰好是"1 个多余的苹果"这种最经典的情形,讲起来最顺口。
这个模拟器只是一个起点,鸽巢原理还有大量可挖掘的内容:无限鸽巢原理、概率鸽巢原理、Ramsey 定理的图论应用……每一条都可以做成独立的演示模块。我打算后续把通用框架抽出来,做一个"离散数学原理演示合集",到时再写一篇分享。如果你在实现过程中有更好的创意或者踩了什么新坑,欢迎一起交流。