阳台小花园开发手记 04|HarmonyOS 7.0 互动卡片:倾斜手机时的卡片效果

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。固定这里的路线、起点和映射之后,再检查普通态与激活态进出时的画面:瓢虫开始走动或提前退场,花盆都应当还是原来的那一盆。

相关推荐
威哥爱编程4 小时前
模拟面试智能体开发实战 04:面试聊到哪算哪?用状态机把整场面试编排起来
python·agent·harmonyos
威哥爱编程4 小时前
模拟面试智能体开发实战 03:AI 面试官太客气?人设 Prompt 让它像个真考官
python·agent·harmonyos
贾伟康9 小时前
HarmonyOS API 26 Navigation 导航栏位置:navBarPosition、左右布局与折叠屏适配
harmonyos·arkts·arkui·navigation·折叠屏
贾伟康1 天前
HarmonyOS API 26 DevEco Code 需求到 ArkTS 骨架的提示词分层
harmonyos·arkts·ai coding·deveco code·工程提效
威哥爱编程1 天前
模拟面试智能体开发实战 02:回答等半分钟,谁还愿意练?把服务端和流式输出搭起来
python·agent·harmonyos
威哥爱编程1 天前
模拟面试智能体开发实战 01:面试挂了都不知道挂在哪?用 Python 给自己造一个随叫随到的 AI 面试官
python·agent·harmonyos
贾伟康1 天前
HarmonyOS API 26 HMAF Skill 可解释任务计划与失败回退
harmonyos·arkts·智能体·skill·hmaf
贾伟康1 天前
HarmonyOS API 26 HMAF 2.0 意图解析的可解释动作图
harmonyos·arkts·智能体·a2a·hmaf
KevinChaos1 天前
阳台小花园开发手记 03|HarmonyOS 7.0 互动卡片:叶片伸出边框仍与花盆对齐
华为·harmonyos