topfans/docs/superpowers/specs/2026-07-06-remove-hardware-gyro-design.md
2026-07-06 21:20:24 +08:00

25 KiB
Raw Blame History

移除光栅卡硬件陀螺仪 · 设计方案

状态:待审核 · 创建日期2026-07-06 · 目标分支feat/actvity

本方案替代 2026-06-30 引入的"加速度计 + One-Euro 滤波"方案commit 4eab42b)。旧 spec/plan 保留作为历史演进记录。


方案概述(必读

要解决的问题

业务问题

  • 铸爱光栅卡H5 + App的倾斜预览在 App 端需要硬件陀螺仪/加速度计权限UX 摩擦大:
    • iOS 13+ Safari / WKWebView 弹出 requestPermission,用户拒绝即不可用
    • Android 12+ 强制 BODY_SENSORS 权限,应用商店审核严格
    • 部分低端 Android 机陀螺仪硬件缺失,只能降级
  • 户外/静止场景下用户无法(或不想)倾斜手机预览,缺少替代交互

技术问题

  • 硬件传感器路径有 3 套Native.js Android / Native.js iOS / DeviceOrientationEvent代码 ~28KBuseLenticularStudioTilt.js
  • 4 天前刚把陀螺仪改为加速度计(4eab42b),整套 One-Euro 滤波、跳变拒绝、快慢双通道、预热跳过逻辑投入大、收益不明
  • 原生插件 imengyu-UniAndroidGyro(含 AAR + iOS Framework成为单点依赖
  • App 端倾斜效果与触摸拖拽效果已经并存LenticularCard.vuetouchstart/move/end 事件一直在 emit simulate),铸爱页(lenticular-result)却用 skipBuiltInTouch=true 屏蔽触摸、强制走硬件路径

整体实现路径

Phase 内容 工作量
Phase 1 useLenticularCraftTiltPreview 新增 simulateTilt / simulateTiltFromNormalized / simulateTiltReset 公共方法(保留旧路径并行) 0.5 天
Phase 2 新增 <ManualTiltSlider> 独立组件 0.5 天
Phase 3 <LenticularCard> / <HolographicCard>showSlider / sliderValue prop + update:sliderValue / sliderReset emitv-model 配套),引入 <ManualTiltSlider> 0.5 天
Phase 4 lenticular-result.vue 切换:改触摸接收器、加滑块、删 3 处 scheduleTiltStart/stopTiltPreview 调用 0.5 天
Phase 5 清理收尾:删 useLenticularStudioTilt.jsuseOneEuroFilter.js、原生插件目录、manifest 注册段、权限与隐私描述 0.5 天
QA 4.3 验收清单iOS 真机 / Android 真机 / H5 Chrome 1 天
合计 ~3.5 天

关键决策

  1. 彻底移除硬件传感器代码(不保留"硬件 / 手动"切换开关)— 决策依据CLAUDE.md MVP 先行原则,"应急开关"属于 YAGNI用户已确认手动模拟可以替代硬件方案
  2. 滑块做成独立组件 <ManualTiltSlider>,与卡片解耦 — 便于未来页面级复用
  3. 触摸拖拽路径保留LenticularCard / HolographicCard 已有),与滑块不互斥,数据驱动
  4. 离散档位逻辑保留(铸爱页 2 层卡硬切效果不动)— 与硬件源无关
  5. 删除原生插件目录 frontend/nativePlugins/imengyu-UniAndroidGyro/ — 这是物理上解除单点依赖
  6. iOS NSMotionUsageDescription 同步删(在 manifest.json)— 不删会导致应用商店审核仍有"访问运动传感器"声明
  7. 历史设计文档保留2026-06-30 accel-oneeuro design/plan— 作为设计演进记录,不删

核心架构图

