☰
Java体重记录APP源码实战:数据模型、统计与图表避坑指南
2026/9/26 12:27:12 网站建设 项目流程

简介:这份资源是基于Java开发的专业体重记录APP设计源码,面向具备一定Java与Android基础、希望学习完整移动应用架构的开发者与课程设计者,可用于体重管理类应用的二次开发或毕业设计参考。压缩包共257个文件,约3.9MB,其中175个Java源文件承载数据采集、存储与界面交互等核心逻辑,32个XML配置文件负责布局、样式与国际化,另有PNG图片、TTF字体、Gradle构建脚本及属性文件,覆盖界面视觉、构建自动化与配置管理,目录结构清晰,便于按模块检索。目前已有313人学习下载。源码完整呈现了体重记录、历史数据查看、目标体重设置与图表趋势展示等功能的实现思路,并涉及数据安全与多设备同步的扩展方向,读者可借此掌握Java应用从构建到发布的完整流程,快速搭建可运行的项目原型。

1. 体重记录 APP 的 Java 源码:从「能跑」到「敢用」差在哪

体重记录类 APP 看起来简单,真动手写才发现坑不少:数据要按天聚合、趋势图要平滑、离线要能存、多设备要能同步,还要处理用户随手删记录、改目标体重这些边界情况。基于 Java 开发的专业体重记录 APP 设计源码,核心价值不在于界面多花哨,而在于把「记录—存储—统计—展示」这条链路做扎实。它适合两类人:一是想拿一个完整 Java 项目练手、补全工程化经验的开发者;二是想快速搭一个自用或小范围使用的体重管理工具的人。这篇笔记按「数据模型怎么定、本地存储怎么选、统计逻辑怎么写、图表怎么接、坑在哪」的顺序拆开讲,每一步都给可复现的代码和参数说明,新手能照着跑,熟手能直接拿去改。

2. 数据模型与存储选型:体重记录 APP 的地基怎么打

体重记录 APP 的数据模型决定了后面统计和同步的难易程度。很多人一上来就建一张weight_record表,字段只有id、weight、date,写到后面发现要加体脂率、要区分手动输入和体脂秤导入、要支持多用户,只能反复改表。我一般会先把实体关系理清楚,再决定用 SQLite 还是 Room。

2.1 核心实体与字段设计

一个能撑住后续扩展的模型至少包含三张表:用户表、体重记录表、目标表。用户表存基础信息和单位偏好;体重记录表存每次测量的数值、时间戳、来源;目标表存起始体重、目标体重、目标日期。关键点是体重记录表的时间戳用long存毫秒,不要用字符串存日期,否则按天聚合时排序和分组都会出问题。

-- 用户表:单位偏好影响后续所有展示逻辑 CREATE TABLE user ( id INTEGER PRIMARY KEY AUTOINCREMENT, nickname TEXT NOT NULL, unit TEXT DEFAULT 'kg', -- kg 或 lb,展示时换算 height_cm REAL, -- 用于计算 BMI created_at INTEGER NOT NULL ); -- 体重记录表:source 区分手动输入和体脂秤导入 CREATE TABLE weight_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, weight_kg REAL NOT NULL, -- 统一存 kg,展示时按用户单位换算 body_fat REAL, -- 可空,体脂秤才有 recorded_at INTEGER NOT NULL, -- 毫秒时间戳 source TEXT DEFAULT 'manual', -- manual / scale / import note TEXT, FOREIGN KEY (user_id) REFERENCES user(id) ); -- 目标表:一个用户同时只有一个生效目标 CREATE TABLE goal ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, start_weight REAL NOT NULL, target_weight REAL NOT NULL, target_date INTEGER, is_active INTEGER DEFAULT 1, FOREIGN KEY (user_id) REFERENCES user(id) );

字段说明:weight_kg统一存公斤,避免单位混存导致统计口径不一致;recorded_at用毫秒时间戳,按天聚合时用strftime或 Java 侧LocalDate转换;source字段为后续数据清洗留口子,体脂秤导入的数据往往有重复和异常值,需要单独标记。is_active用整数而不是布尔,是 SQLite 没有原生布尔类型,用 0/1 更稳。

2.2 Room 持久化与 DAO 写法

