有不少做 Flutter 的同学最近都在观望 OpenHarmony 这套生态。刚好我手头有一个真实的项目练手——把一套艺考真题题库 App 用 Flutter 跑在 OpenHarmony 设备上,其中随机练习模块是整个应用的核心功能之一。今天不聊空话,直接复盘这个项目的设计思路、适配踩坑和随机练习的完整实现方案。
这个项目做下来有个很直观的感受:Flutter for OpenHarmony 已经不是“能不能跑”的问题,而是“怎么跑得稳、怎么把业务逻辑和原生能力缝起来”的问题。艺考真题题库这类应用,核心痛点非常明确:题量大、分类多、考生需要反复刷题。而随机练习模块的体验好坏,直接决定用户是否愿意天天打开这个 App。
如果你正在计划把 Flutter 应用迁移到 OpenHarmony 平台,或者手头正好在做一个题库类、工具类 App,这篇复盘能帮你省掉不少弯路。全文会按项目拆解、环境搭建、随机练习算法、平台通道适配、数据组织、问题排查这几个维度逐步展开。
1. 项目背景与整体设计拆解
先说清楚这个项目到底要做什么。艺考真题题库,面向的是音乐、美术、舞蹈等艺考考生,App 涵盖各科目历年真题、答案解析、模拟练习。用户在真实考场之外需要一个可以反复刷题的训练环境。这个 App 的典型使用场景是“碎片化刷题”,比如等车刷十道题、午休刷一套选择题,因此“随机练习”模块不能只是简单地把题目顺序打乱,还要考虑练习节奏、历史记录、错题归纳、练习时长统计等一整套体验。
技术栈选型上,我选择了 Flutter 而不是原生开发,原因其实挺直白:艺考类 App 将来大概率会同时上 Android、iOS,甚至 Windows 桌面端,Flutter 一套代码多端复用的优势非常明显。而针对 OpenHarmony 的适配,当时 Flutter 官方已经通过社区分支支持 OpenHarmony 构建,这意味着我可以保留 Dart 层的大部分业务逻辑,只需要在平台通道层面做鸿蒙适配。
架构上我分了三层:
- UI 层:Flutter Widget 搭建的题目卡片、答题按钮、倒计时条、结果统计页。
- 业务层:Dart 编写的题库加载、随机抽题、练习状态管理、错题本逻辑。
- 平台层:MethodChannel 和 EventChannel 分别负责读写本地文件、读取设备信息、获取系统配置、调用原生弹窗等能力。
这三层划分在后续适配鸿蒙的时候非常关键。因为 OpenHarmony 的 API 接口和 Android 原生并不是一一对应的,尤其是文件存储、权限申请、FTP 下载这类系统能力,必须收敛到平台层做适配,业务层不要直接依赖 Android 的 api 包。
架构清晰之后,整个项目的开发节奏就很顺:先用 Android 模拟器把纯 Flutter 逻辑调通,再抽出平台通道接口,最后一并适配 OpenHarmony。随机练习模块的所有逻辑都在 Dart 层完成,所以迁移到鸿蒙的时候业务逻辑几乎是零改动。
1.1 核心需求解析:随机练习到底要解决什么问题
随机练习看起来很“简单”,不就是从题库里随机抽题吗?但把需求拆开看,它至少要解决四个问题:
第一,范围控制。艺考真题题库有按科目、按年份、按题型分类的多种维度。考生要练“美术类近五年选择题”,系统就不能随机到音乐类题目。
第二,随机策略。纯随机会让高频考点被稀释,比如某年真题特别有代表性,但纯随机抽取时它出现的概率和其他题目一样,反而达不到训练效果。因此要支持“全随机”和“权重随机”两种模式。
第三,去重。一次练习中不能重复出现同一道题。随着练习次数增加,用户可能练完全部题目,这时候还要有“错题优先重练”的机制。
第四,状态保存。练习做到一半切到后台,或者用户主动退出,再次进入要能恢复进度。这个涉及导航状态和页面状态两层,Flutter 的 Navigator 本身在页面切换时会保留 State,但 App 被系统杀掉进程后,就得依赖本地持久化恢复。
以上四个点,单独拿出来实现都不难,但组合在一起,就是随机练习模块的核心竞争力。我见过很多题库 App 只做了最简单的“随机抽题”,没有范围控制和权重策略,用户刷几轮就发现题目重复严重,或者越练越偏——体验很差。所以在设计随机练习之前,一定要先做一轮需求拆解。
1.2 技术选型:为什么 Flutter + OpenHarmony 是合理组合
Flutter 在 OpenHarmony 上的支持,主要是通过 OpenHarmony 的 Flutter SDK 分支实现的。社区维护了一套支持鸿蒙的引擎和 embedder,可以把 Flutter 的 UI 渲染进 OpenHarmony 应用里。Dart 层的代码、Widget 树、状态管理库(比如 Provider、Bloc)全都原样工作,这非常舒服。
那么问题来了:为什么不直接用 ArkUI 写鸿蒙原生?原因很现实。
第一,如果只做鸿蒙原生,Android 和 iOS 的版本就得各写一套,对于一个中小团队来说成本太高。第二,Flutter 的 UI 渲染自绘,不依赖系统 Widget 库,因此在跨端时 UI 一致性非常好,艺考 App 涉及很多图文混排、表格、公式渲染,Flutter 在这类复杂布局上比跨端 WebView 方案稳得多。第三,Dart 的 AOT 编译性能不错,在鸿蒙设备上跑题卡滑动、选项选中动画,体验可以达到 60 帧。
当然,Flutter for OpenHarmony 也远未到完美的程度。后面章节我会专门展开环境搭建和常见报错,这里先给一个结论:如果你的项目是重型 UI 交互类 App,Flutter 这套组合非常合适;如果只是简单的工具类应用,ArkUI 原生可能更轻量。项目定位不同,选型结论可以完全不同。
2. 环境搭建与 OpenHarmony 适配实战
2.1 Flutter SDK 下载与鸿蒙工具链配置
这个环节是最劝退新手的。如果你直接去 flutter.dev 下载标准版 Flutter SDK,你会发现构建鸿蒙应用时找不到 OpenHarmony 的 toolchain。原因在于,标准的 Flutter SDK 只带 Android、iOS、Web、Windows、macOS、Linux 这些平台的构建脚本,OpenHarmony 的构建支持由社区维护的独立 SDK 提供。
我当时用的方案是:
- 克隆社区维护的 flutter_flutter 仓库,切换到支持 OpenHarmony 的 stable 分支。
- 安装 DevEco Studio,用于打开和管理鸿蒙工程。
- 安装 OpenHarmony SDK,并配置 HarmonyOS 的 toolchain 路径。
- 在
flutter config里添加 OpenHarmony 平台设置,确保flutter doctor能识别到 OpenHarmony SDK 路径。
配置完成后,可以用flutter create --platforms ohos创建鸿蒙工程模板。注意,这里不是--platforms harmony,Different 时期社区命令有过调整,如果发现在flutter create里找不到 ohos 选项,说明 SDK 版本太老。
我第一次配置的时候踩了个很典型的坑:OpenHarmony SDK 目录下同时存在ets、java、native等多个子目录,而社区版的 Flutter SDK 需要的是native和js目录下的工具链。如果你只安装了 IDE 自带的 SDK 而没有安装native组件,构建时会报找不到 ohos 工具链。这个问题的解决方法是打开 DevEco Studio 的 SDK Manager,把native组件勾上并安装。
2.2 解决 gradle 插件声明问题
网上搜热词的时候,很多人搜“you are applying flutter's main gradle plugin imperatively using the apply s”,这是 Flutter Android 构建系统升级后非常常见的一个编译报错。在鸿蒙场景下,由于工程模板的双平台支持,这个问题尤其容易出现。
报错原因:新版 Flutter 希望你在android/settings.gradle里用插件声明方式(plugins DSL)引入 Flutter Gradle 插件,而不是在模块的build.gradle里写apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"。
修复方式为,在android/settings.gradle中添加插件声明:
plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "com.android.application" version "8.1.0" apply false id "com.android.library" version "8.1.0" apply false }同时在android/app/build.gradle中显式声明 Flutter 插件:
plugins { id "com.android.application" id "dev.flutter.flutter-gradle-plugin" }改完之后同步 Gradle,一般就能正常构建。
这个报错的本质是 Flutter 工具链从“命令式脚本”向“Gradle 插件 DSL”迁移的大趋势。在 Heat-1.2 之后的 Flutter 版本里,官方已经强制改成了插件 DSL 方式。如果你为了省事去网上搜一个旧版本 Flutter SDK 来绕开这个报错,后续很多新插件都会因为 SDK 版本不匹配而编译失败,所以我的建议是:直接用新版,把工程模板一起升级。
2.3 当前配置的 Flutter SDK 版本不受支持警告
另一个高频热词是“the current configured flutter sdk is not known to be fully supported.please”,这其实是 Flutter 的版本检查机制在报警。当你的 OpenHarmony 平台插件声明的版本兼容范围和当前 Flutter SDK 不匹配时,构建系统会在终端打印这串警告。
举个例子,如果 Flutter SDK 是 3.22,而你在pubspec.yaml里声明的flutter_ohos相关依赖要求 3.24 以上,就会触发这个警告。它可能会被当成 error 导致构建中止,所以不能完全无视。
应对策略有两种:
- 升级 Flutter SDK 到警告提示的受支持版本。比如把 SDK 切换到 3.24 或 3.27 的稳定分支。
- 如果是企业项目暂时不能升级 SDK,那就明确锁定插件版本,把所有第三方插件的版本约束都放在和当前 SDK 匹配的区间。
注意,不要在flutter config里盲目关闭版本检查。那样虽然能绕过警告,但后续 impeller 渲染引擎的某些特性可能会在鸿蒙设备上表现异常,而且 Flutter 引擎版本和插件原生代码编译有很强的耦合关系,强行跳过检查只会把问题埋到后面。
2.4 Impeller 渲染引擎在鸿蒙上的启用与关闭
Flutter 3.10 之后 Impeller 成为 iOS 上的默认渲染引擎,Android 平台也在逐步切换。到了 OpenHarmony 分支,Impeller 的默认启用策略比较微妙,我先后试过两个版本:一个启用 Impeller,一个不启用,真机对比下来发现:如果 App 里大量使用自定义字体和文本缩放功能,Impeller 的文本渲染在鸿蒙设备上可能有字体模糊、字距异常的问题;但如果以图形和画板类页面为主,Impeller 的性能反而更好。
艺考题库 App 正好是一个文字密集型应用,题目解析里有大量长文本、表格、加粗强调、上下标。在鸿蒙平板上我实测发现,Impeller 渲染长文本时,滚动流畅度和清晰度比 Skia 好,但也出现了个别字体在加粗状态下偶尔发虚的情况。所以最终我选择在鸿蒙平台上关闭 Impeller,等待后续版本适配。在ohos/entry/src/main/ets/entryability/EntryAbility.kt或相应配置里加 flutter 启动参数即可:
--no-enable-impeller如果你也遇到字体渲染问题,优先用这个开关辅助定位,不要一上来就怀疑是字体文件的问题。
3. 随机练习模块的核心实现
3.1 题目数据的建模与本地化加载
在任何随机策略开始之前,先把题目数据建模做好。我这里用的 JSON 结构大致如下:
{ "category": "arts_design", "questions": [ { "id": "A1001", "type": "single", "difficulty": 3, "year": 2022, "stem": "以下哪位画家的代表作品是《开国大典》?", "options": ["徐悲鸿", "董希文", "齐白石", "张大千"], "answer": "B", "analysis": "董希文于1953年创作《开国大典》...", "tags": ["油画", "现代"], "weight": 1.0 } ] }Dart 侧模型用fromJson方法解析,字段不多,不需要引入 json_serializable 这种重的代码生成工具,手写一个Question类配合类型转换就够了。注意 answer 字段我这里存的是选项索引而不是文本,因为选项顺序会被随机打乱。解析完成后,把全部题目缓存到内存中,供后续随机算法使用。
在实际使用时,题库文件可能比较大。一套完整的艺考真题库大概有 1 万多道题,JSON 文件规模在 20-30 MB 左右。如果每次冷启动都从本地 JSON 解析,耗时肉眼可见。所以我在本地做了一个简单的缓存层:首次解析后把题目列表压缩序列化成二进制文件,后续启动直接读缓存,可以有效把题库加载时间从 2 秒以上压缩到 300 毫秒以内。具体实现可以用dart:io的文件操作配合gzip压缩,简单可靠。
3.2 Fisher-Yates 洗牌算法与随机抽题策略
随机练习的核心当然不在 UI,而在抽题策略。最基础的方法是先把题库复制一份,然后对题目列表做洗牌,取前 N 道题。
洗牌算法这里需要避开一个常见误区,就是直接用list.shuffle()。Dart 的List.shuffle()内部实现是 Fisher-Yates 洗牌,本身没有问题,但如果你需要可控的随机种子,就必须自己实现。
为什么需要可控种子?因为艺考练习有一个特殊需求:用户可能希望明天继续练习相同的一套题,以便于复盘。如果完全随机,那么每次进来题都不一样,用户没法针对同一组题做回顾。所以我的设计是允许用户设置一个“练习编号”,用这个编号作为随机种子生成题目序列,既能保证随机,又能保证同套题可复现。
import 'dart:math'; List<Question> generatePracticeSet({ required List<Question> source, required int count, required int seed, }) { final random = Random(seed); final pool = List<Question>.from(source); for (var i = pool.length - 1; i > 0; i--) { final j = random.nextInt(i + 1); final temp = pool[i]; pool[i] = pool[j]; pool[j] = temp; } return pool.take(count).toList(); }这个函数接收源头题库、所需题目数量和随机种子,返回一套随机排序的题目列表。Fisher-Yates 的数学保证是:每个排列出现的概率相等,也就是说题目之间的相对顺序被打散得足够均匀,不会出现连续多道题都是同一个考点的情况。
但这里还有一个问题:如果按权重抽题,就不能直接先洗牌再取前 N 道,因为权重大的题可能排在后面取不到。所以我做了两套策略:
- 纯随机模式:上述洗牌逻辑,适用于“全部真题大乱斗”。
- 权重随机模式:优先按考点权重抽题,保证高频考点有更高的出现率。
3.3 权重抽题实现:让高频考点更醒目
权重抽题函数的实现思路很直接——把每道题的权重值映射到数轴上,然后生成随机数,落到哪段区间就选中哪道题。权重可以来自错题次数、考纲重点标记、年份远近等逻辑。
Question pickQuestionByWeight({ required List<Question> pool, required Random random, }) { var totalWeight = 0.0; for (final q in pool) { totalWeight += q.weight; } var r = random.nextDouble() * totalWeight; for (final q in pool) { r -= q.weight; if (r <= 0) { return q; } } return pool.last; }我通常在“章节练习”模块使用这个函数,因为章节练习本身就是按知识点组织的,出题思路应该贴近考纲。注意,权重值并不需要严格归一化,只要相对大小合理就行。比如,高频考点权重设 3.0,普通考点设 1.0,这样高频考点出现概率是普通的三倍。
做了几轮测试后我调整了一个细节:同一轮练习中,刚被选中过的题要把权重临时置低,避免同一题反复出现。这个可以通过维护一个本轮已选集合来实现,已选题目在下一轮抽题时直接排除,等所有题目都抽完后再重置。
3.4 选项随机乱序与答案索引重映射
艺考做题有一个习惯,就是有些考生会背答案位置。如果一套题库选项顺序固定,刷两轮后,学生看到“第二题选 C”就直接选,不再读题,练习效果大打折扣。解决办法很粗暴——每道题的选项在每次练习时随机打乱:
class ShuffledQuestion { final Question original; final List<String> shuffledOptions; final int correctIndex; } ShuffledQuestion shuffleQuestion(Question q, Random random) { final indices = List<int>.generate(q.options.length, (i) => i); indices.shuffle(random); final shuffledOptions = indices.map((i) => q.options[i]).toList(); final correctIndex = indices.indexOf(q.options.indexOf(q.answer)); return ShuffledQuestion( original: q, shuffledOptions: shuffledOptions, correctIndex: correctIndex, ); }这里有一个决定考场真实感的细节:选项打乱后,原来answer: "B"的信息已经失效,必须重新计算正确答案在新选项列表中的下标。另外,用户在答题时如果选择了 A,提交之后我要把 A 映射回原始选项,才能和数据库里的标准答案对比。这个映射关系在每次生成题目时就要保留,否则后面判分和错题本记录都会错乱。
一开始我图省事,只把字符串 “A”、“B”、“C”、“D” 打乱顺序,然后直接作为新答案。结果就是多选题的判分逻辑一塌糊涂,因为多选要比较选项集合,字符串乱序后集合关系反而容易出错。后来改成选项列表整体重排、下标映射,判分逻辑才稳定下来。
3.5 练习状态的保持与导航状态丢失问题
Wore 的热词里有“flutter navigator切换页面后,会丢失状态吗”,这其实是 Flutter 新手高频问题。标准答案是:Navigator push 到新页面再 pop 回来,原页面的 State 默认不会丢失,因为原来的路由被压在导航栈中,Widget 树还保留在内存里。但如果页面被回收,或者你用了Navigator.pushAndRemoveUntil、popUntil之类的方法,状态就有丢失风险。
题库 App 的状态丢失场景主要发生在系统内存紧张时。艺考 App 在平板上使用频率很高,用户开着 App 的同时后台还有其他应用,系统可能回收了练习页的 State。这种情况下,随机练习的进度、当前题号、已选答案都需要从本地持久化里恢复。
我的做法是在业务层单独维护一个PracticeSession类,把当前题目集合、索引、已答记录、剩余时间周期性地写入 SharedPreferences 或本地文件中。
class PracticeSession { final int seed; final List<String> questionIds; int currentIndex; final Map<String, String> answers; void save() { // 序列化为 JSON 写入本地文件 } factory PracticeSession.restore() { // 从本地读取并反序列化 } }页面侧只负责渲染和用户交互,不直接持有题库数据。这样即使页面状态真的丢了,重新进入页面后可以从PracticeSession里恢复整套练习数据。
热词里还提到了“flutter cubit”,这是 flutter_bloc 库中的状态管理组件,非常适合这个场景。我用 Cubit 管理PracticeSession,页面通过监听 Cubit 的状态变化来刷新 UI。Cubit 的优势是状态变更逻辑集中且可测试,不像 setState 那样散落在各个 Widget 里。
class PracticeCubit extends Cubit<PracticeState> { PracticeCubit() : super(PracticeInitial()); void nextQuestion() { final session = state.session; session.currentIndex += 1; emit(PracticeUpdated(session)); } void selectAnswer(String optionId) { final session = state.session; session.answers[session.currentQuestionId] = optionId; emit(PracticeUpdated(session)); } }这里要留意状态不可变性问题。Cubit 的 emit 触发 UI 重建,如果同一个状态对象被多次修改并 emit,而 UI 又是在build里读取的,容易因为相等判断失效导致不刷新。一般我看 UI 是否正常更新,先确认是否传递了新的 PracticeSession 实例,而不是修改旧实例。
4. 题库数据的组织、同步与更新机制
4.1 题库文件的增量更新
题库类 App 最大的坑在于:题目数据不是一次性固定的,每年都有新的艺考真题入库。如果 App 把题库硬编码在 assets 里,那么每次更新题目都必须发版,在应用市场上架审核,效率太低。所以我在工程里设计了一套增量更新方案。
具体做法是:
- 应用内置基础题库,版本号写在本地配置中。
- 启动时向服务器请求最新题库版本号,如果本地版本号落后,则提示用户下载更新包。
- 更新包是一个 zip 文件,内含若干个按分类组织的 JSON 文件。
- 下载后经过完整性校验、解压、合并、重建索引,最后写入本地缓存目录。
这一套逻辑在 Flutter 侧实现不复杂,难点在不同平台的路径策略。Android 上可以用getApplicationDocumentsDirectory,OpenHarmony 上要用鸿蒙的公共文件目录,路径处理有细微差异。所以我把这部分归到平台层,通过 MethodChannel 暴露一个getDataDirectory方法给 Dart 层调用。
4.2 FTP 更新源的问题与替代方案
有一个热词是“openharmony ftp”,这可能是很多做鸿蒙网络功能的人都会搜索的。鸿蒙对 FTP 协议没有 Java 的Apache Commons Net那样的现成库,所以如果题目更新源是 FTP 服务器,就得在两个端各写一套逻辑:Android 用 Java 实现 FTP 客户端,鸿蒙用 ArkTS 或 C++ 调用 socket 自己写一个简易 FTP 下载器。这样做维护成本比较高。
我后来直接换成了 HTTPS 静态文件下载。题目包是压缩好的 zip,放在对象存储或者 Web 服务器下,用dart:io的HttpClient下载。这比 FTP 省事得多,兼容性也更好。除非你的更新环境是纯内网且只开通了 FTP 端口,否则强烈建议第一个版本就直接用 HTTPS 文件下载,不要自己造 FTP 轮子。
4.3 题库合并与去重策略
下载回来的新题库和本地题库合并,不是简单地把新 JSON 追加到旧 JSON 后面。因为题目 ID 可能冲突,题目内容也可能更新(比如答案修正、解析补充)。我以id作为唯一键,合并时遵循两条规则:
- 如果题目 ID 已存在,对比
updatedAt时间戳,选择更新的版本。 - 如果本地存在旧题但服务器包中已删除,那么保留本地,因为考生可能已经做过这道题了,历史记录需要保留关联。
合并过程在 Dart 侧很容易实现,遍历服务器包里的题目,放入一个Map<String, Question>,key 为题目 ID,value 覆盖更新。然后序列化写回本地缓存。整个过程建议在 isolate 中执行,否则主线程卡顿明显,热词里搜“codex flutter”的朋友应该也遇到过类似的耗时代码问题。
Future<void> mergeQuestionBank({ required List<Question> remoteQuestions, required List<Question> localQuestions, }) async { final map = { for (final q in localQuestions) q.id: q, }; for (final q in remoteQuestions) { final exist = map[q.id]; if (exist == null || q.updatedAt.isAfter(exist.updatedAt)) { map[q.id] = q; } } final merged = map.values.toList(); await cacheQuestionBank(merged); }合并之后,为了加快后续冷启动速度,可以为题目 ID 建立索引文件。这样按 ID 查找单题时不需要遍历整个大列表,在错题本功能中尤其好用。
5. OpenHarmony 平台通道与原生能力调用
5.1 EventChannel 与 MethodChannel 的鸿蒙适配
Flutter 与原生平台的通信有三大通道:MethodChannel(方法调用)、EventChannel(事件流)、BasicMessageChannel(双向消息)。在鸿蒙平台上,这三者的桥接原理和 Android 一致,但注册位置不同。
Android 的 Flutter 引擎是在MainActivity的configureFlutterEngine里注册通道,而鸿蒙是在EntryAbility的onWindowStageCreate生命周期里注册。
// EntryAbility.ets 简化的通道注册逻辑 windowStage.loadContent('pages/Index', (err) => { if (!err) { const flutterEngine = this.getAppContext().getResourceManager(); // 通过引擎注册 EventChannel } });我项目中用 EventChannel 做的比较典型的事情是:把原生侧的文件下载进度推送给 Dart 层。FTP 或 HTTPS 大文件下载时,原生侧能拿到实时的字节进度,但 Flutter 侧只能等到下载完成后才能拿到结果,体验很差。改成 EventChannel 之后,原生侧每隔 200 毫秒推送一次progress事件,Flutter 侧监听并更新进度条,体验和纯原生 App 没有区别。
5.2 鸿蒙侧文件路径与权限适配
题库 App 必然涉及文件读写,但 OpenHarmony 的权限体系和 Android 差异不小。Android 上有READ_EXTERNAL_STORAGE、WRITE_EXTERNAL_STORAGE这种粗粒度权限,而鸿蒙从 API 9 开始就强调ohos.permission.READ_MEDIA之类的按需授权,以及通过安全控件或文件选择器授予 URI 级别的访问能力。
如果想让 Dart 侧代码保持统一,我的建议是把所有文件访问都封装成一个FileStorageService,在 Android 上实现为基于getApplicationDocumentsDirectory,在鸿蒙上实现为基于应用沙箱目录。不要依赖平台为你保证可以读到任意路径的文件,这在鸿蒙上非常不可靠。
有一个实际遇到的问题是:下载到应用沙箱中的 zip 包,如果在解压后直接通过 file path 传给 Flutter 侧读取,偶尔会遇到中文文件名乱码。排查下来是文件名编码不一致。解决方法是 Zip 解压后统一重命名为英文或数字 ID,不保留中文文件名,彻底绕开编码问题。
5.3 判断当前设备是否支持 OpenHarmony 的运行时
还有一个很小的点,但很影响用户体验:相同的 Flutter 代码在 Android 和 OpenHarmony 上运行时,某些平台能力不同。比如在鸿蒙上可以调用系统分享面板,把成绩单分享给好友或老师;而 Android 上的系统分享面板行为又不一样。所以我在平台通道里建了一个getPlatformInfo接口,Dart 层可以获取当前运行环境的标识,再决定 UI 上展示哪些功能入口。
Future<PlatformInfo> getPlatformInfo() async { const channel = MethodChannel('app/core/platform'); final map = await channel.invokeMapMethod('getInfo') as Map; return PlatformInfo( platformName: map['platformName'] as String, systemVersion: map['systemVersion'] as String, isHarmony: map['platformName'] == 'ohos', ); }这个isHarmony标识在后期排查问题的时候特别有用。比如随机练习模块在鸿蒙平板上出现性能问题,我可以用它区分只在鸿蒙上打印调试日志,或者在鸿蒙上自动切换为更省电的渲染模式。
6. 常见问题与排查技巧实录
这一部分集中列出我做这个项目时遇到的典型问题,以及对应的排查思路和解决方案。
6.1 构建报错速查表
| 报错信息 | 原因 | 解决方法 |
|---|---|---|
| Could not close i... | 文件流未关闭,或目录没有写权限 | 检查代码中是否有文件句柄泄漏,使用try/finally或using确保关闭;同时检查鸿蒙沙箱目录路径是否正确 |
| You are applying flutter's main gradle plugin imperatively... | 新版本 Flutter 已弃用命令式 Gradle 插件引入 | 修改 settings.gradle 和 build.gradle,改用 plugins DSL 声明方式 |
| The current configured Flutter SDK is not fully supported... | pubspec 中依赖要求版本与 SDK 版本不匹配 | 升级 Flutter SDK 或锁定插件版本,不要盲目关闭版本检查 |
| Xcode 相关 Flutter 包报版本低 | 同时在 macOS 上构建 iOS 和鸿蒙时,CocoaPods 依赖冲突 | 把 iOS 构建和鸿蒙构建拆分为两个工作目录,或统一升级 Flutter SDK 版本 |
| 鸿蒙模拟器上渲染慢 | Impeller 在 OpenHarmony 上默认启用可能有性能回退 | 启动参数加--no-enable-impeller对比测试;如果是模拟器,优先换真机验证 |
6.2 EventChannel 在鸿蒙注册时机问题
Flutter 的 EventChannel 和 MethodChannel 是两边配对的:Dart 侧创建一个通道,原生侧也要在指定时机注册监听。在 Android 上,时机是configureFlutterEngine;在鸿蒙上,如果注册晚了,Dart 侧的事件监听可能已经建立但原生侧无法发送,表现为“事件丢失”。
解决方法是把原生侧的通道注册代码放在EntryAbility的onWindowStageCreate中,并确保注册逻辑在加载 Flutter 页面之前完成。同时,原生侧消息发送要做空判断,如果 Dart 侧还没有设置监听器,先缓存事件,等待注册完成后再补发。
6.3 真机与模拟器的行为差异
鸿蒙模拟器和真机的差异比 Android 模拟器和真机的差异更大。比较明显的是网络权限、文件存储路径、系统字体渲染三块。我一度在模拟器上试得很顺利,代码推到真机上却发现题目图片加载不出来。排查了很久发现,模拟器同意了我的网络权限申请,但真机上鸿蒙系统在首次启动时会弹权限申请框,用户如果点了拒绝,应用后续的 HTTP 请求全部失败。
所以我在做题库更新功能时,特意在 Dart 层加入了一个权限检测方法。如果发现网络权限没有授予,就引导用户去系统设置里打开。这个是在实际真机测试中被逼出来的功能,一开始完全没考虑到。
6.4 随机练习模块典型的空状态处理
还有一个容易忽视的边缘问题:如果题库更新包还没下载完成,用户就进入随机练习,此时题库列表为空或者只有少量题目,随机练习页面需要显示一个友好的空状态,而不是直接崩掉或白屏。
我在generatePracticeSet里增加了空判断,如果题源数量为 0,就返回一个带提示的空结果;如果题源数量小于所需数量,就全部打乱返回,而不是抛出异常。这样即便服务器资源临时不可用,App 的基本练习功能仍然可以在离线题库上正常运行。
List<Question> generatePracticeSet({ required List<Question> source, required int count, required int seed, }) { if (source.isEmpty) return []; final validCount = min(count, source.length); // 洗牌逻辑略 return shuffled.take(validCount).toList(); }不要小看这个小处理。题库类 App 太依赖服务端时,一旦遇到弱网或服务器故障,用户打开 App 一片空白,立刻就会卸载。保持本地题库可用的底线,是这类工具型应用体验的下限。
6.5 关于“要不要用 Provider / Bloc”的决策
热词里有人搜“flutter cubit”,说明状态管理仍然是 Flutter 社区里讨论最多的话题。我在随机练习模块里用的是 Cubit,在题库更新模块里用的是 Provider,两个没有强行统一。原因很简单,二者解决的是不同层次的问题。Cubit 适合描述“用户动作 -> 状态变化”的显式流程,随机练习非常适合这样的模式;而 Provider 轻量、侵入小,用于处理全局的题库版本信息、用户设置等跨页面共享状态很舒服。
如果你做的是简单项目,用 setState 也能完成所有功能。但随机练习这类涉及进度恢复、答案记录、时间统计的模块,用状态管理方案可以显著降低复杂度和出 bug 的概率。选择哪个不是关键,关键是要保持业务代码对 UI 的隔离。
7. 写在最后
这个项目做下来,最深的体会是:Flutter for OpenHarmony 的适配壁垒不在 Dart 侧,而在平台层的细节。Flutter 本身把跨端的 UI 和逻辑工作做得非常到位,但文件路径、权限、通道注册时机、渲染开关这些东西,每个平台都有自己的脾气,必须有意识和耐心去处理。
随机练习模块的实现也给了我一些反思。很多题库类 App 把随机抽题做成一个点击按钮就够了,但从真实使用场景来看,“随机”不等于“均匀”,更不等于“好练”。加入权重策略、选项乱序、进度恢复、种子可复现之后,这个功能才真正对考生有价值。代码复杂度大约上升了 30%,但用户留存率和对练习效果的评价是几何级提升。
如果你正在做类似的题库应用,我建议先把随机练习的状态管理抽象出来,不要和 UI 绑死。这样后续无论你是换 UI 框架,还是从 Android 迁移到 OpenHarmony,业务逻辑都能原样复用。
最后分享一个小技巧。在鸿蒙上调试 Flutter 应用时,除了用 DevEco Studio 看系统日志,还建议在 Dart 层使用debugPrint而不是print。因为print在 release 模式下会被编译掉,而debugPrint可以手动控制日志级别,这对线上问题的排查非常有用。我在随机练习模块的抽题函数里加了几个debugPrint输出种子和抽题结果,真机上复现问题时效率高了很多。