Android跌倒检测Demo:加速度计与三阶段状态机端侧识别

发布时间:2026/10/26 15:04:26
Android跌倒检测Demo:加速度计与三阶段状态机端侧识别

简介:面向Android开发者、计算机视觉学习者及课程设计人员的跌倒检测识别演示包,重点展示移动端实时跌倒/摔倒识别的基本落地方式,可用于项目原型验证、课堂演示与功能体验。压缩包共2个文件,包含可安装的apk与发布构建元数据json,整体约50.44MB,体积轻量,便于直接部署到设备查看运行效果。已有1946人学习或下载,具备一定参考热度。借助APK可直观感受跌倒检测与识别在Android端的交互流程,json则有助于核对发布版本及包体信息;对于想了解移动端模型集成、相机画面处理与识别结果展示的读者,可作为入门级实践样例和功能拆解参考。

1. 跌倒检测识别 Android Demo:手机端把“摔倒”这件事判出来

一个挺常见的场景:老人夜里起夜滑了一跤,手机就在口袋里,但它只记得住步数,不会报警。这个 Android 跌倒检测识别 Demo 要解决的就是这一步——用手机自带的加速度计,在本地实时判断跌倒识别需要的几个信号:失重、冲击、静止,命中之后触发告警。整个工程不依赖云端,判定逻辑全部跑在端上,适合想给健康类 App 补一块摔倒识别能力、或者拿它当传感器采集与实时判定练手模板的开发者。它更像一份把 android 跌倒检测链路完整走通的工程骨架,而不是开箱即用的医疗级产品——阈值、窗口长度、告警冷却都留了口子,方便你按机型和人群再调。

2. 工程定位与技术选型:加速度采集、滑动窗口、三阶段判定的完整链路

先把“这个 Demo 到底在手机上做了什么”讲清楚,再去看代码会顺很多。很多人第一次拆这类跌倒识别工程,容易卡在一个误解上:以为里面藏了个模型。实际上大多数轻量 Demo 走的是纯阈值加状态机的路子,跌倒识别的准确率够用、功耗低、无需训练数据,改起来也直观。

2.1 一条从传感器到告警的数据链路

这条链路可以拆成四段,每段职责单一:

第一段是采集。通过SensorManager注册TYPE_ACCELEROMETER,拿到三轴加速度,单位是 m/s²,不是 g。陀螺仪是可选增强,用来辅助判断姿态翻转,没有也能跑,只是姿态判定会退化。

第二段是缓冲。传感器回调是逐点到达的,单点判断没有任何意义——一次跌倒的物理过程大概持续 1 到 2 秒,必须把这段时间的数据攒成一个窗口再分析。所以工程里会维护一个固定长度 64 点的滑动窗口,每来一个新样本挤掉最老的一个,形成一个“最近 1.3 秒的切片”。

第三段是特征。窗口进来之后算合加速度 SVM(Signal Vector Magnitude),也就是三轴平方和开根号再除以重力加速度,得到一条以 g 为单位的曲线。这条曲线上有三个关键形态:跌下去那一瞬间的失重谷、触地那一瞬间的冲击峰、以及之后人不动了的平稳段。

第四段是判定。用一个三态状态机把“失重 → 冲击 → 静止”这三个事件串起来,只有顺序正确、时间间隔合理,才认为是一次真跌倒。中间任何一环断了就复位。

为什么把计算放在端上?三个理由。一是隐私,加速度数据能反推出相当多的行为信息,没必要往外传;二是时延,跌倒告警这种场景,端到端多 200 毫秒都嫌多;三是可用性,老人家里 Wi-Fi 断掉是常态,云端方案直接失效。

2.2 技术栈与目录结构

技术选型上,这个跌倒识别 Demo 保持了极简:Java 写业务,Android SDK 自带的SensorManager做采集,没有引入任何第三方传感器库或机器学习框架。前台 Service 负责常驻,Notification负责告警展示。这套组合的好处是依赖树干净,Gradle 同步快,出问题也容易定位。

目录结构大致是这样:

