Android儿童成长APP开发实践:从Room数据模型到疫苗提醒 📅 发布时间:2026/9/16 14:40:39 👁 浏览次数: 简介这是一份面向Android学习者与毕业设计学生的儿童成长APP完整项目工程围绕亲子互动场景设计了任务日历、每日任务、专家推荐、家庭分享与奖励兑换等核心模块可帮助读者理解多模块APP从功能划分到数据交互的实现思路。项目压缩包共包含1151个文件整体大小仅15.23MB其中以java源文件243个、xml布局与配置214个、class编译文件350个、png/jpg界面资源269个为主同时带有jar依赖库与sql数据库脚本便于直接导入Android Studio进行编译、调试和二次开发。目前已有171人学习下载适合正在完成毕业设计、课设作品或想系统梳理Android项目结构的中初级开发者参考。通过阅读任务汇总编辑、奖励小红花、分享积分等奖惩逻辑以及界面间的跳转和数据处理能够较快掌握完整APP的搭建流程与常见设计模式可直接作为项目模板扩展使用。1. 从“记一笔”到“看得懂”Android 儿童成长 APP 到底该做什么孩子出生后的头三年,家长手机里最不缺的就是三种东西打疫苗的预约单、体检本上的身高体重数字、以及相册里数不清的照片。但预约单会丢体检本上的字迹会淡身高体重的记录散落在不同时间点等到儿保医生问“半年前身高多少”,很少有人能张口答上来。儿童成长 APP 解决的就是这件事把线下零散的健康记录变成一个在 Android 手机上可以随时查询、自动提醒、能画成曲线的结构化数据资产。这个标题里的“设计”不只是界面好看更多是数据模型的边界——周岁的宝宝身高跨度可以从 68 到 82 公分男孩女孩的生长曲线标准完全不同“实现”也不是写个增删改查的列表页而要处理单位切换、月龄换算、疫苗间隔天数计算这种全是细节的业务逻辑。适合读这篇的是准备拿 Spring Boot 写过一次接口、但还没完整做过一个带数据库和后台任务的 Android 应用的开发者。接下来我会按做这个标题时最常见的可靠方案来讲从架构选型、Room 数据层设计、核心业务模块实现到最后的图表优化与上架排错。2. 技术选型与工程架构Android 儿童成长 APP 的项目骨架怎么搭2.1 为什么选 Kotlin Jetpack Compose 而不是 Java XML现在新建一个 Android 项目Android Studio 默认模板已经是 Kotlin 和 Compose。Kotlin 对空指针的管控、协程对异步任务的处理在这个 APP 里带来的直接好处是疫苗提醒的定时任务、Room 数据库的读写、图表数据的计算都可以用suspend函数串起来不会出现回调地狱。Compose 则是声明式 UI成长记录页面的“身高/体重/头围”三个输入框和下方的历史列表状态驱动刷新比 XML 的findViewByIdnotifyDataSetChanged少写一半代码。有些对旧项目有感情的同学会坚持 XML ViewBinding。我的建议是如果你是 2024 年之后才动手直接用 Compose。Android Studio 里设置中文界面的教程满天飞说明新入场的开发者越来越多而 Compose 的学习曲线并没有传说中那么陡。真正要花时间的是理解State和remember否则 LazyColumn 列表会频繁重组输入框会丢焦点。2.2 整体架构MVVM 配合 Repository 模式把数据来源藏起来儿童成长 APP 的核心数据是孩子自己产生的身高、体重、头围、疫苗记录这些数据短期不需要云同步初期版本完全可以只用本地 Room 数据库。但架构上要预留一个 Repository 层将来要加“家庭成员多端共享”或“导出 PDF 成长报告”时不需要动 ViewModel。app/src/main/java/com/example/growth/ ├── data/ │ ├── local/ │ │ ├── entity/GrowthRecord.kt │ │ ├── dao/GrowthRecordDao.kt │ │ └── AppDatabase.kt │ └── repository/GrowthRepository.kt ├── ui/ │ ├── home/HomeScreen.kt │ ├── record/RecordScreen.kt │ └── chart/ChartScreen.kt ├── viewmodel/ │ ├── HomeViewModel.kt │ └── RecordViewModel.kt ├── notification/ │ ├── VaccineReminderWorker.kt │ └── ReminderScheduler.kt └── MainActivity.kt这个包结构遵循 MVVM 的分层Activity/Fragment 只负责承载 Compose 页面ViewModel 持有 UI 状态Repository 是 ViewModel 和 Room 之间的唯一通道。好处是业务逻辑可以被测试——比如计算“下次疫苗接种日期”的纯函数不需要启动模拟器就能用 JUnit 验证。2.2.1 Compose 页面里禁用 ViewModel 直接用 LiveData 的写法常见做法是采用collectAsStateWithLifecycle()来收集 Flow。以下是一个最小可用的 ViewModel 写法class HomeViewModel( private val repository: GrowthRepository ) : ViewModel() { val recentRecords: StateFlowListGrowthRecord repository.recentRecords().stateIn( scope viewModelScope, started SharingStarted.WhileSubscribed(5000), initialValue emptyList() ) }WhileSubscribed(5000)的含义是当页面在后台或不可见时上游 Flow 会在 5 秒后停止收集避免不必要的数据库查询。stateIn把冷流变成热流Compose 页面可以直接collectAsStateWithLifecycle()拿到数据。2.3 图表库选型用 Vico 还是 MPAndroidChart画成长曲线是这个 APP 的一个关键功能不能拿 Canvas 手写。MPAndroidChart 虽然老牌、文档全但维护状态一般Compose 下需要套一层AndroidView。Vico 是纯 Compose 实现也支持CartesianChartHost直接嵌入。我的建议如果做 0-3 岁 WHO 生长曲线标准线叠加Vico 更顺手因为它的rememberCartesianChart可以方便地叠加多个CartesianChartLayer。标准线和用户数据点各自成层标注、网格、基准线都更容易定制。val chart rememberCartesianChart( rememberCartesianChartModel( lineSeries listOf( LineSeries( series listOf(standardData), lineColor Color.Gray ), LineSeries( series listOf(userData), lineColor Color.Blue, point Point(fill PointFill.Solid(Color.Red)) ) ) ) )LineSeries就是一条生长曲线两条曲线叠在一起家长一眼就能看出自己的孩子处在同龄人的什么百分位。3. Room 数据层设计身高体重疫苗记录怎么建模才能不乱3.1 实体设计GrowthRecord 和 VaccineRecord 两张表就够了儿童成长记录按记录类型分但字段高度相似。不要建成“身高表”“体重表”“头围表”三张表否则查询时要UNION或多次 join。一张表加type字段即可Entity(tableName growth_record) data class GrowthRecord( PrimaryKey(autoGenerate true) val id: Long 0, val childId: Long, val type: String, // height / weight / head_circumference val value: Float, // 统一存原始数值单位由 child 表决定 val recordDate: Long, // epoch day便于按天排序 val note: String? null ) Entity(tableName vaccine_record) data class VaccineRecord( PrimaryKey(autoGenerate true) val id: Long 0, val vaccineName: String, val doseNumber: Int, // 第几剂 val scheduledDate: Long, val completedDate: Long? null, val remindDaysBefore: Int 3 )这里有几个细节值得说明value是Float而不是String是为了后续绘制图表时直接传给LineSeries不需要在界面层做类型转换recordDate用 epoch day即LocalDate.toEpochDay()返回的 Long而不是存字符串是为了在ORDER BY recordDate DESC时能按数值大小排序而不是按字典序排——字符串 2024-2-2 会排在 2024-10-1 前面但那不是我们想要的。childId是为了多子女家庭预留的哪怕现在只做一个孩子也不要省掉这个字段。3.1.1 为什么不在表里直接存年龄/月龄月龄是recordDate减去child.birthDate得到的。如果直接存“月龄 36”那么当用户发现自己填错生日、改过来之后所有历史记录的月龄就全错了。正确的做法是只存孩子生日所有需要月龄的地方在查询时先计算好再按需返回。Entity(tableName child) data class Child( PrimaryKey(autoGenerate true) val id: Long 0, val name: String, val gender: String, // male/female val birthDate: Long, // epoch day val unitPref: String metric // metric / imperial多数场景用不上但先留着 )3.2 DAO 和数据库升级用 Flow 做响应式查询避免手动刷新Dao interface GrowthRecordDao { Query(SELECT * FROM growth_record WHERE childId :childId ORDER BY recordDate DESC, id DESC) fun observeGrowthRecords(childId: Long): FlowListGrowthRecord Query(SELECT * FROM growth_record WHERE childId :childId AND type :type ORDER BY recordDate ASC) suspend fun getRecordsByType(childId: Long, type: String): ListGrowthRecord Insert suspend fun insertRecord(record: GrowthRecord) Update suspend fun updateRecord(record: GrowthRecord) Delete suspend fun deleteRecord(record: GrowthRecord) }observeGrowthRecords返回的是FlowListGrowthRecord这样 Room 会在表数据变化时自动推送新列表。Compose 页面收集这个 Flow任何新增、修改、删除操作后 UI 自动更新省掉了refresh()或者 EventBus 之类的同步机制。数据库升级要写在Room.databaseBuilder里尤其是有两个以上历史版本之后val db Room.databaseBuilder(context, AppDatabase::class.java, growth.db) .addMigrations(MIGRATION_1_2) .build() val MIGRATION_1_2 object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL(ALTER TABLE growth_record ADD COLUMN note TEXT DEFAULT NULL) } }addMigrations而不是fallbackToDestructiveMigration()否则用户升级版本时数据直接被清空儿童成长记录这种带情感价值的健康数据丢了是会直接一星差评的。3.3 数据校验的边界身高的合理范围不该写死在输入框上“记录身高”页面的输入校验合理范围是 30-250cm体重 0.5-200kg。这些界限不是拍脑袋定的要参考中国 0-18 岁儿童青少年生长曲线的 P3 和 P97 数值。但注意校验只在录入时做一次拦截不在视图层做后置修正。因为低体重早产儿的记录可能低于常规范围家长手滑填 8cm 这种错误要在RecordViewModel里拦下来而不是交给数据库去报异常。fun validateGrowthValue(type: String, value: Float): Boolean { return when (type) { height - value in 30f..250f weight - value in 0.5f..200f head_circumference - value in 20f..80f else - false } }4. 核心业务模块实现记录、曲线与疫苗提醒怎么写4.1 成长记录页相机拍照 手动输入并存重做 UI 状态逻辑记录页是使用频率最高的页面家长可能每周都要打开一次。设计上要有两个入口一个是大大的“手动记录”按钮一个是“拍摄体检本照片”入口拍摄后可以自动 OCR但第一次版本不推荐接 OCR因为孩子的体检本每个城市样式都不一样识别后还要人工修正体验反而差。手动记录的核心是一个底部弹窗表单Composable fun RecordFormDialog( onSave: (type: String, value: Float, date: LocalDate, note: String) - Unit ) { var type by remember { mutableStateOf(height) } var value by remember { mutableStateOf() } var date by remember { mutableStateOf(LocalDate.now()) } // 单位换算联动切换 type 时提示文案跟着变 val unitLabel when (type) { height - cm weight - kg head_circumference - cm else - } OutlinedTextField( value value, onValueChange { input - value input.filter { it.isDigit() || it . }.take(5) }, label { Text(数值$unitLabel) } ) // 日期选择器 DatePickerDialog( initialDate date, onDateSelected { date it } ) }take(5)限制了输入长度避免用户打出 99999 这种一眼假的数据。同时过滤掉了非数字字符用filter { it.isDigit() || it . }代替toFloatOrNull()的 try-catch。ViewModel 侧保存的逻辑fun saveRecord(type: String, value: Float, date: LocalDate) { val epochDay date.toEpochDay() val record GrowthRecord( childId currentChildId, type type, value value, recordDate epochDay ) viewModelScope.launch { repository.insert(record) _event.emit(Event.RecordSaved) } }Event.RecordSaved是用Channel实现的一次性事件用来弹出 Toast 或 Snackbar。这里不能把事件放在StateFlow里否则旋转屏幕后事件会重复触发用户会看到两次“保存成功”。4.2 生长曲线页计算月龄并叠加 WHO 标准线曲线页的逻辑分成两步。第一步是查询该孩子某个指标如身高的全部记录第二步是将每条记录的recordDate减去birthDate算出月龄。fun calculateMonthAge(birthEpochDay: Long, recordEpochDay: Long): Float { val days recordEpochDay - birthEpochDay return days / 30.4375f // 平均每月天数避免大小月差异 }用 30.4375 这个平均月天数而不是直接拿月份差是为了横向对比 WHO 标准数据。WHO 的标准曲线横轴就是月龄拿“精确月龄”去对映射点更稳妥。这里如果想做得更严谨可以用java.time.Period.between(birthDate, recordDate)拿到整年月日再转换为小数但对曲线展示来说误差在 1-2 天肉眼不可见不必过度设计。曲线页用 Vico 的核心代码Composable fun GrowthChartScreen(records: ListGrowthRecord, child: Child) { val chartModel remember(records, child) { val monthAgeList records.map { calculateMonthAge(child.birthDate, it.recordDate) to it.value } val standardList getWhoStandard(child.gender, height) // 从资源文件读取 CartesianChartModel( lineSeries listOf( LineSeries(series standardList, lineColor Color.Gray), LineSeries(series monthAgeList, lineColor Color.Blue) ) ) } CartesianChartHost( chartModel chartModel, marker rememberCartesianMarker( indicator rememberCartesianIndicator() ) ) }remember(records, child)是关键因为只有这两个值变化时才会重建图表模型否则下拉刷新时图表会闪烁。灰度标准线来自getWhoStandard()这是一份存放在assets/下的 CSV 或 JSON 数据例如who_height_boys.csv包含月龄、P3、P50、P97 三列。你不需要自己画所有百分位一般画 P3、P50、P97 三条线就足够家长判断生长趋势了。4.2.1 成长曲线图的 3 个易错点轴标签格式、空数据状态、触控提示横轴坐标默认显示 0、12、24、36 这种数字应该格式化成 “1岁”、“2岁” 或 “24月”。在 Vico 里要自定义CartesianAxis的valueFormatter否则非医疗背景的家长根本看不懂。空数据显示“还没有记录点击下方按钮添加”而不是画一条跑到坐标轴外的线。给图表加个长按或滑动 marker让用户触摸时能看到某一点的具体数值和日期。手滑错位点能立刻自查。4.3 疫苗提醒WorkManager 的周期任务不要用 AlarmManager疫苗提醒最容易被做成“一个列表 一个日期字段”但疫苗的真正复杂度在于剂量间隔乙肝疫苗第 1 剂和第 2 剂要间隔 28 天第 3 剂与第 2 剂间隔 60 天。这些间隔规则如果硬编码在VaccineRecord表里以后改国家免疫规划表时就得改表结构。推荐的设计疫苗表只存“这个孩子实际该打哪一针计划哪天打”具体的间隔规则放在一个VaccineScheduleProvider里从 JSON 或 CSV 读取[ { name: 乙肝疫苗, doses: 3, intervals: [0, 28, 60] }, { name: 脊灰疫苗, doses: 4, intervals: [0, 30, 30, 30] } ]当用户创建一个新孩子时APP 根据出生日期自动生成未来 24 个月的疫苗计划写入vaccine_record表。然后WorkManager每 24 小时检查一次今天该打的疫苗发送通知。class VaccineReminderWorker( context: Context, params: WorkerParameters ) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { val today LocalDate.now().toEpochDay() val dueVaccines db.vaccineRecordDao().getDueVaccines(today) dueVaccines.forEach { vaccine - sendNotification(vaccine) } return Result.success() } }触发方式val request PeriodicWorkRequestBuilderVaccineReminderWorker( 1, TimeUnit.DAYS ).setInitialDelay(1, TimeUnit.HOURS).build() WorkManager.getInstance(context).enqueueUniquePeriodicWork( vaccine_reminder, ExistingPeriodicWorkPolicy.KEEP, request )KEEP策略很重要如果用户重启手机导致 WorkManager 重新调度不会重复创建多个同 ID 的周期任务。很多人喜欢在onResume里调enqueue不用enqueueUniquePeriodicWork的话每进一次页面就多一个 Worker最后一天弹十几个通知。4.3.1 通知权限的适配Android 13 以上要动态申请Android 13API 33开始通知权限从安装时自动授予改成运行时权限。这意味着 APP 首次启动时要引导用户打开通知权限否则疫苗提醒不生效用户会以为 APP 是坏的。if (Build.VERSION.SDK_INT 33) { val permission android.Manifest.permission.POST_NOTIFICATIONS if (ContextCompat.checkSelfPermission(context, permission) ! PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(activity, arrayOf(permission), 1001) } }注意POST_NOTIFICATIONS只在 Android 13 生效。低版本设备不需要这项权限但如果你的minSdk设得低比如 23不要直接写死这个权限在 Manifest —— 在较低版本上会直接被忽略没关系但代码里也不要对低版本调用requestPermissions否则会弹出一个永远无法“允许”的对话框。5. 综合数据看板与多子女切换怎么把零散记录变得好看又实用5.1 首页看板用 Compose 的 canvas 画一个“最近接种/下次接种”卡片主界面不要一上来就是图表。真实用户打开 APP 的第一反应是看“今天有什么需要记录的吗”。看板从上到下依次是当前孩子姓名和年龄会自动算到“X年X个月”最近一次身高体重和距上次记录间隔天数下次疫苗名称和倒计时今天、明天、3天后分别用不同颜色进入图表 / 记录的快捷按钮间隔天数的计算要谨慎处理跨年和闰年。用java.time的ChronoUnit.DAYS.between()而不是手动算毫秒差再除以 86400000后者在夏令时地区会出错虽然中国没有夏令时但代码要有跨时区的普适性。val daysSinceLast ChronoUnit.DAYS.between(lastRecordDate, LocalDate.now())倒计逻辑val daysUntil ChronoUnit.DAYS.between(LocalDate.now(), nextVaccineDate) when { daysUntil 0 - 已逾期 ${-daysUntil} 天 daysUntil 0L - 今天 daysUntil 3 - ${daysUntil} 天后 else - 还有 ${daysUntil} 天 }5.2 多子女切换顶部 Tab 每个孩子独立数据库查询多子女共用一张growth_record表用childId区分。切换孩子的 UI 是一个简单的下拉或横向可滑动 Tab。切换时HomeViewModel要重新加载该 child 的数据关键代码在切换后清空当前childId状态并重新查询fun switchChild(childId: Long) { _currentChildId.value childId viewModelScope.launch { _records.value repository.getRecords(childId) } }StateFlow的value更新才是响应式 UI 的驱动力。如果直接把childId作为参数塞给Flow要记得用flatMapLatest合并流否则旧孩子的数据会闪烁一秒再被替换。5.3 数据导出导出 CSV 或 PDF 时编码要选对儿童成长数据对家长来说很重要提供“导出 CSV 发送到邮箱”功能时最常见的坑是中文乱码。CSV 文件默认用 Excel 打开时按 ANSI 解析而 Android 写文件默认是 UTF-8。val file File(cacheDir, growth_export.csv) file.writeText( \uFEFF buildCsvContent(), Charsets.UTF_8 )\uFEFF是 BOM 头。加上这个字符后 Excel 打开 UTF-8 的 CSV 才会正常显示中文。不加的话用 Excel 打开是乱码用文本编辑器打开又是正常的中文——这个“薛定谔的乱码”坑了无数开发者。6. 发布与排错Android 儿童成长 APP 上架前要过的几道坎6.1 瘦身与包体积优化一张图表引起的几十 MB 增加Vico 图表库和 Room 本身不会让 APK 膨胀太多真正占空间的是 If you include 了 MPAndroidChart 这类带大量 Java 依赖的老牌库。推荐检查一遍只保留arm64-v8a和armeabi-v7a两种 ABI用abiFilters排除x86和x86_64安装包能少 10-20MB开启shrinkResources和minifyEnabled同时给proguard-rules.pro加上 Room、Vico 的 keep 规则如果用了 ViewBinding关掉没用的buildFeatures模块Compose 项目不需要 ViewBindingbuildTypes { release { isMinifyEnabled true isShrinkResources true ... } }6.2 隐私合规儿童数据这个敏感位置怎么处理才扛得住审核Android 应用市场对儿童相关 APP 的隐私审核本来就比普通工具类严格。核心数据都是本地存储不经过你的服务器这本身是一个很大的隐私优势。但商店审核时仍然需要你填写隐私政策网页并且明确声明“本应用不收集、不传输儿童的姓名、生日、健康状况等信息到服务器”不申请CAMERA权限如果为了拍体检本用系统相机ACTION_IMAGE_CAPTURE之后不要读取照片库的READ_EXTERNAL_STORAGE而是把照片缓存到应用私有目录不用ACCESS_FINE_LOCATION、GET_ACCOUNTS这类敏感权限不少开发者为了省事在 Manifest 里申请了所有权限结果审核被拒。反过来隐私政策里写了不收集代码里却偷偷调了AdvertisingIdClient也容易不过。儿童成长 APP 的底线就是不碰网络权限除了导出文件时用ACTION_SEND调起分享。6.3 常见 Crash 和假死现场场景症状原因解法升级数据库IllegalStateException: Migration didnt properly handle新版本表结构和旧版本不一致写Migration类不要删库输入小数NumberFormatException家长输入 “.” 时toFloat()报错输入框校验.单独处理图表数据为空IndexOutOfBoundsExceptionVico 的LineSeries渲染空列表图表绘制前判断records.isNotEmpty()通知没弹到了提醒日没通知没申请通知权限 / 设备厂商杀了进程引导用户开启通知权限WorkManager 里打日志多子女切换时闪退ConcurrentModificationException列表数据变化时还在渲染用ImmutableList包装数据模型最后一个小技巧给VaccineRecord的scheduledDate建个索引。孩子一多疫苗记录查询会变慢。你可以在Entity上直接加Entity( tableName vaccine_record, indices [Index(value [childId, scheduledDate])] )这条索引让“查某孩子某天前后的疫苗”这条高频路径走索引扫描而非全表扫描。一个小改动对上千万行数据可能没感觉但对一个用了三四年的 APP——天天记身高、打疫苗提醒——体感差异还是很明显的。本文还有配套的精品资源点击获取