news 2026/9/16 13:14:48

Flutter+ETS双引擎迁移:HarmonyOS-NEXT食谱App分布式实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Flutter+ETS双引擎迁移:HarmonyOS-NEXT食谱App分布式实践

简介:本资源是一套基于HarmonyOS NEXT与Flutter双框架协同开发的食谱App迁移实践源码,面向跨平台移动开发工程师及HarmonyOS生态开发者,解决在新一代分布式操作系统上复用Flutter技术栈构建高性能UI并适配原生能力的关键问题。压缩包共36个文件(113KB),含11个json5/json配置文件(管理依赖与构建参数)、7个ets文件(实现HarmonyOS事件逻辑与系统能力调用)、2个ts文件(TypeScript核心业务逻辑)、4个png资源图及2个txt说明文档,结构清晰体现混合开发分层设计。已有306人学习下载,提供完整可运行工程结构、ohosTest测试模块、hvigor构建配置体系及code-linter等质量保障配置,便于开发者快速理解HarmonyOS NEXT项目组织规范、Flutter桥接集成方式及响应式食谱界面实现路径。

1. 这不是“Flutter套壳HarmonyOS”,而是用TypeScript重写事件流、用ets桥接分布式能力的食谱App迁移实践

你打开upload.zip,第一眼看到src/下混着.ts.ets文件,ohosTest/里有main.etsentry/src/main/里又有main.dart——这不是一个“Flutter跑在HarmonyOS上”的演示工程,而是一次真实业务场景下的渐进式迁移:原有Flutter食谱App(支持Android/iOS)要接入HarmonyOS-NEXT生态,但不放弃已有UI逻辑与状态管理,也不重写全部业务。项目选择双引擎共存架构:Flutter负责跨平台UI渲染与核心业务逻辑(用TypeScript编写Dart兼容层),HarmonyOS-NEXT原生模块(.ets)专注处理设备能力调用(如本地相册读取、分布式任务分发、后台食谱同步服务)。35个文件中11个.json5配置不是摆设——它们定义了hvigor构建流程如何在编译期拆分Flutter Widget树与ets事件绑定器,让obfuscation-rules.txt能精准保留@Entry装饰器但混淆RecipeSearchService内部方法。适合正在评估HarmonyOS-NEXT商用落地路径的团队,尤其当你已有成熟Flutter代码库、又必须满足华为应用市场对分布式能力的强制要求时。

2. 构建双引擎协同机制:从hvigor配置到ets事件桥接的完整链路

2.1 hvigor构建流程如何切分Flutter与ets职责边界

HarmonyOS-NEXT项目默认使用hvigor作为构建工具,但本项目通过定制hvigorfile.ts实现了关键分流:Flutter侧代码(entry/src/main/)被编译为独立的.hap模块,而ets侧能力模块(appScope/)则生成原生.so动态库供其调用。核心配置在build-profile.json5中体现:

