1. 为什么我最终选择 Flutter 来做 OpenHarmony 美食应用
先交代一下背景。我手头有一个想做很久的个人项目——一款美食烹饪助手 App,核心功能是菜谱库、分类浏览、做法步骤展示。最初计划只做手机端,后来想覆盖更多设备,就想到了开源鸿蒙生态。OpenHarmony 目前的应用生态相比 Android 和 iOS 还处于早期,直接用它的声明式开发框架写一套界面,意味着我的代码很难再复用到其他平台。而 Flutter 社区对 OpenHarmony 的适配已经走过了一段路,用同一套 Dart 代码可以同时输出 Android、iOS、OpenHarmony 三端,这个诱惑对我来说实在太大。
你可能会问,跨平台框架跑在 OpenHarmony 上,渲染性能和原生体验会不会打折扣?我实际测试下来的结论是:对于美食菜谱这种以列表、图文、滚动为主的中重度 UI 应用,Flutter 的渲染引擎在 OpenHarmony 设备上表现完全够用。Flutter 本身就是自己用 Skia 渲染,不依赖系统原生控件,所以它在 OpenHarmony 上跑,和在其他平台上的渲染逻辑几乎一致——突破系统控件差异这个难题,恰恰是 Flutter 最擅长的事。再说,OpenHarmony 的分布式能力、多设备协同这些特性,我完全可以通过平台通道调到原生侧,等到后面做跨设备流转功能再深挖。
这篇文章我不会把整个 App 从头到尾讲完,那实在是太长了。我专注写我最熟悉、也觉得最有代表性的一块:菜谱库主界面的实现。涉及环境配置、工程目录设计、数据模型、列表页 UI 搭建、图片加载与性能优化、真机调试这六个环节。每个环节我都会把"为什么这么做"讲清楚,而不是只丢一段代码让你抄。
2. 环境搭建与工程初始化:最容易劝退新人的一段路
很多人学 Flutter 开发 OpenHarmony 应用,第一步就卡在环境搭建上。我先把这段时间摸索清楚的流程写出来,你照着走能少踩不少坑。
2.1 Flutter SDK 的 OpenHarmony 适配分支
首先要明确,你直接去下载普通的 Flutter SDK 是没法构建 OpenHarmony 应用的。OpenHarmony 适配代码在社区维护的独立分支里,你需要把它克隆到本地,切换到对应的适配版本。这一步我的建议是不要用最新版,选一个已经发布过验证版本的分支,稳定压倒一切。
git clone https://gitee.com/openharmony-sig/flutter_flutter.git cd flutter_flutter git checkout -b oh-3.7.12 origin/oh-3.7.12然后把这个 SDK 的 bin 目录加到你的 PATH 环境变量里。注意你的系统里如果同时装了普通 Flutter SDK,两个 flutter 指令会冲突,我习惯用 alias 区分:alias flutter-oh='~/flutter_flutter/bin/flutter'。这样平时普通项目用官方 SDK,OpenHarmony 项目用这个适配版,互不干扰。
接下来是配置命令。我之前在这条命令上栽过跟头——适配分支的 some 命令和官方 SDK 不完全一致,直接跑flutter doctor会有几项显示异常,正常的。只要最终能输出 Flutter 版本号和 Dart 版本号,说明核心链路已经通了。
2.2 DevEco Studio 与 OpenHarmony SDK 的匹配关系
OpenHarmony 应用的编译打包离不开它的官方集成开发环境。这里有一个关键点:IDE 的版本和 OpenHarmony SDK 的版本必须匹配,而 Flutter 适配分支也有自己对应的 API 版本要求。我用的组合是:
| 组件 | 版本 | 说明 |
|---|---|---|
| Flutter 适配分支 | oh-3.7.12 | 对应 Flutter 3.7 系列 |
| 集成开发环境 | 4.0 系列 | 支持 API 9 及以上 |
| OpenHarmony SDK | API 9 | 与 IDE 自带版本保持一致 |
配置时最容易出错的地方是:Flutter 工程编译 OpenHarmony 侧代码需要读取 IDE 的 SDK 路径,而 IDE 的 SDK 又分为"默认"和"项目级"两套。我建议在 IDE 的 SDK 管理里确认默认 SDK 路径是稳定的,然后在 Flutter 工程根目录配置一个环境变量文件指向它。
2.3 从零初始化一个 Flutter-OpenHarmony 混合工程
普通的flutter create project_name创建出来的工程,默认只有 Android、iOS 和 Web 目录,OpenHarmony 目录不会自动生成。你需要用适配分支提供的模板能力来创建工程:
flutter create --platforms=ohos my_recipe_app如果你和我一样是给已有工程加 OpenHarmony 支持,也可以只执行这条命令,它会在不覆盖你已经写好的 lib 目录代码的前提下,生成 ohos 目录和必要配置文件。
看到这里你可能有个疑问:以后每次写代码都要同时关心 Android 和 OpenHarmony 两个平台的构建配置吗?不需要。90% 的时间你只需要在 lib 目录下写 Dart 代码,平台构建的事情交给命令处理。只有当你需要调用 OpenHarmony 的原生能力(比如分布式数据管理、传感器、地理位置)时,才需要去 ohos 目录下写原生代码,然后用MethodChannel和 Dart 侧互通。我在后面会专门讲一个我实际用到的平台通道样例。
3. 菜谱数据模型与工程目录:把"架子"搭对,后面才不会返工
我一直觉得,界面代码写得好不好,一半取决于你数据模型设计得好不好。主界面看起来是一堆卡片,实际上它是对数据结构的直接映射。如果模型字段设计不合理,等你写到列表项 UI 的时候就要开始到处打补丁。
3.1 数据模型设计:Recipe 类的字段与职责分离
我设计的菜谱核心模型一共有两层:第一层是菜谱列表页展示用的概要信息,第二层是详情页用的完整数据。主界面只关心第一层,但设计时必须预留第二层的扩展空间。
class Recipe { final String id; final String title; // 菜名 final String category; // 分类:热菜/凉菜/汤羹/主食/烘焙 final String difficulty; // 难度:简单/中等/困难 final int cookingTime; // 烹饪时长(分钟) final String coverImageUrl; // 封面图 final String summary; // 一句话描述 final List<String> tags; // 标签:下饭/快手/少油... final String? authorName; // 作者,可空 final int favorCount; // 收藏数 const Recipe({ required this.id, required this.title, required this.category, required this.difficulty, required this.cookingTime, required this.coverImageUrl, required this.summary, required this.tags, this.authorName, this.favorCount = 0, }); }字段类型我故意保持得很克制,有几点的考量:
- 难度和时间都用字符串和整数而不是枚举对象,因为后期数据可能来自云端接口,简单的类型能减少序列化的摩擦。
tags用列表存储,而不是拼装成逗号分隔的字符串。官方推荐的 JSON 序列化方式对列表处理很自然,而且主界面标签横向排列时直接遍历即可,不需要再 split。- 收藏数是基础字段。这个数字直接展示在卡片上,会给人一种"大家都在做这道菜"的信任感,对美食社区类产品是重要的情绪价值设计。
你可能会说,一道菜谱其实还有食材清单、步骤列表、营养信息,为什么不放在同一个模型里?我是故意拆开的。列表页数据源如果是大而全的模型,内存浪费不说,网络请求也会把大量无用字段拉下来。详情数据放到另一个RecipeDetail类里,列表页按需加载,才是合理做法。
3.2 目录结构分层与状态管理选型
这部分我讲两点,第一是目录怎么组织,第二是为什么用 Provider 而不是更重型的方案。
我现在的 lib 目录结构长这样:
lib/ ├── main.dart ├── app.dart ├── models/ │ ├── recipe.dart │ └── recipe_detail.dart ├── pages/ │ ├── home_page.dart │ ├── recipe_list_page.dart │ └── recipe_detail_page.dart ├── widgets/ │ ├── recipe_card.dart │ ├── category_chips.dart │ └── search_bar_widget.dart ├── providers/ │ └── recipe_provider.dart ├── services/ │ ├── api_client.dart │ └── mock_data.dart └── utils/ └── date_format_helper.dart熟悉 Flutter 工程的同学应该能看出来,这是典型的"按功能分包 + 按类型分包"混合模式。models 层与 pages 层严格隔离,providers 只做数据状态管理,services 负责网络获取,utils 放纯函数工具。分层的核心价值是:如果某天我把数据源从本地 mock 换成真实后端 API,只需要改 services 里面的实现,pages 层完全不用动。
状态管理我选了 Provider 而不是 Bloc 或 Riverpod,最关键的原因是:这个菜谱主界面的状态其实非常简单——一个菜谱列表、一个关键词、一个选中分类。Provider 的ChangeNotifier机制已经把这种场景覆盖得很好,代码量小,心智负担低。Bloc 设计的 event-stream 模型在复杂业务里确实更有条理,但对这个小项目来说属于杀鸡用牛刀;Riverpod 虽然更现代,但引入它意味着团队里所有人都要重新学习一套新概念,不划算。
class RecipeProvider extends ChangeNotifier { List<Recipe> _allRecipes = []; List<Recipe> get allRecipes => _allRecipes; String _searchKeyword = ''; String _selectedCategory = '全部'; List<Recipe> get filteredRecipes { if (_searchKeyword.isEmpty && _selectedCategory == '全部') { return _allRecipes; } return _allRecipes.where((recipe) { final matchCategory = _selectedCategory == '全部' || recipe.category == _selectedCategory; final matchKeyword = _searchKeyword.isEmpty || recipe.title.contains(_searchKeyword) || recipe.tags.any((tag) => tag.contains(_searchKeyword)); return matchCategory && matchKeyword; }).toList(); } void setSearchKeyword(String keyword) { _searchKeyword = keyword.trim(); notifyListeners(); } void setSelectedCategory(String category) { _selectedCategory = category; notifyListeners(); } }注意filteredRecipes这个 getter,筛选逻辑全部集中在这里,UI 只需要监听它。搜索匹配的时候我同时匹配了菜名和标签,这样用户搜"少油"也能把对应标签的菜谱筛出来,搜索体验会好很多。这段逻辑一开始我写在 UI 层里,后来发现 UI 刷新和过滤逻辑耦合在一起,调试起来特别乱,果断挪到 provider 里后整个世界清净了。
3.3 本地 Mock 数据:没有后端也能把界面做到位
主界面开发阶段我不建议去依赖真实后端,甚至不建议依赖真实的网络图片。我先在本地放了一批结构完整的模拟数据,图片则优先用打包到 assets 里的本地图片。这样做的原因是:开发前期频繁改布局、调间距,如果用网络图片,每次刷新都可能遇到加载缓慢、占位图闪烁的问题,时间都耗在等待上了。
模拟数据我用一个常量文件保存,形如:
const mockRecipes = [ Recipe( id: 'r001', title: '红烧肉', category: '热菜', difficulty: '中等', cookingTime: 60, coverImageUrl: 'assets/images/braised_pork.jpg', summary: '肥而不腻,入口即化,家宴的经典硬菜。', tags: ['下饭', '家宴', '猪肉'], ), // ... 更多 ];后面接入了真实接口,这些 mock 数据另存为一份"预览模式"的数据源,通过一个开关切换。我实测发现这招对 UI 调试特别有效,你后续也可以沿用这种思路:UI 开发永远有一个不依赖网络的"离线预览模式"。
4. 菜谱库主界面的完整实现:从布局骨架到卡片细节
终于到了核心环节。菜谱库主界面我拆成四个子部分:顶部搜索栏、分类筛选条、菜谱卡片列表、以及空状态和加载状态处理。下面按顺序逐个展开。
4.1 整体布局骨架:如何用 Scaffold + SafeArea 搭出稳定的页面结构
大多数菜谱 App 的主界面都是一个纵向滚动的列表,顶部是固定的搜索和分类,下面是卡片流。我的做法是:
Scaffold( body: SafeArea( child: Column( children: [ buildSearchBar(), buildCategoryTabs(), Expanded(child: buildRecipeListView()), ], ), ), )SafeArea这层绝对不能省。现在很多设备是挖孔屏、圆角屏,不加 SafeArea,搜索栏会被刘海区域遮挡。Expanded让列表占据剩余全部空间,同时保证软键盘弹出时列表能自动收缩。这些都是不太起眼但严重影响体验的细节。
列表本身我用了ListView.builder,而不是ListView(children: ...)。区别在于 builder 是懒加载,只有滚动到视口附近才会创建 item;如果一次性创建上百个卡片,首帧渲染时间会明显变慢。这个选择背后的原理,我在第五部分讲性能时会再展开。
4.2 搜索栏与分类筛选条:让用户快速缩小选择范围
搜索栏我用一个圆角文本框实现,里面左侧放了一个搜索图标,右侧放了一个清空按钮。这里有两个交互细节值得注意:一是onChanged事件触发搜索过滤之后,要防止每次按键都重建整个列表——因此我做了轻微的防抖,用户停止输入 300 毫秒后才触发 provider 更新;二是点击清空按钮时,不仅清除文本,还要恢复键盘焦点——这个体验最自然,用户清空后大概率还想继续输入。
分类筛选条我用横向滚动的一组 Chip 来实现。分类项一般是"全部、热菜、凉菜、汤羹、主食、烘焙、饮品"。横向滚动而不是横向均分的原因很简单:分类一旦多起来,均分模式在窄屏设备上每个分类的点击区域会变得太小,误触率直线上升。
class CategoryChips extends StatelessWidget { final List<String> categories; final String selected; final ValueChanged<String> onSelected; const CategoryChips({ super.key, required this.categories, required this.selected, required this.onSelected, }); @override Widget build(BuildContext context) { return SizedBox( height: 44, child: ListView.separated( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 12), itemCount: categories.length, separatorBuilder: (_, __) => const SizedBox(width: 8), itemBuilder: (context, index) { final category = categories[index]; final isSelected = category == selected; return GestureDetector( onTap: () => onSelected(category), child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: isSelected ? const Color(0xFFFF7043) : Colors.white, borderRadius: BorderRadius.circular(20), border: Border.all( color: isSelected ? const Color(0xFFFF7043) : Colors.grey.shade300, ), ), child: Text( category, style: TextStyle( color: isSelected ? Colors.white : Colors.grey.shade700, fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, ), ), ), ); }, ), ); } }这个组件里我特意用了Container加GestureDetector,而不是现成的ChoiceChip或FilterChip。原因是我在真机上测试发现,默认的Material Chip在某些 OpenHarmony 适配版本上存在 touch 反馈不灵敏的问题,点击后视觉效果滞后。自定义方案最可控,样式也完全由我支配。选中的分类用暖橙色高亮,和美食主题的调性保持一致。
4.3 菜谱卡片 UI:一个卡片该放什么信息,怎么摆放
卡片是整个主界面的"门面",我在它上面花的时间最多。一个信息完整的菜谱卡片应该包含:封面图、菜名、难度、时长、收藏数,外加两个标签。布局上我采用左侧大图、右侧信息的横排卡片。
右侧信息区从上到下是:
- 菜名:大字号加粗,单行最多显示 8 到 10 个汉字,超出用省略号
- 难度与时长:一行排开,中间用圆点隔开,配合两个小图标增加辨识度
- 描述:两行文字,灰色小字号
- 标签与收藏:底部一行,左边最多两个标签,右边顶一个收藏数加心形图标
这个信息密度很讲究。如果一味堆信息,卡片高度会变得很大,一屏显示不了几个菜,用户滑动成本过高;信息太少则吸引不了人点进去。封面图占比最大,因为人对美食是"视觉动物",一张色泽好的封面图比任何文字都有说服力。
卡片还要有明确的"可点击"暗示。我用了一个轻量设计:整卡片是一层InkWell,点击时水波纹扩散的同时伴随轻微缩放,缩放动画用 100 毫秒搞定,太长了会显得拖沓。这里有个小细节:水波纹一定要放进Material容器内层渲染,不然点击的时候整个卡片区域不会出现涟漪效果,而是出现一团灰色阴影,观感差很多。
4.4 菜谱列表的懒加载与下拉刷新:照顾真实使用场景
美食类应用的列表数据量通常不小,随着用户收藏、历史浏览的积累,一次性加载全部列表既不现实也浪费流量。我实现了"初始加载 20 条 + 滚动到底部自动追加 20 条"的分页策略,服务层暴露一个fetchRecipes(page)接口,列表滚动到接近底部时触发下一页请求,请求期间底部显示一个加载指示器。
下拉刷新我用的是RefreshIndicator组件,它包住整个列表,下拉时出现原生样式的菊花旋转图标。注意在 OpenHarmony 的适配版本里,这个组件的默认背景色和主题颜色可能和预期不同,我建议显式传入color参数指定刷新图标颜色,避免在深色背景上看不清。
RefreshIndicator( color: const Color(0xFFFF7043), onRefresh: () async { await provider.refreshRecipes(); }, child: ListView.builder( physics: const AlwaysScrollableScrollPhysics(), // ... ), )有个坑我必须提醒:RefreshIndicator要求其 child 必须是一个"可滚动且始终可滚"的组件。如果列表内容太少填不满一屏,ListView 默认无法触发下拉刷新。加上physics: AlwaysScrollableScrollPhysics()就是为了强制它始终可以滚动,这样内容少时也能愉快地刷新。
4.5 空状态与加载骨架屏:别让用户对着白屏干瞪眼
网络请求有延时,列表可能为空,这些状态处理不好,非常影响观感。我在加载阶段做的是骨架屏——不是转圈圈,而是用灰色块模拟卡片轮廓,看起来就像内容正在"长出来"。实现上我用一个定时器让灰色块做轻微的透明度脉动,视觉上很精致,但代码量却不复杂。
搜索结果为空时,我展示一个居中的空状态视图:一个简单的插图图标搭配"没有找到相关菜谱"的文案,同时给出一个"清空筛选条件"的按钮。这个按钮很关键——用户看到空状态的第一反应不是去重新输入,而是想快速恢复初始列表。一键清空比让用户手动删除搜索词更友好。
加载失败则配上"网络开小差了"的提示和重试按钮。错误提示要具体,别只给一个干巴巴的"错误"二字。用户并不知道这个错误是网络超时、服务器 500 还是数据格式异常,他会认为是自己操作出了问题。给出重试按钮,把控制权交还给用户,是挽回体验的关键一步。
5. 图片加载与列表流畅度优化:实测下来最值得投入的部分
写界面谁都会,但能在中低端 OpenHarmony 设备上保持 60 帧滚动,就需要花心思了。我在这一块儿的优化最耗时,也最见效果。
5.1 图片缓存策略:为什么不能用 Image.network 直接用
刚开始我为了图省事,卡片里的封面图直接用Image.network(coverImageUrl)。跑起来才发现问题:列表快速滑动时,图片会重复加载,缓存命中率极低,滚动越猛卡顿越明显。
原因其实不难理解:Image.network默认虽然有内存缓存,但它不做磁盘缓存,也不做缩略图适配。同一张 2MB 的大图被加载到内存后,显示在 200x200 的卡片上,浪费了整整 100 倍的内存空间。解决思路是引入缓存与缩略图机制。
我在项目里封装了一个CachedImage组件,核心策略是:
- 内存缓存用
ImageCache的默认能力,设置合理上限(比如 100MB) - 磁盘缓存把图片文件存到应用缓存目录,key 是图片 URL 的哈希值
- 网络请求回来先写磁盘,再解码显示,第二次展示直接从磁盘读
这里一个实现细节是:网络图片加载时一定要显式设置cacheWidth。它的作用是告诉图片解码器按指定宽度解码,而不是解出原图全尺寸再缩放。一张 1200px 宽的图,在 200px 宽的卡片上用cacheWidth: 600(2 倍屏)解码,内存占用直接降到原来的四分之一。
5.2 列表 item 复用:ListView.builder 的原生优势
ListView.builder的懒加载机制意味着滚出视口的 item 会被回收,滚回来时重用同类 item 的渲染配置。理论上这是 Flutter 框架默认优化的,不需要我手动做什么。但实际使用中如果你一个卡片组件里写了耗时操作,比如在build方法里做字符串正则匹配、做磁盘 IO、用MediaQuery.of(context)多次查询屏幕参数,那么即使 item 复用了,每次重建的 CPU 开销依然存在。
我给自己定的规矩是:卡片组件build方法里只做"与渲染有关"的操作。任何计算型工作,要么在数据层预先算好,要么在initState里做一次。难度标签的颜色映射我就用了静态常量表,而不是每帧去计算:
class RecipeCard extends StatelessWidget { static const _difficultyColor = { '简单': Color(0xFF66BB6A), '中等': Color(0xFFFFA726), '困难': Color(0xFFEF5350), }; // ... }这样每次重建只有一次映射表查询,开销就是一次哈希查找,极快。
5.3 异步布局与列表占位:防止首帧白屏
还有一个更隐蔽的卡顿来源:卡片里的文字可能有两行、三行、一行,如果每张卡片都等文字排版计算完成后再上屏,列表首帧就要等很久。Flutter 的解决方案是"异步布局",但我很少看到有人真的用上。
如果你发现列表首帧有可感知的推迟,可以尝试把卡片内容区的外层用一个SizedBox固定高度,文字区域用Expanded撑开。这样布局引擎拿到固定高度后就可以提前进入下一张卡片的布局,不需要等文字角落的精确计算。
另一个我常用的技巧是:在图片加载完成之前,先展示一个颜色作为占位背景,而不是一个灰色的Container。这是因为解码后的图片替换到控件树里时,如果颜色保持一致,视觉上几乎察觉不到变化;如果用灰色方块突然切换成彩色图片,会有一个明显的闪烁感。
5.4 实测数据:优化前后对照
我在一台配置不太高的 OpenHarmony 开发板上做了滚动流畅度测试。工具用的是 Flutter 自带的性能浮层,观察帧率曲线。
| 优化项 | 优化前帧率表现 | 优化后帧率表现 |
|---|---|---|
| 网络图未缩略 + 无缓存 | 快速滑动掉到 20 帧左右 | 稳定 55 帧以上 |
| 卡片 build 中做正则解析 | 滚动略有卡顿 | 移走后瞬时响应明显提升 |
| 首帧加载 50 条数据 | 白屏约 800ms | 骨架屏立即出现,约 200ms 出内容 |
数据看起来有点"暴力",但事实就是如此。你不需要做很玄妙的优化,把图片缓存和缩略图机制做好,把不该放在 build 方法里的计算挪走,流畅度就会质变。
6. 真机联调与 OpenHarmony 特定问题排查记录
开发过程中最磨人的不是写代码,而是"代码在我的模拟器上好好的,一上真机就出幺蛾子"。OpenHarmony 生态相对年轻,这些坑我记录下来,希望你能直接绕开。
6.1 签名配置与安装构建:绕不开的打包流程
OpenHarmony 真机安装应用需要签名配置,这和 Android 需要签名文件是类似的。步骤上要先在 IDE 里生成签名证书,然后在工程的构建配置文件里填入对应的证书信息和库文件路径。
这个流程文字描述起来比较繁琐,我建议你第一次配置时用 IDE 的图形引导,它能够自动生成证书并把关键信息回填到构建配置文件中,省去手改配置文件的错误。唯一要注意的是:同一个设备调试时,证书不要频繁更换,否则安装时会报签名不一致的错误,需要先卸载旧应用再安装新的,很浪费时间。
6.2 平台通道调用:在 Flutter 里获取 OpenHarmony 设备信息
主界面右上角我想展示当前设备的版本信息,方便开发时确认自己跑在哪个环境上。这个数据在 Flutter 侧拿不到,需要借助平台通道。
Dart 侧的定义:
static const platformChannel = MethodChannel('com.example.recipe_app/device'); Future<String> getDeviceInfo() async { final info = await platformChannel.invokeMethod('getDeviceInfo'); return info as String; }OpenHarmony 侧,在 ohos 目录的 Ability 中注册并响应:
const deviceInfo = JSON.stringify({ os: 'OpenHarmony', model: 'DevEco Device', version: '3.2', }); this.mContext.getApplicationInfo().then((info) => { // 构造 MethodChannel 回调 });要注意的是,平台通道的名称在两边必须完全一致,否则调用会静默失败,返回的结果是MissingPluginException。我第一次调试时就把com.example.recipe_app/device拼成了com.example.recipe_app.device,结果折腾了一整晚。遇到这种问题时,优先检查字符串是否分毫不差。
6.3 热重载差异:修改原生代码后需要重新构建
Flutter 的热重载(Hot Reload)是所有开发者离不开的神器,但在 OpenHarmony 平台上它有个边界:热重载只对 Dart 代码有效。如果你改了 ohos 目录下的原生代码,必须停掉应用重新构建,热重载不会自动帮你刷新原生侧修改。
我踩过的具体场景是:给平台通道新增了一个方法,然后回到 Dart 侧准备调用。按了热重载之后一调用,直接报方法不存在,我当时还以为是自己通道名写错了,排查了半天才发现是原生代码根本没重新构建。这个坑你在开发中一定会遇到,提前知道能省下大量排查时间。
6.4 调试日志获取:通过日志工具定位问题
OpenHarmony 设备上的 Flutter 侧日志,使用flutter logs指令可以实时查看。很多时候你会发现 Dart 侧的print输出不一定立刻出现在日志里,原因是系统日志有缓冲区延迟。建议调试输出用debugPrint,它会智能截断长日志,避免输出内容在缓冲区中被截断。
如果你怀疑原生侧出了问题,则需要切换到设备的事件监控工具,按照进程名和日志级别过滤。这个工具我不展开细节,但给你一个排查路径:先查日志里有没有Error级别的原生异常,再看有没有Flutter标志的 Dart 异常,两者都正常再看平台通道的返回值。分层过滤日志,比盯着整屏日志瞎猜效率高得多。
7. 从界面实现到后续迭代:我留了几个可延展的钩子
菜谱库主界面做完了,但我很清楚它只是整个美食 App 的地基。我在代码里刻意留了几个扩展位,这里一并交代。
分类筛选目前只有一级维度,后续我想加入"场景筛选",比如早餐、夜宵、减脂餐、儿童餐。这是在数据模型里加一个scenario字段的事,筛选逻辑完全复用现在的模式,不动 UI 骨架。
搜索目前只匹配菜名和标签,后续可以升级为拼音首字母搜索。用户输入"hsr"能匹配"红烧肉",这对中文输入体验来说是一个巨大的提升。实现思路也不复杂:给每个菜谱预计算一个拼音首字母字符串,搜索时同时匹配原名和缩写。注意要放到数据层预计算,不能放到筛选时实时转换,否则会有上百次转拼音开销。
收藏功能已经预留了favorCount,但还没有做收藏榜和个性化推荐。后续接上云端后端后,可以基于收藏数据做协同过滤,给用户推荐同类菜谱。这是从"工具型应用"走向"社区型应用"的关键一步。
平台通道我已经打通了设备信息获取,后续继续扩展分布式能力就有了现成模板。比如把收藏列表通过分布式数据同步到用户的平板、手环甚至智能大屏上,这才是 OpenHarmony 生态区别于其他系统的独特价值。
我在实际项目中最大的体会是:主界面做对,整个应用的气场就对了。你用一套代码跑通三端,省下的是双倍或三倍的 UI 工作量,把省下的时间投入到数据模型、平台通道和核心体验优化上,这笔账怎么算都划算。后面等到真实后端上线、收藏推荐跑起来,我还会回来继续分享这一路的坑和经验。