Flutter on OpenHarmony:列表页与详情页跨端实战
我是在一个周末临时起意把 Flutter 真正打到 OpenHarmony 上的。当时拿到一台开发板系统跑的是标准 OpenHarmony折腾完 DevEco Studio 那一套之后我一直在想光跑通 Hello World 没什么说服力得拿一个像样的业务项目来试水。正好那阵子剧本杀特别火身边朋友组局全靠微信群接龙找店、看评价、凑人数全是手动操作痛点很明确。于是就有了这个“剧本杀组队 App”的练手项目而今天这篇博文就聚焦里面最核心也最考验基本功的两个页面店铺列表与店铺详情。这两个页面放在 Flutter for OpenHarmony 的场景下难度比普通 Android/iOS 要高出一个档次Flutter 在 OpenHarmony 上还属于社区推进的阶段生态不完整插件要自己桥接渲染、构建、真机调试都可能随时踩到坑。但反过来说正因为环境“不顺手”你能实实在在摸到 Flutter 的架构边界知道哪些能力是框架给的、哪些能力必须自己去和原生打交道。这篇文章我会把这个项目从立项思路、环境搭建、列表页实现、详情页路由与组件通信一直写到真机上的踩坑实录适合已经入门 Flutter、想尝试 OpenHarmony 跨端开发或者单纯好奇“Flutter 在非 Android 系统上到底能不能干活”的朋友。1. 为什么是剧本杀组队 App又为什么是 Flutter on OpenHarmony1.1 剧本杀场景给技术出的三道题做任何练手项目我都会先问一句这个业务场景能不能逼出我想验证的技术点剧本杀组队 App 恰好满足。它有三个很典型的移动端场景需求。第一是信息密集型列表。剧本杀店铺列表不像普通电商那样只要图好看它需要同时呈现店名、评分、人均价格、距离、当前可组队剧本、开场时间、剩余空位信息密度和层级都高。列表页要做好必须认真设计数据模型、处理异步加载、管理各种状态。第二是详情页的多信息模块堆叠。一个剧本杀店铺详情页至少要有头图轮播、店铺信息、剧本列表、组队中房间、用户评价、地图位置这些模块。简单的 ListView 塞不下需要 NestedScrollView 或者 CustomScrollView 来做复杂联动这正好考察 Flutter 的滚动体系掌握程度。第三是原生能力调用。组队功能离不开了定位地图展示离不开原生 SDK部分交互可能要调起原生页面。在 OpenHarmony 上这些原生能力没有现成的 Flutter 插件必须自己写 Platform Channel。这就是我特别想验证的部分Flutter 的组件通信和原生桥接换了一个操作系统之后还灵不灵。1.2 Flutter 在 OpenHarmony 上值不值得押注先把结论放这儿值但要有心理准备。Flutter 本身的设计目标是跨平台统一渲染UI 层不依赖系统控件而是自己用 Skia 绘制。这个设计带来的最大好处是只要有人把 Flutter Engine 移植到某个新平台你写的 Dart UI 代码几乎不用改就能跑。OpenHarmony 上目前就是靠社区维护的 Flutter SDK 分支做这件事UI 层基本能复用但底层能力全部要自己接。相比之下如果用 ArkTS 原生写 OpenHarmony 应用会很顺畅但那一套代码只能在 OpenHarmony 生态里用。如果你团队里既有 Android 又要覆盖 OpenHarmony维护两套原生代码的成本是很高的。我当时的判断是Flutter 的成本在于前期环境折腾和原生桥接但一旦把基础铺好后面页面越写越划算。另外提一句前端框架对比RN 在 OpenHarmony 上也有适配方案但 RN 的渲染层依赖原生控件映射鸿蒙的原生控件体系和 Android 并不完全一致适配工作量往往比 Flutter 更大。Flutter 的“自绘”架构在这种新平台上反而成了优势。当然代价就是包体积更大启动稍慢这在开发板那种低配设备上感觉得很明显。1.3 这个项目到底做了什么项目本身没有贪大只做核心闭环店铺列表页 - 店铺详情页 - 拉起组队信息。列表页从接口拉取店铺数据展示卡片信息和加载状态点击卡片后通过路由进入详情页详情页展示完整信息并通过 EventChannel 接收原生侧定位数据显示距离和地图位置最后有一个简单的“加入组队”按钮验证从 Flutter 调起原生页面再返回的能力。这个范围看起来很克制但它把 Flutter 开发里最高频的几个知识点全串起来了异步数据流、状态管理、列表性能、路由传参、页面状态保活、组件通信、原生桥接。这篇文章重点拆列表和详情两个页面因为这两个页面是所有业务 App 的地基地基稳了后面加功能都是堆砖。2. 环境搭建与工程骨架先别急着写代码2.1 Flutter 与 OpenHarmony 工具链版本怎么对应这是整个项目里最劝退新人的环节我一开始也在这上面浪费了差不多一天。核心问题是普通 Flutter SDK 根本不认识 OpenHarmony 设备必须使用适配分支而且版本要跟 OpenHarmony SDK 和 DevEco Studio 严格对上。我当时使用的组合是社区维护的 Flutter OpenHarmony 分支git 拉取后切换到对应 release 分支 DevEco Studio 4.x OpenHarmony SDK API 10 左右。你可以理解成一套三角关系Flutter 引擎依赖 OpenHarmony 提供的图形接口做渲染而工程构建依赖 DevEco 的 hvigor 编译链三者版本错一位都可能导致编译失败或者安装后闪退。配置步骤大致是这样先装 DevEco Studio并在里面下载对应 OpenHarmony SDK记下 SDK 路径。用 git 拉 Flutter 的 ohos 分支不要用官方 master。配置环境变量关键是DEVECO_SDK_HOME指向 DevEco 的 SDK 目录让 Flutter 工具链能找到 OpenHarmony 的编译环境。跑flutter doctor确认Flutter和DevEco两栏都通过。需要注意flutter doctor 对 OpenHarmony 的检测不一定完整别全信最终以能不能在 DevEco 里跑起来为准。我当时卡了很久的是DEVECO_SDK_HOME路径写错导致每次构建都报找不到 native 工具链后来发现 DevEco 的 SDK 目录结构跟 Android SDK 完全不一样路径得指到包含ets、toolchains的那个层级才行。2.2 从零创建项目的步骤这里覆盖一个很常见的入门问题怎么创建一个 Flutter 工程并且让它能跑在 OpenHarmony 上。如果你已经配置好上面的分支 Flutter创建项目其实分两条路。第一条是在 DevEco Studio 里直接建标准工程然后手动把 Flutter 相关目录和配置合进去适合老手但比较绕。第二条更省事先用命令行flutter create .生成 Flutter 工程再在工程里执行这个分支自带的创建命令让它生成ohos目录和 hvigor 配置文件之后用 DevEco Studio 打开工程目录就能识别为一个 OpenHarmony 应用工程。工程结构上注意区分几个目录lib是 Dart 业务代码ohos是 OpenHarmony 原生壳工程里面有一个 entry 模块负责原生侧的页面注册和插件注册。Flutter 页面其实运行在原生壳的一个特定页面里你可以理解成原生开了一个“画布 Activity”Flutter 引擎在这块画布上渲染所有 UI。这个认知对后面理解组件通信很有帮助。命令行生成的工程默认会带ohos目录吗不会普通 Flutter SDK 没有这个能力只有 ohos 分支的 Flutter 才内置了这套模板。所以第一件事永远是从 git clone 拉分支而不要用官网装的稳定版。2.3 依赖选型能少装一个原生插件就少装一个在 OpenHarmony 上选 Flutter 依赖跟 Android 时代完全是两套逻辑。pub.dev 上绝大多数插件默认只实现了 Android 和 iOSOpenHarmony 一个都没有。虽然 Flutter 插件机制里有 federated plugin 的概念理论上可以补一个 OpenHarmony 实现但那是另一个很大的工程。我的选型原则有三个纯 Dart 实现的库优先。比如状态管理、路由、http 请求这些不碰原生代码在 OpenHarmony 上就是普通 Dart 包直接依赖就行。有原生依赖的库慎重。比如官方image_picker、permission_handler除非你已经准备好自己写 OpenHarmony 端实现否则别装。网络图片缓存用cached_network_image这类依赖少一点的或者干脆自己封装一层简单的图片缓存避免引入过多传递依赖。最终我选的是网络层用dio因为它是纯 Dart 实现拦截器、超时、取消请求都齐全状态管理用provider简单直接没有代码生成图片加载自己写了个轻量封装用Image.network加内存缓存。你可能会问为什么不用flutter_bloc或者 cubit我在后面状态管理那节会展开说。依赖越少环境越稳。在 OpenHarmony 这种生态起步期的平台上每多一个原生插件就多一个需要自己填的坑。3. 店铺列表页数据驱动的一整套逻辑3.1 店铺模型怎么设计才够用列表页的模型设计直接决定 UI 要不要做一堆空判断。剧本杀店铺模型我最后是这么设计的class StoreModel { final String id; final String name; final double rating; // 评分4.8 final int reviewCount; // 评价数 final int minPrice; // 人均最低价 final int maxPrice; // 人均最高价 final double distance; // 距离单位公里 final String address; // 地址 final ListString imageUrls; // 头图列表 final ListString hotScripts; // 目前在推的剧本名 final int openSeats; // 当前可以加入的空位数 StoreModel.fromJson(MapString, dynamic json) { ... } }几个细节值得说。价格字段我用minPrice和maxPrice分开因为剧本杀店“人均 88-158 元”这种区间很常见拆开存后面好排序好展示。距离字段直接在服务端算好返回客户端不自己算省去经纬度和地理计算的复杂度。热卖剧本用 List 存名字在列表卡片上可以展示“热门病娇男孩的精分日记 / 你好新天津”这种滚动 tag。模型设计的目标是让 UI 层拿到的数据“已经能用”而不是让 UI 层去拼接。比如卡片上要显示“距你 1.2km”我这里就直接有 distance 字段UI 只负责格式化。格式化这类逻辑也别散落在 build 代码里写在模型里一个get distanceText就行了。3.2 数据层接口、仓库层和异常处理列表页数据层我分了三层接口层、仓库层、状态层。接口层只负责发请求和类型转换仓库层负责缓存和业务判断状态层负责通知 UI 刷新。接口层用 dio 写的核心逻辑类似这样class StoreApi { static FutureListStoreModel fetchStores({int page 1, int pageSize 10}) async { final resp await Dio().get(/api/stores, queryParameters: {page: page, pageSize: pageSize}); if (resp.statusCode ! 200) { throw ApiException(请求失败状态码 ${resp.statusCode}); } final data resp.data; // 假设返回结构是 {list: [...]} return (data[list] as List) .map((e) StoreModel.fromJson(e)) .toList(); } }订单页里最容易翻车的其实不是请求本身而是超时和异常处理。OpenHarmony 开发板上经常有网络不稳定、DNS 解析慢的情况dio 的默认超时是 0也就是永不超时这简直是灾难。我显式设置connectTimeout: 5000receiveTimeout: 10000并且加了一个拦截器统一处理 DioException把它转成业务层能识别错误。仓库层我做了一级内存缓存同一 session 内列表数据不重复请求。别小看这一层在开发板上做下拉刷新如果每次都重新请求 10 张图片内存一定会出问题。这个缓存在后面排查“列表再次进入时闪白屏”问题时会很有帮助。状态层我先说结论列表页我没有用任何状态管理库就是自己写一个ChangeNotifier配合provider包挂载。为什么不用 cubit因为 cubit 本身很好但在这种两页面练手项目里引入flutter_bloc会多出一堆抽象层Event - Bloc - State - UI对团队协作是大帮助对自己写这个量级的项目反而拖慢节奏。我实际的做法是class StoreListViewModel extends ChangeNotifier { ListStoreModel _stores []; bool _loading false; String? _errorMsg; int _page 1; bool _hasMore true; Futurevoid loadMore() async { if (_loading || !_hasMore) return; _loading true; notifyListeners(); try { final list await StoreApi.fetchStores(page: _page); if (list.isEmpty) { _hasMore false; } else { _stores.addAll(list); _page; } _errorMsg null; } catch (e) { _errorMsg e.toString(); } finally { _loading false; notifyListeners(); } } }View 里监听这个 ViewModel 的状态分别渲染加载中、错误、空列表、有数据四种形态。这里的关键是页面 UI 永远不直接感知网络请求只感知状态。这样列表页的加载、局部刷新、错误重试都能通过状态驱动逻辑不会散落在setState里。3.3 列表 UI卡片、骨架屏和性能细节UI 上剧本杀店铺卡片我采用了上下结构的卡片上方是横向滚动头图下方是信息区。头图用PageView横向滑动信息区用三行布局第一行店名加评分第二行价格区间和距离第三行热门剧本 tag。列表的性能主要靠三点保证。第一ListView.builder必须设置itemExtent或者让卡片高度固定这样 Flutter 在做懒加载时可以精确计算滚动范围避免 item 高度测量带来的额外布局开销。开发板上性能弱这个优化效果非常明显。第二图片不能直接无脑Image.network要指定cacheWidth让图片在解码阶段就压缩到屏幕宽度对应像素而不是加载原图。一张头图原图可能 2MB解码成 1080p 内存直接爆掉指定cacheWidth: 720内存占用会降几个量级。第三卡片用const构造让不可变区域复用 Widget 实例减少 rebuild。骨架屏也要提一句。OpenHarmony 上的 Flutter 首次渲染网络图片时会有一段时间的白屏如果只靠 CircularProgressIndicator 转圈体验很差。我实现了一个非常朴素的骨架屏用灰色圆角矩形占位根据店铺数量重复出现数据回来后交叉 fade 进真实卡片。写起来不难但对“这个 App 靠不靠谱”的第一印象影响巨大。下拉刷新我用RefreshIndicator套在CustomScrollView外面加载更多则是监听滚动位置滚动接近底部时触发loadMore()。注意不要再在外面套一层SingleChildScrollView否则手势冲突会让你怀疑人生。最后效果是稳定的下拉刷新 上拉加载这也是所有列表类页面的标准姿势。4. 店铺详情页路由、布局与组件通信4.1 路由设计与参数传参从列表页点进详情页最直白的做法是Navigator.push( context, MaterialPageRoute( builder: (_) StoreDetailPage(storeId: store.id), ), );传一个 storeId 而不是把整个 StoreModel 传过去是我坚持的规矩。原因有三第一详情页的数据应该自己重新拉取这样详情页就算从推送通知等其他入口进入也可以用同样的逻辑初始化第二避免列表页的模型和详情页模型产生耦合第三如果后续加了深链跳转路由只需要一个 id 就能定位。有人可能会问那列表页已经请求过店铺信息详情页再请求一次不是浪费吗从体验上说可以先做一次“本地过渡”把列表页已有 StoreModel 作为initialData传给详情页详情页立刻渲染基础信息再后台拉完整详情覆盖。这样既保证响应速度又保持数据源的独立性。这个方案在 Flutter 里实现非常自然构造StoreDetailPage(storeId: ..., initialData: store)就行。这里插一句热搜里频繁出现的“flutter navigator 切换页面后会不会丢失状态”。答案是默认会。列表页滚动位置、已加载的数据一 push 再 pop 回来如果列表页 Widget 被回收状态就没了。解决方法是给列表页的 ScrollView 用PageStorageKey或者给 State 混入AutomaticKeepAliveClientMixin。我在项目里是两件事都做了这才保证从详情页返回时列表还停在原位置。4.2 详情页布局别用一个 ListView 硬撑详情页我一开始图省事直接一个ListView.builder按顺序渲染各模块。结果很快发现不行头图轮播需要一个跟随列表滚动的效果信息区要折叠吸附在顶部下面的剧本列表又要横向滑动。ListView 里嵌横向 PageView、再嵌套横向 ListView手势冲突和滚动联动会让你疯掉。后来我改成「CustomScrollView SliverList SliverAppBar」的组合。头部用SliverAppBar做可折叠的伸缩头图列表区域用SliverList承载信息区、剧本区、评价区。整个页面滚动联动由 CustomScrollView 统一调度不会出现内层滚动抢手势的问题。详情页的信息区拆成三个模块基础信息模块店名、评分、价格、地址、剧本模块当前可约剧本卡片横滑列表、组队信息模块当前房间、人数、加入按钮。其中组队信息模块是动态的因为它要实时显示“还剩 2 个空位”这块数据我设置为每 10 秒轮询一次放在 ViewModel 里用Timer.periodic驱动。这也是详情页展示组件通信的好地方位置信息通过 EventChannel 持续更新组队人数通过接口轮询两种数据流在同一页面共存处理逻辑反而更清晰。4.3 EventChannel把持续定位数据喂给 FlutterFlutter 组件通信最常见的是 MethodChannel 和 EventChannel 两兄弟。MethodChannel 适合“请求-响应”式调用比如 Flutter 发起、等原生返回结果EventChannel 适合“原生主动推送”式调用比如定位数据、传感器数据、服务端推送。在详情页我需要持续把定位信息同步给 Flutter展示“距你 1.2km”的实时变化。这种场景如果用 MethodChannel那就得 Flutter 端定时发起轮询浪费通道且时序难控用 EventChannel 最自然原生侧拿到经纬度后直接往 channel 里塞数据Flutter 端像订阅流一样持续接收。Flutter 端订阅代码class LocationStream { static const EventChannel _channel EventChannel(com.example.scriptkill/location); static Streamdouble distanceStream(double lat, double lng) { return _channel.receiveBroadcastStream({lat: lat, lng: lng}); } }原生侧OpenHarmony 里的 ArkTS需要实现StreamHandler接口在onListen里启动定位然后通过sink.success(...)持续把定位结果发出去。真实项目里用系统的 location kit我这里练手时是模拟数据每隔 1 秒生成一个递增距离效果上一样能验证通道是否通顺。有个坑必须提醒EventChannel 的订阅是单向的而且它默认是广播式多个订阅者会同时收到消息。如果详情页销毁时没有 cancel 掉订阅原生侧的事件还会不断往 Flutter 传轻则内存泄漏重则触发 channel 异常。我的处理是把订阅对象保存在 ViewModel 里在dispose()时调用subscription.cancel()。另外MethodChannel 我也用了一个场景点击“加入组队”按钮时Flutter 调起原生侧的一个页面来确认用户信息。这就是热搜里“flutter 跳转原生 activity”的对应场景。跨端跳转本质上就是 MethodChannel 调到原生方法原生方法里做 Intent 跳转返回结果再通过回调传给 Flutter。这套机制在 OpenHarmony 上同样适用只是原生 API 名从 Android 的Intent变成startAbility之类的调用。4.4 PlatformView 与原生地图的一个折中方案地图展示是详情页里最折磨人的一块。标准做法是UiKitView或 PlatformView 把原生高德/百度地图嵌到 Flutter 页面里这样复用原生地图能力不自己画。但问题在于 OpenHarmony 上没有对应的地图 SDK 适配版本我试过后发现 PlatformView 的接入成本非常高需要原生侧创建地图组件再把纹理传给 Flutter 渲染中间涉及生命周期同步在开发板上经常出现黑屏和手势失效。我当时做了一个折中方案不用实时交互地图而是用一张根据经纬度生成的地图静态图配合一个“打开地图查看”按钮点击后调起原生侧已经预置好的地图页面。这个方案在体验上损失不大但对技术难度是数量级的下降。项目里不需要频繁拖动地图用户主要看位置和距离静态图加跳转完全够用。如果你确实必须做 PlatformView我的建议是先把最小 demo 跑通再上业务。先在 Flutter 页面里嵌一个最简单的原生 View确认渲染正常再迭代成地图。不要一上来就把地图 SDK 接进去否则你分不清是 PlatformView 的问题还是地图 SDK 的问题。热搜里“flutter platformview 教程”很多但大多数是 Android 场景OpenHarmony 场景下的资料非常少只能靠你得一点点自己试。5. 真机测试踩坑实录与解决方案5.1 问题速查表开发过程中我整理了一份问题清单挑几个典型的放出来给后来者一个快速对照。现象原因解决方式安装后闪退Flutter 分支与 OpenHarmony SDK 版本不匹配统一降级到社区验证过的版本组合网络请求超时开发板网络栈异常DNS 解析慢dio 显式设置超时并做重试EventChannel 收不到消息原生侧未在 onListen 中启动事件源断点在 onListen 回调确认原生 channel 实例创建返回列表页后滚动位置丢失Navigator 回收了列表 State混入 AutomaticKeepAliveClientMixin并加 PageStorageKey图片加载后出现绿边图片解码格式与渲染引擎不兼容指定 cacheWidth 并调整为 RGB565 解码地图 PlatformView 黑屏OpenHarmony 平台纹理合成问题放弃内嵌改用静态图加原生跳转这些坑一半是环境问题一半是 Flutter 本身的高频问题。环境问题靠版本锁定来解决高频问题靠养成写代码时就想“这个页面会不会被回收、这个订阅会不会泄漏”的习惯来规避。5.2 状态丢失这件小事前面已经提了好几次状态丢失这里集中说透。Flutter 的 Navigator 默认是“压栈则下树”页面被覆盖后如果系统资源紧张它的 State 对象可能会被销毁。你从详情页返回列表页如果列表页整个 State 没了会重新走一遍 initState、重新请求数据用户看到的就是列表闪一下白屏然后回到顶部。这个问题的标准解法是两个。一是页面根节点不设为 build 的直接返回而是让 ScrollView 持有PageStorageKey(storeList)Flutter 会把滚动偏移记录在 Map 里下次重建 ScrollView 时恢复二是混入AutomaticKeepAliveClientMixin重写wantKeepAlive true告诉框架这个页面别销毁。两者组合起来效果是双保险。我在真机上反复验证过从详情页返回列表页滚动位置保持在原来位置图片也不重新闪烁。这个体验细节在用户眼里就是“App 到底专不专业”的差别。5.3 构建链路里的两个经典报错构建过程最有代表性的报错有两个。第一个是 Flutter 工程在集成到 OpenHarmony 构建链时如果同时保留了 Android 构建配置会出现类似 “you are applying Flutters main gradle plugin imperatively using the apply script” 的提示。这个报错的本质是你既用 Android Gradle Plugin 构建又显式用 apply 方式引用了 Flutter 的 gradle 插件两种方式冲突。解决思路是做减法明确当前目标平台是哪套构建链不需要的构建配置直接隔离掉。第二个报错是打包阶段偶发的 “could not close input stream” 之类的 IO 异常。一开始我以为是磁盘问题后来发现是构建脚本和 IDE 文件索引同时读取了同一个文件导致的竞争。清理掉 DevEco 的项目缓存并重启后就好了。这类 IO 报错在跨平台工程里不算罕见第一反应不要怀疑代码先清理缓存再考虑其他。从这些坑里我最深的体感是构建链路上的问题十有八九不是代码 bug而是工具链对不齐。面对报错时先锁版本、清缓存再改代码能少走很多弯路。6. 一些个人心得写到最后聊聊我对 Flutter for OpenHarmony 的真实感受。它的确是能干活的状态但更像早期拓荒没有那么多现成插件给你用每接一个原生能力都要自己动手。不过换个角度看这种“不方便”反而逼着你把 Flutter 的架构理解得更透——你会在做列表页的时候意识到状态管理不是玄学而是数据流动的必然会在做 EventChannel 的通宵调试中记住生命周期管理会在反复清缓存中学会尊重构建链路。最后分享一个小技巧如果你想在 OpenHarmony 上试 Flutter别从地图、相机这类重度原生依赖的功能入手先从列表和详情这两个页面开始。它们能让你用最低的成本实践完整的数据流、路由和状态管理闭环建立起对这套跨端方案的信心。等这两页跑熟了再挑一个原生能力逐步啃你会发现剩下的路其实没有想象中那么难。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →