一、项目介绍
在许多户外类、导航类或增强现实类应用中,实时的方位感知是核心功能之一。Android 设备内置了多种传感器,可以通过组合加速度计(Accelerometer)与地磁传感器(Magnetometer)来实现一个稳定的数字“指南针”控件,动态指示手机当前朝向的地理北方。
本项目旨在:
-
基于 Android 原生传感器 API,实现一个实时、平滑、可自定义样式的指南针控件
-
演示 传感器数据融合(加速度 + 磁场)计算手机姿态
-
演示 动画旋转、插值平滑、布局自定义 等核心技巧
-
支持 屏幕旋转、后台与前台注册/注销 的完整生命周期管理
最终效果示例:
-
一张指北针刻度盘背景
-
一根指向北方的刻度针(ImageView),根据设备朝向实时旋转
二、相关技术与知识
-
SensorManager 与 SensorEventListener
-
SensorManager#getDefaultSensor(int type)
获取系统传感器 -
实现
SensorEventListener
,在onSensorChanged()
中获取传感器数据
-
-
加速度计(TYPE_ACCELEROMETER)与地磁场传感器(TYPE_MAGNETIC_FIELD)
-
加速度计输出设备的三轴加速度
-
磁场传感器输出设备周围的三轴地磁场强度
-
-
传感器融合 & 方向计算
-
使用
SensorManager.getRotationMatrix()
根据加速度与磁场数据计算旋转矩阵 -
再调用
SensorManager.getOrientation()
得到方位角(azimuth)
-
-
View 动画与插值
-
使用
ObjectAnimator
或手动在onDraw()
中旋转Canvas
-
使用
LinearInterpolator
或自定义插值器实现平滑旋转
-
-
Android 布局内嵌
-
将 XML 布局写在 Java 文件中,以注释区分,便于一份源码即览全部文件
-
-
生命周期管理
-
在
onResume()
注册传感器监听器,在onPause()
注销,避免后台持续耗电
-
三、实现思路
-
布局准备
-
背景图:一个圆形指南针刻度盘(可自定义 Drawable)
-
前景针:一个竖直向上的指针(PNG/Vector),使用
ImageView
显示
-
-
传感器监听
-
同时注册加速度计与磁场传感器
-
缓存最近读取的两个传感器数组,调用
getRotationMatrix()
与getOrientation()
得到实时 azimuth
-
-
动画旋转
-
在每次方位更新时,通过差值计算当前针与目标角度的最短旋转路径
-
使用
ObjectAnimator
或直接ImageView.setRotation()
并结合Interpolator
实现平滑过度
-
-
生命周期管理
-
在
onResume()
中注册监听,在onPause()
中注销,避免后台耗电与内存泄漏
-
-
屏幕旋转适配
-
监听布局的
onConfigurationChanged()
(或在清单中声明不重建 Activity),保证方向持续更新
-
四、完整代码
// ==============================================
// 文件:CompassActivity.java
// 描述:实现 Android 指南针功能的完整示例
// ==============================================
package com.example.compassapp;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.content.Context;
import android.content.res.Configuration;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.os.Bundle;
import android.view.animation.LinearInterpolator;
import android.widget.ImageView;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
/*
=========================
以下是本示例的布局文件(activity_compass.xml)
整合在此处,用注释分隔,实际开发请将其提取到 res/layout/ 下
=========================
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#FFFFFF">
<!-- 指南针背景:圆形刻度盘 -->
<ImageView
android:id="@+id/ivCompassBg"
android:layout_width="300dp"
android:layout_height="300dp"
android:src="@drawable/compass_dial"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
<!-- 指南针指针:始终指向北方 -->
<ImageView
android:id="@+id/ivCompassArrow"
android:layout_width="32dp"
android:layout_height="150dp"
android:src="@drawable/compass_arrow"
app:layout_constraintTop_toTopOf="@id/ivCompassBg"
app:layout_constraintBottom_toBottomOf="@id/ivCompassBg"
app:layout_constraintStart_toStartOf="@id/ivCompassBg"
app:layout_constraintEnd_toEndOf="@id/ivCompassBg"
android:rotation="0"/>
</androidx.constraintlayout.widget.ConstraintLayout>
=========================
布局结束
=========================
*/
public class CompassActivity extends AppCompatActivity implements SensorEventListener {
private SensorManager sensorManager;
private Sensor accelSensor;
private Sensor magnetSensor;
// 存储传感器原始数据
private float[] accelValues = new float[3];
private float[] magnetValues = new float[3];
// 指南针指针控件
private ImageView ivArrow;
// 上一次的方位角,用于平滑旋转
private float currentAzimuth = 0f;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 加载布局(如上所示,此处假设已在 res/layout/activity_compass.xml)
setContentView(R.layout.activity_compass);
// 初始化传感器
sensorManager = (SensorManager) getSystemService(Context.SENSOR_SERVICE);
if (sensorManager != null) {
accelSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
magnetSensor = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD);
}
// 初始化指针 ImageView
ivArrow = findViewById(R.id.ivCompassArrow);
}
@Override
protected void onResume() {
super.onResume();
// 注册传感器监听器:加速度 + 磁场
if (accelSensor != null)
sensorManager.registerListener(this, accelSensor, SensorManager.SENSOR_DELAY_UI);
if (magnetSensor != null)
sensorManager.registerListener(this, magnetSensor, SensorManager.SENSOR_DELAY_UI);
}
@Override
protected void onPause() {
super.onPause();
// 注销监听,节省电量
sensorManager.unregisterListener(this);
}
@Override
public void onSensorChanged(SensorEvent event) {
// 根据传感器类型缓存数据
if (event.sensor.getType() == Sensor.TYPE_ACCELEROMETER) {
System.arraycopy(event.values, 0, accelValues, 0, event.values.length);
} else if (event.sensor.getType() == Sensor.TYPE_MAGNETIC_FIELD) {
System.arraycopy(event.values, 0, magnetValues, 0, event.values.length);
}
// 只有同时获取到两者数据,才能计算方向
float[] R = new float[9];
float[] I = new float[9];
boolean success = SensorManager.getRotationMatrix(R, I, accelValues, magnetValues);
if (success) {
// orientation[0] = azimuth(方位角),单位 rad
float[] orientation = new float[3];
SensorManager.getOrientation(R, orientation);
float azimuthInRadians = orientation[0];
// 转为度,并规范到 0–360
float azimuthInDeg = (float) Math.toDegrees(azimuthInRadians);
float normalizedAzimuth = (azimuthInDeg + 360) % 360;
// 平滑旋转到目标角度
rotateArrow(currentAzimuth, -normalizedAzimuth);
currentAzimuth = -normalizedAzimuth;
}
}
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
// 精度变化不处理
}
/**
* 旋转箭头动画
*
* @param fromDegree 起始角度
* @param toDegree 目标角度
*/
private void rotateArrow(float fromDegree, float toDegree) {
ObjectAnimator animator = ObjectAnimator.ofFloat(ivArrow, "rotation", fromDegree, toDegree);
animator.setDuration(250); // 动画时长 250ms
animator.setInterpolator(new LinearInterpolator());
animator.start();
}
@Override
public void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
// 若声明不重建 Activity,可在此处理屏幕旋转时的布局适配
}
}
说明:
上述 Java 文件中,通过大段注释将 XML 布局嵌入,实际发布时可将其复制到
res/layout/activity_compass.xml
,并将setContentView(R.layout.activity_compass)
保持不变。
@drawable/compass_dial
和@drawable/compass_arrow
分别是刻度盘和指针图片,建议使用 300×300 的 PNG 和 32×150 的透明背景 PNG,或 VectorDrawable 自定义。
五、方法解读
-
onCreate(...)
-
获取
SensorManager
,并通过getDefaultSensor(...)
拿到加速度计和磁场传感器。 -
setContentView(...)
后,通过findViewById
拿到用于旋转的ImageView ivArrow
。
-
-
onResume() / onPause()
-
分别在前台/后台阶段注册或注销传感器监听器,防止不必要的电量消耗。
-
-
onSensorChanged(SensorEvent event)
-
根据
event.sensor.getType()
判断是加速度还是磁场数据,将其复制到accelValues[]
或magnetValues[]
。 -
当两组数据均已获取时,调用
SensorManager.getRotationMatrix(R, I, accelValues, magnetValues)
生成旋转矩阵; -
再通过
SensorManager.getOrientation(R, orientation)
计算方位角(弧度),转换到度数并归一化到 [0,360); -
调用
rotateArrow()
在当前箭头角度与目标角度之间执行平滑过度。
-
-
rotateArrow(float fromDegree, float toDegree)
-
使用
ObjectAnimator.ofFloat(view, "rotation", from, to)
对ivArrow
的rotation
属性执行动画; -
设置时长 250ms、线性插值器,保证指针快速且平滑地指向新方向。
-
-
onConfigurationChanged(Configuration newConfig)
-
如在清单中声明
android:configChanges="orientation|screenSize"
可避免 Activity 重建,此处可在旋转时微调布局位置。
-
六、项目总结
-
功能实现
-
成功基于原生传感器 API,实现了一个实时、平滑的指南针控件;
-
通过加速度与磁场传感器融合,计算手机朝向,驱动指针旋转。
-
-
性能与体验
-
选用
SENSOR_DELAY_UI
采样率,兼顾实时性与电量消耗; -
使用
ObjectAnimator
做动画,兼容性强且易于控制; -
建议在实机上校准传感器(通过手机系统的“校准指南针”功能),以获得更精准的方位。
-
-
可扩展方向
-
添加平滑滤波:对 azimuth 值再做一层低通滤波(如指数滑动平均),减少抖动;
-
自定义样式:支持 XML 自定义属性,动态设置刻度盘及指针颜色、尺寸;
-
误差提示:当传感器精度低时,在 UI 上提示用户摇晃手机重新校准;
-
AR 叠加:在相机预览上叠加指南针,实现增强现实导航功能;
-
Jetpack Compose 版本:使用
Canvas
直接绘制刻度盘与指针,减少资源依赖。
-
-
注意事项
-
传感器在金属或强磁场环境下会产生误差,应在应用中告知用户重校准;
-
长时间注册传感器监听会持续耗电,应在不用时及时注销;
-
不建议在高频率(如
SENSOR_DELAY_GAME
)下做复杂计算,以免主线程卡顿。
-