ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Flutter for OpenHarmony实战:菜谱搜索App跨端开发全解析

Flutter for OpenHarmony实战:菜谱搜索App跨端开发全解析 做 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 要替代 ArkTSOpenHarmony 的原生应用和系统级功能仍然要走原生跨端业务优先交给 Flutter。两者是互补关系谁适合干什么就干什么。我选择 Flutter 是因为目标很明确美食烹饪助手是一个偏内容展示和交互的 App业务逻辑不涉及太多系统底层能力Flutter 的覆盖面完全够用。另一个原因是 Flutter 的组件生态。搜索页面里的输入框、标签流、卡片列表、下拉刷新、骨架屏这些组件在 Flutter 生态里都很成熟拿到 OpenHarmony 上直接复用省下的工作量要比从头写一套完整页面多得多。1.2 菜谱搜索场景的需求拆解美食烹饪助手的菜谱搜索功能表面上就是一个搜索框加结果列表但实际拆开来看移动搜索场景的典型功能点几乎都汇聚在这里了。我搭建项目时先把搜索场景拆成四个功能模块关键词搜索用户输入菜名实时或点击提交后发起检索拿到菜谱列表。搜索历史记录用过的关键词下次进入搜索页时自动回显支持删除单条和清空。热门搜索提供一组默认的热门词方便用户一键搜索比如红烧肉、番茄炒蛋、清蒸鱼。结果列表展示菜谱名称、作者、评分、标签和封面图支持上拉加载更多。这四个模块相互之间有数据依赖。搜索历史的更新依赖用户提交关键词热门搜索是静态配置结果列表依赖搜索关键词的变更。数据流一旦出现多个页面共享就得引入状态管理方案这也是我选择 Provider 而不是在每个页面里单独 setState 的根本原因。1.3 技术栈与工程结构规划这个项目的技术栈选择完全是围绕快速迁移 稳定落地来定的模块选型理由UI 框架Flutterflutter_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。先定义一个SearchProviderclass SearchProvider extends ChangeNotifier { ListRecipe _results []; bool _isLoading false; String _keyword ; int _page 1; bool _hasMore true; String? _error; ListRecipe get results _results; bool get isLoading _isLoading; bool get hasMore _hasMore; String? get error _error; Futurevoid 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.watchSearchProvider()来读取数据通过context.readSearchProvider()来触发动作。watch和read的区别是这个方案里最重要的一个细节。watch会让组件在 Provider 数据变化时自动重建适用于需要展示状态的 UI 部分read只取引用不监听变化适用于按钮点击、表单提交这类事件回调。如果在按钮的onPressed里用watch不仅会在不必要时重建按钮组件还可能造成性能浪费。在入口处用MultiProvider注册MultiProvider( providers: [ ChangeNotifierProvider(create: (_) SearchProvider()), ChangeNotifierProvider(create: (_) HistoryProvider()), ], child: const SearchPage(), )组件通信的链路就变成了输入框onChanged触发防抖防抖结束后调用context.readSearchProvider().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 ListString tags; Recipe({ required this.id, required this.name, required this.author, required this.rating, required this.coverUrl, required this.tags, }); factory Recipe.fromJson(MapString, 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?)?.castString() ?? [], ); } }解析 JSON 时我特别关注了类型安全。后端接口如果某个字段缺失或者为 null直接强转as String会在运行时崩溃。我用??给了默认值列表类型的字段用castString()做统一转换。这些细节在开发期 Mock 数据时看不出来一旦接真实接口各种脏数据就会冒出来。搜索服务层也很薄class RecipeService { static const pageSize 20; static FutureListRecipe 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 { ListString _keywords []; static const _maxCount 10; static const _storageKey search_history; ListString get keywords _keywords; Futurevoid load() async { final prefs await SharedPreferences.getInstance(); final raw prefs.getString(_storageKey); if (raw ! null) { _keywords ListString.from(jsonDecode(raw)); notifyListeners(); } } Futurevoid 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(); } Futurevoid 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 StreamControllerString.broadcast(); static StreamString 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 里的大列表每次通知都会重建整个列表。后来我把整个列表用一个ConsumerSearchProvider包起来只在列表数据变化时重建不牵连搜索框和推荐区。另外一个大坑是分页重复。快速上拉触发加载更多时如果上一次请求还没返回又发了一次同页请求会出现相同数据重复加入列表。我在 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 就不会因为功能膨胀变成一团乱麻。
返回列表