这几年移动端跨平台的话题基本绕不开 Flutter,而现在又多了一个绕不开的变量:鸿蒙系统。我在年初接到一个内部效率工具需求,要做一款会议记录应用,要求覆盖桌面端、Android 和鸿蒙设备,时间还压得很紧。团队里安卓和 iOS 人手不够,Web 版维护成本高,最后决定用 Flutter 统一实现,再针对 HarmonyOS NEXT 做适配打包。整个过程踩了不少坑,也把一些常见方案摸透了,这篇就完整复盘一下这个项目,从架构设计到具体实现,再到鸿蒙打包发布时那些文档里不会写的东西,希望对正好要做类似需求的人有帮助。
先说结论:Flutter 在鸿蒙上跑通没有任何问题,OpenHarmony 的分支 SDK 已经能稳定支撑日常业务,但如果你要做的应用依赖大量原生能力,尤其是华为账号、IAP 支付、推送这类系统级服务,那就要提早规划原生侧和 Flutter 侧的通信方案。会议记录应用恰好是一个“重 UI、轻系统能力”的典型场景,所以用 Flutter 来做非常合适。
1. 项目整体设计与技术选型思路
1.1 为什么选 Flutter 而不是 uni-app 或原生鸿蒙开发
鸿蒙的生态比较特殊,HarmonyOS NEXT 不再兼容安卓 APK,这对存量跨平台方案是个不小的冲击。市面上能走通的路有 uni-app、React Native 和 Flutter。uni-app 的优势是 Vue 语法、上手快,但性能在复杂列表和富文本场景下会吃力,而且鸿蒙原生插件要自己写桥接层,踩坑后排查成本高。React Native 的鸿蒙支持还在快速迭代,但社区案例相对少,出了问题参考资料有限。
Flutter 走的是自绘引擎路线,UI 层完全不依赖原生控件,跨端一致性做得最好。配合 Flutter 3.x 的 OpenHarmony 分支,可以一套代码同时构建 Android、iOS、Windows、Linux 和鸿蒙的 HAP 包。这次项目还需要桌面端参与,Flutter 的多端适配优势就很明显了。我的判断是,产品需要多端覆盖、UI 交互较重、但系统能力需求可控的项目,Flutter 在当前阶段是最稳妥的选择。
会议记录应用本质上是表单 + 列表 + 富文本 + 文件导出的组合,全部属于 Flutter 的舒适区。唯一需要额外处理的,是鸿蒙的权限申请方式和文件存储路径逻辑,这也不是难事,后面会详细说。
1.2 鸿蒙适配的核心:OpenHarmony 分支与 flutter_flutter
很多刚接触的人会混淆两个概念:Flutter 官方的 stable 分支和 OpenHarmony 社区维护的 flutter_flutter 分支。官方的 stable 分支目前还不对接鸿蒙,你需要拉取 OpenHarmony 的特定版本,导入到代码工程里才能编译出 HAP。这里推荐直接用 gitee 上的 flutter_flutter 仓库,版本要锁定,不要随手拉 master,社区主分支和引擎的同步节奏比较快,容易遇到 API 变动引发的编译错误。
我使用的是 3.7.12 版本的分支,配合 DevEco Studio 4.0 和 HarmonyOS SDK 4.0 进行构建。这个组合相对成熟,社区验证过的项目很多。如果你用的是更新版本的 Flutter 分支,注意查看它的 CHANGELOG,确认支持的 API 级别和 SDK 版本,别盲目升级。
1.3 项目工程架构怎么分
会议记录应用的功能其实比较清晰,核心模块是会议列表、会议详情、富文本编辑、录音管理、文件导出。为了让 Flutter 层的代码不被鸿蒙原生逻辑侵入,我在工程里做了三层拆分:
- Flutter UI 层:负责所有界面的渲染和交互,纯 Dart 实现,不 import 任何平台通道相关代码。
- Flutter 数据层:使用 drift 作为本地数据库,封装异步 CRUD,对外暴露 Repository 接口。
- 原生桥接层:处理鸿蒙特有的能力,比如权限请求、文件保存到公共目录、音频录制格式转换等。
这套分层的好处是,如果将来需要切回 Android 或 iOS 的原生能力,只需要替换桥接层的实现,Flutter 层代码完全不动。团队内部约定所有平台相关调用都走统一的 MethodChannel 封装类,不允许在页面里直接写 MethodChannel.invokeMethod,保证代码可维护性。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 会议记录应用的核心数据模型与数据库设计
2.1 数据模型怎么定义才能兼顾未来扩展
会议记录看似简单,但字段其实不少。我做了一张主表和五张辅助表,主表叫 meetings,核心字段包括 id、title、start_time、end_time、location、organizer、attendees、agenda、content、record_file_path、tags、is_archived、created_at、updated_at。辅助表分别是 participants(参会人)、tags(标签)、attachments(附件)、action_items(待办事项)和 meeting_notes(历史修改记录)。
从实际使用来看,action_items 这张表是最容易被忽略但最有价值的。会议结束后真正要落实的就是待办事项,把这块单独拆表,可以支持按负责人筛选、按截止日期排序,后面做任务跟进视图时就非常方便。
数据库的版本迁移也要一开始就规划好。drift 的 migration 机制挺好用,我在 v1 版本里就把 schemaVersion 定下来,之后每次加字段都写对应的 onUpgrade 逻辑。这个项目迭代到现在已经升级了三次数据库,如果你的 app 已经上线再想起迁移,会很痛苦。
2.2 drift 在 Flutter 端的落地配置与性能优化
drift 的前身是 moor,目前已经是很成熟的 Flutter 本地数据库方案。它基于 SQLite,但提供了类型安全的 Dart API,编译期就能检查 SQL 语句的合法性,这一点比直接拼 SQL 舒服太多。我的 pubspec.yaml 里核心依赖是这样配的:
yaml复制dependencies:
flutter:
sdk: flutter
drift: ^2.14.0
drift_flutter: ^0.1.0
path_provider: ^2.1.0
path: ^1.9.0
provider: ^6.1.1
intl: ^0.18.0
dev_dependencies:
drift_dev: ^2.14.0
build_runner: ^2.4.8
实体的定义用注解方式。这里我给一个简化示例:
dart复制import 'package:drift/drift.dart';
class Meetings extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get title => text().withLength(min: 1, max: 200)();
DateTimeColumn get startTime => dateTime()();
DateTimeColumn get endTime => dateTime()();
TextColumn get location => text().nullable()();
TextColumn get organizer => text().nullable()();
TextColumn get content => text().nullable()();
TextColumn get recordFilePath => text().nullable()();
BoolColumn get isArchived => boolean().withDefault(const Constant(false))();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}
数据库建表和 DAO 的代码用 build_runner 生成。跑命令 dart run build_runner build --delete-conflicting-outputs,编译器会自动产出数据库操作类。Drift 的查询默认是异步的,UI 不会卡顿。会议列表这种高频读场景,我加了 Stream 监听,数据库一有变化界面自动刷新,省去手动 setState。
性能方面,会议记录会产生大量文本数据,如果全部用 Text 字段存,查询和渲染都有压力。我把正文内容采用 Markdown 格式写到单独的表里,和 meetings 表做一对一关联,列表页只加载 title 和时间字段,详情页才拉正文,这样列表滑动明显流畅很多。
2.3 为什么数据库必须提前规划多端同步的接口
项目标题里的“会议记录”,实际落地时还会牵扯到多端数据同步。跨公司远程会议结束后的记录,可能需要在办公手机、个人平板、电脑上都能看到。为了后面接入后端,我的 DAO 层所有写操作都额外维护一个 sync_status 字段和 updated_at 字段,方便后端同步服务识别增量数据。
不要等后端文档出来了再改数据表。在客户端设计阶段留好 sync_status、deleted_at 这类字段,后面接 API 的时候能省一半时间。这个经验是从上一个项目学到的,这次算是提前踩了坑。
3. 开发环境搭建与鸿蒙适配工程配置
3.1 从零搭建 Flutter + 鸿蒙开发环境
这部分是文档最少、坑最多的地方。我的建议是,严格按照社区验证过的环境组合来,不要追求最新版本。具体流程我按实际执行的顺序列一遍:
第一步,安装 Flutter 的 OpenHarmony 分支。直接拉取 gitee 仓库代码到本地,比如放在 D:\flutter_ohos,然后把它的 bin 目录配到系统 PATH 环境变量。注意,这个目录会存放项目用到的特定分支 SDK,尽量不要和平时的 Flutter 共存,否则会互相干扰。
bash复制git clone https://gitee.com/openharmony-sig/flutter_flutter.git -b 3.7.12
第二步,安装 DevEco Studio。鸿蒙应用开发必须依赖 DevEco Studio,它内部集成了 HarmonyOS SDK 和方舟编译器工具链。建议下载 4.0 或者更新的 5.x 版本,第一次启动时让它自动安装 SDK。
第三步,配置环境变量。这里有两个关键变量,一个是 Flutter 相关的 PUB_HOSTED_URL 和 FLUTTER_STORAGE_BASE_URL,如果你在国内网络环境下开发,建议设为国内镜像源,否则依赖下载会很煎熬。另一个是鸿蒙 SDK 的路径,DevEco Studio 会在 local.properties 或环境变量里记录 SDK 位置。
第四步,创建 Flutter 工程。这里有个细节,不要再跑 flutter create 生成出来的模板,因为它默认没有鸿蒙平台目录。我是在 Flutter 工程根目录下手动创建 ohos 目录,把 DevEco Studio 生成的鸿蒙工程骨架放进去。标准 Flutter 工程的 ohos 目录结构包括 entry/src/main/ets/default/pages、entry/src/main/resources 等,Apps 入口文件一般是 entry/src/main/ets/entryability/EntryAbility.ets。
3.2 如何把 Flutter 模块嵌入到鸿蒙工程里
嵌入方式直接决定打包成败。鸿蒙工程中需要配置依赖的 Flutter 模块,在 ohos/entry/oh-package.json5 里添加本地模块依赖:
json复制{
"name": "entry",
"version": "1.0.0",
"dependencies": {
"flutter": "file:../flutter"
}
}
同时要让 Flutter 的产出(libflutter.so、引擎文件)在编译时被链接进去。传统做法是在鸿蒙工程的 build-profile.json5 中配置 Flutter 产物的路径,较新的方式是通过 Flutter 官方提供的 flutter attach 等命令联合调试,但这只适合开发期。
对于打包场景,我采用的做法是先在 Flutter 工程根目录执行构建命令生成 so 和资源包:
bash复制flutter build hap --release
然后在 DevEco Studio 里打开 ohos 目录,它会识别到本地 Flutter 模块,按原有方式构建 HAP。整个过程要保证两个工具的版本能动态匹配,尤其是 Flutter 引擎版本和 DevEco SDK 版本不一致时,构建期会报 so file not found 或 ABIs 不匹配的错误,这个东西查起来非常头痛,所以强烈建议搭环境阶段就锁定组合。
3.3 鸿蒙侧 MethodChannel 与生命周期管理
Flutter 层和鸿蒙原生层的通信,主要通过 MethodChannel。鸿蒙侧用 ets 实现原生逻辑。我在 EntryAbility.ets 的 onWindowStageCreate 生命周期里注册 Flutter 引擎和 MethodChannel:
ets复制import { FlutterAbility, FlutterEngine } from '@ohos/flutter_ohos';
import { MethodChannel, MethodCall } from '@ohos/flutter_ohos';
const CHANNEL_NAME = 'com.example.meeting/native';
export default class EntryAbility extends FlutterAbility {
onWindowStageCreate(windowStage: WindowStage): void {
super.onWindowStageCreate(windowStage);
this.flutterEngine?.getBinaryMessenger().then((messenger) => {
const channel = new MethodChannel(messenger, CHANNEL_NAME);
channel.setMethodCallHandler((call: MethodCall) => {
if (call.method === 'requestPermissions') {
// 请求录音和存储权限
} else if (call.method === 'saveFileToPublic') {
// 导出文件到公共目录
}
});
});
}
}
Flutter 侧也封装一个 PlatformService,把 MethodChannel 的调用统一收敛:
dart复制class PlatformService {
static const MethodChannel _channel = MethodChannel('com.example.meeting/native');
static Future<bool> requestPermissions() async {
try {
final result = await _channel.invokeMethod<bool>('requestPermissions');
return result ?? false;
} catch (e) {
debugPrint('Platform channel error: $e');
return false;
}
}
}
生命周期这块,FlutterAbility 在鸿蒙上有一套自己的生命周期转发逻辑。你在 onPageShow、onPageHide 和 onDestroy 时,要记得处理录音会话的释放、数据库的关闭以及 MethodChannel 的解绑,否则可能出现声音还占着麦克风、文件句柄释放不掉之类的真机问题。
4. 会议记录应用核心功能实现与关键细节
4.1 录音与播放功能的跨端适配思路
会议记录应用最常见的刚需,就是边记录边录音。Flutter 社区推荐用 record 插件来做录音,这个插件在 Android 和 iOS 上封装得比较成熟,但鸿蒙上能直接用的实现不多。我最后是绕道走 MethodChannel,把录音能力放到鸿蒙原生侧。原因有两点,一是鸿蒙的音频会话管理接口和 Android 差异大,二是我需要边录音边把音频实时写入文件,原生侧实现更可控。
鸿蒙侧的录音实现主要依赖 @ohos.multimedia.audio 这个模块。关键点是配置音频捕获源、采样率和编码格式。会议语音对音质要求不高,但长时间录制的文件体积要控制,我用的是 AAC 编码、48kHz 采样率、96kbps 码率,一个小时大约 45MB,可以接受。
录音启动时同步启动一个定时器,每隔 30 秒把当前音频时长回传到 Flutter 侧,刷新 UI 的录制计时器。这个功能涉及事件流通信,MethodChannel 只适合一次性调用,持续回调要用 EventChannel 实现。我在鸿蒙侧创建了一个录音服务类,内部用一个 Consumer 接口持续向外发送时间戳,Flutter 侧监听这个 EventChannel 即可。
4.2 Markdown 编辑器的选型与内容存储
会议记录正文的编辑体验决定了用户愿不愿意用这个应用。我试过几个 Flutter 富文本编辑器,最终选了 appflowy_editor,它对 Markdown 的支持比较自然,能直接粘贴富文本转换成标准 Markdown 结构,而且列表、引用、待办事项这些会议场景常用的格式支持得很好。
编辑器的内容我会实时转成 Markdown 字符串,做防抖保存。这里有个经验值:内容超过五千字后,每次全量序列化会有可感知的卡顿,所以我的策略是输入停止 800ms 后才触发生成 Markdown 和写入数据库,避免输入过程反复触发 UI 重建。
appflowy_editor 的文档里对如何拿到 Markdown 内容说得不细,实际代码是:
dart复制import 'package:appflowy_editor/appflowy_editor.dart';
final delta = editorState.document.toDelta();
final markdown = deltaToMarkdown(delta);
反过来,从数据库读到的 Markdown 要恢复编辑器状态,需要把字符串转成 Document:
dart复制final delta = markdownToDelta(markdownContent);
final document = Document.fromDelta(delta);
editorState = EditorState(document: document);
4.3 会议列表的搜索与筛选功能如何做
列表搜索功能我用的是数据库层的关键词匹配,不用把全文加载到内存。Drift 支持 SQL 的 LIKE 查询,我对 meeting title 和 content 字段做 lower() 匹配。但 SQLite 默认的 LIKE 对中文不友好,所以我的搜索实现有两种路径:中文查阅用 contains 方法,英文和拼音缩写用倒排索引辅助表。
拼音搜索这个需求是产品经理后来加的,说用户习惯搜人名或会议主题的拼音缩写。起初我打算在查询层拦截转换,后来改成在建表的时候额外存一个 pinyin 字段,专门记录 title 和参会人的拼音拼接串,查询时直接 LIKE 这个字段。用空间换时间,在几千条记录量级下体验很好,毫秒级响应。
筛选维度我放了下拉菜单,状态、开始日期、标签三个筛选条件。用 Provider 管理筛选状态,所有筛选条件变化都触发数据库 Stream 重新查询,代码写起来比较简洁。
4.4 文件导出到本地与分享机制
会议结束后的记录要能导出成文档,方便发邮件或者归档。Flutter 侧我生成 Markdown 文件,然后通过鸿蒙原生侧的 FilePicker 或 SaveDialog 弹窗让用户选择保存位置。鸿蒙的公共目录和 Android 的逻辑不一样,不能直接写 /sdcard/Download,必须通过 @ohos.file.fs 模块的 fileIo.openSync 配合 picker 模块选择路径。
如果只是导出到应用私有目录,路径获取很简单:
dart复制final dir = await getApplicationDocumentsDirectory();
final file = File('${dir.path}/meeting_${meeting.id}.md');
await file.writeAsString(markdownContent);
但要导出到用户能看得见的地方,就得走原生:
ets复制import picker from '@ohos.file.picker';
let documentSaveOptions = new picker.DocumentSaveOptions();
documentSaveOptions.newFileNames = ['meeting_${meeting.id}.md'];
let documentPicker = new picker.DocumentViewPicker();
let uri = await documentPicker.save(documentSaveOptions);
拿到 URI 后,再用 fileIo.openSync(uri, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE) 写入数据。这个过程我建议做一个统一的导出工具类,因为每个项目的保存逻辑都一样,放原生侧处理最方便。
5. 鸿蒙打包发布与常见问题排查实录
5.1 HAP 包的上架流程与签名配置
鸿蒙应用的打包和上架和安卓不同。HAP 包需要在 DevEco Studio 里选择构建产物为 App,生成 .app 格式的包体,再传到 AppGallery Connect 进行上架审核。签名使用的是华为的 AGC 签名体系,开发者要在 AGC 控制台开通账号,然后下载证书和 profile 文件配置到项目里。
Debug 包可以直接用 DevEco Studio 的自动签名工具,但 Release 包的签名一定要提前配置好。我在第一次尝试打包时因为没有配置 profile,编译出来怎么都装不上真机,后来才发现是签名文件和包名不匹配。
HarmonyOS NEXT 的审核对权限的说明要求非常严格。如果你的应用要申请麦克风权限,必须在应用市场后台填写使用场景和用途描述,否则会被拒。这块建议在提审前就准备好文案,不要等审核被拒后再补。
5.2 常见崩溃与兼容性问题排查思路
先列一个我在这个项目中实际遇到的高频问题速查表,方便大家直接对照:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
编译报 so file not found |
Flutter 引擎产物未正确链接 | 检查 Flutter 分支版本和 DevEco SDK 是否匹配 |
| 真机运行时白屏 | Flutter 引擎初始化和 UI 线程未同步 | 在 onWindowStageCreate 里等待引擎回调再加载 Flutter 容器 |
| 录音无声音 | 麦克风权限未在 module.json5 中声明 | 在 entry 的 module.json5 中添加 ohos.permission.MICROPHONE |
| 导出文件后找不到文件 | 公共目录保存逻辑错误 | 使用 picker 选择保存位置 |
| 列表滚动卡顿 | 图片、富文本内容直接加载 | 列表页只显示摘要,详情页再加载完整 Markdown |
其中白屏这个问题最坑。原因是 Flutter 引擎的加载是异步的,如果在鸿蒙容器还没就绪时就渲染 UI,界面会一直停在空白状态。解决方法是把 Flutter 容器放置到 onWindowStageCreate 回调里,在引擎状态变为可用后再显示。网上很多案例讲的是虚拟机上跑,实际操作真机时这个时序问题非常明显,一定要留意。
5.3 跨端一致性和交互细节的调整
最后提醒一下,Flutter 在 Android 和鸿蒙上的默认字体渲染和滚动惯性不一样。我在做会议记录正文页面时,发现鸿蒙上中文字体默认用的是 HarmonyOS Sans,而 Android 上是系统默认字体,因此字号相同但观感差异较大。我在主题里固定了字体族,中文字体统一指定为系统默认,避免两端不一致。
底部安全区适配也要单独处理。鸿蒙的全面屏手势区域和 Android 不一样,如果直接用 SafeArea,底部会多出空白。我结合 MediaQuery 的 padding 和 viewInsets 做了动态判断,只在虚拟键盘弹出时才追加间距。这些细节,普通的 Flutter 教程不会提到,但在真机上体验差别很明显。
还有一个小点,鸿蒙上的字体缩放默认值偏高,如果用户开了系统的大字号模式,Flutter 布局可能会溢出。我的做法是在 MaterialApp 里设置 builder,动态覆盖 MediaQuery 的 textScaler,保证记录编辑页的排版不会被系统字号打乱。
6. 项目复盘与后续扩展思考
这个项目从启动到交付用了大约三周。整体节奏是:第一周做环境搭建和鸿蒙适配验证,第二周完成核心业务功能和数据层,第三周集中处理鸿蒙打包和真机调试问题。如果不说鸿蒙适配这个变量,纯 Flutter 实现大概一半时间就能完成,所以说跨端适配的成本要提前算进排期里。
对我个人来说,最大的收获不是把 Flutter 跑在了鸿蒙上,而是想明白了一个道理:跨平台方案的边界,不在于框架本身的渲染能力和性能,而在于团队对原生平台的掌握深度。这次项目中真正耗费时间的,全都是原生桥接层的代码和打包配置,Flutter 层没有遇到特别难的问题。
后续如果继续迭代,我打算把会议记录应用加上多端同步功能,用之前预留的 sync_status 字段对接服务端 API。还有就是录音的转写功能,鸿蒙原生侧提供了语音识别接口,通过 MethodChannel 输入音频流,可以做到边录边转文字,配合 Markdown 编辑器一起使用,整个会议记录体验会提升一个档次。这块功能已经在原型验证中,等跑通了再单独写一篇分享。
如果你也正在用 Flutter 做鸿蒙适配,建议先把工程环境盘明白,再写业务代码,千万别跳过环境验证直接写页面,否则后续越写越难回头。