┌────────────────────────────────────────────────────────────┐
│                    手动输入(双源)                          │
│  ┌────────────────────┐    ┌────────────────────────┐    │
│  │ LenticularCard.vue │    │ <ManualTiltSlider/>     │    │
│  │ (触摸拖拽)          │    │ (滑块 0..1)             │    │
│  └─────────┬──────────┘    └───────────┬────────────┘    │
│            │ emit('simulate', x, y)    │ change: v        │
│            │ 归一化 -1..+1              │ 归一化 0..1       │
└────────────┼─────────────────────────────┼────────────────┘
             ▼                             ▼
   ┌─────────────────────────────────────────────────────┐
   │           useLenticularCraftTiltPreview             │
   │                                                     │
   │  simulateTiltFromNormalized(x, y)  // 触摸入口      │
   │  simulateTilt(dx, dy)              // 滑块入口(度)  │
   │  simulateFromSignedDegrees(dx, dy) // 内部(度)     │
   │  simulate(gamma, beta)             // 内部(-1..+1) │
   │  ↓                                                 │
   │  离散档位映射 (TILT_ACTIVATE/DEACTIVATE 滞回)       │
   │  ↓                                                 │
   │  useLenticularPreview 渲染循环                       │
   │  ↓                                                 │
   │  engine.feedSimulatedTilt() → layerTransforms       │
   └─────────────────────────────────────────────────────┘
                              ▼
                  <LenticularCard :transforms />
                  <HolographicCard :viewAngle />

文档说明

  • 适用范围:前端 uni-app 铸爱光栅卡模块(含全息卡);不影响后端、不影响其他业务
  • 前置版本/历史
    • 4eab42b (2026-07-01) 引入加速度计 + One-Euro 滤波方案
    • 2855cd5 之前使用陀螺仪
    • 10168f8 AI 生成镭射卡功能
    • 889c923 镭射新链路
  • 工作量估算:约 3.5 天(开发 2.5 天 + QA 1 天)
  • 目标读者铸爱光栅卡模块的开发者、iOS/Android 打包配置维护者、QA 验收人

1. 架构与数据流

参见"核心架构图"小节。

数据流要点

  1. 彻底删除 useLenticularStudioTilt.js28KB / 894 行)+ useOneEuroFilter.js~75 行)
  2. API 统一入口useLenticularCraftTiltPreview 暴露 simulateTilt(dx, dy)(度)作为唯一倾斜入口
  3. 触摸路径:保留 LenticularCard.vue / HolographicCard.vue 的触摸事件,把 emit 转发到 simulateTiltFromNormalized(在 useLenticularCraftTiltPreview 内部做归一化 → 度数转换,转换系数 45°/单位
  4. 滑块路径:页面级 <LenticularCard show-slider v-model:slider-value="tiltValue" @slider-reset="tiltValue = 0.5" />(卡片内部嵌入 <ManualTiltSlider>所有权归调用方0.5=中位 → 0°
  5. 铸爱页lenticular-result:去掉 skipBuiltInTouch=true 改为默认false去掉 scheduleTiltStart 调用(无传感器要启动了)

2. 文件变更清单

2.1 删除3 项)

文件 / 目录 大小 删除理由
frontend/composables/useLenticularStudioTilt.js 28KB / 894 行 整体硬件传感器模块彻底无用
frontend/composables/useOneEuroFilter.js ~75 行 StudioTilt 一处使用grep 验证:仅 1 处 import
frontend/nativePlugins/imengyu-UniAndroidGyro/ 含 AAR + iOS Framework + package.json 原生插件无调用方后即可整体删除

2.2 新增1 项)

文件 说明
frontend/components/lenticular/ManualTiltSlider.vue uniapp <slider> 包装组件,双向绑定 0..1,含复位按钮

2.3 修改8 个文件)

