Flutter for OpenHarmony 教育百科实战:答题挑战
摘要: 本文介绍了教育百科App中答题挑战功能的实现细节。该功能通过随机打乱选项、即时反馈答案和清晰显示进度等设计提升用户体验。主要技术点包括: 参数处理:支持可选题目分类和难度,实现随机答题模式; 状态管理:使用多个状态变量控制答题流程,防止重复作答; 核心逻辑:合并并打乱选项、解码HTML实体、延迟跳转确保用户查看结果; 异常处理:加载失败时展示友好提示,避免空白页面; 交互优化:通过push
答题挑战是教育百科App里互动性最强的功能,也是我花时间最多的一个模块。用户需要在有限时间内回答问题,答对加分,答错显示正确答案。听起来简单,但要做好用户体验,细节真的很多——选项要打乱顺序、答案要即时反馈、进度要清晰可见……
今天就来聊聊这个答题流程是怎么实现的。
从页面参数说起
答题页面需要接收两个可选参数:题目分类和难度:
class QuizScreen extends StatefulWidget {
final String? category;
final String? difficulty;
const QuizScreen({super.key, this.category, this.difficulty});
@override
State createState() => _QuizScreenState();
}
为什么是可选的?因为用户可以选择"随机答题",不指定分类和难度,这时候这两个参数就是null。API会返回随机类别的题目。
状态变量的设计
答题页面的状态比较多,我来一个个解释:
class _QuizScreenState extends State {
List _questions = [];
int _currentIndex = 0;
int _score = 0;
bool _isLoading = true;
String? _selectedAnswer;
bool _answered = false;
List _shuffledAnswers = [];
_questions:存储从API获取的题目列表_currentIndex:当前是第几题(从0开始)_score:答对了几题_isLoading:是否正在加载题目_selectedAnswer:用户选择的答案_answered:当前题目是否已作答_shuffledAnswers:打乱顺序后的选项列表
为什么需要_answered这个变量?
这是为了防止用户重复作答。一旦选择了答案,就不能再改了。如果没有这个控制,用户可以一直点直到蒙对为止,那答题就没意义了。
加载题目
页面初始化时加载题目:
@override
void initState() {
super.initState();
_loadQuestions();
}
Future _loadQuestions() async {
try {
final questions = await ApiService.getTriviaQuestions(
amount: 10,
category: widget.category,
difficulty: widget.difficulty,
);
每次加载10道题,这个数量比较合适——太少了不过瘾,太多了用户容易疲劳。
if (mounted) {
setState(() {
_questions = questions;
_isLoading = false;
if (_questions.isNotEmpty) {
_shuffleAnswers();
}
});
}
} catch (e) {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
加载完成后立刻调用_shuffleAnswers()打乱第一题的选项。注意这里的mounted检查,老生常谈了,防止Widget销毁后还调用setState。
选项打乱的逻辑
这是答题功能的核心逻辑之一:
void _shuffleAnswers() {
final question = _questions[_currentIndex];
final correctAnswer = question[‘correct_answer’] as String;
final incorrectAnswers = question[‘incorrect_answers’] as List;
final answers = [
correctAnswer,
…incorrectAnswers.map((e) => e.toString()),
];
answers.shuffle();
_shuffledAnswers = answers.map((a) => _decodeHtml(a)).toList();
}
API返回的数据里,正确答案和错误答案是分开的。我们需要把它们合并成一个列表,然后调用shuffle()方法随机打乱。
为什么要打乱?
如果不打乱,正确答案永远在第一个位置,用户很快就会发现这个规律。打乱后每道题的正确答案位置都是随机的,才能真正考验用户的知识。
HTML实体解码
API返回的文本经常包含HTML实体,比如"表示引号,'表示撇号:
String _decodeHtml(String text) {
return text
.replaceAll(‘"’, ‘"’)
.replaceAll(‘’‘, "’")
.replaceAll(‘&’, ‘&’)
.replaceAll(‘<’, ‘<’)
.replaceAll(‘>’, ‘>’)
.replaceAll(‘é’, ‘é’)
.replaceAll(‘ñ’, ‘ñ’)
.replaceAll(‘ö’, ‘ö’)
.replaceAll(‘ü’, ‘ü’);
}
这个方法把常见的HTML实体转换成正常字符。说实话,这种处理方式不够优雅,更好的做法是用html_unescape这样的库。但为了减少依赖,我选择手动处理最常见的几个。
用户选择答案的处理
这是整个答题流程最关键的部分:
void _selectAnswer(String answer) {
if (_answered) return; // 已作答则忽略
final question = _questions[_currentIndex];
final correctAnswer = _decodeHtml(question[‘correct_answer’]);
setState(() {
_selectedAnswer = answer;
_answered = true;
if (answer == correctAnswer) {
_score++;
}
});
首先检查是否已作答,已作答就直接返回。然后记录用户的选择,标记为已作答,如果答对了就加分。
// 延迟1.5秒后进入下一题
Future.delayed(const Duration(milliseconds: 1500), () {
if (!mounted) return;
if (_currentIndex < _questions.length - 1) {
setState(() {
_currentIndex++;
_selectedAnswer = null;
_answered = false;
_shuffleAnswers();
});
} else {
// 答完所有题目,跳转到结果页
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => QuizResultScreen(
score: _score,
total: _questions.length,
category: widget.category,
difficulty: widget.difficulty,
),
),
);
}
});
}
选择答案后等待1.5秒,让用户有时间看到结果(对了还是错了),然后自动进入下一题。如果是最后一题,就跳转到结果页面。
为什么用pushReplacement而不是push?
因为用户不应该从结果页返回到答题页。想象一下,用户看完成绩按返回键,回到了最后一题的页面,这体验多奇怪。pushReplacement会替换当前页面,按返回键会直接回到答题入口。
页面布局
AppBar显示当前进度和得分:
@override
Widget build(BuildContext context) {
if (_isLoading) {
return Scaffold(
appBar: AppBar(title: const Text(‘答题挑战’)),
body: const Center(child: CircularProgressIndicator()),
);
}
if (_questions.isEmpty) {
return Scaffold(
appBar: AppBar(title: const Text(‘答题挑战’)),
body: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text(‘加载题目失败,请返回重试’),
],
),
),
);
}
先处理加载中和加载失败的情况。加载失败时显示友好的提示,而不是空白页面。
return Scaffold(
appBar: AppBar(
title: Text(‘第 ${_currentIndex + 1} / questions.length题′),actions:[Center(child:Padding(padding:constEdgeInsets.only(right:16),child:Container(padding:constEdgeInsets.symmetric(horizontal:12,vertical:6),decoration:BoxDecoration(color:Theme.of(context).colorScheme.primaryContainer,borderRadius:BorderRadius.circular(20),),child:Row(mainAxisSize:MainAxisSize.min,children:[constIcon(Icons.star,size:18),constSizedBox(width:4),Text(′{_questions.length} 题'), actions: [ Center( child: Padding( padding: const EdgeInsets.only(right: 16), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: Theme.of(context).colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.star, size: 18), const SizedBox(width: 4), Text( 'questions.length题′),actions:[Center(child:Padding(padding:constEdgeInsets.only(right:16),child:Container(padding:constEdgeInsets.symmetric(horizontal:12,vertical:6),decoration:BoxDecoration(color:Theme.of(context).colorScheme.primaryContainer,borderRadius:BorderRadius.circular(20),),child:Row(mainAxisSize:MainAxisSize.min,children:[constIcon(Icons.star,size:18),constSizedBox(width:4),Text(′_score’,
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
),
),
),
],
),
body: _buildQuizContent(),
);
}
标题显示"第 X / 10 题",右上角用一个小标签显示当前得分。这样用户随时都能知道自己的进度和成绩。
题目内容区域
这部分包含进度条、难度标签、题目和选项:
Widget _buildQuizContent() {
final question = _questions[_currentIndex];
final correctAnswer = _decodeHtml(question[‘correct_answer’]);
final difficulty = question[‘difficulty’] ?? ‘medium’;
final category = _decodeHtml(question[‘category’] ?? ‘’);
return Column(
children: [
// 进度条
LinearProgressIndicator(
value: (_currentIndex + 1) / _questions.length,
backgroundColor: Colors.grey[200],
minHeight: 4,
),
进度条放在最顶部,用颜色填充来表示答题进度。value是0到1之间的数值,当前是第3题就是0.3。
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 难度和分类标签
Wrap(
spacing: 8,
children: [
_buildDifficultyBadge(difficulty),
if (category.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
category,
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
),
],
),
难度标签用不同颜色区分,分类标签用灰色,两者并排显示。
const SizedBox(height: 20),
// 题目文字
Text(
_decodeHtml(question['question']),
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
height: 1.4,
),
),
const SizedBox(height: 28),
// 选项列表
..._shuffledAnswers.asMap().entries.map((entry) {
final index = entry.key;
final answer = entry.value;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _buildAnswerOption(answer, correctAnswer, index),
);
}),
],
),
),
),
],
);
}
题目文字用大号字体,行高1.4让多行文字更易读。选项用asMap().entries遍历,这样可以同时获取索引和值。
选项按钮的实现
这是答题交互的核心组件:
Widget _buildAnswerOption(String answer, String correctAnswer, int index) {
final letters = [‘A’, ‘B’, ‘C’, ‘D’];
final letter = index < letters.length ? letters[index] : ‘’;
// 确定选项的状态和颜色
Color? backgroundColor;
Color? borderColor;
Color textColor = Theme.of(context).textTheme.bodyLarge?.color ?? Colors.black;
IconData? trailingIcon;
每个选项前面加上A、B、C、D的字母标识,这是答题界面的常见设计。
if (_answered) {
if (answer == correctAnswer) {
// 正确答案:绿色
backgroundColor = Colors.green.withOpacity(0.1);
borderColor = Colors.green;
trailingIcon = Icons.check_circle;
} else if (answer == _selectedAnswer) {
// 用户选错了:红色
backgroundColor = Colors.red.withOpacity(0.1);
borderColor = Colors.red;
trailingIcon = Icons.cancel;
}
}
作答后,正确答案显示绿色背景和勾选图标;如果用户选错了,错误选项显示红色背景和叉号图标。这样用户可以清楚地看到哪个是对的、自己选的是什么。
return Material(
color: backgroundColor ?? Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: _answered ? null : () => _selectAnswer(answer),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: borderColor ?? Colors.transparent,
width: 2,
),
),
child: Row(
children: [
// 字母标识
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: borderColor?.withOpacity(0.2) ??
Theme.of(context).colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
letter,
style: TextStyle(
fontWeight: FontWeight.bold,
color: borderColor ?? Theme.of(context).colorScheme.primary,
),
),
),
),
const SizedBox(width: 12),
// 选项文字
Expanded(
child: Text(
answer,
style: TextStyle(
fontSize: 15,
fontWeight: _selectedAnswer == answer ? FontWeight.bold : FontWeight.normal,
color: textColor,
),
),
),
// 结果图标
if (trailingIcon != null)
Icon(trailingIcon, color: borderColor, size: 24),
],
),
),
),
);
}
已作答后onTap设为null,这样选项就不可点击了。用户选中的选项文字会加粗,方便识别。
写在最后
答题挑战的实现涉及到很多细节:选项打乱保证公平性,延迟跳转让用户看清结果,颜色和图标让对错一目了然,进度条和得分让用户了解当前状态。
这个功能的核心是状态管理。_answered控制是否可以作答,_selectedAnswer记录用户选择,_currentIndex跟踪进度,_score累计得分。这些状态相互配合,构成了完整的答题流程。
下一篇我们来看答题分类页面,那里会展示所有可用的题目类别,让用户可以选择感兴趣的领域进行挑战。
本文是Flutter for OpenHarmony教育百科实战系列的第十四篇,后续会持续更新更多内容。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐


所有评论(0)