简介:一款基于安卓平台的身体质量指数健康值计算器源码项目,面向安卓初学者及移动开发练习者,帮助理解健康类小工具从输入校验、逻辑计算到结果展示的完整流程。项目共五十一个文件,核心包含七个Java逻辑类、十二个XML界面布局与资源文件、五个MP3健康提示音,以及Gradle构建配置和说明文档,压缩包仅1.06MB,结构清晰便于快速导入安卓开发环境学习。代码实现了身高体重合法性校验、BMI自动计算与健康建议、本地数据库保存历史记录,并以折线图和列表两种方式呈现趋势,同时针对不同BMI区间播放不同音效,功能覆盖了安卓开发中常见的界面交互、数据持久化与简单图表绘制场景。已有七十八人学习下载,适合作为课程设计或安卓入门阶段的参考项目,可从中掌握项目工程组织、页面与逻辑协作以及轻量级数据库存储的典型写法。
1. 从身高体重到健康评估的 Android 实现思路
一个看似简单的 Android 计算器项目,如果只做输入身高体重、输出数字,确实没什么可讲的。但把需求拆细就会看到问题:BMI 阈值怎么定、身高输入用米还是厘米、结果要不要落库、历史趋势用什么图展示、不同健康档位要不要有声音反馈。
这个仓库里的 Android BMI 健康值计算器,把这些问题完整串了起来:标准 BMI 计算与健康档位判定、SQLite 本地持久化、折线图与列表双视图历史记录、差异化健康提示音、输入合法性校验。源码按 Gradle 工程组织,导入 Android Studio 即可构建运行。
对做课程设计或想熟悉 SQLite、RecyclerView、MPAndroidChart 组合的开发者,它能演示一条干净的本机数据链路,下文从计算与校验这层开始逐段拆解。
2. BMI 计算核心逻辑、单位换算与输入数据校验
BMI 数值计算是全项目里公式最短的一段,但工程上的问题往往不出在公式本身,而是集中在单位不一致、精度不一致、输入边界不一致这三个地方。单位不一致指的是用户在界面上输入的是厘米,而公式需要的是米;精度不一致是指 double 参与运算后可能出现 21.349999 这类展示值;输入边界不一致则是校验收得太松或太严都会影响真实体验。这一章先把这三个不一致处理干净,再定义健康档位的判定枚举。
2.1 核心计算公式与单位归一化处理
用户填写身高时,界面上用的单位是厘米,BMI 公式要求的是米,这一层换算必须在计算模块里收敛,不能在每次调用时各自处理。写一个独立的工具类,接口语义设计为入参厘米和千克,返回值精确到一位小数。
public class BmiCalculator { public static double calculate(double heightCm, double weightKg) { if (heightCm <= 0 || weightKg <= 0) { throw new IllegalArgumentException("身高和体重必须大于0"); } double heightM = heightCm / 100.0; return weightKg / (heightM * heightM); } public static double format(double bmi) { return Math.round(bmi * 10) / 10.0; } }heightCm / 100.0 这里的 100.0 必须是浮点字面量,写成 100 会让 Java 执行整数除法,175 会变成 1 而不是 1.75,最终结果会放大三倍以上。入参用 double,调用层从 EditText 拿到的字符串通过 Double.parseDouble 转换后再传入,模型层不感知 UI。format 方法保留一位小数,返回值同时作为数据库存储值和列表展示值,避免同一份数据在不同界面显示成不同精度。
BMI 分类采用国内成人标准比较合适,源码里大概率是在 calculate 之后接了一层档位判断,这里直接给出常用的枚举实现:
public enum HealthLevel { THIN, NORMAL, OVERWEIGHT, OBESE, INVALID; public static HealthLevel fromBmi(double bmi) { if (bmi < 0) return INVALID; if (bmi < 18.5) return THIN; if (bmi < 24.0) return NORMAL; if (bmi < 28.0) return OVERWEIGHT; return OBESE; } }把 BMI 小于 0 当作异常场景,通过枚举的 INVALID 兜底。阈值的可视化为后期加提示音和颜色标记提供了单一入口,后续不管是要改界面文案,还是换音效资源,都只需要改映射逻辑,不需要把 if 散落在 Activity 里。对应档位的健康建议可以定义成下面的对照表,文案可以放在 strings.xml 中做多渠道适配:
| BMI 档位 | 区间范围 | 建议文案 |
|---|---|---|
| 偏瘦 | < 18.5 | 建议增加优质蛋白摄入 |
| 正常 | 18.5 ~ 23.9 | 保持均衡饮食与规律运动 |
| 超重 | 24.0 ~ 27.9 | 控制高热量饮食,增加运动量 |
| 肥胖 | ≥ 28.0 | 建议寻求营养或运动指导 |
2.2 输入合法性校验与异常数据拦截
数据校验的核心思路是:合法范围要有物理语义,而不是简单判空。身高 5 厘米、体重 500 公斤算出来的 BMI 值完全无法反映真实健康状态,必须拦截。校验逻辑放在工具类里返回 boolean,同时配合 UI 层做更具体的失败提示。
public static boolean isHeightValid(double heightCm) { return heightCm >= 50.0 && heightCm <= 250.0; } public static boolean isWeightValid(double weightKg) { return weightKg >= 20.0 && weightKg <= 300.0; }50 到 250 厘米覆盖绝大多数成年人的身高范围,20 到 300 公斤覆盖从极瘦到重型用户的普遍区间。这两个范围并非官方标准,而是实践中的合理值,项目是做健康参考工具,不是医疗器械,边界设置过紧反而会误伤真实用户。如果今后要支持儿童身高体重,阈值必须拆分,不能共用这套常量。
UI 层拿到输入后需要先做字符串处理,再调用校验方法。EditText 的内容要 trim 掉前后空格,避免用户手滑输入空格导致换算错误;空值用 TextUtils.isEmpty 判断,避免 parseDouble 抛 NumberFormatException 导致应用直接崩溃。
String heightStr = etHeight.getText().toString().trim(); String weightStr = etWeight.getText().toString().trim(); if (TextUtils.isEmpty(heightStr) || TextUtils.isEmpty(weightStr)) { Toast.makeText(this, "身高和体重不能为空", Toast.LENGTH_SHORT).show(); return; } double heightCm = Double.parseDouble(heightStr); double weightKg = Double.parseDouble(weightStr); if (!BmiCalculator.isHeightValid(heightCm)) { Toast.makeText(this, "身高需在50到250厘米之间", Toast.LENGTH_SHORT).show(); return; } if (!BmiCalculator.isWeightValid(weightKg)) { Toast.makeText(this, "体重需在20到300公斤之间", Toast.LENGTH_SHORT).show(); return; } double bmi = BmiCalculator.format(BmiCalculator.calculate(heightCm, weightKg));这里每一条校验失败都给出了具体的文案,比“输入错误”四个字清晰得多。Toast 只是最简单的提示方式,如果想做更好的体验,可以在输入框下方用 TextInputLayout 展示 error 状态,避免 Toast 一闪而过。
提示:Double.parseDouble 可以把 “175” 解析成 175.0,但遇到 “175abc” 一样会抛异常。这个项目里界面输入已经限定为键盘类型 decimal,所以不需要额外做正则校验;如果后续要支持扫码录入,最稳妥的做法是再包一层正则,用
^\d+(\.\d+)?$做前置过滤。
3. SQLite 本地存储与历史记录管理实现
计算与校验闭环之后,接下来要考虑的是数据留存。用户每一次测量结果都要存进本地数据库,后续的历史列表和趋势图都依赖这份数据。这一章选 SQLite 做落地方案,原因很直接:单机应用、单表结构、没有跨设备同步需求,没必要引入远端服务或 ORM 框架。源码包里的项目结构也是标准 Android 工程,数据库相关代码集中在 app/src/main 下,和 UI 层完全解耦。
3.1 表结构设计与契约类定义
数据库设计的第一步是定义表结构。表的字段要和计算结果的属性一一对应,同时预留一个主键自增 id 和一个时间戳,时间戳建议用 long 类型的毫秒值,而不是存成字符串。毫秒值排序稳定,占用空间小,而且可以直接用于折线图的 x 轴对齐。契约类用常量把表和字段名集中管理,避免在代码里散落字符串。
public final class BmiContract { private BmiContract() {} public static class RecordEntry implements BaseColumns { public static final String TABLE_NAME = "bmi_records"; public static final String COLUMN_HEIGHT = "height_cm"; public static final String COLUMN_WEIGHT = "weight_kg"; public static final String COLUMN_BMI = "bmi_value"; public static final String COLUMN_CATEGORY = "category"; public static final String COLUMN_TIMESTAMP = "timestamp"; } }实现 BaseColumns 接口可以让 _ID 字段直接有常量可用,Cursor 和 RecyclerView 的 item 标识都会依赖 _ID。category 字段用来存“正常”“超重”这类文本状态,这样查询历史记录时不需要重新计算 BMI,直接在列表上就能展示当时的健康档位。数据库版本号建议定义为常量,后续表结构变更时通过 onUpgrade 做迁移。
SQLiteOpenHelper 的 onCreate 方法里执行建表语句,字段类型用 REAL 存浮点,TEXT 存档位文本,INTEGER 存毫秒时间戳:
public class BmiDbHelper extends SQLiteOpenHelper { private static final String DB_NAME = "bmi_history.db"; private static final int DB_VERSION = 1; public BmiDbHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } @Override public void onCreate(SQLiteDatabase db) { String sql = "CREATE TABLE " + BmiContract.RecordEntry.TABLE_NAME + " (" + BmiContract.RecordEntry._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + BmiContract.RecordEntry.COLUMN_HEIGHT + " REAL, " + BmiContract.RecordEntry.COLUMN_WEIGHT + " REAL, " + BmiContract.RecordEntry.COLUMN_BMI + " REAL, " + BmiContract.RecordEntry.COLUMN_CATEGORY + " TEXT, " + BmiContract.RecordEntry.COLUMN_TIMESTAMP + " INTEGER)"; db.execSQL(sql); } }AUTOINCREMENT 不是必须的,SQLite 对 INTEGER PRIMARY KEY 本身有自增行为,显式声明 AUTOINCREMENT 会额外维护一张 sqlite_sequence 表。对于这种低频写入场景两者差别不大,但项目里留了这层结构,如果你要改造自己的工程,去掉 AUTOINCREMENT 可以让库更干净。
3.2 数据访问层的插入与查询操作
数据访问层把增删改查封装为独立的 Repository 方法,Activity 层不需要接触 SQLiteDatabase 和 Cursor。插入一条记录时,把对象属性组装进 ContentValues,再调用 insert 方法。
public long insertRecord(BmiRecord record) { SQLiteDatabase db = dbHelper.getWritableDatabase(); ContentValues values = new ContentValues(); values.put(BmiContract.RecordEntry.COLUMN_HEIGHT, record.getHeightCm()); values.put(BmiContract.RecordEntry.COLUMN_WEIGHT, record.getWeightKg()); values.put(BmiContract.RecordEntry.COLUMN_BMI, record.getBmiValue()); values.put(BmiContract.RecordEntry.COLUMN_CATEGORY, record.getCategory()); values.put(BmiContract.RecordEntry.COLUMN_TIMESTAMP, record.getTimestamp()); long rowId = db.insert(BmiContract.RecordEntry.TABLE_NAME, null, values); db.close(); return rowId; }查询全部记录时按时间戳升序排列,这样折线图的数据点可以直接按列表顺序映射成 x 轴坐标,不需要在图表层再做一次排序:
public List<BmiRecord> queryAllRecords() { SQLiteDatabase db = dbHelper.getReadableDatabase(); List<BmiRecord> records = new ArrayList<>(); Cursor cursor = db.query( BmiContract.RecordEntry.TABLE_NAME, null, null, null, null, null, BmiContract.RecordEntry.COLUMN_TIMESTAMP + " ASC" ); while (cursor.moveToNext()) { records.add(new BmiRecord(cursor)); } cursor.close(); db.close(); return records; }query 方法的后几个参数依次是 where 条件、where 占位符参数、group by、having、order by。这里 where 组传 null 表示查全表,order by 传 timestamp ASC 保证时间正序。Cursor 遍历完之后必须 close,数据库连接也建议在方法内关闭,避免泄漏。如果后续要支持分页加载,可以在 order by 后面加 LIMIT 20 OFFSET 0,只取最近的 20 条记录。
3.3 原生 SQLite 写法与 Room 的选型边界
这个项目用原生 SQLiteOpenHelper 是合理的:表结构简单、查询场景固定、不涉及表关联,不引入 ORM 框架可以少一层依赖。如果数据表超过三个,或者需要做表间关联,Room 会是更合适的选择。Room 在编译期会校验 SQL 正确性,配合 LiveData 能直接观察数据变化,但对这个小型工具项目来说,拆进来的基础设施成本反而更高。
| 对比维度 | 原生 SQLiteOpenHelper | Room |
|---|---|---|
| 依赖体量 | 无额外依赖 | 需要 kapt/ksp 处理注解 |
| SQL 校验时机 | 运行期报错 | 编译期查错 |
| 与 LiveData 集成 | 需要自己封装 | 原生支持 |
| 数据绑定样板代码 | 手动写 Cursor 遍历 | 自动生成 DAO 实现 |
| 学习成本 | 低 | 中 |
数据层做完后,界面层的取值就简单了。每次计算完成就 insert 一条,进入历史页时 queryAllRecords 拿到完整列表,把这份列表同时交给列表适配器和折线图组件去渲染,这样两个视图的数据源是一致的,不会发生一个更新另一个没更新的问题。
4. 折线图与列表双视图展示历史健康趋势
历史记录需要同时支持列表和折线图查看,这要求数据层返回的记录集合能被两种视图复用。这一章先写列表视图的 Adapter 实现,再接入折线图库,把 x 轴时间、y 轴 BMI 值的映射关系理清楚。
4.1 列表视图与 RecyclerView Adapter 的数据绑定
列表用 RecyclerView 承载,复用标准 ViewHolder 模式。适配器拿到 List 之后,不仅要展示数值,还要把分类文案和 BMI 值对应起来。这部分逻辑放在 Adapter 内部而不是写在 Activity 里,可以让视图逻辑更内聚。
public class BmiRecordAdapter extends RecyclerView.Adapter<BmiRecordAdapter.ViewHolder> { private final List<BmiRecord> records; public BmiRecordAdapter(List<BmiRecord> records) { this.records = records; } @Override public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { View view = LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_bmi_record, parent, false); return new ViewHolder(view); } @Override public void onBindViewHolder(ViewHolder holder, int position) { BmiRecord record = records.get(position); holder.tvBmi.setText(String.valueOf(record.getBmiValue())); holder.tvCategory.setText(record.getCategory()); holder.tvDate.setText(DateUtil.format(record.getTimestamp())); } static class ViewHolder extends RecyclerView.ViewHolder { TextView tvBmi, tvCategory, tvDate; ViewHolder(View itemView) { super(itemView); tvBmi = itemView.findViewById(R.id.tv_bmi); tvCategory = itemView.findViewById(R.id.tv_category); tvDate = itemView.findViewById(R.id.tv_date); } } }onCreateViewHolder 负责加载布局,onBindViewHolder 负责按位置填充数据。如果列表数据量大,把 DateUtil.format 的时间格式化结果缓存起来,避免滚动时频繁创建 SimpleDateFormat 实例。这个项目里历史数据量一般不会太大,但先把这个习惯养起来,后面接真实用户数据时才不会卡。
4.2 MPAndroidChart 折线图接入与数据点配置
Android 自带的 View 体系没有现成的折线图组件,可以用 MPAndroidChart 快速实现。在 app/build.gradle 的 dependencies 块中加入依赖,同步完成后在 XML 布局里声明 LineChart 控件。
implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0'初始化逻辑分三个部分:配置坐标轴、设置数据集、刷新图表。以最近 12 条记录为例:
LineChart chart = findViewById(R.id.chart); List<Entry> entries = new ArrayList<>(); for (int i = 0; i < recentRecords.size(); i++) { float bmiValue = (float) recentRecords.get(i).getBmiValue(); entries.add(new Entry(i, bmiValue)); } LineDataSet dataSet = new LineDataSet(entries, "BMI 变化"); dataSet.setColor(Color.parseColor("#FF7043")); dataSet.setCircleColor(Color.parseColor("#FF7043")); dataSet.setValueTextSize(10f); LineData lineData = new LineData(dataSet); chart.setData(lineData); chart.getXAxis().setPosition(XAxis.XAxisPosition.BOTTOM); chart.getAxisLeft().setAxisMinimum(10f); chart.getAxisRight().setEnabled(false); chart.invalidate();这里用索引 i 作为 x 轴坐标,而不是直接传时间戳,因为折线图的 x 轴本质上是分类轴,时间戳传给 MPAndroidChart 会把数字当作连续的数值轴处理,标签和间距都会错乱。想让 x 轴显示日期,需要用 IAxisValueFormatter 把索引映射成对应的日期字符串。y 轴范围设为 10 起步,保证偏瘦或超重数据点不会贴到图表边缘。需要留意的配置项见下表:
| 配置方法 | 示例参数 | 作用 |
|---|---|---|
| setAxisMinimum | 10f | 设置 y 轴最小值,避免线条贴底 |
| setAxisMaximum | 40f | 设置 y 轴最大值,留出图标顶部空间 |
| setPosition | BOTTOM | 把 x 轴标签放到图表下方 |
| setEnabled | false | 关闭右侧 y 轴,减少视觉干扰 |
| setValueTextSize | 10f | 控制数据点标注字号 |
列表和折线图共用一个 List ,但列表默认展示全部,折线图展示最近 N 条,两者的数据源在 Activity 里各自构造。这样两个视图的展示逻辑互不干扰,后续要改成“图表点击跳转到列表详情某一行”也很方便。
注意:历史数据超过几百条时,建议关闭 LineChart 的动画,并在数据设置完成后调用 chart.notifyDataSetChanged() 再 invalidate(),否则滚动和重绘会互相抢占主线程。
5. 健康提示音、界面交互与 Android Studio 打包部署
健康提示音是这个项目里比较有辨识度的功能点。不同 BMI 档位播放不同音效,本质上是把健康档位枚举映射到音频资源。这一章先把短音效播放的选型说清楚,再讲触发时机设计,最后覆盖 Gradle 打包部署阶段容易遇到的问题。
5.1 SoundPool 与 MediaPlayer 的选型依据
短音效播放不适合用 MediaPlayer。MediaPlayer 有完整的音频引擎状态机,prepare、start、release 都有耗时,连续快速触发时会造成声音重叠和延迟。SoundPool 针对的是短音频的并行播放与低延迟场景,正好匹配这里提示音的需求。初始化时设置最大音流数量为 3,超过这个数量的请求会排队或丢弃,避免用户连续点击计算按钮时音效叠成一团。
SoundPool soundPool; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { SoundPool.Builder builder = new SoundPool.Builder(); builder.setMaxStreams(3); AudioAttributes attrs = new AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION) .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) .build(); builder.setAudioAttributes(attrs); soundPool = builder.build(); } else { soundPool = new SoundPool(3, AudioManager.STREAM_MUSIC, 0); }API 21 及以上用 AudioAttributes 声明声音用途,USAGE_ASSISTANCE_SONIFICATION 表示通知类提示音,系统会对这类音频做音量管理和焦点处理。SDK_INT 小于 21 走旧的构造方法。音效文件在 Activity 的 onCreate 阶段提前 load,播放时直接按 id 触发,不走磁盘 IO。
5.2 不同 BMI 档位的提示音触发时机
提示音应该在计算成功且数据合法的情况下触发,而不是每次点击按钮都播放。计算失败时只给 Toast 提示,保持静默。将档位枚举映射到音频资源 id,这段逻辑和第 2 章里的 HealthLevel.fromBmi 是同一处扩展点。
int soundResId; if (bmi < 18.5) { soundResId = R.raw.sound_thin; } else if (bmi < 24.0) { soundResId = R.raw.sound_normal; } else if (bmi < 28.0) { soundResId = R.raw.sound_overweight; } else { soundResId = R.raw.sound_obese; } soundPool.play(soundResId, 0.8f, 0.8f, 1, 0, 1f);play 方法的六个参数依次是音频 id、左声道音量、右声道音量、优先级、循环次数、播放速率。音量 0.8f 是比较保守的取值,避免提示音吓到用户。优先级传 1 表示中等优先级,在系统音流繁忙时有一定抢占能力;如果不想被打断,可以在播放前检查 AudioManager.isMusicActive 的状态。MediaPlayer 与 SoundPool 的适用边界整理如下:
| 特征 | MediaPlayer | SoundPool |
|---|---|---|
| 适用场景 | 长音频、背景音乐 | 短音效、并发触发 |
| 初始化耗时 | 需要 prepare | 预加载后快速播放 |
| 播放延迟 | 较高 | 低延迟 |
| 同时播放 | 单实例 | 支持多音流 |
5.3 Gradle 构建配置与 APK 打包部署
源码包自带了 gradlew、gradle-wrapper 和 build.gradle,导入 Android Studio 后等待依赖同步即可。需要确认的是 JDK 版本适配:compileSdk 和 targetSdk 如果超过 34,建议用 JDK 17 构建;低于 30 用 JDK 8 或 11 都行。gradle.properties 里设置 jvmargs 可以避免依赖下载时内存不足。
构建时会有几个典型问题:第一,MPAndroidChart 的依赖仓库需要能访问 mavenCentral,v3.1.0 之后的版本官方已经托管到 mavenCentral;第二,签名信息不要直接写进 build.gradle,用 keystore.properties 文件统一管理,并在 .gitignore 里排除;第三,如果项目迁移到了 AndroidX,注意检查依赖中是否有旧的 support 库冲突。
打包成 APK 的命令在项目根目录执行,release 产物默认输出在 app/build/outputs/apk/release/ 下:
./gradlew assembleRelease如果项目没有配置签名,assembleRelease 会生成未签名的 APK,安装前需要额外签名。日常调试直接用 assembleDebug 对应 debug 签名,不需要配置证书。命令行构建和 Android Studio 界面构建的产物没有差异,命令行更适合接入 CI 流程。
6. 边界情况处理与体验优化技巧
最后这一章收敛到两个容易被忽略的细节:极端输入对图表和数据库的影响,以及列表与图表共存时的性能取舍。
6.1 极端数据与数据库升级的兜底策略
用户输入靠近校验边界时,计算本身不会出问题,但折线图会出现异常突出的点,比如 50 厘米、300 公斤算出的 BMI 远超正常范围。处理方式不是缩小输入范围,而是在展示层设置合理的 y 轴上限,比如 setAxisMaximum(60f),超出部分不显示,数据库里保留原始值。数据库升级时,onUpgrade 里要处理旧数据与新表结构不兼容的情况,常见做法是先判断旧版本号,再执行增量 ALTER TABLE,最后更新版本常量。
6.2 历史数据增长后的体验优化
当历史记录数量上升,RecyclerView 的 diff 逻辑和折线图的重绘会成为主要瓶颈。列表页用 ListAdapter 配合 DiffUtil 而不是直接 notifyDataSetChanged,减少整表刷新和闪烁。折线图只取最近 30 条,必要时做抽样绘制。另外,数据点标注在密集时会互相遮盖,可以在 LineDataSet 上关闭 value 显示,或者只在最近 7 个点显示数值。
提示:用户快速多次点击“计算并保存”时,会连续产生多条几乎相同时间的记录并叠加播放提示音。在按钮回调里加一个防抖标志,比如 800ms 内只允许执行一次计算逻辑,可以避免记录爆炸和声音叠放。
计算完成后,校验、落库、折线图刷新、声音播放这四件事的先后顺序建议固定为“校验 → 计算 → 落库 → 刷新视图 → 播放声音”。顺序一旦混乱,就会出现记录已保存但图表还没更新的时序问题。折线图 x 轴日期格式化用 SimpleDateFormat 的 “MM-dd” 模式,年份信息放在列表的日期字段里展示;y 轴范围固定为 10f 到 40f,既能容纳极端值拉伸,又不至于让正常数据被压缩成一条直线。这些阈值统一收口到 Config.java 常量类里,后续调整时只改一处就行。
本文还有配套的精品资源,点击获取