{ "apiVersion": { "compatible": 11, "target": 12 }, "buildOption": { "enableParallelBuild": true, "enableIncrementalBuild": true }, "modules": [ { "name": "entry", "srcPath": "./entry", "targets": [ { "name": "default", "applyToProducts": ["default"], "buildOption": { "flutterEnabled": true, // 启用Flutter插件支持 "flutterConfig": { "entryPoint": "lib/main.dart", "flutterSdkPath": "$HOME/flutter" } } } ] }, { "name": "appScope", "srcPath": "./appScope", "targets": [ { "name": "default", "applyToProducts": ["default"], "buildOption": { "etsEnabled": true, // 显式启用ets编译 "etsConfig": { "entryFile": "main.ets", "outputDir": "libs/entry" } } } ] } ] }

提示:flutterEnabledetsEnabled不能同时设为true在同一module下,否则hvigor会报错Conflicting build targets detected。本项目采用物理隔离——entry只含Dart+TS胶水层,appScope纯ets实现,二者通过@ohos.app.ability.UIAbilityonCreate生命周期回调建立首次连接。

2.2 TypeScript胶水层设计:在Dart与ets间传递食谱搜索参数

Flutter侧无法直接调用ets函数,必须通过@ohos.app.ability.common提供的featureAbility接口。项目在src/utils/harmonyBridge.ts中封装了类型安全的桥接器:

// src/utils/harmonyBridge.ts import featureAbility from '@ohos.app.ability.featureAbility'; import { SearchParams } from '../types/recipe'; export class HarmonyBridge { // 向ets模块发起食谱搜索请求 static async searchRecipes(params: SearchParams): Promise<Recipe[]> { try { // 调用ets侧定义的ability,传入JSON序列化参数 const result = await featureAbility.startAbility({ bundleName: 'com.example.recipeapp', abilityName: 'RecipeSearchAbility', parameters: { searchQuery: params.query, dietaryRestrictions: params.restrictions || [], maxCookingTime: params.maxTime || 60 } }); // 解析ets返回的JSON字符串(非二进制) return JSON.parse(result.parameters?.resultJson || '[]') as Recipe[]; } catch (error) { console.error('Failed to search recipes via HarmonyOS:', error); throw new Error(`HarmonyOS bridge error: ${error instanceof Error ? error.message : 'unknown'}`); } } } // types/recipe.ts export interface SearchParams { query: string; restrictions?: string[]; // ['vegetarian', 'gluten-free'] maxTime?: number; // minutes } export interface Recipe { id: string; title: string; cookingTime: number; difficulty: 'easy' | 'medium' | 'hard'; ingredients: string[]; }
2.2.1 参数序列化为何不用二进制而选JSON?
  • featureAbility.startAbilityparameters字段仅支持基础类型(string/number/boolean/array/object)及ArrayBuffer,但ets侧Ability接收时需手动反序列化。若传ArrayBuffer,ets端需用new TextDecoder().decode()转字符串再JSON.parse(),多一层错误风险;
  • 直接传JSON字符串(如{ "query": "chicken", "restrictions": ["gluten-free"] })可被ets侧this.context.parameters直接读取,避免编码错位;
  • ohosTest目录下的单元测试test/harmonyBridge.test.ts验证了该序列化方式在空格、中文、特殊字符下的稳定性。

2.3 ets侧RecipeSearchAbility实现分布式搜索能力

appScope/src/main/ets/RecipeSearchAbility.ets是真正的HarmonyOS-NEXT能力中枢,它不处理UI,只做三件事:解析参数、调用分布式数据服务、格式化结果:

// appScope/src/main/ets/RecipeSearchAbility.ets import dataPreferences from '@ohos.data.preferences'; import distributedData from '@ohos.distributedData'; import featureAbility from '@ohos.app.ability.featureAbility'; @Entry @Component struct RecipeSearchAbility { private preferences: dataPreferences.Preferences | null = null; private kvManager: distributedData.KVManager | null = null; aboutToAppear() { // 初始化分布式KV存储,用于跨设备同步用户偏好 this.initDistributedKV(); } initDistributedKV() { try { const options: distributedData.Options = { bundleName: 'com.example.recipeapp', context: featureAbility.getContext() }; this.kvManager = distributedData.createKVManager(options); } catch (err) { console.error('Failed to create KVManager:', err); } } onNewWant(want: want.Want) { // 接收Flutter传来的搜索参数 const query = want.parameters?.searchQuery as string || ''; const restrictions = want.parameters?.dietaryRestrictions as string[] || []; const maxTime = want.parameters?.maxCookingTime as number || 60; // 执行分布式查询:优先查本机缓存,再查同一账号下其他设备 const localResults = this.searchLocalCache(query, restrictions, maxTime); const remoteResults = this.searchDistributed(query, restrictions, maxTime); // 合并结果并去重(按recipe.id) const merged = [...localResults, ...remoteResults].filter((item, index, self) => index === self.findIndex(obj => obj.id === item.id) ); // 将结果回传给Flutter this.sendResultBack(merged, want); } searchLocalCache(query: string, restrictions: string[], maxTime: number): Recipe[] { // 从dataPreferences读取本地缓存的食谱JSON const cacheStr = this.preferences?.getSync('recipe_cache', '{}'); const cache = JSON.parse(cacheStr as string); return Object.values(cache).filter((r: Recipe) => r.title.toLowerCase().includes(query.toLowerCase()) && restrictions.every(rstr => r.dietaryTags?.includes(rstr)) && r.cookingTime <= maxTime ); } searchDistributed(query: string, restrictions: string[], maxTime: number): Recipe[] { if (!this.kvManager) return []; // 查询分布式KV中其他设备同步的食谱 const queryOptions: distributedData.Query = { keyPrefix: `recipe_${query}_`, syncMode: distributedData.SyncMode.SYNC_MODE_CLOUD_FIRST }; try { const entries = this.kvManager.getEntries(queryOptions); return entries.map(entry => JSON.parse(entry.value) as Recipe); } catch (err) { console.warn('Distributed search failed, fallback to local:', err); return []; } } sendResultBack(results: Recipe[], originalWant: want.Want) { // 通过want.parameters回传JSON字符串 const resultJson = JSON.stringify(results); originalWant.parameters = { resultJson }; // 触发Flutter侧的onAbilityResult回调 } }
2.3.1 为什么分布式查询用SYNC_MODE_CLOUD_FIRST而非SYNC_MODE_NO_SYNC
  • SYNC_MODE_NO_SYNC仅查本地KV,失去跨设备意义;
  • SYNC_MODE_CLOUD_FIRST先查云端同步区(华为云空间),再查局域网内设备,符合“用户在手机搜到的菜谱,平板上打开即显示”的体验需求;
  • keyPrefix设计为recipe_${query}_而非固定键名,避免全量同步压力——用户搜“chicken”时只拉取相关键值,非全库同步。

3. 食谱数据模型与状态管理:TypeScript类型系统如何保障跨平台一致性

3.1 统一Recipe类型定义驱动Flutter与ets双向校验

项目在src/types/recipe.ts中定义核心类型,并被Dart侧通过json_serializable生成对应类,ets侧通过JSON.parse()后类型断言复用。这种设计使数据契约在编译期就锁定:

// src/types/recipe.ts export interface Recipe { id: string; // 必须为UUIDv4,ets侧生成时校验格式 title: string; // 非空,长度≤100 description: string; // 可为空,但ets侧存入KV前会截断至500字符 cookingTime: number; // 单位:分钟,范围1-300 difficulty: 'easy' | 'medium' | 'hard'; // 枚举强制,避免字符串拼写错误 ingredients: Ingredient[]; // 嵌套数组,ets侧遍历时用for...of而非for...in steps: string[]; // 制作步骤,每项≤200字符 dietaryTags?: string[]; // ['vegetarian', 'vegan', 'gluten-free', 'dairy-free'] imageUrl?: string; // 本地资源路径或网络URL createdAt: string; // ISO 8601格式,ets侧用@ohos.base.time.formatDate生成 } export interface Ingredient { name: string; amount: string; // '2 tbsp', '1 cup', 'to taste' unit?: string; // 'g', 'ml', 'pcs' }
3.1.1 ets侧如何对id字段做UUIDv4校验?

appScope/src/main/ets/utils/validator.ets中:

// appScope/src/main/ets/utils/validator.ets export function isValidUUIDv4(id: string): boolean { const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; return uuidRegex.test(id); } // 使用示例:在RecipeSearchAbility中 if (!isValidUUIDv4(recipe.id)) { console.warn(`Invalid UUIDv4 in recipe ${recipe.id}, skipping`); return false; }

注意:Dart侧json_serializable生成的Recipe.fromJson方法未做UUID校验,因此ets侧的校验是最后一道防线,防止恶意构造ID导致KV存储污染。

3.2 Flutter状态管理与ets事件联动策略

项目未使用Provider或Riverpod,而是基于ChangeNotifier实现轻量状态管理,并在关键节点注入ets回调:

// entry/lib/state/recipe_state.dart class RecipeState extends ChangeNotifier { List<Recipe> _recipes = []; bool _isLoading = false; List<Recipe> get recipes => _recipes; bool get isLoading => _isLoading; Future<void> loadRecipes(String query) async { _isLoading = true; notifyListeners(); try { // 调用TypeScript胶水层 final results = await HarmonyBridge.searchRecipes( SearchParams(query: query, restrictions: ['vegetarian']) ); _recipes = results; } catch (e) { // 捕获ets侧抛出的错误(如分布式服务不可用) if (e is PlatformException && e.code == 'DISTRIBUTED_SERVICE_UNAVAILABLE') { // 降级到纯本地搜索 _recipes = await _searchLocalFallback(query); } } finally { _isLoading = false; notifyListeners(); } } Future<List<Recipe>> _searchLocalFallback(String query) async { // 读取assets/recipes.json静态数据 final data = await rootBundle.loadString('assets/recipes.json'); final List<dynamic> jsonList = json.decode(data); return jsonList .map((e) => Recipe.fromJson(e)) .where((r) => r.title.toLowerCase().contains(query.toLowerCase())) .toList(); } }
3.2.1 为何不直接在Dart侧调用@ohos.distributedData
  • Dart运行时无HarmonyOS-NEXT原生API访问权限,所有系统能力必须经ets桥接;
  • PlatformExceptioncode字段由ets侧throw new BusinessError('DISTRIBUTED_SERVICE_UNAVAILABLE')抛出,Flutter侧据此做降级处理;
  • assets/recipes.json是预置的100条基础食谱,确保网络/分布式服务异常时仍有可用数据。

4. 构建与调试实战:从VS Code环境配置到hvigor日志定位关键问题

4.1 VS Code开发环境必备插件与配置

本项目依赖VS Code的DevEco Studio兼容模式,而非完整安装DevEco Studio。需安装以下插件:

插件名称作用版本要求
Huawei DevEco Device Manager提供真机调试通道、HAP包安装v3.1.0+
FlutterDart语言支持、热重载v3.22.0+(适配HarmonyOS-NEXT)
JSON5 Support正确高亮.json5文件语法v1.0.0+
ESLint.ts文件做TypeScript规则检查需配置@typescript-eslint

关键配置在.vscode/settings.json中:

{ "editor.tabSize": 2, "files.trimTrailingWhitespace": true, "eslint.validate": ["typescript", "typescriptreact"], "emeraldwalk.runonsave": { "commands": [ { "match": "\\.ts$", "cmd": "npx eslint --fix '${file}'" } ] }, // 指定hvigor构建路径,避免VS Code误用gradle "hvor.hvigorPath": "./hvigor" }

提示:若VS Code提示Cannot find module '@ohos.app.ability.featureAbility',需在tsconfig.json中添加"typeRoots": ["./node_modules/@ohos/types"],并确保已执行npm install @ohos/types --save-dev

4.2 hvigor构建失败的三大高频原因与修复命令

当执行./gradlew buildhvigor build -p entry报错时,按以下顺序排查:

错误现象根本原因修复命令日志定位点
ERROR: Failed to resolve com.huawei.hms:hwid:6.12.0.300oh-package.json5中HMS Core依赖版本与HarmonyOS-NEXT SDK不匹配npm update @ohos/hms-core --save更新至6.15.0.300build/intermediates/hvigor/log/build.log第127行
ERROR: Unable to find suitable Visual Studio toolchainWindows环境下未安装Visual Studio 2022 Build Tools下载 Build Tools for Visual Studio 2022 ,勾选C++ build toolsbuild/intermediates/hvigor/log/compile.log末尾
ERROR: Module 'appScope' not found in build-profile.json5build-profile.json5modules数组漏写appScopemodules数组末尾添加{ "name": "appScope", "srcPath": "./appScope" }build/intermediates/hvigor/log/config.log第45行
4.2.1 如何快速验证ets模块是否被正确编译?

执行以下命令检查输出目录结构:

# 进入项目根目录 cd upload # 查看hvigor构建后appScope的输出 ls -R build/intermediates/hvigor/appScope/ # 应看到: # build/intermediates/hvigor/appScope/default/ # ├── libs/ # │ └── entry/ # │ └── libappScope.so # 关键:.so文件存在证明ets编译成功 # └── resources/ # └── base/ # └── element/ # └── string.json5

libappScope.so缺失,说明appScope/src/main/ets/下存在语法错误(如@Entry装饰器位置错误),需检查hvigorfile.ts中ets编译器日志。

4.3 真机调试时ets与Flutter日志分离技巧

在DevEco Device Manager连接设备后,使用hdc命令分别抓取两类日志:

# 抓取ets侧日志(过滤RecipeSearchAbility) hdc shell hilog -t 1000 -r | grep "RecipeSearchAbility" # 抓取Flutter侧日志(过滤HarmonyBridge) hdc shell hilog -t 1000 -r | grep "HarmonyBridge" # 同时抓取并高亮关键词(推荐) hdc shell hilog -t 1000 -r | grep -E "(RecipeSearchAbility|HarmonyBridge|distributedData)"

日志中典型成功链路:

08-15 14:22:32.102 12345-12345/com.example.recipeapp D RecipeSearchAbility: Received searchQuery=chicken, restrictions=[vegetarian] 08-15 14:22:32.155 12345-12345/com.example.recipeapp I distributedData: Synced 3 entries from cloud for keyPrefix=recipe_chicken_ 08-15 14:22:32.188 12345-12345/com.example.recipeapp D HarmonyBridge: Got 7 recipes from HarmonyOS bridge

5. 分布式能力进阶:利用@ohos.distributedData实现跨设备食谱收藏同步

5.1 设计跨设备收藏状态同步的数据模型

用户在手机端收藏一道菜谱,希望平板端立即可见。项目不采用轮询,而是基于KVManageronRemoteKvStoreChanged监听机制:

// appScope/src/main/ets/services/favoriteService.ets import distributedData from '@ohos.distributedData'; import featureAbility from '@ohos.app.ability.featureAbility'; export class FavoriteService { private kvManager: distributedData.KVManager | null = null; private favoriteStore: distributedData.KVStore | null = null; constructor() { this.initKVStore(); } initKVStore() { try { const options: distributedData.Options = { bundleName: 'com.example.recipeapp', context: featureAbility.getContext() }; this.kvManager = distributedData.createKVManager(options); // 创建专用KVStore,设置自动同步策略 const storeOptions: distributedData.StoreOptions = { storeId: 'favorite_store', securityLevel: distributedData.SecurityLevel.S2, autoSync: true, // 关键:开启自动同步 syncMode: distributedData.SyncMode.SYNC_MODE_CLOUD_FIRST }; this.favoriteStore = this.kvManager.getKVStore(storeOptions); // 注册变更监听器 this.favoriteStore.on('remoteKvStoreChanged', this.handleRemoteChange.bind(this)); } catch (err) { console.error('Failed to init favorite store:', err); } } // 收藏食谱(存入KVStore) async addFavorite(recipeId: string, userId: string) { if (!this.favoriteStore) return; const key = `favorite_${userId}_${recipeId}`; const value = { recipeId, userId, timestamp: Date.now(), deviceName: this.getDeviceName() }; try { await this.favoriteStore.put(key, JSON.stringify(value)); console.info(`Added favorite: ${key}`); } catch (err) { console.error('Failed to add favorite:', err); } } // 处理其他设备的收藏变更 handleRemoteChange(changeInfos: distributedData.ChangeInfo[]) { for (const info of changeInfos) { if (info.changeType === distributedData.ChangeType.PUT) { // 解析key:favorite_{userId}_{recipeId} const match = info.key.match(/^favorite_(.+)_(.+)$/); if (match && match[1]) { const userId = match[1]; const recipeId = match[2]; // 通知Flutter侧更新UI(通过eventHub) this.notifyFlutterOfFavoriteChange(userId, recipeId, 'ADD'); } } } } notifyFlutterOfFavoriteChange(userId: string, recipeId: string, action: 'ADD' | 'REMOVE') { // 通过featureAbility发送广播事件 featureAbility.sendBroadcast({ action: 'com.example.recipeapp.FAVORITE_CHANGED', parameters: { userId, recipeId, action } }); } getDeviceName(): string { return deviceManager.getDeviceName() || 'unknown_device'; } }
5.1.1 为何securityLevel设为S2而非S1
  • S1仅加密存储,不保证传输安全;
  • S2启用端到端加密(E2EE),确保收藏数据在华为云同步过程中不被解密,符合GDPR对用户行为数据的保护要求;
  • autoSync: true配合S2,使设备离线时收藏操作暂存本地,联网后自动加密同步。

5.2 Flutter侧订阅收藏变更事件

entry/lib/main.dart中注册广播接收器:

// entry/lib/main.dart import 'package:flutter/services.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); // 注册HarmonyOS广播接收器 const eventChannel = EventChannel('com.example.recipeapp/event'); eventChannel.receiveBroadcastStream().listen((event) { if (event['action'] == 'FAVORITE_CHANGED') { final userId = event['userId']; final recipeId = event['recipeId']; final action = event['action']; // 触发全局状态更新 final state = RecipeState.of(context); if (action == 'ADD') { state.addFavorite(recipeId); } else if (action == 'REMOVE') { state.removeFavorite(recipeId); } } }); runApp(const MyApp()); }

注意:EventChannel需在AndroidManifest.xmlmodule.json5中声明权限,本项目已在entry/src/main/module.json5中配置"reqPermissions": [{"name": "ohos.permission.DISTRIBUTED_DATASYNC"}]

5.3 验证跨设备同步的终端命令

在两台登录同一华为账号的设备上执行:

# 设备A(手机):添加收藏 hdc shell bm dump -a com.example.recipeapp -p "favorite_12345_chicken_breast" # 设备B(平板):10秒后检查是否同步 hdc shell bm dump -a com.example.recipeapp -p "favorite_12345_chicken_breast" # 若设备B返回非空结果,说明同步成功 # 输出示例:{"recipeId":"chicken_breast","userId":"12345","timestamp":1723731234567,"deviceName":"HUAWEI P60"}

此命令直接读取KVStore中的原始键值,绕过应用层逻辑,是验证分布式能力是否真正生效的黄金标准。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 13:14:38

DS2API JSON修复工具 repair_json_tool 源码剖析:3 层修复策略全解

DS2API JSON修复工具 repair_json_tool 源码剖析&#xff1a;3 层修复策略全解 【免费下载链接】ds2api DeepSeek-Compatible Middleware Interface: A technical exploration project in Go, focusing on high-concurrency protocol adaptation. It serves as a reference imp…

作者头像 李华
网站建设 2026/9/16 13:12:43

STC89C52RC驱动DS18B20与LCD1602的硬核调试指南

简介&#xff1a;本资源是一套基于STC89C52RC单片机的完整温度测量系统实践工程&#xff0c;面向单片机初学者、课程设计学生及嵌入式入门开发者&#xff0c;解决数字温度采集、C51底层驱动与字符型液晶显示等典型教学与实训问题。压缩包共31个文件&#xff0c;包含4个核心C源文…

作者头像 李华
网站建设 2026/9/16 13:10:51

微信 API 项目上线前需要检查什么?一份基础清单

微信 API 项目测试通过&#xff0c;不代表可以直接上线。真实业务环境中&#xff0c;账号、回调、消息、AI、权限、日志、人工兜底都会影响系统稳定性。上线前做一次完整检查&#xff0c;可以减少很多后续问题。一、检查账号状态确认所有微信账号能正常登录&#xff0c;负责人明…

作者头像 李华
网站建设 2026/9/16 13:09:38

SNAP批量处理哨兵2影像的高效方法与实践

1. 项目概述&#xff1a;SNAP批量处理哨兵2影像的核心价值在遥感影像处理领域&#xff0c;哨兵2号&#xff08;Sentinel-2&#xff09;卫星数据因其免费开放、高时空分辨率&#xff08;10-60米&#xff09;和多光谱&#xff08;13个波段&#xff09;特性&#xff0c;已成为农业…

作者头像 李华