
前几天一个做手工社群的朋友跟我吐槽说他们在后台经常收到你们城市有没有拼豆店的咨询。拼豆这个玩法最近两年在线下悄悄火了起来不少商场都开了实体体验店但信息分散在点评、小红书、商圈公众号里用户想找个离家近的店得来回切换好几个App。他想让我帮忙做一个全国拼豆实体店查询应用给到店用户自查询使用。我这个常年用 Flutter 做跨平台开发的人第一反应就是用 Flutter 一套代码Android、iOS、鸿蒙、Web 四个端全包了。这篇文章就完整拆解这个项目的开发全过程从需求拆解、技术选型、核心功能实现到鸿蒙适配和打包上架全程都是实操记录特别适合正在学 Flutter、想了解跨平台和鸿蒙开发的朋友。这个应用核心就三件事告诉用户身边哪里有拼豆实体店、门店长什么样能做什么、怎么去。更具体一点就是按城市/定位找门店、看门店详情、跳导航。别小看这三件事涉及的技术点从状态管理到原生通信从地图SDK到数据库管理几乎把 Flutter 跨平台开发的常见知识点全串起来了。下文会按照我做这个项目的真实顺序来讲一边讲思路一边给可以直接抄的代码和配置。无论你是刚入门的 Flutter 新手还是想接鸿蒙开发的老手都能从里面找到能用的东西。先讲一下适合谁参考如果你正准备做一个XX本地生活查询/门店查询类应用或者手头有个 Flutter 项目需要对鸿蒙做适配这篇文章可以当一份实战参考如果你只是对跨平台技术好奇那也能从中看到 Flutter 在真实项目里是怎么组织代码、怎么跟原生系统打交道的。我习惯把话讲直一点能少踩一个坑就少踩一个坑咱们开始正题。1. 项目概述与需求拆解1.1 先搞清楚拼豆实体店查询应用到底在解决什么问题拼豆Perler Beads这种手工DIY在海外流行了很多年最近几年才在国内商场里逐渐铺开。它的形态大致分三类一类是纯卖材料工具的零售店一类是提供场地和模具的DIY体验店还有一类是接定制订单的工作室。问题在于这三类门店信息极度分散平台的评价和位置数据也不完整很多拼豆爱好者只能靠圈子里的口口相传。我做这个应用的第一个动作不是写代码而是做需求梳理把用户寻找拼豆实体店的全过程画了一遍。最常见的路径是打开地图搜拼豆→ 发现结果不全 → 切到小红书看探店笔记 → 再切回地图导航。整个流程至少要三个App来回跳而且地图上搜出来的结果还经常混入拼豆同名但不同内容的店铺。所以产品的核心价值很简单一个入口把全国拼豆实体店的信息统一管理起来让用户按位置、按城市就能查到靠谱的门店并且能够一键导航到店。基于这个定位MVP最小可行版本功能我最终收敛成五个城市定位与门店列表、关键词搜索、按距离排序、门店详情页、跳转第三方地图导航。这个功能清单看着不大但每个功能背后都有值得展开的技术细节后面按模块逐个拆。有一点需要提前说明内容型查询应用最容易翻车的地方其实是数据本身门店名称、地址、营业时间、电话任何一项错了用户到店扑空下一次就不会再打开你的App了。所以数据核验在这个项目里的优先级比界面交互还要高。1.2 为什么选 Flutter 而不是 uni-app 或原生标题里既然写了Flutter 框架跨平台开发这里就多说两句选型的理由。做跨平台有很多条路Java/Kotlin 原生、uni-app、React Native、Flutter。我的选型逻辑主要看三个维度渲染一致性和性能、生态成熟度、对鸿蒙的适配进度。第一Flutter 是自绘渲染引擎不依赖系统自带的控件体系这意味着同一套界面在 Android、iOS、鸿蒙上渲染出来的效果几乎一模一样不会出现左边系统风格、右边自绘风格的割裂感。特别是拼豆这种色彩丰富、卡片密集的界面UI 一致性对用户体验影响非常大。第二Flutter 的 pub.dev 生态非常全地图、定位、状态管理、数据库、图表都有现成方案。第三也是最有意思的一点——鸿蒙生态。Flutter 社区和 OpenHarmony 生态这几年一直在推进 Flutter 对鸿蒙系统的适配现在 Flutter 工程已经可以编译出鸿蒙的 hap 包配合 DevEco Studio 完成签名和上架。这意味着我用同一套代码可以同时覆盖 Android、iOS、鸿蒙和 Web。对比一下 uni-app如果主要做微信小程序uni-app 确实方便但如果你的目标平台里原生 App 体验和鸿蒙适配权重更高Flutter 的编译性能和渲染一致性明显更有优势。而且从工具链来看Flutter 的调试体验热重载热重启、DevTools 性能分析是跨平台框架里做得最成熟的。当然Flutter 也不是没有代价Dart 语言需要学习、原生插件偶尔要自己封装、鸿蒙端一些能力还没完全对齐 Android。但这些代价在一套代码多端上线的收益面前基本可以接受。2. 技术选型与架构设计2.1 整体分层的架构思路这个项目我没有上来就写页面而是先搭了一个轻量级的分层架构目的很简单后面如果加入预约课程、会员积分等功能不用再把代码推倒重来。整个工程分四层表现层Widget Cubit负责页面渲染和状态刷新业务层Repository 接口屏蔽数据来源到底是本地数据库还是远端 API数据层SQLite 本地库 REST API 客户端管理门店数据平台层MethodChannel / EventChannel跟 Android、鸿蒙、iOS 的原生能力通信。为什么用 Cubit 而不是 Bloc 或者 Provider因为这个项目状态并不复杂Cubit 是 Bloc 的轻量版本API 直观一个类对应一个业务状态没有 Bloc 那么多模板代码。社区里经常有人纠结 Flutter 状态管理选哪个我的习惯是小型工具应用用 Cubit / Provider 就够了只有页面间共享状态非常复杂、需要跨模块监听时才上 Bloc、Riverpod 或 GetX。工具型应用引入太重状态管理是典型的过度设计。门店数据的后台我这边直接用 SpringBoot 搭了一个轻量的管理 API内部管理端用了若依框架做数据维护App 通过 REST 接口拉取最新门店数据。这里想强调的是数据管理和客户端一定要解耦否则改一个门店营业时间都要重新发版运营效率太低了。当然如果你只是个人开发不想要后端也可以把门店数据做成一份 JSON 内置到 App 里首次启动时写入 SQLite后面再通过远程配置做增量更新一样可行。2.2 数据库与门店数据模型设计门店表的结构决定了后续所有查询逻辑怎么写。我最终定的字段有id、name、city、address、lat、lng、phone、open_time、close_time、tags、cover、rating。其中 lat/lng 是 Double 类型也就是经纬度tags 用逗号分隔的字符串存比如亲子,教学,材料齐全cover 存封面图 URL。城市字段为什么单独建而不是直接从 address 里截取因为应用里的按城市筛选功能需要按城市做精确分组如果靠字符串模糊匹配很容易出问题比如北京和北京市混在一起。我在 seed 数据时统一了城市标准名称这样一来按 city 分组查询就非常稳定。桌面端准备 SQLite 数据我强烈建议直接用 db4sDB Browser for SQLite这个开源工具。它的界面跟 Excel 有点像可以批量导入 CSV、直观地编辑门店记录比手写一条条 INSERT 语句高效得多。拼豆门店数据来源建议基于公开信息逐一核实整理地址、电话、营业时间必须人工确认过避免用爬虫抓来的脏数据。门店数量到几十上百家的时候这套流程还撑得住如果以后要覆盖上千家就得把数据挪到云端App 端只留一个缓存库。2.3 原生能力桥接MethodChannel 与 EventChannel 分工Flutter 跨平台开发里绕不开的就是跟原生系统的通信。我在这类项目里总结出一条简单规则一次性请求用 MethodChannel持续变化的数据用 EventChannel。MethodChannel请求定位、打开地图导航、获取设备型号和系统版本都是你问我答的模式EventChannel监听定位位置变化、监听应用前后台切换等是原生主动推数据给 Dart的模式。社区里经常有人把 MethodChannel 和 EventChannel 混着用结果要么是回调一直挂着不释放要么是反复注册监听导致消息重复。我的做法是项目里建一个 plugin_layer 目录集中注册所有 Channel并在 App 启动时做初始化。Channel 名字要保持全局唯一比如com.pindou.store/location别随手写一个太通用的名字否则多个插件冲突时排查起来极其痛苦。第 4 节会给出鸿蒙端的具体实现Android 端逻辑类似只是 Kotlin 语法不同。3. 核心功能模块实现3.1 底部导航栏与页面结构应用主体是四个 Tab附近、门店列表、收藏、我的。底部导航我直接用 Material 3 的 NavigationBar 组件配合 IndexedStack 来做页面切换。这里有一个很关键的细节如果你直接用 Navigator.push 的方式加载页面或者切换 Tab 时简单替换 body切回来的时候页面状态会丢。比如用户正在门店列表里往下滑到第 30 条切到收藏再切回来列表直接回到顶部这个体验是灾难级的。所以我用了 IndexedStack四个 Tab 的页面在 App 启动时就全部构建好切换只是改变索引不销毁状态。这也是Flutter navigator 切换页面后丢失状态的标准解法之一。Scaffold( body: IndexedStack( index: _currentIndex, children: const [NearbyPage(), StoreListPage(), FavoritePage(), MinePage()], ), bottomNavigationBar: NavigationBar( selectedIndex: _currentIndex, destinations: const [ NavigationDestination(icon: Icon(Icons.near_me_outlined), label: 附近), NavigationDestination(icon: Icon(Icons.store_outlined), label: 门店), NavigationDestination(icon: Icon(Icons.favorite_outline), label: 收藏), NavigationDestination(icon: Icon(Icons.person_outline), label: 我的), ], onDestinationSelected: (index) setState(() _currentIndex index), ), )顺手说一个偏门需求有人问 Flutter TabBar 点击取消动画效果。TabBar 默认会有一个切换动画如果产品经理要求点了立刻换不要转场动画最直接的办法是给 TabBar 加physics: const NeverScrollableScrollPhysics()关掉滚动再用 AnimatedSwitcher 控制切换动画时长。这类细小的动效问题在跨平台开发里往往是拉开体验差距的地方。3.2 门店列表、搜索与筛选的 Cubit 实现列表页是这个应用使用频率最高的页面。我用 Cubit 管理门店列表状态整个逻辑比直接用 setState 清晰很多。Cubit 暴露一个 StoreListState里面包含 loading、stores、keyword、selectedCity、sortType 等字段UI 跟它做绑定。搜索功能我加了一个防抖用户在输入框里每敲一个字就触发一次查询如果门店多了会卡。我把 Timer 和 Debounce 封装到 Cubit 的 onKeywordChanged 方法里停止输入 300ms 后才真正执行 loadStores。这个细节在实际使用中感知非常强——尤其是手机输入法联想频繁触发文本变化时防抖能避免列表反复刷新导致的白屏闪烁和输入卡顿。筛选维度上我提供了城市选择器和标签筛选。城市用 showModalBottomSheet 弹出城市列表标签则是几个 Chip 组件。每次筛选条件变化Cubit 内部重新查询本地库把结果 emit 出去。这里要提醒一个容易踩的坑Cubit 用 emit 发布新状态时如果连续两次状态对象的内容完全一样UI 可能不会重新渲染所以我在状态对象里加了一个自增的 refreshToken 字段保证每次筛选都触发 build避免明明点了城市却没有反应的诡异问题。3.3 基于定位的附近门店与距离计算附近页面的核心逻辑是获取用户经纬度计算与门店的距离按距离从小到大排列。定位我优先用 geolocator 插件它会统一处理 Android 和 iOS 的权限流程。鸿蒙端早期版本对 geolocator 的支持还不完整我后来通过 MethodChannel 自己封装了一个 getCurrentPosition 方法鸿蒙侧用位置服务模块返回经纬度。距离计算用的是 Haversine 公式别用简单的欧几里得距离做近似——地球是球体纬度差和经度差在现实距离上完全不等价。我把这个方法写成了公共工具函数double haversineDistance(double lat1, double lng1, double lat2, double lng2) { const double earthRadius 6371.0; double dLat (lat2 - lat1) * pi / 180.0; double dLng (lng2 - lng1) * pi / 180.0; double a sin(dLat / 2) * sin(dLat / 2) cos(lat1 * pi / 180.0) * cos(lat2 * pi / 180.0) * sin(dLng / 2) * sin(dLng / 2); double c 2 * atan2(sqrt(a), sqrt(1 - a)); return earthRadius * c; // 单位公里 }门店列表排序时我只对当前城市或者距离阈值内比如 30 公里的数据做排序避免计算量失控。数据量到几千条的时候在 SQL 查询阶段就把经纬度边界圈好只把候选集交给 Dart 做精确排序这样列表滑动的时候才不会卡顿。另一个细节是定位失败的情况用户关闭了定位权限或者信号差的时候应用必须降级到手动选择城市的模式并且要给出明显的提示不能停在白屏状态。3.4 门店详情页与导航跳转门店详情页我放了地址、营业时间、电话、评分、标签、封面图这些信息顶部用图片轮播。这个页面本身比较常规重点说导航跳转的实现。用户在详情页点击去这里我的处理是先判断手机上是否装了地图类App优先使用第三方地图的 URL Scheme 打开如果没有装地图客户端就打开一个使用地图 Web API 的页面让用户在里面查看路线。方案第一层优先跳原生地图不只是体验好还省流量关键是原生地图的路线规划能力比 Web 页面强得多。跳系统导航的核心就是 url_launcher 插件await launchUrl( Uri.parse(https://uri.amap.com/navigation?to${lng},${lat},${name}modecar), mode: LaunchMode.externalApplication, );这里有个小坑Android 11 之后系统对 URL Scheme 的解析和跳转权限管理变严了部分地图类App的包名和 scheme 也发生过变化需要做多个候选判断。还有如果你的应用要上架应用市场建议给跳转地图增加一个中间确认页避免审核时被认为诱导跳转第三方应用。鸿蒙端的跳转逻辑类似只是目标市场是系统自带地图或第三方鸿蒙版地图写法上没有本质区别。4. 鸿蒙适配与跨端落地4.1 鸿蒙工程的搭建与配置标题里明确写了鸿蒙开发这一节重点展开。现在的 HarmonyOS 应用生态已经不再直接兼容 Android 安装包想在鸿蒙设备上跑 Flutter 应用基本路径是使用 Flutter 官方与 OpenHarmony 社区适配的 Flutter 分支一般叫 harmony 支持分支创建工程时启用 ohos 平台再用 DevEco Studio 打开生成的鸿蒙目录配置签名和打包。实操上的步骤大致是安装支持鸿蒙的 Flutter SDK 分支它会有独立的 Flutter 引擎与 Dart 工具链确保 flutter doctor 能识别到 ohos 工具链在现有 Flutter 工程里执行flutter create --platformsohos .给工程增加鸿蒙平台目录用 DevEco Studio 打开 ohos 目录配置应用签名Debug 可以用自动签名Release 需要到 AGC 后台配置证书在鸿蒙工程里配置 module.json5 的权限声明比如位置权限、网络权限最终执行flutter build hap生成构建产物在 DevEco Studio 里部署到鸿蒙模拟器或真机。这里有一个很容易劝退新手的点鸿蒙依赖的 Flutter 分支版本跟官方主干不一定完全同步如果你同时还要维护 Android 和 iOS 的构建最稳妥的方案是不要让工程同时依赖两套 Flutter SDK 版本而是在构建脚本里分别指定 SDK 路径。我本地是把两套 SDK 分开装的桌面用户尤其要注意环境变量里的 PATH 顺序别让 flutter 命令指向了错误的版本。否则你很可能遇到Android 编译好好的切到鸿蒙分支突然一堆红的问题。4.2 鸿蒙端的 Channel 实现鸿蒙端使用 ArkTS 语言实现 Flutter 插件通道。以我封装的位置服务为例Dart 侧用 MethodChannel 发起 getCurrentPosition 调用鸿蒙侧在 ArkTS 里注册同名 Channel然后调用位置服务模块。代码结构如下// Dart 侧 static const MethodChannel _channel MethodChannel(com.pindou.store/location); final MapString, dynamic position await _channel.invokeMethod(getCurrentPosition); // 鸿蒙侧 ArkTS节选 let channel new MethodChannel(com.pindou.store/location); channel.setMethodCallHandler((call) { if (call.method getCurrentPosition) { let result await getGeoLocation(); call.result(result); } });EventChannel 的鸿蒙端写法类似核心区别是原生侧需要主动维护一个事件流把定位变化持续推给 Dart。鸿蒙的定位服务支持连续定位回调我把它包装成事件流Dart 侧以 StreamSubscription 监听。需要注意EventChannel 在页面进入后台之后一定要在合适的时机取消订阅并释放资源否则会一直唤醒定位模块这是手机上最明显的耗电来源。如果你之前只写过 Android 的 Kotlin Channel初次接触 ArkTS 会发现接口风格非常接近节奏上不会陌生。4.3 鸿蒙打包与上架要点鸿蒙应用打包成 hap 之后上架应用市场需要走 AGC 流程。这里我踩过的坑有三个提前帮你避雷第一签名证书Debug 和 Release 证书不能混用Release 证书需要固定的包名和描述文件配置错了会一直报签名校验失败。第二权限声明鸿蒙对权限的审核比较严格位置权限这类敏感权限必须在 module.json5 里声明而且应用在运行时还要做二次弹窗申请。第三版本号鸿蒙应用的版本号规则和 Android 不完全一样可能需要同时修改多个配置文件漏改一个就会导致上架版本不一致。另外特别提醒不要试图用改后缀、改包名的方式绕过鸿蒙的系统安装机制新版系统对应用类型有识别非正规签名的包会直接被拦下来而且对用户来说体验极差。老老实实走 Flutter 鸿蒙分支构建配合 DevEco Studio 一步步配置反而是一劳永逸的方案。鸿蒙这块的社区资料还在快速增长阶段多翻官方文档比自己闭门造车效率高得多。5. 常见问题与排查实录5.1 Flutter SDK 版本警告与构建失败很多人在环境搭建时会遇到一行提示the current configured flutter sdk is not known to be fully supported. please check。我第一次看到也以为是环境坏了其实这行字的意思是当前工程锁定的 Flutter SDK 版本不在官方已知的支持列表里。这种情况多半是电脑上的 Flutter 和项目的 pubspec.lock 版本不一致或者你用了某个相对新的分支版本。解决思路按优先级排先执行flutter upgrade把 SDK 升到工程需要的稳定版本如果项目是从同事手里移交过来的就检查local.properties或者工程里的 Flutter SDK path 配置确认指向的目录正确如果用的是鸿蒙分支的 Flutter还要额外确认分支版本和主分支版本没有混用。这个警告本质上是一个兼容性提示不处理的话后续构建大概率会遇到各种诡异报错建议第一时间处理干净别拖到构建阶段才排查。5.2 Navigator 页面状态丢失Flutter 初学者问得特别多的问题就是navigator 切换页面后会丢失状态吗。如果用 Navigator.push 进入新页面再返回前一页的状态是保留的但如果页面被销毁比如被 pop 掉、或者被路由系统回收状态就会丢。在 Tab 切换场景里我前面用了 IndexedStack 的解法这里再补充一个场景如果项目使用的是 go_router 做路由管理页面状态丢失的原因通常是路由配置里没有启用状态保留。官方推荐的 StatefulShellRoute 配合 ShellRoute 可以保持多个分支页面的状态本质上也类似 IndexedStack 的思想。另一个常见做法是使用 AutomaticKeepAliveClientMixin在 State 里把 wantKeepAlive 返回 true配合 PageView 或 TabBarView 使用适合列表页面在滑动后保持位置。我的项目里列表页和详情页都做了 keepAlive 处理实测下来滑动位置、筛选条件都能保留。记住一点状态保留不是免费的页面实例常驻内存意味着内存占用也会变大所以在低端机型上要做好取舍不是所有页面都需要无脑 keepAlive。5.3 PlatformView 与地图卡顿门店详情页里我集成过一段地图展示用过 Flutter 原生 UiKitView 的人都知道 PlatformView 是性能重灾区。Android 上 PlatformView 在列表滑动时经常出现白块或黑块鸿蒙上的底层实现机制类似也会有图层合成的开销。我的优化方案是地图不直接内嵌在列表项里而是放到详情页的单独区域用混合合成模式或鸿蒙对应的纹理模式来承载地图组件外围不要套复杂的圆角裁剪和阴影效果减少合成压力。其实还有一个更轻量的方案如果只是展示门店位置不需要用户在地图上做操作就直接用静态地图图片一张带标记的图片比一个交互地图轻得多。我在附近列表的缩略图上用的就是静态地图方案性能提升非常明显。进入详情页后用户才会看到真正可交互的完整地图这个交互设计既保住了体验又避开了性能坑。再说一下 Flutter 的渲染引擎。新版 Flutter 默认启用 Impeller 渲染引擎三维图形管线比旧版在抗锯齿和动画流畅度上都有明显提升我在 Android 真机上明显感觉到列表滚动更跟手了。如果真机上出现渲染异常可以在 main() 里临时关闭 Impeller 做个对照测试先定位是引擎问题还是业务代码问题再决定怎么修。鸿蒙分支的渲染引擎也在持续迭代遇到渲染问题优先检查用量的版本是否最新。5.4 环境搭建与组件库选型如果你从零开始搭 Flutter 环境建议按这个顺序来先确保 JDK、Android SDK 就位再装 Flutter SDK最后接 IDEAndroid Studio 或 VS Code。flutter doctor 的输出是最直观的排障工具它会列出缺什么组件。用 Android Studio 创建 Flutter 项目时注意选对应用的包名和应用签名后面改起来特别麻烦。桌面用户装 Flutter 时必须保证 Flutter 和 Dart 目录在同一个盘符权限不够还会导致插件编译失败所以尽量装在非系统盘的普通目录里。之前有朋友问过安卓原生项目嵌入 Flutter 页面的问题这里顺便说一下Flutter 打包产物可以作为一个 module 集成到原生工程里通过 FlutterEngine 和 MethodChannel 与原生页面互跳。不过这属于混合开发范畴跟纯 Flutter 工程加鸿蒙平台是两条路线这次项目用的是后者但你如果以后要渐进式改造原生应用这个思路是可行的。组件库选型方面我的建议是初期用官方组件加 Cubit 足够等页面多了、状态复杂了再评估接入第三方框架。Flutter 官方 Material 组件已经很完善很多需求在官方库里就有解过早引入杂七杂八的库反而是给自己挖坑。这个项目同时也发布了一个 Flutter Web 版本给门店运营方在后台预览数据用。如果你也发布 Web 版要注意 Flutter Web 首次引擎启动确实偏慢主要原因是引擎脚本和字体资源加载耗时。我后来把资源做了拆分图片全部走 CDN 压缩首屏渲染改善还是很明显的。其实这个项目做到现在我自己最大的体会是跨平台开发最核心的能力不是写 UI而是能不能让一套代码在多个端上表现一致又稳定。Flutter 把 UI 一致性解决得很好但原生能力定位、地图、导航、权限仍然需要开发者在 Android、iOS、鸿蒙三端做桥接适配。特别是鸿蒙这个平台文档和社区案例相对较少很多时候要靠自己读源码、打日志、逐个排查。最后再分享一个实操小技巧开发这类门店查询应用时建议把门店数据集中在一个公开的配置地址里配合数据库版本号做增量更新。每次拿到门店变动信息我只改数据文件和版本号App 端启动时异步拉新数据、自动覆盖本地库不需要每次发版。这样当拼豆行业继续扩张、门店数据不断增长时应用能在不升级 App 的前提下持续保持数据新鲜。另外门店查询应用对包体积也很敏感有空把图片做懒加载和 CDN 压缩整体性能空间其实比想象中大多了。