前阵子带团队把一款 Flutter 写的资讯阅读 App 做了一次跨端扩展,新增目标平台是鸿蒙生态。结果产品那边第一个要上的功能就是文字朗读器,也就是把文章内容一键转成语音播放出来。说实话,接到这个任务时我心里是有点打鼓的:Flutter 在三方插件的地图里基本只管 Android 和 iOS,鸿蒙这边的 TTS 相关能力几乎没有现成插件,连官方示例都少得可怜。但真正把开发流程完整跑下来后,我发现这件事并没有想象中那么玄乎,核心就是搞懂 Flutter 与鸿蒙侧的桥接原理,再老老实实把状态管理做好。
这篇文章我会按当时的真实开发顺序来拆解:需求怎么拆、技术方案怎么选、Flutter 与鸿蒙桥接怎么做、TTS 能力怎么封装、UI 状态怎么控、测试发布前要检查哪些东西。整个过程不涉及特别晦涩的底层知识,只要会 Flutter 基础、愿意打开鸿蒙开发工具看日志,就能照着走完。
1. 项目概述:先搞清楚文字朗读器到底要做什么
1.1 需求拆解:不是“能读出来”这么简单
大多数人想到文字朗读器,第一反应是“有一段文字,点一下按钮,手机把文字念出来”。如果只是这个水平,那任何有系统无障碍能力的设备都能做到。但作为一个独立 App 功能,产品提的需求远比这复杂:
- 支持长文章朗读,不只是一小段文本,可能是几万字。
- 朗读过程中,用户可以暂停、继续、停止、跳到下一段。
- 要有语速调节,最好还能切换音色。
- 朗读到当前位置时,界面上的文字要高亮跟随。
- 后台播放时即使切走界面,声音也不能断。
- 切到其他 App 来电时要能暂停,挂断后能恢复。
这些需求聚在一起,就变成一个状态流转问题。声音本身是系统 TTS 引擎在出声,但整个生命周期和 UI 状态必须由 Flutter 端控制。换句话说,这不是“调用一个语音接口”就结束的事情,而是要做一个完整的播放器状态机。
我第一版设计就把这些需求整理成优先级表:
| 需求 | 优先级 | 实现方式 |
|---|---|---|
| 暂停/继续/停止 | P0 | 控制 TTS 引擎实例 |
| 语速调节 | P0 | 每次朗读前重设语速参数 |
| 段落高亮 | P1 | Flutter 层计算当前朗读位置 |
| 后台播放 | P1 | 原生侧获取音频焦点并保持前台服务 |
| 多音色切换 | P2 | 枚举系统音色并让用户选择 |
这样一拆,后面的开发路径就变得很清晰。
1.2 为什么选 Flutter 跨平台方案
产品背景是项目本身已经有一套 Flutter 代码仓库,UI、主题、列表页、详情页都是现成的,唯一的问题是鸿蒙端跑不起来。如果为了鸿蒙单独写一套原生版本,成本至少是双倍,而且后续维护要维护两套 UI。
当时也考虑过直接把详情页做成 WebView 套壳,但朗读器要做高亮跟随和状态联动,Web 侧和原生侧来回通信非常别扭。最终决定继续用 Flutter 主框架,鸿蒙端只负责提供系统能力桥接。这套方案的好处有三个:
- Flutter 端的 UI 和状态管理代码可以完全复用,不用重写页面。
- 鸿蒙端只需要维护一个薄薄的“能力层”,也就是 TTS 引擎的封装和通道注册。
- 后续 Android、iOS 上如果也要做同样的朗读功能,桥接代码结构可以做得很相似,迁移成本低。
值得说明的是,Flutter 在鸿蒙平台上不是完全开箱即用,需要依赖社区维护的鸿蒙适配分支来构建工程。但这不影响开发流程,反而更要求我们理解“Flutter 框架负责界面,原生侧负责设备能力”这个基本分工。
1.3 这套流程适合谁参考
我整理这篇内容的时候,主要面向三类人:
- 正在把已有 Flutter 项目迁移到鸿蒙生态的开发者。
- 想在跨端 App 里接入系统 TTS 能力但找不到现成插件的开发者。
- 对 Flutter 桥接原理感兴趣,想看看 MethodChannel、EventChannel 在鸿蒙侧是怎么落地的读者。
如果只是写一个 demo 级别的“点一下按钮读一句话”,其实不需要这么复杂的流程;但如果你想做一个能上架的、体验完整的朗读器,这套流程里的每个细节基本都绕不开。
2. 技术选型与工程结构:Flutter 端和鸿蒙端怎么分工
2.1 两端职责划分
跨端工程最怕的是职责边界模糊。朗读功能涉及 UI、业务逻辑、系统能力三块,我最终定下的边界是:
Flutter 端负责:
- 文章内容的解析和分句。
- 朗读队列的维护。
- 播放状态管理(空闲、朗读中、暂停中、停止)。
- 高亮文案计算和 UI 展示。
- 语速、音色等设置的持久化。
鸿蒙原生侧负责:
- 创建和释放 TTS 引擎实例。
- 把文本按系统要求送入朗读队列。
- 监听引擎启动、完成、出错、被中断等事件。
- 处理音频焦点和后台播放策略。
通信方式上,我用了两条通道:
- MethodChannel:Flutter 主动发起朗读、暂停、继续、停止等指令。
- EventChannel:原生侧把“读完了这一句”“引擎出错”“朗读被来电打断”等事件回传给 Flutter。
这样 Flutter 侧永远是“发号施令 + 接受状态”,原生侧永远是“执行动作 + 上报事件”,逻辑不会乱。
2.2 工程目录怎么组织
目录结构也是从最初就按桥接思路排好的,避免后续代码混在一起:
lib/ main.dart models/ read_item.dart services/ text_splitter.dart tts_controller.dart bridges/ speech_bridge.dart pages/ reader_page.dart states/ player_state.dart harmony/ entry/ src/ main/ ...其中bridges/speech_bridge.dart是 Flutter 侧的通道封装,tts_controller.dart是对外暴露的控制类,页面层只跟TtsController打交道,不直接感知 MethodChannel 的存在。这样做的好处是:如果后面换了 TTS 引擎,只需要改桥接层,页面不用动。
2.3 TTS 方案对比
开发前我还认真对比了三条路:
| 方案 | 优点 | 缺点 | 结论 |
|---|---|---|---|
| 系统自带 TTS 服务 | 免费、离线、接入快 | 音色和语速控制依赖系统版本 | 首选 |
| 云端合成 API | 音质自然、支持音色多 | 依赖网络、有费用、延迟高 | 暂不做 |
| 自研轻量合成引擎 | 完全可控 | 工程量极大 | 不现实 |
最终选了系统 TTS。因为朗读器这个功能的特点是本地阅读场景偏多,用户对网络不敏感,但对隐私比较看重,文章内容没必要上传到云端。系统 TTS 即使音色不够惊艳,在功能完整度上完全够用。
这个选择也影响后面的开发:跨端桥接只对接系统 TTS,就不需要引入额外的 SDK,减少了鸿蒙适配的复杂度。
3. 核心开发流程与关键实现:从桥接到 UI 的完整链路
3.1 第一步:先跑通 Flutter 到鸿蒙侧的通道
我第一步没有写任何真正的朗读逻辑,而是先验证“Flutter 调用鸿蒙原生方法,原生方法返回结果”这么一条最简单的链路。
Flutter 侧先定义一个最小桥接类:
class SpeechBridge { static const _channel = MethodChannel('app/speech'); Future<bool> isTtsAvailable() async { final result = await _channel.invokeMethod<bool>('isTtsAvailable'); return result ?? false; } }鸿蒙侧对应注册一个 MethodChannel 实现类。核心步骤是:
- 初始化 Flutter 引擎时拿到
MethodChannel实例。 - 注册名为
app/speech的通道。 - 给每个 Method 写一个逻辑分支,先处理
isTtsAvailable。
这一步看着很简单,但它能提前暴露很多环境问题,比如工程有没有成功构建、通道是否注册成功、Flutter 与原生侧的模块是否处于同一个容器里。我当时在通道名上踩过一次,两边写了一个大小写不一致的字符串,调试了半天日志才想起来核对。
所以我对这个阶段的建议是:不要急着写复杂功能,先把最小通道跑通,再往上堆能力。
3.2 第二步:通过桥接通道调用系统 TTS 服务
通道跑通后,开始封装鸿蒙侧的 TTS 引擎。逻辑分为四步:
- 创建 TTS 客户端实例,绑定引擎参数。
- 设置语言、语速、音调等基础参数。
- 注册朗读进度和完成事件的回调。
- 提供
speak(text)、pause()、resume()、stop()四个核心方法。
在鸿蒙原生侧,这些方法会对应映射到 MethodChannel 的调用分支。以speak为例,大致流程是:
收到 speak 指令 -> 做文本预处理,去掉不可朗读字符 -> 设置语速参数 -> 调用 TTS 引擎朗读 -> 返回“已接受”给 Flutter要注意的是,speak方法不能阻塞等待整段读完。系统 TTS 往往是异步的,用队列的方式把文本逐段送入,然后依赖回调判断进度。所以 Flutter 侧发起speak后,原生侧立刻返回“已经开始”,真正完成的信号要靠 EventChannel 再通知回来。
这里也涉及一个技术细节:长文本不能一次性塞进去合成,否则内存和等待时间都扛不住。我当时按章节、段落和句子做了三级拆分,每次最多送入 8 到 10 句话,播完一段再补充下一段。这样既能保证句子之间自然停顿,又能避免一次性合成大文本导致的卡顿。
3.3 第三步:把朗读服务封装成 Dart 层模块
原生能力就绪后,Flutter 侧的封装不能只是裸调 MethodChannel。底层调用是实现细节,页面和状态管理最好只依赖一个稳定抽象。
我写了TtsController,内部持有SpeechBridge,并向外暴露四个核心方法:
class TtsController { Future<void> speak(List<String> paragraphs); Future<void> pause(); Future<void> resume(); Future<void> stop(); }状态方面,我用了一个轻量的播放状态类:
- idle:没有朗读任务。
- speaking:正在朗读。
- paused:暂时暂停。
- stopped:主动停止。
页面上所有按钮和文案都跟这个状态绑定。比如“播放”按钮在speaking状态下变成“暂停”,“暂停”状态下变成“继续”,stopped和idle状态下都显示“播放”。这样逻辑非常直白,不容易出现按钮状态和真实播放状态不一致的情况。
封装的时候我还做了一件事:把 Dart 侧的方法全部设计成Future,但内部通过状态和回调来处理异步结果。因为 MethodChannel 本身是一次性请求-响应模型,如果某个操作失败,Dart 侧至少要能拿到一个错误信息,而不是静默失败。
3.4 第四步:UI 层状态管理与控制按钮实现
朗读器的界面不算复杂:标题、正文列表、播放控制条、语速调节条。
正文列表我按段落渲染,当前朗读段落用高亮背景,正在朗读的句子用下划线标注。高亮更新的来源有两个:
- 进入下一段时,原生侧会回调“段落开始”事件。
- 由于 TTS 进度事件比较粗粒度,我还在 Dart 侧做了兜底:如果一段时间内没有新事件,但状态是
speaking,就继续展示当前段落的高亮。
这里想提醒一个实操思路:不要指望系统 TTS 上报的进度非常精确,尤其标点和分段多的文本,硬件设备上报粒度不稳定。高亮跟随做到“段落级别准确、句子级别尽量准确”就足够,硬要精确到每个字反而容易造成界面频繁跳动。
控制条的布局和数据流我用ChangeNotifier+ValueListenableBuilder做,没有上重型状态管理库。因为整个页面只有一个播放控制台,状态复杂度有限,再用 Redux 这类工具就有点过度设计了。
4. 实战过程中踩过的一些坑
4.1 桥接通道异步回调丢失
这是我在开发中遇到最头疼的问题。现象是:点击播放后,声音正常出现,但界面上的状态很快就回到了空闲,好像从未播放过一样。
查了半天发现,原生侧把“开始播放”的回调用在了一个临时作用域里,方法通道返回后,那部分回调引用被释放掉,状态没法继续上抛。换到 Android 上不会有这个问题,因为 Android 的系统服务生命周期和进程绑定,但鸿蒙侧的接口模型不完全一样,回调持有策略不同。
解决办法是:在原生侧维护一个持久的 TTS 事件监听器,启动时注册、销毁时解除,不能依赖一次性调用创建的回调。同时所有事件都通过 EventChannel 统一上抛,Flutter 侧只维护一个事件订阅入口。
这个坑给我最大的启发是:跨端能力封装时,原生侧的生命周期持有对象必须非常明确。谁创建、谁释放、谁持有,必须写清楚,否则很隐蔽。
4.2 中文朗读的标点与多音字处理
文字朗读器对中文内容的要求远远高于英文。英文单词之间有空格,按空格切分基本没问题;中文长句全靠标点和语义停顿,切错地方,读起来就有一股非常奇怪的“机器感”。
我开始时直接把整篇文章按句子送入引擎,结果发现两个问题:
- 半角逗号、竖线等特殊符号会被引擎拼读出来,特别出戏。
- 某些多音字,比如“银行”和“行走”的“行”,会因为缺少上下文而被读错。
处理思路分成两层:
- 文本清洗层:在分句前把不可朗读字符映射成停顿或直接去掉。
- 上下文提示层:如果系统引擎支持音标或发音标记,就给关键多音字加标注;如果不支持,就只能靠用户反馈修正。
这类问题没有一次性完美解法,只能在真实朗读测试中慢慢积累词库。我最后做了一个很小的本地替换表,使用频率高、读错率明显的词先收录进去。
4.3 页面生命周期与朗读暂停的联动
Flutter 页面切后台、系统来电、耳机拔出,这些场景原生 TTS 引擎不会自动保持一个统一行为。有的设备来电时会暂停,有的会继续读,有的干脆直接停止,体验非常不稳定。
我最终的解决方式是:Flutter 侧监听AppLifecycleState,当页面退到后台时向原生侧发送“暂停”指令;回到前台时,如果原本是朗读中,再发送“继续”指令。
同时原生侧监听音频焦点变化,特别是来电这类强占焦点的情况,强制暂停并把事件上报给 Flutter,避免声音和电话重叠。
这里也有一个体验权衡:如果是用户主动切到后台听文章,就不应该自动暂停;但如果是系统来电强制切换,就必须暂停。所以不能一刀切,要在两端事件里区分“用户主动切后台”和“系统强制打断”。
5. 性能与兼容性优化:不只是让 App 能用
5.1 长文本拆分加载
文章详情页如果一口气把所有文本渲染出来,再全部送入 TTS,内存会非常紧张。尤其是图文混合的文章,图片加载和朗读队列同时进行,极易出现卡顿。
我当时的处理方案是:
- 进入详情页只渲染当前可见的段落。
- 朗读队列只维护当前段落和后面两段。
- 每次段落读完后,再触发下一页数据的预加载。
这个思路本质上是把“无限滑动列表”的懒加载思想搬到朗读队列里。用户听到哪里,数据就准备到哪里,而不是一开始就把全部文本吞进去。实测下来,单篇两三万字的文章,内存占用非常平稳。
5.2 不同机型与系统版本的兼容策略
鸿蒙生态里的设备差异比想象中大。有的手机系统版本高,TTS 能设置语速档位很多;有的设备则只支持默认语速。音色更是五花八门,有的设备只有一个默认音色,有的设备能列出一堆。
我做了两件兜底的事情:
- 启动时读取设备 TTS 能力,生成一个能力清单缓存到本地,UI 根据能力清单渲染可调节项。
- 如果设备不支持某个功能,比如没有音色切换,就把对应入口隐藏,而不是禁用后放那里显得很愣。
还有一种极端情况:设备上没有任何可用的 TTS 引擎。这种场景至少要弹一个明确的提示,不能点了播放什么都没发生。我当时的做法是桥接层返回isTtsAvailable=false,UI 显示“当前设备不支持朗读”。
5.3 包体积与启动速度优化
Flutter 工程接入鸿蒙适配分支后,构建产物比普通 Android 工程大一些。虽然这不是朗读器特有的问题,但在我这个项目里,包体增大意味着发布审核和用户下载成本增加。
我做了几件常规优化:
- 移除不必要的三方依赖,只保留 UI、状态管理、文本处理相关库。
- 图片资源按需加载,详情页大图统一走缩放管道。
- 启动时延后初始化 TTS 引擎,App 首屏展示不受影响。
TTS 引擎的初始化其实是个耗时操作,如果放在启动阶段,可能会卡住首帧。我把初始化放到了用户第一次点“播放”时,这样绝大多数用户根本感知不到初始化过程。
6. 测试与发布:真机验证比看日志更有效
6.1 Dart 层单元测试
桥接层和原生侧不好做单元测试,但 Dart 层的文本拆分、状态流转、语速换算完全可以做。我重点测了三块:
- 分句逻辑:给一段包含新闻、小说、代码片段混合的文本,检查切分结果。
- 状态转移:从停止到播放、播放到暂停、暂停到继续、播放到停止,所有合法路径都要过一遍。
- 设置持久化:语速和音色配置写入本地后重启 App 是否还在。
这些测试不需要真机,跑 Flutter 自带的测试框架就行。特别推荐把“长文本分批”逻辑也写成测试,我靠这个提前抓到一个段落重复朗读的问题。
6.2 真机联动测试清单
模拟器上很多能力是假的,尤其 TTS 的音频输出、音频焦点、后台播放,必须上真机验证。我整理了一份临时清单,每次发版前人工过一遍:
- 点击播放后 1 秒内开始出声。
- 中途切到桌面再回来,状态仍是暂停或继续。
- 播放中来电,声音立刻停止,挂断后状态恢复。
- 息屏状态播放正常,没有断开。
- 语速调节从最慢到最快,没有破音或卡顿。
- 章节切换时,下一章首句能连续播放。
其中最容易翻车的是“息屏状态播放”。有些设备锁屏后会自动回收后台资源,如果不处理音频焦点和前台服务,朗读到一半就会静默。这个问题在 log 里看起来可能一切正常,但真机就是没声音,必须靠真机测试才能发现。
6.3 发布前自查清单
功能稳定后,发布前还要过一轮合规和技术自查:
- 权限申请是否合理,不要申请和朗读无关的权限。
- 隐私弹窗文案里是否说明本地朗读不上传云端。
- 无 TTS 引擎设备的空状态是否完整。
- 后台播放是否需要增加常驻通知栏控制。
- 架构适配:安装包是否覆盖了主流 CPU 和系统版本。
这些点看着琐碎,但发布后用户反馈最多的恰恰都是这些问题,比功能 bug 更影响口碑。
7. 最后补充一点个人心得
整个项目做下来,我最想分享的一点是:跨端 App 接系统能力时,不要急着查“有没有现成插件”,而是先把桥接原理想清楚。Flutter 和鸿蒙之间的 MethodChannel 其实只解决“调用”这一件事,真正难的是把原生侧事件的生命周期处理和 Flutter 侧状态管理对齐。
如果让我再重新做一次,我会在最开始先用一个最小 demo 验证整体链路,而不是直接铺开所有功能。那个最小 demo 里只包含一个按钮和一个文本框,点击后把文本送到鸿蒙侧读出来,只跑通这一件事,后面所有功能都会顺手很多。尤其是鸿蒙生态的开发资料不像 Android 那么齐全,很多细节只能靠日志和真机试错,流程越短,定位问题越快。
最后分享一个非常小的技巧:在桥接层所有的 Method 名称和 Event 名称,我统一维护在一个常量文件里,Flutter 和鸿蒙侧分别引用各自版本的常量。虽然多写了一层文件,但后续排查“名称不匹配”这类低级错误时,能省掉大量对日志的时间。