之前做一个小型智能硬件项目时需要在设备端设计一套完整的“动态反馈”机制牙刷启动时电机如何渐进加速、刷完牙后怎样优雅渐停、App 端如何实时展示清洁进度。为了把交互节奏设计得自然我把松下 Doltz 声波电动牙刷当成一个研究样本认真拆了一遍它的动态设计逻辑顺手用前端和 Python 做了一套模拟实现。这篇文章就从这个角度展开从硬件层到软件层看看一把电动牙刷的“动态设计”是怎么落地的并给出可以复用的代码示例与排查思路。文章适合三类读者一是做智能硬件或 App 动效的开发者想了解设备反馈状态怎么拆解二是对声波牙刷工作原理感兴趣、想用数据分析振动信号的朋友三是希望从前端和 Python 双视角做一个完整 demo 来练手的同学。阅读本文后你可以掌握设备状态机设计、声波振动信号的 FFT 频谱分析、CSS/JS 动效仪表盘实现以及其中容易踩的坑。1. 从一把电动牙刷说起什么是动态设计1.1 动态设计不只是“UI 动画”很多人一听到“动态设计”第一反应是界面上的过渡动画、Loading 动效或者交互动画。但在消费电子产品里动态设计的范围要宽得多。以松下 Doltz 声波电动牙刷为例它带给用户的“动态体验”至少来自三个层面物理层刷头在磁悬浮马达带动下产生高速声波振动清洁效果依赖这个物理运动。设备层指示灯呼吸效果、压力过大时的振动变强或变弱、两分钟计时后马达自动停止、低电量提醒。软件层通过 App 或面板显示刷牙进度、清洁区域切换、剩余时长、电量状态。换句话说动态设计连接的是“机器行为”和“用户感知”。一把电动牙刷好不好用除了马达功率和刷毛材质还取决于这些反馈是否及时、可预期、不突兀。优秀的动态设计能让用户在不看说明书的情况下也明白当前设备处于什么状态。1.2 为什么选择松下 Doltz 作为分析对象松下 Doltz 系列在国内通常叫“松下声波电动牙刷”它的特点是使用磁悬浮马达驱动刷头振动方向沿刷头纵向往复属于“声波振动”类电动牙刷。部分型号具备“压力感应”当刷牙力度过大时会主动调整振动避免损伤牙龈。支持 30 秒换区提示、2 分钟定时停止、低电量提醒等基础交互。充电底座带有状态指示灯通过灯光颜色或闪烁方式传达充电进度。这些功能单独看都不复杂但组合在一起就需要一套完整的“动态状态管理”逻辑什么时候亮灯、什么时候振动、什么时候停下来。这就是我们接下来要拆解的重点。1.3 本文的核心内容范围这篇文章不会测评某款牙刷的好坏而是从三个技术视角做“动态设计”的还原设备状态机设计用 TypeScript 实现一个简化版刷牙状态机。前端动效实现用 HTML CSS JS 做一个模拟 Doltz 风格的刷牙仪表盘。振动数据分析用 Python 生成模拟振动信号并通过 FFT 分析其频谱特征。读完你会得到一份可运行、可改、可扩展的代码而不是空谈概念。2. 声波牙刷的核心原理与动态体验基础2.1 声波马达与振动输出声波电动牙刷的马达并不直接旋转刷头而是让刷头以高频小幅摆动。常见声波牙刷的振动频率大约在每分钟 3 万次左右换算成频率大约是几百赫兹。以每分钟 31000 次为例31000 次/分钟 ÷ 60 秒 约 516.7 Hz这意味着刷头每秒钟完成约 516 次往复运动。这个频率正好落在声波范围的低频段所以叫“声波振动牙刷”。从动态设计角度看马达输出并不是一开机就拉满而应当有一个“渐入”过程。一方面是为了避免牙膏飞溅另一方面是给用户一个“设备已启动”的心理预期。同理结束刷牙时电机也不会瞬间停止而是快速减速。在硬件上这通常通过 PWM脉冲宽度调制或电机驱动芯片实现在软件上则体现为“启动曲线”和“停止曲线”的状态切换。2.2 传感器与状态识别为了让动态反馈更智能设备会采集多种信号压力信号检测刷头与牙齿的接触力度力度过大时设备降频或提醒。电量信号判断剩余电量低电量时通过指示灯或振动节奏提醒。充电信号检测充电底座接触状态切换为充电灯效。计时信号内置 2 分钟计时器每 30 秒提示一次换区。这些信号统一汇聚到单片机或嵌入式控制器的状态机中。状态机决定了“当前输出什么振动频率、亮什么灯、显示什么状态”。2.3 动态设计的“三层模型”为了理清思路可以把设备动态设计分为三层层级表现形式实现要点物理层刷头振动、声波输出电机驱动、PWM 控制、机械结构设备层指示灯、蜂鸣器、振动节奏状态机、定时器、GPIO 控制软件层App 页面、动效、实时数据状态同步、动画、图表渲染这三层环环相扣设备层的状态变化要能反映到软件层软件层的操作要能反向控制设备层。接下来我们先从设备层入手设计一个简化版状态机。3. 设备端的动态状态机设计3.1 为什么用状态机电动牙刷虽然功能简单但它不是一个“一直运转”的设备而是会在多个状态之间切换开机待机状态开始刷牙运行状态中途暂停暂停状态电量不足低电量状态放入底座充电充电状态如果不用状态机直接用一堆 if-else 处理“当前能不能启动”“能不能暂停”“要不要亮灯”之类的问题代码很快就会乱。特别是后期增加新功能比如“敏感模式”“美白模式”时if-else 会越堆越难维护。状态机的好处是把状态变化集中管理每一次切换都走统一的transition方法便于打印日志、做动画联动、做异常兜底。3.2 状态定义与状态转换下面定义五类核心状态type BrushState | IDLE // 待机 | RUNNING // 运行中 | PAUSE // 暂停 | CHARGING // 充电中 | LOW_BATTERY; // 低电量允许的转换关系如下IDLE-RUNNING按下启动按钮RUNNING-PAUSE按下暂停按钮PAUSE-RUNNING继续RUNNING-LOW_BATTERY电量检测低于阈值LOW_BATTERY-IDLE用户关闭设备IDLE-CHARGING放入充电底座CHARGING-IDLE从底座取出PAUSE-IDLE用户关闭设备3.3 状态机实现示例下面是可运行的核心片段文件路径写为src/brushStateMachine.tsexport type BrushState | IDLE | RUNNING | PAUSE | CHARGING | LOW_BATTERY; type StateListener (prev: BrushState, next: BrushState) void; export class BrushStateMachine { private state: BrushState IDLE; private listeners: StateListener[] []; public get current(): BrushState { return this.state; } // 注册状态变化监听器可用于触发 UI 动效 public onTransition(listener: StateListener): void { this.listeners.push(listener); } private transitionTo(next: BrushState): void { const prev this.state; if (prev next) return; console.log([Brush] ${prev} - ${next}); this.state next; this.listeners.forEach((listener) listener(prev, next)); } public start(): void { if (this.state IDLE || this.state PAUSE) { this.transitionTo(RUNNING); } } public pause(): void { if (this.state RUNNING) { this.transitionTo(PAUSE); } } public stop(): void { if ( this.state RUNNING || this.state PAUSE || this.state LOW_BATTERY ) { this.transitionTo(IDLE); } } public charge(): void { if (this.state IDLE || this.state LOW_BATTERY) { this.transitionTo(CHARGING); } } public unplug(): void { if (this.state CHARGING) { this.transitionTo(IDLE); } } public batteryLow(): void { if (this.state RUNNING || this.state PAUSE) { this.transitionTo(LOW_BATTERY); } } } // 使用示例 const brush new BrushStateMachine(); brush.onTransition((prev, next) { // 在这里联动 App 动画更新圆形进度、改变指示灯颜色等 console.log(UI 更新${prev} - ${next}); }); brush.start(); // IDLE - RUNNING brush.pause(); // RUNNING - PAUSE brush.start(); // PAUSE - RUNNING brush.batteryLow(); // RUNNING - LOW_BATTERY brush.stop(); // LOW_BATTERY - IDLE这个状态机的核心价值是把状态变化收敛为可观测的事件。App 端只需要监听onTransition就能驱动所有动态反馈。需要注意的是真实嵌入式环境中状态机一般用 C 语言写在单片机里这里用 TypeScript 是为了方便在浏览器里跑、也方便前端开发者理解。4. 用前端实现 Doltz 风格动态仪表盘4.1 需求说明现在做一个模拟页面用来演示设备状态变化时 UI 如何动态响应。整体需求如下显示当前状态文案例如“运行中”“暂停”“充电中”“低电量”。圆形进度环表示 2 分钟刷牙剩余时间。四个区域块分别对应口腔四个区域每 30 秒高亮切换一次。压力条模拟刷牙力度超过阈值时提示“力度过大”。提供“开始 / 暂停 / 停止 / 充电”四个操作按钮。这个页面不依赖任何框架直接一个 HTML 文件即可运行。4.2 HTML 结构文件路径doltz-dashboard.html!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / titleDoltz 风格动态刷牙仪表盘/title style :root { --primary: #00a0e9; --bg: #f5f7fa; --card-bg: #ffffff; --text: #1f2d3d; --danger: #f56c6c; --success: #67c23a; } * { box-sizing: border-box; } body { margin: 0; min-height: 100vh; background: var(--bg); font-family: -apple-system, BlinkMacSystemFont, Segoe UI, PingFang SC, Microsoft YaHei, sans-serif; display: flex; justify-content: center; align-items: center; color: var(--text); } .dashboard { width: 420px; background: var(--card-bg); border-radius: 24px; padding: 28px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); } .status-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; } .status-text { font-size: 18px; font-weight: 600; } .battery { display: flex; align-items: center; gap: 6px; } .battery-body { width: 40px; height: 18px; border: 2px solid var(--text); border-radius: 4px; position: relative; overflow: hidden; } .battery-fill { position: absolute; left: 0; top: 0; bottom: 0; width: 80%; background: var(--success); transition: width 0.3s ease; } .battery-head { width: 4px; height: 8px; background: var(--text); border-radius: 0 2px 2px 0; } .ring-section { display: flex; flex-direction: column; align-items: center; margin-bottom: 24px; } .ring { width: 200px; height: 200px; position: relative; } .ring svg { width: 100%; height: 100%; transform: rotate(-90deg); } .ring-track { fill: none; stroke: #e4e7ed; stroke-width: 10; } .ring-progress { fill: none; stroke: var(--primary); stroke-width: 10; stroke-linecap: round; transition: stroke-dashoffset 0.5s ease; } .ring-time { position: absolute; inset: 0; display: flex; justify-content: center; align-items: center; font-size: 32px; font-weight: 700; } .zone-section { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 20px; } .zone { text-align: center; padding: 12px 0; border-radius: 12px; background: #f2f6fc; font-size: 14px; font-weight: 500; transition: background 0.3s, color 0.3s; } .zone.active { background: var(--primary); color: #fff; } .pressure-section { margin-bottom: 20px; } .pressure-header { display: flex; justify-content: space-between; font-size: 14px; margin-bottom: 6px; } .pressure-bar { height: 10px; background: #e4e7ed; border-radius: 5px; overflow: hidden; } .pressure-fill { height: 100%; width: 30%; background: var(--success); border-radius: 5px; transition: width 0.2s; } .pressure-fill.warning { background: var(--danger); } .control-group { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; } .btn { border: none; border-radius: 10px; padding: 10px 0; font-size: 14px; cursor: pointer; background: #ecf5ff; color: var(--primary); font-weight: 500; transition: transform 0.1s; } .btn:active { transform: scale(0.95); } .btn.primary { background: var(--primary); color: #fff; } /style /head body div classdashboard div classstatus-bar span classstatus-text idstatusText待机中/span div classbattery div classbattery-body div classbattery-fill idbatteryFill stylewidth: 80%/div /div div classbattery-head/div /div /div div classring-section div classring svg viewBox0 0 200 200 circle classring-track cx100 cy100 r88/circle circle classring-progress cx100 cy100 r88 idringProgress /circle /svg div classring-time idtimeText02:00/div /div /div div classzone-section idzoneSection div classzone>import numpy as np import matplotlib.pyplot as plt from scipy.fft import fft, fftfreq # 参数配置 fs 10000 # 采样率 10000 Hz duration 0.2 # 分析时长 0.2 秒 t np.linspace(0, duration, int(fs * duration), endpointFalse) # 模拟声波牙刷振动信号 f0 31000 / 60 # 每分钟 31000 次换算为 516.67 Hz signal ( 1.0 * np.sin(2 * np.pi * f0 * t) 0.3 * np.sin(2 * np.pi * 2 * f0 * t) 0.1 * np.sin(2 * np.pi * 3 * f0 * t) ) # 加入随机噪声模拟传感器采集误差 rng np.random.default_rng(42) noise 0.05 * rng.standard_normal(len(t)) signal signal noise # 绘制时域波形 plt.figure(figsize(10, 4)) plt.plot(t * 1000, signal, linewidth0.8) plt.title(模拟声波牙刷振动时域波形) plt.xlabel(时间 (ms)) plt.ylabel(振幅) plt.grid(True, linestyle--, alpha0.6) plt.tight_layout() plt.savefig(time_domain.png, dpi120) plt.show()5.3 FFT 频谱分析时域波形只能看到振动形态无法直观读出频率成分。接下来对信号做 FFT观察频谱中的峰值# FFT 频谱分析 N len(signal) yf fft(signal) xf fftfreq(N, 1 / fs) # 取正频率部分 positive xf 0 plt.figure(figsize(10, 5)) plt.plot(xf[positive], np.abs(yf[positive]) * 2 / N, linewidth1) plt.title(模拟声波牙刷振动信号频谱) plt.xlabel(频率 (Hz)) plt.ylabel(幅值) plt.xlim(0, 2000) plt.grid(True, linestyle--, alpha0.6) plt.tight_layout() plt.savefig(frequency_domain.png, dpi120) plt.show()如果没有安装相关库可以在终端执行pip install numpy scipy matplotlib5.4 结果解读运行后应当看到在约 516.7 Hz 处有一个明显主峰对应声波马达的基频。在约 1033 Hz 和约 1550 Hz 处有较小峰值对应二次谐波和三次谐波。噪声表现为频谱中的细小抖动。这说明该振动信号的主要能量集中在基频附近。在实际产品调试中如果频谱中冒出一个与工作频率无关的尖峰通常意味着机械结构存在共振或异常磨损需要从结构设计或装配工艺上排查。用同样方法我们可以对比“空载”和“按压牙齿”两种工况下的频谱差异观察马达负载变化后的频率漂移。这为动态控制算法提供了判断依据。6. 常见问题与排查思路在实际开发或复现本文示例时可能会遇到一些问题。这里整理一个排查清单问题现象常见原因解决思路页面圆环不显示进度SVG 的stroke-dasharray未初始化在页面加载后调用updateRing()确保CIRCUMFERENCE正确圆环进度方向反了SVG 圆环默认从 3 点钟方向开始给 SVG 加transform: rotate(-90deg)让起点在 12 点方向定时器越来越快startTimer()中未清理旧定时器每次启动前先clearInterval(timer)页面显示 2 次区域高亮计时逻辑与状态同步混乱统一由状态机管理时间不在多个回调里改zoneIndexFFT 频谱主峰位置不对采样率与信号频率换算错误检查fftfreq参数确认采样率fs与数组长度一致频谱两端出现巨大峰值信号不是周期整数倍存在频谱泄漏对信号加窗函数如汉宁窗再执行 FFT状态机允许非法跳转transitionTo缺少合法转换限制增加当前状态 - 目标状态的合法性校验表关于 FFT 补充一点本文示例为了简单没有加窗。真实分析时采集的信号不是整周期截断直接 FFT 会产生频谱泄漏表现为主峰两侧出现旁瓣。可以先用汉宁窗对信号做加权处理再计算频谱结果会更干净。7. 动态设计的工程实践建议7.1 状态管理要集中收敛无论是嵌入式固件还是 App 前端都不建议把设备状态散落在各个页面或组件内。推荐的模式是设备侧维护唯一状态机。每次状态变化生成一条事件。上层应用只消费事件不直接修改设备状态。这样可以确保 UI 展示、日志记录、数据分析始终基于同一份状态事实。7.2 动效要追求“可预期”动态设计不是动效越多越好。合理做法是启动时给用户短暂过渡但不要超过 500ms。提示类反馈换区、压力过大要明显但不突兀。每类状态变化对应稳定一致的视觉语言比如“运行中”统一是蓝色“异常”统一是红色或橙色。保持可预期性用户才会形成习惯。如果每次反馈都不一样用户反而会困惑。7.3 性能优先使用 transform 和 opacity在前端做高频动效时尽量使用transform、opacity属性避免频繁触发布局重排。比如本文的圆环进度条使用 SVG 的stroke-dashoffset比每一帧修改width更高效。压力条如果要做连续动画同样建议用transform: scaleX()代替修改width。7.4 日志与数据埋点智能硬件的动态反馈是否有效需要通过数据证明。建议在开发阶段记录每次状态切换的时间戳和触发来源。压力超限的次数和持续时间。单次使用总时长和区域停留时间。这些数据既能用于优化交互节奏也能帮助发现马达异常等硬件问题。当然采集用户数据需要遵守隐私合规要求做到最小化收集、明确告知和脱敏处理。7.5 涉及生产环境的提醒如果项目涉及真实设备和用户数据的生产环境请务必注意设备端固件升级要支持回滚避免新状态机导致设备异常。蓝牙或网络传输的数据要做加密和校验。状态机变更前要在测试环境覆盖主要转换路径再灰度发布。压力保护、低电量停机这类安全相关逻辑需要双保险不能只依赖软件判断。8. 总结与后续学习延伸这篇文章以松下 Doltz 声波电动牙刷为分析对象把“动态设计”拆成了三个可落地的技术模块设备状态机设计、前端动效仪表盘、振动信号频谱分析。你可以尝试做以下扩展把状态机换成 C 语言移植到 ESP32 或 STM32 开发板上连接一个小型马达做一个真实的牙刷控制原型。在仪表盘中加入 Web Bluetooth连接真实设备上报的状态数据。对真实振动信号做加窗 FFT 分析对比不同模式下的频谱差异。在状态机中加入“刷牙评分”逻辑根据压力、时长、区域覆盖度生成每日报告。动态设计是一个横跨机械、嵌入式、前端、数据分析的交叉领域。越是简单的产品越考验设计者对“状态—反馈—体验”之间关系的理解。希望通过本文的代码和思路你能在自己的项目中少踩一些坑做出有“呼吸感”的设备交互。如果这篇文章对你有帮助欢迎收藏备用也欢迎在实际调试后回来交流你的踩坑经验。