做跨平台应用这些年,技术选型从来不是“选最好的框架”,而是“选生态位最对的框架”。我最近把一款药品信息查询应用从零做成三端同步运行,技术栈用的正是 Flutter 框架做跨平台鸿蒙开发——这个组合听起来有点“冷门”,但实际跑下来,性价比远超我最初的预期。这篇文章不是官方文档的翻译,而是我从环境搭建、数据建模到真机调试踩过的完整记录。如果你正在考虑用 Flutter 覆盖鸿蒙生态,或者在做一个医疗健康类的信息查询工具,这篇应该能帮你省下不少弯路。
1. 选型复盘:Flutter+鸿蒙这条路到底走不走得通
1.1 三端覆盖的现状:鸿蒙不再是“以后再说”
做移动端工具类 App 的团队,过去都习惯说“先做 Android 和 iOS,鸿蒙后面再说”。但最近这个“后面”正在加速变成“现在”。鸿蒙设备的保有量上来之后,渠道方和合作方会直接问一句“鸿蒙版本什么时候上”。尤其是工具类应用,用户换机迁移成本低,你不做,隔壁就有人做了。
我接到这个药品信息查询项目时,需求方给了个硬性指标:第一版必须同时覆盖 Android、iOS、鸿蒙三端。这一句话,就把“以后再适配”的路堵死了。我当时心里是有压力的,因为团队里没人真正跑过“Flutter 上鸿蒙”这条路,所有经验都是零。但换个角度想,正是这种“未知”才值得系统性地试一次,不管结果好坏,都能给团队留下可复用的方法论。
摆在我面前的可选方案大致有四类:三套原生并行、Flutter、React Native、uni-app。我没有一上来就看技术文档,而是列了一个粗颗粒度的对比表:
| 方案 | 三端代码复用率 | UI 一致性 | 鸿蒙支持现状 | 团队学习成本 |
|---|---|---|---|---|
| 三套原生并行 | 0%,各端独立维护 | 需要逐端适配 | 最稳,但成本最高 | 高 |
| Flutter | 90% 以上 | 高,自绘引擎保证统一 | 官方与社区适配中 | 中 |
| React Native | 约 80% | 依赖原生组件桥接 | 适配方案成熟度一般 | 中高 |
| uni-app | 约 85% | 依赖 WebView/自绘容器 | 比较早介入,有一定积累 | 低 |
单看这张表,Flutter 和 uni-app 都值得深入评估。我最终押注 Flutter,核心原因是它的渲染机制。药品信息查询应用的核心交互是高频搜索、列表快速滚动、详情页图文混排,这非常吃渲染性能和交互跟手度。Flutter 的 Skia 自绘引擎能保证同一个页面在 Android、iOS、鸿蒙上渲染效果几乎一致,这种“所见即所得”的确定性,对工具类产品很重要。
有人说 uni-app 开发效率更高。我承认,如果做的是简单表单、内容展示类应用,uni-app 有成本优势。但药品信息查询这种以“搜索+列表+详情”为主线的应用,列表滚动帧率稍有波动,用户感知会非常明显。我不想把体验上限寄托在容器层的能力上,所以选了 Flutter。
1.2 Flutter 适配鸿蒙的底层逻辑
“Flutter 能做鸿蒙?”这是我决策时被问得最多的一句话。想搞懂这个问题,得先理解 Flutter 跨平台的本质。
Flutter 之所以能跨平台,核心在于它自带渲染引擎:Dart 代码编译后由引擎直接绘制 UI,平台只需要提供一个原生窗口、一个画布,以及输入事件的通道。理论上,只要能在某个操作系统上创建原生窗口,把 Flutter 引擎接进去,这个系统就能跑 Flutter 应用。鸿蒙的 ArkUI 虽然和 Android 的 View 体系完全不一样,但它同样有原生窗口、有画面绘制能力,这就给 Flutter 适配提供了物理基础。
实际开发时,我并不需要去改引擎源码。鸿蒙生态里已经有基于开源鸿蒙的 Flutter 适配层,核心工作是打通 Flutter 引擎与鸿蒙的窗口、事件、能力通道。我作为业务开发者,开发流程序上没有变:正常写 Flutter 业务代码,适配层负责把工程编译成鸿蒙的应用包,再用 DevEco Studio 做签名和发布。心态上可以把它理解成“Flutter 多了一个平台目标”,不需要重学一套 UI 体系。
不过得提前打个预防针:适配层不是 100% 无感的。我在真实机调试时遇到过字体渲染偏细、返回手势触发区域不一致等差异,这些细节后面会单独讲。总体上说,这条路是通的,但不是“原封不动照搬 Android 经验”就能跑通。
2. 环境搭建实录:版本匹配、工程初始化和真机首跑
2.1 版本矩阵是第一道坎
做跨平台开发,最怕的不是业务代码写不出来,而是工具链版本对不上。Flutter 做鸿蒙适配这块,版本匹配的敏感程度比 Android 开发更高。我一开始图省事,直接用了 Flutter 官方稳定版 SDK,结果创建工程后无法生成鸿蒙产物,查了半天,问题就出在 Dart SDK 版本与适配层要求不一致上。
这里必须先说清楚:不要直接用 Flutter 官方下载页的稳定版。要从鸿蒙适配仓库拉取对应的支持分支,这个仓库的说明里会明确标注它对应的 Flutter 版本、Dart 版本以及开源鸿蒙 SDK 版本。我当时使用的是某适配仓库维护的分支,配套关系大致是 Flutter 3.x、Dart 3.x、OpenHarmony SDK 某版本。各仓库维护进度不一样,你实际操作时,一切以仓库 README 为准,不要自己凭感觉组合版本。
建议按下面的清单准备环境:
- 安装 DevEco Studio。安装时勾选 OpenHarmony SDK 和 HarmonyOS SDK 相关组件,这一步漏了后面构建肯定报错。
- 拉取 Flutter 的鸿蒙支持分支,配置到系统环境变量 FLUTTER_HOME。
- 配置 ANDROID_HOME。这听起来有点奇怪,但鸿蒙构建工具链的某些环节复用了 Android 构建组件,没有它,构建过程会卡在 Gradle 相关步骤。
- 配置 PUB 镜像源,否则拉取依赖包时会非常痛苦。
- 在终端执行 flutter doctor,确认环境是否完整。
这套环境我花了大概半天才完全跑通。中间反复出现过“明明按照文档配了,为什么还是报错”的情况。最后发现,多数问题都出在环境变量没有在终端会话里生效。这个问题很基础,但特别容易卡住人,改完环境变量记得重开终端。
2.2 从 Flutter 工程到鸿蒙 HAP 包
环境就绪后,创建工程的流程和普通 Flutter 项目差别不大:
flutter create drug_query_app --org com.example注意这里的 --org 参数。它对应的包名后面会牵扯到鸿蒙签名和上架,最好一开始就定好,不要随便填。我见过不少项目在后期改包名,改完要重新申请签名证书,还要处理一堆配置关联,非常折腾。
业务代码照常编写。等到需要构建鸿蒙产物时,工程里会多出一个针对鸿蒙的构建命令,类似:
flutter build hap --release构建过程会生成一个供 DevEco Studio 导入的鸿蒙工程目录。之后的操作切到 DevEco Studio:导入这个目录、补上签名配置、编成可安装的应用包。
这里有一个非常容易忽略的细节:鸿蒙工程的应用图标、应用名称、版本号,并不会自动跟随 Flutter 侧的配置。你需要手动去工程配置文件里改。我第一次提测时就因为版本号没同步,测试同学拿到的包版本和需求单完全对不上,来回浪费了半天。记住这个点,能帮你省一次沟通成本。
2.3 真机连接的三个暗坑
第一个坑:应用包安装入口在 DevEco Studio 的 Run 菜单里,但前提是手机已经开启开发者模式,并通过设备连接命令识别成功。如果设备列表里看不到手机,先查驱动和 USB 调试授权,不要反复点安装按钮。
第二个坑:签名配置是硬性的。没有任何签名配置的包在真机上装不进去。这一点和 Android 开发默认的 debug 签名不同,鸿蒙这边即使是调试包,也要求有完整的签名信息。
第三个坑:第一次跑起来要有耐心。鸿蒙首包的编译时间相当长,我这边从执行构建命令到桌面出现图标,花了大概七分钟。前两分钟终端没有任何输出时,你很容易以为卡死了。实际上它是在做整包编译,耐心等就好。这个等待时间后面会越来越短,第二次构建会走缓存,基本一分钟内能完成。
3. 药品数据建模:先把说明书拆成能查询的结构
3.1 用户到底是怎么查药的
写代码之前,我先花时间梳理了核心使用场景。药品信息查询这种工具,用户的真实路径其实很固定,基本就三条:
- 知道药品大概名字,搜索确认后查说明书。
- 拿着医生开的药单,逐盒查用法用量和注意事项。
- 按症状或科室浏览常用药,做日常科普了解。
这三个场景对应到功能上就是:强大的搜索能力、清晰的分类浏览、结构化的详情展示。至于在线问诊、电子处方、购药链接这类“重医疗”功能,第一版我全部砍掉了。原因很简单:功能越重的医疗产品,合规边界越复杂。第一版先把“查药”这一件事做透,比什么都强。
这个决策在后续开发中证明是对的。团队聚焦在查询体验上,反而让搜索、列表、详情这条主链路打磨得很顺。如果把精力分摊到一堆相关功能上,大概率每个点都做不深。
3.2 字段设计:结构化说明书
一条药品信息要展示什么?我整理了一套覆盖大多数常见药的数据模型。下面是一张核心字段表:
| 字段 | 含义 | 示例 |
|---|---|---|
| genericName | 通用名 | 阿莫西林胶囊 |
| brandName | 商品名 | 各药厂不同规格 |
| approvalNumber | 批准文号 | 国药准字格式 |
| manufacturer | 生产厂家 | 某制药股份有限公司 |
| category | 药品分类 | 处方药 / OTC(甲类 / 乙类) |
| indications | 适应症 | 用于敏感菌所致感染等 |
| dosage | 用法用量 | 成人一次 0.25g,一日三次 |
| adverseReactions | 不良反应 | 恶心、腹泻等 |
| contraindications | 禁忌 | 对青霉素过敏者禁用 |
| precautions | 注意事项 | 用药前须做皮试等 |
| storage | 贮藏条件 | 密封,凉暗干燥处保存 |
Dart 侧的模型类大致是这样:
class DrugInfo { final String id; final String genericName; final String brandName; final String approvalNumber; final String manufacturer; final String category; final String indications; final String dosage; final String adverseReactions; final String contraindications; final String precautions; final String storage; final List<String> aliasList; const DrugInfo({ required this.id, required this.genericName, required this.brandName, required this.approvalNumber, required this.manufacturer, required this.category, required this.indications, required this.dosage, required this.adverseReactions, required this.contraindications, required this.precautions, required this.storage, required this.aliasList, }); }这里有个容易被忽视的设计要点:字段不能只从“展示”出发,还要从“搜索”出发。药品的通用名和商品名经常对不上,同一个成分可能有十几个商品名,用户在搜索框里输入的习惯也五花八门。所以我在模型里额外加了 aliasList,用来存别名和拼写标记,搜索时联合查询。没有这个字段,用户搜商品名找不到通用名的情况下,体验会很差。
另外,我在详情页底部固定放了一行提示:“内容仅供参考,不构成诊疗建议,用药请遵医嘱。”信息查询工具必须守住这个边界,既是对用户负责,也是合规底线。
3.3 数据来源与本地存储方案
药品数据属于专业化数据,来源不能瞎编。我们当时采用的方式是通过公开的药品信息数据库导入,拿到一份结构化 JSON,覆盖常用药数千条。数据量说大不大,说小也不小,直接决定了我不能用“每次启动解析 JSON”这种偷懒方案。
我的方案是:首次启动时读取 assets 里的药品 JSON,写入本地 SQLite 数据库;后续启动直接查本地库。选 SQLite 而不是每次解析 JSON,原因有三:
- 搜索场景需要 WHERE 模糊查询,在内存里反复遍历一个大 JSON 数组,性能扛不住。
- SQLite 可以针对热门字段建立索引,前缀搜索、拼音搜索的响应速度是 JSON 解析方案没法比的。
- 后续如果要增量更新,SQLite 做数据合并且比 JSON 覆盖整个列表更可控。
建表语句示意:
CREATE TABLE drug_info ( id TEXT PRIMARY KEY, generic_name TEXT, brand_name TEXT, pinyin_key TEXT, category TEXT, indications TEXT, dosage TEXT, adverse_reactions TEXT, contraindications TEXT, precautions TEXT, storage TEXT ); CREATE INDEX idx_drug_generic ON drug_info(generic_name); CREATE INDEX idx_drug_pinyin ON drug_info(pinyin_key);导入流程不建议放在 UI 线程。虽然几千条数据的插入过程不算长,但在低端机上依然可能卡住首屏。我把它放在异步 isolate 里执行,导入完成后通过事件刷新界面状态。这个细节在第一版真机测试时就发现了,提前处理能避免一个明显的启动卡顿点。
4. 核心功能落地:多维搜索、列表渲染与详情展示
4.1 搜索模块:前缀匹配 + 拼音 + 防抖
搜索是查询应用的门面,做不好整个 App 等于废了。我设计的搜索匹配分为三层:
- 通用名前缀匹配:输入“阿莫”能命中“阿莫西林”。
- 商品名匹配:输入某个商品名同样能查出对应药品。
- 拼音首字母匹配:输入“amxl”能命中“阿莫西林”。中文输入场景里,用户经常懒得切键盘,拼音搜索是刚需。
拼音匹配需要借助拼音转换库,把药品名称转成拼音串存到 pinyin_key 字段。核心代码大概是:
import 'package:lpinyin/lpinyin.dart'; String buildPinyinKey(String name) { return PinyinHelper.getPinyin(name, separator: '').toLowerCase(); }搜索输入处理必须做防抖,这是每个搜索类应用的必修课:
Timer? _debounce; void onSearchChanged(String query) { _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 300), () { _runSearch(query); }); }300 毫秒是一个经验值。太短会导致输入过程中频繁刷新列表,反而造成视觉跳动;太长会感觉响应迟钝。我做了一组简单对比测试:500 毫秒时用户明显觉得“慢半拍”,200 毫秒时快速输入过程会有多余查询请求,300 毫秒最均衡。你可以根据自己的数据库规模和真机性能微调。
搜索结果的空状态也很重要。用户输入一个生僻药名搜不到结果时,我会展示“未找到相关药品,请尝试通用名或商品名”,而不是一个干巴巴的空白页。这个设计能减少用户“是不是 App 坏了”的疑惑。
4.2 列表页与详情页的组件设计
列表页我用 ListView.builder,每个列表项是一张药品卡片,展示通用名、商品名、分类标签。卡片高度固定,滚动性能更有保障。药品卡片右上角放了“处方药 / OTC”标签,用不同颜色区分。用户扫一眼就能知道药品类别,这是实际使用场景里的高频信息。
详情页的结构是 ScrollView + Column,按章节展示适应症、用法用量、不良反应等内容,章节之间用 Divider 分割。我刻意避免把所有内容糊成一段超长文本,而是把每条说明书拆成独立区块。这样用户跳转到详情页后,可以快速定位到自己关心的那一节,而不是从头开始读一篇说明书。
列表项和详情页之间,我没有做复杂的共享元素动画。原因很朴素:药品查询是效率工具,用户要的是“点到即看到”的顺畅感,而不是花哨的转场。过渡动画控制在 200 毫秒以内的淡入淡出,体感最舒服。
4.3 状态管理、收藏与缓存
药品查询应用的状态不算复杂,我用 Provider 就完全够用,没有引入重量级状态管理框架。SearchProvider 负责维护搜索词、结果列表、加载状态;FavoritesProvider 负责收藏列表的读写和本地持久化。收藏用 shared_preferences 存药品 ID 列表,详情页根据 ID 重新查询展示。这套设计的数据流是单向的,出现问题也容易排查。
有一个关于“搜索结果缓存”的优化值得展开一下。用户连续搜索“阿莫西林”“阿奇霉素”“阿司匹林”这类高频词时,每次都要访问 SQLite。我加了一个简单的内存缓存:以搜索词为 key,结果列表为 value,过期时间设置为五分钟。同样关键词二次搜索时,直接命中缓存,响应速度从几十毫秒降到接近零。这个优化代码量不多,但对高频场景的体感提升非常明显。
缓存要注意失效策略。药品数据如果后续支持云端更新,缓存必须在数据更新时主动清空,否则用户会看到旧数据。我当时在数据更新模块里加了一个缓存清理入口,避免了这个隐患。
5. 鸿蒙特有适配:权限、签名打包与性能实测
5.1 隐私与权限声明
鸿蒙对权限的管理严格程度和 iOS 趋近。药品信息查询应用需要用到网络权限来做数据更新,还要在用户主动下载说明书时写文件,所以涉及两类权限。我的原则是:网络权限在工程配置里声明;存储权限等用户触发下载操作时再动态申请,不提前弹窗。提前弹窗不仅体验差,应用市场审核风险也大。
合规层面有一个很多人会忽略的细节:药品名称、搜索记录属于用户敏感数据。应用内隐私政策必须写清楚收集了什么、数据是否加密、是否上传服务器。我第一版忽略了搜索日志的说明,被负责合规的同学打回来改了一版。注意,即使你的搜索是纯本地行为,只要采集了行为数据,就应当如实告知。这个提醒,希望各位同行引以为戒。
5.2 签名与打包:和 Android 的差异点
鸿蒙应用的签名流程和 Android 相似,但不完全一样。差异主要体现在三处:
- 需要先申请签名证书,证书信息绑定应用包名。包名一旦改动,必须重新申请签名,所以前文强调包名要早定。
- 真实机调试包可以直接用 DevEco Studio 自动签名,但发布用的包必须配置正式的签名证书。
- 应用包名和文件名有对应关系,改名安装时会报错。不要为了图方便随意修改产物文件名。
我在首版就吃过包名不一致的亏。当时在 Flutter 侧改了一个包名,但鸿蒙工程里同步了包名,却忘记重新生成签名证书,结果安装时一直报签名校验失败。排查过程很枯燥,最后定位到是签名和包名不匹配。这些坑提前知道,就能少走弯路。
5.3 性能实测与差异适配
我把同一个查药应用分别打成 Android APK 和鸿蒙 HAP,在对应真机上做了简单对比。首屏启动时间鸿蒙侧比 Android 慢两百毫秒左右,但在列表快速滑动帧率上,两侧基本持平。这个结果超出我预期,因为鸿蒙适配层属于“半官方”状态,我原本做好了性能打折扣的心理准备。
优化方面,我重点做了两件事。第一,搜索接口加入内存缓存(前面讲的五分钟缓存机制);第二,详情页大段说明文本采用懒加载构建,避免一次性触发整棵树重建。这两项优化加起来,搜索响应和详情页打开速度都有可感知的提升。
那颗“字体渲染偏细”的坑,当时让我困惑了很久。鸿蒙默认字体是 HarmonyOS Sans,同样字号下,笔画线条比 Android 默认字体略细,导致同一个页面在两个平台上“看起来不一样”。解决办法是在 Flutter 的 ThemeData 里统一配置 fontFamilyFallback,做到字体族和字形风格跨端一致。细节不复杂,但知道了能省下不少调试时间。
最后的几点实在体会
项目收尾后,我最深的感受是:药品信息查询这类工具,数据真实性和合规边界就是生命线。代码写得再漂亮,数据来源说不清楚,没有免责声明,连上架的资格都没有。数据建模阶段多花一天时间把字段和来源理清,后面能省十天的返工时间。
另一个经验是管理预期。第一版功能千万不要铺太开,药品查询这件事,把“查得快、看得清、不误导”这三件事做到位,就已经能超越市场上不少同类产品。功能边界清晰,跨平台开发的技术包袱才能集中在真正有价值的事情上。
如果你也打算用 Flutter 做鸿蒙开发,我建议把这次记录当作一份“排查手册”而非“标准答案”。适配层的迭代速度很快,版本配套关系随时可能变化,但底层原理和排查思路是通用的。先把这条路走通一遍,后续版本的迭代就会顺畅很多。