做 Flutter 跨端开发这些年,我一直在找一个能把整套 UI 能力和开发效率迁移到开放原子开源基金会下那个 OpenHarmony 生态里的实际方案。开源社区维护的 flutter_flutter 分支成熟度上来之后,这条路其实已经可以走通了。这个美食烹饪助手 App 就是我用 Flutter for OpenHarmony 真实跑通的一个项目,核心只做一件事:菜谱搜索。从工程初始化、Provider 状态管理、搜索防抖、历史记录存储到列表分页,把移动端搜索场景最常见的几个环节全部串起来。适合正在调研 OpenHarmony 跨端方案、或者已经在用 Flutter 想平滑迁移到 OpenHarmony 设备上的开发者参考。
写这篇文章不是要罗列 API,而是把我在这个项目里踩过的坑、验证过的写法、以及每一步为什么这么做的思考过程还原出来。代码是完整可跑的,你照着搭一个最小版本,再把业务替换成自己的,基本就是一条比较顺的落地路径。
1. 项目整体设计与技术选型
1.1 为什么用 Flutter 做 OpenHarmony 应用
先回答一个最基础的问题:OpenHarmony 本身有自家UI框架,为什么还要把 Flutter 搬过来?我的答案很直接:团队现有资产和跨端一致性问题。
如果项目已经有成熟的 Flutter 代码库,与其在 OpenHarmony 上用新语言重新写一套业务,不如让 Flutter 直接跑在 OpenHarmony 上,一套 Dart 代码同时覆盖 Android、iOS、OpenHarmony。OpenHarmony 能提供的设备形态很多,从手机、平板到带屏设备都有,Flutter 的自绘渲染引擎在这种多设备场景下表现很稳定,不会像 Web 方案那样受宿主 WebView 能力差异影响。
当然,这不是说 Flutter 要替代 ArkTS,OpenHarmony 的原生应用和系统级功能仍然要走原生,跨端业务优先交给 Flutter。两者是互补关系,谁适合干什么就干什么。我选择 Flutter 是因为目标很明确:美食烹饪助手是一个偏内容展示和交互的 App,业务逻辑不涉及太多系统底层能力,Flutter 的覆盖面完全够用。
另一个原因是 Flutter 的组件生态。搜索页面里的输入框、标签流、卡片列表、下拉刷新、骨架屏,这些组件在 Flutter 生态里都很成熟,拿到 OpenHarmony 上直接复用,省下的工作量要比从头写一套完整页面多得多。
1.2 菜谱搜索场景的需求拆解
美食烹饪助手的菜谱搜索功能,表面上就是一个搜索框加结果列表,但实际拆开来看,移动搜索场景的典型功能点几乎都汇聚在这里了。
我搭建项目时先把搜索场景拆成四个功能模块:
- 关键词搜索:用户输入菜名,实时或点击提交后发起检索,拿到菜谱列表。
- 搜索历史:记录用过的关键词,下次进入搜索页时自动回显,支持删除单条和清空。
- 热门搜索:提供一组默认的热门词,方便用户一键搜索,比如红烧肉、番茄炒蛋、清蒸鱼。
- 结果列表:展示菜谱名称、作者、评分、标签和封面图,支持上拉加载更多。
这四个模块相互之间有数据依赖。搜索历史的更新依赖用户提交关键词,热门搜索是静态配置,结果列表依赖搜索关键词的变更。数据流一旦出现多个页面共享,就得引入状态管理方案,这也是我选择 Provider 而不是在每个页面里单独 setState 的根本原因。
1.3 技术栈与工程结构规划
这个项目的技术栈选择完全是围绕"快速迁移 + 稳定落地"来定的:
| 模块 | 选型 | 理由 |
|---|---|---|
| UI 框架 | Flutter(flutter_flutter 分支) | 保持跨端一致,社区活跃 |
| 状态管理 | Provider | 简单轻量,依赖注入灵活,适合中小型业务 |
| 网络请求 | Dio | 拦截器、超时、取消请求都现成 |
| 本地存储 | shared_preferences | 键值对存储,适配 OpenHarmony 的偏好存储 |
| 数据源 | Mock 数据 + 可切换的远程接口 | 开发期不受网络限制,联调期切换真实接口 |
工程目录我用了比较清晰的分层结构,避免所有代码堆在 main.dart 里:
lib/ ├── main.dart # 入口 + MultiProvider ├── models/ │ ├── recipe.dart # 菜谱数据模型 │ └── search_keyword.dart # 关键词模型 ├── providers/ │ ├── search_provider.dart # 搜索结果状态 │ └── history_provider.dart # 搜索历史状态 ├── services/ │ ├── api_client.dart # Dio 实例封装 │ └── recipe_service.dart # 搜索接口逻辑 ├── pages/ │ ├── search_page.dart # 搜索主页面 │ └── recipe_detail_page.dart └── widgets/ ├── keyword_flow.dart # 热门词/历史标签流 └── recipe_card.dart # 菜谱卡片这里特别注意models和providers分开,菜谱数据模型只做序列化和反序列化,不掺业务逻辑;搜索状态统一收敛到SearchProvider里,页面组件只负责读取状态和触发动作。这样后面接真实接口或者做埋点,都只需要改一个文件。
2. 开发环境搭建与工程初始化
2.1 Flutter for OpenHarmony SDK 安装与配置
开发 Flutter for OpenHarmony 应用,第一步不是写 Dart,而是把环境搭对。OpenHarmony 的 Flutter 适配分支和官方 Flutter 略有不同,不能用普通 Flutter SDK 直接编译到 OpenHarmony 设备上。
我的安装路径是这样的:
- 安装 OpenHarmony SDK。一般通过 DevEco Studio 自带的 SDK Manager 安装,也可以单独下载命令行版本。
- 从 Gitee 上拉取 openharmony 的 flutter_flutter 分支,注意要切换到 release 标签,而不是直接拉 master 开发分支。master 分支的功能虽然新,但配套的引擎构件经常不稳定。
- 配置环境变量,把 flutter_flutter 分支的
bin目录加入 PATH。 - 运行
flutter doctor检查 SDK 是否被正确识别,同时确认 Dart SDK 版本跟分支要求一致。
我实际跑下来,这个环节最常见的坑是分支版本和 OpenHarmony SDK 版本对不上。Flutter 版本太新而 OpenHarmony SDK 版本旧的,编译到.har阶段就会报一堆奇怪错误;反过来版本太旧,又会缺失新 API 的映射。我自己的做法是先确认 OpenHarmony SDK 的 API 版本,再去 flutter_flutter 分支的 release 列表里找对应日期的 tag,两者时间线对齐后再开始写代码。
另外要特别注意,flutter doctor里如果检测不到 OpenHarmony 工具链,不用慌张,这个分支的检查项没有完善到检测所有依赖。真正能否编译,要以flutter build hap或 IDE 里的构建结果为准。
2.2 创建项目并接入 OpenHarmony 平台
环境准备好之后,创建 Flutter 工程没有任何特殊之处,还是标准的flutter create。差别在于工程结构,OpenHarmony 适配分支会在项目里生成一个ohos目录,它等价于 Android 工程里的android目录和 iOS 工程里的ios目录。
接入 OpenHarmony 平台需要注意几个点:
- 在
ohos目录里的module.json5中声明应用所需的权限。搜索功能需要访问网络,一定要加入ohos.permission.INTERNET。如果不加,真机上运行不会有任何代码报错,但所有网络请求都会静默失败,这个定位过程非常熬人。 - OpenHarmony 应用构建产物是
.har或者.hap,不是 Android 的.apk或.aar。刚接触的人经常把 Flutter 插件打包方式和 Android 混淆,这在排查构建问题时是一个容易走偏的方向。 - 设备连接方式用 hdc 工具,类似 adb。编译完成后可以通过 hdc 安装到开发板或手机上调试。
我在初始化阶段就把module.json5的权限配置写好了,除了网络权限,还把存储权限也一并声明。搜索历史要落盘,虽然 shared_preferences 底层用的是偏好存储,正常情况下不需要额外权限,但提前放进去可以避免后面加权限导致整包重新签名。
构建命令我用的是 IDE 里的图形化操作,但命令行方式也需要掌握,尤其是要做自动化打包的时候:
flutter build hap --debug这条命令会生成 hap 安装包。第一次构建会跑很久,引擎编译和依赖下载全是耗时操作,要有点耐心。
2.3 依赖管理与基础封装
依赖管理还是在pubspec.yaml里声明。这个项目用到的包不算多:
dependencies: flutter: sdk: flutter provider: ^6.1.2 dio: ^5.4.0 shared_preferences: ^2.2.2这里有个经验:OpenHarmony 适配分支对插件的支持进度不一样,不是所有 pub.dev 上的插件都能直接用。shared_preferences 这类基础插件已经有对应的 OpenHarmony 实现,可以直接用;但某些依赖原生能力很重的插件,比如涉及相机、定位的,就得去查 openharmony 分支的插件仓库看是否已经移植。
我把网络请求封装成一个单独的ApiClient,核心是给 Dio 设置统一的超时时间和日志拦截器。
class ApiClient { static Dio get dio { final dio = Dio(BaseOptions( connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10), baseUrl: 'https://api.example.com', )); dio.interceptors.add(LogInterceptor(responseBody: true)); return dio; } }超时时间我设置了connect和receive两层,OpenHarmony 真机上的网络栈和 Android 不完全一样,实测下来某些型号的设备DNS解析会比模拟器慢很多,超时设短了容易误报"无网络"。
3. 菜谱搜索核心逻辑实现
3.1 搜索输入防抖:从根源减少无效请求
菜谱搜索最常见的交互是边输入边搜索,用户刚打出"番茄"两个字,中间会经历"番""番茄""番茄炒"三次输入变化。如果不做处理,每变化一次就发一次网络请求,不仅浪费流量,还会导致一个很头疼的竞态问题:先发出的慢请求后返回,覆盖了后面发出但先返回的搜索结果。
防抖的方案是等用户停止输入一段时间后再触发搜索。我给输入框的onChanged回调挂了一个 400ms 的定时器:
Timer? _debounce; void onKeywordChanged(String keyword) { _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 400), () { _searchProvider.search(keyword); }); }400ms 是我调出来的折中值。太短挡不住快速输入,太长又让结果出现得明显迟钝。在实际测试中,400ms 对中文输入法尤其合适,因为中文拼写过程中会有很长一段拼音状态,过快触发会导致每次拼音输入都发起请求。
除了实时搜索,我还保留了点击搜索按钮直接触发的方式。如果用户输入完没有停顿,而是直接点键盘的搜索键或者搜索按钮,就走按钮的完整搜索逻辑,并取消掉之前挂着的防抖定时器。这个细节版本迭代了好几次,最终确认两种入口的搜索路径共享同一个 Provider 方法,只是触发时机不同。
这里有个容易踩的坑:定时器一定要在页面销毁时取消。如果用户输到一半退出页面,定时器还在转,触发了一次网络请求,请求回来时页面已经销毁,轻则内存泄漏警告,重则直接报 setState after dispose 崩溃。
3.2 用 Provider 管理搜索状态
搜索页面的状态其实不少:当前输入的关键词、是否正在加载、结果列表、当前页码、是否还有更多、错误信息。这些状态如果散落在页面的局部变量里,页面一重建就会丢;如果全部塞进一个 StatefulWidget 的 setState,页面每次输入变化都要重建整棵组件树,性能也会出问题。
我选择用 Provider,底层机制是ChangeNotifier。先定义一个SearchProvider:
class SearchProvider extends ChangeNotifier { List<Recipe> _results = []; bool _isLoading = false; String _keyword = ''; int _page = 1; bool _hasMore = true; String? _error; List<Recipe> get results => _results; bool get isLoading => _isLoading; bool get hasMore => _hasMore; String? get error => _error; Future<void> search(String keyword, {int page = 1}) async { _keyword = keyword; _page = page; _isLoading = true; _error = null; notifyListeners(); try { final data = await RecipeService.search(keyword, page: page); if (page == 1) { _results.clear(); } _results.addAll(data); _hasMore = data.length >= RecipeService.pageSize; } catch (e) { _error = '搜索失败,请检查网络后重试'; } finally { _isLoading = false; notifyListeners(); } } }这个类只做两件事:保存搜索状态、对外暴露动作方法。页面组件通过context.watch<SearchProvider>()来读取数据,通过context.read<SearchProvider>()来触发动作。
watch和read的区别是这个方案里最重要的一个细节。watch会让组件在 Provider 数据变化时自动重建,适用于需要展示状态的 UI 部分;read只取引用不监听变化,适用于按钮点击、表单提交这类事件回调。如果在按钮的onPressed里用watch,不仅会在不必要时重建按钮组件,还可能造成性能浪费。
在入口处用MultiProvider注册:
MultiProvider( providers: [ ChangeNotifierProvider(create: (_) => SearchProvider()), ChangeNotifierProvider(create: (_) => HistoryProvider()), ], child: const SearchPage(), )组件通信的链路就变成了:输入框onChanged触发防抖,防抖结束后调用context.read<SearchProvider>().search(),Provider 内部更新状态并notifyListeners(),结果列表组件因为 watch 到了结果变化自动刷新。这个链路非常清晰,页面组件和状态完全解耦。
3.3 搜索接口对接与数据解析
菜谱搜索的核心数据模型Recipe我做了比较完整的字段映射:
class Recipe { final String id; final String name; final String author; final double rating; final String coverUrl; final List<String> tags; Recipe({ required this.id, required this.name, required this.author, required this.rating, required this.coverUrl, required this.tags, }); factory Recipe.fromJson(Map<String, dynamic> json) { return Recipe( id: json['id'] as String, name: json['name'] as String, author: json['author'] ?? '匿名', rating: (json['rating'] as num?)?.toDouble() ?? 0, coverUrl: json['coverUrl'] ?? '', tags: (json['tags'] as List?)?.cast<String>() ?? [], ); } }解析 JSON 时我特别关注了类型安全。后端接口如果某个字段缺失或者为 null,直接强转as String会在运行时崩溃。我用??给了默认值,列表类型的字段用cast<String>()做统一转换。这些细节在开发期 Mock 数据时看不出来,一旦接真实接口,各种脏数据就会冒出来。
搜索服务层也很薄:
class RecipeService { static const pageSize = 20; static Future<List<Recipe>> search(String keyword, {required int page}) async { final response = await ApiClient.dio.get( '/recipe/search', queryParameters: {'keyword': keyword, 'page': page, 'size': pageSize}, ); final list = response.data['data']['list'] as List; return list.map((e) => Recipe.fromJson(e)).toList(); } }项目开发阶段没有现成的菜谱后端,我用了一段本地 Mock 数据来模拟返回结果。Mock 数据放到 assets 里,通过loadString读取后给到列表。切换到真实接口只需要改RecipeService.search一个方法,页面和 Provider 完全不用动。
分页这里有个容易忽略的点:下拉加载更多时,page要递增,然后把新数据addAll到已有列表后面,不能用clear清空。我在search方法里用page == 1判断是全新搜索还是加载更多,这是移动端列表分页的通用套路。
3.4 搜索历史的本地存储与回显
搜索历史的实现有几个细节需要处理:去重、排序、数量上限、持久化。
我用 shared_preferences 存储,以 JSON 字符串的形式保存一个关键词列表。写入逻辑是这样的:
class HistoryProvider extends ChangeNotifier { List<String> _keywords = []; static const _maxCount = 10; static const _storageKey = 'search_history'; List<String> get keywords => _keywords; Future<void> load() async { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_storageKey); if (raw != null) { _keywords = List<String>.from(jsonDecode(raw)); notifyListeners(); } } Future<void> add(String keyword) async { _keywords.remove(keyword); _keywords.insert(0, keyword); if (_keywords.length > _maxCount) { _keywords = _keywords.sublist(0, _maxCount); } final prefs = await SharedPreferences.getInstance(); await prefs.setString(_storageKey, jsonEncode(_keywords)); notifyListeners(); } Future<void> clear() async { _keywords.clear(); final prefs = await SharedPreferences.getInstance(); await prefs.remove(_storageKey); notifyListeners(); } }去重用的是先remove再insert(0, ...)的方式:同一个关键词重复搜索,把它从旧位置移掉,放到最前面,保证最近搜索的排在前面。
上限控制在 10 条,搜索历史太多不仅占存储控件,UI 上也显得杂乱。OpenHarmony 的偏好存储在频繁读写时有一些性能波动,所以我在存储时直接写整个 JSON 字符串,不做逐条增量更新。字符串体积极小,这个方案简单可靠。
4. 搜索界面与交互体验打磨
4.1 搜索页整体布局拆解
搜索页的布局结构我分了三块:顶部搜索区、中部推荐区(热门词和历史)、底部结果区。顶部搜索区是一个TextField,放在 AppBar 的自定义 title 里,输入框自带搜索图标和清除按钮,聚焦时自动弹出软键盘。
中部推荐区用两个标签流展示。热门搜索是固定数据,历史记录从HistoryProvider读取。标签流我用的是Wrap组件而不是ListView,因为标签数量不固定,Wrap 可以自动换行,每一行标签间距保持一致。
结果区是一个ListView.builder,每个 item 是RecipeCard。卡片上展示菜谱封面图、名称、作者、评分和几个标签。为了在 OpenHarmony 真机上保持流畅滚动,ListView.builder的 item 使用了const构造,减少不必要的组件重建。
第一次进入搜索页时,结果区是空的,需要展示一个引导提示:"输入关键词开始探索美食"。这个空态设计一开始容易忽略,但实际很关键,一个空白页面对用户来说没有方向感。
4.2 结果列表、空态和错误态设计
搜索结果区不能只做一个列表,至少要有四种状态:加载中、加载失败、空结果、有数据。我把这四种状态放在一个方法里统一判断:
Widget _buildResultSection() { if (_provider.isLoading && _provider.results.isEmpty) { return const SkeletonList(); } if (_provider.error != null && _provider.results.isEmpty) { return ErrorRetryView(message: _provider.error!, onRetry: _retry); } if (_provider.results.isEmpty) { return const EmptyResultView(); } return RecipeListView(); }骨架屏SkeletonList是几张灰色卡片,用来模拟加载中的样子,比转圈菊花更符合美食内容的调性。加载失败时显示错误信息和重试按钮,点击重试直接调用search方法把上次的关键词再搜一遍。空结果状态放了一个插图和一个文案:"没有找到相关菜谱,换个关键词试试吧"。
有数据时的列表也要处理两个细节:首次加载和加载更多共用列表尾部组件。我用了ScrollController监听滚动位置,当滚动距离底部还剩 200 像素时触发加载更多。如果_hasMore为 false,尾部显示"已经到底了"的收尾提示,避免用户一直上拉永远没有反馈。
我在实际使用中体会到,空态和错误态的充分设计能把一个搜索功能的完成度提升一大截。用户遇到网络问题看到的是友好的重试按钮,不会反复退出去重新进页面。
4.3 键盘适配与组件通信细节
在 OpenHarmony 真机上,软键盘弹出时会不会遮挡搜索框和结果列表,是交互体验的关键。我用MediaQuery.of(context).viewInsets.bottom来获取软键盘高度,把结果列表的底部内边距加上这个高度。
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),另外,点击空白区域收起键盘,用GestureDetector包住整个页面,在onTap里调用FocusScope.of(context).unfocus()。
组件通信在这个项目里除了 Provider,还涉及到跨页面传递数据。点搜索结果跳转到详情页,我用构造函数传Recipe对象,因为跳转是低频操作,不需要用全局状态。但收藏菜谱这种动作,详情页完成后可能要回到列表刷新收藏状态,这种情况我用了一个简单的 EventBus 实现广播。在 Flutter 里可以用Stream模拟事件总线,不需要额外引包:
class FavoriteEvent { static final _streamController = StreamController<String>.broadcast(); static Stream<String> get stream => _streamController.stream; static void emit(String recipeId) => _streamController.add(recipeId); }详情页点击收藏后发送事件,列表页订阅事件后刷新对应卡片的收藏图标。这个模式适合点对点通信,全局状态还是首选 Provider。
5. 常见问题与排查技巧实录
5.1 编译构建报错清单
我用 Flutter for OpenHarmony 从零搭项目,编译阶段遇到的报错是最有代表性的,这里整理成一张速查表:
| 症状 | 常见原因 | 解决办法 |
|---|---|---|
| flutter pub get 超时 | 网络问题或源不稳定 | 切换 PUB_HOSTED_URL 镜像源 |
| 构建失败,找不到 Dart 引擎 | flutter_flutter 分支和 OpenHarmony SDK 版本不匹配 | 对齐 release 标签和 SDK 日期 |
| 报错信息里提到 apply plugin | 构建脚本写法与 Gradle 插件配置冲突 | 按官方模板重写工程级 build 脚本 |
| 新建工程跑不起来 | SDK 没初始化完成或设备未连接 | 重新执行 flutter doctor 并确认 hdc 能识别设备 |
| 产物无法安装 | 签名字段配置不正确 | 在 ohos 工程里检查签名配置 |
这里重点说一个容易被忽略的问题:flutter pull依赖包时,OpenHarmony 分支会额外从自己的仓库拉取平台引擎构件,这个过程中断或者版本不对会让工程卡在编译前。我的经验是尽量使用预先配置好的镜像仓库,并且在首次编译前确保本地缓存完整。
如果你发现网上搜到的报错是 Android 的构建产物格式,不要直接套用。OpenHarmony 走的是 HAR/HAP 构建链路,目标文件和中间产物都和 Android 不一样,要用分支配套的模板。
5.2 网络请求失败排查
搜索功能离不开网络,我在 OpenHarmony 真机上遇到最多的网络问题有三个。
第一个是权限没声明。ohos.permission.INTERNET必须出现在module.json5的requestPermissions列表里。这个权限缺失不会报错,只是所有请求直接返回失败。排查方式是在代码里加日志,看 Dio 的请求是否最终走到onError回调。
第二个是明文 HTTP 请求限制。如果接口地址是http://,OpenHarmony 默认会阻止明文流量。开发期连本地服务时,需要在网络配置文件里声明允许明文流量,或者干脆开发期用 HTTPS 接口。
第三个是抓包失败。很多人会在调试时想抓包看接口返回,但 HTTPS 抓包需要安装证书并关闭证书校验。调试阶段我在ApiClient里临时允许绕过证书校验,但上线前一定会去掉这个开关,否则会把调试通道留给攻击者。
超时时间也要根据真机环境调整。我在前面提到了 connectTimeout 和 receiveTimeout 都设成了 10 秒。有个别设备首次访问接口时,DNS 解析和 TLS 握手时间很长,后续请求会变快,所以首屏搜索偶尔慢不是代码问题。
5.3 状态更新导致界面卡顿
搜索功能在输入快、列表长的时候容易暴露一个性能问题:notifyListeners()被频繁调用,导致整页重建。我排查的路径是先在页面上加了print日志,确认每个组件的build方法触发时间和频率。
定位后发现两个问题:
第一个是防抖期间也触发notifyListeners()。我原来在onChanged里直接给 Provider 的keyword字段赋值并调用通知,这样每次输入都重建页面。后来我把关键字字段分成外部的输入框状态,只在真正发起搜索时才更新 Provider 并通知。
第二个是列表 item 没有做 const 优化。搜索结果列表的RecipeCard依赖 Provider 里的大列表,每次通知都会重建整个列表。后来我把整个列表用一个Consumer<SearchProvider>包起来,只在列表数据变化时重建,不牵连搜索框和推荐区。
另外一个大坑是分页重复。快速上拉触发加载更多时,如果上一次请求还没返回,又发了一次同页请求,会出现相同数据重复加入列表。我在 Provider 里加了一个_isLoadingMore标志,loading 状态下直接忽略新的加载更多请求。
5.4 搜索框焦点与软键盘残留
搜索页的键盘交互在真机上比模拟器敏感很多。我遇到过一个很隐蔽的问题:从搜索结果页返回搜索页,软键盘还保持着弹出状态,把推荐区完全盖住了。
排查方式是打印FocusManager.instance.primaryFocus的状态,发现路由跳转前没有释放焦点。解决方案是在跳转详情页前主动让搜索框失焦:
void _goToDetail(Recipe recipe) { FocusScope.of(context).unfocus(); Navigator.push(context, MaterialPageRoute( builder: (_) => RecipeDetailPage(recipe: recipe), )); }另外还有一个常见问题是搜索提交后键盘没有收起。用户在输入法键盘上点了搜索键,结果列表出来了,键盘还挡着半个屏幕。我在onSubmitted回调里调用unfocus()解决。
如果多个页面都有输入框,建议统一用一个FocusNode管理搜索框焦点,在需要的时候requestFocus和unfocus。这样可以把键盘控制和业务逻辑解耦。
6. 写在最后的实践经验
整个项目从环境搭建到功能跑通,我花在编译链路上的时间远超写业务代码的时间。这不是 Flutter 本身的问题,而是跨平台适配分支固有的学习成本。第一次接触 Flutter for OpenHarmony 的时候,如果有这份上手材料做参考,至少能省掉一半的试错时间。
我个人在实际操作中的体会是,不管后面 OpenHarmony 生态怎么发展,Flutter 这套跨端能力在开源系统和多设备场景下都是值得投入的。先跑通最小闭环,再逐步扩展功能,比一上来就想做完整的大型应用要稳妥得多。在这个项目基础上,下一步可以把搜索排序、食谱详情、用户收藏、周膳食计划这些模块逐步加进去。每增加一个功能点,都回到 Provider 状态管理的架构上来做设计,整个 App 就不会因为功能膨胀变成一团乱麻。