先说结论:Compose里并没有“一键实现震动”的现成能力
如果你想在 Jetpack Compose 里直接找到现成的“设备震动”API,结果大概率会让你失望。androidx.core:haptic-feedback库提供的HapticFeedbackType,本质上只覆盖点击、长按等系统级触觉反馈,至于震动时长、波形、频率这类更细粒度的控制,它并不负责。因此,想在 Compose 中模拟机器运行状态的震动效果,本质上是在做“视觉动画 + 真实触觉”的双通道拟物表达——这件事必须拆开处理:UI 动画是一部分,设备真实震动是另一部分,二者互相不能替代。更关键的是,后者还会受到 Android 权限、系统版本以及硬件能力的明显限制。

真实震动:需要手动调用Vibrator
Compose 本身是声明式 UI 框架,并不直接处理底层硬件交互。想让 Android 手机真正震动起来,你需要跳出 Compose 的界面层逻辑,从Context中获取Vibrator实例,再手动调用对应方法。这里有几个必须注意的前提条件:
- 权限:需要声明
android.permission.VIBRATE。从 Android 12 开始,还需要在AndroidManifest.xml中设置android:required="false",否则应用在提交 Google Play 时可能会被直接拒绝。 - API版本:Android 8.0(API 26)以后,旧版
vibrate(long[] pattern, int repeat)方法已经被标记为废弃,官方更推荐使用vibrate(VibrationEffect)。不过,只有VibrationEffect.createWa veform()才支持自定义震动节奏和频率序列,而它最低要求 API 29(Android 10)。 - 兼容性:对于 API 29 以下的设备,通常只能使用
VibrationEffect.createOneShot(),或者直接做静默降级处理——如果你想精细模拟频率变化,基本很难实现。
因此,更稳妥的调用方式通常如下(注意应放在LaunchedEffect或DisposableEffect中安全执行):
val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
if (vibrator.hasVibrator()) {
val pattern = longArrayOf(0, 50, 100, 50, 200) // 延迟 + 震动/停顿交替
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
vibrator.vibrate(
VibrationEffect.createWa veform(pattern, -1)
)
} else {
vibrator.vibrate(pattern, -1)
}
}
视觉抖动:用Animatable + graphicsLayer实现
如果你要表现“机器运行时的抖动动画”,就不能只依赖手机真实震动,而应该通过 Canvas 或修饰符动画来完成。核心思路是让 UI 元素在较小范围内进行高频位移,并辅以轻微缩放或旋转,从而营造更强的机械运行感。这里有几个关键优化点:
- 选对工具:使用
Animatable驱动offset或rotation,相比animate*AsState更适合循环、高频率的动画场景,也更有利于减少不必要的重组开销。 - 频率控制:可以通过
infiniteRepeatable配合tween中的durationMillis来控制动画周期。例如 100ms 一轮约等于 10Hz,200ms 约等于 5Hz。不过要注意,人眼可明显分辨的抖动上限通常在 15–20Hz 左右,再高就更像模糊而不是震动。 - 避免混乱:不建议直接用
rememberInfiniteTransition()处理多个属性联动(例如同时控制 offset 和 scale),因为不同周期叠加后容易出现错相,最终让动画效果显得生硬且不自然。
下面是一个简单的水平抖动加微缩放示例:
val offsetX = remember { Animatable(0f) }
val scale = remember { Animatable(1f) }
LaunchedEffect(Unit) {
offsetX.animateTo(
1f,
animationSpec = infiniteRepeatable(
animation = tween(100, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
)
)
scale.animateTo(
0.98f,
animationSpec = infiniteRepeatable(
animation = tween(200, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
)
)
}
Box(
modifier = Modifier
.graphicsLayer(
translationX = offsetX.value,
scaleX = scale.value,
scaleY = scale.value
)
)
“频率”这个词,不能简单当成单一参数使用
很多开发者一提到“模拟运行频率”,第一反应就是定义一个frequencyHz: Int参数统一控制。但在真实项目里,“频率”这个概念其实横跨了三个层面,而且每一层都有各自不同的含义和约束:
- UI层:Compose 动画帧率由
MonotonicFrameClock控制,默认会尽量跟随屏幕刷新率(60/90/120Hz)同步。你设置的tween(durationMillis = 50)只代表目标节奏,实际帧间隔仍会受到系统调度和设备性能影响。 - 系统震动层:Android 对连续震动波形存在最小时间间隔限制,通常不会低于 20ms。某些低端机型甚至会把高频 pattern 自动合并执行,导致你设想中的“10Hz 震动”,最终只产生 3–4 次有效反馈。
- 感知层:人体对震动频率的敏感区间大致在 30–300Hz,但手机马达本身的物理响应带宽往往只有 50–150Hz。低于 20Hz,更像“咚咚”的脉冲;高于 200Hz,感受上则更接近“麻刺感”。这和 UI 抖动常见的 5–20Hz,其实完全不是一个维度。
所以,实际设计时一定要把配置拆开:UI 抖动周期建议使用visualPulseIntervalMs,震动节奏用vibrationPattern数组描述,运行状态切换则用独立的isRunning布尔值控制。这三层“频率”更像三套不同的控制逻辑,各自独立、分别处理,硬塞进一个参数里只会让实现越来越混乱。
说到底,真正有难度的并不是写几行动画代码,而是判断当前 Android 设备到底能不能支撑你想要的那种“频率感”:API 级别是否满足、振动马达是 ERM 还是 LRA、用户是否关闭了系统触感反馈。这些问题都无法仅靠 Compose 的声明式写法自动解决,必须在调用前做好运行时检测,并准备明确的降级方案——例如震动不可用时,至少保留视觉抖动动画和声音提示,让用户依然能感知到“机器正在运行”的状态。
