拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Flutter适配OpenHarmony实战:手语App分类列表跨端开发全记录

Flutter适配OpenHarmony实战:手语App分类列表跨端开发全记录

说实话,在真正把项目跑在 OpenHarmony 设备之前,我对“Flutter 写鸿蒙应用”这件事是持保留态度的。毕竟 OpenHarmony 不是安卓,UI 框架、打包产物、权限模型、原生通道全都不一样,传统 Flutter 开发那套经验不可能直接平移。但等到我把这个手语学习 App 迁移到 flutter_for_openharmony 分支,并把分类列表模块完整实现之后,我必须说一句:这套路真能走通,而且走通之后的收益非常大——一套 Dart 代码,既能出安卓包,也能打 HAP(鸿蒙应用包),UI 交互、状态管理、数据层几乎零改动。

这篇文章就把我这次实战的完整过程写出来。核心围绕手语学习场景里最重要也最容易写砸的部分——分类列表:一级分类怎么设计、二级条目列表怎么展示、点击切换如何保状态、原生视频能力怎么通过通道接进来、OpenHarmony 环境下又会踩到哪些典型坑。内容包括环境搭建、数据建模、UI 实现、鸿蒙适配、问题排查五个大块,适合正好想做 Flutter 鸿蒙适配的小伙伴,也适合正在实现“分类-列表-详情”这种经典结构但总觉得差点意思的人。

1. 手语学习APP的整体设计与思路

1.1 为什么是“Flutter + OpenHarmony”这个组合

手语学习这个场景,本身就有很强的多端属性。用户可能在手机上学,在平板上看视频分解,教育机构和公益组织还经常要部署到大屏一体机上。如果每一端都单独写一套原声应用,成本和维护量直接爆炸,这也是我一开始就锁定 Flutter 的原因:UI 层、业务层、状态层全都可以复用,唯一要动的只是平台能力接入层。

OpenHarmony 这边的形势也很明确。作为开源底座,它已经能跑在手机、平板、开发板、智能大屏上,而且跨设备能力本身就是它的主场。手语学习恰好在这些设备上都有需求,这就让“一次编码、多端出包”的价值被拉满了。官方社区维护了 flutter_flutter 的 OpenHarmony 分支,提供了 HAP 的构建通道、ohos 平台工程模板,以及 Flutter 引擎在 OHOS 上的适配。换句话说,Flutter 不是只能在 Android 上跑,只要你把 SDK 切换到这个分支,整个工具链就会认识 OpenHarmony 的构建产物和平台目录。

当然这个组合也不是没有代价。相比安卓的成熟插件生态,OpenHarmony 上的 Flutter 插件目前还有不少洞要补,尤其是音视频播放、摄像头这些依赖原声能力的模块。所以我在项目一开始就做了个决策:凡是 UI 和业务逻辑,必须纯 Dart 实现;凡是原生能力,统一抽到平台通道层,后续逐个补适配。这个决策在后面帮了大忙,分类列表模块基本没有因为鸿蒙适配改过一行 UI 代码。

1.2 手语分类功能到底要做什么

手语学习 App 的核心不是罗列一堆手语词汇,而是要让用户按照合理的路径去学。分类列表在这里扮演的其实是“学习地图”的角色。我把整个内容体系分成了三个层级:

第一层是分类页,按场景划分,比如日常问候、家庭称谓、餐饮食物、医疗求助、交通出行、数字字母。每个分类需要展示名称、一句话描述、图标配色、条目数量,以及该分类下的学习进度。

第二层是分类下的手语条目列表,一个条目就是一个手语词,比如“谢谢”“生病了”“停车”。每条需要展示词名、手语视频封面、简短说明、难度标记。

第三层是条目详情页,播放手语视频,展示分步动作拆解和文字说明。

分类列表如果只是用 ListView 堆一行行文字,产品上是不过关的。它承担了两个重要任务:一是让用户一眼知道有哪些学习主题,二是用卡片、进度、颜色这些视觉元素降低学习门槛。所以我最终采用了“首屏拉伸网格 + 二级卡片列表”的结构:分类页用带渐变色的大卡片网格排列,点击后进入二级条目列表,列表项高度统一、内容紧凑,方便在平板上也保持良好的浏览节奏。

1.3 项目模块与工程结构

这个项目的工程结构完全围绕 feature 来组织,没有用传统的“页面-组件-工具”三层,而是把领域功能独立成包:

  • lib/features/category/:分类列表的模型、仓库、状态管理、UI;
  • lib/features/detail/:条目详情页与播放逻辑;
  • lib/common/:通用组件、颜色、尺寸、工具类;
  • lib/platform/channels.dart:集中定义所有 MethodChannel 和 EventChannel 名称;
  • ohos/:OpenHarmony 工程目录,由分支工具链生成,原生桥接代码在里面。

这样组织的最大好处是,后续如果要加“手语测验”“每日一句”功能,每个功能都能独立开发、独立测试,不会侵蚀彼此的文件。尤其是分类列表这种偏数据驱动的模块,把数据层和 UI 层解耦之后,我改数据模型不影响页面,改页面布局不影响状态管理,调试体验顺畅很多。

2. OpenHarmony端Flutter工程环境与初始化

2.1 环境准备与工具链版本

先说环境。想让 Flutter 认识 OpenHarmony,你需要做的第一件事就是把 Flutter SDK 切换成 flutter_for_openharmony 对应的分支,这一步不能跳过。官方上架 OpenHarmony 的 SDK 下载同样走官方镜像或 FTP 源,直接拉到本地后解压,然后把bin目录加进PATH。

  • 操作系统推荐 Linux 或 macOS,Windows 也能编译,但 OpenHarmony 侧工具链在 Windows 上偶尔会有路径长度和符号链接问题;
  • DevEco Studio 需要安装,并配置好 OpenHarmony SDK 的路径;
  • 确认本机 Java、Git 环境正常;
  • 通过flutter --version检查,看到分支名或者版本号带 ohos 标识,说明切换成功。

环境搭建里最容易出问题的不是 Flutter 本身,而是版本匹配。OpenHarmony 分支一般会锁定某个 Flutter 基线版本,比如当前稳定驱动通常对应 3.22 或 3.24 这条线,不会追官方主线最新的版本。这个不是落后,而是因为引擎适配需要时间。你如果从主工程切过来,记得把pubspec.yaml里的依赖版本整体跑一遍flutter pub upgrade式检查,避免某些包引用了新 SDK 才有的 API。

2.2 创建工程与 AS 快速上手

手语学习 App 的工程创建,我强烈建议用命令行而不是 IDE 向导。因为分支工具链对ohos平台的支持,在环境变量和 SDK 路径上更敏感,命令行跑会打出更直观的日志。

flutter create ./sign_language --project-name sign_language --org com.example.sign

工程生成后,在项目根目录执行:

flutter create --platforms=ohos .

如果当前分支注册了 ohos 平台,这一步会在工程下生成ohos/目录。这个目录是 DevEco Studio 工程,HAP 的编译入口就在这里。

然后打开 DevEco Studio,方式跟 Android Studio 打开安卓工程类似:File -> Open,选择项目根目录下的ohos文件夹。IDE 会自动同步 Gradle 配置。第一次同步会拉取 OpenHarmony SDK 和构建插件,耗时根据网络情况不定,建议边休息边等。同步完成后,你就能在 IDE 里直接调试或者打包了。

如果习惯用 Android Studio 来管理 Flutter 代码,那也没问题——Flutter 代码、pubspec.yaml、Dart 侧调试都在 AS 里操作,只把ohos/目录当作原生工程丢给 DevEco。两条腿走路,互不干扰。

2.3 依赖与插件的“鸿蒙化”检查

