Compose 的震动还能这么玩儿?

1,240 阅读6分钟

2.png

上周我在公司摸鱼的时候,发现 Github 上有一个开源库叫 Jindong,没错,听起来就是京东!

我以为我看错了,京东什么时候开始干这玩意儿了?后面才发现误会了。

Jindong 是什么

1.png

Jindong 为 Compose 跨平台应用(支持 Android 和 iOS)提供了声明式的触觉反馈 DSL。它基于 Compose 构建,让你能通过简单直观的 API 定义复杂的触觉模式。

该开源库的作者是来自韩国的开发者。“Jindong”[tɕindooŋ](진동)在韩语中意为“振动”。
好朴实无华的库名称,如果是中文名那岂不就是——Zhendong?

我们知道,当前移动 UI 开发已成功转向声明式范式,但与硬件 API 的交互(尤其是触觉反馈)仍然主要依赖命令式编程。

所以这个库的初衷,是为了让触觉反馈的交互也转向声明范式,它解决了在 Compose Multiplatform 应用中实现触觉反馈的复杂性。借助 Compose,开发者现在可以将触觉模式以结构化描述的形式进行定义。

也就是说,它提供了一种声明范式的方式调用硬件 API,同时还支持跨平台。

传统震动

我们先来看看传统的命令式方式——使用标准 Android API 创建波形振动,需要并行的时序和振幅值数组:

Column(
    modifier = Modifier.fillMaxSize(),
) {
    Button(onClick = {
        val timings = longArrayOf(0, 50, 100, 50, 100, 50)
        val amplitudes = intArrayOf(0, 255, 0, 128, 0, 255)
        val effect = VibrationEffect.createWaveform(timings, amplitudes, -1)
        defaultVibrator.vibrate(effect)
    }) {
        Text("Vibrate")
    }
}

调整某个节拍的持续时间需要重新计算数组索引。更不用说,这种逻辑是平台相关的,iOS 的 Core Haptics 需要完全不同的代码实现。

声明式

Jindong 将这些底层细节抽象为一种 DSL。开发者只需定义反馈结构,库会负责执行。

首先,我们添加权限和依赖:

<uses-permission android:name="android.permission.VIBRATE" />
implementation("io.github.compose-jindong:jindong:1.0.0")

然后,开始 DSL 形式的震动:

var count by remember { mutableIntStateOf(0) }

JindongProvider { // 需要提供一个 JindongProvider
    if (count > 0) { // 防止第一次进来就震动
        Jindong(count) { // 传入一个 key,当 key 发生变化的时候,就会震动
            Sequence {
                repeat(3) { // 重复三次震动
                    Haptic(50.ms, HapticIntensity.STRONG)
                    Delay(100.ms)
                }
            }
        }
    }

}

Column(
    modifier = Modifier.fillMaxSize(),
) {
    Button(onClick = {
        count++
    }) {
        Text("Vibrate")
    }
}

在这里,开发者只需定义触觉模式(一个重复三次的序列),而库会负责处理平台相关的执行细节。

这种差异不仅仅是语法上的不同——关键在于谁来控制执行过程

这也正是旧式的 View 写法和新式的 Compose 写法的核心差别:

命令式思维声明式思维
“先振动 50ms,然后等待 100ms,再振动 50ms”“我想要一个 50ms-100ms-50ms 的震动模式”
“循环三次,中间有延迟,启动震动”“我想要三次重复”

声明式编程关注于定义目标的逻辑和关系,而不指定如何实现它们。

“描述要做什么,而不是怎么做。”

生命周期管理

在命令式触觉反馈中,生命周期管理是最大的痛点之一。如果用户在长时间振动模式播放过程中导航离开当前页面,会发生什么?

在命令式编程的世界里,你必须手动在 onPauseonDispose 中调用 vibrator.cancel()

而使用 Jindong 时,触觉模式会自动绑定到 Compose 的生命周期:

  • 自动取消:当 Composable 离开组合时,触觉引擎会立即停止。
  • 响应式更新:就像 LaunchedEffect 一样,如果在模式播放过程中 keys 发生变化,Jindong 会自动取消当前模式,并根据新状态重新开始执行。
/**
 * 当 keys 发生变化时,触发触觉模式执行的可组合函数。
 */
