提词器这个品类,说起来不算复杂,但真正动手做的时候会发现细节多到能写一本书。我这次用 Flutter 做了一个跨平台提词器 APP,核心目标就是一套代码跑通 Android、iOS,同时把鸿蒙也带上。选 Flutter 的原因很直接:我在乎的是滚动性能和 UI 自由度,而且 Flutter 的动画管线对这类字幕滚动场景非常友好。花了两周时间,从环境搭建到鸿蒙 HAP 打包都走了一遍,过程中踩了不少坑,尤其是鸿蒙工具链那一段,网上的资料又散又旧。这篇文章就把完整流程和中间的关键决策拆开讲,想用 Flutter 做提词器、想接触鸿蒙适配的开发者都能直接参考。
1. 项目整体设计与技术选型考量
1.1 为什么用 Flutter 做提词器
做提词器 APP,第一需求就是字幕滚动流畅。如果滚动卡顿,哪怕是偶尔掉一两帧,录制素材就会废掉。我评估过原生开发和跨平台方案,最终选 Flutter,核心原因有三个。
第一,Flutter 的渲染引擎是自绘的,不依赖系统原生控件。字幕滚动本质上是文本在视口中做垂直位移,Flutter 的 AnimationController 配合 SingleChildScrollView 或者自定义 CustomPainter,可以做到每帧都精确控制偏移量,性能体验非常稳定。我在 Android 低端机上实测过,即使字号调到 48、滚动速度拉满,帧率依然能稳定在 60 左右。
第二,UI 自由度确实高。提词器的外观控制非常多,比如文字颜色、背景透明度、镜像模式、滚动手势反向,这些在原生里东改一块西改一块很麻烦,在 Flutter 里就是一个 Transform.scale、一个 Container 颜色参数的问题。尤其镜像模式,短视频创作者站在镜头前,需要看到左右翻转的提词内容,这在 Flutter 里只是 scaleX: -1 一行代码,体验极好。
第三,也是我个人的偏好,状态管理简单。提词器的业务状态其实不多:当前文本、滚动位置、播放速度、字号、颜色。我用 Provider 加上一个轻量级模型就够用,不需要引入重型框架。Flutter 的 setState 在页面局部刷新时也足够快,只要别在滚动时频繁重建大组件就行。
1.2 为什么考虑鸿蒙,以及当前的生态状态
鸿蒙装机量这几年增长很快,很多做工具类 APP 的团队已经把“适配鸿蒙”从加分项变成必选项。提词器这种轻量工具,用户换机的成本很低,如果鸿蒙用户打开应用商店发现没有适配版本,很可能转头就用竞品。
Flutter 官方目前对鸿蒙的适配还在推进中,但社区已经有一套成熟方案:OpenHarmony 的 SIG 组维护了一个 flutter_flutter 的 ohos 分支,基于 Flutter 3.22 版本。这个分支在 flutter/physics、flutter/engine、flutter/packages 三件套上都做了一层适配,开发者只需要把本地 Flutter SDK 切换到这个分支,再配合 DevEco Studio 工具链,就能把 Flutter 项目构建成鸿蒙的 HAP 包。
我在实际使用中发现,这个分支的完成度已经能在生产环境里跑业务了。虽然偶尔会遇到某个插件不支持 ohos 平台,但核心的 dart:ui、widget 层都很稳定。对于提词器这种主要用基础组件的 APP,适配鸿蒙的边际成本很低,我这次真正写鸿蒙相关代码的时间,加起来不到一天。
1.3 提词器核心需求拆解
做产品之前先把需求拆清楚。我想做的不是那种最简陋的“白底黑字往上滚”的页面,而是要达到“开箱能用”的体验。所以功能清单拆成了以下四块。
文本管理模块:支持手动输入、粘贴文本,以及从本地 TXT 文件导入。提词器用户经常有现成的讲稿,导入效率比手打高一个量级。导入后要支持编辑、保存、清空,并且自动保存到本地,防止 APP 被杀后丢内容。
播放控制模块:开始、暂停、继续、重置,这是基本功。还要支持速度调节,速度单位我用“字/分钟”,而不是模糊的“快/慢”档位,因为用户需要知道自己每分钟大约能读多少字。提供 50 到 600 字/分钟的调节范围,步进为 10 字/分钟。
外观调节模块:字体大小、文字颜色、背景透明度、滚动方向(正向和镜像)。透明度调节非常关键,很多提词器是架在手机旁,背景半透明可以让用户看到镜头后的场景,这个功能在短视频拍摄时尤其常用。
辅助功能模块:倒计时三秒再开始滚动,给用户一点准备时间;屏幕常亮,避免录制过程中自动锁屏;还有一键暂停的浮层按钮,录制中遇到突发情况能快速停下。
1.4 技术选型速览
| 模块 | 选型 | 说明 |
|---|---|---|
| UI 框架 | Flutter 3.22 ohos 分支 | 一套代码跑 Android/iOS/鸿蒙 |
| 状态管理 | Provider 6.x | 轻量,满足提词器需求,团队新人也易上手 |
| 本地存储 | shared_preferences + path_provider | 存储设置项,读写临时文件 |
| 文件导入 | file_selector | 支持多端选择 TXT 文件 |
| 屏幕常亮 | wakelock_plus | 跨端,API 简单 |
| 路由 | Navigator 2.0 | 页面少,不需要引入 go_router |
这套组合的好处是依赖极少,插件越少,鸿蒙适配时的排错成本就越低。我原本想用 get_it 做依赖注入,后来发现提词器的工程复杂度根本不需要,反而增加理解成本,果断砍掉。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境搭建:Flutter SDK 与鸿蒙工具链配置
2.1 标准 Flutter 环境安装
开始之前先把标准 Flutter 环境装好。如果你不是从零开始,可以跳过这一节直接看 2.2。
从 Flutter 官网下载对应操作系统的 SDK 压缩包,解压后把 bin 目录加入 PATH。我以 macOS 为例:
bash复制# 下载 flutter 3.x SDK 后解压到 ~/development/
export PATH="$PATH:$HOME/development/flutter/bin"
然后运行 flutter doctor 检查依赖项。正常情况会提示 Android toolchain、Xcode 等。用编辑器建议直接装 Android Studio,因为后面鸿蒙的 DevEco Studio 也是基于 IntelliJ 的,键位和习惯一致,切换成本低。
安装完成后跑一遍 flutter create demo_app,在模拟器里运行一下,确保最基础的链路是通的。这一步很重要,很多人后面出问题,其实是标准的安卓环境都没搭完,又叠加了鸿蒙适配的变量,排查起来非常痛苦。
2.2 接入鸿蒙工具链
鸿蒙开发环境和安卓不一样,需要单独配置一套工具链。我的实际操作步骤如下。
第一步,下载 DevEco Studio。 去华为开发者官网下载最新版 DevEco Studio,安装时选上 SDK 组件。DevEco 会带一个 HarmonyOS SDK,默认安装在用户目录下的 Library/Huawei/Sdk 或者指定的路径。
第二步,切换 Flutter SDK 到 ohos 分支。 这是最关键的一步。我用的是 OpenHarmony SIG 维护的 flutter 仓库的 ohos 分支。操作方式有两种。一是单独 clone 一个干净目录作为 Flutter SDK:
bash复制git clone -b ohos https://gitee.com/openharmony-sig/flutter_flutter.git
二是如果你已经有现成的 Flutter 工程,也可以用命令行工具切换,但我不推荐,风险是会动到你平时开发用的 Flutter 环境,其他项目可能跑不了。我建议单独准备一个专门给鸿蒙用的 SDK 目录,比如 ~/development/flutter_ohos。
第三步,配置环境变量。 除了常规的 Flutter 变量,还需要配置 HOS SDK 的路径,比如在 .zshrc 里加:
bash复制export DEVECO_SDK_HOME="$HOME/Library/Huawei/Sdk"
export PATH="$PATH:$HOME/development/flutter_ohos/bin"
然后执行 flutter doctor,如果看到 OHOS toolchain 的状态是绿色的,那工具链就通了。这一步有坑,后面单独说。
2.3 创建支持 ohos 的 Flutter 工程
环境配置好之后,在已有 Flutter 项目里添加 ohos 平台的支撑目录:
bash复制flutter create --platforms ohos .
这个命令会生成一个 ohos 目录,里面是鸿蒙工程结构。之后用 DevEco Studio 打开这个 ohos 目录,就可以做鸿蒙侧的配置和运行了。
注意这里千万不要手滑用 DevEco 新建一个空白鸿蒙工程再往里挪 Flutter 代码,容易把目录结构搞乱。正确姿势是保持 Flutter 工程为根目录,ohos 只是其中一个平台目录,跟 android、ios 平级。这样 flutter build hap 才能正确识别。
验证环境的时候可以跑一个默认的 counter demo,直接连鸿蒙真机运行。如果这一步能跑通,说明整条链路是通的。我第一次跑的时候,卡在了签名配置上。HarmonyOS NEXT 的真机调试必须配置自动签名,需要先登录华为开发者账号,在 DevEco 的 File -> Project Structure -> Signing Configs 里勾选自动签名,它才会生成对应的证书和 profile。这个流程跟 iOS 的自动签名很像,但很多 Flutter 开发者接触得少,容易卡住。
3. 提词器核心功能实现与踩坑记录
3.1 工程目录结构与核心模型设计
功能开发前,先把工程结构理清楚。我采用的是 feature-first 的目录组织方式,每个功能模块自己带页面和状态,避免后期耦合。
text复制lib/
├── main.dart
├── models/
│ └── teleprompter_settings.dart
├── providers/
│ ├── text_provider.dart
│ └── settings_provider.dart
├── pages/
│ ├── editor_page.dart
│ ├── teleprompter_page.dart
│ └── settings_page.dart
└── widgets/
├── speed_slider.dart
├── font_style_picker.dart
└── playback_controls.dart
TeleprompterSettings 这个模型是全局共享的,包含了所有可调节参数。我把它的字段设计成比较规范的命名,方便后续扩展。
dart复制class TeleprompterSettings {
final int fontSize; // 字体大小
final Color textColor; // 文字颜色
final double opacity; // 背景透明度 0.0 ~ 1.0
final int speed; // 滚动速度,字/分钟
final bool mirrored; // 是否镜像
final int countdown; // 倒计时秒数
...
}
为什么把速度做成 int 而不是 double?因为我们用的是字/分钟粒度,整数已经够用,Slider 的值映射过去直接四舍五入就行。用整数还有一个好处,显示在界面上不会出现“123.4”这种尴尬的数字。
3.2 文本管理与文件导入实现
提词器的文本来源有三条路:手动输入、粘贴、TXT 导入。其中 TXT 导入要注意编码问题,Windows 系统生成的 TXT 经常是 GBK 编码,如果直接用 UTF-8 解码会乱码。
我用 file_selector 读取文件字节,先尝试 UTF-8 解码,失败再尝试 GBK。这里贴一下核心代码,用 utf8.decode 加 allowMalformed 参数做兜底判断。
dart复制Future<String> importTextFromFile() async {
const XTypeGroup typeGroup = XTypeGroup(label: 'txt', extensions: ['txt']);
final XFile? file = await openFile(acceptedTypeGroups: [typeGroup]);
if (file == null) return '';
final bytes = await file.readAsBytes();
final String text;
try {
text = utf8.decode(bytes);
} catch (_) {
// 如果 UTF-8 解码失败,尝试 GBK
text = _decodeGbk(bytes);
}
return text;
}
GBK 解码在 Dart 里没有标准库支持,最简单的方式是引入 fast_gbk 这种纯 Dart 包。如果不想引入额外依赖,也可以用 unicode 方式把字节流转换成合法 UTF-8 字符串,但性能一般,文本大时会卡顿。
3.3 字幕滚动核心逻辑
这部分是整个提词器的心脏。我对比了两种滚动方案:SingleChildScrollView 和 CustomPainter。
方案一:SingleChildScrollView 滚动。 用 ScrollController 控制 offset,每次动画帧回调里给 offset 加一个增量。优点是实现简单,文本排版完全交给 Flutter 的文本布局;缺点是如果文本很长,比如上万字,整个 widget 树会非常庞大,初始化时会有一瞬间的卡顿。
方案二:CustomPainter 绘制。 自己计算可见区域的文本范围,只绘制当前屏幕内显示的几行文字。性能极好,万级文本也毫无压力,但处理文本换行、滚动边界、尾部对齐时比较繁琐。
最终我选了方案一,加一个优化:不直接滚动整个大文本,而是用 TextPainter 画在 CustomPaint 里面。实际测试下来,10000 字左右的文本,每秒 60 帧完全扛得住,而且代码更简洁。如果未来做更长文本,比如一本书的章节,再考虑暴力优化也不迟。
滚动速度的计算逻辑是:速度(字/分钟) 先换算成 每帧偏移量(像素)。这里需要知道每行能放多少字,再计算行高,从而得出每分钟滚动的总像素。我用 TextPainter 测量字号和行距,代码如下:
dart复制final textPainter = TextPainter(
text: TextSpan(text: '测', style: textStyle),
textDirection: TextDirection.ltr,
)..layout();
double charWidth = textPainter.width;
double fontSize = textStyle.fontSize ?? 32;
double lineHeight = textStyle.height != null
? textStyle.height! * fontSize
: fontSize * 1.4;
// 每分钟滚动像素 = 字数 * (字体宽度 + 间距)
// 每秒 = 每分钟 / 60
final double offsetPerSecond = speed *
(charWidth + horizontalSpacing);
用 AnimationController 的 addListener 来更新偏移量,这是最稳定的方式。不要用 Timer.periodic 去驱动滚动,因为 Timer 的精度受事件循环影响,在低端机上会产生肉眼可见的卡顿。AnimationController 默认是 60fps 的 ticker,足够顺滑。
dart复制controller.addListener(() {
if (controller.isAnimating && !_isPaused) {
setState(() {
_scrollOffset += offsetPerSecond * controller.speed / 60;
});
}
});
这里有个细节,controller.speed 用不到的时候就固定为 1,真正控制速度的是 offsetPerSecond 的分母值。如果后续要加“慢动作”功能,调整速度直接改基数就行。
3.4 播放控制与镜像模式
播放控制逻辑集中在 PlaybackControls 组件里,对外暴露四个按钮:播放/暂停、重置、镜像开关、设置。状态管理用的是 Provider,按钮点击后修改 settings_provider 和 text_provider 的状态,TeleprompterPage 根据这些状态重新渲染。
暂停的实现不能直接停掉 AnimationController,因为 AnimationController 停掉后 ticker 会停止,但滚动偏移量已经累加,需要记住当前位置。我的做法是:点击暂停时,把 _scrollOffset 暂存到本地变量,同时停止 AnimationController;点击继续时,用暂存的 offset 作为起点继续累加。这样就能完美暂停和恢复。
镜像模式是最简单的部分:
dart复制Transform.scale(
scaleX: _settings.mirrored ? -1 : 1,
child: teleprompterContent,
)
为什么提词器需要镜像?因为短视频博主如果正对着镜头念词,内容在屏幕上是从左到右正常排列的,但通过前置摄像头看到的画面是反向的。开了镜像后,屏幕上的字对于镜头而言是正向的,博主扫一眼就能流畅念出来。这个功能一开始我觉得是可有可无的小功能,但发给几个做自媒体的朋友测试,反馈说这是刚需,不做不行。
3.5 设置页的持久化
设置项如果不做持久化,用户每次打开 APP 都要重新调字体和速度,体验非常差。我用 shared_preferences 来保存所有设置项,存储方式采用 JSON 字符串,读取时反序列化成一个 TeleprompterSettings 对象。
dart复制Future<void> loadSettings() async {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString('teleprompter_settings');
if (jsonString != null) {
_settings = TeleprompterSettings.fromJson(json.decode(jsonString));
}
}
这里有一个容易被忽略的坑:SharedPreferences 是异步加载的,如果 APP 启动时设置页还没加载完成就进入提词页,会出现设置丢失的错觉。我的解决办法是在 main() 里先等待 loadSettings() 完成再运行 runApp(),虽然启动会慢几十毫秒,但换来的是 app 内状态一致性,值得。
4. 多端运行验证与鸿蒙打包发布
4.1 Android 与 iOS 快速验证
在写鸿蒙适配之前,先把 Android 和 iOS 跑通验证一次逻辑。我用了一台红米和一台上古 iPhone 做真机测试,重点关注三件事:滚动流畅度、屏幕适配、横竖屏切换。
滚动流畅度方面,即使在红米 note 系列这种中端机上,AnimationController 驱动的滚动也没有明显掉帧。字体放大到 64 时,由于单帧绘制区域变大,低端机会有一点点波动,但在软件层做了优化:把字体放大时,自动把滚动速度的偏移增量调大,减少绘制频率。
屏幕适配方面,用 SafeArea 包住首页,防止刘海屏遮挡返回按钮。横竖屏我直接强制竖屏,因为提词器场景下横屏意义不大,而且会引入复杂的布局适配,项目周期不划算。
4.2 鸿蒙适配的关键点
在 ohos 目录生成后,打开 DevEco Studio,第一眼看到的是标准的鸿蒙工程,包括 AppScope、entry 和 build-profile.json5。Flutter 的 dart 代码不需要改动,但是有几个地方必须手动处理。
第一,模块权限配置。 如果要用到网络权限、文件读取权限,在 entry/src/main/module.json5 里配置:
json复制{
"module": {
"name": "entry",
"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
},
{
"name": "ohos.permission.READ_USER_STORAGE"
}
]
}
}
注意格式化 JSON 时会自动加上 // comment 注释,DevEco 是支持的,但如果你用 VSCode 编辑,会被当成语法错误。建议直接用 DevEco 打开编辑,不要混用工具。
第二,插件兼容性排查。 Flutter 项目的插件生态在 ohos 上并不是全部可用。我的项目用到了 file_selector,这个插件在 ohos 分支上还没有完善的实现,打开文件选择器会报 MissingPluginException。解决办法有两个,一是换成系统原生渠道,通过鸿蒙的 picker 选择文件再回调给 Flutter;二是退而求其次,在提词器里用“粘贴文本 + 手动输入”替代文件导入,实际体验影响不大。我最后选择了方案二,因为对于提词器这个场景,文件导入属于锦上添花,粘贴文本已经完全够用。
第三,构建 HAP 包。 在项目根目录执行:
bash复制flutter build hap --release
如果签名配置好了,build 成功后会在 ohos/entry/build/default/outputs/default 目录生成 .hap 文件。这个文件可以直接通过 hdc 命令安装到鸿蒙真机:
bash复制hdc install entry-default-signed.hap
4.3 上架与签名
鸿蒙应用上架华为应用市场,需要在 AppGallery Connect 后台创建应用,提交时需要上传签名证书和 Profile 文件。提词器属于工具类 APP,上架审核会比社交通讯类宽松很多,但要注意隐私政策里说明清楚收集了哪些数据。我的提词器完全不收集用户数据,文本都是本地存储,所以在隐私政策里写“所有数据仅存于本地”即可。
签名过程中我踩过一次坑:用 DevEco 自动签名生成的是调试证书,如果要上架必须用发布证书,且发布证书的包名必须和应用市场创建时的包名一致。如果包名不一致,安装时会报 signature verification failed。这个提醒非常重要,包名一定要在项目最开始就定好,后面不要改。Flutter 工程的包名配置在 android/app/build.gradle 和 ohos/AppScope/app.json5 里,两边保持一致。
5. 常见问题与排查技巧实录
5.1 Flutter 工具链常见问题速查
| 问题现象 | 排查方向 | 解决方案 |
|---|---|---|
flutter doctor 不显示 OHOS |
DevEco SDK 未正确安装或环境变量未配置 | 检查 DEVECO_SDK_HOME 是否指向 SDK 根目录 |
运行 flutter create --platforms ohos 报错 |
Flutter SDK 版本过低 | 确保使用 ohos 分支且 flutter --version 为 3.22+ |
| 真机运行提示签名无效 | 未配置自动签名或证书过期 | DevEco 里登录华为账号,重新勾选自动签名 |
| 安装 HAP 后一打开就闪退 | module.json5 权限不足或 plugin 缺失 | 查看 hdc 日志,确认具体崩溃原因 |
file_selector 无法使用 |
插件未适配 ohos | 改用文本粘贴或调用鸿蒙原生 picker |
5.2 滚动卡顿与文本显示问题
滚动卡顿是提词器用户最敏感的体验问题。我遇到的一次卡顿来自一个隐藏细节:文本过长时,SingleChildScrollView 整体渲染会占用大量内存,滚动时 GC 回收造成 jank。解决办法是限制最大单次载入文本长度,比如超过 20000 字就提示用户分批导入,或者在初始化时用 TextPainter 做一次截断,只保留可见区域附近的文本片段。
文本显示方面,遇到过中文引号被错误换行、英文长单词溢出到屏幕外的问题。Flutter 默认的换行规则会按空格断行,但对于长串英文 URL 会直接溢出。我用了 Text 组件的 overflow: TextOverflow.fade,溢出部分淡出,配合 maxLines 限制,效果比 clip 好很多。
5.3 提词器滚动手势冲突
如果允许用户手动滑动提词内容,会和自动滚动逻辑产生冲突。我的处理方式是:自动滚动开启时不响应手势,只有暂停时才允许拖动。这样避免了滚动位置被手势打乱的问题。取消自动滚动时记住一个简单的逻辑:手动拖动后,若速度不为 0,自动滚动的偏移量需要重置到拖动后的位置,否则会出现“手一松,内容又跳回去”的鬼畜现象。
5.4 鸿蒙真机调试时遇到的两个大坑
第一个大坑是 hdc 连不上设备。HarmonyOS NEXT 的设备默认关闭 USB 调试,需要在设备的 开发者选项 里打开 USB 调试,并且连接电脑后确认授权弹窗。这一步和安卓差不多,但如果电脑上同时装了安卓的 adb 驱动,会抢占 USB 通道。我折腾了半小时,最后是在 DevEco 的 Tools -> SDK Manager 里单独指定了 hdc 的路径,问题才解决。
第二个大坑是鸿蒙调试包安装成功后桌面找不到图标。原因是 Flutter 构建的 HAP 包,入口 Ability 名称和 DevEco 默认模板不一致。排查后发现是 entry/src/main/module.json5 里的 abilities[0].name 写成了 MainAbility,而 Flutter 的 ohos 分支预期是 EntryAbility。修改后重新构建,图标就正常出现了。这个问题在官方 issue 里有记录,但检索关键词不够直观,很多人都会遇到。
6. 个人实操心得与工具推荐
提词器 APP 开发这件事,回过头来看最花时间的不是 UI 细节,也不是滚动逻辑,而是环境适配和跨端联调。Flutter 跨平台确实省了很多事,但“跨平台”三个字背后的成本,需要开发者对每一端构建体系都有基本认知。我的建议是,如果你准备做类似工具类 APP,先把安卓端跑通,再考虑鸿蒙。鸿蒙适配单独抽一个下午来做,不要把两边的问题混在一天里排查,否则心态容易崩。
工具链方面,有几个细节让我印象深刻。DevEco Studio 虽然是基于 IntelliJ 的,但它的代码补全和 Flutter 插件的配合度一般,写 Dart 界面我还是用 VSCode + Flutter 插件,只在构建和真机调试时切到 DevEco。两边切换虽然麻烦一点,但至少代码编辑体验是流畅的。另外,文本对比工具推荐 Beyond Compare,在做 ohos 目录和原生模板对比时非常有用。
最后分享一个小经验:提词器这类 APP 的 UI 设计,一定要避免给用户“操作台感”。我最初把设置项全部堆在主页面,结果界面很乱,测试用户反馈“一打开不知道按哪个”。后来我把调节项全部收进底部弹出的半屏面板,主界面只留开始、暂停、镜像三个按钮,使用率明显提升。设计上做减法,在工具类应用里永远是加分项。
