☰
Flutter 鸿蒙化适配实战:用 scaffoldio 把新工程搭建压到 10 分钟
2026/10/2 3:41:09 网站建设 项目流程

2025 年还在做 Flutter 鸿蒙化适配的团队,基本都过了“能不能跑起来”的阶段,真正卡时间的是工程脚手架的搭建。一个标准 Flutter 应用要跑到鸿蒙上,除了 lib 目录,你还得手工维护 ohos 壳工程:module.json5、build-profile.json5、EntryAbility、权限声明、平台通道注册样板,每个新项目都得从头来一遍。我们团队在这个阶段刚好沉淀了一套基于 scaffoldio 的代码生成引擎,配合鸿蒙 Flutter 分支,把“建一个新鸿蒙 Flutter 工程”的时间从半天压到了 10 分钟以内。这篇文章就是我对这个适配过程的完整复盘,包含整体设计、模板改造、生成器实现和高频炸坑点,适合正在准备或正在做 Flutter 鸿蒙迁移的开发者,也适合想给团队自定义工程模板的效能玩家。

1. 为什么要给 scaffoldio 做鸿蒙化适配

1.1 先搞明白 scaffoldio 到底是个什么引擎

scaffoldio 是一个基于 Dart 的通用脚手架生成器,和 stagehand、cookiecutter 这类工具思路类似,但把 Flutter 工程当成了头等场景来设计。它的工作模式很简单:一份 YAML 描述文件定义工程结构,一组模板目录放好带变量的代码片段,然后通过命令把变量注入、渲染并写出整棵目录树。例如:

scaffoldio create app --name demo --org com.example --platforms android,ios,ohos

这条命令执行之后,demo 目录下会自动生成一个包含 android、ios、ohos 三个平台壳工程的完整 Flutter 项目。它快的原因不只是“省了点击向导的时间”,更关键的是把团队里反复沉淀下来的最佳实践固定成了模板:统一的三方依赖版本、统一的路由命名规范、统一的 lint 规则、统一的 CI 配置。过去每新建一个工程,光是复制老工程再逐个改包名、清理残留,就得花 30 到 60 分钟;用 scaffoldio 后,同样的产出只需要一分钟,而且不会出现老工程里某个历史遗留文件被一起带过来的问题。

1.2 鸿蒙端缺的不是模板,是“一致性工程骨架”

现在 openharmony-sig 维护的 Flutter 分支已经能正常编译 Flutter 应用,不少团队的实际做法却是“复制一个 Android 工程,再手工补一套 ohos 目录”。这种复制法在早期验证可行性时没问题,一旦要批量开新项目、统一做版本升级,问题就来了:

对比项手工复制老工程scaffoldio 模板生成
目录残留容易带上旧模块命名和失效文件每次从模板干净渲染
包名一致性靠人肉替换,容易漏YAML 变量统一注入
平台配置漂移每个工程各自为政模板同源,改一处全量生效
权限声明需求变化时逐工程手改在配置里声明后自动渲染
升级引擎成本每个老工程都要重新验证改模板后重新生成即可

鸿蒙化适配的核心目标,并不是让你把某个第三方库重新写一遍,而是让一套输入能同时产出多端工程。原生的 Flutter 工程该有的一样不少,额外再多产出 ohos 平台目录,同时把鸿蒙特有的权限、能力声明、平台通道样板代码都收敛到模板层。这样后续无论是新增一个插件桥接,还是调整入口页面,都只需要改 scaffoldio 的模板,而不是去所有工程里捞同一个配置。

2. 鸿蒙化适配的整体设计与关键原理

2.1 先说清 Flutter 鸿蒙工程在找你要什么

要做适配,先得知道鸿蒙 Flutter 工程到底长什么样。一个标准的 Flutter 鸿蒙工程,在项目根目录下会多出一个ohos/目录,内部结构与 Android 工程的 app module 类似,但配置格式完全不同:

ohos/ ├── AppScope/ │ ├── app.json5 │ └── resources/ ├── entry/ │ ├── build-profile.json5 │ ├── hvigorfile.ts │ ├── oh-package.json5 │ └── src/ │ └── main/ │ ├── module.json5 │ ├── ets/ │ │ ├── entryability/ │ │ │ └── EntryAbility.ets │ │ └── pages/ │ │ └── Index.ets │ └── resources/

