最近一直在折腾 OpenHarmony 上的 Flutter 跨平台开发,这个系列写到第 11 天,总算做到一个像样的电商实战页面了。今天要拆解的是商品详情页从零开发全过程,重点是把轮播图和点击跳转这条链路完整打通,中间踩了不少坑,也沉淀了一些在 OpenHarmony 环境下的特殊处理方案,分享出来给同样在做鸿蒙跨平台适配的朋友参考。
说句实话,商品详情页在电商 App 里是转化率最高的页面,也是 UI 交互最复杂的几个页面之一。轮播图、价格区、规格选择、底部操作栏、详情富文本,每个模块单独拎出来都不难,但组合在一起,还要适配 OpenHarmony 的窗口特性,就有点意思了。这篇内容适合已经跑通 Flutter 基础环境、想在 OpenHarmony 上练手商业项目的人,我会把代码结构、关键实现和真机调试心得全部展开讲。
1. 商品详情页整体设计与架构梳理
1.1 为什么用 Flutter 来写 OpenHarmony 的业务层
我之前在系列前几篇讲过,OpenHarmony 原生开发用的是 ArkTS + ArkUI,语法和生态与主流前端框架有很大差异。如果你团队里已经有 Flutter 的技术积累,或者需要同一套代码同时覆盖 Android、iOS、Web 和 OpenHarmony 这几个端,那 Flutter 的跨平台优势是非常明显的。OpenHarmony 社区目前已经有一个比较活跃的 Flutter 适配分支,能做到大部分基础组件和手势系统的映射,虽然还在持续完善,但跑一个商品详情页这种量级的业务页面完全够用。
选择 Flutter 的另一层原因在于电商页面的复杂度。——原生开发一个小程序或者说 ArkUI 页面,光是处理轮播手势嵌套和列表滚动的性能问题就要花不少精力,而 Flutter 的渲染管线是自己控制的,在复杂 UI 场景下更容易做性能优化。当然,这不是说 ArkUI 不行,而是从团队技术栈复用和迭代效率角度考虑,Flutter 在跨端业务上确实是更省力的一条路。
1.2 详情页功能拆解与信息架构设计
在动手写代码之前,我习惯先把页面拆成独立模块,这样做的好处是后面做性能优化和组件复用时会轻松很多。商品详情页通常包含以下几个核心区域:
- 顶部轮播图区:展示商品主图,支持自动播放、手动滑动、点击查看大图
- 商品信息区:标题、副标题、价格、划线价、销量、库存
- 规格选择区:颜色、尺码等规格项,点击弹出底部选择面板
- 店铺信息区:店铺头像、名称、进店看看入口
- 详情内容区:图文混排的商品详情,用富文本或者图片列表渲染
- 底部操作栏:客服、店铺、收藏、加入购物车、立即购买
我在项目里用一张表格把模块和对应的状态管理方式理清楚,开发的时候照着表格填代码就行:
| 模块 | 组件方案 | 状态来源 | 交互说明 |
|---|---|---|---|
| 轮播图 | 自研 GoodsBanner | 图片列表 | 自动播放,点击查看大图 |
| 价格区 | 普通 Text 组件 | Product 模型 | 无特殊交互 |
| 规格选择 | showModalBottomSheet | 本地状态 | 选中后回填到页面 |
| 底部操作栏 | 固定底部导航 | 本地状态 | 跳转购物车或立即购买 |
| 详情内容 | ListView + 图片 | 接口数据 | 网络图片懒加载 |
这篇文章主要完整实现轮播图和点击跳转链路,规格选择和底部操作栏会做一个简化版本,重点是把整体架构理顺,后续可以在不伤筋动骨的前提下扩展。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从零构建商品数据模型与 Mock 数据层
2.1 商品模型字段设计
商品详情页的数据来源,在真实项目里肯定是后端接口返回,但前端开发不能等接口,所以 Mock 数据层要提前设计好。我定义了一个 Product 模型,字段覆盖了详情页所有展示信息,同时也方便后面对接真实接口时做字段映射。
dart复制class Product {
final String id;
final String title;
final String subtitle;
final double price;
final double originalPrice;
final int sales;
final int stock;
final List<String> images;
final List<Spec> specs;
final String shopName;
final String shopAvatar;
Product({
required this.id,
required this.title,
required this.subtitle,
required this.price,
required this.originalPrice,
required this.sales,
required this.stock,
required this.images,
required this.specs,
required this.shopName,
required this.shopAvatar,
});
}
字段设计上有几个细节值得注意。价格我用 double 而非 int,是因为优惠活动里经常出现 199.9 这种非整数价格,避免后续要改类型。库存 stock 在 UI 上不直接展示,但下单时要传给后端做校验。images 用 List 保留顺序,轮播图的顺序由接口保证,前端不做重排。规格 Spec 单独抽一个类,方便扩展多组规格。
2.2 Mock 数据与接口替换思路
Mock 数据层我单独抽了一个 ProductRepository,对外暴露 fetchProductDetail 方法。这样做的核心原因是,后续从 Mock 切换到真实接口时,只需要改这一个方法内部实现,页面代码完全不用动。
dart复制class ProductRepository {
static Future<Product> fetchProductDetail(String id) async {
await Future.delayed(const Duration(milliseconds: 300));
return Product(
id: id,
title: 'OpenHarmony 定制款智能手表',
subtitle: '支持 Flutter 跨平台开发调试,鸿蒙生态适配优选',
price: 1299.00,
originalPrice: 1999.00,
sales: 2300,
stock: 56,
images: [
'https://example.com/images/watch_1.png',
'https://example.com/images/watch_2.png',
'https://example.com/images/watch_3.png',
],
specs: [
Spec(name: '颜色', values: ['曜石黑', '星空银', '远峰蓝']),
Spec(name: '版本', values: ['标准版', '尊享版']),
],
shopName: '鸿蒙优选旗舰店',
shopAvatar: 'https://example.com/images/shop_logo.png',
);
}
}
有人会觉得,做个详情页何必煞费苦心搞 Repository,直接页面里写死数据不就行了。我在实际项目中吃过亏,一开始图省事把 Mock 数据写在 Widget 里,后面接接口的时候,页面里到处是临时数据,改起来非常痛苦。Repository 多写几行代码,换来的是后续替换接口时改一个文件的清爽体验。
3. 轮播图核心实现与手势处理
3.1 自研轮播组件还是引入第三方库
轮播图组件在 Flutter 生态里有很多现成方案,比较有名的是 carousel_slider,功能完整也稳定。但我这次决定自己在 PageView 基础上封装一层,原因有两个。
第一,OpenHarmony 的 Flutter 适配分支对第三方插件的支持是有滞后性的,我用 carousel_slider 碰到过手势滑动不跟手的问题,排查到最后发现是插件内部的 GestureDetector 与鸿蒙的触摸事件映射存在兼容性问题。第二,自研轮播组件的代码量并没有想象中那么大,核心逻辑就是 PageView 加 Timer,加上指示器也就一百多行,但这部分代码是完全由自己控制的,遇到问题可以直接改,不用等插件作者更新。
3.2 GoodsBanner 组件完整实现
轮播图的关键点有三个:自动播放、无限循环、指示器联动。自动播放用 Timer 控制页面的切换;无限循环用“大数法”来实现,而不是真的无限生成页面;指示器通过监听当前页码来更新选中状态。
dart复制class GoodsBanner extends StatefulWidget {
final List<String> images;
final ValueChanged<int>? onIndexChanged;
final void Function(int index)? onTap;
const GoodsBanner({
super.key,
required this.images,
this.onIndexChanged,
this.onTap,
});
@override
State<GoodsBanner> createState() => _GoodsBannerState();
}
class _GoodsBannerState extends State<GoodsBanner> {
late final PageController _controller;
Timer? _timer;
int _currentIndex = 0;
static const int _initialPage = 10000;
@override
void initState() {
super.initState();
_controller = PageController(initialPage: _initialPage);
_startAutoPlay();
}
void _startAutoPlay() {
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 3), (timer) {
if (!_controller.hasClients) return;
final nextPage = _controller.page!.round() + 1;
_controller.animateToPage(
nextPage,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
});
}
@override
void dispose() {
_timer?.cancel();
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.bottomCenter,
children: [
PageView.builder(
controller: _controller,
itemCount: widget.images.length * 2 + _initialPage * 2,
onPageChanged: (index) {
final realIndex = index % widget.images.length;
setState(() => _currentIndex = realIndex);
widget.onIndexChanged?.call(realIndex);
},
itemBuilder: (context, index) {
final realIndex = index % widget.images.length;
return GestureDetector(
onTap: () => widget.onTap?.call(realIndex),
child: Image.network(
widget.images[realIndex],
fit: BoxFit.cover,
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return Container(
color: Colors.grey.shade200,
alignment: Alignment.center,
child: const CircularProgressIndicator(strokeWidth: 2),
);
},
errorBuilder: (context, error, stackTrace) {
return Container(
color: Colors.grey.shade200,
alignment: Alignment.center,
child: const Icon(Icons.broken_image_outlined, size: 48),
);
},
),
);
},
),
Positioned(
bottom: 12,
child: _buildIndicator(),
),
],
);
}
Widget _buildIndicator() {
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(widget.images.length, (index) {
final isActive = index == _currentIndex;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: isActive ? 18 : 6,
height: 6,
decoration: BoxDecoration(
color: isActive ? Colors.white : Colors.white.withOpacity(0.5),
borderRadius: BorderRadius.circular(3),
),
);
}),
);
}
}
大数法的原理很简单,初始页设成一个很大的整数(我用的 10000),PageView 可以向左和向右滑动很多页都不会触达边界。当前下标通过 index % images.length 取余数得到真实索引。这个方案唯一要注意的是性能,itemCount 虽然很大,但 PageView 本身是懒加载的,只会构建当前屏附近的页面,所以不会有内存问题。
3.3 点击轮播图跳转大图预览的完整链路
轮播图本身做得再好,如果没有点击跳转,交互链路就是不完整的。我在 GoodsBanner 上暴露了一个 onTap 回调,点击当前图片时,携带图片列表和当前索引跳转到全屏预览页。
dart复制void _handleBannerTap(int index) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => PhotoPreviewPage(
images: _product.images,
initialIndex: index,
),
),
);
}
全屏预览页是一个独立的页面,核心是 PageView 浏览大图,支持双指缩放,顶部显示当前图片序号,底部有保存和关闭按钮。这里我采用了 Hero 动画做转场过渡,让缩略图到全屏图的切换更平滑。
dart复制class PhotoPreviewPage extends StatelessWidget {
final List<String> images;
final int initialIndex;
const PhotoPreviewPage({
super.key,
required this.images,
required this.initialIndex,
});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
PageView.builder(
controller: PageController(initialPage: initialIndex),
itemCount: images.length,
itemBuilder: (context, index) {
return InteractiveViewer(
maxScale: 4.0,
child: Center(
child: Hero(
tag: 'product_image_$index',
child: Image.network(
images[index],
fit: BoxFit.contain,
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
},
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.broken_image_outlined,
color: Colors.white54,
size: 64,
);
},
),
),
),
);
},
),
Positioned(
top: MediaQuery.of(context).padding.top + 8,
left: 16,
right: 16,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(Icons.close, color: Colors.white, size: 28),
onPressed: () => Navigator.of(context).pop(),
),
Text(
'${initialIndex + 1} / ${images.length}',
style: const TextStyle(color: Colors.white, fontSize: 16),
),
],
),
),
],
),
);
}
}
这里有个细节值得多说一句。Hero 动画的 tag 必须唯一,我一开始写成固定的 'product_image',结果页面里多个 Hero 共享同一个 tag,Flutter 直接报错。改成拼上 index 之后才正常。如果你在跳转时用了 ListView 或者 GridView 的缓存机制,还要注意同一个 index 的组件是否在多个页面里同时存在。
4. 详情页布局与状态管理实操
4.1 页面整体布局与滚动联动
商品详情页的滚动区域包含商品主图区、价格区、规格区、店铺区和图文详情,我选择用 CustomScrollView 加 Sliver 系列组件来构建,而不是一个简单的 ListView。原因是 SliverAppBar 可以很方便地实现顶部导航栏的透明度渐变和标题渐显效果,这在电商详情页里是标配交互。
页面整体结构如下:
dart复制CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 300,
pinned: true,
backgroundColor: Colors.white,
leading: _buildBackButton(),
title: _buildAppBarTitle(),
flexibleSpace: FlexibleSpaceBar(
background: GoodsBanner(
images: _product.images,
onIndexChanged: (index) {
setState(() => _currentBannerIndex = index);
},
onTap: _handleBannerTap,
),
),
),
SliverToBoxAdapter(
child: _buildProductInfoSection(),
),
SliverToBoxAdapter(
child: _buildSpecSection(),
),
SliverToBoxAdapter(
child: _buildShopSection(),
),
SliverToBoxAdapter(
child: _buildDetailSection(),
),
SliverPadding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 60),
),
],
)
SliverAppBar 的 pinned 设为 true,让导航栏在滚动时固定;expandedHeight 与轮播图的高度保持一致,让图片区域完全嵌入导航栏的 FlexibleSpaceBar 中。这样做的好处是图片上滑时能自然折叠,视觉上非常流畅。
4.2 状态管理选择:setState 就够了
很多朋友一上来就喜欢给项目引入 Provider 或者 GetX,这个习惯在大型项目里没问题,但如果是页面级别的状态,setState 完全够用,还更直观。
商品详情页的状态其实很简单,主要是当前轮播图下标、选中规格、收藏状态这几个。我直接用 StatefulWidget 管理,逻辑链路更清晰。只有当你发现同一个状态被多个页面共享时才需要考虑引入状态管理框架,比如购物车角标数字这种全局状态。
有一个小技巧:把频繁变化的状态和稳定的 UI 分开。比如轮播图自动播放时,如果整个页面 setState,会导致价格区、详情区全部重建,虽然 Flutter 的 diff 机制能保证性能,但没必要。我把轮播图封装成独立的 StatefulWidget,它的页码切换只重建自己内部 UI,不会影响父组件。
5. OpenHarmony 真机上的特殊适配与坑
5.1 图片加载与网络权限问题
我在 OpenHarmony 模拟器上调试时,最常遇到的一个问题就是网络图片加载不出来,页面上一片灰。这个问题并不是 Flutter 代码的问题,而是 OpenHarmony 应用默认没有申请网络权限。在原生 ArkTS 工程里,你需要检查 module.json5 中是否配置了网络权限。
json复制{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
}
]
}
}
Flutter 工程通过鸿蒙适配分支构建时,也要确保这个权限已经加到对应的 module.json5 文件里。如果权限没有配置,Image.network 会静默失败走 errorBuilder,表现就是占位图或者灰块,非常容易忽略。排查这个问题的思路是,先用浏览器或者原生页面测试同一个图片地址是否可访问,排除网络环境因素后再回来看应用的权限配置。
5.2 状态栏高度与页面安全区适配
OpenHarmony 设备的状态栏高度计算方式和 Android 有一些细微差别,直接使用 MediaQuery.of(context).padding.top 在部分机型上会拿到不准确的值。
我采用的方案是,在页面入口处统一读取一次安全区数据,然后通过 InheritedWidget 传给所有需要适配的子组件。这样做的好处是避免在 build 过程中反复读取 MediaQuery,也方便针对鸿蒙设备做特殊处理。
dart复制final padding = MediaQuery.of(context).padding;
final topHeight = padding.top > 20 ? padding.top : 24;
这个逻辑的意思是,如果 padding.top 异常偏小,就兜底用一个默认值。鸿蒙的一些平板设备状态栏是悬浮样式,padding.top 的值可能是 0,这时候需要手动撑出一个安全距离,否则顶部关闭按钮会和状态栏重叠。
5.3 页面转场动画的兼容处理
Flutter 默认的 MaterialPageRoute 转场动画是从右侧滑入,这个动效在 OpenHarmony 适配分支上偶尔会出现掉帧情况。如果你对转场动画要求高,我建议在详情页这个大页面使用 fadeUpwards 或者 fadeThrough 这类纯透明度过渡,要比水平滑动在鸿蒙真机上更稳定。
dart复制Navigator.of(context).push(
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) => PhotoPreviewPage(
images: _product.images,
initialIndex: index,
),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(opacity: animation, child: child);
},
),
);
6. 常见问题排查与性能优化实录
6.1 问题速查表
开发过程中,我把遇到的问题和解决思路记录成了一个速查表,分享给大家,遇到同类问题可以直接对照排查。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 轮播图不自动播放 | Timer 被销毁或 PageController 未初始化 | 检查 dispose 中是否误取消 timer,hasClients 判断 |
| 图片加载失败显示灰块 | 网络权限未配置 | 在 module.json5 增加 ohos.permission.INTERNET |
| 点击轮播图无响应 | 手势冲突 | 给 PageView 外层加 GestureDetector 时确认没有竞争 |
| 跳转大图页面后 Hero 动画报错 | tag 重复 | 保证每个 tag 唯一,可拼接 index |
| 页面滚动卡顿 | 图片过大 | 使用 loadingBuilder 和压缩图,或使用缩略图地址 |
| 返回键无法关闭大图页 | 路由栈管理问题 | 检查是否误用了 pushReplacement |
6.2 性能优化实测经验
商品详情页的性能优化,我重点做了三件事。
第一,图片懒加载。首屏进入时,只加载轮播图第一张和商品信息区的图片,其他图片等滚动到可视区域再加载。Flutter 的 Image.network 默认就是懒加载的,但如果你用了缓存插件,要注意自己实现类似逻辑。
第二,用 RepaintBoundary 隔离不必要的重绘区域。轮播图自动播放时,指示器在不断变化,这个区域用 RepaintBoundary 包起来后,重绘不会扩散到整个页面。
第三,页面销毁时释放资源。我在 dispose 里显式取消 Timer,并且调用 PageController.dispose。对于网络图片,Flutter 内部有缓存管理,但如果你的图片特别多,可以用 precacheImage 预加载关键图片,同时通过 PaintingBinding.imageCache.clear() 和 clearLiveImages() 在页面销毁时释放内存。
dart复制@override
void dispose() {
_timer?.cancel();
_controller.dispose();
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
super.dispose();
}
这里要注意,dispose 里清空图片缓存是一个比较粗暴的做法,如果应用还有其他页面依赖缓存,会导致那些页面图片重新加载。比较稳妥的做法是根据页面类型来决定是否清理,或者在 App 级别做统一的缓存策略,而不是每个页面都来一次。
开发这套商品详情页,我最大的体会是跨平台开发的核心不是 UI 怎么写,而是适配和排查思路的建立。同样的轮播图,在 Android 上跑得好好的,到 OpenHarmony 上可能因为设备传感器上报的频率不同导致自动播放抖动;同样的网络图片,在 iOS 上能加载,到鸿蒙上可能就因为证书策略加载不出来。只有把这些问题一个个排查清楚,你才能真正说自己的代码是跨平台的。
后续这个项目还可以继续扩展的地方有很多,比如规格选择弹窗与购物车联动、详情页的评论列表无限滚动、分享海报的生成与保存。尤其是分享海报生成,在 OpenHarmony 上要用到底层画布能力,和 Flutter 的 Canvas 有交互,是个很有意思的探索方向。等我把这一块做完,再来更新这个系列。