@Composable
fun jindong(
    vararg keys: Any?,
    content: @Composable JindongScope.() -> Unit,
) {
    val pattern = rememberHapticPattern(content)
    val executor = LocalHapticExecutor.current
    LaunchedEffect(*keys) {
        executor.execute(pattern)
    }
}

这正是 Compose 的一大优势,无需特别关心生命周期。

当你离开组合时,当前的副作用自动结束!

搞点例子

以下是一些 Jindong 的实用场景。

下面的例子因为篇幅我没有显示的指定 JindongProvider,实际上所有的 Jindong 都需要在 JindongProvider 内部。

屏幕进入反馈

使用 Unit 作为触发键,可在进入屏幕时立即触发热反馈:

@Composable
fun WelcomeScreen() {
    // 页面出现时执行一次
    Jindong(Unit) {
        Haptic(100.ms, HapticIntensity.MEDIUM)
    }
}

按钮点击反馈

@Composable
fun TapButton() {
    var taps by remember { mutableIntStateOf(0) }
    if (taps > 0) {
        Jindong(taps) {
            Haptic(100.ms, HapticIntensity.LIGHT)
        }
    }
    Button(onClick = { taps++ }) {
        Text("Tap")
    }
}

淡出效果

@Composable
fun FadeOutHaptic(trigger: Int) {
    Jindong(trigger) {
        RepeatWithIndex(5) { index ->
            val intensity = HapticIntensity.Custom(1.0f - (index * 0.2f))
            Haptic(50.ms, intensity)
            Delay(100.ms)
        }
    }
}

模拟心跳

@Composable
fun HeartbeatHaptic(trigger: Int) {
    Jindong(trigger) {
        Repeat(5) {

            // Lub(强脉冲)
            Haptic(60.ms, HapticIntensity.STRONG)
            Delay(80.ms)

            // Dub(弱脉冲)
            Haptic(40.ms, HapticIntensity.MEDIUM)
            Delay(400.ms)
            
        }
    }
}

强烈建议各位试一下,这个特别像!

内部原理:Compose Runtime 与 CMP

Compose Runtime 的作用是什么?

Compose Runtime 是一个与 UI 渲染无关的树管理引擎。Jindong 依赖于 Compose 运行时的两个核心组件:

  • ComposeNode:这是一个通用函数,用于将节点插入到树中。在 Jindong 中,每个 DSL 函数(如 HapticDelaySequence)都会在内部调用 ComposeNode,以创建特定的 HapticNode 实现。
  • Applier:运行时需要知道如何将这些节点连接起来。Applier 负责处理 HapticNode 树的结构更新,该树将在播放前被遍历,以收集 ScheduledHapticEvent 事件。

就像 Compose UI 通过构建 LayoutNode 树来渲染像素一样,Jindong 通过构建 HapticNode 树来合成振动效果。这使我们能够使用标准的 Compose 特性(如 SideEffectremember),结合 Kotlin 的标准控制流(如 if/when/for),自然地构建动态的触觉体验。

Compose Multiplatform 实现

Jindong 遵循标准的 CMP(Compose Multiplatform)架构:

cmp.png

  1. 通用层(Common Layer) :包含 Compose DSL 定义、节点(Nodes)以及 HapticPattern 数据模型。所有时间计算和模式编译都在此层完成,确保跨平台行为的一致性。
  2. 平台层(Platform Layer,即执行器 Executor)
    • Android:将 HapticPattern 转换为 VibrationEffect(使用 VibrationEffect.createWaveform)。
    • iOS:将 HapticPattern 转换为 CHHapticPattern,用于 Core Haptics。

Android 和 iOS 对振动强度的处理方式不同。

Android 使用振幅整数(1–255),而 iOS 的 Core Haptics 使用归一化的浮点值(0.0–1.0)。

Jindong 提供了一个统一的抽象层,使开发者可以使用归一化的百分比(例如,intensity = 0.5f)来定义强度,无需关心平台特定的数学转换。

总结

Jindong 旨在解决在 Compose Multiplatform 应用中实现触觉反馈的复杂性。通过利用 Compose 运行时,开发者可以将触觉模式定义为结构化描述,而非一系列电机命令序列。

各位还在等什么,赶紧用起来试试呀!