ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Flutter在OpenHarmony电商分类导航模块的实践与优化

Flutter在OpenHarmony电商分类导航模块的实践与优化

在OpenHarmony应用生态里做电商客户端,团队最初的第一反应是用ArkUI的ArkTS语言从零搭一套原生页面。但真正评估完技术栈、组件复用成本和多端维护压力之后,我们最终为某商城项目X选择了Flutter跨端方案来承载商城业务,第一个攻坚目标就是分类导航模块。

分类导航模块,说白了就是用户在商城首页点进"分类"入口之后看到的那套界面:左侧一列一级分类,右侧跟着切换的商品分组卡片。它看起来简单,真正做起来牵扯的东西一点不少——分类数据结构怎么设计、左右两侧如何联动、滚动定位怎么计算、首帧渲染怎么优化,每一项都会直接影响用户找商品的效率。文章里所有代码片段都基于Dart 3.x与Flutter 3.19+,鸿蒙侧宿主工程基于OpenHarmony API 10+版本,全部在真机上跑过。适合正在评估鸿蒙上选型Flutter的开发者、已经集成好Flutter但不知道怎么设计复杂交互页面的同学、以及想确认跨端框架在鸿蒙真机上能不能扛住电商场景的朋友。

1. 选型复盘:OpenHarmony上用Flutter做导航模块,值不值

先说结论:对商城业务来说,值得,但前提是你得接受"部分原生能力仍要绕道"这个事实。我们在启动这个项目前做过一轮详细的对比,把ArkTS原生方案、Flutter跨端方案和WebView方案放在一起评过,主要从开发效率、性能表现、生态成熟度和团队学习成本四个维度看。

1.1 为什么非得用Flutter而不是ArkTS原生生写

OpenHarmony的ArkUI框架设计得相当不错,声明式语法、状态管理、组件联动做得都很顺手,如果是从零开始且只服务于鸿蒙单端,原生方案基本是最好的。但我们的场景是:商城App未来至少要覆盖OpenHarmony设备和其他移动平台,后端逻辑和大部分UI组件希望一套代码多端复用。Flutter的跨端特性正好命中这个诉求,业务层用Dart写成一套,鸿蒙端、其他移动端共用,只有极小部分平台相关的代码需要单独适配。

另一个理由是团队历史代码。项目启动前,团队内部已经有了一套基于Flutter开发的商城客户端业务组件库,分类页、商品详情页、购物车这些页面在别的平台上都验证过稳定,直接复制到鸿蒙端能省掉大量重复开发时间。有人可能担心Flutter在OpenHarmony上性能打折扣,实际情况是:普通列表、图片加载、点击交互这类常规场景,性能表现非常接近原生。真正需要谨慎的是视频播放、复杂地图、相机这类重度原生能力,而分类导航模块恰好不碰这些,用Flutter做非常合适。

1.2 模块边界怎么划:导航模块到底该包含什么

在动手写代码之前,我们先把"分类导航模块"的边界定清楚了。它不是简单的两级页面,而是一个带有完整交互闭环的独立业务模块,拆解下来包含四块内容:

  • 分类数据的管理:从服务端获取分类树,解析、缓存、更新,以及分类图标的本地化管理。
  • 左侧一级分类栏:竖排展示一级分类名称,支持选中态切换、滚动跟随,选中后联动右侧内容更新。
  • 右侧商品分组区:展示当前一级分类下的二级分类入口和商品卡片,支持上下滚动、定位目标分组。
  • 联动交互逻辑:点击左侧分类滚动右侧到对应位置,滚动右侧时左侧选中项自动跟随,两边不能互相打架。

这个模块放在工程里是独立的一个大目录,内部再拆成数据层、UI层和状态管理层。边界划清楚之后,后面不管是新功能开发还是出问题排查,都不用在整个App里到处翻,效率高很多。

2. 数据层先行:分类树结构与本地缓存方案

分类导航模块的核心数据是一个两级分类结构。一级分类比如"手机数码""家用电器""服饰鞋包",二级分类是每个一级分类下的子项,比如"手机数码"下面有"手机""耳机音响""智能设备"。实际项目里分类可能有更多层级,但我们只取两级展示,三级及以下的深度通过二级分类页继续下钻。