文件 关键变更
frontend/composables/useLenticularCraftTiltPreview.js ① 删 useLenticularStudioTilt 引用和 scheduleTiltStart/stopTiltPreview ② 新增公共方法 simulateTilt(dx, dy)(度)③ 新增 simulateTiltFromNormalized(x, y)(适配触摸)④ lockPreviewStill 保留
frontend/composables/useLenticularPreview.js 改动最小:注释更新(移除"接受 simulate() 注入"的隐含承诺),内部逻辑不动
frontend/composables/useHolographicPreview.js startGyro/startDeviceOrientation 等所有 DeviceOrientationEvent 路径;保留 simulate(x, y) 作为唯一入口;移除 OneEuro 引用
frontend/components/lenticular/LenticularCard.vue ① 新增 prop showSlider: Boolean,默认 false ② 新增 prop sliderValue: Number0..1,双向)③ 模板内 v-if="showSlider" 渲染 <ManualTiltSlider> ④ 触摸开始/移动 emit simulate(x, y) 不变;触摸结束改为不再 emit (0,0)(与滑块数据驱动相容,见 4.1
frontend/components/lenticular/HolographicCard.vue 同上prop 一致;触摸结束同样不再 emit (0,0)
frontend/components/lenticular/CastloveLenticularPreview.vue show-slider prop 透传
frontend/pages/castlove/lenticular/lenticular-result.vue ① 删 scheduleTiltStart/stopTiltPreview 调用3 处)② 删 :skip-built-in-touch="true",改用触摸 emit ③ 加 show-slider prop ④ @simulate 改为 (x, y) => simulateTiltFromNormalized(x, y)
frontend/manifest.json ① 删 app-plus.nativePlugins.imengyu-UniAndroidGyro30-44 行)② 删 BODY_SENSORS 权限69 行)③ 删 NSMotionUsageDescription 隐私描述82 行)
构建设置 iOS Info.plist 由打包机自动生成,修改 manifest.json 即可生效)

2.4 不修改但需回归验证

  • frontend/pages/castlove/lenticular/lenticular-create.vue:不挂载 LenticularCard 实际渲染,无需改
  • frontend/pages/castlove/lenticular/lenticular-thinking.vue:同上
  • frontend/utils/lenticular-engine.js:渲染逻辑与输入源无关,无需改

3. API 设计

3.1 useLenticularCraftTiltPreview(layersRef)

返回对象

字段 类型 状态 说明
physics Reactive<PhysicsConfig> 保留 物理参数
layerTransforms Ref<Object> 保留 渲染输出(直接喂给 <LenticularCard :transforms>
simulate(gamma, beta) (number, number) => void 保留 内部用,归一化输入
relax(factor) (number=0.85) => void 保留 平滑归零
lockPreviewStill() () => void 保留 强制回中(清空离散档位状态 lastLayerIdx / snapCooldownUntil / dirSign / dirOppositeStreak + simulate(0, 0))。使用场景:进入页面初始化 / 用户主动点"归位"按钮 / 切换卡类型时。不再scheduleTiltStart 触发Phase 4 之后该调用一并删除)。
simulateTilt(dx, dy) (number, number) => void 新增 滑块/外部入口,单位
simulateTiltFromNormalized(x, y) (number, number) => void 新增 触摸入口,-1..+1 转 °(系数 45°
simulateTiltReset() () => void 新增 滑块点"复位"时调用
gyroSourceLabel Ref<string> 删除 不再有硬件源
scheduleTiltStart () => void 删除 不再启动传感器
stopTiltPreview () => void 删除 同上

内部状态lastLayerIdxsnapCooldownUntildirSigndirOppositeStreak 全保留(离散档位逻辑不动)。

3.2 useHolographicPreview()

字段 类型 状态
physics Reactive 保留
viewAngle Reactive{x,y,z} 保留
isWebGLReady, hasWebGLError, fps Ref 保留
simulate(x, y) (number, number) => void 保留(成为唯一驱动入口)
relax(factor) 保留
onWebGLReady/onWebGLError/onFPSUpdate 保留
startGyro/stopGyro 删除(设备方向监听整套删)
gyroSource 删除
accelHandler, accelBaselineReady, accelBaseX/Y 删除(基线追踪是 DeviceOrientationEvent 配套)
detectPerformanceTier, HOLO_PERFORMANCE_PRESETS 保留(独立工具函数)

3.3 <LenticularCard> / <HolographicCard> 新增 props

defineProps({
  // ... 既有 props ...
  skipBuiltInTouch: { type: Boolean, default: false },   // 保留
  showSlider:       { type: Boolean, default: false },   // 新增:是否显示内置滑块
  sliderValue:      { type: Number,  default: 0.5 },     // 新增v-model:sliderValue 双向绑定
})

const emit = defineEmits([
  'simulate',            // 保留:触摸拖拽 emit (x, y) -1..+1
  'update:sliderValue',  // 新增v-model 配套v-model:sliderValue 双向绑定)
  'sliderReset',         // 新增:滑块复位按钮 emit ()
])

emit 转发逻辑(卡片内部):

<ManualTiltSlider
  v-if="showSlider"
  :value="sliderValue"
  @change="v => emit('update:sliderValue', v)"
  @reset="emit('sliderReset')"
/>
  • gyroSource prop 保留但写死为 'simulation'(不删 prop 以避免破坏外部传参,向后兼容,仅作为显示字段,不参与业务逻辑)
  • 滑块所有权归调用方:调用方使用 v-model:sliderValue="tiltValue" 双向绑定,滑块拖动时自动更新 tiltValue;调用方再将 tiltValue 通过 simulateTilt 喂入 composable
  • sliderReset emit 后,调用方负责将 tiltValue 重置为 0.5

3.4 <ManualTiltSlider> 新组件 API

// 文件frontend/components/lenticular/ManualTiltSlider.vue
defineProps({
  value:      { type: Number,  default: 0.5, validator: v => v >= 0 && v <= 1 },
  disabled:   { type: Boolean, default: false },
  showReset:  { type: Boolean, default: true },
  min:        { type: Number,  default: 0 },
  max:        { type: Number,  default: 1 },
  step:       { type: Number,  default: 0.01 },
})

const emit = defineEmits<{
  change: [v: number]   // 用户拖动
  reset:  []            // 点复位
}>()
  • 视觉:卡片下方 32rpx 高度的细条 + 居中圆点 thumb + 右侧小图标"↺"
  • 平台:使用 <slider> uniapp 组件App/H5/小程序都支持,原生渲染,跟手)