FallDetectionDemo/ ├── app/ │ ├── src/main/ │ │ ├── java/com/example/falldetection/ │ │ │ ├── MainActivity.java // 权限申请、监测开关、模拟跌倒入口 │ │ │ ├── sensor/SensorCollector.java // 注册传感器 + 维护滑动窗口 │ │ │ ├── detect/FeatureExtractor.java // 合加速度极值、均值、方差、姿态变化 │ │ │ ├── detect/FallDetector.java // 失重-冲击-静止三态状态机 │ │ │ ├── service/FallMonitorService.java// 前台服务,保证后台不被回收 │ │ │ └── ui/AlertActivity.java // 告警弹窗与倒计时取消 │ │ └── AndroidManifest.xml // 传感器无需声明权限,前台服务需要 │ └── build.gradle └── settings.gradle

注意AndroidManifest.xml那一行——加速度计本身不需要任何权限声明,这是很多人会绕的弯路,跑去申请BODY_SENSORS或者ACTIVITY_RECOGNITION,其实跟单纯的加速度采集没关系。需要声明的只有前台服务那一套。

2.3 关键参数:改哪一个会带来什么后果

这套阈值方案的成败几乎全在参数上。下面这几个值是我调过之后觉得比较稳的起点,你可以当基线用:

参数默认值单位调大或调小的后果
采样周期SENSOR_DELAY_GAME(约 20ms)ms调稀会漏掉冲击峰;调密则功耗上升明显
窗口长度64 个采样点点变长覆盖更完整但告警延迟上升;变短容易只看到冲击看不到静止
失重阈值0.5g调高更容易进入失重态,误报随之增加
冲击阈值2.5g调低更灵敏,甩手机、拍桌子都可能触发
静止区间0.7 ~ 1.3g区间收窄判定更严,躺沙发这类场景容易漏
静止持续2000ms调短易误报,调长易漏报
告警冷却5000ms防止同一次跌倒连弹多个告警

冲击阈值 2.5g 这个数是经验值。日常走路合加速度峰值大概在 1.3 到 1.8g,跑步能摸到 2.2g 左右,急停急转偶尔会冒到 2.6g。把阈值卡在 2.5g 意味着跑步基本上不会触发,但快速蹲下起身偶尔会擦边——这就是为什么单靠冲击峰值判断不够,必须配合失重和静止两段。

3. 核心模块拆解:从 onSensorChanged 到一次跌倒告警的代码细节

这一章把四个核心类逐个拆开。每个类都不长,但里面的边界条件值得逐行看。

3.1 传感器采集与滑动窗口

采集类的职责就是两件事:注册监听、维护窗口。窗口用ArrayDeque实现,满了就从头挤掉,天然就是滑动语义。

