ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java体重记录APP源码实战:数据模型、统计与图表避坑指南

Java体重记录APP源码实战:数据模型、统计与图表避坑指南 简介这份资源是基于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) ListWeightRecord 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 按天聚合与缺失值填充按天聚合的核心是「每天取一条代表值」。常见做法是取当天最后一条记录因为用户晚上称重的概率更高。如果某天没有记录趋势图上不能直接断开要么用前一天的値填充要么标记为缺失点。我一般用TreeMapLocalDate, Double做聚合天然按日期排序。public MapLocalDate, Double aggregateByDay(ListWeightRecord records) { MapLocalDate, 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 ListPoint fillMissing(MapLocalDate, Double dailyMap, LocalDate start, LocalDate end) { ListPoint 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(ListWeightRecord 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(ListWeightRecord 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 轴用体重 ListEntry entries new ArrayList(); ListString labels new ArrayList(); ListPoint 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 值用索引iY 值用体重这样 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.5kgsetLabelCount(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(ListWeightRecord 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 的用户对数据准确性很敏感一个显示错误就可能导致用户卸载。希望帮到你。本文还有配套的精品资源点击获取
返回列表