
简介这是一份面向Android初、中级开发者及计算机专业毕业设计的完整文档资料对应基于Android平台的健康饮食APP设计与实现项目。系统采用Java语言、Eclipse开发环境与MVC模式围绕用户日常饮食管理需求实现登录、饮食推荐、营养搭配查询、养生圈等功能适合用于课程设计、毕业设计或移动开发入门学习参考。资源共包含1个docx文档压缩包大小1.83MB文档约含15章结构从设计背景、需求分析到可行性分析、总体结构设计、数据库设计及详细功能模块依次展开并附系统测试方法与测试详情。已有84人学习下载。该文档除给出业务流程与设计原则外还重点呈现了用户登录、饮食推荐、搭配查询等模块的实现思路可作为项目开发时梳理功能架构、撰写设计说明及准备答辩的重要参考。1. 这个健康饮食APP解决的不是记录饮食这么简单用户留不住不是菜谱不够好看而是大多数人打开饮食类App之后不知道该吃什么、吃多少、还差多少。对于一个基于 Android 的健康饮食APP核心设计目标不是做一个菜谱浏览器而是把食物 - 营养 - 今日剩余热量 - 推荐这条数据闭环跑通。用户每录入一顿饭界面要立刻告诉他今天还能吃多少、蛋白质够不够、晚上该补蔬菜还是补主食。下面顺着 Android 开发中更常见的实现路径讲清楚数据库模型怎么设计、热量怎么算、界面上的进度条和列表如何把数据串起来包含可以直接落地的 Room 代码、RecyclerView 片段和发布前的配置注意点。适合正在做课程设计、毕业设计或准备上架的个人开发者和初级 Android 工程师。2. 健康饮食APP的架构设计与技术选型Room MVVM 图表库开始就把技术方向定下来本地数据库用 Room界面架构用 MVVM StateFlow图表用第三方库。这个组合在个人健康类项目里最容易被验证也方便后续改成多人协作项目。2.1 本地数据库为什么选 Room 而不是 SQLiteHelper 或云数据库健康饮食APP的典型场景是用户在自己的手机上维护一份食物库和每日记录。对个人项目而言直接连云端数据库会让登录、网络状态、缓存一致性占掉大量开发时间用 SQLiteHelper 则需要写不少重复模板。Room 把 SQLite 访问收敛到注解和接口上编译期校验 SQL 语句表结构改动用 Migration 管理数据变更能对外暴露 Flow省掉手工搬线程的样板代码。方案学习成本同步能力编译期校验适合场景SQLiteHelper低无无一次性脚本、极简单存取Room中不支持需配合 WorkManager有本地优先的个人健康记录云数据库高强无多端同步、社群功能提示个人开发的健康饮食APP建议本地优先云端延后先把数据模型做稳之后再通过 WorkManager 做增量同步避免一开始就被账号体系拖住。使用 Room 时我一般会在 Android Studio 里直接打开 Database Inspector先看 Entity 建出的表长什么样再调 DAO 返回类型。Retrofit 能省 JSON 解析Room 能省 Cursor 遍历这就是本地数据库选它的核心原因。2.2 MVVM 三层划分与包结构代码分成三层UI 层Activity/Fragment/Adapter、ViewModel 层StateFlow 业务逻辑、数据层Repository 访问 Room。健康饮食APP的录入入口很多手动登记、从搜索列表选择、快捷复制昨天如果都用 Activity 直接写 DAO逻辑会散开。包结构按模块拆com.example.healthydiet/ ├── ui/ │ ├── home/ // 首页进度条、今天的营养余量 │ ├── record/ // 三餐记录和食物搜索 │ └── profile/ // 个人资料、目标设置 ├── viewmodel/ │ ├── TodayViewModel.kt │ └── RecordViewModel.kt ├── data/ │ ├── local/ │ │ ├── FoodDatabase.kt │ │ ├── FoodDao.kt │ │ └── entity/ │ └── repository/ └── di/ViewModel 不直接暴露查询结果而是暴露 StateFlow 旋转屏幕后状态不丢界面只负责 collectLatest 刷新。Activity 里不写业务判断业务全部归 ViewModel网络和数据库归 Repository。这个分层对营养目标计算尤其重要因为计算要在多个界面复用。2.3 图表库选型MPAndroidChart 还是自绘 Canvas健康饮食APP至少会有两个主图表一周热量趋势和三大营养素比例。MPAndroidChart 和 Vico 都有现成的折线图和饼图但项目体积和自定义成本不一样。图表方案集成成本自定义能力包体积MPAndroidChart中文档较旧中约 300KBVico中API 新高较小自绘 Canvas高完全可控无额外依赖如果页面只要展示七日摄入热量用 Vico 或 MPAndroidChart 都行。关键是不要为了一个饼图封装出自定义图表引擎否则后期改样式会很痛苦。直接在 build.gradle 里加依赖dependencies { implementation(androidx.core:core-ktx:1.12.0) implementation(androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0) implementation(androidx.room:room-runtime:2.6.1) kapt(androidx.room:room-compiler:2.6.1) implementation(com.github.PhilJay:MPAndroidChart:v3.1.0) implementation(androidx.fragment:fragment-ktx:1.6.2) }提示Room 对 Kotlin 协程支持从 2.6.0 开始更完整KSP 比 kapt 快在 Android Studio Koala/Jellyfish 及以后版本优先换 KSP。2.4 构建配置的最小可用参数新建 Android 项目时默认模板带一套版本号。健康饮食APP不涉及相机、定位或后台下载minSdk 设为 26 已经能覆盖大量主流设备targetSdk 设为 34 时要注意 Android 14 的隐式 Intent 限制但当前功能不会触发。build.gradle 里还需要显式开启 ViewBindingandroid { namespace com.example.healthydiet compileSdk 34 defaultConfig { applicationId com.example.healthydiet minSdk 26 targetSdk 34 versionCode 1 versionName 1.0.0 } buildFeatures { viewBinding true } }ViewBinding 会把布局 XML 编译成绑定类列表 item、首页 fragment 的绑定代码都省掉 findViewById 的样板。binding 类还能在编译期发现 id 写错的问题比 findViewById 返回空指针提前到编译阶段暴露。这个配置配好后后续所有界面实现都可以用 binding 引用控件代码量会直观减少。3. 数据库设计与热量统计核心实现从食物表到今日剩余数据闭环靠三张表food、diet_record、user_profile。这一章讲清楚表怎么建、查询怎么写、热量目标怎么算。3.1 三张表的关系设计与字段约束先明确关系food 是基础数据diet_record 是用户每次进餐的记录通过 food_id 指向食物user_profile 是单行的身体参数本地版本只存一条。表名关键字段约束/说明foodid, name, calorie, protein, fat, carbohydrate, unit, categorycalorie 按每 100g/100ml 存diet_recordid, food_id, grams, meal_type, record_time, datedate 格式 yyyy-MM-dd冗余字段user_profileuser_id, age, gender, height_cm, weight_kg, activity_level, target_calorie单用户时 user_id 固定为 1注意 diet_record 里冗余 date 字段而不是直接拿 record_time 在 SQL 里执行date()转换。冗余字段可以建索引按日查询就是索引等值查询数据量到几万条也不会卡。date 的值建议在 Repository 层算好用LocalDate.now().toString()生成统一用系统默认时区避免模拟器和真机时区不一致导致今天的数据看不到。3.2 Food 实体与 Record 实体代码Food 实体类入下Entity(tableName food) data class Food( PrimaryKey(autoGenerate true) val id: Long 0L, ColumnInfo(name name) val name: String, ColumnInfo(name calorie) val calorie: Double, ColumnInfo(name protein) val protein: Double, ColumnInfo(name fat) val fat: Double, ColumnInfo(name carbohydrate) val carbohydrate: Double, ColumnInfo(name unit) val unit: String, ColumnInfo(name category) val category: String )Room 在编译期校验 SQL 和表名字段名不一致时用 ColumnInfo 显式声明。calorie 表示每 100 克基础热量不是用户实际吃下去的热量。如果界面需要按“一碗饭 200g”展示就在换算层做不要改基础数据。unit 字段会存在“g、ml、份”三种建议再把base_amount一起存进去例如牛奶按 100ml 存换算时只乘一个比例系数。3.3 Room DAO 实现按日汇总摄入量DAO 中把当天的所有记录按食物 join 后求和Dao interface DietRecordDao { Query( SELECT COALESCE(SUM(f.calorie * r.grams / 100.0), 0) FROM diet_record r INNER JOIN food f ON r.food_id f.id WHERE r.date :date ) fun getTotalCalorie(date: String): FlowDouble Query( SELECT f.name, r.grams, f.unit, f.calorie FROM diet_record r INNER JOIN food f ON r.food_id f.id WHERE r.date :date ORDER BY r.record_time DESC ) fun getRecordsWithFood(date: String): FlowListRecordWithFood }两个查询的关键逻辑COALESCE 把没有记录时的空值转成 0避免首页空指针f.calorie * r.grams / 100.0完成每 100 克到实际克数的热量换算ORDER BY r.record_time DESC保证最新记录排在最前。RecordWithFood 是一个普通数据类不需要 Entity它只表示一次 join 查询的结果。在增删记录时只要 DAO 返回 FlowRoom 会在对应表变化时自动重新推送数据UI 不需要手动调用刷新。天气应用删除一条记录后首页剩余热量、列表、图表三个界面都依赖同一份数据Flow 推送能避免各界面状态不一致的问题。3.4 今日剩余热量与目标热量计算这里不能拍脑袋需要一套可解释的计算逻辑。常见做法是先用 Mifflin-St Jeor 公式计算基础代谢率BMR再按活动系数算维持热量最后加减热量差得到每日目标。代码单独抽成一个对象方便单元测试。object NutritionCalculator { fun calculateBMR( gender: String, weightKg: Double, heightCm: Double, age: Int ): Double { val base 10.0 * weightKg 6.25 * heightCm - 5.0 * age return if (gender 男) base 5 else base - 161 } fun calculateTargetCalorie( bmr: Double, activityLevel: String, adjust: Double -300.0 ): Double bmr * activityFactor(activityLevel) adjust private fun activityFactor(level: String): Double when (level) { 轻度活动 - 1.375 中度活动 - 1.55 重度活动 - 1.725 else - 1.2 } }活动系数其实是查表过程从activity_factor表读取也行。但实际验证的时候我一般把五个档位写进常量而非让用户在设置页手填一个数字。因为普通人并不知道自己属于 1.375 还是 1.55给“久坐、轻度活动、中度活动”这种档位描述比给数值更不容易出错。剩余热量公式定义为剩余热量 目标热量 - 当日已摄入 运动消耗运动这一项不建议在第一版里做。原因清楚本地 App 很难精确获得实时消耗接 Health Connect 又会引入额外权限和数据校准成本。先把摄入一条一条记准比加一个误差很大的运动估算更有价值。3.5 种子数据管理每次安装都有食物库空数据库没法演示。常见做法是在 assets 里放一个food_seed.json首次启动时用 RoomDatabase.Callback 插入而不是在 MainActivity 的 onCreate 里同步插入。val db Room.databaseBuilder(context, FoodDatabase::class.java, healthy_diet.db) .addCallback(object : Callback() { override fun onCreate(db: SupportSQLiteDatabase) { super.onCreate(db) CoroutineScope(Dispatchers.IO).launch { // 用 assets 里的 JSON 解析后通过 DAO 批量插入 } } }) .build()Callback 的 onCreate 只在第一次建库时执行后续版本升级不会重复插入。批量插入建议用Insert的重载方法传入 List 不要一条一条 insert否则首启在低端机上会有明显卡顿。4. Android 界面实现RecyclerView 列表、进度条和录入防错数据层和服务层搭建好之后界面层要把 Flow 拉出来的数据变成用户可操作的反馈。这一章拆三个高频页面的实现最后一个小节处理底部导航。4.1 RecyclerView 列表展示今天吃过什么记录列表用 RecyclerView 最合适。Adapter 只负责把 RecordWithFood 的每一项绑定到 item layout删除动作通过回调交给 ViewModel。class RecordAdapter( private val onDelete: (Long) - Unit ) : ListAdapterRecordWithFood, RecordAdapter.VH(DiffCallback) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { val binding ItemRecordBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return VH(binding) } override fun onBindViewHolder(holder: VH, position: Int) { val item getItem(position) holder.binding.foodName.text item.food.name holder.binding.foodAmount.text ${item.grams} ${item.food.unit} holder.binding.calorie.text %d 千卡.format( (item.food.calorie * item.grams / 100.0).toInt() ) holder.binding.ivDelete.setOnClickListener { onDelete(item.id) } } companion object { val DiffCallback object : DiffUtil.ItemCallbackRecordWithFood() { override fun areItemsTheSame(oldItem: RecordWithFood, newItem: RecordWithFood) oldItem.id newItem.id override fun areContentsTheSame(oldItem: RecordWithFood, newItem: RecordWithFood) oldItem newItem } } }ListAdapter 配合 DiffUtil 可以避免每次刷新调用 notifyDataSetChanged 造成的列表闪烁。换算显示在 bind 里做因为每个 item 要显示实际摄入热量和 3.3 节数据库层的 SUM 各自独立不能共用一段代码。4.2 首页热量进度条用 ProgressBar 还是自定义 View首页的今日热量进度最常用水平 ProgressBar 加一个文字提示。XML 中设置 max 和 progress代码里用页面状态更新。ProgressBar android:idid/calorieProgress style?android:attr/progressBarStyleHorizontal android:layout_widthmatch_parent android:layout_height12dp android:max2200 android:progress650 android:progressTint#4CAF50 android:backgroundTint#E0E0E0 android:indeterminatefalse /viewModel.todayState.collectLatest { state - binding.calorieProgress.max state.targetCalorie.toInt() binding.calorieProgress.progress state.consumedCalorie.toInt() val remain state.targetCalorie - state.consumedCalorie binding.calorieRemain.text if (remain 0) 还能吃 ${remain.toInt()} 千卡 else 已超出 ${(-remain).toInt()} 千卡 }提示进度条表达已摄入剩余用文字表达两种信息不要混在同一个控件上否则用户要把进度条反向看体验会很差。如果要展示蛋白质摄入达标率建议在 ViewModel 里算出百分比UI 只拿 0-100 的整数。把业务公式放到 XML 或 Fragment 中第三个界面要用时又复制一遍公式后续改动成本会翻倍。4.3 录入页表单校验与重复提交防护录入页面的常见问题不是数据库不会写而是用户输入异常的克数导致脏数据。校验放在 ViewModel 的入口方法中单元测试时可以只测 ViewModel。校验场景提示语额外边界克数为空请输入克数必须能被 toDouble 解析克数 0克数必须大于 0最小有效值 1克数 2000一次最多记录 2000g上限 2000餐次为空请选择餐次默认午餐防重复提交的常用写法是给 ViewModel 加一个状态锁private val _isSaving MutableStateFlow(false) fun saveRecord() { if (_isSaving.value) return _isSaving.value true viewModelScope.launch { repository.insertDietRecord(formToRecord()) _isSaving.value false } }如果某个新增入口需要网络请求可以把防重复锁放在 Repository 内部避免让 UI 等待锁释放。健康饮食APP当前版本完全本地存储所以锁放在 ViewModel 足够。4.4 用 BottomNavigation 组织三个一级页面首页、记录、个人资料三个页面用 BottomNavigationView 承载配合三个 Fragment。布局结构LinearLayout ... androidx.fragment.app.FragmentContainerView android:idid/nav_host_fragment android:layout_widthmatch_parent android:layout_height0dp android:layout_weight1 / com.google.android.material.bottomnavigation.BottomNavigationView android:idid/bottomNav android:layout_widthmatch_parent android:layout_heightwrap_content app:menumenu/bottom_nav_menu / /LinearLayout底部导航加上后要注意 Fragment 切换时不要每次都重建。推荐用 Navigation Component 的replace逻辑或者手动用 show/hide 保留状态。首页的 StateFlow 数据在重新可见时会重新读取所以列表跳动问题主要出现在旋转屏幕时用 ViewModel 缓存 TodayUiState 可以解决一部分。5. 健康饮食APP发布前的回归检查数据一致性、混淆与日志排查发布前集中处理三类问题健康饮食APP的崩溃率会明显降低。5.1 数据一致性重复点击与数据库事务录入页快速点击保存可能插入两条相同记录。用 isSaving 防止重复点击之外也可以把插入记录和更新目标热量等组合操作包进db.withTransaction { }确保要么全部成功要么全部回滚。Room 的 Flow 会在事务提交后自动推送新结果不需要手动刷新。另一个数据一致性问题是删除记录后的列表索引错乱。ListAdapter 的 DiffCallback 中 areItemsTheSame 使用 id 比较删除的是旧 id新列表去掉该项后RecyclerView 不会复用错误的 ViewHolder。5.2 混淆配置里容易漏掉的三处release 构建开启 minifyEnabled 后常见问题有三类Room 实体被混淆图表库控件找不到Kotlin 协程报 supervisor 异常。常规做法是把实体类和图表库全部 keep-keep class com.example.healthydiet.data.local.entity.** { *; } -keep class com.github.mikephil.charting.** { *; } -keep class kotlinx.coroutines.** { *; }前面两行直接解决问题。第三行在现代协程库中已自带规则不需要手动 keep如果你发现 release 包里协程崩溃先从依赖版本和 R8 full mode 入手不要一上来就关混淆。5.3 日志开关、崩溃堆栈与回归清单日志全部走 Timber在 Application 里判断 BuildConfig.DEBUGif (BuildConfig.DEBUG) { Timber.plant(DebugTree()) } else { Timber.plant(ReleaseTree()) // 只记录 WTF 级别 }最后验证时准备一份固定饮食清单例如早餐 100g 燕麦、午餐 200g 鸡胸肉、晚餐 150g 米饭记录后和手工计算的热量总量对比。我一般把这组数据写成 AndroidTest 里的参数化测试每次跑测试机时自动验证 DAO 和 NutritionCalculator 的准确性改版后能快速发现回归问题。本文还有配套的精品资源点击获取