简介:本资源是一套基于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.ets但entry/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" } } } ] } ] }提示:
flutterEnabled与etsEnabled不能同时设为true在同一module下,否则hvigor会报错Conflicting build targets detected。本项目采用物理隔离——entry只含Dart+TS胶水层,appScope纯ets实现,二者通过@ohos.app.ability.UIAbility的onCreate生命周期回调建立首次连接。
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.startAbility的parameters字段仅支持基础类型(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桥接;
PlatformException的code字段由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+ |
| Flutter | Dart语言支持、热重载 | 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 build或hvigor build -p entry报错时,按以下顺序排查:
| 错误现象 | 根本原因 | 修复命令 | 日志定位点 |
|---|---|---|---|
ERROR: Failed to resolve com.huawei.hms:hwid:6.12.0.300 | oh-package.json5中HMS Core依赖版本与HarmonyOS-NEXT SDK不匹配 | npm update @ohos/hms-core --save更新至6.15.0.300 | build/intermediates/hvigor/log/build.log第127行 |
ERROR: Unable to find suitable Visual Studio toolchain | Windows环境下未安装Visual Studio 2022 Build Tools | 下载 Build Tools for Visual Studio 2022 ,勾选C++ build tools | build/intermediates/hvigor/log/compile.log末尾 |
ERROR: Module 'appScope' not found in build-profile.json5 | build-profile.json5中modules数组漏写appScope项 | 在modules数组末尾添加{ "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 bridge5. 分布式能力进阶:利用@ohos.distributedData实现跨设备食谱收藏同步
5.1 设计跨设备收藏状态同步的数据模型
用户在手机端收藏一道菜谱,希望平板端立即可见。项目不采用轮询,而是基于KVManager的onRemoteKvStoreChanged监听机制:
// 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.xml或module.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中的原始键值,绕过应用层逻辑,是验证分布式能力是否真正生效的黄金标准。
本文还有配套的精品资源,点击获取