
1. 前言
上一篇为叶片留出了卡片边框外的空间,并让花盆继续留在原位。现在可以给瓢虫增加一点动作:点击花盆,等它从叶后探出来,轻轻倾斜手机,让它沿叶片走动。
这个动作包含一个容易忽略的预期。当手停下来时,瓢虫也应该停下来;反向倾斜时,它应该及时回头。如果平放手机时仍在晃动,或者已经反向却迟迟不动,单纯延长动画时间很难同时解决两个问题。
我会先固定叶片和路线,再讨论输入怎样改变位置。这样,背景偏移、叶片伸展与瓢虫运动不会混在同一次调试里。

工程继续使用绑定薄荷 plant-001 的2×2桌面动态卡片。普通页面是 MintCard,激活页面是 MintLive,扩展仍为 MintFormAbility 和 MintLiveAbility。花盆、叶片、瓢虫与观察日记沿用前三篇。
本篇只改变当前激活会话中的装饰位置。倾斜不会登记浇水,也不会产生植物长高了多少的结论。普通态依然负责摘要与日记入口。
2. 方案
最直接的做法,是取一个传感器值,乘上系数,交给瓢虫的位置属性。代码很短,但它把两个问题藏进了乘法:输入表示什么,以及位置应该记住什么。
对于角速度输入,数值描述旋转的快慢。手机倾斜后保持不动,角速度会趋近于零。如果直接将它映射为位置,瓢虫就会趋向起点。这样的行为可以表达"晃一下",却不符合这里"移动后停住"的预期。
因此,我选择累计短会话内的相对变化,再把累计量映射到路线位置。它不提供绝对姿态,也不试图在很长时间内维持方向估计。每次激活重新开始,正好符合这张卡片的交互尺度。
为了便于定位问题,保留两种策略作对照:
| 策略 | 位置的依据 | 需要观察的问题 |
|---|---|---|
| A:原始值直接映射 | 当前输入乘固定增益 | 停止旋转后是否回到起点,输入噪声是否直接传到位置 |
| B:相对累计后映射 | 经死区处理的输入按时间累计,再限幅和平滑 | 小动作是否被忽略,累计是否漂移,反向是否迟滞 |
A与B改变了映射语义和处理步骤。即使B的画面更平稳,也不能把差异全部归功于死区。研究死区时,应在B内部只改变阈值,保持其他参数不变。
路线也需要先确定。我们选取叶片内部的一小段直线,固定两端与起点;选点时把瓢虫的完整图形算进去,给叶缘留出余量。两端在叶片内,并不自动保证中间每个位置都安全,弯曲或凹陷的轮廓尤其需要检查。

第03篇的申请区域限制整个装饰层,叶片路线进一步限制瓢虫。两个约束应同时成立。申请到更大的画布,并不意味着瓢虫可以在画布里任意移动。
接下来把处理链拆成原始输入、目标位置与呈现位置。输入波动影响目标,插值决定如何接近目标,屏幕最终显示什么还需要画面证据。把这三者分别记录,才能判断问题发生在哪一段。
3. 实现
互动卡片用陀螺仪数据配合阈值、限幅与动画过渡。GyroscopeUtil 是示例中的辅助封装,接入时需要阅读它的实现,并核对当前SDK的SensorServiceKit接口。
这里先为应用算法定义输入契约:选定轴、统一正方向,角速度换算为rad/s,时间换算为单调递增的秒值。轴向、原始单位、权限、采样选项、退订签名以及LiveForm调用限制,应在接入层逐项核对。后面的算法只依赖这个契约。
加载顺序仍然沿用前文。在 onLiveFormCreate 中同步加载 pages/MintLive,之后读取植物资料;传感器初始化也不能挡住默认画面。若订阅失败,瓢虫停在起点,花盆和普通态入口仍然可用。
我把累计逻辑放进一个不依赖页面的对象。每个激活会话创建一个对象,参数由配置传入:
ts
interface MotionConfig {
deadZone: number; // rad/s
gain: number; // 路线进度 / rad,要求 > 0
start: number; // 起点进度,范围 [0, 1]
maxGap: number; // 可接受的样本间隔,单位 s
}
function clamp(v: number, low: number, high: number): number {
return Math.min(high, Math.max(low, v));
}
function removeDeadZone(v: number, threshold: number): number {
return Math.sign(v) * Math.max(Math.abs(v) - threshold, 0);
}
class RouteMotion {
private angle: number = 0;
private previousTime: number | undefined = undefined;
private config: MotionConfig;
constructor(config: MotionConfig) {
this.config = config;
}
accept(rate: number, time: number): number {
if (!Number.isFinite(rate) || !Number.isFinite(time)) {
this.previousTime = undefined;
return this.position();
}
const previous = this.previousTime;
if (previous === undefined) {
this.previousTime = time;
return this.position();
}
if (time <= previous) {
return this.position();
}
this.previousTime = time;
const dt = time - previous;
if (dt > this.config.maxGap) {
return this.position();
}
const c = this.config;
const speed = removeDeadZone(rate, c.deadZone);
const low = -c.start / c.gain;
const high = (1 - c.start) / c.gain;
this.angle = clamp(this.angle + speed * dt, low, high);
return this.position();
}
private position(): number {
const c = this.config;
return clamp(c.start + this.angle * c.gain, 0, 1);
}
}
创建对象前,配置需要验证为有限值:死区非负,增益与最大间隔为正,起点在闭区间内。不要等到除法产生无效结果,再让页面处理异常。
这段代码有几项刻意的选择。第一个样本只建立时间基准;重复或倒退的时间被丢弃;过长间隔更新基准,但不跨过空档积分。非有限输入则清除时间基准,等待下一有效样本重新开始。请求采样间隔不能替代实际时间差。
死区采用连续削减:阈值以内输出零,超过阈值后减去阈值大小。它避免在越过阈值的一刻直接跳到原始幅度,但也会减少有效累计量。轻微而缓慢的动作可能因此变得不明显,这正是后面需要测量的代价。

累计量本身也受边界限制。如果只限制最终位置,用户在边缘继续倾斜时,内部数值可能不断增长。等到反向,系统得先抵消这些看不见的积累,瓢虫才会离开边缘。这里让累计状态随路线一起饱和,使反向输入能够及时改变目标。
路线进度最后转换为激活UI内的vp坐标:
ts
interface Point {
x: number;
y: number;
}
function pointOnRoute(a: Point, b: Point, progress: number): Point {
const p = clamp(progress, 0, 1);
return {
x: a.x + (b.x - a.x) * p,
y: a.y + (b.y - a.y) * p
};
}
坐标转换沿用第03篇:背景根据 liveFormInfo.rect 放置,路线相对背景锚点定义,再换算到激活UI。不能把窗口位置当成局部偏移,也不能把传感器数值或截图像素直接填入vp坐标。花盆和叶片保持原来的尺寸。
目标更新之后,再决定画面如何接近它。这里采用按时间计算权重的一阶平滑,避免固定权重随着更新频率变化而产生不同手感:
ts
function smoothProgress(
current: number, target: number, dt: number, tau: number
): number {
if (tau <= 0) {
return clamp(target, 0, 1);
}
const alpha = 1 - Math.exp(-Math.max(dt, 0) / tau);
return clamp(current + alpha * (target - current), 0, 1);
}
dt 是本次渲染更新距离上次更新的秒数,tau 是平滑时间常数。调用方保证输入有限,每帧读取最新目标;传感器回调只更新目标,不再为每个样本叠加一段新的过渡。这个选择让时间处理集中在一处。
较大的 tau 会让变化更缓,也会增加跟随迟滞。官方示例中的100ms不能直接充当这里的最佳参数,更不能当作端到端响应时间。
参数应随构建一起保存,至少包括:
| 参数组 | 记录内容 |
|---|---|
| 输入 | 传感器类型、轴与符号、原始单位、时间换算、采样设置 |
| 映射 | 死区、增益、起点、累计边界、异常间隔上限 |
| 几何 | 路线端点、瓢虫尺寸、边缘余量、背景矩形 |
| 渲染 | 平滑时间常数、更新方式、初始化与停止规则 |
会话结束时,先把接收器标记为失效,再退订传感器、停止应用持有的渲染更新并释放引用。退订与停止操作即使其中一个失败,也应继续尝试其他清理并记录错误。清理入口需要允许重复调用。
排队中的回调还可能到达,因此回调入口必须检查所属会话是否仍然有效。重新激活时创建新的累计器与接收器,不能让旧回调获得新会话的写入权。仅靠把累计值清零,无法解决这个问题。
这些资源只在激活期间存在。单次动效申请仍不超过3500ms,而且可能提前终止;不通过后台监听或自动重试来延续这段互动。API26新增的系统摇动入口与这里的倾斜控制仍是两条职责不同的路径。
4. 检验
我会先记录输入,再比较算法。两次手动倾斜很难完全一样,第二次画面更顺滑,可能只是手移动得更慢。同一条输入回放可以固定这个变量。
每条原始记录保存样本序号、时间戳与各轴值,并在文件元数据中注明单位、轴向、设备、系统、SDK和构建编号。应用侧 runId 关联一次会话,formId 关联卡片实例,植物身份仍为 plant-001。
处理结果另存累计量、目标进度、插值进度、触边状态及丢弃原因。保留原始数据,才能在修改死区后重新计算,而不必把上一次处理后的结果误当输入。

