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

资讯详情

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

Flutter跨平台开发实战:OpenHarmony微动漫App标签筛选实现

Flutter跨平台开发实战:OpenHarmony微动漫App标签筛选实现

1. 先把项目背景和整体思路捋清楚

1.1 微动漫App到底在做什么,标签筛选解决的痛点

先说清楚这款App的定位。我做的这个微动漫App,核心是集聚一批时长短、节奏快的动漫内容,用户刷起来不费劲。内容和传统长视频平台不一样,微动漫的单个作品可能只有两三分钟,但量很大,题材也杂:搞笑、国漫、热血、治愈、悬疑、泡面番,什么都有。如果只靠一个首页按时间流硬推,用户很快就会腻,因为他想看的是“搞笑”但首页一直给他推“悬疑”,体验非常差。

所以标签筛选这个功能,表面上看是“一排按钮点击过滤”,本质上是给用户一个内容导航入口。用户点了某个标签,列表立刻只显示符合这个标签的作品,这个交互在整个App里属于高频路径。我在设计的时候把它当成核心模块来做,而不是简单地在列表页上面塞一行按钮。

标签筛选的实现,难点倒不是单个标签的过滤,而是几个地方容易翻车:标签数据怎么组织,筛选逻辑放在哪一层处理,过滤之后列表怎么更新动画才不会显得突兀,以及多标签组合时状态同步会不会乱。最关键的一点是,在OpenHarmony这种新平台上,Flutter的生态环境和Android不完全一样,部分插件要重新适配,这直接影响到能写多少原生逻辑、能依赖哪些第三方库。所以我一上来就确定了数据层和UI层的边界,把标签筛选做成纯Dart逻辑,原生通道只用来做少量的系统能力调用,这样平台差异的影响面就能控住。

1.2 为什么选择Flutter + OpenHarmony组合,而不是自研框架

实际上这个项目最开始考虑过两条路:一是OpenHarmony自家的ArkUI组件开发,二是Flutter跨端方案。ArkUI目前做轻量应用确实没问题,但有两点让我下不了手。

第一是存量代码。微动漫App已经有一套成熟的Flutter业务代码,里面有自定义渲染的播放进度条、复杂的动画场景、图片缓存体系,这些用ArkUI重写一遍,工作量不是一个量级。第二是团队的技术栈惯性。Flutter的状态管理、路由、组件体系大家已经磨合了很久,用ArkUI相当于推倒重来。

Flutter for OpenHarmony正好补上了这个空缺。OpenHarmony官方提供的flutter_flutter和flutter_engine适配分支,核心渲染走的是Flutter自绘引擎,UI表现和Android端能保持一致。我实测下来,常见的Widget、动画、滚动组件在鸿蒙设备上表现都正常。唯一需要留意的是一些底层插件,比如网络库、文件路径获取、设备信息等,这些和操作系统绑定得深,必须走鸿蒙的原生通道重新接一遍。

这个选择带来的收益非常直接:标签筛选这套核心筛选逻辑,在Android端和OpenHarmony端可以完全复用一套Dart代码,UI细节也能在两端保持像素级一致。后续就算要加新的内容展示模块,也只需要在Flutter层扩展。

2. 标签筛选功能的数据模型与状态设计

2.1 标签数据结构与“多选组合”逻辑

标签筛选第一步不是写UI,而是定数据模型。如果这一步没想清楚,后面做多选、全选、取消筛选都会很痛苦。我当时定义的模型结构大概是这样的:

/// 动漫内容的基本信息 class AnimeItem { final String id; final String title; final String coverUrl; final List<String> tags; // 如 ['搞笑', '泡面番'] AnimeItem({required this.id, required this.title, required this.coverUrl, required this.tags}); } /// 筛选条件状态,所有标签的选中情况都记录在这里 class FilterState { final List<String> selectedTags; final List<AnimeItem> allItems; List<AnimeItem> get filteredItems { if (selectedTags.isEmpty) { return allItems; } return allItems.where((item) { // 只要包含任一所选标签就显示,这里用的是“或”逻辑 return item.tags.any((tag) => selectedTags.contains(tag)); }).toList(); } }

这里有个值得细讲的点:筛选逻辑到底是“或”还是“且”。用户选了两个标签,比如“搞笑”和“热血”,他期望看到的是“包含搞笑的作品”加上“包含热血的作品”,还是“同时包含搞笑和热血的作品”?

从用户视角来说,绝大多数内容平台的标签筛选都是“或”逻辑。比如用户想换口味,点“搞笑”又点“热血”,他的心态是:这两种我都能接受,你帮我都筛出来就行。如果你用“且”逻辑,他反而会觉得内容怎么变得这么少。所以我在FilterState里明确用了any,也就是“包含任意选中标签”就展示。这个决策在代码里就一行,但它决定了用户的筛选心智。