这篇实战里,项目依赖的插件并不多,核心几个是:flutter_bloc或者cubit做状态管理、dio做网络请求(如果后续要远程加载词汇)、video_player或者自定义平台通道播放视频。但你一定要注意:

  • 检查每个插件在 pub 上是否有 ohos 平台的实现;
  • 没有的话,看插件是否纯 Dart 实现,纯 Dart 的可以放心用;
  • 依赖原生能力的,要自己通过 MethodChannel 在ohos/entry/src/main/ets里补实现。

我在这个项目里遇到的典型情况就是视频播放插件在 OpenHarmony 上不可用,于是自己封装了一个OhosVideoPlugin,用 MethodChannel 控制播放、暂停、跳转,用 EventChannel 把播放进度和播放结束事件回传给 Dart 侧。

class OhosVideoPlugin { static const MethodChannel _control = MethodChannel('sign_language/video_control'); static const EventChannel _events = EventChannel('sign_language/video_events'); Future<void> play(String assetPath) async { await _control.invokeMethod('play', {'path': assetPath}); } Stream<dynamic> get events => _events.receiveBroadcastStream(); }

原生侧用 OpenHarmony 的 AVPlayer 能力去实现同一个通道名。这样 Dart 侧完全不知道底层换了播放器,对上层 UI 和列表模块来说,就是一个普通插件。

3. 分类列表的数据建模与状态管理

3.1 手语分类的领域模型设计

分类列表看起来简单,但一旦数据层级没设计好,后面改起来就是灾难。我先把模型拆开来看:顶层是分类对象,分类下面挂条目列表,条目又关联视频资源和说明文本。

class SignCategory { final String id; final String name; final String subtitle; final int colorValue; final String iconAsset; final List<SignItem> items; SignCategory({ required this.id, required this.name, required this.subtitle, required this.colorValue, required this.iconAsset, required this.items, }); factory SignCategory.fromJson(Map<String, dynamic> json) { return SignCategory( id: json['id'] as String, name: json['name'] as String, subtitle: json['subtitle'] as String? ?? '', colorValue: json['color'] as int? ?? 0xFF607D8B, iconAsset: json['icon'] as String? ?? '', items: (json['items'] as List<dynamic>? ?? []) .map((e) => SignItem.fromJson(e as Map<String, dynamic>)) .toList(), ); } } class SignItem { final String id; final String name; final String videoAsset; final String description; final int difficulty; SignItem({ required this.id, required this.name, required this.videoAsset, required this.description, this.difficulty = 1, }); factory SignItem.fromJson(Map<String, dynamic> json) { return SignItem( id: json['id'] as String, name: json['name'] as String, videoAsset: json['video'] as String, description: json['description'] as String? ?? '', difficulty: json['difficulty'] as int? ?? 1, ); } }

模型设计上有几个关键点。第一,id是稳定标识,我强烈不建议拿中文名当 id,后续如果要接后台、做进度上报,中文名很容易因为编码或空格问题出幺蛾子。第二,颜色值直接存在 JSON 里,这样 UI 层可以根据分类动态配色,不必硬编码映射表。第三,条目数量不落库,而是通过items.length实时计算,避免两份数据不一致的问题。

3.2 数据加载与 Repository 模式

数据源我采用了 assets 本地 JSON,原因很简单:第一批手语内容量不大,几十个分类加上两三百个条目,本地加载速度快、离线可用、减少网络依赖。后续如果内容扩充到几千条,再平滑迁移到网络接口即可,前提是 Repository 接口形态不变。

class CategoryRepository { Future<List<SignCategory>> fetchCategories() async { final raw = await rootBundle.loadString('assets/data/categories.json'); final decoded = json.decode(raw) as List<dynamic>; return decoded .map((e) => SignCategory.fromJson(e as Map<String, dynamic>)) .toList(); } }

这里必须做一层 Repository。因为页面和状态层不应该知道数据来自本地还是网络,Repository 是唯一的数据入口。如果后续接入在线内容,只需要改 Repository 的实现,页面代码一行都不用动。这也是我在实战里反复跟身边同事强调的:不要在 Widget 里直接去 loadString,不要跳过 Repository 层。

JSON 文件放在assets/data/categories.json后,记得在pubspec.yaml的flutter.assets段里声明目录,否则运行时直接报找不到资源。

