每日挑战功能——日常学习激励设计
一、每日挑战的产品逻辑
每日挑战是学习应用中的重要激励机制。它的核心设计原则是:
- 固定性:同一天内所有用户看到相同的 5 道题(公平竞争)
- 变化性:每天自动更换题目(新鲜感)
- 完成感:每日答题 + 打卡,形成规律的学习节奏
- 成就感:连续打卡天数、正确率等数据驱动激励
本项目的DailyChallengeManager实现了这个完整机制。
二、DailyChallengeManager 源码分析
2.1 响应式单例
@ObservedV2exportclassDailyChallengeManager{privatestaticinstance?:DailyChallengeManager;privateSTORAGE_KEY:string=PreferConstant.DAILY_CHALLENGE_DATA;@TracetodayCompleted:boolean=false;@TracetodayCorrectCount:number=0;@TracetodayTotalCount:number=0;@TracecheckInDays:string[]=[];@TracecurrentStreak:number=0;@TracechallengeQuestions:TopicItemType[]=[];privateconstructor(){this.refreshFromPreferences();}publicstaticgetInstance():DailyChallengeManager{if(!DailyChallengeManager.instance){DailyChallengeManager.instance=newDailyChallengeManager();}returnDailyChallengeManager.instance;}关键设计点:
@ObservedV2单例,所有@Trace字段变化时 UI 自动刷新- 构造时调用
refreshFromPreferences()从本地存储恢复状态 - 每天 5 道题,通过日期种子算法保证同一天题目固定
2.2 每日题目生成
generateDailyChallenge(allQuestions:TopicItemType[]):void{if(allQuestions.length===0){return;}lettodayStr=this.getTodayStr();// 检查是否已生成本日题目letexistingQuestions=this.challengeQuestions;if(existingQuestions.length===5&&this.todayCompleted===false){letfirstKey=existingQuestions.length>0?existingQuestions[0].keyID:'';letdateSeed=todayStr.split('-').join('');if(firstKey.startsWith('DC_'+dateSeed)){return;// 题目已存在,直接复用}}// 日期字符串 → 数字种子letseed=0;for(leti=0;i<todayStr.length;i++){seed=seed*31+todayStr.charCodeAt(i);}seed=Math.abs(seed);// Fisher-Yates 洗牌算法选取 5 道题letcount=Math.min(5,allQuestions.length);letselected:TopicItemType[]=[];letindices:number[]=[];for(leti=0;i<allQuestions.length;i++){indices.push(i);}for(leti=indices.length-1;i>0;i--){// 线性同余伪随机seed=(seed*16807+7)%2147483647;letj=seed%(i+1);lettemp=indices[i];indices[i]=indices[j];indices[j]=temp;}for(leti=0;i<count;i++){selected.push(allQuestions[indices[i]]);}this.challengeQuestions=selected;this.saveToPreferences();}种子算法:将日期字符串"2026-07-08"的每个字符的 charCode 依次乘以 31 后累加,生成一个整数种子。这个种子在同一天内是确定性的,不同天则不同。
Fisher-Yates 洗牌:基于种子进行伪随机排列。每次从剩余元素中选取一个与当前位置交换,保证了所有排列等概率。
2.3 打卡系统
checkIn():void{lettodayStr=this.getTodayStr();if(this.isCheckedInToday()){return;// 同一天不能重复打卡}this.checkInDays.push(todayStr);this.updateStreak();this.saveToPreferences();}isCheckedInToday():boolean{lettodayStr=this.getTodayStr();for(leti=0;i<this.checkInDays.length;i++){if(this.checkInDays[i]===todayStr){returntrue;}}returnfalse;}打卡操作:
- 每天只能打卡一次(已打卡则跳过)
- 打卡日期以
YYYY-MM-DD格式存储 - 打卡后更新连续天数
2.4 连续天数计算
privateupdateStreak():void{if(this.checkInDays.length===0){this.currentStreak=0;return;}letsorted=this.checkInDays.slice().sort();letstreak=1;lettodayStr=this.getTodayStr();letyesterday=newDate();yesterday.setDate(yesterday.getDate()-1);letyesterdayStr=`${yesterday.getFullYear()}-...`;// 最后一次打卡不是今天或昨天 → 断签letlastCheckIn=sorted[sorted.length-1];if(lastCheckIn!==todayStr&&lastCheckIn!==yesterdayStr){this.currentStreak=0;return;}// 从后往前遍历,统计连续天数for(leti=sorted.length-1;i>0;i--){letcurrent=newDate(sorted[i]);letprev=newDate(sorted[i-1]);letdiffMs=current.getTime()-prev.getTime();letdiffDays=Math.round(diffMs/(1000*60*60*24));if(diffDays===1){streak++;}else{break;}}this.currentStreak=streak;}连续天数计算的关键逻辑:
- 如果最近一次打卡不是今天或昨天,重置为 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(leti=0;i<this.checkInDays.length;i++){if(this.checkInDays[i].startsWith(monthStr)){letdayStr=this.checkInDays[i].substring(monthStr.length);letday=parseInt(dayStr,10);if(day>=1&&day<=31){result[day-1]=true;}}}returnresult;}返回一个长度为 31 的布尔数组,标识当月每一天的打卡状态。这个数据可以驱动日历视图的渲染——已打卡的日期显示特殊标记。
三、完成挑战
completeChallenge(correctCount:number,totalCount:number):void{this.todayCompleted=true;this.todayCorrectCount=correctCount;this.todayTotalCount=totalCount;this.saveToPreferences();}完成当日挑战后,记录正确数和总数,标记todayCompleted = true。
四、每日重置
refreshFromPreferences():void{letdata=PreferenceUtil.getInstance().get(this.STORAGE_KEY,this.getDefaultData())asDailyChallengeData;if(data){this.todayCompleted=data.todayCompleted??false;this.todayCorrectCount=data.todayCorrectCount??0;this.todayTotalCount=data.todayTotalCount??0;this.checkInDays=data.checkInDays??[];this.currentStreak=data.currentStreak??0;this.challengeQuestions=data.challengeQuestions??[];}lettodayStr=this.getTodayStr();letlastDay=this.checkInDays.length>0?this.checkInDays[this.checkInDays.length-1]:'';if(lastDay!==todayStr){// 新的一天,重置完成状态this.todayCompleted=false;this.todayCorrectCount=0;this.todayTotalCount=0;}}在refreshFromPreferences中,如果检测到最后一次打卡不是今天,就自动重置今日的完成状态和成绩。这意味着用户每天打开应用时,挑战状态会自动刷新。
五、与 LearningPlan 的关系
DailyChallengeManager 和 LearningPlanManager 共同构成了学习激励体系:
| 维度 | DailyChallengeManager | LearningPlanManager |
|---|---|---|
| 周期 | 每日 | 长期累积 |
| 核心数据 | 每日 5 题、打卡 | 每日目标、总进度 |
| 激励方式 | 每日完成感 | 徽章成就系统 |
| 数据存储 | DAILY_CHALLENGE_DATA | LEARNING_PLAN |
DailyChallengeManager 关注"今天的挑战",LearningPlanManager 关注"长期的成长"。
六、DailyChallengePage 页面设计
DailyChallengePage 展示每日挑战内容,关键 UI 元素包括:
- 当天挑战进度(已完成/未完成、正确数/总数)
- 5 道挑战题目的答题区域
- 打卡按钮
- 连续打卡天数展示
- 月度打卡日历
这些 UI 元素的数据全部来自 DailyChallengeManager 的@Trace字段,数据更新自动触发 UI 刷新。
七、总结
每日挑战功能通过日期种子伪随机算法实现了"同题不同天"的机制,结合打卡和连续天数计算,构建了一个完整的日常学习激励体系。@ObservedV2响应式单例让状态管理变得简单,PreferenceUtil 持久化保证了数据在应用重启后不丢失。对于希望增加用户粘性的学习类应用,每日挑战是一个非常有效的功能模块。