2.1 分类模型的Dart定义与服务端协议约定

服务端返回的数据是JSON数组,结构长这样:

[ { "id": "c001", "name": "手机数码", "icon": "https://cdn.example.com/cats/phone.png", "children": [ {"id": "c00101", "name": "手机", "image": "https://cdn.example.com/cats/phone.png"}, {"id": "c00102", "name": "耳机音响", "image": "https://cdn.example.com/cats/headset.png"} ] }, { "id": "c002", "name": "家用电器", "icon": "https://cdn.example.com/cats/appliance.png", "children": [ {"id": "c00201", "name": "冰箱", "image": "https://cdn.example.com/cats/fridge.png"}, {"id": "c00202", "name": "洗衣机", "image": "https://cdn.example.com/cats/washer.png"} ] } ]

对应Dart模型不能只写一个简单的class。为了后续方便做缓存、比较和局部刷新,我在模型里额外加了几个字段:

class CategoryNode { final String id; final String name; final String iconUrl; final List<CategoryNode> children; // 本地标记字段,不参与服务端同步 bool _expanded; int _cacheHash; CategoryNode({ required this.id, required this.name, required this.iconUrl, required this.children, bool expanded = false, }) : _expanded = expanded; String get cacheKey => '$id-$name-${iconUrl.hashCode}'; Map<String, dynamic> toJson() { return { 'id': id, 'name': name, 'icon': iconUrl, 'children': children.map((e) => e.toJson()).toList(), }; } }

这里有个踩过的小细节:icon字段服务端返回的是图片URL,但你最好不要在模型层直接把它当网络地址用。我们在实际开发中发现,分类导航的第一帧渲染如果要依赖网络请求加载图标,首帧时间会爆炸到两秒以上。所以模型层只负责持有URL和本地缓存路径,真正渲染时先读本地缓存文件,缓存没有命中再走网络下载。cacheKey这个字段就是为了做缓存版本比对用的,服务端数据更新后hash变化,我们就知道要重新拉图标了。

2.2 本地缓存策略:首帧秒开的关键

商城分类导航能不能做到"秒开",关键不在UI代码多优秀,而在数据加载策略。用户上次打开App时已经把分类树请求了一遍,这次进来如果还要等网络回来才能渲染,体验会非常差。我们的做法是把分类数据和图标缓存分层处理:

  • 分类数据缓存:用shared_preferences存一份JSON字符串,每次App冷启动先读本地数据直接渲染页面,同时后台发起网络请求刷新缓存,服务端数据有变化再更新UI。
  • 图标缓存:图片文件统一存到应用私有目录下,缓存key用上面提到的cacheKey,URL变化或者分类名变化都会触发重新下载。
  • 网络请求优化:只在数据为空或者本地文件缺失时才显示骨架屏和加载动画,正常情况几乎看不到loading。
class CategoryRepository { static const _cacheKey = 'category_tree_cache_v3'; Future<List<CategoryNode>> loadCategories() async { final prefs = await SharedPreferences.getInstance(); final cached = prefs.getString(_cacheKey); if (cached != null && cached.isNotEmpty) { final parsed = jsonDecode(cached) as List<dynamic>; _lastLocal = parsed .map((e) => CategoryNode.fromJson(e as Map<String, dynamic>)) .toList(); // 后台刷新,不阻塞UI _fetchFromServer(); return _lastLocal; } return _fetchFromServer(); } }

缓存版本号这里要额外提醒一句:一旦你的分类模型加了字段、改了结构,务必手动手动把_cacheKey从v2升到v3。我们项目就出过一次问题:模型加了排序字段后没升版本,用户手机里残留的旧缓存被解析成新模型,虽然没崩溃,但分类顺序完全乱了,排查了大半天才发现。这种坑事后想想完全能避免。

3. 左侧分类栏:从静态列表到选中态联动

左侧分类栏UI看起来不过是一个垂直放列表,很多初学者会觉得写个ListView就行。真正把它做细节了才会发现一堆门道:选中态的背景和字体怎么处理、列表数据项变化时怎么保持选中项不丢失、分类项较多时怎么让当前选中项保持在可视区域中间。

3.1 整体布局结构:一列ListView加一列内容区

页面整体我用Row布局,左侧宽度固定为100dp,右侧用Expanded撑满剩余空间。左侧是一个纯列表,为了让选中项背景色能盖满整列而两端不留白,我用了ListView但给每个item的宽度设置为左列宽度,配合自定义的Container背景。

Widget buildCategoryPage() { return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( width: 100, child: _buildLeftCategoryList(), ), Expanded( child: _buildRightContentArea(), ), ], ); }

左侧列表这里有一个交互细节值得展开说。用户点击左侧某个类别之后,这个类别要变为选中态,并且要尽量显示在可视区域的中间位置,而不是仅仅高亮当前item。Flutter的Listview提供ensureVisible方法可以滚动到item可见,但它是懒加载的,如果在item尚未build的情况下直接调容易出问题。更稳妥的方式是给ListView设置controller,然后计算目标索引对应的offset,用animateTo滚动:

void scrollToSelectedIndex(int index, int totalCount) { // 估算每个item高度为50dp,加上分割线后是50.5dp const itemHeight = 50.0; const separatorHeight = 0.5; final targetOffset = index * itemHeight + index * separatorHeight; // 减去半个可视高度,让选中项尽量居中 final adjustedOffset = (targetOffset - _leftListHeight / 2).clamp(0.0, double.infinity); _leftController.animateTo( adjustedOffset, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); }

注意这种按行高估算offset的方式,要求item高度必须固定。所以左侧分类项的UI在设计时就不要允许内容换行,一行文字加上固定上下padding,高度恒定。否则偏移量算不准,滚动位置会飘。

3.2 选中态的视觉反馈与无障碍细节

选中态的默认做法是白底加高亮文字,但我们在真机上测试发现一个问题:左侧分类栏宽度只有100dp,如果选中态只用文字颜色区分,亮度较高或字体较小时,用户很容易看不出当前选中的是哪一项。后来我们把选中态改成了白色圆角背景块加主题色文字,未选中项是透明底加灰色文字,对比度一下就上来了。

Widget _buildCategoryItem(CategoryNode node, bool isSelected) { return Container( alignment: Alignment.center, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 14), decoration: BoxDecoration( color: isSelected ? Colors.white : Colors.transparent, borderRadius: BorderRadius.circular(0), ), child: Text( node.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14, fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, color: isSelected ? const Color(0xFFE93B3B) : const Color(0xFF666666), ), ), ); }

这里我特意没有给左侧列表加圆角背景,因为在100dp这么窄的列表里,圆角会让选中态和列边缘之间出现空隙,视觉上反而不整端。如果有设计师要求圆角引导牌风格,记得让背景块和列表等宽,只给右上和右下加圆角,保持左侧贴边。

无障碍这块也提一下:OpenHarmony环境里Flutter的语义树默认是开启的,如果我们不额外处理,VoiceTab逐个朗读分类项时会把"第1项""共10项"这类描述也读出来,非常聒噪。建议用Semantics包一层,只保留label和selected状态,把容器级语义关掉,用户用读屏软件时体验会好很多。

4. 右侧内容区:懒加载布局与滚动定位计算

右侧内容区承载的是当前一级分类下的二级分类入口和商品卡片。如果分类下的商品多,数据量会非常庞大,直接一次性渲染整个右侧列表会卡到没法用。这里最核心的问题有两个:一是如何做到"懒加载且懒渲染",二是左右滚动联动时的定位计算怎么做才准确。

4.1 右侧布局选型:为什么不直接用GridView而用CustomScrollView

我一开始直接选了GridView铺商品卡片,二级分组头用GridView的header不好意思,加上分组头塞进GridView比较别扭,而且分组头还需要跟着内容一起滚动。后来改成了CustomScrollView加SliverGrid的组合:

Widget _buildRightContentArea() { return CustomScrollView( controller: _rightController, slivers: [ for (var group in currentGroupList) SliverToBoxAdapter(child: _buildGroupHeader(group)), SliverGrid( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, mainAxisSpacing: 8, crossAxisSpacing: 8, childAspectRatio: 0.75, ), delegate: SliverChildBuilderDelegate( (context, index) => _buildProductCard(currentProductList[index]), childCount: currentProductList.length, ), ), ], ); }

