作为一个常年用 Flutter 做跨平台项目的开发者,我最近在鸿蒙生态上踩了不少坑,也收获了不少实战经验。这次把一个简易童谣大全应用从零到一完整跑到了鸿蒙真机上:Flutter 框架负责 UI 与业务逻辑,跨平台能力覆盖 Android、iOS 与鸿蒙,童谣列表、详情、音频播放、收藏一应俱全。这篇教程适合想给现有 Flutter 项目补一个鸿蒙输出端的团队,也适合刚接触 Flutter 但想尝试鸿蒙开发的个人开发者。
1. 项目缘起与整体设计思路
1.1 为什么用 Flutter 开发鸿蒙应用
先聊一个很多开发者都会关心的问题:鸿蒙应用到底该用 ArkTS 原生写,还是可以用 Flutter?我的答案是,看团队情况。如果团队本身就是 Flutter 技术栈,手头已经维护着 Android、iOS 两端的代码,那给鸿蒙加一个输出端是性价比较高的方案,UI 层和业务逻辑几乎不用动,主要成本在原生适配层。
鸿蒙目前的生态已经支持 Flutter 引擎运行,UI 渲染走的是鸿蒙的原生图形栈,Dart 代码可以直接跑起来,插件层面则需要看有没有对应的 ohos 平台实现。这个现状意味着:纯 Dart 写的页面、状态管理、网络请求、本地存储,在鸿蒙上基本都能无缝迁移;但涉及系统能力(音频、相机、定位、蓝牙等)的插件,需要确认是否支持鸿蒙,或者干脆自己用平台通道封装一层。
这个童谣应用恰好是一个非常适合验证这套模式的场景:功能不算复杂,但又有音频播放、本地数据持久化这些典型的原生能力诉求。把它完整跑通,相当于给团队趟了一条 Flutter 到鸿蒙的完整路径,后面再做复杂项目时,风险就小很多。
1.2 童谣应用需要哪些功能
项目名叫“童谣大全”,但“简易”两个字很关键,它决定了我们不应该做成一坨大而全的 App,而是把核心链路打通、界面干净好用就行了。我列了一个最小的功能集:
- 童谣列表:首页展示童谣卡片,支持按分类筛选(摇篮曲、问答歌、数字歌、动物主题等)。
- 童谣详情:点进列表项后展示完整文本,支持字体大小调节,方便给孩子看。
- 音频播放:每首童谣配一段音频,支持播放、暂停、停止、进度展示。
- 收藏功能:可以把常听的童谣收藏起来,下次从“我的收藏”快速进入。
- 本地搜索:按童谣名称搜索,数据量小,全部本地处理就行。
这些功能覆盖了 Flutter 开发中比较常见的能力:列表渲染、路由跳转、状态管理、本地 JSON 解析、SharedPreferences 存储、平台通道调用原生音频能力。作为教程型项目,它足够典型,又不会因为业务复杂把读者绕晕。
1.3 技术选型:本地数据、音频播放、状态管理
技术选型上,我走了比较保守的路线,尽量少依赖第三方插件,因为鸿蒙生态的插件支持还在逐步完善中,用太多社区包容易在适配环节翻车。
- 本地曲库:用内置 JSON 文件存储童谣数据,启动时一次性加载到内存。
- 收藏数据:用 shared_preferences 保存收藏的童谣 ID 列表,这个插件在鸿蒙上已经有可用的适配实现。
- 状态管理:不引入 Provider、Riverpod 这类重型状态库,直接用 Flutter 自带的 ChangeNotifier 加 InheritedWidget 思路,再配合 ValueListenableBuilder,简单直接、无额外依赖。
- 音频播放:这是唯一需要动用原生能力的地方。我选择了自定义平台通道的方式,用 MethodChannel 调用鸿蒙侧的播放器封装,绕开第三方播放插件对鸿蒙支持的不确定性。
这套组合的好处是:大部分代码是纯 Dart,跨端行为完全一致,只有音频播放这一处需要写鸿蒙原生代码。对新手来说,学习曲线也比较友好,不需要面面俱到地掌握各种框架。
2. 环境准备与工程搭建
2.1 版本匹配与工具链
先说一个最容易让人卡住的地方:Flutter SDK 和鸿蒙开发工具链的版本匹配。鸿蒙应用开发需要 DevEco Studio,而 Flutter 要输出鸿蒙端,需要安装带 ohos 平台支持的 Flutter SDK。如果你直接装官方 Flutter 稳定版,很可能发现flutter create的时候根本没有 ohos 这个平台选项,构建指令也识别不了flutter build hap。
我本地环境的情况供参考:Flutter SDK 使用支持鸿蒙输出的版本(Dart 基础语法一致),DevEco Studio 安装好鸿蒙 SDK,并配置好 OpenHarmony 或 HarmonyOS NEXT 相关的开发环境。这里我不建议照抄某一版本号,因为鸿蒙 SDK 更新迭代比较快,换了版本后生成的模板结构可能有差异。关键在于确认三件事:
- Flutter SDK 里存在
ohos平台模板; flutter doctor能识别出鸿蒙相关工具链;- 命令行执行
flutter build hap时有明确产物输出。
这三项都通过了,环境基本就没问题。
2.2 从零创建 Flutter 工程
创建工程的时候,可以明确指定需要生成的平台目录,命令类似这样:
flutter create --platforms=android,ios,ohos nursery_rhymes_app如果当前的 Flutter SDK 支持 ohos 平台,这条命令会自动生成ohos/目录,整个工程的代码结构包括:
lib/:Dart 源码目录,所有 UI 和业务逻辑都在这。android/、ios/:原有的跨平台壳工程。ohos/:鸿蒙壳工程,里面包含 Entry 模块、JSON 配置文件以及鸿蒙原生代码目录。
ohos/目录里的内容初看会比较陌生,但本质上和 Android 工程的android/类似,包含应用入口、资源文件和权限声明。Flutter 引擎会在这个壳工程里启动,Dart 侧的 UI 渲染到鸿蒙的窗口上。
2.3 鸿蒙工程与 Flutter 的关联方式
有一个容易被忽略的点:Flutter 生成的ohos工程里,真正的 Flutter 引擎依赖是通过配置声明的。你在 DevEco Studio 里打开ohos/目录,看到的项目和普通鸿蒙工程长得不太一样,除了入口 Ability 外,还会有一个专门加载 Flutter 容器的地方。
我把整个加载流程概括成三步:
- 鸿蒙侧的 Entry 配置加载
FlutterContainer或者类似组件; - 组件启动后初始化 Flutter 引擎并加载 Dart 入口;
- 引擎把第一帧渲染到鸿蒙窗口后,Flutter 侧代码开始跑
main()。
如果这一步正常,你会看到 App 首页的 Flutter UI 出现在鸿蒙模拟器或真机上。很多环境问题都是在这一步集中暴露的,比如签名不对、SDK 版本不兼容、权限声明缺失等。
提示:首次在 DevEco Studio 中构建时,记得检查自动签名是否配置好。如果没有有效的签名信息,应用无法安装到真机,模拟器上也可能出现安装失败。
3. 数据层与状态管理
3.1 童谣数据结构设计
童谣数据本身不复杂,但设计得合理一些,后面加功能会省很多事。我用的数据结构大概是这样的:
{ "id": "rhyme_001", "title": "小星星", "category": "摇篮曲", "lyrics": "一闪一闪亮晶晶,满天都是小星星……", "audio": "assets/audio/rhyme_001.mp3", "duration": 85, "collected": false }id是唯一标识,category用来做分类筛选,lyrics是全文歌词,audio指向本地音频资源,duration是音频时长(秒),collected是运行时收藏状态。这里有个小心机:最终收藏状态其实存在本地存储里,但每次加载曲库时会和本地收藏 ID 列表合并,避免 UI 层反复读取存储。
3.2 用 JSON 做内置曲库
曲库数据存放在 Flutter 工程的assets/目录下,我在pubspec.yaml里声明资源:
flutter: assets: - assets/data/rhymes.json - assets/audio/加载逻辑放在一个独立的 Repository 类里。用rootBundle.loadString读取 JSON 文本,然后用jsonDecode转成 List,再映射成模型对象。整个过程是同步的,因为数据量小,几十首童谣根本不会有性能压力。
不过有两个细节要注意:一是音频文件不要塞在 JSON 里,而是存路径字符串,否则资源管理会变得很乱;二是分类名称最好统一用枚举值或者常量,避免用户手动输入产生同义重复(比如“催眠曲”和“摇篮曲”被当成两类)。
3.3 状态管理为什么选 ChangeNotifier
这个小应用的状态无非三种:曲库列表、当前播放的童谣、收藏集合。用 ChangeNotifier 加一个全局单例,完全够用。
我定义了一个AppState类:
class AppState extends ChangeNotifier { List<Rhyme> _allRhymes = []; Rhyme? _currentRhyme; bool _isPlaying = false; Set<String> _collectedIds = {}; List<Rhyme> get allRhymes => _allRhymes; Rhyme? get currentRhyme => _currentRhyme; List<Rhyme> get collectedRhymes => _allRhymes.where((r) => _collectedIds.contains(r.id)).toList(); void toggleCollect(String id) {} void playRhyme(Rhyme rhyme) {} void stopPlay() {} }页面侧监听这个对象,数据变化后直接notifyListeners,相关的AnimatedBuilder会自动刷新。为什么不选 Provider?因为引入一个状态管理框架本身没问题,但对教程型项目来说,每多一个依赖,读者就要多学一个概念,而且如果这个框架的某个内部实现依赖了不兼容的 API,在鸿蒙上又可能多一个排查点。ChangeNotifier 是 Flutter 自带的,零成本,足够撑起这个量级的应用。
4. 页面开发实战
4.1 首页列表与分类 Tab
首页是整个应用的门面,我把它做成两段式:顶部是分类 Tab,下面是对应的童谣卡片列表。分类 Tab 不单独建表,而是从曲库数据里动态抽取所有出现过的分类,好处是新增童谣时不用同步维护分类数据。
列表卡片我用了Card搭配ListTile,左侧显示童谣标题和分类标签,右侧放一个收藏图标和一个播放按钮。这个页面最关键的是点击逻辑:点卡片主体进详情页,点收藏图标切换收藏状态,点播放按钮直接开始播放,三者互不干扰。实测在鸿蒙上触摸事件的响应没问题,但注意不要在一个GestureDetector里嵌套太多手势判断,否则会出现点击穿透或者事件竞争。
我用的导航方式是Navigator.push,但这里有个鸿蒙上的细节:Flutter 生成的鸿蒙工程如果开启了类似 Android 的单 Activity 模式,Navigator.push表现正常;但如果配置不当,可能出现页面切换时软键盘弹不出来的问题。这个后面在问题排查部分再展开。
4.2 详情页与歌词展示
详情页是信息密度最高的页面,要同时展示标题、音频控制条、完整歌词和收藏按钮。布局上用CustomScrollView做整体滚动,把顶部音频控制区和歌词区放在一起,避免用户在小屏上滑动时看不到播放控制。
歌词展示有个小技巧:不要把长歌词直接堆在一个Text里,而是按行拆分后用列表渲染。这样做的好处有两个:一是后续可以精准实现“当前正在唱哪一行”的高亮效果;二是长文本在鸿蒙上的排版更稳定,不会出现整块文字渲染模糊的问题。
播放控制条我用一个Slider展示当前进度,拖动滑块可以跳转播放。这里要特别注意:滑块拖动过程中会频繁触发回调,不要每次回调都去调用原生播放器的 seek 方法,否则很容易卡顿。我的做法是拖动中只更新本地 UI 状态,手指松开后再真正 seek。
4.3 收藏与搜索
收藏功能的实现核心是数据同步。每一首童谣的收藏状态变更时,立即写入 SharedPreferences,同时更新内存中的AppState,保证 App 杀掉重开之后收藏还在。
我把“已收藏”存储成一个 JSON 数组,只存 ID 列表,不存完整数据。原因很简单:收藏列表需要展示的标题、分类、封面等信息都可以从曲库里查,没必要重复存储。这样也避免了以后修改曲库数据时,收藏数据不同步的问题。
搜索功能则是在列表页加一个搜索框,用where过滤标题和歌词包含关键词的童谣。数据量小时,直接在内存里遍历没有问题,不需要引入数据库。我实测在几百首童谣的规模下,每次搜索响应时间都在几毫秒以内,体验已经足够流畅。
注意:搜索时过滤条件要对首尾空格做 trim,否则用户输入“ 小星星 ”会搜不到结果。很多新手在这个细节上吃过亏。
5. 音频播放:平台通道与鸿蒙原生适配
5.1 播放方案选型
音频播放是整个项目里唯一动了原生代码的地方,也是我个人觉得最有技术含量的部分。一开始我也考虑直接用第三方音频插件,但查了一圈之后发现鸿蒙适配的成熟度参差不齐,有的插件 Android 和 iOS 都支持,ohos 目录则是空的或者处于实验状态。与其等插件补全,不如自己封一层平台通道,把控制权握在自己手里。
Flutter 和鸿蒙原生通信的标准方式是 MethodChannel。Dart 侧定义一个工具类,通过MethodChannel('com.example.rhymes/audio')发起调用,鸿蒙侧在 Entry 对应的 Ability 里注册 MethodChannel 的处理器。这样音频的初始化、播放、暂停、停止、seek 都走平台通道,页面侧只管接收状态回调。
5.2 MethodChannel 桥接设计
Dart 侧的音频服务类我命名为AudioPlayerService,核心代码如下:
class AudioPlayerService { static const _channel = MethodChannel('com.example.rhymes/audio'); static Future<void> play(String path) async { await _channel.invokeMethod('play', {'path': path}); } static Future<void> pause() async { await _channel.invokeMethod('pause'); } static Future<void> stop() async { await _channel.invokeMethod('stop'); } static Future<void> seekTo(int positionMs) async { await _channel.invokeMethod('seekTo', {'positionMs': positionMs}); } }你可以看到,方法名都起得尽量直观。实际开发中,我还会增加一个getDuration方法,用来在播放开始后回传音频总时长。而播放进度,则通过鸿蒙侧的 EventChannel 或者 MethodChannel 的回调持续发给 Flutter,让滑块实时跳动。
5.3 鸿蒙侧 AVPlayer 封装
鸿蒙侧的核心工作是封装一个基于系统播放器的工具类。音频播放能力在鸿蒙原生侧有几个可用选择,我最终选了比较成熟的音视频播放组件,初始化大致流程是:
- 创建播放器实例;
- 设置播放源的 URI,可以指向本地
resfile资源,也可以指向应用沙箱路径; - 注册播放状态监听,包括播放完成、播放出错、缓冲更新;
- 调用
prepare后进入prepareDone状态,再调用play开始播放。
这里有个 Flutter 应用特有的资源路径问题:Flutter 的assets音频文件在 Android 上可以通过flutter_assets访问,但在鸿蒙侧,路径解析规则不一样。我是通过 Flutter 侧把音频文件的完整路径传过来,鸿蒙侧直接拿这个路径创建播放源。如果路径不对,会出现初始化成功但播放没声音的情况。
我建议在这个环节加足够的日志,把路径、播放器状态、错误码全部打出来,排查效率会高很多。
5.4 播放状态同步
原生音频播放有个特点:播放是异步的,状态变化可能在任何时刻发生。如果 Flutter 侧不知道原生侧当前播到哪了,UI 就会显示错乱。
我的做法是在鸿蒙侧播放器状态变化时,通过回调把事件发回 Flutter。Dart 侧接收到后统一派发给AppState,由它更新当前播放状态和进度。这样无论用户是点击播放按钮、拖动进度条,还是系统因为音频焦点变化自动暂停,UI 都能保持一致。
这里有一个特别容易踩的坑:音频焦点。在鸿蒙上,播放过程中如果来了电话或者别的应用抢占了音频焦点,鸿蒙侧可能会暂停播放,但 Flutter 侧如果没收到暂停回调,UI 还是显示“正在播放”。所以一定要把“播放被系统打断”的事件透传出来,让 UI 进入暂停状态而不是继续假装在播放。
6. 常见问题与排查技巧实录
6.1 构建问题速查
我在开发过程中遇到并解决了不少构建层面的问题,整理成一张速查表,希望对大家有帮助:
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
flutter create没有 ohos 选项 | Flutter SDK 不支持鸿蒙输出 | 换用带 ohos 平台支持的 SDK 版本 |
flutter build hap报未知命令 | SDK 不匹配或配置文件缺失 | 检查 SDK 版本并重新生成工程 |
| 构建时提示签名缺失 | DevEco Studio 未配置自动签名 | 打开 ohos 工程检查签名配置 |
| 运行时首页白屏 | 引擎加载失败或 Entry 配置错误 | 查看鸿蒙侧日志,定位引擎初始化报错 |
| 音频播放没有声音 | 资源路径不对或播放状态未正确流转 | 检查音频源路径和播放器状态日志 |
有个经验之谈:遇到构建报错时,第一时间看鸿蒙侧的日志而不是 Flutter 侧的日志。因为很多底层问题(比如 SDK 版本不兼容、签名不对)根本到不了 Flutter 层,只在原生层面就崩了。
6.2 真机调试的坑
模拟器上跑通不代表真机没问题。我在真机调试时遇到几个比较典型的情况:
一是应用安装后打不开,常见原因是设备版本和应用 targetSdk 不匹配。鸿蒙设备的系统版本升级很快,旧工程里如果 targetSdk 太低,新设备很可能直接拒绝安装。
二是 USB 调试模式不稳定,连接一段时间后会掉线。这个不一定是代码问题,建议换一条质量好的数据线,关闭电脑的省电策略,优先使用直连端口。
三是真机上音频播放的延迟明显比模拟器高。这个和硬件解码有关,优化空间不大,但可以通过提前预加载下一首歌曲减少用户感知到的停顿感。我在童谣详情页做了一步预加载:当用户进入详情页时,提前把这首童谣的音频初始化好,点击播放的响应速度会明显快很多。
6.3 体验优化小技巧
这个应用虽然叫“简易”,但体验细节能拉开的差距还是很大的。
先说列表加载。童谣列表数量少,我用的是直接一次性加载,但如果以后数据量增大,建议改成懒加载甚至分页。Lottie 动画、封面图这类资源尽量避免在列表页首屏加载过多,否则首帧渲染会被卡住。
再说字体大小调整。我给歌词页面做了一组“增大”“减小”按钮,使用MediaQuery.textScaler控制。这里需要注意,鸿蒙设备的默认字体设置可能和 Android 不同,如果用户系统字体已经是超大号,直接在 App 里再放大,容易把界面挤爆。我设置了最大缩放比例,超过之后就不再增加。
最后是页面切换动画。Flutter 默认的页面切换动画在鸿蒙上跑起来没问题,但感觉略生硬。我给详情页设置了自定义过渡动画:左侧滑入加轻微透明度变化,整体很轻量,不会让低端设备掉帧。
说到音频播放策略,还有一个小建议:播放过程中切到后台时,要明确两个行为——是继续播还是暂停。如果是童谣哄睡场景,用户经常会锁屏后继续听,这时候要继续播;如果是快速预览场景,切后台暂停会更合理。我给这个应用加了一个配置项,默认锁屏后继续播放,用户可以在设置里改成切后台暂停。
7. 收尾前再分享一点实践心得
这个项目的完整链路走下来,我最强烈的感受是:Flutter 跨鸿蒙开发的障碍已经不在 UI 层,而在原生能力适配层。Dart 代码和页面渲染在鸿蒙上跑得很顺,真正需要花时间的是音频、存储、权限这些系统特性。给项目做鸿蒙适配时,最好先做一次“原生能力盘点”,把用到的系统能力列出来,逐个确认鸿蒙侧有没有对应的 API,再决定哪些用现成插件、哪些自己写平台通道。
关于音频播放那段平台通道的代码,我特意写得足够简单,就是希望你有需求时可以直接往里面加功能。比如后续想支持后台播放,就在鸿蒙侧接入音频焦点的后台模式配置;想支持播放列表,就在 Dart 侧维护一个队列和索引,层层调通就可以。
如果你正准备给自己的 Flutter 项目加鸿蒙支持,建议先拿一个功能简单、但涉及原生能力和本地存储的项目练手,不要一上来就迁移重型应用。把壳工程、签名、平台通道这些基础链路理清楚之后,你会发现后面做复杂项目的进程会顺畅很多。祝大家都能早日把应用跑上鸿蒙真机。