另外标签本身的存储也需要注意。标签的字符串是中文,直接比较没问题,但如果有带表情符号的标签,或者从后端拿到的标签带有空格,就会出现匹配不上。我在接入真实数据时统一做了一次trim,还做了大小写归一化。虽然微动漫标签基本没有英文,但预防成本很低,顺手就做了。

2.2 用Cubit还是Bloc?动态筛选状态管理选型

状态管理方案我纠结过一段时间的。标签筛选这个场景,状态变化其实很简单:点击标签,状态更新,列表刷新。理论上用setState都能搞定。但工程上一个很重要的考虑是“这个状态要不要和别的模块共享”。

比如首页的热门推荐模块,用户打完标签之后,推荐列表的内容如果跟着变化,那筛选状态就不能只放在标签栏页面里,要提升到更上层去管理。我当时为了后续好扩展,直接采用了Bloc的状态管理方案,但具体到筛选这个模块,用的是Bloc的简化版——Cubit。

Cubit和Bloc的区别很多人搞不清楚。简单说,Bloc需要定义Event、State再加Bloc类,事件驱动很明显,适合复杂流程;Cubit只需要一个类,里面放若干方法,每个方法触发一次状态发射。标签筛选这种逻辑,本质上是“用户点一下,内部重新计算结果”,用Cubit就够,没必要写一堆Event类增加样板代码。

我的Cubit实现大致长这样:

class FilterCubit extends Cubit<FilterState> { FilterCubit(FilterState initialState) : super(initialState); void toggleTag(String tag) { final currentTags = List<String>.from(state.selectedTags); if (currentTags.contains(tag)) { currentTags.remove(tag); } else { currentTags.add(tag); } emit(state.copyWith(selectedTags: currentTags)); } void clearTags() { emit(state.copyWith(selectedTags: [])); } }

这里有一个非常容易踩的坑:copyWith的时候一定要把selectedTags做一次深拷贝,不能直接传原来的List实例。因为Dart的List是引用类型,如果你在Cubit里emit一个新状态,但内部引用的List还是旧的,旧的List又被UI某个地方持有,就会出现“界面改了但状态没变”的诡异问题。我在项目里统一在copyWith里做List.from,规则就是宁可多拷贝一次,也不要共享引用。

再补一句,如果你用Bloc,那流程就变成toggleTag事件被dispatch,然后Bloc内部通过mapEventToState异步返回新状态。不是说不行,但在这个模块里Bloc的async流程属于过度设计。Cubit直接同步emit,过滤计算也是内存操作,响应速度快得多。实测在千级数据量下,一次筛选更新的耗时在几毫秒内,用户完全感受不到延迟。

3. 标签筛选UI的实现细节

3.1 标签栏布局与交互反馈

标签筛选的UI层,最关键的是两部分:顶部的标签栏和下方的内容列表。标签栏如果做得太重,会抢内容区的注意力;做得太轻,用户又不知道当前筛的是哪些标签。我最后采用的水平滚动标签条,配合选中态的高亮。

标签按钮实现用的ChoiceChip,两端交互细节比较多。ChoiceChip有一个selected属性用于控制选中状态,配合selectedColor和labelStyle可以调出想要的视觉效果。我在实测中发现,如果标签数量超过屏幕宽度,必须用SingleChildScrollView包起来,并设置scrollDirection: Axis.horizontal,否则在窄屏设备上会出现溢出告警。溢出这个东西在开发调试阶段容易被忽略,但到了真机上就是黄条和渲染错乱。

标签栏的选中反馈还要注意动效节奏。用户点击一个标签,他是希望立刻看到“选中”的视觉变化,同时列表内容平滑刷过去。我用了AnimatedContainer来处理选中背景色的渐变,时间控制在150ms,太短没感觉,太长会让快速连续点击的体验卡顿。你连续点“搞笑”再点“热血”,如果每个标签都重置动画,整个标签条会显得很抖。一个优化方式是:动画只作用于选中态变化的那个标签,其他标签不重新build。

标签栏还涉及一个无障碍细节。既然标签是筛选条件,它承担了一定的“功能按钮”属性,我用Semantics包裹了标签条,让屏幕阅读器能读出来当前选的是哪些标签。这算是个加分项,但内容平台还是值得做。

3.2 筛选结果列表的更新与动画衔接

