Android女性生理健康管理应用:周期预测算法与工程实现
简介这份资源是一套基于Android平台开发的女性生理健康管理APP完整项目包含全部源码与项目说明文档适合计算机相关专业学生在课程设计、期末大作业或毕业设计阶段作为参考项目也适合具备一定Android基础的学习者用于调试与二次开发。APP核心功能包括记录女性生理周期与生理期状态根据历史日期预测下一次生理期并结合记录状态提供健康建议与分析同时支持输入身高体重计算标准体重整体功能贴近实际生活场景。压缩包共134个文件大小约2.27MB主要包含Java源码、XML布局与配置文件、PNG图片资源、Gradle构建脚本、APK安装包及项目说明文档目录结构完整导入Android Studio后即可阅读与运行。目前已有94人学习浏览适合想要理解Android应用完整开发流程、数据存储与界面交互设计的读者参考。1. 女性生理健康管理 APP一条“记录→周期→预测”的完整链路很多女生手机上都有个“日历法”记录生理期到日子就手动标红漏一次下个月就全乱。实习时我接过一个课程设计题目就是“基于 Android 的女性生理健康管理 APP”需求很直白记录每次生理周期的开始日、结束日把流量、痛经、情绪这些状态一起存下来再用历史日期推下一次经期、易孕期。源码工程里还附了一份项目说明把表结构、预测逻辑、页面跳转交代得比较完整适合正在做 Android 毕设、或者想独立做一个健康类工具的人拿来当骨架。这类工程的价值不在界面有多漂亮而在“日期→周期→预测”这条数据链路是否立得住日期存错了预测就偏状态字段设计差了统计就没意义预测口径定死了产品就没法解释误差。这篇文章就按“算法推演 → 工程落地 → 踩坑记录 → 进阶验证”的顺序把这条链路完整拆开。你拿到源码后可以直接对着改。2. 周期预测是怎么算出来的平均周期法、易孕期窗口与参数表2.1 为什么预测必须用“平均周期”而不是上一次的间隔生理周期不是固定值医学上 21 天到 35 天都算常见波动范围。如果直接用“上一次间隔”预测下一次等于把一次偶然值当成全量规律用户只要熬一次夜、压力大一次预测就会翻车。所以大多数周期类产品都用平均周期法先收集历次经期“开始日”的间隔天数算平均值作为下次预测的中枢同时记录最小值和最大值给用户展示一个“区间”而不是一个“单点日期”。这套思路里有个关键概念叫“黄体期长度”。排卵之后到下次经期之间的时间相对固定医学上通常按 14 天估算。因此排卵日 下次经期日 − 14 天易孕期则取“排卵日前 5 天到排卵日后 1 天”这个窗口。注意不同资料对窗口口径有分歧有的写“前 5 后 4”产品文案里不要把这个区间说成“绝对危险期”否则很容易被用户投诉误导。那为什么不把预测放到服务端这类应用的数据极其私密用户对“经期数据上传服务器”普遍敏感纯客户端本地算数据不出手机、离线可用还省后端成本。毕设工程把预测器写成一个独立的工具类就是为了一不依赖网络、二方便单元测试。这也是我拿到源码后第一个会看的地方预测器是否独立于 Activity决定了你可不可以直接跑 JUnit 验证算法。2.2 最小可用的预测器Java 代码与逐个参数说明下面这份代码是我按最常见做法写的预测器不依赖任何第三方库可以直接放进util包里。它接收一个按时间升序排列的经期开始日列表输出下次经期、排卵日、易孕期区间、平均/最小/最大周期。import java.time.LocalDate; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; public class CyclePredictor { // 黄体期长度排卵到下次经期之间的天数医学上常按14天估算 private static final int LUTEAL_PHASE_DAYS 14; // 参与计算的最近周期数超过6个只取最近6个避免早年数据拉偏 private static final int MAX_WINDOW 6; public static Prediction predict(ListLocalDate history) { Prediction result new Prediction(); if (history null || history.size() 2) { // 数据不足时兜底下次周期按28天估算不做易孕期推算 result.nextPeriodStart history null || history.isEmpty() ? LocalDate.now().plusDays(28) : history.get(history.size() - 1).plusDays(28); result.cycleAverage 28; result.cycleMin 28; result.cycleMax 28; result.fertileStart null; result.fertileEnd null; return result; } int size Math.min(history.size(), MAX_WINDOW); ListLocalDate recent history.subList(history.size() - size, history.size()); int totalGap 0; int minGap Integer.MAX_VALUE; int maxGap 0; for (int i 1; i recent.size(); i) { // 两个“开始日”之间的天数才是周期长度 int gap (int) ChronoUnit.DAYS.between(recent.get(i - 1), recent.get(i)); totalGap gap; minGap Math.min(minGap, gap); maxGap Math.max(maxGap, gap); } int avgGap Math.round(totalGap / (float) (recent.size() - 1)); LocalDate lastStart recent.get(recent.size() - 1); result.nextPeriodStart lastStart.plusDays(avgGap); result.cycleAverage avgGap; result.cycleMin minGap; result.cycleMax maxGap; // 排卵日下次经期-14天易孕期取排卵日前5天到后1天 result.ovulationDate result.nextPeriodStart.minusDays(LUTEAL_PHASE_DAYS); result.fertileStart result.ovulationDate.minusDays(5); result.fertileEnd result.ovulationDate.plusDays(1); return result; } public static class Prediction { public LocalDate nextPeriodStart; public LocalDate ovulationDate; public LocalDate fertileStart; public LocalDate fertileEnd; public int cycleAverage; public int cycleMin; public int cycleMax; } }代码逻辑分三段先做数据量检查不足两次记录就兜底返回 28 天再取最近 6 次开始日循环累加间隔并记录最小最大值最后用“平均间隔”推到下次经期再往回推排卵日和易孕期。三个参数值得你调LUTEAL_PHASE_DAYS不同医学口径可能写 12 到 16 天MAX_WINDOW建议至少 3数据越少窗口就得越小avgGap用Math.round而不是直接截断否则周期 29.6 天会被算成 29 天日积月累误差越来越大。提示subList 返回的是原列表的视图只读没问题如果后续要改写记得先new ArrayList(recent)拷贝一份。2.3 状态记录只存“观察值”不参与周期计算“状态”指的是用户在某次经期里的主观感受和客观表现流量多少、痛经程度、情绪波动、备注文字。这些字段的价值在于统计展示比如“最近三个月痛经次数”设计表时注意它永远不要参与周期预测算法——预测只需要“开始日”这一列其他字段哪怕全为空也不能影响预测结果。字段类型含义是否参与预测idINTEGER主键自增否start_dateTEXTISO 格式日期如 2025-06-08是end_dateTEXT经期结束日可为空否只用于算时长flow_levelINTEGER流量1 少 / 2 中 / 3 多否pain_levelINTEGER痛经0 无 / 1 轻 / 2 中 / 3 重否moodTEXT情绪备注如“烦躁”“低落”否noteTEXT自由文本备注否字段设计上最容易犯的错是把开始日存成long毫秒时间戳。毫秒值在 UI 上展示时需要反复做时区转换只要用户换过一次手机、跨过一次时区日期就会偏移一天。我一般统一用 ISO 字符串yyyy-MM-dd存储LocalDate解析和展示都直接排序也天然是字典序即时间序。3. 把源码工程跑起来项目结构、SQLite 表与日历记录页3.1 先读项目说明确认入口、SDK 版本与构建通道拿到压缩包之后别急着用 Android Studio 打开先解压看“项目说明”。这类工程最常见的坑是Gradle 版本和你本机 JDK 不匹配Sync 直接红一片。项目说明里通常会写清楚 minSdk、targetSdk、以及依赖了哪些第三方库。我一般会先打开build.gradle确认三件事compileSdk是否和本地已安装的 SDK 一致minSdk是多少这决定能不能直接用java.time以及是否引入了 Room 还是纯手写 SQLite。确认完再 Sync能省掉大半导入报错。一份典型的此类工程目录结构如下拿到源码后先按这个顺序读代码文件/目录职责读它的目的app/src/main/java/.../model/数据实体看字段是否和表结构对齐db/DBHelper.javaSQLiteOpenHelper看建表语句和版本号util/CyclePredictor.java周期预测算法看预测口径和参数ui/MainActivity.java记录页主入口看流程录入→保存→刷新ui/CalendarAdapter.java日历网格适配器看标记逻辑和月份切换ui/HistoryActivity.java历史记录列表看编辑/删除是否重算3.2 数据库设计一行数据一个周期事件数据库用 SQLite 足够表结构按 2.3 节的字段来建。重点有两个start_date加UNIQUE约束防止用户重复录入同一天end_date允许为空因为经期还没结束时用户可以只记录开始日。建表语句如下CREATE TABLE period_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, start_date TEXT NOT NULL UNIQUE, end_date TEXT, flow_level INTEGER, pain_level INTEGER, mood TEXT, note TEXT );插入数据时我一般用insertWithOnConflict配合CONFLICT_IGNORE这样撞了唯一约束不会抛异常只返回 -1再由业务层提示用户“这一天已有记录”。好处是不用先查一遍再插少一次 IO代码也短ContentValues cv new ContentValues(); cv.put(start_date, record.startDate.toString()); // 日期统一用 ISO 字符串 cv.put(end_date, record.endDate null ? null : record.endDate.toString()); cv.put(flow_level, record.flowLevel); cv.put(pain_level, record.painLevel); cv.put(mood, record.mood); cv.put(note, record.note); long rowId db.insertWithOnConflict(period_record, null, cv, SQLiteDatabase.CONFLICT_IGNORE); if (rowId -1) { // 界面弹 Toast 提示该开始日已存在请去历史页编辑 }查询历史周期开始日时记得ORDER BY start_date ASC预测器强依赖列表升序。拿到Cursor后逐行解析成LocalDate这里有一个隐藏约束入库格式必须是yyyy-MM-dd否则LocalDate.parse直接抛异常。如果项目说明里说“日期存的是yyyy/MM/dd”那你得先做一次数据迁移或者统一改成LocalDate.parse(date, DateTimeFormatter.ofPattern(yyyy/MM/dd))别两边混着用。3.3 日历网格与经期标记RecyclerView 的 42 个格子日历记录页是这类 APP 的门面主流实现是 RecyclerView 嵌套 GridLayoutManager一格一天。难点不在 RecyclerView而在“怎么生成一个月的格子数据”。一次显示 42 个格子6 行 × 7 列是最省事的方案因为无论 28 天还是 31 天的月份加上前导空格后都不会超过 42 格。生成格子的核心代码private ListLocalDate buildMonthGrid(int year, int month) { ListLocalDate cells new ArrayList(); LocalDate firstOfMonth LocalDate.of(year, month, 1); // 周一作为第一列MONDAY.getValue()1映射后为0 int leadingEmpty (firstOfMonth.getDayOfWeek().getValue() 6) % 7; LocalDate cursor firstOfMonth.minusDays(leadingEmpty); for (int i 0; i 42; i) { cells.add(cursor); cursor cursor.plusDays(1); } return cells; }leadingEmpty的计算把“周一”对应的DayOfWeek值 1 映射成 0周日映射成 6正好对应第一列到最后一列。LocalDate.of(year, month, 1)的 month 是 1 到 12和Calendar.MONTH从 0 开始完全不一样这个细节坑过无数人尽量在项目里彻底封死Calendar只在初始化日期选择器时用它。标记逻辑写在onBindViewHolder里三个判断依次来先判断格子日期是否属于当前显示月份不属于就置灰再判断是否在SetLocalDate periodSet里是则给红色圆底最后判断是否落在易孕期区间[fertileStart, fertileEnd]内是则给橙色圆角。判断区间用compareTo比用contains遍历更快因为区间是连续的。点击某个格子时弹一个DatePickerDialog或自行实现的选择面板把选中的日期写入数据库后重新加载periodSet并notifyDataSetChanged()。这里有个经验数据库读写尽量都放到子线程或runOnIdle否则切换月份卡顿非常明显。4. 预测偏差、数据重复、低版本崩溃5 个常见坑4.1 预测日期差一天月份从 0 开始与时区偏移现象用户明明按真实日期录入的日历上的经期标记却出现在前一个月的末尾预测结果也差一天。原因查下来通常有两类。第一类是用了Calendar.MONTH它从 0 开始传入 5 表示 6 月代码写成Calendar.MONTH, month后标记整体偏移一个月。第二类是拿Date.getTime()毫秒直接加24 * 60 * 60 * 1000来推天数遇到夏令时地区或某些时区跨日时刻偏移就会差一天。解决日期在存储、计算、比较三处全部改用LocalDate只在界面展示和日期选择器回调用时转格式。项目里凡是出现Calendar.MONTH和86400000L的地方全部替换掉。4.2 新用户零数据空列表导致的崩溃现象第一次安装打开 APP没录过任何记录点“预测”页面直接白屏Logcat 报NullPointerException。原因预测器对空列表调用了get(last)或者算平均间隔时recent.size() - 1等于 0触发除零。这类工程 80% 的崩溃都发生在边界数据上而不是主流程。解决少于两条记录就不做预测界面提示“至少记录两个月后才会给出预测”兜底逻辑放在CyclePredictor.predict()内部不要在 Activity 里判断。这样无论从哪个入口调用都不会崩。4.3 同一天的经期被录了两次统计全乱现象用户某天补录时把同一个开始日又录了一遍历史页出现两条相同日期的记录周期间隔变成 0 天预测结果直接错乱。原因建表没加UNIQUE(start_date)业务层也没查重数据库接受了重复数据。解决建表加唯一约束插入用insertWithOnConflict(CONFLICT_IGNORE)返回 -1 时提示用户“这一天已有记录请去历史页编辑”。如果项目里已经跑出了脏数据写一条清理 SQL 按start_date去重保留id最小的一条再补上唯一索引。4.4 API 26 以下跑 java.timeNoSuchMethodError现象在 Android 6.0 或 7.0 的测试机上一进日历页就崩Logcat 报NoSuchMethodError: java.time.LocalDate.parse。原因java.time是 Android API 26 才引入的。项目说明里如果写了minSdk 21但代码直接用了LocalDate低版本系统里不存在这个类运行时必然崩。解决两条路。要么把minSdk提到 26放弃 8.0 以下的老设备毕设演示完全够用要么在build.gradle里开coreLibraryDesugaring把java.time部分支持到低版本。我更推荐前者省心少引依赖。如果项目说明里的 API 等级和你实际跑的设备不一致先按设备调整。4.5 改了开始日期预测没变先查数据有没有重载现象用户在历史页把某个周期的开始日从 5 号改成 7 号回到首页发现预测日期纹丝不动。原因分两种。如果改的是end_date那预测不变是正常的因为算法只消费start_date结束日只影响时长展示如果改的是start_date那就是保存成功后没有重新从数据库加载列表UI 还在用旧数据。解决每次编辑保存成功立即回调刷新重新loadPeriodStartDates()再调CyclePredictor.predict()最后notifyDataSetChanged()。同时把“结束日不参与周期预测”写进项目说明文档避免评审老师误以为是 bug。5. 进阶与验证加权预测、回测偏差与“区间化”展示预测精度不是平均周期法的强项但它简单、可解释、易实现。想再进一步可以用“加权平均”替代“算术平均”最近一次周期对下一次的参考价值最大给它更高权重。代码只在平均周期处改一个循环// 越靠后的间隔权重越高最后一次间隔参考价值最大 public static int weightedAverageCycle(ListLocalDate history) { int n history.size(); int sum 0; int totalWeight 0; for (int i n - 1; i 1; i--) { int gap (int) ChronoUnit.DAYS.between(history.get(i - 1), history.get(i)); int weight i; // 最近的间隔 weight 最大 sum gap * weight; totalWeight weight; } return Math.round(sum / (float) totalWeight); }别小看这个改动对周期规律明显波动的用户比如“最近三个月从 28 慢慢拉到 34 天”的情况加权预测能更快跟上趋势算术平均反而会滞后。跑通后建议再做一次“回测”用前几次记录预测下一次把预测值和真实值比较统计平均偏差多少天。我习惯在设置页放一行小字展示“最近 6 次回测平均偏差 ±X 天”既让用户对预测有信心也让你自己心里有底。产品展示上把“预计 6 月 28 号来”改成“预计 6 月 25 日 7 月 1 日之间来”用第 2 章的cycleMin和cycleMax拼区间用户接受度高得多。我第一版就吃过这个亏——把预测日期写得像闹钟一样精确结果用户提前两天来就截图说我不准改成区间加一句“周期受作息、压力波动影响”之后类似的抱怨基本消失。这套“记录→预测→区间化展示→自我验证”的循环才是这类 APP 真正值得投入打磨的地方。希望帮到你。本文还有配套的精品资源点击获取