疾阅App阅读训练功能技术解析
继续聊聊疾阅App,这次是阅读训练功能。想体验完整功能,可以去鸿蒙应用市场搜索"疾阅"下载。
写在前面
上一篇我们聊了疾阅App的文字动画功能,今天聊聊阅读训练——怎么设计训练计划、记录训练数据、评估训练效果。
阅读训练是个长期过程,需要持续的数据记录和分析。Web端用localStorage或IndexedDB存储训练数据,鸿蒙端用RDB更合适,特别是需要复杂查询和统计的时候。
今天这篇,我会从训练计划、数据记录、效果评估这几个方面,聊聊疾阅App的阅读训练功能。
1. 训练计划:课程设计
疾阅App提供多种训练课程。
课程数据结构:
interface TrainingCourse {
id: string;
name: string;
description: string;
level: 'beginner' | 'intermediate' | 'advanced';
lessons: Lesson[];
totalDuration: number; // 总时长(分钟)
}
interface Lesson {
id: string;
title: string;
type: LessonType;
content: string;
targetWPM: number; // 目标阅读速度
duration: number; // 预计时长(秒)
}
enum LessonType {
SPEED_READING = 'speed_reading',
COMPREHENSION = 'comprehension',
FLASH_READING = 'flash_reading',
CHUNK_READING = 'chunk_reading'
}
// 预设课程
const trainingCourses: TrainingCourse[] = [
{
id: 'basic_speed',
name: '基础速读',
description: '适合初学者的速读入门课程',
level: 'beginner',
totalDuration: 30,
lessons: [
{
id: 'l1',
title: '词语闪现',
type: LessonType.FLASH_READING,
content: '快速识别闪现的词语,训练视觉反应速度',
targetWPM: 200,
duration: 300
},
{
id: 'l2',
title: '句子速读',
type: LessonType.SPEED_READING,
content: '以固定速度阅读句子,逐步提高阅读速度',
targetWPM: 300,
duration: 600
},
{
id: 'l3',
title: '段落理解',
type: LessonType.COMPREHENSION,
content: '阅读段落后回答问题,测试理解能力',
targetWPM: 250,
duration: 900
}
]
},
{
id: 'advanced_speed',
name: '高级速读',
description: '挑战更高阅读速度的进阶课程',
level: 'advanced',
totalDuration: 60,
lessons: [
{
id: 'l4',
title: '块状阅读',
type: LessonType.CHUNK_READING,
content: '一次阅读多个词语,扩大视幅',
targetWPM: 500,
duration: 600
},
{
id: 'l5',
title: '快速扫读',
type: LessonType.SPEED_READING,
content: '快速扫视文章,提取关键信息',
targetWPM: 800,
duration: 900
}
]
}
];
2. 训练记录:数据存储
ArkTS训练记录管理:
import { relationalStore } from '@kit.ArkData';
interface TrainingRecord {
id: string;
courseId: string;
lessonId: string;
startTime: number;
endTime: number;
wordCount: number;
wpm: number;
comprehensionScore?: number;
completed: boolean;
}
class TrainingDatabase {
private rdbStore: relationalStore.RdbStore | null = null;
async init(context: Context) {
const config: relationalStore.StoreConfig = {
name: 'reading_training.db',
securityLevel: relationalStore.SecurityLevel.S1
};
this.rdbStore = await relationalStore.getRdbStore(context, config);
await this.rdbStore.executeSql(`
CREATE TABLE IF NOT EXISTS training_records (
id TEXT PRIMARY KEY,
course_id TEXT NOT NULL,
lesson_id TEXT NOT NULL,
start_time INTEGER,
end_time INTEGER,
word_count INTEGER,
wpm REAL,
comprehension_score INTEGER,
completed INTEGER DEFAULT 0
)
`);
}
// 保存训练记录
async saveRecord(record: TrainingRecord): Promise<void> {
if (!this.rdbStore) return;
const bucket: relationalStore.ValuesBucket = {
id: record.id,
course_id: record.courseId,
lesson_id: record.lessonId,
start_time: record.startTime,
end_time: record.endTime,
word_count: record.wordCount,
wpm: record.wpm,
comprehension_score: record.comprehensionScore,
completed: record.completed ? 1 : 0
};
await this.rdbStore.insert('training_records', bucket);
}
// 获取用户的所有记录
async getRecords(): Promise<TrainingRecord[]> {
if (!this.rdbStore) return [];
const resultSet = await this.rdbStore.querySql(
'SELECT * FROM training_records ORDER BY start_time DESC'
);
const records: TrainingRecord[] = [];
while (resultSet.goToNextRow()) {
records.push({
id: resultSet.getString(resultSet.getColumnIndex('id')),
courseId: resultSet.getString(resultSet.getColumnIndex('course_id')),
lessonId: resultSet.getString(resultSet.getColumnIndex('lesson_id')),
startTime: resultSet.getLong(resultSet.getColumnIndex('start_time')),
endTime: resultSet.getLong(resultSet.getColumnIndex('end_time')),
wordCount: resultSet.getLong(resultSet.getColumnIndex('word_count')),
wpm: resultSet.getDouble(resultSet.getColumnIndex('wpm')),
comprehensionScore: resultSet.getLong(resultSet.getColumnIndex('comprehension_score')),
completed: resultSet.getLong(resultSet.getColumnIndex('completed')) === 1
});
}
resultSet.close();
return records;
}
// 获取课程记录
async getCourseRecords(courseId: string): Promise<TrainingRecord[]> {
if (!this.rdbStore) return [];
const resultSet = await this.rdbStore.querySql(
'SELECT * FROM training_records WHERE course_id = ? ORDER BY start_time DESC',
[courseId]
);
const records: TrainingRecord[] = [];
while (resultSet.goToNextRow()) {
records.push({
id: resultSet.getString(resultSet.getColumnIndex('id')),
courseId: resultSet.getString(resultSet.getColumnIndex('course_id')),
lessonId: resultSet.getString(resultSet.getColumnIndex('lesson_id')),
startTime: resultSet.getLong(resultSet.getColumnIndex('start_time')),
endTime: resultSet.getLong(resultSet.getColumnIndex('end_time')),
wordCount: resultSet.getLong(resultSet.getColumnIndex('word_count')),
wpm: resultSet.getDouble(resultSet.getColumnIndex('wpm')),
comprehensionScore: resultSet.getLong(resultSet.getColumnIndex('comprehension_score')),
completed: resultSet.getLong(resultSet.getColumnIndex('completed')) === 1
});
}
resultSet.close();
return records;
}
// 获取统计数据
async getStatistics(): Promise<TrainingStatistics> {
const records = await this.getRecords();
if (records.length === 0) {
return {
totalSessions: 0,
totalWords: 0,
totalTime: 0,
averageWPM: 0,
highestWPM: 0,
improvement: 0
};
}
const totalWords = records.reduce((sum, r) => sum + r.wordCount, 0);
const totalTime = records.reduce((sum, r) => sum + (r.endTime - r.startTime), 0);
const wpmValues = records.map(r => r.wpm);
const averageWPM = wpmValues.reduce((a, b) => a + b, 0) / wpmValues.length;
const highestWPM = Math.max(...wpmValues);
// 计算进步幅度(最近10次 vs 最早10次)
let improvement = 0;
if (records.length >= 20) {
const recent10 = records.slice(0, 10);
const early10 = records.slice(-10);
const recentAvg = recent10.reduce((sum, r) => sum + r.wpm, 0) / 10;
const earlyAvg = early10.reduce((sum, r) => sum + r.wpm, 0) / 10;
improvement = ((recentAvg - earlyAvg) / earlyAvg) * 100;
}
return {
totalSessions: records.length,
totalWords,
totalTime,
averageWPM: Math.round(averageWPM),
highestWPM,
improvement: Math.round(improvement)
};
}
}
interface TrainingStatistics {
totalSessions: number;
totalWords: number;
totalTime: number;
averageWPM: number;
highestWPM: number;
improvement: number;
}
3. 训练界面:课程执行
ArkTS训练界面:
@Component
struct TrainingPage {
@State currentLesson: Lesson | null = null;
@State isTraining: boolean = false;
@State elapsedTime: number = 0;
@State wordsRead: number = 0;
@State currentWPM: number = 0;
@Prop courseId: string = '';
@Prop lessonId: string = '';
private startTime: number = 0;
private timer?: number;
async aboutToAppear() {
// 加载课程数据
await this.loadLesson();
}
async loadLesson() {
// 从数据库或本地数据加载
const course = trainingCourses.find(c => c.id === this.courseId);
if (course) {
this.currentLesson = course.lessons.find(l => l.id === this.lessonId) || null;
}
}
build() {
Column() {
if (this.currentLesson) {
// 课程信息
Text(this.currentLesson.title)
.fontSize(24)
.fontWeight(FontWeight.Bold)
.margin({ top: 20 })
Text(this.currentLesson.description)
.fontSize(14)
.fontColor('#666')
.margin({ top: 8 })
// 训练区域
if (this.isTraining) {
this.TrainingArea()
} else {
this.PreTrainingInfo()
}
} else {
Text('课程加载失败')
.fontSize(16)
.fontColor('#F44336')
}
}
}
@Builder
PreTrainingInfo() {
Column() {
// 目标信息
Row() {
this.InfoCard('目标WPM', `${this.currentLesson?.targetWPM}`)
this.InfoCard('预计时长', `${Math.round((this.currentLesson?.duration || 0) / 60)}分钟`)
}
.width('90%')
.margin({ top: 20 })
Button('开始训练')
.width('90%')
.height(50)
.margin({ top: 30 })
.onClick(() => this.startTraining())
}
}
@Builder
InfoCard(title: string, value: string) {
Column() {
Text(value)
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#2196F3')
Text(title)
.fontSize(14)
.fontColor('#666')
.margin({ top: 4 })
}
.layoutWeight(1)
.padding(16)
.backgroundColor('#fff')
.borderRadius(12)
.margin(4)
}
@Builder
TrainingArea() {
Column() {
// 训练内容
if (this.currentLesson?.type === LessonType.SPEED_READING) {
ScrollingText({
text: this.currentLesson.content,
speed: 60000 / this.currentLesson.targetWPM
})
} else if (this.currentLesson?.type === LessonType.FLASH_READING) {
FlashText({ text: '示例词语' })
}
// 统计信息
Row() {
this.StatDisplay('时间', this.formatTime(this.elapsedTime))
this.StatDisplay('已读词数', this.wordsRead.toString())
this.StatDisplay('当前WPM', this.currentWPM.toString())
}
.width('90%')
.margin({ top: 20 })
// 控制按钮
Row() {
Button('暂停')
.layoutWeight(1)
.onClick(() => this.pauseTraining())
Button('结束')
.layoutWeight(1)
.margin({ left: 12 })
.onClick(() => this.endTraining())
}
.width('90%')
.margin({ top: 20 })
}
}
@Builder
StatDisplay(title: string, value: string) {
Column() {
Text(value)
.fontSize(20)
.fontWeight(FontWeight.Bold)
Text(title)
.fontSize(12)
.fontColor('#666')
.margin({ top: 2 })
}
.layoutWeight(1)
}
startTraining() {
this.isTraining = true;
this.startTime = Date.now();
this.elapsedTime = 0;
this.wordsRead = 0;
this.timer = setInterval(() => {
this.elapsedTime = (Date.now() - this.startTime) / 1000;
this.calculateWPM();
}, 100);
}
pauseTraining() {
this.isTraining = false;
if (this.timer) {
clearInterval(this.timer);
this.timer = undefined;
}
}
async endTraining() {
this.pauseTraining();
// 保存记录
const record: TrainingRecord = {
id: `record_${Date.now()}`,
courseId: this.courseId,
lessonId: this.lessonId,
startTime: this.startTime,
endTime: Date.now(),
wordCount: this.wordsRead,
wpm: this.currentWPM,
completed: true
};
const db = new TrainingDatabase();
await db.init(getContext());
await db.saveRecord(record);
// 显示结果
this.showResults();
}
calculateWPM() {
if (this.elapsedTime > 0) {
this.currentWPM = Math.round((this.wordsRead / this.elapsedTime) * 60);
}
}
showResults() {
// 跳转到结果页面
router.pushUrl({
url: 'pages/TrainingResult',
params: {
wpm: this.currentWPM,
wordsRead: this.wordsRead,
time: this.elapsedTime,
targetWPM: this.currentLesson?.targetWPM
}
});
}
formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
aboutToDisappear() {
if (this.timer) {
clearInterval(this.timer);
}
}
}
4. 进度追踪:数据可视化
ArkTS进度图表:
@Component
struct ProgressChart {
@State records: TrainingRecord[] = [];
@State timeRange: 'week' | 'month' | 'year' = 'week';
async aboutToAppear() {
await this.loadRecords();
}
async loadRecords() {
const db = new TrainingDatabase();
await db.init(getContext());
this.records = await db.getRecords();
}
build() {
Column() {
// 时间范围选择
Row() {
Button('本周')
.backgroundColor(this.timeRange === 'week' ? '#2196F3' : '#f0f0f0')
.fontColor(this.timeRange === 'week' ? '#fff' : '#333')
.onClick(() => this.timeRange = 'week')
Button('本月')
.backgroundColor(this.timeRange === 'month' ? '#2196F3' : '#f0f0f0')
.fontColor(this.timeRange === 'month' ? '#fff' : '#333')
.margin({ left: 8 })
.onClick(() => this.timeRange = 'month')
Button('全年')
.backgroundColor(this.timeRange === 'year' ? '#2196F3' : '#f0f0f0')
.fontColor(this.timeRange === 'year' ? '#fff' : '#333')
.margin({ left: 8 })
.onClick(() => this.timeRange = 'year')
}
.margin({ top: 20 })
// WPM趋势图
Canvas(this.drawChart)
.width('90%')
.height(250)
.backgroundColor('#fff')
.borderRadius(12)
.margin({ top: 16 })
// 统计卡片
Row() {
this.StatCard('平均WPM', this.getAverageWPM().toString())
this.StatCard('最高WPM', this.getHighestWPM().toString())
this.StatCard('训练次数', this.records.length.toString())
}
.width('90%')
.margin({ top: 16 })
}
}
@Builder
StatCard(title: string, value: string) {
Column() {
Text(value)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#2196F3')
Text(title)
.fontSize(12)
.fontColor('#666')
.margin({ top: 2 })
}
.layoutWeight(1)
.padding(12)
.backgroundColor('#f5f5f5')
.borderRadius(8)
.margin(4)
}
drawChart = (ctx: CanvasRenderingContext2D) => {
const width = 300;
const height = 250;
const padding = 40;
const filteredRecords = this.getFilteredRecords();
if (filteredRecords.length === 0) return;
const wpmValues = filteredRecords.map(r => r.wpm);
const maxWPM = Math.max(...wpmValues);
const minWPM = Math.min(...wpmValues);
// 绘制坐标轴
ctx.beginPath();
ctx.moveTo(padding, padding);
ctx.lineTo(padding, height - padding);
ctx.lineTo(width - padding, height - padding);
ctx.strokeStyle = '#ddd';
ctx.stroke();
// 绘制数据点
const stepX = (width - 2 * padding) / (filteredRecords.length - 1);
ctx.beginPath();
filteredRecords.forEach((record, index) => {
const x = padding + index * stepX;
const y = padding + (height - 2 * padding) * (1 - (record.wpm - minWPM) / (maxWPM - minWPM));
if (index === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
ctx.strokeStyle = '#2196F3';
ctx.lineWidth = 2;
ctx.stroke();
// 绘制目标线
const targetWPM = 300; // 示例目标
const targetY = padding + (height - 2 * padding) * (1 - (targetWPM - minWPM) / (maxWPM - minWPM));
ctx.beginPath();
ctx.moveTo(padding, targetY);
ctx.lineTo(width - padding, targetY);
ctx.strokeStyle = '#F44336';
ctx.lineWidth = 1;
ctx.setLineDash([5, 5]);
ctx.stroke();
}
getFilteredRecords(): TrainingRecord[] {
const now = Date.now();
let startTime = 0;
switch (this.timeRange) {
case 'week':
startTime = now - 7 * 24 * 60 * 60 * 1000;
break;
case 'month':
startTime = now - 30 * 24 * 60 * 60 * 1000;
break;
case 'year':
startTime = now - 365 * 24 * 60 * 60 * 1000;
break;
}
return this.records.filter(r => r.startTime >= startTime).reverse();
}
getAverageWPM(): number {
const filtered = this.getFilteredRecords();
if (filtered.length === 0) return 0;
return Math.round(filtered.reduce((sum, r) => sum + r.wpm, 0) / filtered.length);
}
getHighestWPM(): number {
const filtered = this.getFilteredRecords();
if (filtered.length === 0) return 0;
return Math.max(...filtered.map(r => r.wpm));
}
}
5. 成就系统:激励机制
ArkTS成就系统:
interface Achievement {
id: string;
name: string;
description: string;
icon: string;
condition: (stats: TrainingStatistics) => boolean;
unlocked: boolean;
}
const achievements: Achievement[] = [
{
id: 'first_training',
name: '初次训练',
description: '完成第一次训练',
icon: '🎯',
condition: (stats) => stats.totalSessions >= 1,
unlocked: false
},
{
id: 'speed_demon',
name: '速读达人',
description: '达到500 WPM',
icon: '⚡',
condition: (stats) => stats.highestWPM >= 500,
unlocked: false
},
{
id: 'consistent_reader',
name: '坚持不懈',
description: '连续7天训练',
icon: '🔥',
condition: (stats) => stats.totalSessions >= 7,
unlocked: false
},
{
id: 'word_master',
name: '万词阅读',
description: '累计阅读10000个词',
icon: '📚',
condition: (stats) => stats.totalWords >= 10000,
unlocked: false
}
];
@Component
struct AchievementPage {
@State achievements: Achievement[] = [];
@State statistics: TrainingStatistics | null = null;
async aboutToAppear() {
await this.loadData();
this.checkAchievements();
}
async loadData() {
const db = new TrainingDatabase();
await db.init(getContext());
this.statistics = await db.getStatistics();
// 加载成就状态
this.achievements = achievements.map(a => ({
...a,
unlocked: a.condition(this.statistics!)
}));
}
checkAchievements() {
// 检查是否有新解锁的成就
this.achievements.forEach(achievement => {
if (achievement.unlocked) {
// 检查是否是新解锁
// ...
}
});
}
build() {
Column() {
Text('成就')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.margin({ top: 20 })
Text(`已解锁 ${this.achievements.filter(a => a.unlocked).length} / ${this.achievements.length}`)
.fontSize(14)
.fontColor('#666')
.margin({ top: 8 })
List({ space: 12 }) {
ForEach(this.achievements, (achievement: Achievement) => {
ListItem() {
Row() {
Text(achievement.icon)
.fontSize(36)
Column() {
Text(achievement.name)
.fontSize(16)
.fontWeight(FontWeight.Medium)
Text(achievement.description)
.fontSize(14)
.fontColor('#666')
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
.layoutWeight(1)
if (achievement.unlocked) {
Text('✓')
.fontSize(24)
.fontColor('#4CAF50')
}
}
.padding(16)
.backgroundColor(achievement.unlocked ? '#E8F5E9' : '#f5f5f5')
.borderRadius(12)
}
})
}
.margin({ top: 16 })
}
}
}
总结
疾阅App的阅读训练功能,从课程设计、数据记录、进度追踪到成就系统,每一部分都有它的技术要点。鸿蒙端的RDB提供了可靠的数据存储,Canvas提供了灵活的图表绘制,让训练数据的管理和展示变得简单高效。
如果你想做学习训练类App,这些功能模块都很有参考价值。课程管理、数据统计、成就激励,这些都能提高用户的参与度和粘性。
疾阅App就聊到这里。下一篇文章,我们换个方向,聊聊演界App的提词器功能。