简介:这是一份基于安卓平台、采用Java语言开发的跑步应用项目实战资料,面向安卓课程设计、毕业设计及移动开发入门者,覆盖用户注册登录、传感器计步、跑步计时、目标设定与数据存储等完整功能模块,有助于理解从界面搭建到数据持久化的全过程。资源包含三百三十个文件,以界面布局文件、Java程序源文件、图片资源文件为主,另外还有项目构建脚本、动态库、演示视频和使用说明文档,整体体积约三十三兆,方便对照工程结构进行学习。已有三百九十七人学习浏览,适合通过实际项目理解安卓生命周期、传感器调用、本地数据库存储、异步任务处理等核心知识点。借助源码、演示视频与说明文档,可以快速还原应用开发流程,深入梳理用户系统、计步算法、计时逻辑与目标管理的代码实现,同时积累权限配置、自定义组件与界面交互设计等实用技巧,对安卓工程能力提升很有帮助。
1. 跑步App不只是计步器,它是Android组件协作的样板间
一个合格的跑步App,表面上看起来只有“开始跑步、看数据、结束跑步”三个动作,但当你真正拆开源码,会发现它几乎把Android开发的核心组件全部串了一遍:传感器事件流需要处理、页面需要状态保存、训练数据需要持久化、后台计时需要扛住组件重建。很多初学者卡在“看了不少教程,一动手就拼不起来”的境地,就是因为零散知识点缺乏载体。这个项目用一条“注册登录 → 设目标 → 开跑 → 记录 → 查历史”的主线,把SensorManager、SQLite、Handler、生命周期管理全部落到了具体代码里。适合两类人:一是做Android课程设计需要完整可跑通项目的同学,二是想理解“传感器数据如何变成业务数据”的初级工程师。
2. SensorManager计步:从加速度波形到步数统计
2.1 为什么不用TYPE_STEP_DETECTOR,而选加速度计
Android系统从4.4开始提供了TYPE_STEP_DETECTOR和TYPE_STEP_COUNTER两个传感器类型,理论上可以直接拿到系统校准过的步数。但在这个跑步App的场景里,直接用系统计步器有一个致命缺陷:它只给你一个数字增量,不给你运动状态。跑步App需要区分走路、跑步、停止,需要计算步频和大致距离,这些信息在TYPE_STEP_COUNTER的回调里全部拿不到。所以绝大多数课程设计项目会回到TYPE_ACCELEROMETER,自己处理原始数据。
加速度计输出的是手机在X、Y、Z三个轴上的加速度,单位是m/s²。静止时,三轴矢量合约为9.8m/s²,这是重力加速度。人走路时,身体重心每一跨步会有一个上下起伏,体现在加速度计上就是竖直方向的加速度周期性变化——脚落地时有一个冲击峰值,身体上升时有一个过冲。跑步时这个峰值幅度更大、频率更高,走路大约每秒1.4到1.8步,跑步是每秒2.5到3.5步。这个频率差异就是后面做步态识别的切分依据。
如果直接拿某个轴的数据来分析,会有一个隐患:手机在口袋里或手臂上的朝向不稳定,X轴可能根本不在竖直方向。稳妥的做法是先求三轴矢量模:
@Override public void onSensorChanged(SensorEvent event) { if (event.sensor.getType() == Sensor.TYPE_ACCELEROMETER) { float x = event.values[0]; float y = event.values[1]; float z = event.values[2]; // 计算矢量模,消除手机姿态对单轴数据的影响 float magnitude = (float) Math.sqrt(x * x + y * y + z * z); // magnitude减去重力加速度,得到“纯动态加速度” float dynamic = magnitude - SensorManager.GRAVITY_EARTH; } }这里用SensorManager.GRAVITY_EARTH(9.80665f)做静态偏置,得到的dynamic值在走路时正负波动,跑步时波动幅度更大。注意,onSensorChanged的回调频率由注册时的SENSOR_DELAY_GAME(约20ms一次)或SENSOR_DELAY_UI(约60ms一次)决定。计步场景建议用SENSOR_DELAY_GAME,太快的SENSOR_DELAY_FASTEST会浪费电量,而且步频最高也就5Hz,过采样没意义。
2.2 波峰检测和动态阈值的配合
拿到动态加速度序列后,最经典的算法是“波峰检测 + 阈值过滤”。每一步落地都会产生一个明显的波峰,所以问题变成:如何确定“这一步真的算一步”?
固定阈值是最容易想到的方案,比如动态加速度超过2.0就算一步。但这个方案在跑步场景下会失败:慢跑时步幅小、冲击轻,峰值可能只有1.5;快跑或下坡时峰值可以到4.0以上。用固定阈值,不是漏计就是多计。一个实用的做法是把阈值设为动态的——维护一个最近N个波峰值的滑动窗口,每次用平均峰值的60%到70%作为下一轮的判断阈值。
private ArrayList<Float> peakHistory = new ArrayList<>(); private float currentThreshold = 2.0f; private void detectStep(float dynamicAcc) { if (dynamicAcc > currentThreshold) { // 记录波峰,如果超过阈值则认为是潜在步点 peakHistory.add(dynamicAcc); // 波峰之后加速度会回落,回落后再等下一个波峰 rising = true; } if (rising && dynamicAcc < 0) { // 从峰值回到负值,说明一步完成 stepCount++; rising = false; // 更新阈值:取最近10个峰值的平均值,乘0.65 if (peakHistory.size() > 10) { peakHistory.remove(0); } float sum = 0f; for (float v : peakHistory) { sum += v; } currentThreshold = (sum / peakHistory.size()) * 0.65f; } }这段代码有两个关键参数:0.65f是动态阈值系数,数值越小越容易被触发(高灵敏度)、越大越容易漏步;窗口大小10表示用最近10步的冲击强度来适应当前的运动状态。代码里的逻辑是:只有加速度从峰值回落到负值才算走完一步,这其实是利用了“完整一步包含一个冲击和一个缓冲”的事实,避免同一步被重复计数。如果直接把检测步数放在dynamicAcc > threshold里面,每一步会产生大量重复计数,这是很多新手实现跑完一看步数多到离谱的根本原因。
2.3 低频噪声过滤:为什么不直接处理原始值
从onSensorChanged拿到的数据并不干净。走路时手机在口袋里晃动,会产生一些幅值不高但频率很杂的抖动信号;跑步时手臂摆动也会叠加一个低频分量到加速度信号上。如果不做处理,这些噪声会被当成“步点”,结果就是实际跑了100步,计数器显示130。
常见的做法是加一个简单的移动平均滤波(Moving Average),或者用一阶低通滤波。移动平均的窗口一般取5到10个采样点,窗口太大滞后明显,窗口太小滤不掉抖动。
private final int FILTER_WINDOW = 6; private float[] filterBuffer = new float[FILTER_WINDOW]; private int bufferIndex = 0; private float lowPassFilter(float input) { filterBuffer[bufferIndex] = input; bufferIndex = (bufferIndex + 1) % FILTER_WINDOW; float sum = 0f; for (float v : filterBuffer) { sum += v; } return sum / FILTER_WINDOW; }每拿到一个原始动态加速度,先过lowPassFilter,然后把平滑后的值丢给detectStep判断。窗口大小FILTER_WINDOW = 6在采样间隔20ms时对应120ms的平滑跨度,正好能覆盖一步的冲击上升沿,不会把真实峰值削平。
实际操作时会发现:滤波器后的峰值比原始值略低,所以动态阈值的第一版初始值要往低调一点,比如2.0改成1.8。这个微调属于参数校准环节,后面在验证章的调试部分再展开。
3. SQLite数据层:跑步记录如何被可靠地存下来
3.1 表结构设计:一份能应付查询需求的三表方案
跑步App的数据存储如果只用SharedPreferences存一个总步数和总时长,代码写起来简单,但后续查历史、按日期聚合、算平均配速全都做不了。这个项目采用的是SQLite本地持久化方案,这也是课程设计要求中“数据存储”环节最容易被考察的部分。
数据层按职责拆分了三张表:用户表、跑步记录表、目标表。用户表存账户信息;跑步记录表每次跑完插一条,存步数、时长、距离、平均配速和时间戳;目标表存用户设定的每日跑量目标。三张表用user_id关联,如果不需要多用户登录,也可以去掉用户表只保留后两张。但既然项目里有注册登录界面,保留用户表会让整个数据流更完整。
CREATE TABLE user ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, created_at INTEGER DEFAULT (strftime('%s','now')) ); CREATE TABLE run_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, step_count INTEGER NOT NULL, duration_ms INTEGER NOT NULL, distance_meters REAL NOT NULL, avg_pace TEXT NOT NULL, start_time INTEGER NOT NULL, FOREIGN KEY(user_id) REFERENCES user(id) ); CREATE TABLE daily_goal ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, goal_date TEXT NOT NULL, target_distance_meters REAL NOT NULL, achieved_distance_meters REAL DEFAULT 0, UNIQUE(user_id, goal_date) );关于duration_ms和start_time两个字段,很多人会下意识存成TEXT加可读字符串,但更合适的做法是存INTEGER类型的时间戳毫秒值。不要在表里直接存"2025-01-15 08:30:00"这种字符串,排序和区间查询时字符串比较效率低且容易踩格式不一致的坑。
3.2 SQLiteOpenHelper实现与事务写入
创建数据库的入口是自定义SQLiteOpenHelper子类,在onCreate里执行建表语句,onUpgrade里做表重建或字段迁移。需要注意版本号的管理:版本只能递增,不能倒退,否则会触发SQLiteException。
public class DBHelper extends SQLiteOpenHelper { public static final String DB_NAME = "running_app.db"; public static final int DB_VERSION = 1; public DBHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } @Override public void onCreate(SQLiteDatabase db) { db.execSQL("CREATE TABLE user (...)"); db.execSQL("CREATE TABLE run_record (...)"); db.execSQL("CREATE TABLE daily_goal (...)"); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 课程设计场景直接重建,生产环境应做ALTER TABLE迁移 db.execSQL("DROP TABLE IF EXISTS run_record"); onCreate(db); } }插入跑步记录时,建议用得自SQLiteDatabase.insertWithOnConflict或者beginTransaction包裹批量操作。跑步结束后涉及两步写入:插入一条run_record,同时更新当日daily_goal的achieved_distance_meters字段。这两步必须放在同一个事务里,否则会出现“跑步记录有了但目标进度没更新”的脏数据状态。
public void saveRunRecord(RunRecord record, long userId, String date) { SQLiteDatabase db = dbHelper.getWritableDatabase(); db.beginTransaction(); try { ContentValues recordValues = new ContentValues(); recordValues.put("user_id", userId); recordValues.put("step_count", record.getStepCount()); recordValues.put("duration_ms", record.getDurationMs()); recordValues.put("distance_meters", record.getDistanceMeters()); db.insert("run_record", null, recordValues); ContentValues goalValues = new ContentValues(); goalValues.put("achieved_distance_meters", record.getDistanceMeters()); db.update("daily_goal", goalValues, "user_id=? AND goal_date=?", new String[]{String.valueOf(userId), date}); db.setTransactionSuccessful(); } finally { db.endTransaction(); } }beginTransaction和setTransactionSuccessful之间的代码才算事务体,finally块里的endTransaction确保异常时回滚。很多课程设计的评分点会看这里有没有事务保护,因为它直接影响数据一致性。
3.3 读多写少的查询策略:索引与异步化
跑步记录表会随着使用时间变长而膨胀,如果每次查询历史列表都全表扫描,体验会越来越差。建立合适的索引是一种低成本优化手段,常见查询路径有两个:按用户查历史记录,按日期查目标完成情况。
CREATE INDEX idx_run_record_user_time ON run_record(user_id, start_time DESC);这个复合索引的字段顺序有讲究:user_id放在前面做等值过滤,start_time放后面做排序,这样一次索引扫描就能完成“查某用户的记录并按时间倒序排列”的完整逻辑,不需要额外做文件排序。索引不是越多越好,写操作频繁的表要控制索引数量,否则每次插入都要维护索引树。
查询操作不能放在主线程执行。SQLiteDatabase的query系列方法支持异步CursorLoader,但课程项目里常见做法是维护一个简单的线程池。查询结束后通过Handler切回UI线程更新RecyclerView适配器;每次跑步结束也会触发一次记录查询,同步更新当天目标进度。
ExecutorService queryExecutor = Executors.newSingleThreadExecutor(); queryExecutor.execute(() -> { List<RunRecord> records = dao.queryRecentRecords(userId, 20); runOnUiThread(() -> adapter.setData(records)); });4. 生命周期与Handler:跑表中途切后台,数据怎么不丢
4.1 Activity重建是计步数据的第一杀手
跑步场景对生命周期特别敏感:用户跑着跑着来电话了,或者切到微信回条消息,Activity会经历onPause甚至onStop。如果你的传感器监听注册在Activity里,系统在内存不足时可能直接销毁Activity,但服务里的传感器回调还活着,就出现了“数据还在涨但界面不在”的割裂状态。反向情况也存在:屏幕旋转触发Activity重建,onCreate重新执行,如果不做状态恢复,传感器重新注册后回调从零开始,一次跑步记录就被拆成了两半。
处理这个问题的通用做法是把传感器监听、计时器、步数累积逻辑全部下沉到Service里,Activity只负责展示Service暴露的数据。跑步App通常用startForegroundService启用前台服务,避免进程被系统在后台回收。服务的onStartCommand里做传感器注册:
@Override public int onStartCommand(Intent intent, int flags, int startId) { sensorManager = (SensorManager) getSystemService(SENSOR_SERVICE); accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER); sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_GAME); startTimer(); return START_STICKY; }START_STICKY的返回值是另一个关键细节:它告诉系统,如果进程被异常杀掉,系统会尝试重建服务并传入null的Intent。如果服务被主动停止(用户点击结束跑步),则走stopSelf,系统不会重建。这里的判断需要在onStartCommand里检查Intent是否为null,为null时做恢复逻辑而不是重新开启一轮新记录。
4.2 Handler与HandlerThread:避免在主线程里做步数统计
onSensorChanged的回调频率是毫秒级的,如果在里面做浮点运算加数组操作,短期没问题,但主线程同时还要负担UI绘制和触摸事件,时间长了就可能出现卡顿。更稳妥的做法是让传感器数据在子线程中消费,Android提供的工具是HandlerThread。
private HandlerThread sensorThread; private Handler sensorHandler; private void startSensorThread() { sensorThread = new HandlerThread("SensorThread"); sensorThread.start(); sensorHandler = new Handler(sensorThread.getLooper()); sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_GAME, sensorHandler); }SensorManager.registerListener的第四个参数可以指定一个Handler,这样onSensorChanged回调就运行在sensorThread的Looper里,主线程完全不被传感器数据流打扰。UI更新时再通过runOnUiThread或者主线程Handler发送消息。这里注意,不用Thread直接用HandlerThread的原因在于:它内置了Looper消息循环,适合持续消费高频事件的场景;如果你用原始Thread,需要自己维护wait/notify,完全没有必要。
4.3 计时器选型:Handler计时为什么优于CountDownTimer
跑步计时常见两种写法:CountDownTimer倒计时或者Handler.postDelayed循环。CountDownTimer语义上更适合“限时倒计时”,跑步是“从零开始累加”,用倒计时语义去包装会很别扭。推荐用Handler做定时累加,每秒钟发一条空消息,在handleMessage里更新计时值。
private static final int MSG_UPDATE_TIME = 1; private void stopTimer() { handler.removeCallbacksAndMessages(null); } private void startTimer() { handler.postDelayed(tickRunnable, 1000); } private Runnable tickRunnable = new Runnable() { @Override public void run() { runningTimeSeconds++; // 通过广播或LiveData通知界面刷新 handler.postDelayed(this, 1000); } };请务必注意removeCallbacksAndMessages(null)这一行。如果不主动移除回调,Service被stopSelf后任务仍然在消息队列里排队,Runnable持有Service引用导致内存泄漏,同时计时还在继续。这是最常见的Handler缺陷。
5. 用应用内调试面板校准步数阈值与配速计算
5.1 校准工具:实时输出传感器波形峰谷值
买不到手机或者不想反复安装调试包的时候,可以在App里直接内置一个调试面板,把实时动态加速度、当前阈值和步数累计值打印在屏幕上。实现方式很简单,在跑步界面加一个TextView,每次onSensorChanged时刷新内容。
private void updateDebugPanel(float dynamicAcc, float threshold, int step) { String info = String.format("dyn=%.2f th=%.2f steps=%d\n" + "lastPeak=%.2f peakAvg=%.2f", dynamicAcc, threshold, step, lastPeak, peakAvg); debugText.setText(info); }用这个面板可以直观看到走一步时dynamicAcc的峰值到底到多少,弱步态(慢走)和强步态(快跑)的差异有多大。如果慢走时峰值总是低于当前阈值导致漏步,就调低初始阈值或者把动态阈值系数从0.65降到0.55。如果静止时步数还在涨,说明阈值太低把抖动也算进去了,把系数调高到0.75。这种调参方式比盲改代码跑一遍要高效得多。
5.2 距离与配速的计算方法验证
计步正确率只有通过对比才能判断——找一段已知长度的测试路线进行校准,验证步数计算和配速推算的逻辑是否符合预期。在跑步场景下通常有两种距离计算方式:基于步数乘步幅,或基于GPS轨迹计算。如果选择步数乘步幅的方式,在不知道用户真实步幅时设置一个默认值(比如身高乘以0.45系数),并在App设置页面允许用户修正,这样比硬编码0.7米更贴近实际。
val distanceMeters = stepCount * strideLength val avgPace = if (durationSeconds > 0 && distanceMeters > 0) { // 配速 = 分钟/公里 durationSeconds / 60.0 / (distanceMeters / 1000.0) } else { 0.0 }这段代码的输出是一个分钟/公里为单位的配速值,例如5.8表示每公里5分48秒。注意durationSeconds / 60.0这一步要写成浮点数除法,两个Int相除会直接截断成整数,这是配速计算里最常见的错误源。
5.3 停表与边界步的处理逻辑
跑完步点“结束”按钮时,界面上显示的总步数往往包含最后那几步停顿时的抖动,导致最终记录比实际多几秒的计步信号。处理方式比较简单:停止计步后,把最后1000ms内检测到的步点丢弃,因为正常结束流程下最后一刻的动作属于减速缓冲,不属于主动跑步。这个逻辑放在Service的stopSelf调用之前执行,先冻结步数,再清理传感器监听,最后才停止计时器。整个顺序保证用户看到的是冻结后的稳定数值,而不是传感器还在一两个回调后才停下来的滞后数据。
调试完成后,把校准好的阈值、步幅系数、服务生命周期处理一并完善,剩下的就是反复跑几次测试路径来验证数据一致性,例如先在室内固定距离走30步对比计步器结果,再在户外跑1公里校验距离和配速精度。通过这个流程,一套课程设计的验收素材基本就齐了。
本文还有配套的精品资源,点击获取