3.5 数值约定一致性

上下文 范围 备注
触摸 emit 实际 [-1.1, +1.1],有效 [-1, +1] 现有 LenticularCard.dispatch() 末尾 * 1.1 系数放大line 183-184允许轻微超出模拟路径中由 3.5 公式截断到 [-45°, +45°]
滑块 value 0..1 中位 0.5 = 0°
simulateTilt(dx, dy) -45..+45 度 物理量纲,与旧 simulateFromSignedDegrees 一致(推导:触摸 x∈[-1,+1]×45° 与滑块 v∈[0,1]×(±45°)
simulate(gamma, beta) -1..+1 引擎层归一化
滑块 → 度 公式 dx = (v - 0.5) * 90v ∈ [0,1] → dx ∈ [-45°, +45°]
触摸 → 度 公式 dx = x * 45x ∈ [-1, +1] → dx ∈ [-45°, +45°]

4. UI + 错误处理 + 测试

4.1 滑块 UI 形态

┌────────────────────────────────────────────────┐
│                                                │
│           ┌──────────────────┐                  │
│           │                  │                  │
│           │   LenticularCard │  ← 触摸区        │
│           │                  │  (emit simulate) │
│           │                  │                  │
│           └──────────────────┘                  │
│                                                │
│   ↺  [────●──────────]                          │
│         0    0.5    1                           │
└────────────────────────────────────────────────┘
元素 规格
位置 卡片下方 16rpx 间距,水平居中
高度 48rpx
滑块本体 <slider> uniapp 组件min=0 / max=1 / step=0.01
Thumb 圆点 24rpx 直径,主题色高亮
复位按钮 左侧 图标 32rpx点击 emit sliderReset
颜色变量 与 LenticularCard 主题色一致(透传 prop 或写死 #6E7AFF

两种输入源的优先级规则

  • 触摸拖拽 + 滑块不互斥:都向同一 simulateTilt* 入口打,最后一次操作生效
  • 触摸结束 emit (0, 0),会盖掉滑块当前位置 → 这是 bug
  • 修复方案:触摸结束 emit 改为不发送 (0,0),改由调用方决定是否 relax(0.86),或 lockPreviewStill()
  • 滑块拖动时也类似:在拖动结束时 emit reset完全由数据驱动(滑块 0.5 = 中位 = 不倾斜)

4.2 错误处理 / 边界场景

场景 处理
skipBuiltInTouch=true + showSlider=true 都允许,触摸被屏蔽,滑块生效
skipBuiltInTouch=false + 触摸与滑块同时操作 最后一次事件胜出,无须加锁
触摸事件 e.touches 为空(changedTouches 也为空) 现有代码已用 if (!touch) return 兜底,保留
滑块 value 超出 0..1(外部 bug 组件 prop validator 拦截console.warn
simulateTilt 收到 NaN/Infinity 入口处 Number.isFinite() 检查,否则 console.warn 丢弃
layersRef 长度为 0 或 1 离散档位逻辑已有 if (count < 2) return 兜底,保留
App 端 WebView 触摸延迟 uniapp <slider> 原生组件,跟手性 OK触摸事件延迟是 uniapp 通用问题,沿用现状
旧硬件传感器代码残留 useLenticularStudioTilt.js 整体删除,无残留风险
Phase 5 漏删 BODY_SENSORS 权限 §2.3 已列入修改清单QA 用附录 grep 命令兜底校验
Phase 5 漏删 iOS NSMotionUsageDescription §2.3 已列入修改清单QA 用附录 grep 命令兜底校验

4.3 测试策略

前端测试原则(来自 memory share-impl-test-policy.md):前端不写 Vitest走手动验收 + 后端接口测试

类型 覆盖范围 方法
后端接口测试 不涉及本次(无后端变更)
前端手动验收 所有修改页面iOS 真机 / Android 真机 / H5 Chrome 见下方验收清单
单元验证 useLenticularCraftTiltPreviewsimulateTilt / simulateTiltFromNormalized / 离散档位逻辑 在 DevTools console 用 console.log 直接调用验证(不再单独写测试文件)
平台覆盖 iOS 真机、Android 真机、H5 Chrome QA 走查

手动验收清单(每平台各一遍)

  1. 打开 lenticular-result 页面,确认卡片显示正常
  2. 触摸拖拽:手指在卡片上左右拖动,卡片光栅效果跟随;松开手指不强制回中(验证 4.1 修复)
  3. 滑块拖动:拖动滑块 0 → 1卡片光栅从最左切到最右铸爱页 2 层卡应硬切档位)
  4. 复位按钮:点击 ↺,滑块回 0.5,卡片回中间
  5. 组合:拖动滑块到 0.3,然后触摸卡片到最右 → 触摸胜出
  6. manifest.jsonBODY_SENSORS / NSMotionUsageDescription / nativePlugins.imengyu-UniAndroidGyro 已删除grep 验证)
  7. 旧 iOS 用户授权提示不再弹出(验证 DeviceOrientationEvent 已删干净)
  8. Android 真机扫描APP 启动时不再申请 BODY_SENSORSadb logcat | grep SENSORS
  9. lenticular-create / lenticular-thinking 页面无回归(这两个页面无 LenticularCard 实际渲染,但要确认不报错)

5. 实施顺序 + 风险 + 回退

5.1 实施顺序5 个阶段,每阶段独立可回退)