flutter: assets: - assets/data/ - assets/images/ - assets/videos/

3.3 状态管理选型与 Cubit 落地

说到状态管理,这个项目的分类列表有一个特点:状态数量不多,但状态切换比较频繁——加载中、加载成功、加载失败、选中某一个分类。用setState其实也能写,但分类列表还涉及跨页面传递状态、返回后保持列表位置,用原生的 setState 写起来会很别扭。我最后选了flutter_cubit,也就是 bloc 库里的 Cubit 部分,而没有直接用 Bloc。

原因有三点。一是 Cubit 比 Bloc 简单,不需要写 Event 类,对于“加载数据、切换选中项”这种有限操作,写 Event 纯属浪费;二是 Cubit 基于状态类的机制,天然适合跟BlocBuilder、BlocListener配合,UI 层能精确控制重建范围;三是纯 Dart 实现,在 OpenHarmony 上没有任何原生依赖,不用适配。

enum CategoryStatus { initial, loading, loaded, failure } class CategoryState { final CategoryStatus status; final List<SignCategory> categories; final String? errorMessage; const CategoryState({ this.status = CategoryStatus.initial, this.categories = const [], this.errorMessage, }); CategoryState copyWith({ CategoryStatus? status, List<SignCategory>? categories, String? errorMessage, }) { return CategoryState( status: status ?? this.status, categories: categories ?? this.categories, errorMessage: errorMessage ?? this.errorMessage, ); } } class CategoryCubit extends Cubit<CategoryState> { CategoryCubit(this._repository) : super(const CategoryState()); final CategoryRepository _repository; Future<void> load() async { emit(state.copyWith(status: CategoryStatus.loading)); try { final result = await _repository.fetchCategories(); emit(state.copyWith( status: CategoryStatus.loaded, categories: result, )); } catch (e) { emit(state.copyWith(status: CategoryStatus.failure, errorMessage: '$e')); } } void selectCategory(String id) { final categories = state.categories; if (categories.isEmpty) return; final index = categories.indexWhere((c) => c.id == id); if (index == -1) return; } }

Cubit 落地的核心体会是:状态类要不可变,每次变更通过copyWith产生新实例。这不仅是惯例,而是让 UI 正确重建的前提。BlocBuilder 是依靠状态对象是否发生变化来决定是否重建的,如果直接改旧对象里的字段,UI 不会响应。

4. 分类列表的UI实现与交互开发

4.1 分类网格与条目列表的双层结构

分类首页我用的是CustomScrollView + SliverGrid,而不是最普通的 GridView。理由是要在顶部放一个搜索框和欢迎横幅,用 CustomScrollView 可以把这些头部和网格列表放到同一个滚动体系里,滑动联动非常自然。网格卡片尺寸在大屏和手机上都能保持合理密度,我使用了SliverGridDelegateWithMaxCrossAxisExtent,限定每列最大宽度,这样屏幕变宽时自动增加列数。

class CategoryHomeView extends StatelessWidget { const CategoryHomeView({super.key, required this.cubit}); final CategoryCubit cubit; @override Widget build(BuildContext context) { return BlocBuilder<CategoryCubit, CategoryState>( bloc: cubit, builder: (context, state) { if (state.status == CategoryStatus.loading) { return const Center(child: CircularProgressIndicator()); } if (state.status == CategoryStatus.failure) { return _ErrorRetryView( message: state.errorMessage ?? '加载失败', onRetry: cubit.load, ); } return CustomScrollView( slivers: [ const SliverToBoxAdapter(child: _HomeHeader()), SliverPadding( padding: const EdgeInsets.all(16), sliver: SliverGrid( gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: 200, mainAxisSpacing: 12, crossAxisSpacing: 12, childAspectRatio: 0.92, ), delegate: SliverChildBuilderDelegate( (context, index) => _CategoryCard( category: state.categories[index], onTap: () => _openCategory(context, state.categories[index]), ), childCount: state.categories.length, ), ), ), ], ); }, ); } }

