FEATURED · 精选文章

Flutter for OpenHarmony门禁App实战:入口初始化与主导航架构解析

发布时间 / 2026/9/19 21:24:26
来源 / 创域科博编辑部
栏目 / 资讯中心
Flutter for OpenHarmony门禁App实战:入口初始化与主导航架构解析 做这个小区的门禁管理App我一开始是有点犹豫的。门禁这个场景牵扯到的硬件链路非常长蓝牙、NFC、二维码、远程开门、物业后台、访客管理任何一个环节没处理好用户的第一体验就直接崩掉。但犹豫之后还是决定用 Flutter for OpenHarmony 来做原因很简单团队手里同时有 Android 和鸿蒙两拨设备要覆盖而 Flutter 在鸿蒙生态上的适配已经跑通了一套 UI 代码两边复用开发效率能省下差不多一半。这篇博文就把我从零搭建这套门禁管理 App 的完整过程写下来重点讲应用入口和主导航这两块的实现思路、选型原因和最终落地代码也把我在 OpenHarmony 上踩过的那些坑一并整理出来适合正在评估 Flutter 在鸿蒙设备上做实际项目的同学参考。这个项目不是那种“能跑就行”的 Demo而是真的要投给小区物业使用的 App。所以应用入口要处理登录态恢复、蓝牙权限申请、推送初始化这些脏活主导航要解决页面保活、路由跳转、跨模块通信这些基础架构问题。这篇文章不会只贴代码我会把每一步为什么要这么做的逻辑也讲清楚尽量让看完的人能直接照着搭一套自己的门禁应用骨架。1. 实战背景与技术选型思路1.1 门禁管理App到底在解决什么问题先花点篇幅把业务讲清楚因为门禁 App 的“入口”和“导航”跟普通工具类 App 不太一样很多设计都是被业务逼出来的。传统的小区门禁是刷卡、刷钥匙、按密码物业需要给每个住户发实体卡访客来了要么让保安登记要么住户下楼接。这套流程对住户和物业都不友好尤其是老旧小区门禁卡丢失、复制、访客冒充的问题一直存在。门禁管理 App 要解决的就是把“门禁凭证”和“访客授权”数字化住户用手机开门访客由住户远程授权或者生成临时二维码物业在后台能看到所有通行记录。具体到 App 端核心功能大概有这么几块首页展示住户关联的门禁设备列表点击就能开门支持蓝牙开门、二维码开门、远程云端开门三种方式通行记录页展示自己的开门历史访客管理页用来邀请访客、生成限时二维码物业公告、报修之类的扩展功能挂在“我的”或者首页入口里。这意味着主导航里至少要有四个常驻 Tab首页、记录、访客、我的。页面之间的关系不是简单的平级跳转首页会推到蓝牙连接页、二维码生成页、编辑门禁页记录页也会推到详情页。路由体系要能支撑这种“Tab 内嵌套栈”的模型这是主导航设计里最需要提前想清楚的地方。1.2 为什么选Flutter作为OpenHarmony的跨端方案选型的时候其实对比了三套方案ArkTS 原生开发、React Native、Flutter。这里不评价谁绝对好只说我当时的判断标准。第一团队人力有限。我们的设备端覆盖安卓和鸿蒙如果两边各写一套原生界面光 UI 联调的时间就翻倍。Flutter 的渲染层是自绘的不管是跑在 Android 还是 OpenHarmonyUI 表现基本一致这对我这种小团队太重要了。第二Flutter 在 OpenHarmony 上的适配已经能用了。OpenHarmony SIG 组织维护了一个 flutter_flutter 的 fork 分支专门适配鸿蒙内核和 Ability 生命周期。社区的 flutter_ohos 项目也在持续迭代像 PlatformView、MethodChannel 这些关键能力都通了能满足门禁这种中重度交互 App 的需求。第三生态和招聘市场。Flutter 开发者比 OpenHarmony 原生开发者好找得多我们团队内部补 Flutter 的技术栈也快。至于 ArkTS 原生它在鸿蒙上的性能确实最好但如果只为了一个平台的性能去多写一套代码对门禁这种工具型产品来说性价比不高。这里要特别提醒一点跑 OpenHarmony 的 Flutter 不能用官方 Flutter SDK得用 OpenHarmony 适配版本。我建议用 FVM 做多版本 Flutter 管理在项目根目录的 .fvmrc 里固定 SDK 版本否则团队里有人用官方版本、有人用适配版本很容易出现“我本地能跑你本地跑不起来”的尴尬。1.3 项目总体架构与目录规划门禁 App 虽然功能量不算巨大但会持续迭代所以目录结构我一开始就按 Feature 分层来规划没有把所有页面堆在一个文件夹里。lib/ main.dart # 应用入口初始化后启动 App app.dart # MaterialApp.router 配置 core/ router/ # GoRouter 路由配置 services/ # 蓝牙、扫码、推送、日志等服务 storage/ # 本地存储封装 utils/ # 通用工具函数 features/ auth/ # 登录、登录态恢复 home/ # 首页、门禁卡片、快捷开门 records/ # 通行记录 invite/ # 访客邀请 mine/ # 我的、设置 community/ # 物业公告、报修等扩展功能 shared/ widgets/ # 公共组件如加载态、空态、错误重试 theme/ # 主题、颜色、字体这样划分的好处是每个 feature 内部可以独立维护自己的页面、状态、服务代理不会出现一个几百行的 xxx_controller.dart 文件。后续加新功能比如缴费、智能家居联动直接新增一个 feature 目录就行主项目的改动会非常小。2. 应用入口落地与初始化链路2.1 main函数里到底要做什么Flutter 应用的入口就是 main.dart 里的 main() 方法。很多入门教程会在 main 里直接 runApp但门禁 App 不能这么干因为 App 启动时有一堆异步初始化要做顺序错了就会在首屏直接崩溃。先看我的入口代码import package:flutter/material.dart; import package:flutter/services.dart; import app.dart; import core/services/dependencies.dart; import core/services/log_service.dart; Futurevoid main() async { // 1. 确保 Flutter 引擎绑定拿到底层能力 WidgetsFlutterBinding.ensureInitialized(); // 2. 设置系统 UI 样式状态栏、导航栏 SystemChrome.setSystemUIOverlayStyle( const SystemUiOverlayStyle( statusBarColor: Colors.transparent, statusBarIconBrightness: Brightness.dark, systemNavigationBarColor: Colors.white, ), ); // 3. 注册全局异常兜底 await LogService.init(); FlutterError.onError (details) { LogService.report(details.exceptionAsString(), details.stack ?? StackTrace.current); }; PlatformDispatcher.instance.onError (error, stack) { LogService.report(error.toString(), stack); return true; }; // 4. 初始化业务依赖 await Dependencies.init(); // 5. 启动 App runApp(const AccessApp()); }这里有几个容易被忽略的点第一WidgetsFlutterBinding.ensureInitialized() 必须最先调用。它负责把 Flutter 的 Binding 层和平台通道绑定起来后续调用 MethodChannel 或者 SystemChrome 之前一定要确保已经初始化否则会直接报“Binding has not yet been initialized”。第二SystemChrome 的配置要尽量放在 runApp 之前。门禁 App 有扫一扫页面、二维码展示页不同页面对状态栏颜色的要求差别很大基础值在入口设置好后具体页面再按需覆盖。如果入口不配启动首帧会出现状态栏字体颜色跟背景融在一起的问题。第三main 方法用 async 的话记得在 runApp 前的每个 await 都做好错误处理。门禁场景最常见的问题就是蓝牙服务初始化失败如果失败直接 throwApp 就卡在白屏了。我后面单独做了 Dependencies.init 的降级策略。2.2 登录态、推送、蓝牙依赖的初始化顺序Dependencies.init 的顺序是我调过好几轮才定下来的顺序错了会导致用户启动后进入一个很怪的状态。先看代码import package:shared_preferences/shared_preferences.dart; import ../services/auth_service.dart; import ../services/ble_service.dart; import ../services/push_service.dart; import ../services/location_service.dart; class Dependencies { static Futurevoid init() async { // 1. 本地存储最先 final prefs await SharedPreferences.getInstance(); // 2. 登录态恢复 final authService AuthService(prefs); await authService.restoreSession(); // 3. 业务服务初始化不阻塞关键路径 final futures Futurevoid[]; if (await BleService.checkPermissionAndEnable()) { futures.add(BleService.instance.init()); } futures.add(PushService.instance.init()); futures.add(LocationService.instance.init()); await Future.wait(futures, eagerError: false); } }为什么是“本地存储 - 登录态 - 业务服务”这个顺序本地存储放第一没有争议因为后面的所有服务基本都要读配置。登录态放第二是因为导航的第一个分支就要用如果本地有 token 且未过期直接进主导航如果没有或者过期进登录页。这个判断必须在 runApp 之前完成否则用户会先看到一闪而过的登录页再被跳到主页体验非常糟糕。蓝牙、推送、定位这几个服务的初始化我用了 Future.wait 并行处理并且把 eagerError 设为 false。原因是它们之间其实没有强依赖关系并行初始化能缩短冷启动时间。蓝牙初始化失败只影响首页开关门功能不影响记录页和访客页推送失败也只是收不到通知App 主体还是能用的。如果任何一个失败就整体崩掉那这个设计就是脆弱的。注意Future.wait 的 eagerError: false 是默认行为但在 OpenHarmony 上我遇到过异步任务抛异常导致后续任务不执行的情况。建议每个 service 的 init 方法内部自己 catch 住异常把失败原因上报到日志中心然后正常返回。这样未来排查问题有据可查也不影响其他服务的初始化。2.3 入口的异常兜底与日志采集门禁 App 是给住户高频使用的一旦出现“打开就闪退”的问题用户会直接去应用商店打差评。所以入口的异常兜底是我专门花时间做的一部分。Flutter 侧的异常主要分两类Dart 层未捕获异常和 Flutter 框架层异常。我在 main 里同时挂了 FlutterError.onError 和 PlatformDispatcher.instance.onError 两个钩子FlutterError.onError (details) { LogService.report(details.exceptionAsString(), details.stack ?? StackTrace.current); }; PlatformDispatcher.instance.onError (error, stack) { LogService.report(error.toString(), stack); return true; // 表示已处理不让应用直接挂掉 };这里有个细节PlatformDispatcher.instance.onError 的返回值如果是 true表示这个异常已经被处理了应用不会退出返回 false 的话就会走系统默认的崩溃流程。对门禁这种工具 App我的策略是能救就救尽量不主动退出。当然如果异常发生在初始化阶段导致业务不可用我准备了另一个兜底策略启动一个“降级模式页面”告诉用户“App 初始化失败请重启或联系物业”而不是单纯地黑屏。日志采集这里我要多提一句。门禁 App 跟硬件交互多很多问题在开发机上复现不了只能在用户现场看日志。所以我在 LogService 里做了本地文件滚动写入同时支持把日志带上设备信息上传到服务器。日志一定要带上时间戳、设备型号、Flutter 版本、OpenHarmony 版本、内存占用这些上下文不然排查蓝牙连接失败时根本无从下手。3. 主导航架构设计与状态保持3.1 路由方案怎么选GoRouter还是原生Navigator导航方案我是认真对比过 Navigator 1.0 和 GoRouter 的。说实话小项目用 Navigator 1.0 完全没问题门禁 App 规模也不大但考虑到日后要加很多二层三层页面而且要做深链跳转比如物业通知点进去直接到某条记录详情我最终选了 GoRouter。GoRouter 是基于 Navigator 2.0 的声明式路由库核心价值是路由和 UI 解耦。你可以把整个 App 的路由表集中定义页面跳转通过路径和参数来驱动而不是每个页面手动 Navigator.push。这样一来深链、重定向、页面NotFound处理都很好做也不用在每一层页面里去传递回调。我的路由配置大概长这样import package:go_router/go_router.dart; final GoRouter appRouter GoRouter( initialLocation: /home, redirect: (context, state) { final authService AuthService.instance; final isLoggedIn authService.isLoggedIn; final isLoginRoute state.matchedLocation /login; if (!isLoggedIn !isLoginRoute) { return /login; } if (isLoggedIn isLoginRoute) { return /home; } return null; }, routes: [ GoRoute( path: /login, builder: (context, state) const LoginPage(), ), ShellRoute( builder: (context, state, child) HomeShell(child: child), routes: [ GoRoute(path: /home, builder: (context, state) const HomePage()), GoRoute(path: /records, builder: (context, state) const RecordsPage()), GoRoute(path: /invite, builder: (context, state) const InvitePage()), GoRoute(path: /mine, builder: (context, state) const MinePage()), ], ), GoRoute( path: /devices/:deviceId/detail, builder: (context, state) DeviceDetailPage(deviceId: state.pathParameters[deviceId]!), ), GoRoute( path: /records/:recordId, builder: (context, state) RecordDetailPage(recordId: state.pathParameters[recordId]!), ), ], );这里用 ShellRoute 是 GoRouter 实现底部导航 Tab 的标准做法。ShellRoute 里的 child 就是当前子路由对应的页面而外层框架底部导航栏、页面安全区处理保持不变。这样切 Tab 时不会整个页面都重建只有内部内容变化。3.2 底部导航栏实现与页面保活主导航的 UI 层我用的是 Material 3 的 NavigationBar搭配 IndexedStack 做页面保活。为什么用 IndexedStack因为门禁首页在蓝牙连接成功后可能存在一个已连接的设备状态如果用户切到“记录”再切回来首页重新初始化的话就要重新扫描蓝牙、重新连接不仅慢还会闪一下加载态。保活是必须的。看 HomeShell 的完整实现import package:flutter/material.dart; import package:go_router/go_router.dart; class HomeShell extends StatefulWidget { final Widget child; const HomeShell({super.key, required this.child}); override StateHomeShell createState() _HomeShellState(); } class _HomeShellState extends StateHomeShell { int _currentIndex 0; void _onDestinationSelected(int index) { setState(() _currentIndex index); // 同步路由保持 tab 与地址栏一致 switch (index) { case 0: context.go(/home); case 1: context.go(/records); case 2: context.go(/invite); case 3: context.go(/mine); } } override Widget build(BuildContext context) { final routes [/home, /records, /invite, /mine]; final currentIndex routes.indexOf(GoRouterState.of(context).matchedLocation); final index currentIndex -1 ? _currentIndex : currentIndex; return Scaffold( body: IndexedStack( index: index, children: [ // 这四个子页面由 ShellRoute 统一接管 for (final route in routes) _buildTabPage(route), ], ), bottomNavigationBar: NavigationBar( selectedIndex: index, onDestinationSelected: _onDestinationSelected, destinations: const [ NavigationDestination( icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: 首页, ), NavigationDestination( icon: Icon(Icons.receipt_long_outlined), selectedIcon: Icon(Icons.receipt_long), label: 记录, ), NavigationDestination( icon: Icon(Icons.person_add_alt_outlined), selectedIcon: Icon(Icons.person_add_alt), label: 访客, ), NavigationDestination( icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: 我的, ), ], ), ); } Widget _buildTabPage(String route) { return RouterWidget(builder: (context) { switch (route) { case /home: return const HomePage(); case /records: return const RecordsPage(); case /invite: return const InvitePage(); default: return const MinePage(); } }); } }这里有一个需要注意的点底部导航的选中状态要和 GoRouter 的当前路由保持同步不能只靠 setState 维护 _currentIndex。因为用户可能通过深链直接跳转到 /records这时底部导航要自动高亮“记录”Tab而不是还停在“首页”。我在 build 里用 GoRouterState.of(context).matchedLocation 反推出当前索引保证 UI 和路由永远一致。IndexedStack 的副作用也要提前了解所有子页面会一次性全部 build只是不显示而已。如果四个 Tab 页面里都有比较重的初始化逻辑首帧会有性能压力。我的处理是首页这种老牌页面正常加载记录页和访客页的数据请求并不在 build 里触发而是通过 StatefulWidget 的 initState 配合“延迟加载”来做只有等页面第一次可见时才真正拉数据。实现上可以用一个 VisibilityDetector 或者简单的 “是否已激活” 标记避免启动时同时发 4 个网络请求。3.3 页面间跳转、参数传递与跨模块通信主导航搭好之后剩下的就是业务页面之间的跳转和数据通信。GoRouter 的页面跳转分两种context.go 和 context.push。go 会替换当前路由栈适合 Tab 切换这种场景push 会压入新的路由适合“首页 - 设备详情 - 设置界面”这种需要返回的页面栈。门禁场景里最典型的跳转链路是首页门禁卡片点击 - 设备详情页 - 蓝牙连接页/二维码展示页。参数传递我用的是 GoRouter 的 pathParameters 和 queryParameters// 首页点击某个门禁设备 context.push(/devices/${device.id}/detail); // 详情页里读取参数 final deviceId state.pathParameters[deviceId];如果参数是一次性的、跨页面的复杂对象比如用户刚刚从蓝牙扫描页选中的设备模型我建议不要硬塞到路由参数里而是用一个共享的 “临时状态容器” 来传递class DeviceTransfer { static DeviceModel? selectedDevice; }为什么这样做因为路由参数本质上是字符串不适合穿对象而且门禁设备的模型里有很多字段传参会变得很啰嗦。临时状态容器在进程活着的时候一直有效如果用户在详情页杀掉 App 再重进反正也要重新选设备这种临时状态丢失是合理的。跨模块状态管理我用的是 Riverpod。选它的原因一是轻量没有 Boilerplate二是它在路由和业务逻辑分离后天然适合做“Provider 覆盖”这种模式。比如首页的蓝牙连接状态我用一个 StateNotifierProvider 管理首页 Widget 只是监听它真正的连接逻辑在 service 层final bleConnectionProvider StateNotifierProviderBleConnectionController, BleConnectionState((ref) { return BleConnectionController(); }); class BleConnectionController extends StateNotifierBleConnectionState { BleConnectionController() : super(const BleConnectionState.disconnected()); Futurevoid connect(String deviceId) async { state const BleConnectionState.connecting(); try { final result await BleService.instance.connect(deviceId); state result ? const BleConnectionState.connected() : const BleConnectionState.disconnected(); } catch (e) { state BleConnectionState.error(e.toString()); } } }这样导航只管跳转业务逻辑集中在 service 层页面只管渲染和事件分发。后面如果要接新的门禁品牌硬件比如从蓝牙换到 NFC 或者超宽带只需要改服务层实现页面和路由完全不用动。4. 门禁核心功能页的实战拆解4.1 首页门禁卡片与快捷开门首页是整个 App 的门面也是高频操作入口。我的首页布局是顶部显示住户所在小区和当前天气下面是一个支持左右滑动的门禁卡片列表每张卡片上有关键的门禁设备信息和 “开门” 按钮右下角是一个悬浮的 “扫一扫” 按钮。核心交互在“开门”按钮上。点击后的流程是检查当前门禁设备的可用开门方式蓝牙/NFC/远程/二维码展示操作底部弹窗。如果选择蓝牙开门调用 BleService扫描并连接到门禁设备发送开门指令。如果选择远程开门调用后端接口后端再向门禁控制器下发指令。开门结果通过 Toast 或者弹窗反馈同时写入本地记录。开门按钮的代码大概长这样Futurevoid _handleOpenDoor(DeviceModel device) async { setState(() _openingDeviceId device.id); try { final result await _openDoorService.open(device); if (!mounted) return; if (result.success) { _showMessage(开门成功); ref.read(recordsProvider.notifier).refresh(); } else { _showMessage(result.errorMessage ?? 开门失败请重试); } } finally { if (mounted) { setState(() _openingDeviceId null); } } }这块有几个优化细节门禁卡片的开关门按钮不能用普通的 TextButton要给它一个比较大的点击热区因为住户可能在电梯里单手操作开门过程中按钮要变成 loading 状态并禁用防止重复点击产生多次开门指令开门成功或者失败都要有明确的视觉反馈最好配合震动。关于蓝牙开门我额外做了一层“自动重连”逻辑如果 App 检测到之前连接过的门禁设备在附近就在首页卡片上显示“已连接”状态用户点击开门按钮时不再走扫描流程直接发送指令。这个优化能明显提高开门的响应速度用户体感从“点击后等 3 秒”变成“秒开”。4.2 通行记录页与数据加载状态处理通行记录页是一个典型的分页列表需要展示时间、门禁位置、开门方式蓝牙/远程/二维码、开门结果。这个页面单独拿出来讲是因为列表页的数据加载状态太多处理不好就会闪屏、卡顿、白屏。我的实现方案是使用分页加载 下拉刷新 错误重试代码结构如下class RecordsPage extends ConsumerStatefulWidget { const RecordsPage({super.key}); override ConsumerStateRecordsPage createState() _RecordsPageState(); } class _RecordsPageState extends ConsumerStateRecordsPage { final _scrollController ScrollController(); int _page 1; bool _hasMore true; bool _loading false; override void initState() { super.initState(); _scrollController.addListener(_onScroll); Future.microtask(() ref.read(recordsProvider.notifier).loadFirstPage()); } void _onScroll() { if (_scrollController.position.pixels _scrollController.position.maxScrollExtent - 200) { _loadMore(); } } Futurevoid _loadMore() async { if (_loading || !_hasMore) return; setState(() _loading true); await ref.read(recordsProvider.notifier).loadNextPage(); setState(() _loading false); } override Widget build(BuildContext context) { final state ref.watch(recordsProvider); return Scaffold( appBar: AppBar(title: const Text(通行记录)), body: switch (state.status) { RecordListStatus.loading const Center(child: CircularProgressIndicator()), RecordListStatus.error Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text(state.errorMessage ?? 加载失败), const SizedBox(height: 12), FilledButton( onPressed: () ref.read(recordsProvider.notifier).loadFirstPage(), child: const Text(重新加载), ), ], ), ), RecordListStatus.empty const Center(child: Text(暂无通行记录)), RecordListStatus.success RefreshIndicator( onRefresh: () ref.read(recordsProvider.notifier).refresh(), child: ListView.separated( controller: _scrollController, itemCount: state.records.length (_hasMore ? 1 : 0), separatorBuilder: (_, __) const Divider(height: 1), itemBuilder: (context, index) { if (index state.records.length) { return const Padding( padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator()), ); } final record state.records[index]; return RecordListItem(record: record); }, ), ), }, ); } }这里利用了 Dart 3 的 switch 表达式状态枚举清晰代码比 if-else 舒服很多。还要注意分页的防抖——_loading 标记要卡在发起请求前置为 true否则用户快速滚动到列表底部会同时触发好几个分页请求页面会闪好几下 loading。通行记录这个页面的另外一个要求是数据准确性开门记录是从后端接口拉取的可能存在延迟比如云端开门指令刚发出去后端还没记录成功。所以我在首页开门成功后会调用 refresh() 强制刷新一次列表同时在列表页用下拉刷新让用户手动同步。4.3 访客邀请与物业通知等扩展模块访客邀请模块的流程是住户录入访客手机号和姓名设置门禁权限的有效时间段系统生成一个限时二维码住户通过微信发给访客。访客到小区门口时保安扫码或者访客自己扫码进入。这个模块在导航层面就是“访客”Tab内部包含两张页签我的邀请、访客记录。实现思路跟记录页类似多了一个生成二维码的页面。二维码用 qr_flutter 库生成关键是把过期时间写到二维码内容里服务端扫码时统一做校验不要在客户端只显示一个不过期的二维码那样容易出安全问题。物业通知这块我把它放在“我的”页面的通知列表里而不是单独做一个 Tab。它本质上是一个公告信息流调用后端接口拉取物业发布的公告点击公告跳到详情页。如果公告里绑定了某些业务动作比如“请在线缴纳停车费”详情页里可以跳转到支付页面。这种因为业务状态触发的深链GoRouter 的 redirect 机制就派上用场了。5. 常见问题排查与OpenHarmony适配实录5.1 渲染、生命周期与插件兼容问题OpenHarmony 上跑 Flutter跟 Android 最大的感受就是“大部分能跑但细节要修”。我最先遇到的是画面渲染异常启动后有概率出现首帧黑屏或者局部花屏尤其在切换后台再回来的场景。排查下来问题出在 OpenHarmony 的 Flutter 适配层对图形栈的处理跟 Android 不太一样Raster 线程在这种场景下有概率没来得及重建帧缓冲。针对这个问题的处理方案把 OpenHarmony 的 Flutter 适配分支升级到了社区修复该问题的版本。在生命周期回到前台时手动触发 Flutter 引擎重绘一次WidgetsBinding.instance.addObserver( LifecycleEventHandler( onResume: () { // 低频概率下通过 setState 或 navigate 触发重建可以缓解花屏 SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); }, ), );在 OpenHarmony 上关闭 Impeller强制使用 Skia 渲染。Impeller 是 Flutter 新的渲染引擎虽然在 iOS/Android 上表现很好但在 OpenHarmony 的适配分支上还不够成熟关闭之后渲染稳定性明显提升。插件兼容也是重灾区。门禁 App 用到蓝牙、定位、通知这些能力很多 Flutter 插件在 OpenHarmony 上没有现成实现。我的做法是写一个自己的 MethodChannel 适配层在 Dart 侧定义接口在 ArkTS 侧写原生实现class BluetoothChannel { static const MethodChannel _channel MethodChannel(com.example.access/ble); static Futurebool isEnabled() async { return await _channel.invokeMethodbool(isEnabled) ?? false; } }ArkTS 侧对应实现 MethodChannel 的 handler这样 Flutter 代码不用改底层能力通过桥接交给 OpenHarmony 原生代码处理。虽然工作量比直接用现成插件要大但可控性高很多排查问题也方便。5.2 导航与页面缓存引起的异常导航这块我踩过一个比较隐蔽的坑用 ShellRoute 包了四个 Tab 的子路由之后如果子页面里有自己的 TextEditingController 或者 ScrollController在 Tab 切换时偶尔会报 “A TextEditingController was used after being disposed”。原因是 IndexedStack 虽然保活了页面但如果有代码在页面 dispose 时把 controller 也 dispose 掉了而 IndexedStack 仍然持有这个页面的 Element再次切换回来时 controller 却已经没了。解决办法是不要在 State.dispose 里销毁那些可能被 IndexedStack 复用的 controller而是交给页面自己管理如果需要销毁要在页面真正从树里移除时再做。更稳妥的做法是用 StatefulWidget 的 AutomaticKeepAliveClientMixinclass RecordsPage extends ConsumerStatefulWidget { const RecordsPage({super.key}); override ConsumerStateRecordsPage createState() _RecordsPageState(); } class _RecordsPageState extends ConsumerStateRecordsPage with AutomaticKeepAliveClientMixinRecordsPage { override bool get wantKeepAlive true; override Widget build(BuildContext context) { super.build(context); // ... } }页面显式声明 wantKeepAlive 为 trueFlutter 框架就知道这个页面是需要保持状态的不会在 Tab 切换时触发正常 dispose 流程。这个 mixin 加上之后很多奇怪的控制器被释放的问题就消失了。5.3 门禁蓝牙通信在鸿蒙上的坑蓝牙通信是门禁 App 最依赖的硬件能力也是问题最多的。在 OpenHarmony 上我用蓝牙做 BLE 通信时遇到两个主要问题。第一个是扫描不到设备。OpenHarmony 的蓝牙权限跟 Android 不太一样除了要声明权限还要在设置里打开“附近设备”的开关而且不同厂商对鸿蒙的权限实现有差异。后来我在首次启动时专门做了一个权限引导页把蓝牙权限、定位权限扫描 BLE 需要、附近设备权限一起请求用户拒绝哪一项就高亮提示哪一项避免用户自己瞎找。第二个是连接后通信不稳定表现在偶发的发送指令超时。这个不是 OpenHarmony 独有的但表现更明显。我最终的方案是一个三层重试机制发送指令前先检查 GATT 连接状态断线就重连。发送后等待回调设置 5 秒超时超时则再重发一次。连续两次失败才认为开门失败提示用户“请检查门禁设备是否正常”。同时我把所有指令发送都做了序列化同一时间只有一个指令在发送中避免多指令交错导致门禁设备处理错乱class BleCommandQueue { final _queue BleCommand[]; bool _isSending false; void enqueue(BleCommand cmd) { _queue.add(cmd); _drain(); } Futurevoid _drain() async { if (_isSending) return; while (_queue.isNotEmpty) { _isSending true; final cmd _queue.removeAt(0); try { await BleService.instance.send(cmd); } catch (e) { // 记录失败不阻断后续指令 } finally { _isSending false; } } } }这样处理之后蓝牙开门的成功率从最初的大概 85% 提升到了 99% 以上偶尔的失败也能在用户感知层面被控制在“一次点击没有反应后自动重试”的范围内。门禁 App 这种项目开发期间遇到的问题远远不只在代码层面。很多坑是到了真机测试、到了用户现场才暴露出来的。比如我在某个型号的鸿蒙平板上发现首页门禁卡片的阴影渲染有明显的锯齿排查到最后是那个设备上 Flutter 的 Skia 渲染对某些 GPU 驱动做了软件回退。遇到这种问题别上来就改自己的代码先看看是不是渲染引擎的兼容性问题。根据我的经验做鸿蒙上的 Flutter 项目最值得追加的投资是尽早做真机兼容性测试不要全部开发完再拿给测试那时光适配设备差异就能耗掉两三个星期。我后来把所有团队手里的鸿蒙设备型号拉了一个清单每周五下午统一跑一遍核心流程的 smoke test蓝牙、扫码、导航切换、登录态恢复全部过一遍。这比后期集中排查省太多精力。最后再分享一个小技巧。如果你也在做 Flutter for OpenHarmony 的应用把 FVM 用的 SDK 版本、OpenHarmony 适配仓库的版本精确地记录在 README 里最好连 hash 和验证日期都写清楚。这个项目我中间停了两周再回来时 SDK 已经被队友升级过了结果构建出一堆莫名其妙的问题后来靠 README 里的版本号才回退到可用的组合。这套经验不只是门禁 App 适用所有跨端项目都一样环境版本是最容易忽略、也最容易爆雷的隐性依赖。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