Phase 内容 涉及文件 风险 验证
1. 底层 API useLenticularCraftTiltPreview 新增 simulateTilt / simulateTiltFromNormalized / simulateTiltReset保留scheduleTiltStart 等方法(先并行不删) 1 文件 极低(纯新增) 单元调用验证
2. 新组件 新增 ManualTiltSlider.vue,独立可单测 1 新文件 极低 H5 Chrome 单独挂载测
3. 卡片接入 LenticularCard / HolographicCardshowSlider/sliderValue prop + update:sliderValue/sliderReset emitv-model 配套),引入 <ManualTiltSlider> 2 文件 prop 增减可能影响外部调用方) lenticular-result 页面挂载验证
4. 页面切换 lenticular-result.vue:① 改 @simulate 接收器 ② 加 show-slider ③ 删 scheduleTiltStart 等 3 处调用 1 文件 触摸 + 滑块组合验收
5. 清理收尾 ① 删 useLenticularStudioTilt.js ② 删 useOneEuroFilter.js ③ 删原生插件目录 frontend/nativePlugins/imengyu-UniAndroidGyro/ ④ 删 manifest.jsonnativePlugins 注册段、BODY_SENSORSNSMotionUsageDescription ⑤ PR 描述提示 3 文件 + 1 目录 中(删除 28KB + 改 manifest grep 残留 + 真机无传感器调用

关键不变量Phase 1-4 完成后仍然可回退到当前状态(旧 scheduleTiltStart 路径保留运行);只有 Phase 5 是真正不可逆。

5.2 风险点

风险 等级 缓解措施
useLenticularStudioTilt.js 删除后存在隐藏引用 Phase 5 之前全局 grep useLenticularStudioTilt / useOneEuroFilter / BODY_SENSORS / NSMotionUsageDescription / imengyu-UniAndroidGyro 必须 0 命中CI 加 grep 校验
触摸结束 emit (0,0) 导致与滑块状态打架 已在 4.1 给出修复方案(触摸结束不强制发 0,0由数据驱动
HolographicCard.vue 当前无页面引用 按"全套都改"原则同步清理,不单独删除该组件(可能未来用)
4eab42b 新增的两个 accel-oneeuro 文档变成历史 保留不删(作为设计演进记录),但在新 spec 开头加"本方案替代 2026-06-30 accel-oneeuro 方案"说明
iOS NSMotionUsageDescription 不在仓库 PR 描述 / iOS 开发者文档显式提示
Android BODY_SENSORS 权限是 4eab42b 才加的 manifest 改动是单点git blame 清晰
触屏 + 滑块同时操作的边界 设计上接受"最后一次胜出"无锁4.2 已说明
真机回归范围 iOS/Android/H5 三平台均需过 4.3 验收清单
设计师未确认滑块视觉 4.1 已给出 ASCII mock + 规格UI 沿用主题色即可
滑块 emit 命名(update:sliderValue)与 simulate 风格不同 极低 滑块用 Vue 3 标准 v-model 模式(update:sliderValue),与现有 simulate(x, y) 风格不同但符合 Vue 约定§3.3 命名表已标注

5.3 兼容回退方案

阶段 回退方式 粒度
Phase 1 出问题 revert 单 commit 1 文件
Phase 2 出问题 删除新文件 1 文件
Phase 3 出问题 revert prop 改动 2 文件
Phase 4 出问题 revert 页面改动 1 文件
Phase 5 出问题 revert 删除 + manifest 3 文件 + 需回补权限字符串 + 恢复插件目录
整体回退 git revert <merge-commit> 一次到位

应急开关(可选,不实施除非 QA 需要):

  • 保留一个 LENTICULAR_USE_HARDWARE_GYRO 全局开关,默认 false
  • 如果线上发现严重问题,远程打开临时回退到旧路径
  • 决定:不实现。理由CLAUDE.md MVP 先行原则 + 28KB 代码就为"应急"留着属于 YAGNI。Phase 1-4 并行保留旧路径已经足够

5.4 范围确认

维度 范围
包含 5 个 composables删 2 改 3删 useLenticularStudioTilt + useOneEuroFilter改 useLenticularCraftTiltPreview + useLenticularPreview + useHolographicPreview+ 1 新组件 + 1 原生插件目录(删)+ 2 卡片组件LenticularCard + HolographicCard+ 1 包装预览组件CastloveLenticularPreview+ 1 页面 + manifest
包含 1 个新 spec 文档 docs/superpowers/specs/2026-07-06-remove-hardware-gyro-design.md(本文件)
不包含 后端代码(无后端变更)
不包含 lenticular-create.vue / lenticular-thinking.vue(无 LenticularCard 实际渲染)
不包含 删除 4eab42b 的 accel-oneeuro 设计/实施文档(保留为历史记录)
不包含 重构 LenticularEngine 渲染逻辑(本次不改)
不包含 "未来接回硬件"的接口预留YAGNI

5.5 文档同步

  • 新增docs/superpowers/specs/2026-07-06-remove-hardware-gyro-design.md(本文件)
  • 保留docs/superpowers/specs/2026-06-30-lenticular-android-accel-oneeuro-design.md(历史)
  • 保留docs/superpowers/plans/2026-06-30-android-accel-oneeuro-implementation.md(历史)
  • 保留docs/superpowers/specs/2026-06-26-lenticular-webgl-engine-design.mdWebGL 引擎与倾斜源无关)
  • 保留docs/superpowers/specs/2026-05-22-lenticular-gyro-optimization-design.md(原始陀螺仪方案)

grep 校验清单Phase 5 前必跑)

# 应全部 0 命中
grep -rn "useLenticularStudioTilt" frontend/
grep -rn "useOneEuroFilter" frontend/
grep -rn "imengyu-UniAndroidGyro" frontend/
grep -rn "BODY_SENSORS" frontend/
grep -rn "NSMotionUsageDescription" frontend/
grep -rn "imengyu" frontend/