每日挑战功能——日常学习激励设计一、每日挑战的产品逻辑每日挑战是学习应用中的重要激励机制。它的核心设计原则是固定性同一天内所有用户看到相同的 5 道题公平竞争变化性每天自动更换题目新鲜感完成感每日答题 打卡形成规律的学习节奏成就感连续打卡天数、正确率等数据驱动激励本项目的DailyChallengeManager实现了这个完整机制。二、DailyChallengeManager 源码分析2.1 响应式单例ObservedV2exportclassDailyChallengeManager{privatestaticinstance?:DailyChallengeManager;privateSTORAGE_KEY:stringPreferConstant.DAILY_CHALLENGE_DATA;TracetodayCompleted:booleanfalse;TracetodayCorrectCount:number0;TracetodayTotalCount:number0;TracecheckInDays:string[][];TracecurrentStreak:number0;TracechallengeQuestions:TopicItemType[][];privateconstructor(){this.refreshFromPreferences();}publicstaticgetInstance():DailyChallengeManager{if(!DailyChallengeManager.instance){DailyChallengeManager.instancenewDailyChallengeManager();}returnDailyChallengeManager.instance;}关键设计点ObservedV2单例所有Trace字段变化时 UI 自动刷新构造时调用refreshFromPreferences()从本地存储恢复状态每天 5 道题通过日期种子算法保证同一天题目固定2.2 每日题目生成generateDailyChallenge(allQuestions:TopicItemType[]):void{if(allQuestions.length0){return;}lettodayStrthis.getTodayStr();// 检查是否已生成本日题目letexistingQuestionsthis.challengeQuestions;if(existingQuestions.length5this.todayCompletedfalse){letfirstKeyexistingQuestions.length0?existingQuestions[0].keyID:;letdateSeedtodayStr.split(-).join();if(firstKey.startsWith(DC_dateSeed)){return;// 题目已存在直接复用}}// 日期字符串 → 数字种子letseed0;for(leti0;itodayStr.length;i){seedseed*31todayStr.charCodeAt(i);}seedMath.abs(seed);// Fisher-Yates 洗牌算法选取 5 道题letcountMath.min(5,allQuestions.length);letselected:TopicItemType[][];letindices:number[][];for(leti0;iallQuestions.length;i){indices.push(i);}for(letiindices.length-1;i0;i--){// 线性同余伪随机seed(seed*168077)%2147483647;letjseed%(i1);lettempindices[i];indices[i]indices[j];indices[j]temp;}for(leti0;icount;i){selected.push(allQuestions[indices[i]]);}this.challengeQuestionsselected;this.saveToPreferences();}种子算法将日期字符串2026-07-08的每个字符的 charCode 依次乘以 31 后累加生成一个整数种子。这个种子在同一天内是确定性的不同天则不同。Fisher-Yates 洗牌基于种子进行伪随机排列。每次从剩余元素中选取一个与当前位置交换保证了所有排列等概率。2.3 打卡系统checkIn():void{lettodayStrthis.getTodayStr();if(this.isCheckedInToday()){return;// 同一天不能重复打卡}this.checkInDays.push(todayStr);this.updateStreak();this.saveToPreferences();}isCheckedInToday():boolean{lettodayStrthis.getTodayStr();for(leti0;ithis.checkInDays.length;i){if(this.checkInDays[i]todayStr){returntrue;}}returnfalse;}打卡操作每天只能打卡一次已打卡则跳过打卡日期以YYYY-MM-DD格式存储打卡后更新连续天数2.4 连续天数计算privateupdateStreak():void{if(this.checkInDays.length0){this.currentStreak0;return;}letsortedthis.checkInDays.slice().sort();letstreak1;lettodayStrthis.getTodayStr();letyesterdaynewDate();yesterday.setDate(yesterday.getDate()-1);letyesterdayStr${yesterday.getFullYear()}-...;// 最后一次打卡不是今天或昨天 → 断签letlastCheckInsorted[sorted.length-1];if(lastCheckIn!todayStrlastCheckIn!yesterdayStr){this.currentStreak0;return;}// 从后往前遍历统计连续天数for(letisorted.length-1;i0;i--){letcurrentnewDate(sorted[i]);letprevnewDate(sorted[i-1]);letdiffMscurrent.getTime()-prev.getTime();letdiffDaysMath.round(diffMs/(1000*60*60*24));if(diffDays1){streak;}else{break;}}this.currentStreakstreak;}连续天数计算的关键逻辑如果最近一次打卡不是今天或昨天重置为 0断签从后往前遍历已排序的打卡日期统计连续天数连续的定义是相邻两次打卡相差 1 天同一天多次打卡只计一次由 checkIn 方法确保2.5 日历打卡数据getCheckInCalendarMonth(year:number,month:number):boolean[]{letresult:boolean[]newArray(31).fill(false)asboolean[];letmonthStr${year}-${month.toString().padStart(2,0)}-;for(leti0;ithis.checkInDays.length;i){if(this.checkInDays[i].startsWith(monthStr)){letdayStrthis.checkInDays[i].substring(monthStr.length);letdayparseInt(dayStr,10);if(day1day31){result[day-1]true;}}}returnresult;}返回一个长度为 31 的布尔数组标识当月每一天的打卡状态。这个数据可以驱动日历视图的渲染——已打卡的日期显示特殊标记。三、完成挑战completeChallenge(correctCount:number,totalCount:number):void{this.todayCompletedtrue;this.todayCorrectCountcorrectCount;this.todayTotalCounttotalCount;this.saveToPreferences();}完成当日挑战后记录正确数和总数标记todayCompleted true。四、每日重置refreshFromPreferences():void{letdataPreferenceUtil.getInstance().get(this.STORAGE_KEY,this.getDefaultData())asDailyChallengeData;if(data){this.todayCompleteddata.todayCompleted??false;this.todayCorrectCountdata.todayCorrectCount??0;this.todayTotalCountdata.todayTotalCount??0;this.checkInDaysdata.checkInDays??[];this.currentStreakdata.currentStreak??0;this.challengeQuestionsdata.challengeQuestions??[];}lettodayStrthis.getTodayStr();letlastDaythis.checkInDays.length0?this.checkInDays[this.checkInDays.length-1]:;if(lastDay!todayStr){// 新的一天重置完成状态this.todayCompletedfalse;this.todayCorrectCount0;this.todayTotalCount0;}}在refreshFromPreferences中如果检测到最后一次打卡不是今天就自动重置今日的完成状态和成绩。这意味着用户每天打开应用时挑战状态会自动刷新。五、与 LearningPlan 的关系DailyChallengeManager 和 LearningPlanManager 共同构成了学习激励体系维度DailyChallengeManagerLearningPlanManager周期每日长期累积核心数据每日 5 题、打卡每日目标、总进度激励方式每日完成感徽章成就系统数据存储DAILY_CHALLENGE_DATALEARNING_PLANDailyChallengeManager 关注今天的挑战LearningPlanManager 关注长期的成长。六、DailyChallengePage 页面设计DailyChallengePage 展示每日挑战内容关键 UI 元素包括当天挑战进度已完成/未完成、正确数/总数5 道挑战题目的答题区域打卡按钮连续打卡天数展示月度打卡日历这些 UI 元素的数据全部来自 DailyChallengeManager 的Trace字段数据更新自动触发 UI 刷新。七、总结每日挑战功能通过日期种子伪随机算法实现了同题不同天的机制结合打卡和连续天数计算构建了一个完整的日常学习激励体系。ObservedV2响应式单例让状态管理变得简单PreferenceUtil 持久化保证了数据在应用重启后不丢失。对于希望增加用户粘性的学习类应用每日挑战是一个非常有效的功能模块。