列表数据变化时,如果直接setState然后ListView重建整个列表,用户会明显看到内容“闪”一下,尤其在快速切换标签时,列表会闪烁甚至跳到顶部。为了平滑过渡,我选择了AnimatedSwitcher来做列表容器的切换:每次filteredItems变化时,用新列表替代旧列表,并加一个轻微的淡入淡出效果。

AnimatedSwitcher有个注意点,它默认要求新旧子组件的key不同,才能正确识别变化并执行动画。我一开始没设置key,结果怎么切都不出动画,后来是给ListView.Builder加了一个ValueKey(filteredItems.length),才正常。之所以用数据总数做key,是因为过滤过程中items的组件结构本身可能没变,但长度变了,这个key能强制Switcher认为这是两个不同的列表。

AnimatedSwitcher( duration: const Duration(milliseconds: 250), child: ListView.builder( key: ValueKey(filteredItems.length), itemCount: filteredItems.length, itemBuilder: (context, index) { return AnimeCard(item: filteredItems[index]); }, ), )

另外,列表里包含的AnimeCard是图片加标题加标签的小卡片。筛选之后,如果卡片内容变了但组件被复用,可能会出现图片闪现旧图的情况。为此我给AnimeCard的构造函数设置了一个基于item.id的key,保证数据变化时组件重新创建,不会复用旧图片的渲染状态。

列表滚动位置也要注意。用户按标签筛选后,列表长度变化,如果之前滚动位置在很下面,而新列表只有几个元素,框架会强行调整滚动位置到合理区域,表现就是“跳了一下”。我通常会在切换筛选条件时把列表的scrollController.jumpTo(0),让用户的视线回到顶部重新开始浏览。你如果不处理这个细节,用户在快速切换标签时很容易晕。

4. 完整实操:从创建Flutter工程到跑通OpenHarmony端

4.1 环境准备和工程初始化

这个项目的开发环境,我是在macOS上搭的,用的OpenHarmony官方维护的Flutter分支。配置步骤有点繁琐,但照着做一次之后,后面本地开发就很顺畅了。

先要把OpenHarmony的Flutter SDK克隆到本地,注意不能直接用Flutter官方的SDK,因为OpenHarmony适配分支的底层引擎、插件注册机制和官方版本有差异。版本对应关系也要留意。我遇到了一个很常见的坑:本地Flutter SDK版本和项目里的flutter版本不一致,跑起来就会报类似"The current configured Flutter SDK is not known to be fully supported"的警告。这种警告不一定影响构建,但它意味着你的依赖解析可能匹配错版本,最终生成的代码行为无法保证。建议团队统一指定一个固定版本,用fvm做版本管理,项目根目录放.fvmrc,新同事拉代码后一条命令就能切换。

OpenHarmony项目的构建产物是hap包,你需要下载DevEco Studio来跑模拟器和构建。DevEco Studio本质上就是定制版的IDE,但它和Flutter的关联方式需要手动配置:在DevEco Studio里打开你生成的.ohos目录工程,然后配置SDK路径指向你克隆的OpenHarmony Flutter SDK。配置不对的时候,常见报错是找不到flutter引擎的库,或者构建时直接不认识CMake脚本。

如果你是在自己电脑上从零开始,我大概梳理一下必要的准备步骤:

  • 安装Node.js 14以上版本,OpenHarmony的工具链部分依赖node脚本。
  • 安装DevEco Studio和对应的SDK、Toolchains。
  • 克隆Flutter for OpenHarmony SDK,并配置环境变量。
  • 用flutter create --platforms ohos .在项目目录生成OpenHarmony平台工程。
  • 在DevEco Studio中导入生成的ohos目录,等它完成Gradle和CMake的依赖同步。

整个环境配下来大概需要一个多小时。如果你是第一次接触,最好不要中间跳步,特别是SDK路径配置那一步,很多后续的native插件编译问题都跟路径对不上有关系。

4.2 核心代码实现,直接可抄作业

筛选功能的完整实现,我分成三层来讲:入口页面、Cubit逻辑、列表渲染。

入口页面是一个带AppBar的普通页面,AppBar下方嵌入标签栏,然后占满剩余空间的是列表区域。页面创建时,从仓库层加载全部动漫数据,然后初始化FilterCubit:

class TagFilterPage extends StatelessWidget { final FilterCubit filterCubit; TagFilterPage({required List<AnimeItem> items}) : filterCubit = FilterCubit(FilterState(allItems: items, selectedTags: [])); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('微动漫')), body: Column( children: [ _TagBar(cubit: filterCubit), Expanded( child: BlocBuilder<FilterCubit, FilterState>( bloc: filterCubit, builder: (context, state) { final items = state.filteredItems; return AnimatedSwitcher( duration: const Duration(milliseconds: 250), child: ListView.builder( key: ValueKey(items.length), itemCount: items.length, itemBuilder: (context, index) { return AnimeCard(key: ValueKey(items[index].id), item: items[index]); }, ), ); }, ), ), ], ), ); } }