进入分类后,二级列表用ListView.separated,条目卡片高度统一。这里有个细节:卡片高度如果不定,视频封面和文字说明会导致列表项高度跳跃,滑动时卡顿明显。所以我给条目卡片固定了一个标准高度,描述最多两行,超出部分省略号处理。

4.2 卡片设计与 TabBar 点击动画处理

分类卡片我做了比较重的视觉设计:线性渐变背景、左上角图标、左下角分类名和条目数、右上角一个小的进度百分比。渐变色从模型里的colorValue派生,相邻两个卡片颜色体系来自同一色相不同饱和度的方案。这个视觉方案的优点是分类层次一目了然,用户可以靠颜色记忆快速定位目标分类。

分类详情页顶部的 Tab 用来切换“全部”“初级”“进阶”三个难度筛选。这里有个很容易被忽略的体验问题:Flutter 的 TabBar 默认点击会有一个滚动动画动画,从当前位置平滑切换到目标位置。在手语学习这种强调快速定位的场景里,这个动画反而拖节奏。处理办法是把 TabController 的animationDuration设置为Duration.zero,或者干脆用_tabController.index = target直接切。

TabController( length: 3, vsync: this, animationDuration: Duration.zero, );

这样点击后立即切换内容,没有中间滚动过程。实测在 OpenHarmony 真机上,动画越少,列表重建越快,体验越干脆。如果你希望保留一点动画给用户反馈,建议至少把时间从默认 300ms 降到 80ms 左右,小于 100ms 既能感知又不拖沓。

4.3 详情跳转与页面栈状态保持

点击条目列表中的某一个手语项,需要跳到详情页。这里我用的是普通Navigator.push,但有个雷点:OpenHarmony 分支上,如果页面跳转时没有正确配置路由表,某些设备上会出现返回后列表位置丢失或者页面空白的情况。

我的解决方式是给二级列表加PageStorageKey,同时让列表页面 State 混入AutomaticKeepAliveClientMixin。前者会把滚动位置保存到 PageStorage,后者让页面在导航栈中被缓存,而不是销毁重建。

class _CategoryDetailViewState extends State<CategoryDetailView> with AutomaticKeepAliveClientMixin { @override bool get wantKeepAlive => true; }
ListView.separated( key: const PageStorageKey<String>('category-item-list'), ... )

状态保持这件事,在 Flutter 里经常被忽略,但它对手语学习场景特别重要。用户可能在“医疗求助”分类下翻到第 20 个词,不小心点进详情看了两秒视频,返回的时候如果列表从头开始滚,这种挫败感是致命的。

5. 适配OpenHarmony会碰到的“特殊战场”

5.1 “当前配置的 Flutter SDK 不被完全支持”警告处理

这个警告几乎是每个从官方分支切到 OpenHarmony 分支的人第一眼就会看到的:

The current configured Flutter SDK is not known to be fully supported. Please consult the Flutter documentation for supported SDK versions.

看到它先别慌。这不是说你的环境坏了,而是 IDE 检查到 Flutter SDK 版本不在某个已知的受支持列表里。OpenHarmony 分支版本号往往和官方主版本不一致,IDE 不认很正常。处理方式有两种:

  • 把工程里pubspec.yaml或 gradle 配置依赖的 Flutter SDK 版本约束,修改成当前分支实际报告的版本;
  • 如果用的是 DevEco 内置检测,某些版本下需要关闭对 Flutter SDK 的严格检查,或者把 Flutter SDK 的版本配置在项目的local.properties中显式指定。

这个警告不影响编译,但如果直接忽略,后续排查问题时分不清是警告还是真正错误,建议工程一建立就处理干净。

5.2 Gradle 主插件命令式应用报错与修复

工程构建时报过这样一个错误:

You are applying Flutter's main Gradle plugin imperatively using the `apply script` method, which is deprecated.

