flutter_for_openharmony逆向思维训练app实战+逆向推理实现
本文介绍了Flutter逆向思维训练App的核心架构设计,主要包括: 导航结构设计 采用IndexedStack管理4个Tab页,保持切换时的状态 使用ConvexAppBar实现凸面导航栏 逻辑谜题作为主模块入口,包含逆向推理功能 屏幕适配方案 通过ScreenUtilInit初始化屏幕适配 全项目使用.w/.h/.sp单位确保多设备兼容 设计稿基准尺寸设为375x812(iPhone X) 功

本文涉及文件
lib/app.dartlib/feature_pages.dartlib/models.dartlib/main.dart
1. 先把结构想清楚:这个功能挂在哪里
逆向推理不是一个“孤立页面”,它属于首页四大模块中的“逻辑谜题”。
在 lib/app.dart,根布局使用 IndexedStack 存放 4 个 Tab。
为什么用 IndexedStack
- 切换 Tab 时,已打开 Tab 的状态不会丢
- 对这种训练类应用很友好:用户来回切换不会重置进度
核心代码(来自 lib/app.dart)
class ReverseThinkingApp extends StatefulWidget {
const ReverseThinkingApp({super.key});
State<ReverseThinkingApp> createState() =>
_ReverseThinkingAppState();
}
核心设计点:
- 采用
StatefulWidget是为了支撑Tab切换的状态更新,这是动态页面的基础; - 类名遵循大驼峰命名法,符合Dart官方编码规范;
- 构造函数添加
super.key,保证Widget树的唯一性标识。
class _ReverseThinkingAppState extends State<ReverseThinkingApp> {
int _currentIndex = 0;
final List<Widget> _pages = [
const LogicPuzzlesPage(),
const PatternRecognitionPage(),
const WordProblemsPage(),
const ProgressStatsPage(),
];
}
状态管理设计:
_currentIndex私有变量控制当前显示的Tab页,初始值0对应首个“逻辑谜题”页;_pages列表存储所有Tab页组件,使用const修饰避免重复构建;- 页面列表顺序与底部导航栏严格对应,防止切换错位。
Widget build(BuildContext context) {
return MaterialApp(
title: '逆向思维训练',
home: Scaffold(
body: IndexedStack(
index: _currentIndex,
children: _pages,
),
布局核心逻辑:
IndexedStack替代普通Stack,仅渲染当前index对应的页面,节省性能;MaterialApp作为根组件,提供主题、路由等基础能力;Scaffold封装页面骨架,包含body和底部导航栏。
bottomNavigationBar: ConvexAppBar(
items: const [
TabItem(icon: Icons.psychology, title: '逻辑谜题'),
TabItem(icon: Icons.grid_on, title: '模式识别'),
TabItem(icon: Icons.text_fields, title: '文字推理'),
TabItem(icon: Icons.analytics, title: '进度统计'),
],
底部导航设计:
- 使用
ConvexAppBar(凸面导航栏)提升视觉体验,区别于原生BottomNavigationBar; - 每个
TabItem绑定图标+文字,图标选用Material Design内置图标,风格统一; const修饰items,避免每次build重新创建对象。
initialActiveIndex: _currentIndex,
onTap: (index) => setState(() =>
_currentIndex = index),
backgroundColor: Colors.blue,
activeColor: Colors.white,
height: 60.h,
),
),
);
}
交互与适配:
onTap回调仅更新_currentIndex,通过setState触发页面重建;height: 60.h使用屏幕适配单位,适配不同尺寸设备;- 导航栏配色采用蓝底白字,符合训练类App清晰、专业的视觉定位。
这个 height: 60.h 会用到 flutter_screenutil。
因此入口一定要初始化 ScreenUtil,否则会有 LateInitializationError。
2. 为什么入口要包一层 ScreenUtilInit
你项目里大量存在 .w/.h/.sp。
比如 EdgeInsets.all(16.w)、SizedBox(height: 20.h)、TextStyle(fontSize: 16.sp)。
这些写法背后依赖 flutter_screenutil 的初始化状态。
如果没有 ScreenUtilInit,那么第一次调用 .w/.h/.sp 就可能崩。
入口(lib/main.dart)建议保持下面这种形式
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(
ScreenUtilInit(
designSize: const Size(375, 812),
minTextAdapt: true,
splitScreenMode: true,
初始化关键配置:
WidgetsFlutterBinding.ensureInitialized()确保Flutter引擎初始化完成;designSize设置设计稿基准尺寸(375x812为iPhone X尺寸);minTextAdapt开启文字最小适配,防止文字过小无法阅读;splitScreenMode支持分屏模式,提升多设备兼容性。
builder: (_, __) => const ReverseThinkingApp(),
),
);
}
构建逻辑:
builder回调返回主应用组件,避免额外嵌套层级;- 匿名参数
_和__表示未使用,符合Dart编码规范; - 主应用组件用
const修饰,减少初始化开销。
这里的参数含义
designSize: 你的设计稿基准尺寸minTextAdapt: 字体缩放时更稳splitScreenMode: 兼容分屏布局
这样做之后,后续页面使用 .w/.h/.sp 才是可靠的。
3. 逆向推理的入口:从“逻辑谜题”列表进入
逆向推理入口在 LogicPuzzlesPage 里。
它会构建一个 ListView,每个功能用一张卡片表示。
你现在的项目中,逆向推理对应这一行(在 lib/feature_pages.dart)
_buildFeatureCard(context, '逆向推理',
Icons.refresh, const ReverseReasoningPage()),
导航设计:
_buildFeatureCard是封装的通用卡片组件,参数包含上下文、标题、图标、目标页面;- 图标选用
Icons.refresh,隐喻“逆向、刷新思维”,贴合功能定位; - 目标页面用
const修饰,避免提前创建实例。
Widget _buildFeatureCard(BuildContext context, String title,
IconData icon, Widget targetPage) {
return Card(
margin: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
child: ListTile(
leading: Icon(icon, color: Colors.blue),
title: Text(title, style: TextStyle(fontSize: 16.sp)),
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => targetPage),
),
),
);
}
卡片封装细节:
Card组件提供阴影和圆角,提升视觉层次;ListTile封装leading-icon+title布局,减少自定义代码;onTap通过Navigator.push跳转页面,使用MaterialPageRoute保证过渡动画。
_buildFeatureCard 内部的关键逻辑是 Navigator.push。
也就是用户点击卡片后,打开对应的功能页。
这种结构的好处
- 主页只负责“导航”,功能页只负责“功能本身”
- 增加新功能时,只需要新增一个
Page和一条_buildFeatureCard
4. 先把题目模型定义好:LogicPuzzle
逆向推理是一组选择题。
你已经在 lib/models.dart 定义了 LogicPuzzle。
这一步非常重要。
如果没有模型抽象,页面里会充满零散字段,后续维护很难。
class LogicPuzzle {
final String id;
final String question;
final String correctAnswer;
final List<String> options;
final String explanation;
模型设计原则:
- 所有字段使用
final修饰,保证不可变,符合Dart不可变对象设计理念; - 字段命名采用小驼峰,符合Dart编码规范;
- 核心字段全覆盖:题干、选项、正确答案、解析,满足训练场景需求。
LogicPuzzle({
required this.id,
required this.question,
required this.correctAnswer,
required this.options,
required this.explanation,
});
}
构造函数设计:
- 使用
required关键字强制所有参数必须传入,避免空值; - 无默认参数,保证每个题目实例的完整性;
- 简洁的构造函数写法,便于快速创建题目实例。
这个模型定义了题目的核心字段,结构清晰。id 用于唯一标识,方便后续持久化或统计。correctAnswer 直接存储选项文本,判断时无需映射。options 为列表,支持任意数量选项。explanation 为解析文本,训练反馈的核心。
字段解释
id: 题目唯一标识question: 题干options: 选项列表correctAnswer: 正确选项(用文本对齐选项值)explanation: 解析文本
注意一个实现细节
你把 correctAnswer 直接写成某个 option 的字符串。
这让判断非常简单
selectedAnswer == puzzle.correctAnswer
这样不需要额外做映射。
5. ReverseReasoningPage:为什么它必须是 StatefulWidget
逆向推理不是“展示静态内容”,它有三个动态状态
currentPuzzle: 当前题目下标selectedAnswer: 当前选择showResult: 是否显示解析
因此页面使用 StatefulWidget。
在你目前的项目规模下,直接 setState 足够清晰。
如果后续要做“跨页面共享进度”,再考虑把状态上提或引入状态管理也不晚。
6. 页面核心实现:题目 + 单选 + 解析
下面这段代码就是你当前仓库里的逆向推理完整实现(位于 lib/feature_pages.dart)。
class ReverseReasoningPage extends StatefulWidget {
const ReverseReasoningPage({super.key});
State<ReverseReasoningPage> createState() =>
_ReverseReasoningPageState();
}
页面结构设计:
- 遵循StatefulWidget标准写法,分离组件壳与状态;
- 构造函数传递key,保证Widget树的正确更新;
- 状态类命名以
_开头,私有访问,符合封装原则。
class _ReverseReasoningPageState extends State<ReverseReasoningPage> {
final List<LogicPuzzle> puzzles = [
LogicPuzzle(
id: '1',
question: '如果所有鸟都会飞,企鹅是鸟,那么企鹅会飞。这个结论错在哪里?',
correctAnswer: '前提错误:并非所有鸟都会飞',
题目数据设计:
puzzles列表用final修饰,保证初始化后不可变;- 题目实例直接初始化在页面中,便于快速调试和演示;
- 题干设计贴合“逆向思维”核心,引导用户反推逻辑漏洞。
options: ['前提错误:并非所有鸟都会飞',
'推理错误',
'结论正确',
'企鹅不是鸟'],
explanation: '逆向思维:从结论反推前提,发现初始前提"所有鸟都会飞"是错误的。',
),
选项与解析设计:
- 选项列表包含正确答案和干扰项,干扰项覆盖常见错误认知;
- 解析文本明确点出“逆向思维”核心方法,强化训练目标;
- 字符串换行采用逗号分隔,保持代码整洁。
LogicPuzzle(
id: '2',
question: '一个房间里有3个开关,外面有3个灯泡。你只能进房间一次,如何找出每个开关对应的灯泡?',
correctAnswer: '先开一个开关等几分钟,关掉再开另一个',
options: ['随机尝试', '先开一个等几分钟再换',
'无法确定', '需要工具'],
explanation: '逆向思维:利用温度信息,先开一个开关让灯泡发热,关掉后开另一个,进房间通过温度和亮度判断。',
),
];
多题目扩展设计:
- 第二个题目聚焦“逆向利用信息”(温度+亮度),丰富训练维度;
- 选项表述简洁,避免冗余,降低用户阅读成本;
- 解析明确说明逆向思维的应用场景,帮助用户迁移能力。
int currentPuzzle = 0;
String? selectedAnswer;
bool showResult = false;
核心状态设计:
currentPuzzle初始值0,默认显示第一道题;selectedAnswer为可空字符串,初始null表示未选择;showResult初始false,默认不显示解析,保证流程闭环。
Widget build(BuildContext context) {
final puzzle = puzzles[currentPuzzle];
return Scaffold(
appBar: AppBar(title: const Text('逆向推理')),
body: Padding(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
页面构建逻辑:
- 先获取当前题目实例,简化后续代码引用;
Scaffold+AppBar构建标准页面骨架,符合Material Design规范;Padding统一页面内边距,16.w适配不同屏幕;Column作为主布局,CrossAxisAlignment.start左对齐,符合阅读习惯。
LinearProgressIndicator(
value: (currentPuzzle + 1) / puzzles.length,
backgroundColor: Colors.grey[200],
color: Colors.blue,
),
SizedBox(height: 20.h),
Text('谜题 ${currentPuzzle + 1}/${puzzles.length}',
style: TextStyle(fontSize: 18.sp,
fontWeight: FontWeight.bold)),
进度与标题设计:
LinearProgressIndicator自定义背景色和进度色,提升视觉辨识度;- 进度计算
(currentPuzzle + 1)/总数,符合用户“第N题/共M题”的心智模型; - 标题文字加粗+大号字体,突出当前题目进度。
SizedBox(height: 16.h),
Text(puzzle.question,
style: TextStyle(fontSize: 16.sp)),
SizedBox(height: 24.h),
题干展示设计:
- 多层
SizedBox分隔,控制间距节奏(20h→16h→24h),提升阅读体验; - 题干字体大小
16.sp,兼顾可读性和屏幕空间; - 直接绑定
puzzle.question,数据驱动UI。
...puzzle.options.map((option) => RadioListTile<String>(
title: Text(option, style: TextStyle(fontSize: 14.sp)),
value: option,
groupValue: selectedAnswer,
onChanged: (value) => setState(() =>
selectedAnswer = value),
)),
选项渲染设计:
- 使用
map遍历选项列表,动态生成RadioListTile,无需重复代码; RadioListTile自带单选逻辑,无需自定义选中状态;onChanged回调通过setState更新选中状态,简单高效;- 选项文字
14.sp,略小于题干,形成视觉层级。
SizedBox(height: 20.h),
if (showResult) ...[
Container(
padding: EdgeInsets.all(12.w),
decoration: BoxDecoration(
color: selectedAnswer == puzzle.correctAnswer
? Colors.green[100]
: Colors.red[100],
borderRadius: BorderRadius.circular(8.r),
),
结果展示设计:
- 条件渲染
showResult,未确认答案时不显示解析; Container自定义背景色,正确为浅绿、错误为浅红,直观反馈;8.r圆角适配不同屏幕,保持视觉一致性;12.w内边距保证文本与边框间距。
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('答案:${puzzle.correctAnswer}',
style: TextStyle(fontWeight: FontWeight.bold)),
SizedBox(height: 8.h),
Text(puzzle.explanation,
style: TextStyle(fontSize: 14.sp)),
],
),
),
],
解析内容设计:
- 答案文字加粗,突出核心信息;
8.h间距分隔答案和解析,提升阅读节奏;- 解析文字
14.sp,与选项文字大小一致,保持视觉统一; CrossAxisAlignment.start左对齐,符合文本阅读习惯。
Spacer(),
Row(
children: [
if (currentPuzzle > 0)
ElevatedButton(
onPressed: () => setState(() {
currentPuzzle--;
selectedAnswer = null;
showResult = false;
}),
child: const Text('上一题'),
),
上一题按钮设计:
- 条件渲染,仅当不是第一题时显示;
- 点击回调重置所有状态,保证切换题目后页面干净;
ElevatedButton自带点击反馈,符合Material Design交互规范。
Spacer(),
ElevatedButton(
onPressed: selectedAnswer != null
? () => setState(() => showResult = true)
: null,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 12.h),
),
child: const Text('确认答案'),
),
确认答案按钮设计:
- 按钮禁用逻辑:未选择答案时不可点击;
- 自定义按钮样式,背景色与主题一致,内边距适配屏幕;
- 点击仅更新
showResult状态,逻辑单一,便于维护。
if (currentPuzzle < puzzles.length - 1)
SizedBox(width: 12.w),
if (currentPuzzle < puzzles.length - 1)
ElevatedButton(
onPressed: showResult
? () => setState(() {
currentPuzzle++;
selectedAnswer = null;
showResult = false;
})
: null,
child: const Text('下一题'),
),
],
)
],
),
),
);
}
}
下一题按钮设计:
- 条件渲染,仅当不是最后一题时显示;
- 按钮禁用逻辑:未确认答案时不可点击,强制用户完成当前题;
- 点击重置所有状态,保证新题目从初始状态开始;
12.w间距分隔按钮,避免视觉拥挤。
到这里,你已经拥有一个最小闭环
- 有题目
- 能选择
- 能确认
- 能展示解析
- 能上一题/下一题
7. 进度条的细节:为什么用 (current + 1) / total
LinearProgressIndicator(value: (currentPuzzle + 1) / puzzles.length)
进度计算设计:
- 加1处理:第一题显示1/总数,而非0/总数,符合用户直觉;
- 浮点计算:自动转换为0-1之间的数值,适配进度条
value要求; - 无额外计算逻辑,保持代码简洁。
如果写成 currentPuzzle / puzzles.length,第一题会显示 0%,用户直觉上会觉得“还没开始”。
而 currentPuzzle + 1 能让进度条对齐“我正在做第几题”的心智模型。
这里还隐含一个约束:puzzles.length 不能为 0。
你现在把题目写成常量列表,天然不会出现 0。
后续如果要做动态题库,建议至少保证
- 没有题目时给一个空态
- 或者在 build 前提前 return 一个提示
if (puzzles.isEmpty) {
return Center(child: Text('暂无题目,请稍后再试',
style: TextStyle(fontSize: 16.sp)));
}
空态处理设计:
- 提前判断题目列表是否为空,避免数组越界;
- 居中显示提示文本,提升用户体验;
- 字体大小适配,保证不同屏幕可见性。
8. RadioListTile 的选中逻辑:groupValue 就是“唯一真相”
RadioListTile<String>(
value: option,
groupValue: selectedAnswer,
onChanged: (value) => setState(() => selectedAnswer = value),
)
单选逻辑设计:
value绑定选项文本,groupValue绑定选中状态,天然匹配;- 无需维护多个bool值,仅用一个字符串管理选中状态;
onChanged直接更新状态,逻辑无冗余。
可以把它理解成:
- 每个选项有一个
value - 当前选中项由
groupValue决定
当 groupValue == value 时就会显示选中。
这套机制的好处是:
- 你不需要给每个选项维护一个 bool
- 状态始终是一个字符串,很容易做比较和存储
你现在把 selectedAnswer 定义成 String?,配合按钮禁用也很自然。
9. showResult 的价值:用最少的状态,构建清晰的流程
你把“展示解析”这个流程做成一个 bool:showResult。
触发点只有一个:点击“确认答案”。
onPressed: selectedAnswer != null ? () => setState(() => showResult = true) : null,
流程控制设计:
- 单一状态控制解析显示,无复杂分支;
- 按钮禁用逻辑与选中状态强关联,防止无效操作;
setState仅更新必要状态,性能最优。
这会带来两个明显好处。
第一,按钮天然具备引导性。
- 用户必须先选一个
- 才能确认
第二,页面逻辑不会分叉。
- 没确认就不显示解析
- 确认了就显示解析
你没有引入额外枚举或状态机,但流程依然很清晰。
10. 为什么“下一题”必须依赖 showResult
你的下一题按钮写法是
onPressed: showResult ? () => setState(() {
currentPuzzle++;
selectedAnswer = null;
showResult = false;
}) : null,
流程约束设计:
- 强制用户先确认答案,再进入下一题,保证训练效果;
- 切换题目时重置所有状态,避免状态污染;
- 禁用状态直观,用户明确知道操作顺序。
这个约束其实是体验设计。
如果不依赖 showResult,用户可能连续点“下一题”直接跳过思考。
而你现在的约束强制用户“先确认,再进入下一题”。
这个方式的“真实性”在于它贴近训练类产品的基本目标:
- 强制反馈闭环
- 不让用户无意识跳题
并且实现上很朴素。
- 不需要额外计时
- 不需要拦截手势
- 一个 bool 足够
11. 状态重置:为什么每次切题都清空 selectedAnswer
上一题/下一题都有这三句
selectedAnswer = null;
showResult = false;
状态重置设计:
- 切题时清空选中状态和解析显示,保证新题初始状态;
- 两行代码完成重置,逻辑清晰;
- 避免旧题状态污染新题,提升用户体验。
这是一个容易被忽略但非常关键的细节。
如果你不重置 selectedAnswer,会出现两个问题。
第一,新题可能会默认选中某个选项
第二,用户会误以为“系统替我选了答案”。
因此重置是更稳的。
后续如果你希望“返回上一题保留上次选择”,那就需要把状态改成
- 以题目 id 为 key 的 Map
Map<String, String?> answersById = {};
Map<String, bool> showResultById = {};
selectedAnswer = answersById[puzzle.id] ?? null;
showResult = showResultById[puzzle.id] ?? false;
answersById[puzzle.id] = selectedAnswer;
showResultById[puzzle.id] = true;
历史状态设计:
- 用Map存储每个题目的选中状态和解析显示状态;
- 切换题目时从Map读取历史状态,无则使用默认值;
- 确认答案时保存状态,保证返回上一题可见。
Map<String, String?> answersByIdMap<String, bool> showResultById
这样上一题/下一题就不会互相污染。
12.只改 puzzles 列表
LogicPuzzle(
id: '3',
question: '一个人从5楼掉下来没事,从1楼掉下来却受伤了,为什么?',
correctAnswer: '从5楼掉下来掉进了泳池,1楼掉在水泥地',
options: ['体重不同', '从5楼掉泳池/1楼掉水泥地',
'运气好', '楼层高度测量方式不同'],
explanation: '逆向思维:跳出"掉落高度=伤害程度"的固定思维,考虑掉落环境的差异。',
),
- 延续id递增规则,保证唯一性;
- 题干设计贴近生活,降低理解成本;
- 解析强化逆向思维核心,保持训练一致性。
因为 UI 完全依赖 puzzles[currentPuzzle]。
也就是说
- 加 10 道题,UI 不用动
- 改题干、改选项、改解析,UI 不用动
这就是模型抽象带来的收益。
如果你计划后续接入本地存储(例如记录用户做题结果),建议保持 LogicPuzzle 不变,新增一个“结果模型”。
项目里已经有一个 PuzzleResult(在 lib/models.dart),后续可以把它用起来。
class PuzzleResult {
final String puzzleId;
final String? userAnswer;
final bool isCorrect;
final DateTime completedAt;
PuzzleResult({
required this.puzzleId,
required this.userAnswer,
required this.isCorrect,
required this.completedAt,
});
}
结果模型设计:
- 包含题目ID、用户答案、是否正确、完成时间,覆盖统计需求;
- 所有字段final,保证不可变;
- 构造函数强制参数,保证数据完整性。
13. 这一页与其他页的一致性:布局与适配保持统一
你会发现 ReverseReasoningPage 的布局风格跟其他训练页很一致:
Scaffold + AppBarPadding(EdgeInsets.all(16.w))- 用
SizedBox(height: xx.h)做间距
Widget buildCommonLayout({required Widget child}) {
return Padding(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LinearProgressIndicator(value: progressValue),
SizedBox(height: 20.h),
Text(title, style: TextStyle(fontSize: 18.sp, fontWeight: FontWeight.bold)),
SizedBox(height: 16.h),
child,
Spacer(),
buildCommonButtonArea(),
],
),
);
}
通用布局封装:
- 提取重复布局逻辑,减少代码冗余;
- 暴露child参数,支持自定义内容;
- 统一进度条、标题、间距,保证风格一致。
这种一致性非常重要。
它不只是“看起来统一”,更是降低维护成本。
当你要统一调整全局间距时,你会发现很多页面都只用改一个数值即可。
14. 小结:把“逆向推理实现”落到工程里,靠的是三个点
- 入口初始化适配:
ScreenUtilInit - 入口导航清晰:
LogicPuzzlesPage只负责 push 到功能页 - 功能页状态闭环:
selectedAnswer + showResult + currentPuzzle
这三个点组合起来,代码不复杂,但完成度很高。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐
所有评论(0)