_TagBar的实现就是接收所有可用的标签列表,遍历生成ChoiceChip。标签列表从哪来?我从item数据里把所有tags抽出来然后去重,保证标签栏上不会出现重复项。这里不建议硬编码标签集合,因为运营后面加内容类型,标签是动态的。

class _TagBar extends StatelessWidget { final FilterCubit cubit; _TagBar({required this.cubit}); @override Widget build(BuildContext context) { final allTags = cubit.state.allItems .expand((item) => item.tags) .toSet() .toList(); return BlocBuilder<FilterCubit, FilterState>( bloc: cubit, builder: (context, state) { return SingleChildScrollView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( children: allTags.map((tag) { final isSelected = state.selectedTags.contains(tag); return Padding( padding: const EdgeInsets.only(right: 8), child: ChoiceChip( label: Text(tag), selected: isSelected, onSelected: (_) => cubit.toggleTag(tag), ), ); }).toList(), ), ); }, ); } }

如果你有多选后一键取消的需求,加一个“全部”按钮,它的逻辑是调用cubit.clearTags()。这个按钮的样式要做区分,不要让用户分不清它是标签还是操作项。我实践下来,用TextButton放在最左侧,或者放在标签栏的尾部,都比混进标签里好。我这次放在最前面,选中态就是没有高亮,旁边配一个“已选n个”的小文字提示。

4.3 在OpenHarmony真机上运行与调试

代码写完,接下来是最折磨人也最兴奋的一步:把App跑到鸿蒙设备上,确认标签筛选的交互在真实环境是流畅的。用真机调试时,DevEco Studio通过hdc工具连接设备,效果类似于adb。我遇到的第一个问题就是hdc连接不稳定,设备列表里能看到设备,但点击运行一直提示超时。

排查下来发现是电脑的USB驱动问题。macOS上偶尔会出现设备没有正确授权,需要在DevEco Studio的设备管理里重新信任设备。Windows环境下更麻烦,要手动安装HiUSB驱动。如果你只是做调试,用远程模拟器也能跑通大部分流程,但涉及滚动手感、图片加载这种体验性问题,还是建议上真机判断。

App跑起来之后,我重点观察了三件事:第一个是标签点击到列表刷新有没有肉眼可见的掉帧,因为OpenHarmony的Flutter引擎是新适配的,性能表现和Android原生有差距。实测下来,列表长度在100条以内的过滤操作,帧率稳定在60fps左右,没有卡顿。第二个是图片加载。列表卡片里有封面图,如果你用flutter的Image.network,底层走的是OpenHarmony的HTTP能力,需要确保网络权限在module.json里已经配置。我一开始漏配了ohos.permission.INTERNET,结果列表能渲染但图片全部加载失败,控制台报了一堆网络异常。第三个是返回手势。OpenHarmony的系统返回手势和Flutter的导航逻辑交互时,偶尔会触发重复pop,导致页面退出两次。这个我通过给NavigatorObserver加一个状态锁,限制同一帧内只能pop一次。

运行调试真机时,日志输出也要注意。DevEco Studio控制台会把Flutter层和系统层的日志混在一起,如果没有做过滤,找一条业务日志非常痛苦。我自己一般用hdc shell hilog配合关键字过滤,只输出包含flutter和tagfilter的日志,效率高很多。

5. 常见问题与排查技巧实录

5.1 状态丢失与页面切换的坑:“Navigator切换页面后状态会不会丢”

在写标签筛选时,有一个问题大家问得特别多:用Navigator跳转到详情页再回来,筛选状态会不会丢失?我只能说,它取决于你的状态放哪。

如果你把FilterCubit放在页面的State里,Navigator.push一个新页面,然后pop回来,State还在,筛选状态按理说不会丢。但如果你在push的过程中,因为某些原因页面被重建了,比如系统内存不足回收了资源,状态就没了。这个在Android上很常见,OpenHarmony也有类似的生命周期回收机制。保险的做法是用一个更高层的状态容器,把FilterCubit实例放在页面路由的arguments里,或者用一个全局的Provider管理,而不是让每个页面自己new一个Cubit。