使用Sliver的好处是自动支持懒加载,对OpenHarmony真机的内存占用非常友好。我们把每个分组的产品放在一组SliverGrid里面,每个分组头独立一个SliverToBoxAdapter,这样scroll到哪个分组头就能直接拿到它的offset。

关于商品卡片的宽高比这里多解释一句。电商场景下商品卡片通常是上图下文,图片上方有商品名、价格、销量等信息,比例如果只定成1:1会让卡片高度不够,0.75这个值是我们拿真机调过的,能保证两列布局下图片区域占约60%高度,底部信息区不至于被挤掉。

4.2 分组定位的offset精确算法

右侧滚动定位的核心问题:点击左侧第N个分类,右侧要滚动到该分类对应分组头的位置。由于右侧分组头和分组内容的高度不确定,不能像左侧那样按固定高度估算,必须用RenderSliver的几何信息动态计算。

double getGroupOffset(int groupIndex) { final scrollable = _rightController.position.context.notificationContext; if (scrollable == null) return 0; final renderBox = scrollable.findRenderObject() as RenderBox; final topOffset = renderBox.size.height; double totalOffset = 0; for (int i = 0; i < groupIndex; i++) { final headerContext = _groupHeaderGlobalKeys[i].currentContext; if (headerContext != null) { final headerRender = headerContext.findRenderObject() as RenderBox; totalOffset += headerRender.size.height; // 每个分组下面的商品也占高度,这里通过全局key拿到分组容器尺寸 totalOffset += _groupContainerGlobalKeys[i].currentContext == null ? 0 : (_groupContainerGlobalKeys[i].currentContext!.findRenderObject() as RenderBox).size.height; } } return totalOffset; }

这段代码的真正难点在于:分组头的位置是随内容动态变化的,右侧的CustomScrollView又会对当前可视区域外的Sliver做惰性布局,直接通过GlobalKey拿不到未渲染的组。我实际用的做法是,在数据层存储一份"每个分组的estimatedHeight",从接口返回的二级分类数量、商品数量和平均卡片高度预估,算出一个非常接近真实值的offset,滚动到位后再通过渲染对象修正一帧。

从工程角度讲,这种"预估值加动态修正"的方案比精确计算稳定得多。用户可以感知到的是滚动结束后右侧内容可能与预期有一点点偏差,但下一帧就会自动对齐,肉眼几乎看不出问题。这里的关键点是不要试图在scrollTo完成后立刻做二次修正,因为此时框架可能还没完成布局,正确做法是在end状态下延迟一帧再修正。

5. 双向联动的状态管理:两个交互方向不能互相打架

真正的难度在于双向联动,也就是点击左侧分类时右侧要跟着滚动,右侧手动滚动到另一分组时左侧选中项也要自动切换。两个方向共用一套滚动状态,处理不当就会出现抖动、回跳甚至死循环。

5.1 状态管理选型:轻量级InheritedWidget加状态分离

分类导航页的状态并不算特别复杂,我考量过Riverpod、Bloc和Provider,最终选择了自带加一个轻量级状态类结合ChangeNotifier。理由有三个:模块内共享状态少、联动逻辑不需要跨页面传递复杂状态、团队对状态管理方案的熟悉度排序中Provider最高。

不过有一点特别提醒:状态不能全部塞进一个Notifier里。左侧选中项、右侧当前分组索引、右侧滚动位置这三者是三个不同频率变化的数据,如果放在同一个Model里,任何一方的notifyListeners()都会导致整个导航页刷新,性能损耗很大。我实际拆成了三个:

class CategorySelectionModel extends ChangeNotifier { int leftSelectedIndex; int get rightGroupIndex => leftSelectedIndex; } class CategoryScrollState extends ChangeNotifier { bool isAutoScrolling = false; int currentVisibleGroupIndex = 0; }

isAutoScrolling这个标志是联动防抖的关键。当点击左侧触发了右侧的animateTo,在动画过程中右侧的滚动监听器会不断回调,如果不把这次滚动标记为"程序主动触发的滚动",监听器会以为用户手动翻页了,又回头去改左侧选中项,左侧改了又触发右侧滚动,循环就出来了。

5.2 防抖与标记位:避免联动死循环的完整链路

联动防抖我前后调了三版,这里把最终顺滑的链路写清楚。

第一层标记:isAutoScrolling。点击左侧时置为true,animateTo执行完且滚动停止后再置为false。右侧滚动监听里,如果isAutoScrolling为true则直接返回,不做左侧选中项更新。这种方式简单有效,但有个几十毫秒的小窗口期,用户快速连续点击左侧分类时,上一次的停止回调可能还没执行完,下一次点击又开始了,标记位状态混乱。

第二层防御:用ScrollPosition的isScrollingNotifier代替手动标记。Flutter 3.19里可以直接监听滚动开始和结束事件,不会漏掉快速连续操作:

_rightController.position.isScrollingNotifier.addListener(() { if (_rightController.position.isScrollingNotifier.value) { _scrollState.isAutoScrolling = true; } else { // 滚动完全停止后,延迟释放标记,等分组的定位修正完成 Future.delayed(const Duration(milliseconds: 50), () { _scrollState.isAutoScrolling = false; }); } });

第三层是逻辑兜底。右侧滚动监听器里更新左侧选中项前,必须判断当前滚动方向是否跨过了分组边界。滚动到屏幕顶部的行为会连续触发N次监听,如果每次回调都去改左侧索引,左侧列表会跟着疯狂跳动。正确做法是记录上一次的分组索引,只有当索引确实发生变化时才刷新左侧选中态。

void _onRightScrolled() { if (_scrollState.isAutoScrolling) return; final currentIndex = _calculateVisibleGroupIndex(); if (currentIndex != _lastGroupIndex) { _lastGroupIndex = currentIndex; _selectionModel.updateLeftSelectedIndex(currentIndex); } }

updateLeftSelectedIndex内部会判断新索引和旧索引是否相同,相同则不发通知。这套三级防抖链路在真机上跑下来非常稳,快速来回滑动、正反方向切换都不会再出现抖动和跳动。

5.3 右侧滚动边界处理:回弹、阻尼与分组边界

OpenHarmony真机上滚动物理效果和标准Android有细微差别,尤其是分组边界处的回弹。我实测发现,在鸿蒙设备上使用默认的BouncingScrollPhysics会让右侧内容区滚过头再弹回来,这时分组边界检测很容易算出错误的索引。建议在导航模块里直接固定物理效果:

physics: const ClampingScrollPhysics()

ClampingScrollPhysics不会产生iOS那种回弹效果,边界处理更干脆,边界检测结果也更稳定。这一点在OpenHarmony上尤其重要,因为不同设备的阻尼系数确实有差异,统一用Clamping能抹掉这些差异。

另外,右侧内容区的最后往往还有一段留白或者"已经到底啦"提示,这段高度会干扰分组边界判断。我是在数据层算分组的高度时直接把这个尾部区域排除掉,不让它参与索引计算。头部如果有"搜索框"之类固定不滚的内容,也一样要排除。

6. 真机适配与性能优化:首帧秒开和长列表不卡

分类导航模块在模拟器上跑得飞快,一上真机各种问题就出来了。OpenHarmony真机普遍拿来做对照的分辨率都不太一样,适配和性能优化是必须实打实过一遍真机的环节。

6.1 图片加载与缓存:不能让网络图片拖垮滚动帧率

右侧商品卡片每个都有商品图,如果每张图都从网络加载,滚动时图片闪白、卡顿会非常严重。我们用的是cached_network_image加自定义的磁盘缓存目录。但注意一个坑:OpenHarmony环境下网络库的底层实现和标准移动端不太一样,图片加载库如果默认走系统网络栈,个别真机上会偶发请求超时。

解决方式是给ImageProvider设一个超时时间,并配合本地缓存目录优先读取。

CachedNetworkImage( imageUrl: product.imageUrl, cacheKey: product.cacheKey, maxWidthDiskCache: 720, // 限制磁盘缓存图片尺寸,避免OOM placeholder: (_, __) => buildPlaceholder(), errorWidget: (_, __, ___) => buildPlaceholder(), )

产品列表接口返回的商品图URL动辄是原图,如果不限制宽高直接缓存,磁盘占用会迅速膨胀到几百兆。maxWidthDiskCache设为720足够适配大部分情况下的商品卡片展示。另外我会在ListItem的build方法里对占位图做一个极小尺寸的本地绘制,一张灰色圆角矩形,避免用户看到一片空白。

6.2 真机分辨率适配:小屏大屏和折叠屏都要管

OpenHarmony生态设备屏幕范围很广,从手机到平板都有。我们用设计稿以375x812为基准做像素换算,但右侧商品卡片的crossAxisCount不能写死为2,否则在平板上卡片会拉得又宽又空。我用LayoutBuilder做响应式判断:

final crossAxisCount = constraints.maxWidth > 400 ? 3 : 2; final childAspectRatio = crossAxisCount == 2 ? 0.75 : 1.0;

左侧分类栏100dp在平板上会显得略窄,可以适当放宽到120dp。不过把SizedBox(width: 100)写死也没问题,因为分类栏项目文字都是短词,100dp在大多数场景下够用。

字体缩放是我一开始忽略的点。OpenHarmony真机系统如果开启了字体缩放,Text内容可能会截断或者换行,进而破坏左侧固定行高的假设,导致左侧滚动定位偏移量算错。我给左侧分类项外层包了一层固定高度约束,并关闭textScaler:

MediaQuery( data: MediaQuery.of(context).copyWith(textScaler: const TextScaler.linear(1.0)), child: _buildLeftCategoryList(), )

需要说明这个做法会让分类文字不随系统字体缩放,对于导航栏这种短词场景影响不大,反而能保证布局稳定。商品价格、名称等信息区则不阻断字体缩放,保证可读性。

6.3 首帧性能实测:从1820ms压到440ms

这块放最后写,是因为它最考验细节,也最出效果。优化前的首帧数据是1820ms,体感就是点开分类页白屏一下再出内容,完全不合格。做了三轮优化之后降到440ms,体感已经能达到"点开即见"的标准。

第一刀砍在网络请求上:本地缓存命中后直接渲染,不等待服务端响应。这一步把首帧从1820ms压到800ms。

第二刀砍在图片加载上:架构调整成"先渲染文字列表,再异步补图片",分类项图标从网络改为本地Asset和缓存混合。图标没加载出来时先显示灰色底和分类名,等图片回来了再替换。这一步之后首帧到550ms。

第三刀是布局耗时:右侧CustomScrollView初始渲染的分组数量控制到2个,其他分组等滚动接近时再构建。配合Sliver的惰性加载机制,右侧内容区初始化不再全量创建Item,首帧进一步降到440ms。

// 懒分组:只先渲染前两个分组,其余分组滚动到附近再触发加载 final visibleGroupCount = _scrollState.currentVisibleGroupIndex + 2; final visibleGroups = groupList.take(visibleGroupCount).toList();

这样处理虽然代码上多了一些状态判断,但换来的性能提升非常明显。对OpenHarmony这种生态仍在快速发展的平台来说,跨端框架跑电商场景完全可行,该抠的性能细节和大平台一个都不能少,抠完之后体验并不会输给原生方案太多。

个人最深的体会是:分类导航这种模块,难点从来不在某个单一技术点上,而在相互耦合的边界处理上。数据缓存、双向滚动、图片懒加载、布局惰性化,任何一个环节出问题,用户感受到的就是卡顿、跳动和加载慢。建议动手前先把数据模型和联动标记位设计清楚,后面会少走很多弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进