比较从死区开始:固定增益、路线、采样和平滑设置,只改变阈值。随后固定死区,再研究平滑时间常数。每个真机条件重复三次,保留全部记录;提前中断的会话单列,不拼接成一条长轨迹。
| 动作或条件 | 需要核对的表现 |
|---|---|
| 平放手机 | 同一时间窗口内的位置波动与累计漂移 |
| 缓慢倾斜后停住 | 轻倾能否产生位移,停止后是否继续漂移 |
| 快速反向 | 输入反向到目标、画面分别反向的时间 |
| 到达边缘后反向 | 图形是否越界,返回是否受累计饱和影响 |
| 结束后再次激活 | 是否从同一起点开始,时间基准是否重建 |
| 突然退出后继续倾斜 | 是否仍有页面更新,旧回调是否被拒绝 |
| 订阅失败或异常样本 | 默认画面是否保留,是否发生持续重试 |
"抖动小"需要一个具体口径。可以在预先约定的平放窗口内记录最大与最小位移之差,同时观察起点到终点的漂移。一个缓慢漂走的瓢虫,未必有明显的高频抖动,不能因此判定稳定。
反向响应也分两段:输入何时满足预定反向判据,目标何时反向;画面又在何时显示反向。判据的幅度阈值和持续时间应在比较前固定,避免把某个噪声样本当成动作开始。

计算得到的插值位置只证明应用的计算结果。实际图形是否越过叶缘、是否及时转向,还需要录屏。测量截图时保留像素密度与裁剪比例,分别记录px和vp。没有共同时间基准时,不把日志时间直接对应到某一帧。
如果在观察到反向之前会话就结束,应记为"窗口内未出现"或"被中断",不能填成0ms。轨迹图也应由采集数据生成;指标和实际表现留到采集后填写,不用一条理想曲线代替结果。
最后检查退出。记录订阅数、清理事件和最后一次有效更新序号。退订调用返回,并不等于已经证明没有更新;还要观察后续回调是否被拒绝,以及再次激活是否只由新会话驱动。

平稳与灵敏需要放在一起解释。死区增大可能减少平放波动,也可能吞掉轻倾;平滑增强可能让轨迹柔和,也可能让反向更迟。参数选择应交代这些代价,结论限定在对应设备、输入和构建条件下。
5. 结尾
让瓢虫停在叶片上,首先需要明确位置的含义。角速度经过时间累计得到短时相对变化,路线约束决定它能走到哪里,平滑负责接近目标,会话边界负责何时停止。
沿着这些职责,我们得到了独立的映射代码、参数记录表、可回放输入格式和清理检查方法。它们使调参有了依据,也让故障能够落到具体环节。
下一篇继续使用同一盆 plant-001 和同一个 MintLive。固定这里的路线、起点和映射之后,再检查普通态与激活态进出时的画面:瓢虫开始走动或提前退场,花盆都应当还是原来的那一盆。