简介:这是一款面向体重管理场景的Java专业APP设计源码,适合Android或Java桌面应用开发者学习完整的项目结构与业务实现。压缩包共257个文件、约3.9MB,包含175个Java源文件承载核心业务逻辑,32个XML文件负责布局与配置,另有字体、图片、Gradle构建脚本等配套资源,目录划分清晰,便于对照阅读。项目通过记录体重数值、查看历史变化、设置目标等方式,帮助用户更直观地管理健康数据,也展示了从数据存储到界面交互的完整实现思路。已有313人学习下载,适合正在练习Java应用开发、希望获得可运行项目参考的初中级开发者。
1. 为什么“体重记录”比“记步数”更难做
体重复盘看起来是“一张表、一个数字、一个保存按钮”的简单 App,但真正把这种做法在真实设备上跑几个月,问题才会浮出来:同一天称了两次该留哪条、体脂率缺失时界面怎么显示、用户跨时区后“今天”的边界在哪、连续几次异常值是否该提醒。专业体重记录 APP 的源码价值,不在“能增删改查”,而在存储模型、统计边界和交互约束是否从一开始就按可长期维护的方式设计。
所以这篇直接按一个完整项目的思路来讲:先定数据模型,再写 Java 数据访问层,然后落到 RecyclerView 分组、录入校验和本地提醒,最后补上测试、迁移和发布检查。适合已经掌握 Java 语法、想做一个能上架而不是只能跑 Demo 的 Android 应用的人。
2. 建表与选型:体重数据怎么存,才对得起“专业”两个字
2.1 在 SQLite 和 Room 之间选型
任何体重记录 APP 都绕不开本地存储。方案只有三类:纯文件(JSON/CSV)、SQLiteDirect、SQLite 上面的 ORM。纯文件适合十几条数据,但一旦要按周统计、按区间查均值,文件方案就需要把整个数组加载进内存过滤,行数超过几千条就会出现可感知的卡顿。
SQLite 本身是正确的底子,但直接用SQLiteOpenHelper写 Java 代码,所有升级分支都堆在onUpgrade里,每个版本都要用if (oldVersion < x)判断,改动一多非常容易漏分支。Android 的官方 ORM 是 Room,它对 SQLite 做了一层编译期封装:SQL 语法在 build 阶段校验,表结构和实体类不一致时直接编译报错,版本迁移有专门的 Migration 机制,用起来比手写数据库帮助类省一半代码。
这一段选型结论可以定死:新项目优先 Room,老项目里如果已经写了大量 SQLiteOpenHelper 且测试覆盖足够,才继续沿用。Room 不是性能银弹,但它把“建表、查询、迁移”三件最容易被写错的事变得可验证。
2.2 体重记录表的设计与字段约束
存储模型是第一优先级。体重记录的特点是读多写多但单条记录很小,列表页几乎永远按时间倒序展示,统计页需要按天/周/月聚合。基于这个访问模式,最小完整的表结构如下:
CREATE TABLE IF NOT EXISTS weight_record ( record_id INTEGER PRIMARY KEY AUTOINCREMENT, weight_kg REAL NOT NULL, body_fat_percent REAL, measure_at INTEGER NOT NULL, note TEXT, is_goal_achieved INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_weight_measure_at ON weight_record(measure_at DESC);measure_at使用 INTEGER 类型存 epoch 毫秒时间戳,而不是字符串。时间戳的比较、排序、区间过滤都走原生整数运算,比"2025-01-02 08:30"这种文本格式快,也避免日期格式不一致导致的排序错误。注意不要在同一张表里既存measure_at又存measure_date,两个字段同源就会在写入时面临“改了一个忘改另一个”的脏数据风险。
字段含义如下表:
| 字段 | 类型 | 用途 |
|---|---|---|
| record_id | INTEGER | 主键,自增 |
| weight_kg | REAL | 体重数值,统一以千克存储 |
| body_fat_percent | REAL | 体脂率,部分秤不支持时允许空 |
| measure_at | INTEGER | 测量时间,epoch 毫秒 |
| note | TEXT | 备注,如“早晨空腹” |
| is_goal_achieved | INTEGER | 0/1,标记这条记录是否达到目标 |
索引建在measure_at DESC上。行数几万时索引收益不明显,但列表页和统计页的查询计划能直接命中倒序索引,避免每次ORDER BY都触发文件排序。添加记录时,record_id自增不作为业务含义使用,同一用户一天多次记录完全合法。
2.3 数据精度:double 存储与显示格式化的边界
体重字段不建议用float。float是 32 位,尾数只有 24 位,在极端数值下会有几厘的误差;体重通常是 30~300 的范围,虽然单个值打印看不出问题,但连续乘除计算后误差会累积。推荐double+REAL,如果后续要接蓝牙秤数据再考虑定点数方案。
真正会出问题的不是存储,而是“显示”。同一时刻的界面、导出 CSV、统计图表,必须保证同一条记录显示出来是同一个值。常见做法是在Repository层保留double,只在 UI 绑定数据时用DecimalFormat格式化为一位小数,格式化后的字符串不写回数据库。这一条看着简单,实际项目翻车最多:为了界面好看,把"64.80"存回weight_kg,统计结果就从数值型变成了字符串型,AVG、MAX全乱。
用 Room 映射实体时,Java 类这样写:
@Entity(tableName = "weight_record") public class WeightRecord { @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "record_id") private long recordId; @ColumnInfo(name = "weight_kg") private double weightKg; @ColumnInfo(name = "body_fat_percent") private Double bodyFatPercent; @ColumnInfo(name = "measure_at") private long measureAt; @ColumnInfo(name = "note") private String note; @ColumnInfo(name = "is_goal_achieved") private int isGoalAchieved; }recordId用long,weightKg用原始类型double,bodyFatPercent用包装类型Double。这样区分是有意的:体重必填且不允许空,体脂率可能缺测,包装类型能明确表达“空”和“0.0”的差异。DAO 查询返回时,Room 对包装类型的空列会直接赋 null。
3. 用 Java 落地增删改查、BMI 计算和一周趋势
3.1 DAO 接口与 Repository 的调用关系
Room 的 DAO 本质是接口加注解,编译期生成具体实现。可以设计如下:
@Dao public interface WeightDao { @Insert(onConflict = OnConflictStrategy.REPLACE) long insert(WeightRecord record); @Update int update(WeightRecord record); @Delete int delete(WeightRecord record); @Query("SELECT * FROM weight_record ORDER BY measure_at DESC LIMIT :limit") List<WeightRecord> getLatestRecords(int limit); @Query("SELECT * FROM weight_record WHERE record_id = :id") WeightRecord getById(long id); }@Insert返回long,即新记录的行号;onConflict = OnConflictStrategy.REPLACE表示主键冲突时用新数据覆盖。@Update和@Delete返回int,代表影响的行数。调用方通过这个返回值判断操作是否真的生效,比如删除返回 0 说明这条记录在界面上已经被其他人删掉了。
在实际源码中,Controller 不会直接持有 DAO,中间加一个WeightRepository包装所有数据库调用,后续换成网络同步时会轻松很多:
public class WeightRepository { private final WeightDao weightDao; public WeightRepository(WeightDao weightDao) { this.weightDao = weightDao; } public long addRecord(double weightKg, Double bodyFat, long measureAt, String note) { WeightRecord record = new WeightRecord(); record.setWeightKg(weightKg); record.setBodyFatPercent(bodyFat); record.setMeasureAt(measureAt); record.setNote(note); return weightDao.insert(record); } public List<WeightRecord> getLatest(int limit) { return weightDao.getLatestRecords(limit); } }Repository 层把业务入参转换成实体对象,DAO 只负责 SQL。体重 APP 虽然小,但后续添加“目标体重”“身体数据同步”时,只动 Repository,不动 DAO,这是源码可维护性的第一道边界。
3.2 按天/按周的最小统计查询
体重统计的核心需求集中在最近一条、区间平均、区间最大最小值、记录条数。Room 里可以直接写聚合查询:
@Query("SELECT COUNT(*) FROM weight_record WHERE measure_at >= :startAt AND measure_at <= :endAt") int countBetween(long startAt, long endAt); @Query("SELECT AVG(weight_kg) FROM weight_record WHERE measure_at >= :startAt AND measure_at <= :endAt") double averageWeightBetween(long startAt, long endAt); @Query("SELECT MAX(weight_kg) FROM weight_record WHERE measure_at >= :startAt AND measure_at <= :endAt") double maxWeightBetween(long startAt, long endAt);startAt和endAt都是毫秒时间戳。闭区间>=与<=保证首尾两天都包含,避免出现“周一的记录被漏掉”的边界问题。调用方的日期计算放在 Java 层做,不写死在 SQL 里:
Calendar cal = Calendar.getInstance(); cal.set(Calendar.HOUR_OF_DAY, 0); cal.set(Calendar.MINUTE, 0); cal.set(Calendar.SECOND, 0); cal.set(Calendar.MILLISECOND, 0); long todayStart = cal.getTimeInMillis(); long yesterdayStart = todayStart - TimeUnit.DAYS.toMillis(1); int recentDays = dao.countBetween(yesterdayStart, todayStart);这里用TimeUnit.DAYS.toMillis(1)而不是直接写24 * 60 * 60 * 1000。后者是 int 乘法,24 * 60 * 60 * 1000 已经溢出 int 范围,乘出来是负数,写入时间戳后查询结果会完全错乱。这个坑每周至少会坑到一次不细看的开发者。
3.3 BMI 计算:在 Java 层算还是在 SQL 层算
BMI 不在 SQL 里算。SQL 层只负责取原始值,计算逻辑放 Java 工具类,原因有两个:BMI 需要在用户设置界面里存身高,数据库表存的是体重,两边的数据源不同;SQL 层计算会引入ROUND函数依赖,换数据库时行为可能不一致。
public class WeightCalc { public static double calcBmi(double weightKg, double heightCm) { if (heightCm <= 0) { return 0; } double heightM = heightCm / 100.0; return weightKg / (heightM * heightM); } public static double roundToOneDecimal(double value) { return Math.round(value * 10.0) / 10.0; } }roundToOneDecimal用Math.round(value * 10.0) / 10.0做四舍五入,比String.format("%.1f", value)稳定得多。后者依赖默认 Locale,在某些区域设置下小数点会变成逗号,导入导出 CSV 时字段分隔符就乱了。体重相关展示均按x.x一位小数输出,两个 BMI 同为 22.4 的用户,原始计算值可能有 0.01 的差异,不影响展示和判断。
第 3 章的统计思路可以汇总成下面对照表,写代码时按场景选中即可:
| 场景 | 查询方式 | 返回结果 |
|---|---|---|
| 首页最新一条 | ORDER BY measure_at DESC LIMIT 1 | 最新体重记录 |
| 本周平均 | AVG(weight_kg)+ 周起点/终点 | 均值,double |
| 本月变化量 | 取本月首条和末条,Java 层相减 | 差值为正表示增重 |
| 某天是否已记录 | COUNT(*) WHERE measure_at BETWEEN ? AND ? | 条数,0 则未记录 |
变化量计算必须由 Java 层取两条原始记录再相减,不要在 SQL 里用MAX(weight_kg) - MIN(weight_kg),因为最大体重和最小体重可能发生在不同日期,那个差值没有任何健康含义。
4. RecyclerView 分组列表、录入校验与本地提醒通知
4.1 按日期分组的 RecyclerView 实现
体重记录列表的常见交互是“日期头 + 当天所有记录”。实现分组列表最直接的做法是维护一个List<Object>,日期头是一个对象,记录是一个对象,列表 adapter 根据类型渲染不同的 item。
public class RecordAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> { public static final int TYPE_HEADER = 0; public static final int TYPE_ITEM = 1; private final List<Object> items = new ArrayList<>(); public void submitRecords(List<WeightRecord> records) { items.clear(); String lastDate = ""; for (WeightRecord record : records) { String date = DateUtils.formatDay(record.getMeasureAt()); if (!date.equals(lastDate)) { items.add(new DateHeader(date)); lastDate = date; } items.add(record); } notifyDataSetChanged(); } }submitRecords里先把所有记录按时间排序(DAO 已经排好),再遍历:日期和上一条不同则插入一个DateHeader。这里用一个lastDate变量临时存储上一条的日期,比每循环一次都查 Map 更少依赖额外数据结构。需要注意日期格式函数用的是本地时区,绝不能直接new SimpleDateFormat("yyyy-MM-dd").format(new Date(timestamp))后用 UTC 或其他时区格式化,会导致分组错位到前一天。
记录 item 的布局要展示体重值、体脂率、时间。体脂率为空时显示"--"而不是0.0,0.0 会让用户误以为体脂真的为 0,这一类细节是区分“Demo”和“专业”的直观差异。
4.2 录入页的正则校验与单位换算
录入体重最容易误触的是小数点位置和单位混用。界面输入接受"64.5"这种格式,数据库统一存千克。校验规则用正则:
private static final Pattern WEIGHT_PATTERN = Pattern.compile("^(3[0-9]|[4-9][0-9]|[12][0-9]{2}|300)(\\.[0-9])?$"); public boolean isValidWeight(String input) { return WEIGHT_PATTERN.matcher(input).matches(); }正则的含义:整数部分允许 30~39、40~99、100~299、300;小数部分可选,最多一位。这样把输入限制在 30.0~300.0 千克,超过范围的直接不通过。为什么校验放在点击保存之前而不是之后?因为保存失败时的错误弹窗会打断输入流,专业 App 的做法是把不合法输入标记为红框并添加错误提示,而不是等用户填完所有字段再整体报错。
单位换算放在 Repository 的入口处。体重秤如果是英制,换算成千克后进入表;界面仍然显示千克。表里只存一种单位,任何界面、导出、统计都不再关心单位分支,避免“这个页面用的是斤、那个页面用的是磅”的经典混乱。
4.3 提醒通知:AlarmManager 和 PendingIntent 参数
体重记录 APP 通常需要一个“每天晚上提醒记录”的功能。这个需求不需要精确到秒,所以用AlarmManager.setWindow()而不是setExact():
Intent intent = new Intent(context, WeightReminderReceiver.class); PendingIntent pendingIntent = PendingIntent.getBroadcast( context, 1010, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE ); AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); if (alarmManager != null) { long triggerAt = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(30); alarmManager.setWindow(AlarmManager.RTC_WAKEUP, triggerAt, TimeUnit.MINUTES.toMillis(15), pendingIntent); }setWindow第三个参数是窗口长度,系统在触发时间后的 15 分钟内任意时刻唤起一次广播。这样避免了SCHEDULE_EXACT_ALARM权限。Android 12 以上准点闹钟权限默认关闭,非必需场景申请它会给应用商店审核带来额外解释成本。
PendingIntent参数说明:
| 参数 | 值 | 说明 |
|---|---|---|
| requestCode | 1010 | 同一个 Intent 目标的标识,用于区分不同提醒 |
| flags | FLAG_UPDATE_CURRENT | 更新已有 PendingIntent 的内容 |
| flags | FLAG_IMMUTABLE | Android 12+ 要求,防止被其他应用篡改 |
BroadcastReceiver 收到的数据就是 PendingIntent 创建时传入的信息。如果后续要区分“早晚两个提醒”,把requestCode分别设为 1010 和 1020,Receiver 里用intent.getIntExtra("type", 0)分支处理。
5. 从源码到上架:内存库测试、表结构迁移与发布检查
5.1 用 Room 内存库给 DAO 写测试
数据库逻辑的测试不需要真机硬盘,Room 提供了内存数据库构建方式,测试结束时自动关闭,不会污染用户数据。
@RunWith(AndroidJUnit4.class) public class WeightDaoTest { private WeightDao dao; private RoomDatabase db; @Before public void setUp() { Context context = ApplicationProvider.getApplicationContext(); db = Room.inMemoryDatabaseBuilder(context, AppDatabase.class) .allowMainThreadQueries() .build(); dao = db.weightDao(); } @After public void tearDown() { db.close(); } @Test public void insertAndQueryLatest() { WeightRecord record = new WeightRecord(); record.setWeightKg(64.5); record.setMeasureAt(System.currentTimeMillis()); dao.insert(record); List<WeightRecord> records = dao.getLatestRecords(1); assertEquals(1, records.size()); assertEquals(64.5, records.get(0).getWeightKg(), 0.001); } }allowMainThreadQueries()只建议测试代码使用,业务代码里不要开,否则主线程执行 SQL 会造成 UI 卡顿。断言体重时用assertEquals(64.5, actual, 0.001),第三个参数是允许误差,double 不能直接比较相等。这类测试能保证每次改动 DAO 后查询结果没有回归,是源码可维护性的底线。
5.2 表结构迁移:从版本 1 升到 2
体重记录表后期大概率要加字段。从版本 1 到版本 2 增加“来源 source”字段,迁移写成一个Migration对象:
static final Migration MIGRATION_1_2 = new Migration(1, 2) { @Override public void migrate(@NonNull SupportSQLiteDatabase db) { db.execSQL("ALTER TABLE weight_record " + "ADD COLUMN source TEXT DEFAULT 'manual'"); } };ALTER TABLE ... ADD COLUMN中的DEFAULT 'manual'必须有。旧数据没有source列,加列时若不设置默认值,升级后 Room 读取旧行会对不上 schema,抛出IllegalStateException使 App 无法启动。Room 迁移完成后会拿当前 SQLite schema 和实体类注解做一致性校验,任何一处不匹配都会在启动阶段崩溃,这比运行到某条查询才发现问题要早得多。
5.3 发布前的检查清单
| 检查项 | 怎么查 |
|---|---|
| targetSdk | 在build.gradle中确认与商店要求一致 |
| 权限 | 未联网功能不声明INTERNET,提醒不声明SCHEDULE_EXACT_ALARM |
| 空数据 | 首次启动空列表时显示引导文案,而不是白屏 |
| 数字边界 | 输入 29.9、300.1 被拦截,BMI 身高为 0 时计算结果不崩溃 |
| 迁移 | 安装旧版本,写入一条记录,再覆盖安装新版,记录仍在 |
| 混淆 | proguard-rules.pro保留 Room 相关 keep 规则,release 包测试通过 |
检查清单最后一项尤其容易漏:debug 包能跑,release 包打开就崩,多半是混淆规则没配。打包前先跑一次./gradlew assembleRelease,再用adb install -r覆盖安装旧包,确认升级后原有体重记录仍然存在。如果日志里出现SQLiteConstraintException,先回头检查迁移脚本,不要直接去操作数据库修复用户数据。
本文还有配套的精品资源,点击获取