public class SensorCollector implements SensorEventListener { // SENSOR_DELAY_GAME 名义 20ms,多数机型实测在 50Hz 上下浮动 private static final int SAMPLING_PERIOD_US = SensorManager.SENSOR_DELAY_GAME; // 64 点 ≈ 1.28 秒,刚好覆盖一次跌倒从失重到静止的完整过程 private static final int WINDOW_SIZE = 64; // 每 10 个样本输出一次窗口,把判定频率压到约 5Hz,省电 private static final int EMIT_EVERY_N = 10; private final SensorManager sensorManager; private final Sensor accelerometer; private final Deque<float[]> window = new ArrayDeque<>(WINDOW_SIZE); private final OnWindowReadyListener listener; private int counter = 0; public interface OnWindowReadyListener { void onWindow(float[][] samples, long timestampMs); } public SensorCollector(Context ctx, OnWindowReadyListener listener) { this.listener = listener; this.sensorManager = (SensorManager) ctx.getSystemService(Context.SENSOR_SERVICE); this.accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER); } public boolean start() { // 部分低端机或模拟器没有加速度计,直接返回 false 让上层提示 if (accelerometer == null) return false; sensorManager.registerListener(this, accelerometer, SAMPLING_PERIOD_US); return true; } public void stop() { sensorManager.unregisterListener(this); } @Override public void onSensorChanged(SensorEvent event) { if (event.sensor.getType() != Sensor.TYPE_ACCELEROMETER) return; // values 单位是 m/s²,不是 g,后续所有阈值判断都要按这个换算 float[] sample = {event.values[0], event.values[1], event.values[2]}; synchronized (window) { if (window.size() == WINDOW_SIZE) { window.pollFirst(); // 挤掉最老的点,保持长度固定 } window.addLast(sample); // 窗口刚满且到达输出节奏时,把当前窗口交给判定层 if (window.size() == WINDOW_SIZE && (++counter % EMIT_EVERY_N == 0)) { listener.onWindow(window.toArray(new float[0][]), event.timestamp / 1_000_000L); } } } @Override public void onAccuracyChanged(Sensor sensor, int accuracy) { // 加速度计精度变化对阈值方案影响很小,这里留空即可 } }

EMIT_EVERY_N这个降频很关键。如果每个样本都回调,等于每秒 50 次特征计算加状态机判断,手机待机功耗会明显上去。压到 5Hz 之后,一次跌倒的判定延迟最多多 200ms,对告警这个场景完全可以接受。

event.timestamp用的是纳秒,除以1_000_000转成毫秒。这里必须用传感器自己的时间戳,不能用System.currentTimeMillis()——后者会被系统时间调整影响,做静止时长判断时会出玄学问题。

3.2 特征提取:合加速度与姿态变化

特征层把一整个窗口压缩成几个数字,后面状态机只认这几个数字。这样做的另一个好处是,将来要换模型,特征层几乎不用动。

public class FeatureExtractor { public static final float G = SensorManager.GRAVITY_EARTH; // 9.80665 public static class Feature { public float svmMax; // 窗口内合加速度最大值,单位 g public float svmMin; // 最小值 public float svmMean; // 均值 public float svmStd; // 标准差,衡量窗口内的起伏剧烈程度 public float tiltDelta; // 窗口首尾重力方向夹角,单位度 } public static Feature extract(float[][] window) { Feature f = new Feature(); int n = window.length; float max = -Float.MAX_VALUE, min = Float.MAX_VALUE; float sum = 0, sumSq = 0; for (float[] s : window) { // 合加速度:三轴平方和开根号,再除以重力加速度换成 g float svm = (float) Math.sqrt(s[0] * s[0] + s[1] * s[1] + s[2] * s[2]) / G; max = Math.max(max, svm); min = Math.min(min, svm); sum += svm; sumSq += svm * svm; } f.svmMax = max; f.svmMin = min; f.svmMean = sum / n; float var = Math.max(0f, sumSq / n - f.svmMean * f.svmMean); f.svmStd = (float) Math.sqrt(var); f.tiltDelta = angleBetween( averageOf(window, 0, n / 4), // 窗口前 1/4 的平均方向 ≈ 跌倒前重力方向 averageOf(window, n * 3 / 4, n)); // 后 1/4 ≈ 跌倒后重力方向 return f; } private static float[] averageOf(float[][] w, int from, int to) { float[] avg = new float[3]; for (int i = from; i < to; i++) { avg[0] += w[i][0]; avg[1] += w[i][1]; avg[2] += w[i][2]; } int count = Math.max(1, to - from); avg[0] /= count; avg[1] /= count; avg[2] /= count; return avg; } private static float angleBetween(float[] a, float[] b) { float dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; float na = (float) Math.sqrt(a[0] * a[0] + a[1] * a[1] + a[2] * a[2]); float nb = (float) Math.sqrt(b[0] * b[0] + b[1] * b[1] + b[2] * b[2]); if (na < 1e-3f || nb < 1e-3f) return 0f; float cos = Math.max(-1f, Math.min(1f, dot / (na * nb))); return (float) Math.toDegrees(Math.acos(cos)); } }

tiltDelta这个设计值得多说一句。用“窗口前后两段重力方向的夹角”来衡量姿态变化,比直接取某一轴的倾角稳得多——因为手机在口袋里怎么放是完全随机的。人从站立到倒地,重力方向相对手机坐标系会转过一个大角度,通常超过 60 度;而日常走动时这个值一般不会超过 25 度。实测下来,姿态变化这个特征对降低误报的作用比调冲击阈值还明显。

3.3 三态状态机:失重、冲击、静止的顺序判断

这是整个跌倒识别工程里最容易写错的地方。很多人的第一版是“冲击超过 2.5g 就报警”,结果一天能响二十次。正确的做法是把三个物理事件按时间顺序串起来。

public class FallDetector { // 阈值全部以 g 为单位 private static final float FREE_FALL_G = 0.5f; private static final float IMPACT_G = 2.5f; private static final float STILL_LOW_G = 0.7f; private static final float STILL_HIGH_G = 1.3f; private static final float TILT_DEG = 40f; private static final long STILL_MS = 2000L; private static final long IMPACT_TIMEOUT_MS = 4000L; private static final long COOLDOWN_MS = 5000L; private static final int IDLE = 0; private static final int FREE_FALL = 1; private static final int IMPACT = 2; private int stage = IDLE; private long impactTs = 0; private long lastAlertTs = 0; /** 返回 true 表示这次窗口判定为一次跌倒,需要上层告警 */ public boolean onFeature(FeatureExtractor.Feature f, long ts) { switch (stage) { case IDLE: // 第一段:失重。合加速度掉到 0.5g 以下,说明人正在下坠 if (f.svmMin < FREE_FALL_G) { stage = FREE_FALL; } return false; case FREE_FALL: // 第二段:冲击。失重之后出现大峰值,且姿态明显翻转 if (f.svmMax > IMPACT_G && f.tiltDelta > TILT_DEG) { stage = IMPACT; impactTs = ts; } else if (f.svmMin > FREE_FALL_G) { // 只有失重没有冲击,比如坐电梯,复位 stage = IDLE; } return false; case IMPACT: // 第三段:静止。冲击后合加速度回到 1g 附近,并且持续够久 boolean still = f.svmMax < STILL_HIGH_G && f.svmMin > STILL_LOW_G; if (still && ts - impactTs >= STILL_MS) { stage = IDLE; if (ts - lastAlertTs >= COOLDOWN_MS) { lastAlertTs = ts; return true; } } else if (ts - impactTs > IMPACT_TIMEOUT_MS) { // 冲击之后一直没静止,多半是剧烈运动,复位 stage = IDLE; } return false; default: stage = IDLE; return false; } } }

三个状态之间的转移条件,每一个都对应一个物理直觉。IDLE → FREE_FALL用svmMin是因为失重表现为一个谷;FREE_FALL → IMPACT用svmMax是因为冲击表现为一个峰;IMPACT → IDLE用svmMax和svmMin同时收窄,是因为静止意味着窗口内几乎没有起伏。

IMPACT_TIMEOUT_MS这个兜底非常重要。没有它的话,一个人在公交车上被颠了一下进去 IMPACT 态,如果接下来半小时都站得很稳,状态机会一直挂在那儿;一旦出现任何波动就可能误报。

3.4 前台服务与告警:让监测真正活下来

从 Android 8 开始,后台应用拿不到持续传感器回调;不做前台服务,切到后台监测基本就废了。

public class FallMonitorService extends Service { private static final String CHANNEL_ID = "fall_monitor"; private static final int NOTIFY_ID = 1001; private SensorCollector collector; private final FallDetector detector = new FallDetector(); @Override public int onStartCommand(Intent intent, int flags, int startId) { startForeground(NOTIFY_ID, buildNotification()); collector = new SensorCollector(this, (samples, ts) -> { FeatureExtractor.Feature f = FeatureExtractor.extract(samples); if (detector.onFeature(f, ts)) { onFallDetected(f, ts); } }); if (!collector.start()) { stopSelf(); // 设备没有加速度计,直接退出 } return START_STICKY; // 被系统回收后尽量拉起 } private Notification buildNotification() { NotificationManager nm = getSystemService(NotificationManager.class); // IMPORTANCE_LOW:常驻通知不出声不弹窗,避免打扰 NotificationChannel ch = new NotificationChannel( CHANNEL_ID, "跌倒监测", NotificationManager.IMPORTANCE_LOW); nm.createNotificationChannel(ch); return new Notification.Builder(this, CHANNEL_ID) .setContentTitle("跌倒监测运行中") .setSmallIcon(android.R.drawable.ic_menu_compass) .setOngoing(true) .build(); } private void onFallDetected(FeatureExtractor.Feature f, long ts) { // 这里换成你自己的动作:弹全屏 Activity、播报警音、发短信、上报服务端 Intent i = new Intent(this, AlertActivity.class) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); startActivity(i); } @Override public void onDestroy() { if (collector != null) collector.stop(); super.onDestroy(); } @Override public IBinder onBind(Intent intent) { return null; } }

onStartCommand里先startForeground再注册传感器,顺序不能反。反过来的话,在注册到startForeground之间那一小段时间里,进程优先级还是普通后台,理论上存在被回收的窗口。虽然实际很难撞上,但这类血泪经验没必要自己去踩。

4. 编译与真机验证:让 Demo 在手机上第一次报出跌倒

代码看完,接下来把它跑起来。跌倒检测这类工程有个特点:模拟器完全没法验证,因为模拟器不会产生真实的传感器数据流。所以从第一步就得准备真机。

4.1 环境准备与依赖检查

需要一个装好 Android Studio 的开发机,JDK 用 IDE 自带的就行。工程的build.gradle里保持最简依赖,除 AndroidX 基础库之外不需要任何东西:

android { namespace "com.example.falldetection" compileSdk 34 defaultConfig { applicationId "com.example.falldetection" minSdk 21 targetSdk 34 versionCode 1 versionName "1.0" } compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } } dependencies { implementation "androidx.appcompat:appcompat:1.6.1" implementation "androidx.core:core:1.12.0" }

minSdk 21是保守起见,传感器 API 在更低的版本上也能用,但通知渠道那一套从 26 才有,低版本需要做分支,这里不展开。targetSdk建议跟着当前主流版本走,不然前台服务的类型声明会报错。

AndroidManifest.xml里需要声明三样东西:

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <service android:name=".service.FallMonitorService" android:exported="false" android:foregroundServiceType="health" />

FOREGROUND_SERVICE_HEALTH和foregroundServiceType="health"是针对较新系统版本的前台服务类型要求,少了会直接抛异常起不来。POST_NOTIFICATIONS从 Android 13 起是运行时权限,代码里还得再请求一次。

4.2 编译、安装与第一次运行

命令行编译的方式最直接:

# 编译 debug 包 ./gradlew assembleDebug # 装到已连接的设备上 adb install -r app/build/outputs/apk/debug/app-debug.apk

装好之后打开应用,MainActivity里应该有一个“开始监测”的开关。点开之后先看通知栏有没有出现常驻通知——有,说明前台服务起来了;没有,那就是权限或者服务类型声明的问题,先去 Logcat 里搜ForegroundServiceStartNotAllowedException。

接着验证传感器是否真的在出数据。在SensorCollector.onSensorChanged里临时加一行日志:

// 调试期打开,确认回调频率和数据单位 android.util.Log.d("Sensor", "x=" + event.values[0] + " y=" + event.values[1] + " z=" + event.values[2]);

手机平放在桌上时,三个值里应该有一个接近 9.8,另外两个接近 0。如果三个都是 0,检查是不是跑在模拟器上;如果频率明显低于 40Hz,换个机型再试,有些系统对传感器做了节流。

4.3 用模拟数据验证判定链路

真拿手机摔一次当然不现实,验证判定逻辑最快的办法是直接构造特征喂给状态机。在MainActivity里加一个调试按钮,或者写成单元测试:

private void simulateFall() { FallDetector detector = new FallDetector(); long t0 = System.currentTimeMillis(); // 窗口 1:失重段,最小值 0.32g,还没冲击,姿态基本没变 FeatureExtractor.Feature a = new FeatureExtractor.Feature(); a.svmMin = 0.32f; a.svmMax = 1.05f; a.tiltDelta = 6f; // 窗口 2:冲击段,峰值 3.4g,姿态翻转 78 度 FeatureExtractor.Feature b = new FeatureExtractor.Feature(); b.svmMin = 1.02f; b.svmMax = 3.40f; b.tiltDelta = 78f; // 窗口 3:静止段,起伏收敛到 1g 附近,时间推到冲击后 2.1 秒 FeatureExtractor.Feature c = new FeatureExtractor.Feature(); c.svmMin = 0.94f; c.svmMax = 1.06f; c.tiltDelta = 2f; boolean r1 = detector.onFeature(a, t0); boolean r2 = detector.onFeature(b, t0 + 200); boolean r3 = detector.onFeature(c, t0 + 2300); android.util.Log.d("FallTest", "结果: " + r1 + " / " + r2 + " / " + r3); // 预期输出 false / false / true,只有最后一个窗口返回 true }

这段代码的价值在于,它把“判定逻辑对不对”和“传感器数据真不真”两件事拆开了。如果模拟数据能出 true 而真机摔不出来,问题肯定在采集或者预处理;如果模拟数据都出不来 true,那就是状态机本身的 bug。排查时能省掉一大半时间。

再补一组反例:只给失重和冲击、不给静止(模拟坐电梯或跳一下),预期应该返回全 false。以及连给两次完整的跌倒序列,第二次应该被冷却时间挡掉。

4.4 真机采样时的几个现实变量

真机测试的时候你会发现,同一份阈值在不同人身上效果差很多。身形偏瘦的人冲击峰值普遍偏高,偏胖的人失重段持续时间更长;把手机放裤兜和放在上衣口袋,姿态变化的幅度也不一样。

我自己的做法是找三到五个不同体型的人,每人做五次受控的“坐到软垫上”动作,把每次的svmMax、svmMin、tiltDelta打出来存成表格。看这批数分布在哪儿,再回头定阈值。硬套别人调好的参数,翻车概率很高。

5. 避坑与排查:跌倒检测 Demo 最容易翻车的五个地方

这一节挑五个我自己踩过或者见别人踩过的坑。每个都按“现象 → 原因 → 解决”写,遇到对应情况可以直接对号入座。

5.1 判定逻辑侧的三类问题

坑一:阈值怎么调都不触发。

现象很明显,日志里能看到传感器数据一直在进来,svmMax也能打到 3 以上,但FallDetector从来返回 false。

原因基本只有一个:单位没换算。SensorEvent.values返回的是 m/s²,静止放桌上时 z 轴是 9.8 左右,不是 1。如果你的阈值按 g 写(比如失重阈值 0.5),但直接拿原始值去比,那svmMin永远大于 0.5,状态机连第一个状态都进不去。

解决方法是统一单位。要么在特征层就除以SensorManager.GRAVITY_EARTH,让后续全部按 g 算;要么所有阈值都乘 9.8。推荐前者,因为 g 这个量纲跟物理直觉对得上,调阈值时不容易犯迷糊。改完顺手检查一下svmMax在正常走动时是不是落在 1.2 到 1.8 之间,是的话单位就对了。

坑二:一天误报几十次。

现象是手机在兜里、放桌上、被人拿起来,都会莫名其妙弹告警。

原因通常有两个。第一个是冲击阈值定得太低,2.0g 以下的话,日常快速坐下、拍一下桌子、手机掉沙发上都能过。第二个是缺少姿态判断,只有“冲击 + 静止”两段就报警,那手机从手里滑到床上完全符合这个模式。

解决分两步。先把冲击阈值往上提到 2.5g 到 3.0g 之间,用上一节说的采样方法确认日常动作的峰值上限在哪。再补上tiltDelta > 40这个条件——人是站着摔到躺着的,重力方向必然大幅翻转;而手机掉到床上,手机本身可能翻了个身,但持机人的姿态没变,这个特征能过滤掉很大一批。如果还压不住,就在告警前加一次确认:允许用户有 15 秒的取消窗口,超时没取消才算真告警。

坑三:真摔了但被判成剧烈运动。

现象跟坑二正好相反,测试时从椅子上侧身倒地,日志里能看到失重和冲击都命中了,最后却没出告警。

原因多半出在静止判断上。IMPACT_TIMEOUT_MS设成了 4 秒,但摔倒之后人往往会疼得动一下,或者下意识去够手机,导致窗口内的svmMax短暂冲到 1.5g 以上,不满足静止条件,状态机被复位。

解决是放宽静止区间,把上限从 1.3g 提到 1.6g,同时把静止要求从“连续满足”改成“累计满足”——用一个计数器记录满足条件的窗口数,达到阈值就算静止,中间允许少数几个窗口跳出区间。另外TILT_DEG这个条件可以适当降低到 35 度,因为有些摔倒姿势手机没有完全翻转。

5.2 运行环境侧的两类问题

坑四:切到后台几分钟就收不到数据了。

现象是应用在前台一切正常,锁屏或者切走之后,过一会儿再看通知还在,但日志里没有新的传感器回调了。

原因是传感器监听没跟上前台服务。从 Android 8 之后,普通后台进程拿不到持续的高频传感器数据;从 Android 12 之后,前台服务本身也有启动限制。如果你的registerListener是写在Activity里的,切走之后监听就下沉到了后台进程,被系统限流是必然的。

解决是把注册和反注册都挪到Service里,并且确保startForeground在任何耗时操作之前调用。onStartCommand返回START_STICKY,被系统回收后尽力拉起。再检查一下AndroidManifest.xml里的foregroundServiceType有没有声明——这个漏了的话,在较新的系统上会直接抛MissingForegroundServiceTypeException,服务根本起不来。

坑五:模拟器上一切正常,换真机就崩。

现象是 Android Studio 自带模拟器跑得好好的,装到真机上启动就闪退,或者监测开关点不动。

原因有两个层次。浅层是模拟器根本不产生真实的传感器事件,所以那段逻辑在模拟器上压根没执行过,第一次上真机才暴露问题。深层是有些机型确实没有陀螺仪,如果你的代码里对getDefaultSensor(TYPE_GYROSCOPE)的返回值没做判空,直接拿去registerListener,在某些设备上会抛空指针。

解决办法第一是强制真机调试,模拟器只用来验证 UI 和编译问题。第二是所有getDefaultSensor的返回值都要判空,加速度计没有就直接提示“设备不支持”,陀螺仪没有就跳过相关逻辑、让姿态判定退化成只用加速度计。第三是在onDestroy里确保unregisterListener被执行,这条漏了会导致 Activity 反复重建时累积多个监听器,同一个样本被处理好几遍,判定结果变得很诡异。

6. 二次开发:把阈值判定换成特征加轻量模型

阈值方案的短板很明确——泛化能力弱,换个人、换个放置位置就得重调。想往下走一步,方向是把FallDetector里的状态机换成模型,但采集层和特征层基本不用动,这也是当初把这两层拆开的原因。

一个可行的做法是复用第 3.2 节算出来的五个特征,再补几个:冲击峰值出现的延迟、窗口内超过 2g 的样本数、失重谷到冲击峰的间隔时间。凑成 10 维左右的输入,用公开数据集或者自己录的数据训一个小模型。规模不用大,几十个隐层单元的全连接网络或者一棵随机森林就够,转成 TFLite 之后体积只有几十 KB,在端上跑一次推理不到 5ms。

维度阈值状态机特征加模型
冷启动数据量不需要至少几百条正负样本
跨人群泛化差,需要逐人调参较好,数据覆盖到就行
可解释性强,每一步都能打印弱,只能看置信度
调试成本低,改个数字即可高,要重新训练和转换
端上开销几乎为零单次推理几毫秒,可接受

改造时的关键点是保留阈值状态机作为兜底。模型输出置信度,超过 0.8 才触发告警;低置信度区间仍然走原来的三段判定,两边取或。这样即使模型在某个没见过的场景下失效,也不会比现在更差。

验证方法上,我吃过一次亏,必须说清楚:不能用训练集上的准确率来评价。我一般会把数据按人切分,同一个人要么全在训练集、要么全在测试集,绝不混着来。然后看混淆矩阵里“跌倒被判成日常”的那一格,因为这个方向的错误代价远比反过来大。改完模型之后,我每次都会拿旧版本在同一批测试数据上跑一遍做对比,确认新版本在漏报率

本文还有配套的精品资源,点击获取