简介:这是基于安卓平台设计并实现的儿童成长APP完整项目,主要面向安卓初中级开发者及需要毕业设计参考的高校学生,重点解决儿童任务管理与家庭互动激励场景的开发需求。项目覆盖任务日历、每日任务、专家推荐、家庭分享、奖励兑换等核心模块,从类结构看还涉及论坛、新闻、宠物养成等扩展功能,整体功能较完整。资源包共1151个文件,包含243个Java源码、214个XML界面布局、350个class编译文件、31个jar依赖库及数百张png/jpg图片资源,另有SQL数据库脚本,压缩包大小约15.23MB。目前已有171人学习下载,便于快速评估项目的完整性。学习者可借助该资源理解安卓常见目录结构、数据持久化方式及模块化交互设计,也可基于奖励小红花、阅读统计等机制快速搭建同类儿童教育类应用原型。
1. 儿童成长APP不是“记录一下”那么简单
一个只记录身高体重的儿童成长应用,上线三个月后日活大概率跌破 5%。家长不是不愿打开,而是没有“不得不打开”的理由:疫苗忘了打、体检错过了、辅食哪天加的想不起来。所谓儿童成长APP,核心价值其实是一条完整的时间轴:纵向是孩子的身体数据曲线,横向是里程碑、疫苗、相册和文字日记。它的设计难点不在界面多好看,而在数据模型是否扛得住时间维度、提醒是否真的能推送到用户手上,以及家长敢不敢把孩子的照片和健康数据放心存进来。这篇文章按“数据层 → 功能链路 → 系统适配 → 导出与隐私”这条主线,把一个 Android 儿童成长APP从建表到上架前该补的坑过一遍,适合独立开发者和中小团队参考。
2. 数据模型先行:Room 表设计是儿童成长APP的骨架
2.1 先按业务拆出五张核心表,别一上来就堆功能
儿童成长APP的功能模块虽然可以无限加,但底层数据离不开这几类:儿童档案、生长记录、里程碑、疫苗接种、多媒体素材。建议第一版只做这五张表,后面再根据反馈加辅食记录、睡眠记录,不要一开始就设计一张巨大的“记录表”存所有类型。
下面是一份我常用的表结构划分:
| 表名 | 核心字段 | 说明 |
|---|---|---|
| child | id, name, gender, birthday, avatar_uri | 一个APP支持多个孩子,所以单独建表 |
| growth_record | id, child_id, record_type, value, unit, record_time, note | 身高、体重、头围、BMI 都走这张表 |
| milestone | id, child_id, milestone_type, happened_at, note | 抬头、翻身、独坐、爬行、走路、说话 |
| vaccine | id, child_id, vaccine_name, due_date, vaccinated_at, reminder_offset_days | 疫苗本身有“应种日期”和“实际接种日期”两个时间 |
| media_asset | id, child_id, asset_type, uri, taken_at, description | 相册、语音日记统一走这张表 |
注意growth_record里的record_type字段,这是用一张表存多种测量数据的标准做法。查询时用record_type + record_time做联合条件,不需要为身高体重各建一张表。好处是统计曲线、导出报表、做图表时数据源是统一的,坏处是对索引设计有一定要求。
2.2 时间字段用 epoch millis,别用字符串
儿童成长APP所有涉及“什么时候发生”的字段,统一用Long类型的 epoch millis 存储,不要在 SQLite 里存"2025-06-01 10:30"这类文本。原因有三个:排序和范围查询直接走索引、时区转换交给上层处理、导出到 CSV 或 JSON 时格式统一。
实体的写法参考下面这段代码:
@Entity(tableName = "growth_record") data class GrowthRecordEntity( @PrimaryKey(autoGenerate = true) val id: Long = 0, @ColumnInfo(name = "child_id") val childId: Long, @ColumnInfo(name = "record_type") val recordType: String, // height / weight / head_circumference / bmi @ColumnInfo(name = "value") val value: Double, @ColumnInfo(name = "unit") val unit: String, @ColumnInfo(name = "record_time") val recordTime: Long, @ColumnInfo(name = "note") val note: String? = null )record_time存的是毫秒时间戳,查询某个月的数据时直接传startOfMonthInMillis和endOfMonthInMillis两个边界值。这里有个常见的坑:很多人用Calendar.getInstance()拿到月初后忘记把DAY_OF_MONTH设成 1,导致查出来缺了几天数据。
2.3 Room DAO 的查询要带排序和限制
DAO 层不要只写一个全量查询,数据量大了以后全量加载很快会让页面卡顿。下面是一个按日期范围查询的示例:
@Dao interface GrowthRecordDao { @Query("SELECT * FROM growth_record WHERE child_id = :childId AND record_type = :type " + "AND record_time BETWEEN :startTime AND :endTime ORDER BY record_time DESC") fun getRecordsInRange(childId: Long, type: String, startTime: Long, endTime: Long): Flow<List<GrowthRecordEntity>> @Query("SELECT * FROM growth_record WHERE child_id = :childId AND record_type = :type " + "ORDER BY record_time DESC LIMIT 1") suspend fun getLatestRecord(childId: Long, type: String): GrowthRecordEntity? }用Flow作为返回类型,Room 会在表数据变化时自动重新推送,ViewModel 里观察这个 Flow 就能实现“新增一条记录后曲线图自动刷新”。getLatestRecord用于首页展示最近一次测量值,LIMIT 1和ORDER BY record_time DESC缺一不可,不然遇到同一时刻多条记录时结果不确定。
Room 版本建议直接用 2.6.0 以上,配合 KSP 而不是 kapt,编译速度提升明显。gradle 里这样配:
plugins { id("com.google.devtools.ksp") version "2.0.21-1.0.28" } dependencies { implementation("androidx.room:room-runtime:2.6.1") implementation("androidx.room:room-ktx:2.6.1") ksp("androidx.room:room-compiler:2.6.1") }提示:如果你在 Android Studio 里新建项目时选了 Kotlin DSL 的 build.gradle.kts,插件声明方式略有差异,KSP 版本也要和 Kotlin 版本匹配,具体对应关系参考 KSP 官方 release notes。
3. 儿童成长APP核心功能:从录入到曲线图的完整链路
3.1 ViewModel 承载业务状态,Repository 做数据分发
很多初版方案把数据库操作直接堆在 Activity 里,页面一重建就丢状态,写起来爽,后面改需求想哭。常见做法是引入一个很轻的 Repository 层,Activity/Fragment 只和 ViewModel 打交道。
class GrowthViewModel(private val dao: GrowthRecordDao) : ViewModel() { val heightRecords: StateFlow<List<GrowthRecordEntity>> = dao .getRecordsInRange(currentChildId, "height", startMillis, endMillis) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) fun addRecord(type: String, value: Double, unit: String, time: Long, note: String?) { viewModelScope.launch { dao.insert( GrowthRecordEntity( childId = currentChildId, recordType = type, value = value, unit = unit, recordTime = time, note = note ) ) } } }stateIn的作用是把 Flow 转成StateFlow,配合SharingStarted.WhileSubscribed(5000),界面在后台时自动停止收集,省电省内存。注意currentChildId不要写死,切孩子时整个 ViewModel 需要重建,最简单的方案是在 ViewModel 里暴露一个switchChild(childId)方法重新绑定 flow。
3.2 曲线图画线前先做排序和异常值过滤
用 MPAndroidChart 或者自绘 View 画成长曲线时,经常会遇到曲线来回穿越的问题。这是因为数据库返回的数据没有按时间排序,或者录入时选错了日期。结合前面的 DAO,ORDER BY record_time DESC已经倒序,画图前再reversed()一次即可。
另一个坑是异常值。一岁孩子身高记录突然出现 180cm,曲线直接拉到天花板。在 ViewModel 里加一个过滤逻辑:
fun getChartData(records: List<GrowthRecordEntity>): List<GrowthRecordEntity> { val sorted = records.sortedBy { it.recordTime } val threshold = when (recordType) { "height" -> 30.0 // 相邻两次身高差超过30cm视为异常 "weight" -> 10.0 // 相邻两次体重差超过10kg视为异常 else -> 50.0 } return sorted.filterIndexed { index, record -> if (index == 0) true else abs(record.value - sorted[index - 1].value) < threshold } }过滤条件不要写死在业务代码里,做成一个可配置项,因为不同年龄段孩子的生长速度差异很大,新生儿一个月长 3cm 是正常的,三岁孩子一个月长 3cm 也是正常的,但两岁孩子一天长 3cm 必然录错了。
3.3 里程碑时间轴用分页加载避免一次性渲染
里程碑数据和生长记录不同,它是离散事件,数量不会爆炸,但相册类多媒体数据会。假设一个孩子从 0 岁到 6 岁拍了 5000 张照片,你不可能一次性查出来全部加载。里程碑和相册混合时间轴建议用 Paging 3 库,按时间倒序分页。
@OptIn(ExperimentalPagingApi::class) class TimelinePagingSource( private val dao: MediaAssetDao, private val childId: Long ) : PagingSource<Int, MediaAssetEntity>() { override suspend fun load(params: LoadParams<Int>): LoadResult<Int, MediaAssetEntity> { val page = params.key ?: 0 val pageSize = params.loadSize val offset = page * pageSize val data = dao.getMediaPage(childId, pageSize, offset) val nextKey = if (data.size < pageSize) null else page + 1 return LoadResult.Page(data, prevKey = null, nextKey = nextKey) } }LoadParams.loadSize由 Paging 框架根据屏幕尺寸自动计算,不需要手动写死每页 20 条。如果发现快速滑动时图片加载卡顿,优先检查缩略图生成策略而不是分页大小——原图直接加载是最常见的性能杀手。
3.4 数据校验要在 UI 层和 ViewModel 层各做一次
UI 层校验防止用户输入明显不合理的数据,ViewModel 层校验防止外部调用或数据迁移时带入脏数据。下面是一个双保险的写法:
fun validateGrowthRecord(type: String, value: Double): Boolean { return when (type) { "height" -> value in 30.0..220.0 // 单位cm "weight" -> value in 1.0..200.0 // 单位kg "head_circumference" -> value in 20.0..80.0 else -> false } }提示:单位换算也建议放在这层做。UI 上让家长选择“斤”还是“kg”,提交到 ViewModel 时统一转成 kg 入库,避免后续图表和导出时还要根据每条记录的单位做分支处理。
4. 提醒与系统适配:Android 版本差异是儿童成长APP绕不开的坎
4.1 疫苗提醒选 AlarmManager 还是 WorkManager
疫苗提醒是在指定日期当天提醒,不是周期性任务,所以 WorkManager 并不合适。WorkManager 适合“每隔一段时间同步一次”这种容忍延迟的任务,它不保证在精确时间触发。疫苗提醒需要精确到某一天甚至某个时间段,用 AlarmManager 才是可靠方案。
下面是我在项目里用的对比表:
| 维度 | AlarmManager | WorkManager |
|---|---|---|
| 触发时机 | 指定时间点,精度高 | 最快间隔15分钟,不保证精确 |
| 系统重启后 | 需要 RECEIVE_BOOT_COMPLETED 重新注册 | 自动恢复 |
| 电量优化 | Android 12+ 精确闹钟需特殊权限 | 受 Doze 影响,但框架自动处理 |
| 适合场景 | 疫苗、体检、吃药提醒 | 云端同步、数据备份 |
4.2 精确闹钟权限:Android 12 和 Android 13 都要处理
从 Android 12(API 31)开始,AlarmManager.setExactAndAllowWhileIdle()需要SCHEDULE_EXACT_ALARM权限。Android 13(API 33)进一步细分了通知权限,新装的 APP 默认收不到通知,必须动态申请POST_NOTIFICATIONS。
Manifest 里声明如下:
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />动态申请通知权限的代码:
if (Build.VERSION.SDK_INT >= 33) { val permission = ContextCompat.checkSelfPermission( context, Manifest.permission.POST_NOTIFICATIONS ) if (permission != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions( activity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQUEST_CODE_NOTIFICATION ) } }需要注意一个边界:SCHEDULE_EXACT_ALARM在 Android 14(API 34)上默认是拒绝的,用户必须手动到“闹钟和提醒”设置页开启,你的 APP 首次启动时应该检测这个权限是否可用。检测方式:
val alarmManager = context.getSystemService(AlarmManager::class.java) val canScheduleExactAlarms = if (Build.VERSION.SDK_INT >= 31) { alarmManager.canScheduleExactAlarms() } else { true }如果返回 false,不要直接跳到系统设置页,先弹一个说明页解释为什么需要精确提醒,用户确认后再跳转。
4.3 通知渠道参数:高低优先级的差别直接影响用户流失
疫苗提醒的通知渠道一定要用IMPORTANCE_HIGH,配合震动和声音,否则用户错过提醒会认为 APP “没用”。Android 8.0(API 26)以后,渠道参数创建后不能修改,所以要尽早定义好:
val channel = NotificationChannel( CHANNEL_ID_VACCINE, "疫苗与体检提醒", NotificationManager.IMPORTANCE_HIGH ).apply { description = "疫苗接种、体检、用药提醒" enableVibration(true) vibrationPattern = longArrayOf(0, 300, 200, 300) setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), null) }注意vibrationPattern是“等待 0ms → 震动 300ms → 停止 200ms → 震动 300ms”,不要设计成连续震动,部分国产手机会把长震动识别为来电并做特殊处理,导致通知体验异常。
4.4 用 adb 验证闹钟是否真的注册成功
开发阶段最怕的是“本地测试正常,发布后用户说没提醒”。一个很实用的验证手段是用 adb 查看系统里实际注册的闹钟:
adb shell dumpsys alarm | grep -A 5 com.example.childgrowth输出里能看到Alarm{...}后面跟着触发时间、重复间隔、包名。如果输出为空,说明闹钟注册失败或已经被系统清理。另外,adb shell dumpsys notification --noredact可以查当前 APP 的通知渠道是否存在以及 importance 等级。
提示:国产 ROM 的“自启动管理”和“电池优化白名单”是提醒类 APP 的另一个大坑。发布后引导用户在系统设置里关闭电池优化,很多 APP 选择在首次创建提醒时弹一个引导页,这个交互在应用市场上的评论里反馈最好。
5. 成长数据导出与隐私处理:家长愿意长期使用的最后一个理由
5.1 一键导出 CSV,字段顺序和转义是细节
儿童成长APP 的数据价值在于让家长能把孩子几年的成长记录带得走。做法是提供一个“导出数据”入口,生成 CSV 或 JSON 文件,用 FileProvider 分享出去。CSV 看似简单,字段里有逗号和换行时不做转义很容易坏文件。
fun generateCsv(children: List<ChildEntity>, records: List<GrowthRecordEntity>): String { val sb = StringBuilder() sb.append("child_name,record_type,value,unit,record_time,note\n") records.forEach { record -> val childName = children.find { it.id == record.childId }?.name ?: "未知" val timeText = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.CHINA) .format(Date(record.recordTime)) val note = record.note?.replace("\"", "\"\"")?.replace("\n", " ") ?: "" sb.append("$childName,${record.recordType},${record.value},${record.unit},$timeText,\"$note\"\n") } return sb.toString() }record_time导出时最好先转成可读格式再写文件,不要直接导出毫秒时间戳。note字段用双引号包裹,里面的双引号转义成两个双引号,这是 CSV 规范里最容易被忽略的地方。导出文件用FileProvider.getUriForFile生成 content:// URI。
5.2 全量备份:把 Room 数据库文件复制到 Download 目录
更完整的备份是直接把数据库文件.db复制到外部存储,这样用户换手机后可以把文件导入回来。Android 10(API 29)以后分区存储导致应用不能随意往公共目录写文件,常见做法是写到MediaStore.Downloads:
val dbFile = File(context.applicationInfo.dataDir, "databases/child_growth.db") val values = ContentValues().apply { put(MediaStore.Downloads.DISPLAY_NAME, "child_growth_backup_${System.currentTimeMillis()}.db") put(MediaStore.Downloads.MIME_TYPE, "application/octet-stream") put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) } val uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) uri?.let { contentResolver.openOutputStream(it)?.use { output -> FileInputStream(dbFile).copyTo(output) } }注意:数据库备份时最好先关闭 Room 的连接池,或者直接用BackupAgent在 onBackup 里把数据库 file 读出来。Room 默认开启了 WAL 模式,直接拷贝 .db 文件会漏掉-wal里尚未合并的数据,稳妥的做法是备份前执行一次PRAGMA wal_checkpoint(FULL),或者把.db、.db-wal、.db-shm三个文件一起备份。
5.3 隐私最小化:照片 EXIF、位置信息、同意记录
儿童数据属于敏感个人信息,国内外应用市场对这类 APP 的审核越来越严格。有几个实践值得在一开始就做:相册功能把图片复制到应用私有目录后,立即剥离 EXIF 信息中的 GPS 位置和拍摄设备型号;不采集精确定位权限,宝宝疫苗接种点查询改用城市选择而不是定位;家长账号的注册协议里明确列出数据存储期限和删除方式,并提供“一键删除全部数据”的入口。
这些功能不出现在首页,但会在应用市场审核和用户口碑两个层面起作用。数据导出功能在这里还有一个隐藏价值:审核员能看到这个 APP 的数据是可迁移、可删除的,而不是一个锁死用户数据的黑盒。
本文还有配套的精品资源,点击获取