这个其实不是 OpenHarmony 分支的错,而是较新版本 Gradle 对整个 Flutter 插件加载方式的要求变了。旧版 Flutter 工程在android/app/build.gradle或对应模块里用apply from: "$flutterRoot/packages/flutter_tools/gradle/app_plugin_loader.gradle"这样的命令式加载,新版希望用plugins块声明式加载。在 OpenHarmony 工程里同样存在这个问题。

修复方法就是把ohos模块的build.gradle中的apply方式迁移到settings.gradle的pluginManagement+plugins块:

plugins { id 'dev.flutter.flutter-plugin-loader' version '1.0.0' id 'com.android.application' version '8.1.0' id 'org.jetbrains.kotlin.android' version '1.9.22' }

改完之后同步一次,报错消失,构建速度还会稍微提升。注意版本号要根据项目实际使用调整,不要直接照抄。

5.3 打包时 java.lang.AssertionError 问题

在打 HAP 包时,我撞上了一个挺隐蔽的构建崩溃,日志长这样:

java.lang.AssertionError: java.lang.Exception: Could not close i...

被问到完整堆栈里,卡在增量缓存相关的类关闭环节。这类问题在即时编译环境下少见,但切换分支环境后容易触发,主要是历史构建缓存与当前工具链不匹配。解决组合拳:

flutter clean rm -rf .dart_tool rm -rf ohos/.idea rm -rf ohos/build

然后重新flutter pub get,再进 DevEco 重新同步。实测这样处理后,AssertionError 彻底消失。如果再次出现,检查一下磁盘剩余空间和 inode 是否耗尽,OpenHarmony 构建会生成大量临时文件,空间不足也会导致写入关闭异常。另外注意不要用杀毒软件的实时扫描去盯构建目录,Windows 上被文件锁定导致的关闭异常很常见。

5.4 Impeller 与 PlatformView 的兼容性取舍

渲染引擎方面,OpenHarmony 分支逐步在跟进 Flutter 的 Impeller 渲染管线。Impeller 的目标是解决 Skia 在部分 GPU 上的着色器编译掉帧问题,但它的设备覆盖范围还在扩展中。手语 App 里有视频播放,视频画面必然涉及原生 Surface 和 Flutter 渲染层的叠加,如果两者兼容不好,就可能出现列表滚动时视频区域白屏、闪烁或者画面撕裂。

我这边实际遇到的情况是:在部分 OpenHarmony 开发板上,开启 Impeller 后卡片列表快速滚动时偶尔掉帧,关掉切回 Skia 后反而更稳定。所以在项目里没有强行追求新渲染后端,而是通过 Flutter 引擎的FLUTTER_ENGINE_SWITCHES环境变量或者构建参数,在 HAP 里显式关闭 Impeller,保证视频列表的稳定性。

如果你的应用没有 PlatformView 或者视频播放,只是普通列表和文字,那 Impeller 可以放心开,收益是滚动动画更丝滑。就手语学习这种重视频、重列表的场景,我建议是“列表和视频尽量分层,避免同一个屏幕里出现多个原生 Surface 同时渲染”。详情页的视频全屏播放,列表页只放视频封面图,这样 PlatformView 的压力会小很多,渲染也稳定。

6. 实战中遇到的问题与排查技巧实录

6.1 列表加载偶发乱序与重复

有一次真机上连续切分类再回首页,发现分类顺序变化,甚至出现重复卡片。排查后发现不是 UI 的问题,而是rootBundle.loadString在并发调用时,我对同一份数据执行了多次加载,并且把结果直接塞进了 Cubit 的状态,后一次的加载覆盖前一次,中间穿插了状态更新,导致列表在 UI 层出现了冗余。

解决方式是引入缓存:Repository 里缓存第一次加载的结果,后续fetchCategories直接返回缓存,不重复读资源。同时给 Cubit 的load()加了防重入判断,如果状态已经是 loaded 就不重复拉取。

class CategoryRepository { List<SignCategory>? _cache; Future<List<SignCategory>> fetchCategories() async { if (_cache != null) return _cache!; final raw = await rootBundle.loadString('assets/data/categories.json'); final decoded = json.decode(raw) as List<dynamic>; _cache = decoded .map((e) => SignCategory.fromJson(e as Map<String, dynamic>)) .toList(); return _cache!; } }

