做艺考真题题库这个方向,其实我一直想找一个既能快速迭代内容、又能覆盖多终端的方案。手头这台 OpenHarmony 设备成了试金石——用 Flutter 写同一套界面和逻辑,跑在 OpenHarmony 系统上,第一优先级做的不是刷题界面,而是“分类浏览”。这个判断看着简单,实际上决定了整个题库 App 的信息架构。如果你也在做题库类应用,或者想把一套 Flutter 工程迁移到 OpenHarmony 设备上,这篇实战记录里的选型理由、工程配置、状态管理思路和踩坑清单,应该能省下你不少调试时间。
1. 为什么把艺考题库先做成分类浏览
1.1 从使用场景反推:题库应用最核心的动作是“找题”
我最早做题库的时候,一上来就想做刷题页、计时器、错题本,结果目录树和筛选入口拖了两个礼拜还没定。后来把使用场景摆到桌面上一看:一个艺考考生打开题库,第一步永远是“我要先找到自己那个专业,再看对应的真题”。美术生要找素描、色彩、速写,音乐生要找视唱练耳、乐理,舞蹈生要找剧目和基本功,专业下面还要按省份、按年份筛。找题这个动作不顺畅,后面做再多酷炫功能都是白搭。
所以我把“分类浏览”当成应用的骨架来做。它不是页面上一个普通的导航栏,而是决定数据怎么组织、页面之间怎么跳转、后续搜索和收藏功能往哪里挂的核心模块。这个阶段想清楚,后面加功能只是往架子上挂肉,不会伤筋动骨。
1.2 Flutter 和 OpenHarmony 的组合到底解决什么问题
很多人对 Flutter 的印象还停留在“跨 Android 和 iOS”,实际上 Flutter 引擎的架构决定了它对底层系统的依赖可以压缩到很小一块。OpenHarmony 提供了标准的系统能力和图形栈,Flutter 在 OpenHarmony 上的适配本质上是让引擎的渲染层接上 OpenHarmony 的图形能力,Dart 层面的 Widget、状态管理、路由这些代码几乎不用为系统做特殊修改。
这意味着什么?意味着你在 Flutter 里写好的分类列表、题卡组件、主题配色,可以直接跑到 OpenHarmony 上,不需要用 ArkUI 重写一遍。对于题库类应用来说,页面大多是列表、卡片、文本渲染,不涉及太多高精度的原生控件,Flutter 这种自绘渲染反而能保证不同系统间的视觉还原度。加上 Flutter 的布局和动画体系非常成熟,做分类折叠、切换动画、列表刷新这些交互,代码量比原生少一大截。
1.3 分类浏览不是菜单,而是信息架构
很多同学把分类浏览做成一个简单的 ListView,点一下跳到另一个页面,返回再点另一个分类。这种实现放到分类少、层级浅的小 Demo 里没什么问题,但艺考真题题库的分类维度是多层的:学科 -> 专业 -> 科目 -> 年份 -> 地区。其中某些层级之间还有交叉,比如同一个素描真题既属于美术学科,又属于某省份某年。
如果只做一层列表,用户每一次切换都要重新进入页面、重新加载,体验很割裂。更合理的方式是把分类做成常驻侧边栏或者筛选面板,右侧实时联动显示当前分类下的题目,切换时只更新数据源和列表,不销毁页面。这个设计听起来理所当然,但实现起来需要明确状态管理方案,这正是 Flutter 工程里最容易写乱的地方。我在这套项目里直接用 Provider 解决了数据联动问题,后面会详细拆代码。
2. 环境搭建与工程初始化
2.1 需要的工具和版本匹配
跑通 Flutter 到 OpenHarmony,工具链不是一套普通的 Flutter 环境,需要在标准 Flutter SDK 的基础上换成适配 OpenHarmony 的版本。整体组件清单大概是这样:
| 组件 | 作用 | 备注 |
|---|---|---|
| OpenHarmony SDK | 提供系统 API、编译 HAP 所需的基础能力 | 用 DevEco Studio 里默认下载的版本即可 |
| DevEco Studio | 管理工程、连接设备、签名打包 | 主要用它做设备管理和签名配置 |
| 适配 OpenHarmony 的 Flutter SDK | 让 Flutter 工具链能构建出 OpenHarmony 应用 | 注意不要和标准 Flutter SDK 混用 |
| hdc 命令行工具 | 安装应用、查看日志 | 对应 Android 的 adb,由 DevEco Studio 提供 |
版本匹配是最容易出现问题的环节。官方模板会锁一套 Flutter 版本和 OpenHarmony API 版本的组合,不要自己随手升级到最新版。我建议首次搭建时直接用工程模板锁定的版本,跑通之后再决定要不要升级。用大白话说就是“先把车开起来,再考虑换轮胎”。
2.2 创建工程的关键配置
配置 OpenHarmony SDK、创建 Android 和 iOS 平台代码、然后将 OpenHarmony 作为一个新的平台支持添加到工程中,其中有个很关键的点,要确定编译目标版本是否与设备系统版本匹配。还有 URL scheme 配置,用于应用之间的跳转和深度链接,如果打开应用没有需求,就用默认的即可。
在工程的pubspec.yaml里,environment部分的 Dart SDK 版本约束也要保持一致。如果这里写得太新,而环境里的 Flutter 版本不够,一跑flutter pub get就会报版本冲突。这类问题排查起来其实并不复杂,先看终端里输出的版本提示,再检查pubspec.yaml里的约束,改到满足条件即可。
创建完工程之后,先用默认模板编译一次最小应用,确认 DevEco Studio 里能看到设备、能完成安装。这一步成功,说明 OpenHarmony SDK、Flutter SDK、hdc 设备连接这条链路是通的,后面的开发才是真正写业务代码。
2.3 第一分钟该验证什么
环境装好之后,第一件事不是写分类浏览,而是做一次“最小闭环”验证:新建一个空白页面,塞一个 Text 控件,编译安装到 OpenHarmony 设备或模拟器上,确认文字能正常显示。这一步会暴露很多底层问题,比如引擎 so 库没打包进去、系统版本不兼容、签名配置错误等。
最小闭环跑通后,第二件事是验证热重载。开发状态下使用热重载能极大提升调 UI 的效率。如果热重载在这套环境下不稳定,也不要纠结,OpenHarmony 适配版对热重载的支持会受限于工具链版本,重编译一次也不算太慢。我通常的做法是:调 UI 时尽量用热重载,遇到状态逻辑或原生能力调不通时,直接全量重编。
3. 题库数据模型与 JSON 预置方案
3.1 分类模型和题目模型怎么设计
分类浏览不是简单拉一个标题列表,它需要同时驱动左侧的分类树和右侧的题目列表。所以数据模型必须在一开始就把“分类”和“题目”之间的关系定清楚。
我定义的分类模型长这样:
class ArtCategory { final String id; final String name; final String? parentId; final int sort; final String? icon; final List<ArtCategory> children; ArtCategory({ required this.id, required this.name, this.parentId, this.sort = 0, this.icon, this.children = const [], }); bool get isRoot => parentId == null; }题目模型有两个关键字段:categoryId表示属于哪个分类,year和region是艺考题特有的筛选项。类别用 int 是为了后续做本地化文案映射更方便。
class ArtQuestion { final String id; final String categoryId; final String type; // 科目类型,如:素描、色彩、速写、乐理 final String stem; // 题干 final List<String>? options; final String? answer; final String? analysis; // 解析 final String? year; final String? region; final String? imageUrl; // 题图或参考图 ArtQuestion({ required this.id, required this.categoryId, required this.type, required this.stem, this.options, this.answer, this.analysis, this.year, this.region, this.imageUrl, }); }分类用parentId自引用形成树,这样以后加“二级筛选”,比如大类“美术”下面挂“素描”“色彩”“速写”,就不需要改表结构。题目只认叶子分类,这样刷题的时候不会出现“选了一个大类却不知道加载哪些题”的尴尬。
3.2 把题库数据放进 assets 并加载
题库数据在初期阶段不需要服务器,直接打包到应用里是最简单的方案。我在工程的assets/data/目录下放了一个questions.json,结构是“分类数组 + 题目数组”的扁平结构,避免 JSON 嵌套太深导致解析麻烦。
{ "categories": [ { "id": "art", "name": "美术", "parentId": null, "sort": 1 }, { "id": "sketch", "name": "素描", "parentId": "art", "sort": 1 }, { "id": "color", "name": "色彩", "parentId": "art", "sort": 2 } ], "questions": [ { "id": "q001", "categoryId": "sketch", "type": "素描", "stem": "请根据照片完成一幅素描静物作品,要求体现结构关系。", "year": "2024", "region": "A省" } ] }在pubspec.yaml里注册 assets 目录:
flutter: assets: - assets/data/questions.json静态 JSON 的好处是:离线可用、启动无等待、没有接口波动。缺点是更新题库需要发版。所以我用 Repository 模式把数据源头封装起来,以后有服务器了,只需要替换 Repository 里从网络加载的逻辑,UI 层不需要动。
3.3 Repository 模式:把数据源头藏起来
我在项目里加了一个QuestionRepository,接口只暴露三个方法:加载全部分类、按父级加载子分类、按分类 ID 加载题目。
class QuestionRepository { List<ArtCategory> _categories = []; List<ArtQuestion> _questions = []; Future<void> loadFromAsset(String path) async { final raw = await rootBundle.loadString(path); final json = jsonDecode(raw) as Map<String, dynamic>; final categoryList = (json['categories'] as List) .map((e) => ArtCategory.fromJson(e as Map<String, dynamic>)) .toList(); _categories = _buildTree(categoryList); _questions = (json['questions'] as List) .map((e) => ArtQuestion.fromJson(e as Map<String, dynamic>)) .toList(); } List<ArtCategory> get rootCategories => _categories.where((c) => c.isRoot).toList(); List<ArtCategory> childrenOf(String parentId) => _categories.where((c) => c.parentId == parentId).toList(); List<ArtQuestion> questionsOf(String categoryId) => _questions.where((q) => q.categoryId == categoryId).toList(); }Repository 层做树形构建和查询过滤,UI 层永远不知道数据是来自本地还是网络。这属于分层设计带来的好处,平时可能感觉不到,一旦接入接口、加缓存、做离线同步,这个抽象层能让你少改很多页面代码。
4. 分类浏览核心实现:Provider 状态管理与页面联动
4.1 页面结构拆解:左侧分类树 + 右侧题目流
分类浏览页面我采用的是“左侧窄栏 + 右侧内容区”的结构,左侧展示一级分类,选中某个一级分类后,二级分类平铺在右侧顶部作为 Tab,下方显示对应题目流。这种做法对题库类应用非常合适,因为艺考分类天然有层级,而且分类数量不会多到需要搜索。
整个页面的 Widget 树大概是这样的:
Scaffold( body: Row( children: [ SizedBox(width: 96, child: _CategorySidebar()), VerticalDivider(width: 1), Expanded(child: _QuestionFlow()), ], ), )_CategorySidebar显示一级分类,_QuestionFlow根据当前选中的分类加载题目列表。右侧顶部可以放二级分类的横向滚动选择器,点击切换时只更新题目流,不重建左侧栏。
4.2 Provider 管理当前分类的关键代码
这里直接用 Provider 管理当前选中的分类以及分类下的题目列表。定义CategoryProvider继承ChangeNotifier:
class CategoryProvider extends ChangeNotifier { final QuestionRepository _repo; ArtCategory? _currentRoot; // 当前选中的一级分类 ArtCategory? _currentLeaf; // 当前选中的叶子分类 List<ArtQuestion> _currentQuestions = []; CategoryProvider(this._repo); ArtCategory? get currentRoot => _currentRoot; ArtCategory? get currentLeaf => _currentLeaf; List<ArtQuestion> get currentQuestions => _currentQuestions; void selectRoot(ArtCategory root) { _currentRoot = root; final children = _repo.childrenOf(root.id); if (children.isNotEmpty) { selectLeaf(children.first); } else { _currentLeaf = root; _currentQuestions = _repo.questionsOf(root.id); } notifyListeners(); } void selectLeaf(ArtCategory leaf) { _currentLeaf = leaf; _currentQuestions = _repo.questionsOf(leaf.id); notifyListeners(); } }在应用入口用 MultiProvider 注入 Repository 和 CategoryProvider:
runApp( MultiProvider( providers: [ Provider<QuestionRepository>( create: (_) => QuestionRepository()..loadFromAsset('assets/data/questions.json'), ), ChangeNotifierProvider<CategoryProvider>( create: (ctx) => CategoryProvider(ctx.read<QuestionRepository>()), ), ], child: const ArtExamApp(), ), );Provider 的用法其实不复杂,核心就三步:定义 ChangeNotifier、在入口注册、在页面里用 Consumer 或context.watch监听数据变化。很多项目写着写着就乱了,大多是因为一个 Provider 管了太多不相干的状态。我这里只让 CategoryProvider 管“当前分类 + 当前题目列表”,别的状态不要塞进来。
4.3 切换分类时数据是怎么流动的
用户点左侧的一级分类,数据流动是这样的:
- 用户点击触发
provider.selectRoot(root)。 selectRoot内部取出一级分类的子级,默认选中第一个子级。selectLeaf查询该子级下的题目列表,赋值给_currentQuestions。- 调用
notifyListeners(),通知所有监听者。 - 右侧
Consumer重建,展示新的题目列表。
右侧列表的消费代码:
Consumer<CategoryProvider>( builder: (context, provider, child) { final questions = provider.currentQuestions; if (questions.isEmpty) { return const Center(child: Text('该分类暂无题目')); } return ListView.builder( itemCount: questions.length, itemBuilder: (context, index) => QuestionCard(question: questions[index]), ); }, )这里有一个细节:题目列表一定要用ListView.builder,不要直接把全部题目map成 Column 子项。题库数据量上来之后,一次性构建几百个 Card 的 Widget 会明显卡顿,ListView.builder按需构建才能真正流畅。
4.4 列表渲染的取舍:全量加载还是分页
我在这套项目里先把 JSON 全部读进内存,按分类过滤展示。这么做对艺考题库前期是合理的,因为单分类下的题目数量不大,几百道题的过滤操作在内存里瞬间完成。全量加载还能让“返回上一目录”时秒开,没有等待感。
如果单分类题目量过万,就要加上分页逻辑。我的建议是 Repository 层保留questionsOf(categoryId, {int? limit, int? offset})的重载方法,UI 层滚动到底部时触发下一页加载。这样改动的成本很低,但前期不用为了假想的大数据量过度设计。
5. 真机调试、打包与问题排查实录
5.1 从工程到设备:编译、安装、热更新的流程
OpenHarmony 设备调试和 Android 最大的区别是构建产物不同。Android 产出 APK,OpenHarmony 产出 HAP。在 DevEco Studio 里可以通过构建任务直接生成 HAP,然后安装到设备上。
调试时我用的是反复构建安装的方式:先把 HAP 安装到设备,确认基础能跑,再用宿主机上的 Flutter 工具附加上去进行热重载调试。如果热重载频繁失效,不要硬扛,直接把应用冷启动重新跑一遍,效率反而更高。
还有一个值得提醒的点:真机调试前检查设备是否已经开启开发者模式和 USB 调试,OpenHarmony 设备通常需要手动开启才能被 hdc 工具识别。识别到设备后,通过 hdc 命令可以边装应用边看日志:
hdc install entry-default-signed.hap hdc hilog | grep flutter日志里看到的崩溃栈,是定位大部分问题最快的路径。
5.2 打包 HAP 并离线安装到 OpenHarmony 设备
打包 HAP 需要签名配置。在 DevEco Studio 里可以通过配置签名信息生成打包用的证书和配置文件,然后执行构建命令,产出entry-default-signed.hap。
命令行安装步骤:
hdc list targets hdc install entry-default-signed.hap安装成功后设备上会出现应用图标,桌面点击进入应用,验证分类浏览是否正常联动。打离线包这件事在测试阶段尤其重要,因为不是所有设备都方便随时连着开发机。把 HAP 发给测试同事,用hdc install装上,反馈问题直接从日志里看,效率比现场调试高不少。
5.3 高频踩坑清单与排查思路
| 问题表现 | 可能原因 | 排查思路 |
|---|---|---|
| 编译时报框架版本不匹配 | Flutter SDK 和 OpenHarmony SDK 版本不配套 | 用模板锁定的版本,不要单独升级 |
| 应用装上了但启动白屏 | 引擎 so 库没打进去或签名不对 | 重新构建并检查 HAP 里是否有 libflutter.so |
| 切分类后页面数据没更新 | Provider 状态没触发 notifyListeners | 检查 selectLeaf 里是否调用了 notifyListeners |
| 题目列表滑动掉帧 | 一次性渲染了太多 Card | 改成 ListView.builder 并限制 item 高度 |
| 点击无反应但日志无异常 | 手势和 ScrollView 冲突 | 检查 Card 内是否嵌套了横向滑动的控件 |
| 热重载后白屏或状态丢失 | 适配版热重载能力不完整 | 冷启动应用,不要依赖热重载 |
印象最深的一次是分类切换后列表长度对,但内容一直是上一次的数据。查了半天发现是题目列表是从一个旧的缓存列表里取的,没有重新走 Repository 查询。后来把selectLeaf逻辑简化成“只从 Repository 查询一次,再赋值给新列表”,问题就消失了。这个经验后来也变成了团队里的一个约定:Provider 里所有列表字段,赋值时都创建新实例,不要原地修改旧列表。这样可以避免无数诡异的界面残留问题。
另外再补一个容易被忽略的坑:rootBundle.loadString是异步的,如果 Provider 在数据加载完成前就被 UI 消费,会出现“分类列表为空”的假象。让 Repository 在入口完成初始化,或者给页面加一个加载态,确保questionsOf只在数据就绪后被调用。这个小改动可以避免很多新手调试时间。
最后再分享一点个人体会
把分类浏览做扎实之后,我明显感觉到整个题库应用的骨架稳了。后面加搜索框、加收藏夹、加刷题记录,都只是往这个骨架上挂新功能。分类浏览看起来简单,实际上它强迫你先把数据模型定对、把状态管理理顺、把列表性能想清楚,这三件事恰恰是题库类应用最容易被忽视的地基。
如果你后面也要做类似的应用,我的建议是:先把分类模型设计成一棵可扩展的树,再花一天时间把 Provider 的数据流写顺,最后用ListView.builder保证流畅度。这三个点做完,你的分类浏览就已经能超过市面上不少半成品应用了。至于界面是深色还是浅色,卡片要不要圆角,这些等到功能稳定之后再打磨也不迟。