其中module.json5对应 Android 的AndroidManifest.xml,负责声明入口能力、数据共享类型和权限;build-profile.json5是 Hvigor 构建系统的配置,声明编译 SDK 版本和签名信息;EntryAbility.ets是应用入口,也是 Flutter 引擎挂载的地方。可以把 ohos 目录理解成“鸿蒙侧的原生壳工程”,Flutter 引擎和 Dart 代码最终都被打进一个.hap安装包,由原生壳在窗口创建时把 Flutter 界面加载起来。

所以 scaffoldio 要做鸿蒙化适配,本质上就是“增加一个能生成这套原生壳工程的模板引擎”,也就是把 ohos 目录的每一个文件都模板化。

2.2 scaffoldio 的三个可扩展点

geçen 项目过程中我总结下来,scaffoldio 的设计给适配留了三个非常明确的扩展点。

第一个是模板仓库扩展。scaffoldio 把所有平台模板放进templates/目录,每个平台一个子目录,内部使用 mustache 语法承载变量。为鸿蒙增加模板,就是在templates/下新建一个ohos/目录,把上文那一整套壳工程文件都塞进去,并把包名、工程名、SDK 版本、权限列表替换成变量。这一层只解决“长什么样”的问题,不涉及逻辑。

第二个是配置模型扩展。工程描述文件的平台字段需要增加 ohos,同时要有一组专用参数:apiVersion、bundleName、deviceTypes、permissions、signingConfigs。这些参数在生成时会带着默认值,用户可以在自己的工程描述文件里覆盖,也可以直接在命令行通过--extra传入。我在设计时特意把所有鸿蒙参数都带了默认值,这样最普通的场景哪怕用户不填任何东西,也能生成一个能编过的壳工程。

第三个是生命周期钩子。scaffoldio 在渲染前、渲染后提供了 hook。渲染前的校验钩子里,我会检查 ohos 参数是否合法,比如权限名必须是ohos.permission.XXX格式、bundleName 必须符合反向域名规范;渲染后的钩子里,则会自动执行一次flutter build hap --debug做冒烟编译,宁可生成时多花一分钟,也不要让开发者拿到一个根本跑不起来的工程。

2.3 两个适配方案,我们为什么选了模板内嵌

在方案选型上,我们其实试过两条路。第一条是“后处理脚本”方案:仍然用原来的模板生成标准 Flutter 工程,再额外通过一个 Dart 脚本去修改配置、塞入 ohos 目录。初期确实快,但问题很快暴露——脚本里开始堆积各种平台判断,模板和脚本之间很容易产生状态不一致,改一处忘了另一处,整个工程就崩了。

第二条就是现在采用的“模板内嵌”方案,把 ohos 模板作为 scaffoldio 的一等平台直接支持,生成行为完全由模板描述,而不是由脚本控制。这样维护模型就变成“一套模板仓库、多平台目录”,改动一个平台模板不会影响其他平台。因为模板仓库是独立版本管理的,还能给 ohos 模板打 tag,团队内部直接指定版本引用,回滚也容易。对比下来,模板内嵌虽然首次投入要大一点,但长期收益明显更稳。

3. 实操:从零把 scaffoldio 适配到鸿蒙端

3.1 先搭好鸿蒙 Flutter 的开发链路

适配之前,开发环境必须先准备好。鸿蒙 Flutter 并不在官方 Flutter 主干里,而是由 openharmony-sig 社区仓库维护,需要单独 clone 一个 Flutter SDK,并把它的 bin 目录加到 PATH 前面:

git clone -b master https://gitee.com/openharmony-sig/flutter_flutter.git export PATH=$PWD/flutter_flutter/bin:$PATH export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn

注意这个仓库的分支跟官方stable、master并不一一对应,具体用哪个分支要看它 release 页面里声明的 Flutter 版本,以及你本地要用的 HOS SDK 版本。以我当时的实践为例,Flutter 3.22 对应 OpenHarmony 5.0 及以上的 SDK,compatibleSdkVersion 用的 5.0.0(12),具体数值要以官方 release 为准。

环境变量配好后,跑一次flutter doctor -v,正常的话能看到 OHOS toolchain 已经带出来。接下来安装 DevEco Studio,并在里面配置好 HarmonyOS SDK。这里有一个我自己踩过的坑:DevEco Studio 的 SDK 目录默认是隐藏目录~/Library/OpenHarmony/Sdk,而flutter doctor识别 SDK 靠的是LOCAL_HOS_SDK_HOME环境变量,不手动指过去就会一直停留在“找不到 SDK”的状态:

export HOS_SDK_HOME=$HOME/Library/OpenHarmony/Sdk

3.2 设计 ohos 模板目录:从 module.json5 到 EntryAbility

环境准备好后,我开始在 scaffoldio 的模板仓库里搭 ohos 模板。目录结构如下:

templates/ohos/ ├── ohos/ │ ├── AppScope/ │ │ ├── app.json5 │ │ └── resources/base/element/string.json │ └── entry/ │ ├── build-profile.json5 │ ├── hvigorfile.ts │ ├── oh-package.json5 │ └── src/main/ │ ├── module.json5 │ ├── ets/ │ │ ├── entryability/EntryAbility.ets │ │ └── pages/Index.ets │ └── resources/base/profile/main_pages.json

模板里的变量基本遵循一套命名约定:{{projectName}}、{{bundleName}}、{{apiVersion}}、{{permissions}}。像module.json5里的权限部分,就直接写成循环渲染:

{ "module": { "name": "entry", "type": "entry", "deviceTypes": [{{#deviceTypes}} "{{.}}"{{#last}}, {{/last}}{{/deviceTypes}}], "abilities": [ { "name": "EntryAbility", "srcEntry": "./ets/entryability/EntryAbility.ets", "skills": [ { "entities": ["entity.system.home"], "actions": ["action.system.home"] } ] } ], "requestPermissions": [ {{#permissions}} { "name": "{{.}}" }{{#last}}, {{/last}} {{/permissions}} ] } }

EntryAbility.ets是 Flutter 引擎挂载的核心文件,模板里我会保留一个最干净的挂在窗口上的实现,并在旁边用注释写明“如果后续要接平台插件,把插件桥接注册在这里”:

import Flutter from 'flutter/Flutter'; import { UIAbility } from '@kit.AbilityKit'; import { window } from '@kit.ArkUI'; export default class EntryAbility extends UIAbility { onWindowStageCreate(windowStage: window.WindowStage): void { Flutter.loadModule('entry/src/main/ets/pages/Index', windowStage); // 平台插件桥接注册建议放在此处的窗口创建成功回调之后 } }

有一件事我在第一次做模板时踩了坑:build-profile.json5 里的签名配置千万不要硬编码进模板。签名的证书路径和华为账号信息属于个人/团队私密内容,一旦写进模板仓库,后续所有用模板生成的工程都会带上旧签名,轻则包名冲突,重则安全风险。正确做法是模板里只保留signingConfigs的占位变量,具体值由 scaffoldio 的运行时从本地配置或环境变量读取。

3.3 改造 scaffoldio 生成器,把 ohos 平台跑通

模板目录搭好之后,接下来是生成器核心逻辑。我按 scaffoldio 的扩展机制增加了一个 ohos 平台处理器,流程分四步:解析 YAML -> 收集变量 -> 渲染模板 -> 写出文件并执行钩子。

核心逻辑其实不长,关键是变量收集这段,因为这里会把用户的输入统一整理成鸿蒙模板需要的数据结构作为默认值。以apiVersion为例,它的默认值不是写死的,而是从用户本地的 HOS SDK 探测出来的,这样能最大限度避免“模板生成了,一编译就提示版本不匹配”的情况:

Map<String, dynamic> collectOhosParams(ScaffoldConfig config) { return { 'projectName': config.projectName, 'bundleName': config.bundleName ?? 'com.example.${config.projectName}', 'apiVersion': config.apiVersion ?? probeLocalSdkVersion(), 'deviceTypes': config.deviceTypes ?? ['phone'], 'permissions': config.permissions ?? defaultPermissions, 'signingConfigs': loadSigningConfigsFromEnv(), }; }

渲染过程用的是 scaffoldio 内置的 mustache 模板引擎,它只负责把{{var}}替换成值,不执行任何逻辑。因此我需要单独处理列表循环的语法,这就是刚才 module.json5 里出现{{#permissions}}这类片段的原因。这个阶段不需要有多复杂,关键是让“生成的工程能被引擎接受”。

生成逻辑跑通后,我在渲染后钩子里加了一个编译冒烟测试——执行一次flutter build hap --debug并把日志回传,如果编译失败就中止并输出错误信息。这一步对保证模板质量非常关键,因为模板引用的框架类名一旦拼错,在生成阶段完全看不出来,只有编译时才会爆炸。

3.4 实战:生成一个带“底部导航+下拉刷新+EventChannel”的鸿蒙 Flutter 工程

整套链路跑通后,我最常用的一条实战命令长这样:

scaffoldio create app --name demo \ --org com.example \ --platforms android,ios,ohos \ --navigation bottom_tab \ --features refresh_indicator,event_channel

生成出来的 demo 工程里,lib 目录会有一份带底部导航和下拉刷新的模板页面,ohos 目录则是完整的鸿蒙壳工程。其中 main.dart 的关键部分长这样:

import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; class HomePage extends StatefulWidget { const HomePage({super.key}); @override State<HomePage> createState() => _HomePageState(); } class _HomePageState extends State<HomePage> { static const _eventChannel = EventChannel('com.example.demo/network_state'); @override Widget build(BuildContext context) { return Scaffold( body: RefreshIndicator( onRefresh: _refresh, child: ListView.builder( itemBuilder: (context, index) => ListTile(title: Text('Item $index')), ), ), bottomNavigationBar: NavigationBar( destinations: const [ NavigationDestination(icon: Icon(Icons.home), label: '首页'), NavigationDestination(icon: Icon(Icons.settings), label: '设置'), ], ), ); } }

EventChannel 是鸿蒙化适配里比较典型的一块。Dart 侧代码和 Android、iOS 完全一致,关键在于鸿蒙侧挂钩。scaffoldio 模板生成的鸿蒙壳工程里,会在 EntryAbility 的窗口创建完成后注册同名 channel,规则是 channel 名必须和 Dart 侧完全一致,而且事件类型要匹配。实际上 ArkTS 侧写起来大致就是这样:

import Flutter from 'flutter/Flutter'; import { window } from '@kit.ArkUI'; export default class EntryAbility extends UIAbility { onWindowStageCreate(windowStage: window.WindowStage): void { Flutter.loadModule('entry/src/main/ets/pages/Index', windowStage); const engine = Flutter.getFlutterEngine(); engine.getBinaryMessenger().getEventChannel('com.example.demo/network_state') .setStreamDataListener({ onData: (data) => { return { state: 'connected', timestamp: Date.now(), }; }, }); } }

这里有一个非常容易忽略的点:Dart 侧 EventChannel 接收到的数据类型和 ArkTS 侧返回类型必须严格遵守标准编码规则。比如 ArkTS 里返回int,Dart 侧可能收到int或num,但如果你在 ArkTS 侧返回了float,Dart 侧没做类型防护就会直接抛类型转换错误。所以我建议模板里统一返回Map<String, Object>这类结构化数据,别用裸的基础类型。

4. 鸿蒙端构建中的高频问题排查

4.1 白屏问题:module.json5 权限和入口配置不完整

我们团队第一次用模板生成鸿蒙工程时,模拟器上遇到的第一个问题就是白屏。App 能安装、能启动,但页面一直是白色,没有任何崩溃日志。

排查下来,根因在module.json5的入口配置不完整。模板生成的 module.json5 在abilities里丢了一个skills段的entity.system.home声明,导致入口 UIAbility 没有正常关联到桌面图标。另外还有一个隐藏点:pages字段指定的 profile 路径如果和实际文件不匹配,也会出现能安装但启动后白屏的情况。现在的模板里我会把这两处都作为强制必填项,并在渲染后钩子里做一次路径存在性校验。

4.2 EventChannel 通信断在鸿蒙端:不只是名字一致就行

很多从 Android 迁移过来的开发者,第一次在鸿蒙上调试平台通道,都觉得“我只是把 channel 名改成一样就完了”。实际操作下来,EventChannel 两边都注册了,但 Dart 侧就是收不到事件。

我排查过几次后发现,问题大多出在三处。第一,channel 的注册时机太早,EntryAbility 在窗口创建完成之前就注册了事件监听,引擎根本还没准备好;第二,序列化类型不一致,ArkTS 侧发送double,Dart 侧按int接收;第三,事件流结束没有按协议返回结束标志,导致 Dart 侧认为流一直未打开。我们模板现在的做法是把所有平台通道的注册都挪到onWindowStageCreate的回调里,并且统一用标准 JSON 数据承载,这基本上能规避掉绝大多数通信异常。

4.3 PlatformView 和渲染异常:Impeller 开关的坑

Flutter Impeller 是官方持续推进的渲染引擎,鸿蒙 Flutter 分支也在逐步启用。但实践中,部分 PlatformView 组件在开启 Impeller 后会出现闪烁、空白或者纹理错位的问题,尤其是嵌入地图和高德地图这类原生视图的混合场景。

如果你在鸿蒙真机上碰到类似的渲染异常,先别慌着改业务代码,试一下关闭 Impeller 重新构建:

flutter build hap --debug --no-enable-impeller

如果关掉就正常,说明问题出在 Impeller 与特定 PlatformView 的兼容性上。需要注意的是,官方后续会默认开启 Impeller,所以这个开关是临时止血方案,长期还是得推动插件方适配。

4.4 真机无线调试:鸿蒙不叫 adb,叫 hdc

很多 Flutter 开发者习惯用 adb 调试 Android 真机,到了鸿蒙就顺手去敲 adb 命令,结果发现完全不好使。鸿蒙的调试桥接工具是hdc(HarmonyOS Device Connector),一般随 DevEco Studio 一起安装。

无线调试的正确姿势是:先用 USB 数据线连上设备,在设置里打开“开发者选项”里的“无线调试”,然后通过 hdc 转向 TCP/IP 连接:

hdc list targets hdc tconn 192.168.1.100:5555

连接成功后可以看到 target 状态变为 ready。如果连不上,优先检查手机端无线调试端口是不是默认 5555,以及电脑和手机是否同一个局域网。另外有个很小的坑:电脑上同时连着 Android 手机时,偶尔会出现 hdc 跟 adb 抢占 USB 通道的情况,导致目标设备列表异常,拔掉其他设备再试。

4.5 包体积和构建性能:hap 为什么比 apk 明显大

在同一个业务代码量下,鸿蒙的 release hap 体积往往比 Android 的 release apk 大不少。这不完全是优化问题,而是鸿蒙 Flutter 仍然需要把 Flutter 引擎链接进安装包,并且当前构建产物对裁剪还比较粗。

构建产物体积范围说明
Android release APK20-30 MB支持按 ABI 拆包
HarmonyOS release HAP35-50 MB默认包含引擎和原生壳
HarmonyOS debug HAP90-120 MBdebug 引擎未裁剪

目前一个实用的优化手段是构建时指定目标平台,裁剪掉不需要的 CPU 架构产物:

flutter build hap --release --target-platform ohos-arm64

团队 CI 里也可以把 Hvigor 的构建缓存和 Dart 的增量编译缓存都落地到本地共享目录,否则每次构建都要重新编译插件原生代码,时间会非常痛苦。

5. 我踩过的坑和一点经验总结

5.1 模板同源,别搞两套 Flutter 工程

做鸿蒙化适配很容易出现一个错误念头:是不是给鸿蒙单独维护一套 Flutter 工程?我的建议是千万不要。业务层 Dart 代码完全处于平台无关的状态,唯一的差异点只在原生壳工程和平台通道注册。因此我只在 scaffoldio 里为 ohos 增加了一个模板目录,Dart 代码层面的路由、状态管理、组件通信依然共用一个模板。这样后续 Flutter 官方分支升级时,我只需要重新生成所有平台模板,然后对比 ohos 目录的编译结果,不用为鸿蒙单独维护一套业务代码。

模板同源还有一个额外的好处:团队里任意一个 Flutter 工程师都可以参与模板迭代,不必先成为鸿蒙专家再去动模板,因为模板目录里的架构是统一的,平台差异被限制在很小的范围内。

5.2 给团队的脚手架配“版本锁”

工程模板这个东西,最怕的就是“谁都能改,改完没人知道”。我们团队后来在 scaffoldio 的模板仓库里加了一个版本锁文件,把这几条写死:

engines: flutterHos: 3.22.0 huaweiSdk: 5.0.0(12) devEco: 5.0.0 template: ohosTemplateTag: v1.4.2

每次升级 Flutter 分支或 HOS SDK,先把锁文件的版本号改掉,然后跑一次完整的模板生成 + 鸿蒙编译,确认没问题后再让团队重新拉模板更新。这条流程看着只多了十来分钟,实际省掉的排查时间远比这多。

5.3 别迷信本地编译通过,真机才是硬标准

我最初用模板生成工程时,在本地模拟器编译运行一切正常,一上真机就崩,而且崩得毫无规律。后来意识到,鸿蒙真机上的权限弹窗、后台限制、屏幕适配都和模拟器有差异。特别是申请了敏感权限的应用,比如定位、网络状态读取,真机上第一次运行会触发用户授权,如果模板生成的权限声明没有走到合规弹窗流程,应用很容易直接挂掉。所以现在模板里默认不申请多余权限,只保留应用启动必需的最小集,需要再在工程描述里显式声明,这个思路也推荐给大家。

至于后续扩展,我个人觉得 scaffoldio 的鸿蒙化适配还可以继续加两块:一是把 OpenHarmony 的分布式 FilePicker、Distributed Data 这类能力封装成统一的 Flutter 插件模板;二是把 Electron 应用迁移鸿蒙时涉及的 Web 页面壳工程也纳入模板仓库,让同一套脚手架既管 Flutter 又管 Web 混合应用。这些做下来,整个团队的鸿蒙交付节奏会再上一个台阶。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询