这个坑提醒我:数据加载的幂等性,在高频操作场景下必须从一开始就考虑。

6.2 组件通信与局部刷新

分类卡片上的学习进度,需要根据用户查看详情的行为实时更新。这个问题本质是组件通信:详情页里看完一个视频,回到列表页,卡片进度要更新。我用的是ValueNotifier+AnimatedBuilder组合。

每个分类卡片内部持有一个ValueNotifier<double>,默认是 0。详情页播放完成后,通过回调让页面层拿到对应分类 id,然后更新对应的ValueNotifier。卡片只监听自己的 notifier,因此只有一个卡片重建,不需要整个列表 setState。

class _CategoryCard extends StatelessWidget { final SignCategory category; final ValueNotifier<double> progressNotifier; @override Widget build(BuildContext context) { return AnimatedBuilder( animation: progressNotifier, builder: (context, child) { return _buildCard(progressNotifier.value); }, ); } }

这种方式比 Provider 直接管全量状态更轻量,特别适合列表内部小范围动态变化。我到现在都认为,不是所有状态都该全局化,局部状态用局部刷新是更优解。

6.3 跳转原生页面后 Flutter 侧状态丢失

项目里有一步操作需要跳转到 OpenHarmony 原生页面,比如调起系统分享。这里就碰到 Flutter 跳转原生 Activity 后,返回时 Flutter 页面被重建、列表滚动位置丢失的问题。根因是原生页面不在 Flutter 的路由栈里,返回时 Flutter view 被重建,页面状态没有持久化。

处理方式是,跳转原生页面之前,先用一个NavigatorObserver捕获当前路由信息,并把关键状态(当前分类 id、滚动位置、过滤条件)写进一个全局状态缓存。原生页面返回后,在onResume里恢复状态。如果场景允许,也可以干脆把分享功能用 Dart 侧的share包实现,不路由原生页面,这样最省事。

6.4 常见问题速查表

现象可能原因解决方案
列表返回后滚动位置丢失页面被销毁,未保存滚动偏移使用 AutomaticKeepAliveClientMixin + PageStorageKey
分类卡片顺序错乱Repository 并发加载导致状态覆盖缓存首次加载结果,load 方法做防重入
Tab 切换动画拖节奏默认 animationDuration 过长设Duration.zero或降低到 80ms
视频区域白屏Impeller 与 PlatformView 渲染冲突关闭 Impeller,分层渲染
打 HAP 报 AssertionError构建缓存与工具链不匹配flutter clean + 删除缓存目录
Gradle 插件格式报错命令式 apply 已废弃迁移到 plugins 声明式加载
视频播放无声音或黑屏原生通道未正确实现检查 EventChannel 回调与 AVPlayer 状态机
真机安装失败签名配置缺失或 HAP 包名冲突检查签名证书与 module.json5 配置

7. 一点实操层面的体会

如果让我总结这次分类列表实战最核心的经验,我会说三件事。第一,模型设计是列表的灵魂。分类、条目、视频路径、颜色、难度这些字段,先花半小时想清楚,后面省十天工。第二,状态管理一定要和数据源解耦。Cubit 不是唯一选择,但用 Repository 把数据来源藏住,是所有方案里最稳的做法。第三,OpenHarmony 适配没有想象中可怕,但要预留平台通道的抽象层,不要在一个页面里直接调原生方法。

分类列表模块跑通之后,最爽的瞬间是把同一个 HAP 装上平板和手机,看到网格卡片在两种屏幕上自动调整布局,而 Dart 代码没有改过一行。这种跨端复用带来的踏实感,正是我选择 Flutter 做这个项目的初心。

接下来我准备在这个基础上扩展两个方向:一个是在分类卡片上叠加每日学习计划的推荐逻辑,另一个是把“手语动作相似词”做成一个底部弹出的对比列表。后面有进展了再继续写文分享。

返回列表