Android 侧现在主流用 Room,它本质是 SQLite 的 ORM 封装,编译期校验 SQL,比手写SQLiteOpenHelper少很多运行时崩溃。下面是一个按天聚合查询的 DAO 写法,注意GROUP BY用的是转换后的日期字符串,不是原始时间戳。

@Dao public interface WeightDao { // 插入记录,返回自增 id @Insert long insert(WeightRecord record); // 按天聚合:取每天最后一条记录作为当天体重 @Query("SELECT weight_kg, recorded_at FROM weight_record " + "WHERE user_id = :userId AND recorded_at >= :startTime " + "ORDER BY recorded_at ASC") List<WeightRecord> queryRange(int userId, long startTime); // 统计:最近 N 天的平均体重 @Query("SELECT AVG(weight_kg) FROM weight_record " + "WHERE user_id = :userId AND recorded_at >= :startTime") float avgWeight(int userId, long startTime); // 删除单条记录 @Delete void delete(WeightRecord record); }

逻辑说明:queryRange返回原始记录列表,按天聚合放在 Java 层做,因为「取每天最后一条」这种业务规则用 SQL 写会依赖数据库方言,放在 Java 层用TreeMap按LocalDate分组更可控。avgWeight直接走 SQL 聚合,减少内存占用。参数startTime由调用方计算,比如「最近 30 天」就是System.currentTimeMillis() - 30L * 24 * 3600 * 1000。

注意:Room 的@Query里不要用SELECT *,字段顺序变化会导致映射错位,显式列出字段名更安全。

2.3 单位换算与精度处理

体重数据涉及单位换算和浮点精度两个容易翻车的点。存储统一用公斤,展示时按用户偏好换算,换算系数固定为1 kg = 2.20462 lb。浮点数比较不要用==,用Math.abs(a - b) < 0.01。统计平均值时,如果记录数很多,float累加会有精度损失,建议用double累加后再转回。

public class UnitConverter { private static final double KG_TO_LB = 2.20462; // 存储值转展示值 public static double toDisplay(double weightKg, String unit) { return "lb".equals(unit) ? weightKg * KG_TO_LB : weightKg; } // 展示值转存储值 public static double toStorage(double displayWeight, String unit) { return "lb".equals(unit) ? displayWeight / KG_TO_LB : displayWeight; } // 浮点比较,避免 0.1 + 0.2 != 0.3 这类问题 public static boolean nearlyEqual(double a, double b) { return Math.abs(a - b) < 0.01; } }

参数说明:KG_TO_LB保留五位小数足够日常使用;nearlyEqual的阈值 0.01 对应 10 克,体重场景下这个精度足够。如果做体脂率统计,阈值可以放宽到 0.1,因为体脂秤本身误差就在这个量级。

3. 统计与趋势计算:把原始记录变成可读曲线

有了原始记录,下一步是把它变成用户能看懂的统计结果。体重记录 APP 的统计需求通常包括:最近 7 天/30 天趋势、周环比、目标完成度、BMI 变化。这些计算不难,但边界情况多,比如某天没有记录怎么办、用户删了中间某条记录怎么处理、跨月跨年怎么算。

3.1 按天聚合与缺失值填充

按天聚合的核心是「每天取一条代表值」。常见做法是取当天最后一条记录,因为用户晚上称重的概率更高。如果某天没有记录,趋势图上不能直接断开,要么用前一天的値填充,要么标记为缺失点。我一般用TreeMap<LocalDate, Double>做聚合,天然按日期排序。

public Map<LocalDate, Double> aggregateByDay(List<WeightRecord> records) { Map<LocalDate, Double> dailyMap = new TreeMap<>(); for (WeightRecord r : records) { LocalDate date = Instant.ofEpochMilli(r.recordedAt) .atZone(ZoneId.systemDefault()) .toLocalDate(); // 同一天多条记录,后写入的覆盖先写入的 dailyMap.put(date, r.weightKg); } return dailyMap; } // 填充缺失日期:用前一天的値补齐,首日缺失则跳过 public List<Point> fillMissing(Map<LocalDate, Double> dailyMap, LocalDate start, LocalDate end) { List<Point> points = new ArrayList<>(); Double lastKnown = null; for (LocalDate d = start; !d.isAfter(end); d = d.plusDays(1)) { Double v = dailyMap.get(d); if (v != null) { lastKnown = v; } if (lastKnown != null) { points.add(new Point(d, lastKnown)); } } return points; }

逻辑说明:aggregateByDay用TreeMap保证日期有序,同一天多条记录时后写入的覆盖先写入的,对应「取最后一条」的业务规则。fillMissing用前值填充,首日没有记录时跳过,避免曲线从 0 开始造成误导。参数start和end由调用方根据查询范围传入,比如最近 30 天就是LocalDate.now().minusDays(29)到LocalDate.now()。

3.2 周环比与目标完成度

周环比是拿本周平均体重和上周平均体重比,计算变化量和变化率。目标完成度是拿当前体重和起始体重、目标体重算进度百分比。这两个指标都涉及除法,要处理分母为零的情况。

public class StatsCalculator { // 周环比:返回变化量(kg),正数表示增重 public static double weekOverWeek(List<WeightRecord> records) { long now = System.currentTimeMillis(); long oneWeek = 7L * 24 * 3600 * 1000; double thisWeek = avgInRange(records, now - oneWeek, now); double lastWeek = avgInRange(records, now - 2 * oneWeek, now - oneWeek); return thisWeek - lastWeek; } private static double avgInRange(List<WeightRecord> records, long from, long to) { double sum = 0; int count = 0; for (WeightRecord r : records) { if (r.recordedAt >= from && r.recordedAt < to) { sum += r.weightKg; count++; } } return count == 0 ? 0 : sum / count; } // 目标完成度:0 到 1 之间,超出范围截断 public static double goalProgress(double start, double target, double current) { if (Math.abs(start - target) < 0.01) return 1.0; double progress = (start - current) / (start - target); return Math.max(0, Math.min(1, progress)); } }

参数说明:weekOverWeek用滚动 7 天而不是自然周,避免周一用户看到数据突然跳变;avgInRange对空区间返回 0,调用方需要判断count是否为 0 再决定是否展示;goalProgress用Math.max和Math.min把进度截断在 0 到 1 之间,防止用户体重反弹时进度变成负数。

3.3 BMI 与体脂率计算

BMI 公式是体重(kg)除以身高(m)的平方。体脂率如果体脂秤没有直接给,可以用美国海军公式估算,但误差较大,建议只做参考展示。计算时注意身高单位是厘米,要先除以 100。

public class HealthIndex { // BMI:身高单位 cm public static double bmi(double weightKg, double heightCm) { if (heightCm <= 0) return 0; double heightM = heightCm / 100.0; return weightKg / (heightM * heightM); } // BMI 分级,返回中文描述 public static String bmiLevel(double bmi) { if (bmi < 18.5) return "偏瘦"; if (bmi < 24) return "正常"; if (bmi < 28) return "偏胖"; return "肥胖"; } }

参数说明:bmi对身高做零值保护,避免除零崩溃;bmiLevel的分级阈值采用国内常用标准,和 WHO 标准略有差异,如果面向国际用户需要调整。体脂率估算公式涉及腰围、颈围等数据,如果 APP 没有采集这些字段,建议直接展示体脂秤返回值,不要自己算。

4. 图表展示与交互:让曲线不卡顿、不误导

体重趋势图是用户看得最多的界面,性能问题和展示误导都出在这里。常见翻车场景:记录上千条时图表卡顿、Y 轴从 0 开始导致曲线看起来像直线、日期标签重叠。这一章讲怎么用 MPAndroidChart 把趋势图做稳。

4.1 MPAndroidChart 接入与数据绑定

MPAndroidChart 是 Android 侧最常用的图表库,接入步骤是加依赖、初始化LineChart、构造Entry列表、设置LineDataSet。关键点是 X 轴用索引而不是时间戳,时间戳直接当 X 值会导致标签计算复杂。

// 初始化图表 LineChart chart = findViewById(R.id.weightChart); chart.getDescription().setEnabled(false); chart.setTouchEnabled(true); chart.setDragEnabled(true); chart.setScaleEnabled(true); // 构造数据:X 轴用索引,Y 轴用体重 List<Entry> entries = new ArrayList<>(); List<String> labels = new ArrayList<>(); List<Point> points = fillMissing(dailyMap, startDate, endDate); for (int i = 0; i < points.size(); i++) { entries.add(new Entry(i, (float) points.get(i).weight)); labels.add(points.get(i).date.format(DateTimeFormatter.ofPattern("MM-dd"))); } LineDataSet dataSet = new LineDataSet(entries, "体重"); dataSet.setMode(LineDataSet.Mode.CUBIC_BEZIER); // 平滑曲线 dataSet.setDrawCircles(false); // 点太多时不画圆点 dataSet.setLineWidth(2f); dataSet.setColor(Color.parseColor("#4CAF50")); LineData lineData = new LineData(dataSet); chart.setData(lineData); // X 轴配置:标签数量控制,避免重叠 XAxis xAxis = chart.getXAxis(); xAxis.setValueFormatter(new IndexAxisValueFormatter(labels)); xAxis.setLabelCount(Math.min(6, labels.size())); xAxis.setGranularity(1f); xAxis.setPosition(XAxis.XAxisPosition.BOTTOM); chart.invalidate();

逻辑说明:Entry的 X 值用索引i,Y 值用体重,这样 X 轴标签通过IndexAxisValueFormatter映射,避免时间戳换算。setMode(CUBIC_BEZIER)让曲线平滑,但数据点少时可能过冲,如果记录少于 5 条建议用LINEAR。setDrawCircles(false)在数据点多时显著提升渲染性能。setLabelCount限制标签数量,防止日期文字挤在一起。

4.2 Y 轴范围与视觉误导

Y 轴从 0 开始是很多图表的默认行为,但体重变化通常只有几公斤,从 0 开始会让曲线看起来像一条直线,用户感知不到变化。正确做法是让 Y 轴范围围绕数据最小值和最大值留出余量。

// 计算 Y 轴范围:数据最小最大值各留 1kg 余量 float min = Float.MAX_VALUE, max = Float.MIN_VALUE; for (Entry e : entries) { min = Math.min(min, e.getY()); max = Math.max(max, e.getY()); } YAxis yAxis = chart.getAxisLeft(); yAxis.setAxisMinimum(min - 1f); yAxis.setAxisMaximum(max + 1f); yAxis.setLabelCount(5, false); chart.getAxisRight().setEnabled(false); // 隐藏右轴

参数说明:余量取 1kg 是经验值,如果用户体重波动很小可以缩到 0.5kg;setLabelCount(5, false)的第二个参数false表示不强制精确,让库自己选好看的刻度;隐藏右轴避免重复刻度干扰阅读。

4.3 手势交互与性能优化

图表支持拖动和缩放后,数据量大时容易卡顿。优化手段包括:限制可见范围、关闭不必要的动画、用setDrawCircles(false)减少绘制元素。如果记录超过 500 条,建议按周聚合后再展示,而不是画每一天的点。

// 限制缩放范围,防止用户缩到看不清 chart.setVisibleXRangeMaximum(30); // 最多显示 30 个点 chart.setVisibleXRangeMinimum(7); // 最少显示 7 个点 chart.moveViewToX(entries.size() - 1); // 默认定位到最新 // 关闭动画,列表页嵌入图表时动画会拖慢滚动 chart.animateX(0);

逻辑说明:setVisibleXRangeMaximum(30)限制一屏最多 30 个点,超过就靠拖动查看;moveViewToX让图表默认显示最新数据,符合用户打开 APP 先看当前体重的习惯;animateX(0)关闭入场动画,在RecyclerView里嵌入图表时能明显减少卡顿。

5. 避坑与排查:体重记录 APP 开发中最容易翻车的 5 个点

这一章记录我在做体重记录类 APP 时真实踩过的坑,每条按「现象 → 原因 → 解决」写,方便对照排查。

5.1 时区问题导致日期错位

现象:用户晚上 11 点记录体重,第二天打开 APP 发现记录显示在前一天。原因:时间戳转日期时用了 UTC 时区,而用户在东八区,跨天边界差 8 小时。解决:所有日期转换统一用ZoneId.systemDefault(),不要用ZoneOffset.UTC。如果做多时区支持,存储时额外存一个用户时区字段。

5.2 浮点数精度导致统计偏差

现象:用户记录 70.1、70.2、70.3,平均体重显示 70.19999。原因:float累加精度损失。解决:统计时用double累加,展示时用String.format("%.1f", value)格式化。存储层用REAL类型,Java 侧用double接收,不要用float。

5.3 数据库升级丢数据

现象:APP 版本更新后用户记录全部消失。原因:Room 数据库版本号变了但没写Migration,默认行为是删表重建。解决:每次改表结构都要写Migration,并在RoomDatabase.Builder里addMigrations。测试时用fallbackToDestructiveMigration只用于开发阶段,上线前必须去掉。

static final Migration MIGRATION_1_2 = new Migration(1, 2) { @Override public void migrate(SupportSQLiteDatabase db) { db.execSQL("ALTER TABLE weight_record ADD COLUMN note TEXT"); } };

5.4 图表数据量大时卡顿

现象:用户记录超过 1000 条后,趋势图滑动明显掉帧。原因:每次重绘都遍历全部Entry,且开启了圆点绘制。解决:按周聚合后再展示,关闭圆点,限制可见范围。如果必须展示全部数据,用LTTB降采样算法把点数降到 200 以内。

5.5 单位换算方向搞反

现象:用户切换到磅后体重显示 154,切回公斤变成 70,但再切磅变成 339。原因:换算时把存储值当展示值又乘了一次系数。解决:明确「存储永远是公斤,展示才换算」这条规则,所有换算只发生在 UI 层,数据层不感知单位。写单元测试覆盖「公斤→磅→公斤」往返一致性。

6. 从能跑到敢用:体重记录 APP 的验证清单与一个实用技巧

代码写完只是第一步,能不能放心用要看验证做得够不够。我一般会跑一份检查清单:数据库升级是否写了 Migration、时区转换是否统一、浮点比较是否用了阈值、图表在 1000 条数据下是否流畅、单位换算往返是否一致、空数据和单条数据是否崩溃。这份清单跑完,基本能挡住 80% 的线上问题。

一个实用技巧是给统计逻辑写纯函数单元测试,不依赖 Android 环境。把StatsCalculator、UnitConverter、HealthIndex这些类做成无 Android 依赖的纯 Java 类,用 JUnit 直接测,跑得快且稳定。下面是一个测试示例:

@Test public void testGoalProgress() { // 起始 80kg,目标 70kg,当前 75kg,进度应为 50% double progress = StatsCalculator.goalProgress(80, 70, 75); assertEquals(0.5, progress, 0.001); // 体重反弹到 85kg,进度应截断为 0 assertEquals(0.0, StatsCalculator.goalProgress(80, 70, 85), 0.001); // 起始等于目标,进度为 1 assertEquals(1.0, StatsCalculator.goalProgress(70, 70, 70), 0.001); } @Test public void testUnitRoundTrip() { double kg = 70.5; double lb = UnitConverter.toDisplay(kg, "lb"); double back = UnitConverter.toStorage(lb, "lb"); assertTrue(UnitConverter.nearlyEqual(kg, back)); }

参数说明:assertEquals的第三个参数是误差容忍度,浮点测试必须带这个参数,否则会因为精度问题随机失败。testUnitRoundTrip验证往返一致性,这是单位换算最容易出错的场景。

另一个容易忽略的点是数据导出。用户用了半年,想换手机或者备份,如果没有导出功能,数据就锁死在 APP 里。建议在设置页加一个「导出 CSV」按钮,把weight_record表按时间范围导出,字段包括日期、体重、体脂、备注。导出逻辑用StringBuilder拼 CSV,注意处理逗号和换行转义。

public String exportCsv(List<WeightRecord> records) { StringBuilder sb = new StringBuilder("date,weight_kg,body_fat,note\n"); DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); for (WeightRecord r : records) { String date = Instant.ofEpochMilli(r.recordedAt) .atZone(ZoneId.systemDefault()).format(fmt); sb.append(date).append(",") .append(r.weightKg).append(",") .append(r.bodyFat == null ? "" : r.bodyFat).append(",") .append(r.note == null ? "" : r.note.replace(",", ",")) .append("\n"); } return sb.toString(); }

逻辑说明:CSV 表头固定,方便 Excel 直接打开;备注里的逗号替换成中文逗号,避免破坏列结构;体脂和备注为空时输出空字符串,不要输出null。导出文件写到getExternalFilesDir下,不需要存储权限,卸载 APP 时自动清理。

最后说一个我自己的习惯:每次改完统计逻辑,先跑一遍单元测试,再用真实数据在模拟器上过一遍图表,确认曲线形状符合预期。体重记录 APP 的用户对数据准确性很敏感,一个显示错误就可能导致用户卸载。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询