我这边更推荐后一种方式:在App入口统一初始化一个AppScope级别的FilterCubit,所有页面通过依赖注入获取同一个实例。这样即使某个页面被回收,重新打开时还是能拿到之前的筛选状态。代价是你需要区分“页面临时筛选”和“全局筛选”的边界,比如你从首页标签点进内容列表,可能希望筛选条件随页面关闭而重置;但如果是从“我的关注”页面进入标签筛选,用户可能期望保留筛选条件。这个业务判断只能你来定,没有通用规则。

5.2 Impeller渲染引擎与绘制异常

Flutter的新版渲染引擎Impeller,在设计目标上是替换Skia,提供更稳定的GPU绘制。OpenHarmony适配的Flutter分支,对Impeller的支持还没有完全成熟。我在开发中遇到过一个诡异的现象:标签切换后列表出现短暂的黑块,一闪而过,截图截不到,但肉眼能看见。

我当时怀疑是数据问题,后来在渲染层面排查,发现是Impeller在OpenHarmony设备上做纹理回收时,缓冲区的同步没做好。最后的解决办法很朴素:临时把Impeller关掉,改回Skia渲染。这个操作可以在Info.plist或者对应平台的配置文件里设置。对于内容型App,Skia的渲染稳定性在现网设备上已经被大量验证过,牺牲一点新引擎的华丽特性,换来显示稳定是值得的。

另外,Flutter的动画性能也和渲染引擎强相关。如果你在标签筛选时用了比较重的阴影或者模糊效果,在低端鸿蒙设备上可能掉帧。我实测下来,卡片阴影用BoxShadow分布渲染,在渲染负载高的翻页场景会有轻微掉帧,后来改成图片自带阴影或轻量的Container decoration,明显好转。

5.3 EventChannel双端通信的适配要点

标签筛选本身不涉及原生通信,但它依赖的底层能力,比如内容列表里的敏感词过滤、埋点上报,都需要走EventChannel。OpenHarmony的Flutter插件体系跟Android不一样,EventChannel的注册方式有差异,这里我单独提一下。

在Android里,你用GeneratedPluginRegistrant自动注册,插件类实现MethodCallHandler就完事了。但OpenHarmony这边,第三方插件往往需要手动在工程的ohos目录里添加依赖,然后在MainAbility的OnStart方法里注册。如果你发现调用原生方法一直返回null,或者MethodChannel没有任何响应,多半就是插件没有注册上。

调试EventChannel问题,我建议先写一个最小的测试通道,只传一个字符串回传,确认链路通了再挂业务逻辑。不要一上来就调试复杂结构,因为双端类型映射很容易出问题:Dart侧的Map到鸿蒙侧的Map字段类型不一致,当时会不报错,只是读取结果不对。最典型的是数字类型,Dart的int到鸿蒙的int转过来默认是32位,大数会被截断,要用Long显式接收。标签筛选的埋点数据基本是字符串和整数,也踩过这个大数截断的坑,后来统一在原生侧转成字符串回传,问题就没了。

5.4 工程构建和插件相关的其他坑

构建Flutter for OpenHarmony工程时,用Gradle做原生依赖管理,出现类似“you are applying flutter's main gradle plugin imperatively using the apply```的方法”这种警告时,不要忽略。这个警告表面上是说Gradle插件的应用方式不标准,但如果你强行忽略,后面很可能在CMake配置阶段报错,导致整个open harmony构建失败。

正确的处理方式是按照提示把flutter gradle插件的配置改成标准方式,不同Flutter版本配置位置有区别,要看当前SDK的文档。我的经验是最新版本里,需要在根build.gradle里用plugins块声明,然后把app模块的apply改成显式依赖。改完之后,构建速度也会略快一些。

如果你用Android Studio开发Flutter,又想在这台机器上同时跑OpenHarmony构建,要注意环境变量里的PATH顺序问题。因为两边都可能配置了ohos命令和gradle,顺序不对会导致解析到一个错误的命令。我提供个笨办法:OpenHarmony相关命令用绝对路径调用,别依赖PATH。

还有一个常见问题:OpenHarmony模拟器不支持某些桌面GPU特性,导致Flutter的引擎初始化失败。如果你用的是模拟器调试,报错和引擎GL相关的初始化失败,直接换真机测试,别在模拟器上浪费时间。


标签筛选这个功能,工作量看起来不大,但真正做到体验顺滑、状态稳定、跨平台不翻车,需要打磨的细节还是不少。我个人最大的体会是:不要把筛选逻辑和UI耦合在一起,状态单独抽出去管理,后面怎么加新玩法都能兜得住。目前这个微动漫App的标签模块已经稳定跑在OpenHarmony和Android双端,下一步我准备把筛选条件加一个“排序”维度,比如按更新时间、按热度排序,底层还是沿用现在这套FilterState结构,扩展起来不费劲。

返回列表