很多人做小程序的第一个练手项目是待办清单或者天气查询,这两个确实经典,但它们有个共同的短板:全程都在跟接口和列表打交道,碰不到"设备本身"。你想练的东西——页面生命周期、渲染层和逻辑层怎么通信、原生能力怎么调、真机和模拟器差在哪——它们一个都练不到。指南针这个案例不一样,它代码量不大,一个页面就能写完,但它把小程序里最容易被跳过的那块给逼出来了:手机传感器怎么通过 JSAPI 把数据喂给页面,页面又怎么在高频数据下不卡。我带过几个刚入门的朋友做这个,几乎每个人都会在同一个地方卡住,区别只是卡住之后有没有搞明白为什么。
下面我按真实的开发顺序走一遍,从建项目到真机调通,中间把选型理由、参数含义、踩坑记录都摊开讲。看完你应该能独立写出来,并且知道每一行代码为什么这么写,而不是复制粘贴一堆看不懂的 rotate。
1. 为什么我用指南针当小程序的第一个练手项目
1.1 这个小案例一次性串起了小程序的四条主线
先说清楚它到底练了什么。一个能跑的指南针小程序,至少要覆盖这四件事:
- 项目结构:app.json 里注册页面、页面目录下四个文件的职责划分,这是所有小程序的地基。
- 原生能力调用:
wx.startCompass、wx.onCompassChange、wx.stopCompass这一组 API,属于"设备和原生能力"这一大类,和定位、陀螺仪、蓝牙是同一套调用范式。 - 数据驱动视图:传感器回调拿到的角度值,怎么变成界面上转动的表盘。
- 生命周期管理:页面隐藏、切后台、退出时把监听关掉,这是新手最容易漏的一步,也是后面做任何带定时器、带监听的小程序都必须养成的习惯。
把这四件事拆开看,每一件都有更简单的替代练法。但能在一个两百行以内的项目里同时练到,指南针算是性价比最高的那一档。而且它的反馈特别直观——转手机,指针跟着动,成没成功一眼就知道,不用去猜接口返回对不对。
另外一个隐性好处是:指南针通常不需要弹授权框。这跟定位完全不同,wx.getLocation一调用就是权限引导弹窗、用户拒绝、拒绝后引导去设置页那一整套流程。入门阶段先绕开授权这条线,能让你专注在"数据怎么用"上。当然等你要做地图类项目,授权那块迟早要补,但不是现在。
1.2 很多人对"指南针小程序"的三个误解
我见过最多的三个误解,先摆在前面,避免你走弯路。
误解一:以为指南针拿到的是手机屏幕指向哪里。实际上direction描述的是手机在水平面上摆放时,机身上方(通常是屏幕顶端)相对正北偏转了多少度。这个前提很重要——它是给水平放置的手机定义的。你要是把手机立起来对着自己看,读数的物理含义就变了,指针会开始乱跳,这不是代码 bug。
误解二:以为开发者工具里能调。真不是。模拟器对罗盘这类持续变化的传感器支持很不完整,我常用的几个版本里,模拟器上基本看不到数值变化。第一次打开的人十有八九会以为代码写错了,然后开始怀疑基础库版本、怀疑 API 名字拼错,折腾半天。记住一句:指南针这类项目,从第一行代码开始就应该用真机调试,模拟器只用来调样式布局。
误解三:以为度数越精确越好。消费级手机的磁力计精度本来就有限,加上周围环境的磁场干扰(手机壳的磁吸、桌上的金属、旁边的音箱),读数是会漂的。追求"一点都不抖"是错误目标,正确的目标是在抖动和灵敏度之间找一个舒服的平衡点,这个后面第 4 节会具体讲怎么做。
2. 项目骨架:一个页面就够,但配置不能马虎
2.1 app.json 与页面注册的最小集合
新建项目之后,你会拿到一个默认的目录结构。指南针只需要一个页面,所以先把多余的东西删干净,只留一个pages/index/index。这一步很重要,因为很多人后面调样式调到怀疑人生,原因就是默认页面的 wxss 里有一堆全局样式在捣乱。
app.json的最小配置大致是这样:
{ "pages": [ "pages/index/index" ], "window": { "navigationBarTitleText": "指南针", "navigationBarBackgroundColor": "#1a1a1a", "navigationBarTextStyle": "white", "backgroundColor": "#1a1a1a" }, "style": "v2", "sitemapLocation": "sitemap.json" }几个细节值得说。navigationBarBackgroundColor和backgroundColor我都设成了深色,因为指南针这类仪表的视觉语言天然偏暗色——浅色背景下刻度线很难做得清晰,而且深色能藏住一些渲染上的小瑕疵。"style": "v2"是新项目默认带的,它会启用新版组件样式,如果你后面发现 button 之类的样式跟教程对不上,先检查这一项。
pages数组的顺序有讲究:第一项就是小程序的启动页。只有一个页面时无所谓,但等你要加"设置页""关于页"的时候,别把启动页挪到第二位去了。
2.2 页面四件套各自管什么
页面目录下的四个文件,职责一定要分清楚,不然后面会越写越乱。
| 文件 | 职责 | 指南针项目里放什么 |
|---|---|---|
index.wxml | 结构 | 表盘容器、刻度、指针、角度文字 |
index.wxss | 样式 | 圆形表盘、刻度线定位、旋转动画、配色 |
index.js | 逻辑 | 启动监听、接收方向数据、更新视图、页面卸载时停止监听 |
index.json | 页面配置 | 一般只写{},或单独覆盖导航栏标题 |
新手常见的错误是把大量逻辑塞进 wxml 的表达式里,比如直接在模板里算角度、算方位文字。模板里的表达式能力很有限,复杂计算一定要放到 js 里算好,模板只负责展示。这跟 Vue、React 里"模板里别写业务逻辑"是同一条经验。
另外index.json不要留空文件不管,如果里面残留了默认的"usingComponents": {}之外的配置,可能会和 app.json 冲突。养成习惯:不用的字段删掉,而不是注释掉。
2.3 表盘用图片还是纯 CSS:我的选型过程和理由
这是这个项目第一个真正需要做决定的地方。可选方案有三个,我把当时的对比列出来:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 整张表盘 PNG 图片 | 做得快,视觉效果上限高 | 高倍屏要出多套图,包体积大,颜色改一次要重新导出 | 表盘有复杂纹理、品牌标识 |
| 纯 CSS 画(view + transform) | 体积为零,颜色随手改,刻度可动态生成 | 写起来啰嗦,精细纹理做不出来 | 极简/工业风表盘,本案例首选 |
| Canvas 绘制 | 自由度最高,性能可控,能画任何东西 | 要处理 DPR、尺寸适配,代码量大一截 | 需要复杂图形或高频重绘 |
我选的是纯 CSS。理由很直接:这个项目的目的是练小程序的机制,不是练美术。纯 CSS 方案不需要任何外部资源,克隆代码就能跑,不会出现"图片路径不对导致表盘不见了"这种跟主题无关的问题。而且刻度用wx:for循环生成,顺便又练了一遍列表渲染。
具体怎么做?表盘分三层:
- 外圈容器:一个正方形
view,用border-radius: 50%变成圆,作为定位基准。 - 刻度层:用
wx:for生成 72 条刻度(每 5 度一条),每条用transform: rotate(Ndeg)从圆心向外摆放。做法是给刻度一个"高度为半径、宽度为 1px、transform-origin在底部"的长条,旋转之后就自然呈放射状。 - 指针层:一个三角形(用
border技巧画)或者一个细长条,固定在表盘中央,默认朝上。
这里有个小坑:transform-origin的默认值是元素中心,而刻度需要绕表盘圆心旋转,所以刻度的定位必须在圆心处,然后靠旋转展开。我见过有人用left/top一个个算坐标去摆刻度,算到最后发现精度不够、缝隙不均匀,白折腾。
3. direction 从哪来:把磁力计的数据讲明白
3.1 磁力计、加速度计和"手机朝向"的三角关系
在写代码之前,值得花两分钟搞清楚数据是怎么来的,不然出了问题你连往哪个方向排查都不知道。
手机里跟"朝向"有关的传感器主要是两个:
- 磁力计(电子罗盘):测的是地磁场在手机三个轴上的分量。地磁场是个天然存在的、方向基本稳定的矢量,磁力计通过测量它的分量,就能反推出手机相对磁北的偏转角。这就是指南针能工作的物理基础。
- 加速度计:测的是重力方向,用来判断手机是平放、竖放还是倾斜。微信在计算
direction的时候,会结合加速度计的数据做姿态补偿。
理解这一点非常关键,它解释了两个现象。第一,为什么手机要尽量平放——地磁场的水平分量才是判断方位的关键,手机倾斜时计算难度上升,结果也就不稳。第二,为什么周围有磁铁会乱——磁力计读的是"所有磁场叠加的结果",你旁边放个磁吸手机壳,它测到的是地磁场加磁铁的场,方向自然偏了。
3.2 direction 的取值、跳变和 accuracy
微信的罗盘接口回调和别的传感器一样,是个持续触发的形式:
wx.onCompassChange(function (res) { console.log(res.direction, res.accuracy) })res.direction是面对的方向与正北的夹角,单位是度,取值 0 到 360,正北为 0,顺时针递增。也就是东是 90,南是 180,西是 270。
这里有个必须提前意识到的问题:它是环形的,不是线性的。359 度再转一点就变成 1 度,这个跳变会让任何直接做插值的代码抽风。我第一版就栽在这——指针从 359 度"平滑过渡"到 1 度时,会疯狂倒转一整圈,看起来像抽筋。后面第 4 节会给完整的处理方案。
res.accuracy官方给的是精度参考值,不同基础库版本对这个字段的说明略有差异,我的用法是:把它当成一个可信度提示,数值越小通常越稳。它不适合拿来做精确的数学修正,但可以用来做一个简单的"信号质量"指示——比如数值大于某个阈值时,在界面上提示"请远离金属物体或做 8 字校准"。这个功能加上去之后,体验会明显不一样,因为用户终于知道"读数乱跳不是 App 坏了"。
3.3 三个传感器 API 该怎么选
微信提供的和方向相关的 API 不止一个,很多人第一次翻文档会看花眼。我把它们的关系理一下:
| API | 提供的数据 | 用在哪 |
|---|---|---|
wx.onCompassChange | 相对正北的方位角 | 指南针、方向指示 |
wx.startCompass/wx.stopCompass | 开启/关闭罗盘监听 | 精确控制监听的生命周期 |
wx.onDeviceMotionChange | 三轴旋转角(alpha/beta/gamma) | 需要完整姿态的场景,如水平仪、体感 |
wx.onAccelerometerChange | 三轴加速度 | 摇一摇、计步、翻转检测 |
单做指南针,wx.onCompassChange就够了。但我建议一定要配合wx.startCompass和wx.stopCompass用,原因有两个。
第一个原因跟版本有关:早期基础库在页面里注册onCompassChange之后,部分场景下监听会一直持续,即使你离开页面。显式调用startCompass开启、stopCompass关闭,行为最可控。
第二个原因是省电。传感器持续开着是实打实耗电的,用户在小程序里逛别的页面时,你的罗盘还在后台跑,这不合适。
典型的调用节奏是:onLoad或onShow里startCompass,onHide和onUnload里stopCompass。为什么两个都要加?因为onHide管的是"切后台/跳到别的页面",onUnload管的是"页面被销毁"。只写onUnload,用户切到微信聊天再切回来,监听其实还在;只写onHide,页面真正销毁时资源没释放干净。两个都写上,几行代码的事。
提示:
onCompassChange这类监听接口,多次调用会注册多个回调。如果你在onShow里无条件注册,每次切回页面都会多一个回调,角度更新就会越来越频繁、越来越卡。规范做法是在注册前先wx.offCompassChange反注册一次,或者用一个标记位保证只注册一次。
4. 指针动起来:旋转到底该转谁
4.1 表盘反转还是指针正转
这是全篇最值得想清楚的一个设计问题。
物理世界里的指南针是这样的:指针永远指向北(指北针),你转动底座,底座上的刻度盘跟着转,指针相对底座的角度就变了。手机上的"指南针"其实是反过来的——手机的屏幕就是那个底座,表盘贴在你手上,你转手机,表盘跟着你转,这时候你希望指针始终指向地球的北方,所以指针要相对屏幕反向旋转。
但视觉上更常见的做法恰恰相反:让刻度盘反向旋转,指针固定朝上。这样用户看到的是"整个表盘在转",指针纹丝不动地指向屏幕顶端,加上顶部一个固定的指示标记,读出来的就是当前手机朝向的方位角。
两种都能用,我选了后者,理由是它和"手机屏幕顶部指向哪里"这个直觉一致。你转手机 90 度,表盘就转 90 度,屏幕上"北"这个字跑到左边去了,非常符合预期。
实现上就一行:
<view class="dial" style="transform: rotate({{rotateDeg}}deg)"> <!-- 刻度、方位文字都放这里面 --> </view>rotateDeg等于-direction。注意是负的,因为要让表盘朝反方向补偿手机自身的旋转。
配合一条 CSS 过渡,视觉上就顺滑了:
.dial { width: 560rpx; height: 560rpx; border-radius: 50%; transition: transform 180ms linear; }180ms这个值有讲究。太短(比如 50ms)传感器本身数据就那样,看起来还是一跳一跳;太长(比如 500ms)会明显滞后,你手都停下来了指针还在追。180 到 220 之间是我实测比较舒服的区间。
4.2 角度跳变处理与低通滤波
上一节说的 359 到 1 度跳变问题,这里给完整方案。
先看滤波器本身。原始数据带噪声,直接上界面会抖得厉害。最省事又有效的办法是一阶低通滤波(本质就是加权平均):
// 角度低通滤波 // 关键在于先把角度差归一化到 -180 ~ 180,再插值 function smoothAngle(current, last, k) { let delta = current - last if (delta > 180) delta -= 360 if (delta < -180) delta += 360 let next = last + delta * k return (next + 360) % 360 }这三行是整个指南针最核心的算法,值得逐句说。
delta = current - last算的是"这一次比上一次转了多少"。如果这个差值大于 180 度,说明实际转向应该是另一个方向(比如从 359 到 1,差值是 -358,但物理上只转了 +2 度)。所以用两条 if 把差值强制拉回-180 ~ 180这个最短路径区间。这一步不做,就会出现"指针绕远路倒转一圈"的鬼畜画面。
k是平滑系数,取值 0 到 1。k越大跟手越紧、抖动越明显;k越小越稳、越迟钝。我在真机上试下来,静止时用 0.15,运动时用 0.35是个不错的组合,可以简单根据两次采样的差值大小动态切换:
const speed = Math.abs(delta) const k = speed > 15 ? 0.35 : 0.15最后(next + 360) % 360是把结果重新归一化回 0 到 360,因为插值之后可能算出负数。
再配合上一步的 CSS transition,双重平滑叠加,实际手感相当不错。这里有个反直觉的经验:别把两种平滑都调到最强。滤波系数调得很小、过渡时间又设得很长,结果就是指针像泡在水里,慢慢悠悠地飘过去,看着比抖动还难受。我的做法是滤波负责去掉高频噪声,过渡负责补上视觉圆角,两者都取中等偏轻的量。
4.3 用 WXS 把高频更新从逻辑层挪走
如果你把rotateDeg放在data里,用setData更新,在真机上大概率会看到卡顿。原因是setData要走一次逻辑层到渲染层的跨线程序列化通信,一秒钟调几十次,开销很实在。
有两个优化方向,按投入产出比排序。
方向一:降低更新频率。最简单,用时间戳节流,比如保证 100ms 内最多更新一次界面,中间的数据照常接收但不渲染:
let lastUpdate = 0 wx.onCompassChange(function (res) { const now = Date.now() if (now - lastUpdate < 100) return lastUpdate = now this.setData({ rotateDeg: -res.direction }) })十帧每秒左右,配合 CSS 过渡,视觉上完全够用。这个方案改动最小,建议先做这个。
方向二:用 WXS 响应事件,绕开 setData。WXS 是运行在渲染层的脚本,可以在渲染层直接改样式,不走跨线程通信。适合你已经把其它都调好了、还想再压一压性能的情况。
// dial.wxs function update(newValue, oldValue, ownerInstance) { ownerInstance.selectComponent('.dial').setStyle({ transform: 'rotate(' + (-newValue) + 'deg)' }) } module.exports = { update: update }<view class="dial" change:prop="{{dialWxs.update}}" prop="{{direction}}"></view>两个注意点。第一,WXS 响应事件在较低的基础库版本上不支持,用之前先确认目标基础库。第二,selectComponent选中的节点必须有稳定的类名,别用动态类名。
还有一个更彻底的方案:用 Canvas 画整个表盘。Canvas 的 2D 接口里有requestAnimationFrame,可以在渲染层自己驱动重绘,完全不用 setData。代码量大不少,要处理 DPR、尺寸适配、每一帧重绘刻度。我的建议是前两个项目别碰 Canvas,等你要做数据可视化或者复杂动效的时候再上。
5. 模拟器里稳如老狗,真机上乱转:调试链路复盘
5.1 开发者工具能测什么、不能测什么
先说结论,省得你重复我的弯路:
- 能用模拟器做的:布局、配色、字体大小、表盘刻度位置、静态的旋转效果(手动改
data里的初始角度)。 - 不能用模拟器做的:方向数据的真实变化、精度表现、不同机型的差异、耗电和性能。
我踩的第一个坑就是对着模拟器调了半天。界面完美,指针不动。我当时的怀疑顺序是这样的:先怀疑 API 名字拼错(检查了,没错),再怀疑基础库版本太低(查了,够),然后怀疑权限(指南针不需要,白查),最后才想起来去真机上看。真机一跑,动得好好的。
从那以后我养成了一个习惯:只要涉及传感器、蓝牙、相机、文件系统这类原生能力,第一件事就是真机预览,别在模拟器上纠结。
真机调试的两种方式也顺便说一下区别。"预览"是扫码在真机上跑,看效果最快,但看不到 console。"真机调试"会建立调试连接,能看日志、能打断点,但有时候会有延迟。我的用法是:改样式用预览,排查数据问题用真机调试。
5.2 磁干扰、8 字校准和手机姿态
真机跑起来之后,你很快就会遇到读数不准的问题。这不是你的代码问题,是物理环境问题。常见的干扰源按影响从大到小排:
- 磁吸手机壳、磁吸支架:影响最大,基本属于"有它就别想准"。
- 扬声器、耳机、电源适配器:附近半米内都有影响。
- 金属桌面、笔记本电脑:影响明显但通常不至于完全失效。
- 钢筋混凝土建筑内部:地磁场被建筑结构影响,整体偏差。
校准的办法就是经典的"8 字晃动"——拿着手机在空中画几个 8 字,让磁力计采集到各个方向的磁场数据,重新建立基准。这是所有电子罗盘的通用做法,不是玄学。
手机姿态也很关键。水平放置时读数最可信,因为这时候重力方向和屏幕法线重合,姿态补偿最简单。你要是把手机立起来看,指针会开始乱走,因为倾斜状态下的方位计算依赖加速度计和磁力计的联合解算,误差被放大了。
我的处理办法是在界面上加一句轻提示:"请将手机水平放置"。这句话看起来不起眼,但它能消掉一大半"你的指南针不准"的反馈。
5.3 iOS 与 Android 的实际差异记录
跨平台差异是这个项目另一个必修课。我把实测到的不同整理成表:
| 差异点 | iOS | Android |
|---|---|---|
| 数据更新频率 | 相对较稳,约 10 次/秒上下 | 机型差异大,部分机型明显更频繁 |
| 静止时的抖动 | 较小,但偶尔有缓慢漂移 | 抖动更明显,个别机型有周期性跳变 |
| 首次启动 | 通常很快出数 | 部分机型有 1 秒左右预热期 |
| 表盘过渡手感 | 同样的 transition 时长,iOS 看起来更顺 | 部分机型需要把 transition 稍微调长一点 |
这张表不保证覆盖所有机型,但方向是准的:iOS 的数据更"干净",Android 更"毛"。所以同一套滤波参数在两端的手感可能不一样。如果你的用户两端都有,可以考虑在代码里做一点跟随:Android 上把滤波系数调小一点(更平滑),iOS 上保持原值。用wx.getSystemInfoSync()里的platform字段就能区分。
不过我的建议是入门阶段先别做这个区分,统一用一套中等参数,等真的收到反馈再说。过早为平台差异写分支,只会让代码变乱。
6. 五个我踩过的坑和对应处理
6.1 页面切走了监听还在跑
现象是:切到别的页面再切回来,指针转得比以前更灵敏,甚至开始一顿一顿地跳。
原因前面提过,onCompassChange是累加注册的。你在onShow里注册一次,切回来又注册一次,就有两个回调在同时更新同一个data,界面的更新频率翻倍。
修法是这样:
onShow() { wx.startCompass() wx.offCompassChange(this.compassHandler) // 先反注册,防止重复 wx.onCompassChange(this.compassHandler) }, onHide() { wx.stopCompass() wx.offCompassChange(this.compassHandler) }, onUnload() { wx.stopCompass() wx.offCompassChange(this.compassHandler) }关键是把回调函数存成实例属性(上面代码里的this.compassHandler),这样off的时候能精确匹配到同一个函数引用。如果你写成wx.onCompassChange(function(){...})的匿名函数,就永远反注册不掉了,这是很多人卡住的地方。
6.2 setData 频率过高导致的卡顿
这个坑的表现很有意思:在性能好的手机上一点问题都没有,换到中低端机就开始掉帧,表盘转起来一顿一顿的,甚至整个页面滚动都变卡。
原因就是setData调用太频繁。修法在 4.3 节讲了,加时间戳节流。我这里补充一个排查手法:在setData前面加一行计数,看一秒钟到底调了多少次。
let count = 0 setInterval(() => { console.log('每秒 setData 次数:', count) count = 0 }, 1000)如果你看到数字是六七十甚至上百,那卡顿的原因就没跑了。加节流之后应该降到 10 次左右。
顺带说一个容易忽略的点:setData只传变化的那一个字段,别顺手把整个data对象传进去。这个习惯在字段少的时候看不出差别,字段一多就是灾难。
6.3 部分机型拿不到 direction
少数机型上,回调里res.direction可能是undefined或者一直是 0。这个我遇到过两次,一次是老旧机型传感器缺失,一次是系统层面的权限/开关问题。
处理方式很简单,但要写:
wx.onCompassChange(function (res) { if (typeof res.direction !== 'number' || isNaN(res.direction)) { this.setData({ sensorReady: false }) return } this.setData({ sensorReady: true, rotateDeg: -res.direction }) })然后在界面上根据sensorReady显示占位提示,比如"当前设备暂不支持方向感应"。这比让用户对着一个不动的指针发呆要好得多。
还有一个边界情况是direction恰好等于 0。这时候它是合法的(正北),不要用!res.direction去做判断,那会把 0 当成 falsy 值处理掉。这个坑我见过不止一个人踩。
6.4 角度文字和指针不同步
我在表盘中心加了一个显示当前度数的文字,结果发现文字更新和表盘旋转总是差那么一点点。原因是我在同一个setData里更新了两个字段,但表盘有 CSS transition 而文字没有,所以视觉上表盘在"追",文字已经跳到位了。
修法有两种。一种是给数字也加过渡,但这个方案很别扭,数字变化用插值看着很怪。另一种是文字不做平滑,直接显示原始角度值的整数,并且接受它和表盘过渡之间的轻微不同步——实际上用户根本注意不到。我选了后者,因为少一层处理就少一个 bug 来源。
如果你实在在意,可以做一件事:文字显示的是滤波后的角度,而不是原始角度。这样文字和表盘至少是基于同一个数值的,误差只剩 transition 那一点点。
6.5 深色模式下刻度线看不见
小程序会跟随系统深色模式。如果你的表盘本来是深底浅字,切到深色模式时某些默认颜色会被系统改掉,刻度可能就糊成一片了。
我这个项目是自绘的深色表盘,颜色全是硬编码的,所以没受影响。但如果你的方案里用了小程序默认色值,记得在app.json里显式关闭或配置深色模式适配:
{ "darkmode": false }或者老老实实为深色模式写一套变量。入门项目推荐前者,别给自己加戏。
7. 把指南针做成一个能拿得出手的小工具
跑通之后别急着停,加几个小功能,这个项目才算完整,也才真正能写进你的作品集。
7.1 方位文字与度数显示
把 0 到 360 映射成"北、东北、东、东南、南、西南、西、西北"这八个方位,是最简单也最实用的一步。
const DIRECTIONS = ['北', '东北', '东', '东南', '南', '西南', '西', '西北'] function getDirectionText(deg) { // 每个方位占 45 度,+22.5 是为了让分界落在两个方位的中间 const index = Math.round(((deg % 360) + 360) % 360 / 45) % 8 return DIRECTIONS[index] }+22.5这个偏移量的思路是:0 度是正北,但正北应该是一个区间(-22.5 到 22.5)而不是一个点。用Math.round配合 45 度的间隔,正好实现这个区间划分。不加偏移的话,359 度会被判成"北",358 度就被判成"西北"了,边界会很怪。
7.2 目标朝向与相对角度
这个功能是我觉得最有意思的一步:记录一个目标方向,然后显示你当前相对目标的偏角。
做法很简单:点击按钮时把当前direction存下来作为目标角度,之后每次更新算一下差值:
function getRelative(target, current) { let diff = target - current if (diff > 180) diff -= 360 if (diff < -180) diff += 360 return diff }差值是正的就说明目标在你的左边,负的在右边。配上"向左转 35 度"这样的提示文字,一个小型的定向工具就有雏形了。用的还是第 4 节那个归一化的思路,同一个算法在项目里出现了两次,说明它确实是这类问题的通用解法。
7.3 后续可以往哪长
如果你想继续在这个项目上练手,几条现成的路:
- 加水平仪:用
wx.onDeviceMotionChange拿 beta 和 gamma,画一个气泡水平仪。同一个页面上放指南针和水平仪,是很典型的户外工具界面。 - 加位置信息:接
wx.getLocation拿经纬度,显示当前坐标。这一步会引入授权流程,正好补齐第 1 节提到的那块短板。 - 做成方向记录器:把几个关键方向存进
wx.setStorageSync,下次打开还在。练本地存储和数据结构设计。 - 优化渲染:把表盘换成 Canvas 实现,对比一下两种方案的帧率。这是从"能跑"到"跑得好"的一步。
我个人在这个项目上的体会是,它的价值不在于指南针本身——现在手机上系统自带的指南针比你能做出来的好用得多。它的价值在于用最小的代码量把小程序的原生能力调用链路完整走了一遍。传感器数据进来、滤波处理、驱动视图、生命周期收尾,这四步你在做蓝牙、做相机、做定位的时候,流程是一模一样的。把这个项目吃透,后面那些就不是从零开始了。
最后再分享一个小细节:判断指南针准不准,不用盯着数字看。把手机平放在桌上,读数应该是稳定的;然后你慢慢转一圈,读数应该单调增加或减少。如果转到某个方向突然跳一下又跳回来,那附近肯定有磁干扰,换个位置再试。这个土办法比看accuracy数值直观多了。