2026/10/10 2:30:32

基于Android的女性生理健康管理APP:周期预测算法与数据存储实践

基于Android的女性生理健康管理APP:周期预测算法与数据存储实践 简介这是一份基于安卓的女性生理健康管理应用完整项目面向计算机相关专业学生的课程设计、期末大作业及毕业设计也适合安卓开发者作为实战参考。应用可记录女性生理周期与状态预测下一次生理期日期并根据记录提供建议与分析同时支持输入身高体重计算标准体重功能完整具备良好的界面交互与数据管理逻辑。项目压缩包共一百三十四个文件涵盖源代码、界面布局文件、图片资源、构建配置以及可直接安装的安装包总大小仅二点二七兆目录结构清晰便于按模块查阅。目前已有九十四人学习下载。代码经过严格调试下载即可运行项目说明文档有助于理解整体架构可对照学习安卓界面搭建、数据存储、日期推算、健康建议生成等关键实现适合作为实战练习与二次开发的基础。1. 基于Android的女性生理健康管理APP从记录生理期到预测下一次这套源码的含金量在哪基于Android的女性生理健康管理APP拆开来看核心就是一条数据链路记录生理期的开始与结束日期存进本地数据库再用这些日期预测下一次生理期。这套源码里真正有含金量的不是首页的统计卡片而是周期推算算法和数据库红线——这两个部分一旦写错用户体验和数据信任会同时翻车。某个做健康类外包的朋友曾因为语义没想清楚而返工用户记录完当天经期状态下一页要显示“距离下次生理期还有几天”他直接拿上次日期加28天算了个固定值。用户周期一变预测全乱差评里全在抱怨“APP算不准”。问题不在界面上而在于他没有先设计“周期长度序列”再设计“预测算法”。这篇文章按一个仿写顺序展开先拆数据库字段和预测算法再落到Room建表和页面交互接着是一份避坑清单最后讲提醒与备份的进阶做法。拿到源码包后建议按这个顺序读周期计算工具类 → DAO查询语句 → 记录页ViewModel → 日历视图与通知。2. 生理期预测的核心逻辑数据模型先定对预测算法才谈得上准2.1 记录字段怎么设计经期起止日期、身体状态与备注类型这类APP的第一步不是写预测算法而是想清楚“一条记录长什么样”因为后面所有的周期均值、预测日期、日历高亮都是这些字段算出来的。常见做法是拆成两张表一张存经期记录、一张存身体状态但实际仿写时我建议用一张宽表起步原因很简单记录页只有一个提交动作拆表会让事务和联查变复杂收益却不明显。看一下实体类的典型写法Entity(tableName period_record) data class PeriodRecord( PrimaryKey(autoGenerate true) val id: Long 0, val startDate: Long, // 经期开始日期存毫秒时间戳 val endDate: Long, // 经期结束日期 val cycleLength: Int, // 本次周期长度单位天 val flowLevel: Int, // 流量等级0少 1中 2多 val symptom: Int, // 症状位掩码1腹痛 2腰酸 4头痛 8乏力 val note: String? // 备注 )字段设计上有几个决定预测质量的关键点。第一startDate与endDate存毫秒时间戳不存“2026-05-12”这种字符串因为“查询最近六次经期开始日”要用ORDER BY排序时间戳天然有序字符串按字典序排序会出错这是个极隐蔽的坑。第二cycleLength不是用户输入的值而是代码算出来再回填的它表示“上一次开始日到这一次开始日的天数差”后面所有平均周期都依赖这个字段。第三symptom用位掩码而不是多个布尔字段目的是以后加“情绪低落”“失眠”等标签时不需要改表结构。注意endDate与startDate的差值不要直接相减因为用户可能把开始日和结束日选成同一天那样经期长度至少应该是1而不是0。入库前做一次下限收敛。2.2 平均周期法与移动窗口法两种预测算法的实现与取舍预测下一次生理期常用算法有平均周期法、移动窗口平均法、贝叶斯估计但这套源码场景内真正会用到的是前两种因为它们不依赖外部协变量只需要日期序列。平均周期法的思路是取最近3到6次经期开始日两两相减得到周期长度序列求平均值再用最后一次开始日加上平均周期得出预测值。但要注意平均周期法在周期波动大的用户身上会“两头都不准”比如用户最近三次周期是28天、21天、45天平均值落在31天左右预测既不早也不晚完全不贴合实际。这是因为平均值被极端值拉偏了。所以实际仿写时更推荐移动窗口加权平均法只取最近3次周期并给更近的记录更高权重。下面这段代码是移动窗口法的核心实现object CycleCalculator { private const val DAY_MILLIS 1000L * 60 * 60 * 24 fun predictWithWeightedWindow(startDates: ListLong, window: Int 3): Long { // 至少两次经期记录才能构成一个周期少于两次直接返回0 if (startDates.size 2) return 0L val sorted startDates.sorted() val intervals mutableListOfInt() for (i in 1 until sorted.size) { val days ((sorted[i] - sorted[i - 1]) / DAY_MILLIS).toInt() intervals.add(days) } // 只取最近 window 次周期越靠近当前日期的记录权重越高 val recent intervals.takeLast(window) val weights listOf(1, 2, 3) var weightedSum 0 var weightTotal 0 recent.forEachIndexed { index, day - val w weights[index.coerceAtMost(weights.size - 1)] weightedSum day * w weightTotal w } val avgCycle (weightedSum.toDouble() / weightTotal).roundToInt() return sorted.last() avgCycle * DAY_MILLIS } }代码说明先对开始日期排序避免用户乱序补录导致的间隔计算错误intervals存放两两之间的天数差也就是周期长度序列takeLast(window)只保留最近三次周期。weights按“越近权重越高”设计最后一周期权重为3倒数第二为2最早为1。coerceAtMost防止记录不足三次时下标越界。最后用最后一次开始日加上加权平均周期得到预测开始日。参数说明window默认取3不建议取更大值因为生理周期受压力、作息、疾病影响很大半年前的周期对上个月几乎没有参考价值。如果你把variance很高的历史数据和近期数据混在一起平均预测会稳定地偏离真实日期。2.3 预测结果返回的数据结构平均周期、预测开始日与剩余天数预测函数返回一个Long只是基础版实际页面需要展示的不只是下一个日期还有“距离今天还剩几天”“平均周期是多少天”“排卵窗口大约在哪几天”。所以工具类的返回值应该设计成一个不可变数据类让调用方一次拿到全部展示素材。data class PredictionResult( val avgCycle: Int, val nextStartDate: Long, val daysUntilNext: Long, val ovulationDate: Long? ) fun predictAll(startDates: ListLong, today: Long System.currentTimeMillis()): PredictionResult? { if (startDates.size 2) return null val sorted startDates.sorted() // ... 省略加权平均计算 ... val nextStart sorted.last() avgCycle * DAY_MILLIS val ovulation nextStart - 14L * DAY_MILLIS // 排卵日按“下次经期前14天”估算 val daysUntil (nextStart - today) / DAY_MILLIS return PredictionResult(avgCycle, nextStart, daysUntil, ovulation) }这段代码里的排卵日计算是很多仿写者会搞错的地方排卵日估算用的是“下次预计开始日往前推14天”而不是“上次结束日往后推14天”。因为黄体期长度相对固定所以从下次开始日反推更接近生理事实。返回null的场景必须由页面兜底显示“再记录一次经期即可预测”绝不能显示“0天”否则用户会误以为当天就来生理期。3. 用Room建表与DAO封装把预测算法接到真正的数据源上3.1 Room实体与DAO建表、插入记录与查询最近经期开始日算法再完整没有本地数据就是空转。数据层用Room是Android开发中最常规的方案它能在编译期校验SQL还能通过协程suspend函数直接被ViewModel调用。建表用Entity注解DAO用接口加注解方法。这里的关键是查询语句要按需取列不要每次都把整条记录查出来再取字段那样会多出无意义的对象创建。Dao interface PeriodRecordDao { Insert suspend fun insert(record: PeriodRecord): Long Query(SELECT * FROM period_record ORDER BY startDate DESC LIMIT :limit) suspend fun getRecentRecords(limit: Int): ListPeriodRecord Query(SELECT startDate FROM period_record ORDER BY startDate DESC LIMIT :limit) suspend fun getRecentStartDates(limit: Int): ListLong Query(DELETE FROM period_record WHERE id :id) suspend fun deleteById(id: Long) }逻辑说明第一个查询按startDate倒序取最近N条完整记录用于日历视图回显经期区间。第二个查询只投影startDate列用于预测算法因为预测只需要开始日期序列不需要每次都加载症状、备注等大字段。第三个查询是删除单条记录用于用户误录后的纠错。suspend关键字一定要保留Room从2.4版本开始完整支持协程不写suspend就得用RxJava或自己维护线程池完全没有必要。每次插入后调用方应当主动刷新预测结果而不是等待下一次打开页面时再算。3.2 数据库初始化与单例封装不要在Activity里build数据库数据库实例的管理是整个数据层的承重墙。如果每个Activity都去调用Room.databaseBuilder会在配置变化时产生多个实例轻则内存泄漏重则触发“数据库已被关闭”的异常。常规做法是在Application的onCreate里初始化或者在AppDatabase的companion object里维护一个双重校验锁的单例。Database(entities [PeriodRecord::class], version 1) abstract class AppDatabase : RoomDatabase() { abstract fun periodRecordDao(): PeriodRecordDao companion object { Volatile private var INSTANCE: AppDatabase? null fun get(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { INSTANCE ?: Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, period_health.db ).build().also { INSTANCE it } } } } }这里有一个生产中几乎必踩的坑Room默认不允许在数据库版本升级时进行破坏性迁移。如果你的第一个版本没有预埋MIGRATION用户从v1升级到v2后会直接Crash报错是SQLiteException: Duplicate column name。新手最容易的应付方式是加fallbackToDestructiveMigration()但这会让用户的所有历史记录在升级瞬间被清空。生理健康数据的价值恰恰在于历史连续性所以升级策略必须用Migration类新增字段时执行ALTER TABLE ADD COLUMN不要走删表重建的捷径。3.3 ViewModel中的数据刷新策略用MediatorLiveData监听变化页面启动时加载一次预测结果是不够的因为用户可能从记录页返回后数据库已经变化。更可靠的仿写方式是在ViewModel里持有MediatorLiveData把LiveData数据源改为“查询数据库的函数”每当插入、删除操作完成后调用refresh方法重新执行查询并postValue。class PeriodViewModel(private val dao: PeriodRecordDao) : ViewModel() { val prediction MediatorLiveDataPredictionResult?() fun refreshPrediction() { viewModelScope.launch { val startDates dao.getRecentStartDates(6) val result CyclePredictor.predictAll(startDates) prediction.postValue(result) } } }这段代码把预测计算和数据库查询全部解析进协程页面层拿到的是已经算好的PredictionResult不需要关心线程切换。执行时机上建议在onStart里调用一次refreshPrediction同时记录页保存成功后也调用一次这样用户从保存页返回到主页时数据永远是新的。注意不要用onResume无脑刷新那会让正在滚动日历的用户被UI更新打断。4. 把记录页与预测页跑起来界面交互、日历高亮与展示格式4.1 记录页的实现日期选择、状态勾选与非法输入拦截记录页的交互模型很固定选择开始日期和结束日期勾选流量等级与症状保存。最容易出错的不是布局而是结束日期早于开始日期的场景。这个校验一定放在ViewModel或校验函数里不要放到DAO层因为DAO只负责持久化不应该承担业务规则。fun validateAndInsert(startDate: Long, endDate: Long, flowLevel: Int, symptomMask: Int) { if (endDate startDate) { errorMessage.postValue(结束日期不能早于开始日期) return } viewModelScope.launch { val previous dao.getRecentStartDates(1).firstOrNull() val cycleLength if (previous ! null) { ((startDate - previous) / (1000 * 60 * 60 * 24)).toInt() } else { 0 } dao.insert(PeriodRecord( startDate startDate, endDate endDate, cycleLength cycleLength, flowLevel flowLevel, symptom symptomMask, note null )) } }逻辑说明查询上一次开始日期来计算本次周期长度如果一条历史记录都没有cycleLength先置0等用户录第二次时才能算出第一个完整周期。这种处理会让首条记录的周期长度为0所以UI上任何涉及平均周期的地方都要对0做保护避免“除以0”或显示“0天”这种尴尬结果。布局上开始结束日期用TextView模拟按钮点击后弹DatePickerDialog流量等级用RadioGroup保证单选症状用CheckBox支持多选。保存按钮点击后先校验再insert最后调用refreshPrediction刷新主页与预测页。4.2 日历视图自绘每周网格、经期高亮与跨月空白格很多仿写方案直接引入第三方日历库但如果你想改样式、控制性能自己画一个6行7列的月历完全不复杂。用RecyclerView加GridLayoutManager是性能最好的做法因为月份切换时只需要替换数据源不需要重建整个列表。每个格子绑定一个LocalDate对象根据DAO查出的经期日期集合判断是否高亮。fun bindMonth(monthStart: LocalDate, periodDates: SetLocalDate) { val daysInMonth monthStart.lengthOfMonth() val firstDayOfWeek monthStart.dayOfWeek.value // 周一1周日7 val totalCells 42 var cellIndex 0 for (row in 0 until 6) { for (col in 0 until 7) { val dayNumber row * 7 col - firstDayOfWeek 2 val date if (dayNumber in 1..daysInMonth) { monthStart.withDayOfMonth(dayNumber) } else { null } val holder recyclerView.findViewHolderForAdapterPosition(cellIndex) cellIndex } } }代码说明totalCells取42是为了保证任何月份都能填满6行因为最大有31天加上第一天偏移最多需要37个格子42足够。dayNumber从1到daysInMonth表示当月真实日期落在范围外的是相邻月份的空格直接显示空白。periodDates是用startDate与endDate循环展开后的日期集合用Set存是为了查询O(1)。性能优化点bindMonth不应当在每次滑动时都调用因为GridLayoutManager的ViewHolder复用机制会让滚动过程中的绑定十分频繁。建议在月份切换完成后再触发一次bindMonth经期高亮颜色用浅粉色或浅红色避免高饱和色刺激用户感知。不要把经期第一天做成特殊图案那是产品需求不是技术需求。4.3 预测页展示的数据装配剩余天数、预计开始日与排卵窗口预测页的展示要直接把PredictionResult的字段翻译成用户能看懂的表达。核心的显示逻辑是三行文案第一行大字显示“距离下次生理期还有N天”第二行显示“预计开始日M月D日”第三行显示“平均周期28天”。如果PredictionResult为null显示“记录满2次后自动开启预测”。日期格式化时有一个必须注意的坑不要直接用SimpleDateFormat加默认时区因为时间戳是按毫秒存的如果设备时区变化显示日期可能差一天。正确做法是把Long转成LocalDate再格式化val nextDate Instant.ofEpochMilli(prediction.nextStartDate) .atZone(ZoneId.systemDefault()) .toLocalDate() val formatted nextDate.format(DateTimeFormatter.ofPattern(M月d日))这样无论用户怎么切换时区显示的本地日期都是正确的。预测页应当也显示排卵窗口文案写“预计排卵日M月D日”并在下方用浅色小字注明“算法估算结果不构成医疗建议”这个免责声明不是摆设健康类应用应该有边界意识。5. 仿写与运行中的五个高频问题数据库、日期换算与提醒的踩坑记录5.1 首次打开CrashRoomDAO没有数据库实例现象安装后首次启动点击记录页直接闪退日志指向SQLiteException: database not initialized。原因Activity的onCreate里才执行Room.databaseBuilder().build()而Application初始化时已经有其他组件通过反射或ContentProvider提前访问了DAO导致并发初始化竞争。解决把数据库单例放在Application的onCreate里并确保AppDatabase.get(context)内部是双重校验锁的单例。不要在Fragment里再通过Activity取context去build第二个实例那会触发“attempt to re-open an already-closed object”异常。5.2 升级版本后用户历史记录全部消失现象v1正常使用升级到v2后打开APP发现所有经期记录都不见了像是被清空。原因v1工程里没有写Migrationv2给PeriodRecord表加了新字段Room检测到schema不一致默认抛异常。开发者为了先跑通改加了fallbackToDestructiveMigration()结果是清空所有表。解决使用Migration类重命名升级路径比如新增cycleVariance字段时val MIGRATION_1_2 object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL(ALTER TABLE period_record ADD COLUMN cycleVariance INTEGER NOT NULL DEFAULT 0) } }然后在databaseBuilder上调用.addMigrations(MIGRATION_1_2)。开发阶段把版本号改回去会触发同样问题因为这个Migration不匹配。5.3 预测结果总是比实际早两天或晚两天现象用户反馈预测的生理期开始日和自己实际日期差两天而且方向不固定有时提前有时推迟。原因预测逻辑用System.currentTimeMillis()做所有日期差计算没有先转成LocalDate。设备跨时区或夏令时切换时毫秒差除以一天的毫秒数会得到不整除的结果roundToInt会随机进退位。解决统一改用java.time先把毫秒转成LocalDate再用ChronoUnit.DAYS.between计算间隔。不要用Calendar.get(DAY_OF_YEAR)相减跨年时会算错。升级后要重新校准一次周期序列因为历史记录的时间戳本身不可能被修正。5.4 后台收不到经期提醒通知现象记录保存后设置了提醒App切到后台后通知一直没出现前台时又正常。原因用AlarmManager的setExactAndAllowWhileIdle发送闹钟但没有引导用户关闭电池优化。部分国产品Rom的“纯净后台”机制在亮屏前强制延迟了精确闹钟的执行通知被系统吃掉。解决推荐改用WorkManager的OneTimeWorkRequest设置initialDelay为剩余天数。它会等待系统维护窗口执行误差几小时在生理提醒场景下是可以接受的。也可以同时申请POST_NOTIFICATIONS权限并在Android 13以上动态请求不请求权限则通知不显示。5.5 日历视图中的经期日期偏移了一天现象日历里高亮的经期第一天与实际选择开始日不符总是偏前一天或后一天。原因把开始日期推进到结束日期时使用for循环加DAY_MILLIS夏令时切换当天一天的长度不是24小时整循环加24小时后日期错位。解决用LocalDate的plusDays(1)生成日期序列完全规避夏令时问题。以下是一个安全展开方法var cursor startLocalDate while (!cursor.isAfter(endLocalDate)) { periodDates.add(cursor) cursor cursor.plusDays(1) }这段代码逻辑清晰且没有毫秒运算是处理区间展开的推荐写法。6. 进阶落地WorkManager定时提醒、CSV导出与本地数据隐私保护6.1 用WorkManager做预测日提醒initialDelay与取消策略提醒功能不建议裸用AlarmManager原因在5.4已经说过。WorkManager的优势是自带持久化与重试机制进程被杀后系统会择机补拉。每次保存记录成功后用enqueueUniqueWork加上REPLACE策略替换旧任务保证用户提前手动更新记录后不会收到过期提醒。val workRequest OneTimeWorkRequestBuilderPeriodReminderWorker() .setInitialDelay(daysUntilNext, TimeUnit.DAYS) .build() WorkManager.getInstance(context).enqueueUniqueWork( period_reminder, ExistingWorkPolicy.REPLACE, workRequest )setInitialDelay按天计算Worker内部再根据当前日期二次校验如果发现预测已经过期或用户已更新记录则不发送通知。通知渠道要先createNotificationChannelAndroid 8.0后不建渠道的通知会被系统静默丢弃。6.2 把记录导出为CSV列顺序与字段转义提供“导出数据”按钮能显著提升用户对健康类应用的信任感。导出格式优选CSV每行一条记录列顺序建议固定为开始日期、结束日期、周期长度、流量等级、症状掩码、备注。导出时注意转义备注字段含逗号或换行时用双引号包裹整个字段。val sb StringBuilder() sb.appendLine(startDate,endDate,cycleLength,flowLevel,symptom,note) records.forEach { r - val note r.note?.replace(\, \\) ?: sb.appendLine(${r.startDate},${r.endDate},${r.cycleLength},${r.flowLevel},${r.symptom},\$note\) }6.3 隐私保护的上架级要求本地存储、加密与导出路径女性生理数据属于高度敏感的个人健康数据项目的隐私底线应当是默认不联网、不采集、不上传。如果后续你要加云同步至少对数据库做加密处理Room上层套SQLCipher是目前Android端较稳的方案性能损失约5%到10%对这个体量的应用几乎感知不到。导出CSV时用MediaStore写入用户选择的具体目录不要写到公共Download目录且没有做隔离那样其他应用可能扫描到数据内容。最后说一个我在这类健康项目中沉淀下来的教训这类APP后期崩坏通常不是技术问题而是功能堆砌。体温、情绪、体重、睡眠每个都加结果主流程反而没人维护。拿到这套源码我建议你第一个里程碑不是扩展功能而是把“预测准确度、数据不丢失、提醒可靠”这三件事打磨扎实。多阶段走稳再考虑扩展场景。希望帮到你。本文还有配套的精品资源点击获取