拓冰建站拓冰建站
首页 / 资讯中心 / 正文

健康饮食安卓App实战:Room数据库、Compose界面与卡路里精度

简介这是一份基于安卓设计的健康饮食管理应用项目工程适合高校计算机相关专业学生用于毕业设计、课程设计、大作业、工程实训或学科竞赛准备。项目包含完整源码、工程配置与说明经过严格测试运行可复现原项目设计报告也能在此基础上借鉴展开。压缩包共304个文件以Kotlin源码、XML布局和PNG图片资源为主体其中kt处理业务逻辑、xml负责界面与配置、png/jpg保存设计素材另含MP4演示视频、音频及Gradle构建文件整体约101.52MB。项目采用Android Gradle工程组织目录结构清晰具备完整构建配置和运行脚本便于导入调试及二次开发。目前已有80人学习下载结合演示视频与界面素材可快速理解健康饮食管理各功能模块的实现方式也可作为课程设计和毕业答辩的高分参考。1. 健康饮食管理App在安卓上的工程化起点在很多安卓开发练习和期末大作业里健康饮食管理是出现频率最高的综合型题目它没有复杂的联网业务却要求把本地数据库、界面状态、日期查询和构建打包整条链路走通。真正动手后会发现卡人的不是界面长什么样而是几件容易被带过的事——食物营养成分怎么入库、每天的热量按原料算还是按熟食算、压缩包交到别人手上之后能不能在安卓Studio里一次跑通。这篇文章按一个可交付原型来讲先把数据表和热量口径定死再用Jetpack Compose写交互界面最后走一遍构建打包并在结尾点出三个会把卡路里算歪的隐蔽坑。适合要交期末项目、准备安卓面试题或者想给团队快速做演示原型的开发者。2. 健康饮食App的数据层Room表结构、DAO与热量口径2.1 先定边界这类App要管住哪四类数据需求到手先做数据边界划分。健康饮食管理App要管理的无非四类数据食物本身名称、每百克热量、三大营养素、用户吃下去的流水记录哪一天哪一餐、哪种食物、多少克、每日营养目标热量上限和营养素配比、体重之类的衍生记录。前三类属于高频读写衍生记录是低频累积混进同一张表会让查询越写越臃肿。我一般把主数据和流水分开Food作为主数据表只存食材营养常量MealRecord作为流水表只存“吃了什么、吃多少”的事件Profile单独放当前生效的每日目标。这样所有统计查询都能围绕外键和日期展开不会出现一张宽表里字段越堆越多的局面。目标数据的更新频率远低于三餐记录同一时刻只有一份有效配置单独建表也方便做重置和覆盖。2.2 Room的实体类与DAO用最小字段撑起热量汇总Room是安卓官方的SQLite封装比直接写SQLiteOpenHelper省去大量样板代码DAO返回的Flow还能直接对接Compose的状态流。下面这张Food表的字段设计去掉所有暂时用不到的列只保留计算一次完整膳食热量所必需的项。Entity(tableName food) data class FoodEntity( PrimaryKey(autoGenerate true) val id: Long 0, val name: String, // 食材名比如“鸡胸肉” val kcalPer100g: Double, // 每百克可食部千卡数 val proteinPer100g: Double, // 每百克蛋白质克数 val fatPer100g: Double, // 每百克脂肪克数 val carbPer100g: Double, // 每百克碳水化合物克数 val edibleRatio: Double 1.0 // 可食部比例1.0表示全可食 )字段名里最容易忽略的是edibleRatio。带骨、带皮的食材如果按毛重计算热量偏差能到20%以上所以它必须作为入库时的强制字段而不是等查询时临时处理。每百克热量也统一约定为“每百克可食部”的数值从源头避免同一个食材在库里出现两套热量口径。Dao interface MealDao { Query( SELECT COALESCE(SUM(f.kcalPer100g * r.grams / 100.0 * f.edibleRatio), 0.0) FROM meal_record r INNER JOIN food f ON r.foodId f.id WHERE r.date :date ) suspend fun getDayKcal(date: String): Double }这条SQL把流水表和主数据表join起来一天的热量合计全部交给数据库算App层拿到手就能直接展示。COALESCE的作用是当天没有记录时返回0而不是null省掉一层空指针判断。这里把grams定成用户实际吃进口的重量单位统一为克date使用“yyyy-MM-dd”字符串而非毫秒时间戳按天分组时不需要再做时区换算。提示grams字段在数据库层建议用REAL而不是INTEGER。虽然多数输入是整数克但后续要支持0.5克或净含量小数时不会被迫改动表结构。如果用纯SQLite实现同样逻辑需要自己管理helper、游标和类型转换而Room在编译期生成实现DAO的返回值还能直接暴露给ViewModel做状态流的数据源。新项目没有历史包袱的情况下没必要退回老方案。2.3 热量计算的三条统一口径第一所有入库数值一律按“每百克可食部”归一化。第二流水表只存吃进口的克数不存计算后的热量这样既能统计每日总热量也能反查蛋白质等营养素明细。第三热量单位统一用千卡很多食物成分表用的是千焦换算关系是1 kcal 4.184 kJ如果源数据不换算直接入库单条记录偏差不大三餐叠加后数据会完全失真。营养素克数本身不参与热量计算只用于目标页的进度条展示但同样要求入库前完成单位归一。下面这张表列出入库前必须核对的口径字段入库单位常见错误口径修正说明kcalPer100g千卡/百克千焦/百克源表为千焦时先除以4.184grams克两、份、碗记录页只允许输入克避免估算edibleRatio0.0~1.0浮点百分比整数85%应存0.85而非85dateyyyy-MM-dd字符串毫秒时间戳字符串按天分组更直接这里补一个实践细节Food表里不要混入“汉堡”“盖浇饭”这类组合菜品。复合食物很难用三大营养素精确描述强行入库会导致所有统计失真。常见的做法是Food表只存单一食材组合餐由用户在记录页用多行记录表达。初期是麻烦一点但数据可信度比省几次点击重要得多。3. Jetpack Compose的实现记录界面、状态流与热量进度3.1 为什么这类记录页面适合用Compose而不是XMLCompose是当前安卓Studio新建项目的默认UI方案。对健康饮食App这种页面数量少、局部刷新频繁的界面它的优势很明显热量条、营养素占比强依赖数据状态用声明式组合函数描述UI后状态变化会自动触发重组不需要手写findViewById和Adapter。跟XML写法相比同样的记录页大概能省三分之一样板代码。页面状态我习惯用ViewModel的StateFlow驱动避免Composable直接持有数据库引用。整体数据流是Room DAO返回FlowRepository做一层轻转换ViewModel收集后暴露StateFlowComposable通过collectAsStateWithLifecycle订阅。这个链条不复杂但每一环职责单一后面加搜索、加图表都不需要重构。3.2 记录一餐热量的最小可运行代码下面这个简化版记录区包含“选择食物、输入克数、点击保存”三个动作。食物选择器只写了最基础的版本完整项目里需要换成带搜索的弹窗。Composable fun MealRecordSection( vm: MealViewModel, modifier: Modifier Modifier ) { val uiState by vm.uiState.collectAsStateWithLifecycle() var grams by remember { mutableStateOf() } Column(modifier modifier.padding(16.dp)) { ExposedDropdownMenuBox( expanded uiState.foodPickerExpanded, onExpandedChange { vm.toggleFoodPicker() } ) { OutlinedTextField( value uiState.selectedFood?.name ?: 请选择食物, onValueChange {}, readOnly true, trailingIcon { ExposedDropdownMenuDefaults.TrailingIcon(expanded uiState.foodPickerExpanded) }, modifier Modifier.menuAnchor().fillMaxWidth() ) ExposedDropdownMenu( expanded uiState.foodPickerExpanded, onDismissRequest { vm.closeFoodPicker() } ) { uiState.foodList.forEach { food - DropdownMenuItem( text { Text(${food.name}${food.kcalPer100g}kcal/100g) }, onClick { vm.selectFood(food); grams } ) } } } OutlinedTextField( value grams, onValueChange { grams it.filter(Char::isDigit) }, label { Text(食用克数) }, keyboardOptions KeyboardOptions(keyboardType KeyboardType.Number), modifier Modifier.fillMaxWidth() ) Button( onClick { val food uiState.selectedFood val g grams.toIntOrNull() ?: 0 if (food ! null g 0) vm.addRecord(food.id, g) }, enabled uiState.selectedFood ! null (grams.toIntOrNull() ?: 0) 0, modifier Modifier.fillMaxWidth() ) { Text(保存这一餐) } } }这段代码有两个值得细看的参数。readOnly true让食材框只能从下拉菜单选择不手输名称避免Food表里长出大量拼写不一致的重复数据。克数输入框用filter(Char::isDigit)过滤非数字字符配合Number键盘在多数输入法下不会再出现负数和字母。保存按钮的enabled条件同时检查食材和克数从入口挡掉无效提交。下拉菜单的展开状态放在uiState而不是Composable内部这是有意为之。后续要做埋点、要跨页面同步展开状态甚至要做“最近常用食材”置顶都不需要回改UI层只更新ViewModel里的状态即可。3.3 ViewModel与Repository数据从点击到数据库的路径UI层不直接拿DAO是这类App的基本纪律。Repository这一层在小项目里看着多余一旦要给记录页加“本周热量曲线”加“目标完成率”聚合逻辑放进Repository就能被多个页面复用。下面这个最小实现已经能跑通“点保存→写Room→刷新当天列表”的闭环。class MealRepository(private val dao: MealDao) { fun todayRecords() dao.getRecordsByDate(LocalDate.now().toString()) fun dayKcal(date: LocalDate): FlowDouble dao.getDayKcal(date.toString()) suspend fun addRecord(foodId: Long, grams: Int) { dao.insertRecord(MealRecordEntity(0, foodId, LocalDate.now().toString(), grams)) } } class MealViewModel(private val repo: MealRepository) : ViewModel() { private val _uiState MutableStateFlow(UiState()) val uiState: StateFlowUiState _uiState.asStateFlow() init { viewModelScope.launch { repo.todayRecords().collect { records - _uiState.update { it.copy(todayList records) } } } } fun addRecord(foodId: Long, grams: Int) { viewModelScope.launch { repo.addRecord(foodId, grams) } } }两个细节需要说明。第一ViewModel持有的是Repository而不是DAO后续如果要把数据源从本地Room换成本地网络缓存改动被限制在Repository一个类里。第二从Flow收集到的todayList直接放进UiStateCompose在列表变化时自动重组不需要再手动刷新Adapter。addRecord是suspend函数在viewModelScope里执行它不返回结果因为Room的insert成功后会自动触发Flow发出新列表。3.4 今日热量目标与营养素进度条的联动主界面在记录区上方通常会放一个热量进度条。这个位置最容易写错的逻辑是直接用“已摄入/目标热量”算百分比漏掉运动消耗。很多运动App处理每日热量的常见做法是val remaining (dailyTargetKcal exerciseBurnKcal - intakeKcal) .coerceAtLeast(0)exerciseBurnKcal来自运动模块没有接入时就写死为0。coerceAtLeast(0)把剩余热量限制在非负区间否则早餐吃超了午餐和晚餐看到负数会很影响使用心情。剩余比例按三档切换进度条颜色remaining / dailyTarget进度条颜色提示文案大于40%绿色热量余量充足20%~40%橙色晚餐建议清淡小于20%红色已接近今日上限三档阈值写死在when表达式里即可。要注意的是dailyTargetKcal应该从Profile表读取而不是定义成全局常量。不同用户的减脂目标和维持目标差出几百千卡一旦写死后期加用户配置时就要回到UI层逐处找硬编码。4. 构建与发布从安卓Studio到可复现的zip交付4.1 Gradle配置锁定一套能过编译的版本组合拿到源码包后最常见的问题就是版本对不上。Gradle、AGP、Kotlin、compileSdk这四个版本互相有兼容约束单独升哪一个都可能报错。下面这组版本组合能覆盖当前绝大多数健康饮食App的编译需求适合作为起点。// 项目级 build.gradle.kts plugins { id(com.android.application) version 8.2.2 apply false id(org.jetbrains.kotlin.android) version 1.9.22 apply false } // 模块级 build.gradle.kts android { namespace com.example.healthmeal compileSdk 34 defaultConfig { applicationId com.example.healthmeal minSdk 24 targetSdk 34 versionCode 1 versionName 1.0.0 } } dependencies { implementation(androidx.core:core-ktx:1.12.0) implementation(androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0) implementation(androidx.room:room-runtime:2.6.1) kapt(androidx.room:room-compiler:2.6.1) }minSdk 24对应安卓7.0能覆盖绝大多数存量设备如果只是内部演示原型也可以抬到26以上减少兼容代码。kapt那行是Room必需的注解处理器如果项目切换到Kotlin 2.x建议换成KSP否则编译期会遇到symbol processing相关报错。compileSdk、targetSdk和依赖版本没有绝对绑定关系但保持三者都接近当前稳定版本能少踩很多资源加载不出来的坑。4.2 签名与Release包别把Debug包当交付物打包之前需要先生成签名文件没有签名的包只能装进模拟器真机安装会被包管理器直接拦截。生成签名用一条keytool命令完成keytool -genkeypair -v \ -keystore healthmeal.jks \ -alias healthmeal \ -keyalg RSA -keysize 2048 -validity 10000命令执行过程中会提示输入密钥库口令和组织信息这些字段不能留空。生成的healthmeal.jks要放到项目外目录绝对不能压进zip。签名口令建议单独写在signing.properties并加入.gitignore不要直接写进build.gradle。构建时选择Build Generate Signed Bundle or APK如果只是组内演示选APK即可后续要上架安卓应用市场则需要改出AAB格式并处理分屏和自适应图标相关的资源。4.2.1 签名文件与混淆开关这个量级的App不建议开启代码混淆。Release构建类型里把minifyEnabled设为false能省掉proguard规则配置也保住运行时崩溃堆栈的可读性。真机测试时如果堆栈全变成a.b.c排错成本会高很多对一个以演示和教学为主要目标的zip包来说得不偿失。4.2.2 按可运行标准检查zip内容压缩包里不应该出现只适合本机的文件。打包之前检查三个地方项目里有没有残留的local.properties里面带有本机SDK路径、build目录是否还在、.gradle缓存是否被一并压了进去。这些文件会直接导致接收方同步失败甚至覆盖对方的本地配置。4.3 zip交付清单解压后30秒内能跑起来标题里的.zip代表了这种项目的交付形态。一个完整的源码包至少要包含下面这些内容接收方才能在30秒内开始验证路径内容必须存在的原因app/src/main/java/Kotlin源码没有源码则zip失去意义app/src/main/res/资源文件Compose项目也需要图标和主题app/build.gradle.kts模块级构建配置依赖版本在这里声明README.md环境说明写明安卓Studio和JDK版本signing/healthmeal.jks签名文件可选没有签名只能接收方自己生成README里只写三句话安卓Studio版本、JDK版本、打开方式File Open选中项目根目录等待Gradle Sync完成。把zip发出去之前我的习惯是在干净目录里解压一遍确认项目里没有build/和.gradle/残留再重新压缩。这样别人拿到手时是一个不携带任何本机绝对路径的干净源码包。5. 健康饮食App的卡路里精度3个隐蔽排错点5.1 千焦当成千卡直接入库食物成分表里的热量单位经常混用1 kcal 4.184 kJ这个换算关系在从不同网站抄数据时特别容易被漏掉。症状是某几类食材的热量比线下查询结果普遍高四倍左右而果汁、米饭这类单位一致的食材看起来正常。排查时在食物列表页临时打印kcalPer100g原始值和权威成分表抽样对照如果某类食材系统性高了约4.18倍直接对整表做批量除法修正即可。5.2 可食部比例没有参与计算用户记录鸡腿时按毛重输入200克食物表里存的是每百克可食部的热量两者不在同一称重口径下每天算出来的热量就会少掉一截。修正分两步参照食物成分表的可食部列把带骨食材的edibleRatio设为0.55到0.65同时在下拉菜单的食材名上加备注比如“鸡腿带骨”提醒用户按市场称重输入毛重。缺少第二步用户永远不知道自己要输入什么。5.3 数据库升级时把旧记录弄丢App发版后Food表新增字段只改了Entity却没写Migration结果用户覆盖安装后直接崩溃。用Room做数据库升级必须显式声明迁移逻辑val MIGRATION_1_2 object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL(ALTER TABLE food ADD COLUMN edibleRatio REAL NOT NULL DEFAULT 1.0) } }写这段SQL时要注意SQLite的参数限制新增字段若声明为NOT NULL必须同时提供默认值。验证迁移是否正确我习惯在一台装了旧版本APK的模拟器里先手动录入三天数据再覆盖安装新版本检查历史记录是否完整、日期排序是否正常。如果旧数据一条不丢、热量汇总结果不变这份迁移才算真正通过这时候重新打zip交付出去才靠谱。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门