简介面向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 秒的切片”。第三段是特征。窗口进来之后算合加速度 SVMSignal 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约 20msms调稀会漏掉冲击峰调密则功耗上升明显窗口长度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 Dequefloat[] 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:nameandroid.permission.FOREGROUND_SERVICE / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE_HEALTH / uses-permission android:nameandroid.permission.POST_NOTIFICATIONS / service android:name.service.FallMonitorService android:exportedfalse android:foregroundServiceTypehealth /FOREGROUND_SERVICE_HEALTH和foregroundServiceTypehealth是针对较新系统版本的前台服务类型要求少了会直接抛异常起不来。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 才触发告警低置信度区间仍然走原来的三段判定两边取或。这样即使模型在某个没见过的场景下失效也不会比现在更差。验证方法上我吃过一次亏必须说清楚不能用训练集上的准确率来评价。我一般会把数据按人切分同一个人要么全在训练集、要么全在测试集绝不混着来。然后看混淆矩阵里“跌倒被判成日常”的那一格因为这个方向的错误代价远比反过来大。改完模型之后我每次都会拿旧版本在同一批测试数据上跑一遍做对比确认新版本在漏报率本文还有配套的精品资源点击获取