尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于Flutter与OpenHarmony的剧本杀组队App活动中心开发实践

基于Flutter与OpenHarmony的剧本杀组队App活动中心开发实践 1. 项目定位与整体设计思路1.1 剧本杀组队App的核心需求拆解先明确一件事剧本杀这个场景最核心的用户痛点从来不是“看剧本”而是“凑人”。一个本子动辄五六个人临时缺一两个人就开不了车群里喊半天也没回应这是线下剧本杀店和玩家社群每天都在发生的真实问题。我们做的这个App本质上是把线下拼车的需求搬到线上让玩家能快速浏览近期活动、一键发起组队、自动同步报名状态。活动中心在这个App里承担的角色很重它不只是一个列表页而是整个产品的业务中枢。从功能拆解来看活动中心至少包含四条核心链路活动浏览按时间、剧本类型、门店筛选活动详情展示本子信息、组队进度、队员名单发起组队创建活动、设置人数上限和开本时间以及加入/退出活动动态更新剩余名额。如果这四条链路做不顺后面再多花哨的社交功能都立不住。所以我在项目启动时定的第一原则是先把活动中心的闭环走通再去碰用户体系、支付、评价这些外围模块。开发顺序上我会把活动列表、活动详情、发起组队、加入活动作为第一优先级每一步都保证可以在真机上跑通并验证数据流避免一口气铺太大导致后面返工。1.2 为什么选择 Flutter 加 OpenHarmony 这个组合技术选型这块我调研过几条路线纯 OpenHarmony 原生开发ArkTS、React Native 适配版、Flutter 的 OpenHarmony 分支。最终选了 Flutter原因有几个。第一Flutter 的 OpenHarmony 适配方案已经不是实验性项目了OpenHarmony SIG 组持续在维护 flutter_flutter 仓库的 ohos 分支核心渲染引擎和平台通道都已经跑通。这意味着我可以用一套 Dart 代码同时覆盖 Android、OpenHarmony 甚至 iOS如果后续需要团队不需要维护两套 UI 代码剧本杀这种创业团队最缺的就是人力跨端复用是实打实的省钱。第二Flutter 在 UI 表现力上更合适。剧本杀App的视觉风格偏年轻化活动卡片需要展示封面图、剧本标签、拼车进度条、倒计时这些元素Flutter 的 widget 组合和自定义绘制能力做这类界面很顺手而且滚动性能在低端机上也比同价位的 WebView 方案流畅。第三ArkTS 原生开发当然有它的优势但对团队里熟悉 Dart 的成员来说Flutter 的学习曲线更平缓。OpenHarmony 的 ArkTS 框架还在快速迭代中第三方组件生态远不如 Flutter 丰富我们在开发中经常要用到日期选择器、图片缓存、二维码生成这类能力Flutter 生态里现成的 package 直接拿来就能用省掉大量造轮子的时间。需要说清楚的是Flutter 在 OpenHarmony 上并非没有问题渲染引擎部分功能、平台插件的覆盖度、调试工具链的成熟度都还在追赶 Android 原生。但只要理解它在哪一层是通的、在哪一层需要自己补这个组合完全值得投入。1.3 整体架构设计项目架构上我采用的是分层设计从上到下分别是 UI 层、状态管理层、数据层每层之间通过明确的接口通信不跨层依赖。UI 层只负责渲染 widget 和派发用户事件不直接写网络请求状态管理层用 Cubit 接收 UI 事件、调用数据层接口、产出状态数据层统一封装网络、本地缓存和平台通道。模块划分上按业务边界拆成四个目录activity活动中心核心功能、user用户信息与登录态、common通用组件、工具类、路由表、data网络层、模型层、仓库实现。这样后续加新模块时不会动到已有功能的内部结构改起来也敢下手。依赖注入方面我没有引入重的框架直接在入口处初始化核心单例再通过构造函数传给页面。剧本杀App的实体还比较轻过早引入 get_it 这类容器反而增加理解成本等模块数量上来了再逐步引。2. 环境搭建与工程初始化2.1 工具链与版本选择这块是新人最容易卡住的地方我单独拉出来细说。做 Flutter for OpenHarmony 开发不能直接用官方 flutter SDK得用 OpenHarmony 适配分支。我当前用的是 OpenHarmony-sig 维护的 flutter 仓库ohos分支版本对应 Flutter 3.x 的适配版。3.7 和 3.22 我都实测过3.x 更高版本的适配分支在 ArkTS 的互操作层上做得更收敛问题更少建议优先选新一点的版本。配合的工具链包括DevEco StudioOpenHarmony 应用开发 IDE用于 ohos 平台的原生工程调试和 OpenHarmony SDK。其中 DevEco Studio 内部的 SDK 管理器支持安装不同的 OpenHarmony SDK 版本建议装上 API 9 以上的版本否则 ArkTS 相关语法支持不到位。还有一个经常踩的坑环境变量。检查PATH里 flutter 指向的是不是适配分支所在路径终端里跑flutter doctor确认。如果在 DevEco Studio 里打开的工程是从另一个路径初始化出来的两边 flutter 版本不一致后面构建时会报奇怪的 Gradle 错误这种问题排查起来特别浪费时间。2.2 初始化命令与目录结构初始化命令很简单在空目录里执行flutter create --platforms ohos --org com.scriptmurder --project-name script_murder_team .加--platforms ohos是关键这样 flutter 才会生成ohos平台目录。生成完之后的目录结构大概长这样lib/ # Dart 源码 main.dart app.dart features/ activity/ presentation/ data/ domain/ ohos/ # OpenHarmony 原生工程 entry/src/main/ ets/ resources/ build-profile.json5 android/ # 保留 Android 平台方便对比调试 pubspec.yaml analysis_options.yaml如果你在生成时发现没有ohos目录大概率是 flutter SDK 不是 ohos 分支或者版本太旧不支持该平台参数。重新 clone 适配分支并切到 ohos 分支再试。2.3 依赖与资源配置pubspec.yaml里我使用的核心依赖如下dependencies: flutter: sdk: flutter dio: ^5.4.0 # 网络请求 flutter_bloc: ^8.1.5 # 状态管理 equatable: ^2.0.5 # 状态比较 intl: ^0.19.0 # 日期格式化 cached_network_image: ^3.3.1 # 封面图缓存 pull_to_refresh: ^2.0.0 # 下拉刷新与分页这里有几个细节cached_network_image在 ohos 平台上的磁盘缓存路径是走的原生文件系统需要提前申请文件读写权限否则加载图片偶尔会报错pull_to_refresh这个包我用得不算太重只是借它的分页加载逻辑如果你不喜欢第三方刷新组件可以直接用 Flutter 自带的RefreshIndicator做下拉刷新分页部分自己写脚本控制页码。权限配置上需要在ohos/entry/src/main/module.json5里加网络请求权限配置{ module: { requestPermissions: [ { name: ohos.permission.INTERNET } ] } }不配这个权限跑真机调试时网络请求会被静默拦截控制台里可能只报一个failed to connect很多人在这里卡了一整个下午。3. 活动中心数据层与业务模型设计3.1 活动实体定义活动中心的业务模型是整个模块的“地基”定义得不好后面每个页面都会跟着难受。我定义了一个ScriptActivity实体核心字段如下class ScriptActivity extends Equatable { final String id; // 活动唯一ID final String title; // 剧本名 final String hostName; // 发起人昵称 final String storeName; // 门店名称 final int currentPlayers; // 当前已报名人数 final int maxPlayers; // 人数上限 final DateTime startTime; // 开本时间 final String coverUrl; // 剧本封面图 final ListString tags; // 类型标签硬核 / 情感 / 本格 final String difficulty; // 难度等级 1~5 bool get isFull currentPlayers maxPlayers; bool get isStartingSoon startTime.difference(DateTime.now()).inHours 2; override ListObject? get props [ id, title, hostName, storeName, currentPlayers, maxPlayers, startTime, coverUrl, tags, difficulty ]; }用Equatable是为了在 Bloc/Cubit 中做状态比较时能自动判断是否变化避免每次 setState 都重建整个列表。isFull和isStartingSoon这类 getter 写在模型里UI 层直接读取不要在组件里散落各种判断逻辑。时间字段我建议一律用DateTime而不是字符串。后端接口如果返回的是 ISO8601 字符串在数据解析层统一转换显示格式化放到 UI 层去做这样列表排序、倒计时计算都方便。3.2 数据仓库与网络层实现数据层我分了两层ApiClient负责真正的 HTTP 请求ActivityRepository面向状态管理层提供业务方法。这样做的原因是后续如果需要给网络加缓存、加 mock 数据都只要改仓库层页面和状态层不用动。class ActivityRepository { final ApiClient _apiClient; static const int _pageSize 10; FutureListScriptActivity fetchActivities({ required int page, String? typeFilter, bool descending true, }) async { final response await _apiClient.get( /activities, queryParameters: { page: page, size: _pageSize, type: typeFilter, order: descending ? desc : asc, }, ); final data response.data[data] as Listdynamic; return data .map((json) ScriptActivity.fromJson(json as MapString, dynamic)) .toList(); } FutureScriptActivity fetchActivityDetail(String id) async { final response await _apiClient.get(/activities/$id); return ScriptActivity.fromJson(response.data[data] as MapString, dynamic); } Futurevoid joinActivity(String id, String userId) async { await _apiClient.post(/activities/$id/join, data: {userId: userId}); } Futurevoid leaveActivity(String id, String userId) async { await _apiClient.post(/activities/$id/leave, data: {userId: userId}); } }分页的 size 我设成 10这是实测下来活动卡片以约 600px 高度呈现时移动端单屏差不多 1.5 屏的内容量。设太大首屏加载会变慢太小下拉加载太频繁。网络层的ApiClient用 Dio 封装加了一层拦截器统一处理登录态过期和错误码class ApiClient { ApiClient({required String baseUrl}) : _dio Dio(BaseOptions( baseUrl: baseUrl, connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10), )) { _dio.interceptors.add(InterceptorsWrapper( onError: (e, handler) { if (e.response?.statusCode 401) { // 触发重新登录流程 } handler.next(e); }, )); } FutureResponsedynamic get(String path, {MapString, dynamic? queryParameters}) { return _dio.get(path, queryParameters: queryParameters); } FutureResponsedynamic post(String path, {MapString, dynamic? data}) { return _dio.post(path, data: data); } }超时时间的设置很多人会忽略但剧本杀用户在弱网环境下刷列表非常常见。connectTimeout 设 10 秒、receiveTimeout 设 10 秒是我在真机上反复调过的平衡点太长页面白屏太久太短会在地铁场景下频繁报错。3.3 用 Cubit 管理状态为什么不用 Bloc 的完整版状态管理这块我最终选的是flutter_bloc里的Cubit而不是完整版的Bloc。原因很实在活动中心的业务逻辑里异步请求就是“加载列表、加载更多、加入活动、退出活动”没有特别复杂的事件转换链。Cubit 的emit一次到位写起来更短团队协作时 review 代码也轻松。如果你后续觉得某个模块的事件流程越来越复杂再单独把它升级成 Bloc不影响整体架构。列表页的状态我定义成三个类class ActivityListState extends Equatable { final ListScriptActivity activities; final int currentPage; final bool hasMore; final ActivityListStatus status; final String? message; const ActivityListState({ this.activities const [], this.currentPage 0, this.hasMore true, this.status ActivityListStatus.initial, this.message, }); ActivityListState copyWith({...}) {...} override ListObject? get props [activities, currentPage, hasMore, status, message]; } enum ActivityListStatus { initial, loading, success, failure, loadingMore }注意activities直接放在状态里而不是单独维护一个列表变量这是 Bloc 系列框架的关键纪律所有状态变更都通过 emit 新状态完成绝不能在外面用全局变量改列表再手动 setState。这样做的最大好处是页面与状态同步是单向的不会出现“UI 显示 5 个人但数据层已经 6 个人”的错位。Cubit 的核心实现逻辑也很直白class ActivityListCubit extends CubitActivityListState { ActivityListCubit({required this.repository}) : super(const ActivityListState()); final ActivityRepository repository; Futurevoid loadActivities({bool refresh false}) async { if (state.status ActivityListStatus.loading) return; final page refresh ? 0 : state.currentPage 1; if (!refresh state.status ActivityListStatus.loadingMore) return; emit(state.copyWith( status: refresh ? ActivityListStatus.loading : ActivityListStatus.loadingMore, )); try { final items await repository.fetchActivities(page: page); final merged refresh ? items : [...state.activities, ...items]; emit(state.copyWith( activities: merged, currentPage: page, status: ActivityListStatus.success, hasMore: items.length 10, )); } catch (e) { emit(state.copyWith( status: ActivityListStatus.failure, message: e.toString(), )); } } Futurevoid joinActivity(ScriptActivity activity) async { try { // 调仓库接口 } catch (_) { // 失败恢复原状态 } } }这里有个我在实践中翻过车的点loadActivities里如果上一轮状态是loadingMore再触发 refresh 时两个请求同时进行返回后各自的emit会互相覆盖列表数据就乱了。所以我在方法开头就加了两道守卫同一时间只允许一个请求在跑。这也是为什么后来我不用 Bloc 完整版——它的事件队列机制虽然能防并发但代码量明显多一截Cubit 加几个判断条件就够了。4. 活动列表页 UI 与交互实现4.1 页面组件结构与列表卡片布局列表页的 UI 结构我从一开始就做了组件拆分避免所有布局堆在一个_ActivityListPageState里。最外层是Scaffold加AppBar下面分三块筛选栏、列表主体、底部加载提示。class ActivityListPage extends StatelessWidget { const ActivityListPage({super.key, required this.cubit}); final ActivityListCubit cubit; override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text(活动中心)), body: BlocBuilderActivityListCubit, ActivityListState( bloc: cubit, builder: (context, state) { if (state.status ActivityListStatus.loading state.activities.isEmpty) { return const Center(child: CircularProgressIndicator()); } if (state.status ActivityListStatus.failure state.activities.isEmpty) { return ErrorRetryView(message: state.message, onRetry: () cubit.loadActivities(refresh: true)); } return SmartRefresher( controller: _refreshController, enablePullDown: true, enablePullUp: state.hasMore, onRefresh: () cubit.loadActivities(refresh: true), onLoading: () cubit.loadActivities(), child: ListView.separated( itemCount: state.activities.length, itemBuilder: (context, index) { final activity state.activities[index]; return ActivityCard(activity: activity); }, separatorBuilder: (_, __) const SizedBox(height: 12), ), ); }, ), floatingActionButton: FloatingActionButton.extended( onPressed: () Navigator.pushNamed(context, AppRoutes.publishActivity), label: const Text(发起组队), icon: const Icon(Icons.add), ), ); } }列表项ActivityCard要承载的信息较多排版上我按“上中下”三段来组织顶部是封面图和状态标签即将开始/已满员/招募中中间是剧本名、门店位置与开场时间底部是拼车进度条和参加按钮。拼车进度条我用LinearProgressIndicator加上自定义配色让用户一眼看出当前几个人、还差几个人这是组队场景里转化率最高的信息。4.2 下拉刷新与分页加载的实现细节下拉刷新用的SmartRefresher组件本身有 Controller 管理刷新状态。这里有个很重要的细节当BlocBuilder里的状态从 loading 变回 success 时必须手动调用_refreshController.refreshCompleted()否则刷新动画会一直卡在转圈状态。void _handleStateChange(ActivityListState state) { if (state.status ActivityListStatus.success) { if (_refreshController.isRefresh) { _refreshController.refreshCompleted(); } if (_refreshController.isLoading) { _refreshController.loadComplete(); } } _refreshController.enablePullUp state.hasMore; }分页加载的“是否还有更多”判断我直接看返回条数是否等于 pageSize。既然 pageSize 是 10返回不到 10 条就说明到底了。这个方法简单可靠不用后端多返回一个hasMore字段。但也提醒一句如果后端做了去重如果页与页之间有交叠数据需要自行处理指纹去重我在这里加了一层MapString, ScriptActivity按 id 去重的保护防止接口配置异常导致重复渲染。SmartRefresher在 Flutter 的 ohos 分支上有一个小兼容问题在某些 OpenHarmony 版本上列表滚动到顶部时下拉刷新手势触发的阻力反馈不够明显。如果你实测发现手感不对可以换用 Flutter 自带的RefreshIndicator它的 Material 实现是纯 Flutter 层渲染不依赖平台手势表现更稳定。4.3 空态与异常态处理列表页最容易让人忽略的是空态和异常态。很多同学直接if (list.isEmpty) return SizedBox()用户看到的就是一个空白页面完全不知道是加载失败、没有数据还是筛选条件太严格。我在空态视图里做了区分接口正常返回空列表时显示“当前没有活动点击搜索更多剧本”加一个去发布页的按钮loading 失败时展示错误文案和“重新加载”按钮错误文案是后端返回的错误码转换过的可读信息而不是SocketException这种天书。异常态还有个隐蔽场景下拉刷新时网络超时SmartRefresher 已经在转圈此时 Cubit 里 status 是 failure。如果refreshCompleted()不会调用页面会一直卡在下拉状态。所以我在_handleStateChange里也监听 failure只要不是 loading 和 loadingMore就把刷新动画停掉并且用ScaffoldMessenger弹一条 SnackBar 提示“网络不给力”。这个小处理让整个状态的流转闭环了。5. 活动详情页与组队逻辑5.1 路由定义与参数传递详情页的路由我放在独立的路由表AppRoutes中统一管理不走匿名MaterialPageRoute的硬编码。这样做的好处是后续要接深度链接或者从通知栏直接跳转详情页只需要一个 routeName 就能找到页面不用猜参数。class AppRoutes { static const String home /; static const String activityDetail /activity/detail; static const String publishActivity /activity/publish; static MapString, WidgetBuilder get routes { home: (_) const ActivityListPage(), activityDetail: (_) const ActivityDetailPage(), publishActivity: (_) const PublishActivityPage(), }; }参数怎么传我的做法是详情页不直接在构造函数里拿整个ScriptActivity对象而是只传一个activityId进页面后通过 Cubit 重新拉取详情数据。原因有两个一是详情接口可能返回比列表项更完整的字段直接复用列表对象会丢数据二是当用户从详情页加入活动返回列表时列表数据需要刷新嵌套传对象并不会帮助处理这个同步问题。当然为了省一次网络请求我会在跳转时顺带传一个列表页已有的ScriptActivity作为初始展示数据详情 Cubit 初始化后先用它渲染再异步拉详情覆盖。这样首屏速度很快也不至于白屏。5.2 发起组队表单校验与提交发布页的表单我做了实时校验而不是等到点击提交才检查。剧本名、人数上限、开场时间这三项是必填其中人数上限我用了一个DropdownButtonFormField选择 4 到 12 人在起始值就排除 0 和 1 的情况。时间选择用的是showDatePicker加showTimePicker的组合选完存进DateTime变量。这里有个经验必须同时做“现在时间之后”的校验否则用户选的过去时间会导致详情页倒计时变成负数进度条直接异常。校验失败时在按钮上方显示红色提示文案同时禁用提交按钮。提交的时候按钮要进入 loading 状态防止用户手快点了两下后端创建了重复活动。我用一个局部变量_submitting控制Futurevoid _submit() async { if (_submitting) return; setState(() _submitting true); try { await _repository.publishActivity(...); if (!mounted) return; Navigator.pop(context, true); } catch (e) { setState(() _submitting false); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(发布失败$e)), ); } }mounted检查是异步回调里的必备操作。页面在请求过程中被用户返回回调执行 setState 会直接报错用if (!mounted) return优雅退出。5.3 加入与退出活动的状态同步详情页的加入按钮核心逻辑是状态切换还没加入时显示“我要加入”已加入显示“我已上车”活动满员时按钮变成灰色不可点击且文案为“已满员”。这几种状态由详情 Cubit 里的isJoined和activity.isFull共同决定。加入接口调用成功后我会更新本地的详情状态把currentPlayers 1同时把isJoined置为 true。人数变了之后拼车进度条自动跟着变。这里的关键点是详情页返回列表页后列表页的数据必须同步。我的做法是在Navigator.push返回后无论结果如何都触发列表 Cubit 的刷新逻辑final result await Navigator.pushNamed(context, AppRoutes.activityDetail); if (result ! null) { context.readActivityListCubit().loadActivities(refresh: true); }有人会觉得每次都刷新太浪费流量但剧本杀组队场景里一个活动的名额变化极快本来可能就差一到两个位置你拿到旧数据去组队只会永远扑空。宁可多刷一次也要保证数据新鲜。退出活动我额外加了一个确认弹窗防止误触导致位置被别人顶掉Futurevoid _confirmLeave() async { final confirmed await showDialogbool( context: context, builder: (ctx) AlertDialog( title: const Text(确认退出本次组队), content: const Text(退出后名额将立即释放可能无法再次加入。), actions: [ TextButton(onPressed: () Navigator.pop(ctx, false), child: const Text(再想想)), FilledButton(onPressed: () Navigator.pop(ctx, true), child: const Text(退出)), ], ), ); if (confirmed true) { context.readActivityDetailCubit().leaveActivity(); } }6. OpenHarmony 平台适配与原生交互6.1 Flutter 在 OpenHarmony 上的运行机制先说清楚 Flutter 在 OpenHarmony 上是怎么跑起来的这决定你遇到问题时的排查方向。OpenHarmony 分支的 Flutter 引擎核心渲染层Skia/Impeller被编译为 so 库运行在应用进程中Dart 虚拟机也是同样的机制所以 Flutter 的 UI 代码在 OpenHarmony 和 Android、iOS 上的行为基本一致。真正有差异的是平台通道Platform Channel。Platform Channel 是一道桥桥的两端分别是 Dart 侧和 OpenHarmony 原生侧。Dart 侧发消息给原生侧原生侧处理完后把结果回传。Flutter 官方适配 OpenHarmony 时已经把这个桥打通了但我们自己写插件时两边都要分别实现才能对上号。Android 里你可能写过MethodChannelOpenHarmony 里对应的写法是 ArkTS 侧的MethodChannel封装类和注册方式不同但心智模型是一样的。6.2 用 EventChannel 拉取原生活动提醒项目里我实际用到 EventChannel 的场景是接门店的“开场前提醒”能力。业务需求是用户在活动开始前 30 分钟会收到系统通知。这个通知如果放在 Flutter 侧用flutter_local_notifications实现在 OpenHarmony 上的插件支持还不完善我就在原生侧用 OpenHarmony 的本地通知接口实现然后通过 EventChannel 把通知回调传递到 Flutter 侧Flutter 侧借此更新页面上的提醒状态。Dart 侧监听事件流class NativeNotificationService { static const EventChannel _channel EventChannel(com.scriptmurder.notification/events); static Streamdynamic get onNotificationTap { return _channel.receiveBroadcastStream().map((event) event); } } // 在详情页或 App 入口注册 void _initNativeNotificationListener() { NativeNotificationService.onNotificationTap.listen((event) { final activityId event[activityId] as String?; if (activityId ! null) { // 跳转到对应活动详情页 Navigator.pushNamed(context, AppRoutes.activityDetail, arguments: activityId); } }); }事件流是持续性的所以要在页面销毁或不需要接收时取消订阅否则会收到已退出页面的回调做页面跳转时容易出错。OpenHarmony 侧对应实现一段 ArkTS 代码注册 EventChannel 并往 Dart 侧发数据import { EventChannel } from ohos/flutter_ohos; let eventChannel new EventChannel(com.scriptmurder.notification/events); let eventSink null; eventChannel.setStreamHandler({ onListen: (arguments, sink) { eventSink sink; // 设置通知点击回调触发 sink.success({ activityId: xxx }) }, onCancel: (arguments) { eventSink null; } });这套方案的运行链路是用户在原生通知栏点开提醒ArkTS 侧捕获点击事件通过 EventChannel 把 activityId 传到 FlutterFlutter 再走自己的路由跳转。整个过程像两个部门之间接了一个专用对讲机比轮询服务器优雅得多也比原生跳 Flutter 页面再传参简单。需要特别提醒EventChannel 的流是“单向的”原生侧不依赖 Dart 的invokeMethod返回值。如果你需要 Dart 主动让原生做某件事并拿结果那是 MethodChannel 的职责。不要把两种 Channel 混用否则数据流会乱这是我在项目里调试了很久才理清的。6.3 OpenHarmony 打包与构建的关键命令构建 ohos 包的命令和 Android 类似只是目标平台不同# 构建 release 包 flutter build ohos --release # 构建 hap 包会输出到 ohos/entry/build 下 # 需要先在 DevEco Studio 中配置好签名第一次跑flutter build ohos时可能在下载 Flutter ohos 引擎产物时卡很久这个依赖网络状况。之后的增量构建会快很多。常见报错是 Gradle 同步失败多半是 OpenHarmony SDK 路径没配对。DevEco Studio 里配置好 SDK 和默认签名之后命令行的 flutter 构建才能正常走通。Gradle 相关还有一个反复出现的问题you are applying flutters main gradle plugin imperatively using the apply。这个报错多见于同时存在 Android 和 ohos 平台的工程。排查逻辑是去ohos/build-profile.json5和根目录的settings.gradle.kts里检查 flutter gradle 插件引用的方式确保调用的写法是插件声明式而非 apply 命令强制引用的方式。如果你不需要 Android 平台可以把工程里的 android 目录暂时移走能省掉很多无谓的 Gradle 同步时间。真机调试时DevEco Studio 里能看到 Flutter 附加到设备后的日志输出Dart 侧的debugPrint会出现在控制台。但如果你直接跑flutter run -d ohos设备发现连不上大概率是 ohos 设备地址配置问题。OpenHarmony 真机的调试端口和 adb 体系不完全一样更稳妥的做法是在 DevEco Studio 里先跑通原生安装再通过 Flutter attach 附加调试。7. 常见问题与排查技巧实录7.1 一组必须知道的高频坑我把这次开发里真实踩过的、且在 Android 上没有遇到过的坑整理成了一张速查表方便各位在做 ohos 适配时直接对照排查。现象根因解决方式列表页第一次加载永远超时未配置ohos.permission.INTERNET在 module.json5 中添加网络权限网络图片加载后无法缓存缓存目录未授权显式申请媒体库或文件读写权限并指定缓存路径构建时报 Flutter Gradle 插件应用方式错误android 与 ohos 工程共存时 Gradle 解析规则冲突检查 settings.gradle.kts 的插件声明或移除不需要的平台目录图片大量加载时列表滑动掉帧缺少图片缓存复用使用cached_network_image并且给列表项包RepaintBoundary刷新动画停止不了未在状态回到 success 时调用refreshCompleted()监听状态变化统一处理刷新和加载完成回调详情页返回后列表数据还是旧的加入/退出活动后未触发列表刷新路由返回处统一调用loadActivities(refresh: true)EventChannel 收不到原生通知原生侧 sink 未在 onListen 后正确赋值在 setStreamHandler 的 onListen 中保存 eventSink 引用7.2 列表卡顿的性能优化实测活动列表在数据量到 30 到 50 条时如果没有做性能优化会在 OpenHarmony 真机上出现肉眼可见的滑动掉帧。我做了三件事效果立竿见影。第一件是给每个ActivityCard包上RepaintBoundary。Flutter 在列表滑动时会重复绘制可见区域的 widget如果卡片不是被独立隔离的绘制层任何子 widget 的变化都可能触发整棵组件树的重新绘制。RepaintBoundary让每张卡片的绘制结果被缓存滑动时只移动光栅化好的纹理开销小很多。第二件是封面图的预加载策略。列表页滚动时并不需要把每张封面图都立刻缓到内存只需要在当前可见区域前后各预留两屏的图片缓存。我用cached_network_image的memCacheWidth参数将缓存宽度限制在 200px 左右——活动封面在列表卡片里就占那么大区域完全没必要保留 2K 分辨率的内存副本。这个改动直接减少了近 60% 的图片内存占用。第三件是列表项的计数优化。currentPlayers的变化会频繁触发状态更新但列表的itemBuilder里如果直接用BlocBuilder监听整个列表状态任何一项数据变化都会重建所有卡片。我改用BlocSelector只选择当前卡片需要的那份数据和加入状态比如BlocSelectorActivityListCubit, ActivityListState, int( selector: (state) { final activity state.activities[index]; return activity.currentPlayers; }, builder: (context, currentPlayers) { return Text($currentPlayers/${activity.maxPlayers}人); }, );这样只有人数字段变化时该卡片的重建才会触发其他卡片完全不受影响。实测下来列表滚动帧率在 OpenHarmony 真机上从肉眼可见的掉帧恢复到接近满帧。7.3 调试 OpenHarmony 平台时的一些心得调试环境的搭建我总结一个适合团队的套路。第一步所有团队成员统一 Flutter SDK 版本用fvm来管理多版本切换避免“我本地能跑你本地跑不了”的经典矛盾。第二步在 DevEco Studio 里配好一套公共签名保证大家都用同一个签名打包测试机之间可以直接互相覆盖安装。第三步在 GitHub Actions 里配一套 ohos 的 CI 构建脚本每天定时构建 nightly 包这样平台适配的问题在合入代码的次日就会暴露而不是拖到发版时才发现。还有个小技巧OpenHarmony 真机上调试时debugPrint输出的内容量比 Android 少一些 Dio 请求日志可能被吞掉。我看网络问题的第一步是直接在onError拦截器里打印完整 error同时打开 Dio 的 verbose 模式dio.interceptors.add(LogInterceptor( requestBody: true, responseBody: true, error: true, ));这样即使系统日志被裁剪至少业务层能把请求和响应的关键信息完整打出来。7.4 页面状态丢失的预防与恢复关于 Flutter 里最常见的“Navigator 切换页面后列表状态丢失”的问题我在这次项目里给了自己的答案。Flutter 的Navigator.push默认会保留上一个页面的 State 对象但内存紧张时系统可能回收底层页面再次返回时 State 已丢失列表回到初始状态。预防方案是给页面 Cubit 做状态持久化。我不推荐把列表数据整份写进本地数据库太笨重。更实际的方案是在loadActivities成功后把当前状态对象序列化缓存到内存单例或磁盘的轻量缓存中页面重建后Cubit 初始化时优先读取缓存状态再静默去后台刷新。用户看到的是“页面还在数据在更新”而不是“页面白屏开始转圈”。class ActivityListCubit extends CubitActivityListState { ActivityListCubit({required this.repository}) : super(_loadCachedState()) { _init(); } static ActivityListState _loadCachedState() { final cached ActivityCache.instance.read(); return cached ?? const ActivityListState(); } Futurevoid _init() async { if (state.activities.isEmpty) { await loadActivities(refresh: true); } else { await loadActivities(refresh: true); // 静默刷新UI 不出现 loading } } }静默刷新时避免弹出加载转圈方法是在loadActivities里加参数showLoading只有首次加载才显示全屏 loading刷新操作只在成功或失败后给出轻量提示。这样处理过之后用户几乎感知不到状态丢失——这也是我最终决定不引入额外页面缓存库的原因Cubit 的恢复机制已经够用了。写在最后的个人经验活动中心这个模块做完我最深的一个体会是Flutter 跨端开发的价值在 OpenHarmony 生态里体现得尤其明显——一套 Dart 代码跑通 Android 和 OHOS比起维护两套原生工程省下的开发时间是实打实的不用再为同一个页面反复做 iOS 和安卓的差异化适配。但我也必须说跨端不是银弹。OpenHarmony 的分支适配目前仍然处于追赶阶段坑比 Android 多工具链没有 Android 那么顺手。我的建议是团队里至少留一个人专门盯 ohos 分支的更新节奏每次 Flutter 版本升级前先去变更日志里看有没有影响引擎层的改动再决定要不要升级。宁可晚两周升级也不要在一个全量线上版本里冒未知构建风险。最后分享一个小技巧如果你也准备在 OpenHarmony 上做活动类项目记得在项目初期就把 EventChannel 的调用链跑通并沉淀成内部文档。平台通道的消息名、参数格式和报错码这些信息网上能找到的完整资料不多你踩过的每一个坑都可能是后来人要大半天排查的难题。写下来是给团队也是给自己省时间。这个项目后续的想象空间不小。活动中心只是切入点剧本库管理、用户评分、门店排班、消息推送都能在这个骨架上长出来。每一步扩展的时候回到分层架构和状态管理的纪律里来就不会乱。
返回列表