尧图精选

Flutter 鸿蒙化适配实战:用 scaffoldio 把新工程搭建压到 10 分钟

🕒 发布时间:2026/10/2 3:41:13 📁 来源:尧图网络
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_URLhttps://storage.flutter-io.cn注意这个仓库的分支跟官方stable、master并不一一对应具体用哪个分支要看它 release 页面里声明的 Flutter 版本以及你本地要用的 HOS SDK 版本。以我当时的实践为例Flutter 3.22 对应 OpenHarmony 5.0 及以上的 SDKcompatibleSdkVersion 用的 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/Sdk3.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 探测出来的这样能最大限度避免“模板生成了一编译就提示版本不匹配”的情况MapString, 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 StateHomePage createState() _HomePageState(); } class _HomePageState extends StateHomePage { 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 里返回intDart 侧可能收到int或num但如果你在 ArkTS 侧返回了floatDart 侧没做类型防护就会直接抛类型转换错误。所以我建议模板里统一返回MapString, 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 侧发送doubleDart 侧按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 命令结果发现完全不好使。鸿蒙的调试桥接工具是hdcHarmonyOS 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 混合应用。这些做下来整个团队的鸿蒙交付节奏会再上一个台阶。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →