
写在前面
今天我们来实现盲盒App中的仓库管理功能。 这是一个非常有趣的功能,用户可以在这里查看自己抽到的商品和潮盒,还可以进行置换、提取、开箱等操作。
在开发这个功能时,我深刻体会到了用户体验设计的重要性。一个好的仓库管理界面,不仅要展示信息,还要让用户能够方便地进行各种操作。我们需要考虑:如何让用户快速找到想要的物品?如何让操作流程简单直观?如何处理各种边界情况?
这篇文章我会详细讲解仓库管理的实现过程,包括Tab切换、网格布局、置换和提取功能、开箱动画等。代码会分成小段,每段后面都有详细的解释。
功能概述
我们要实现的仓库管理包含以下功能:
- Tab切换:商品和潮盒两个Tab
- 网格布局:使用GridView展示物品
- 潮豆显示:顶部显示用户的潮豆余额
- 置换功能:将商品置换成潮豆
- 提取功能:将商品提取发货
- 开箱功能:打开潮盒获得商品
- 空状态处理:没有物品时的友好提示
- 自定义绘制:使用CustomPaint绘制盲盒图标
页面结构设计
首先,让我们看看整体的页面结构:
import 'package:flutter/material.dart';
import '../../config/app_colors.dart';
import '../../services/user_service.dart';
import '../../widgets/common/app_bar.dart';
import 'warehouse_product_detail_page.dart';
import 'exchange_product_page.dart';
class WarehousePage extends StatefulWidget {
final int initialTab;
const WarehousePage({super.key, this.initialTab = 0});
State<WarehousePage> createState() => _WarehousePageState();
}
这里有几个关键点:
- StatefulWidget:因为需要管理Tab状态和数据更新
- initialTab参数:允许从外部指定初始显示哪个Tab,默认为0(商品Tab)
- 导入UserService:用于管理用户的商品、潮盒和潮豆数据
State类的初始化
接下来看State类的实现:
class _WarehousePageState extends State<WarehousePage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final UserService _userService = UserService();
void initState() {
super.initState();
_tabController = TabController(
length: 2,
vsync: this,
initialIndex: widget.initialTab
);
}
这段代码的关键点:
- SingleTickerProviderStateMixin:为TabController提供动画支持,这是使用TabController必须的
- late关键字:表示_tabController会在使用前初始化,避免空安全问题
- UserService单例:通过UserService()获取单例实例,管理用户数据
- initialIndex:使用widget.initialTab设置初始Tab,实现灵活的页面跳转
设计思考:为什么要支持initialTab参数?因为在其他页面可能需要直接跳转到"潮盒"Tab,比如用户购买了潮盒后,应该直接显示潮盒列表。
void dispose() {
_tabController.dispose();
super.dispose();
}
资源释放非常重要!TabController是一个动画控制器,如果不释放会导致内存泄漏。这是Flutter开发中容易忽略的细节。
构建主界面
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: CustomAppBar(
title: '我的仓库',
),
body: Column(
children: [
_buildChaodouBar(),
_buildTabBar(),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildProductGrid(_userService.products),
_buildBoxGrid(_userService.boxes),
],
),
),
],
),
);
}
这里的布局结构很清晰:
- CustomAppBar:使用自定义的AppBar组件,保持全局样式统一
- Column布局:从上到下依次是潮豆栏、Tab栏、内容区
- Expanded:让TabBarView占据剩余空间,实现自适应布局
- TabBarView:根据Tab显示不同的内容,商品或潮盒
潮豆余额显示
Widget _buildChaodouBar() {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Container(
width: 28,
height: 28,
decoration: const BoxDecoration(
color: Color(0xFFFFD700),
shape: BoxShape.circle
),
child: const Center(
child: Text('🫘', style: TextStyle(fontSize: 14))
),
),
const SizedBox(width: 8),
Text(
'我的潮豆:${_userService.chaodou}',
style: const TextStyle(fontSize: 14, color: Colors.black87)
),
],
),
);
}
这个组件展示用户的潮豆余额:
- 浅灰色背景:使用F5F5F5颜色,与白色背景形成对比但不突兀
- 圆形图标:金色圆形配合豆子emoji,视觉上很直观
- 动态数据:通过_userService.chaodou实时显示余额
用户体验:潮豆是虚拟货币,放在顶部显眼位置,让用户随时知道自己的余额,这对于置换功能很重要。
Tab栏实现
Widget _buildTabBar() {
return Container(
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: Color(0xFFEEEEEE), width: 1)
),
),
child: TabBar(
controller: _tabController,
labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
labelStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500
),
unselectedLabelStyle: const TextStyle(fontSize: 16),
indicatorColor: Colors.black,
indicatorWeight: 2,
indicatorSize: TabBarIndicatorSize.label,
tabs: const [
Tab(text: '商品'),
Tab(text: '潮盒')
],
),
);
}
Tab栏的设计要点:
- 底部边框:使用淡灰色边框分隔Tab栏和内容区
- 黑色指示器:选中的Tab用黑色下划线标识,简洁大方
- 字体粗细变化:选中的Tab字体加粗,增强视觉反馈
- indicatorSize.label:指示器只在文字下方,不占满整个Tab宽度
商品网格布局
Widget _buildProductGrid(List<Map<String, dynamic>> items) {
if (items.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inventory_2_outlined,
size: 64,
color: Colors.grey.shade300
),
const SizedBox(height: 16),
Text(
'暂无物品',
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade500
)
),
],
),
);
}
空状态处理是很重要的用户体验细节:
- 居中显示:使用Center和Column让内容垂直居中
- 图标提示:使用inventory图标,语义明确
- 淡灰色:使用灰色调表示"空"的状态,不会太突兀
- 友好文案:"暂无物品"比"没有数据"更人性化
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 16,
childAspectRatio: 0.68,
),
itemCount: items.length,
itemBuilder: (context, index) => _buildProductCard(items[index]),
);
}
GridView的配置参数:
- crossAxisCount: 2:每行显示2个商品,适合手机屏幕
- crossAxisSpacing: 12:横向间距12像素
- mainAxisSpacing: 16:纵向间距16像素,稍大一些让布局更舒适
- childAspectRatio: 0.68:宽高比,这个比例让商品卡片看起来更修长
踩过的坑:childAspectRatio的值需要反复调试。太大会让卡片太扁,太小会让卡片太高。0.68这个值是我试了很多次后觉得最合适的。
商品卡片设计
Widget _buildProductCard(Map<String, dynamic> item) {
return GestureDetector(
onTap: () => _goToDetail(item),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFEEEEEE)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: const Color(0xFFF8F8F8),
borderRadius: const BorderRadius.vertical(
top: Radius.circular(8)
),
卡片的基础结构:
- GestureDetector:整个卡片可点击,跳转到详情页
- 边框和圆角:淡灰色边框,8像素圆角,现代简洁风格
- Column布局:图片在上,信息在下
- Expanded(flex: 3):图片占3份空间,信息占2份(后面会看到)
image: item['image'] != null
? DecorationImage(
image: NetworkImage(item['image'] as String),
fit: BoxFit.cover,
)
: null,
),
child: item['image'] == null
? Center(
child: Icon(
Icons.shopping_bag_outlined,
size: 60,
color: Colors.grey.shade300
)
)
: null,
),
),
图片加载处理:
- 条件渲染:如果有图片URL就显示网络图片,否则显示占位图标
- NetworkImage:Flutter内置的网络图片加载
- BoxFit.cover:图片填充容器,保持比例裁剪
- 占位图标:使用购物袋图标作为默认占位符
实际经验:在真实项目中,建议使用cached_network_image包来缓存图片,提升加载速度和用户体验。
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item['name'],
style: const TextStyle(
fontSize: 13,
color: Colors.black87
),
maxLines: 1,
overflow: TextOverflow.ellipsis
),
const SizedBox(height: 4),
Text(
'数量:${item['quantity']}',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600
)
),
const Spacer(),
信息区域的布局:
- Expanded(flex: 2):占2份空间,与图片的3:2比例
- 商品名称:13号字体,单行显示,超出用省略号
- 数量显示:12号字体,灰色,次要信息
- Spacer():把按钮推到底部,无论文字多少都保持按钮在底部
操作按钮实现
Row(
children: [
Expanded(
child: _buildActionButton(
'置换',
false,
() => _goToExchange(item)
)
),
const SizedBox(width: 8),
Expanded(
child: _buildActionButton(
'提取',
true,
() => _goToDetail(item)
)
),
],
),
],
),
),
),
],
),
),
);
}
两个操作按钮:
- 置换按钮:将商品换成潮豆,次要操作(白色背景)
- 提取按钮:提取商品发货,主要操作(黄色背景)
- Expanded:两个按钮平分宽度
- 间距:8像素间距让按钮不会挤在一起
Widget _buildActionButton(
String text,
bool isPrimary,
VoidCallback onTap
) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 6),
decoration: BoxDecoration(
color: isPrimary ? AppColors.primary : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isPrimary
? AppColors.primary
: Colors.grey.shade300
),
),
child: Center(
child: Text(
text,
style: TextStyle(
fontSize: 12,
color: isPrimary ? Colors.white : Colors.black87
)
),
),
),
);
}
按钮组件的设计思路:
- isPrimary参数:区分主要和次要按钮,主要按钮用品牌色填充
- 圆角16:较大的圆角让按钮更柔和
- 边框处理:主要按钮边框与背景同色,次要按钮用灰色边框
- 文字颜色:主要按钮白色文字,次要按钮黑色文字,保证对比度
设计原则:主要操作和次要操作要有明显的视觉区分,让用户一眼就能看出哪个是推荐操作。
置换功能实现
void _goToExchange(Map<String, dynamic> item) async {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ExchangeProductPage(product: item)
),
);
if (result != null && result['exchanged'] == true) {
setState(() {
final quantity = result['quantity'] as int;
_userService.decreaseProductQuantity(item['id'], quantity);
_userService.addChaodou(28600 * quantity);
});
}
}
置换流程的关键点:
- async/await:等待置换页面返回结果
- 传递商品数据:通过构造函数传递item给置换页面
- 接收返回值:置换成功后返回exchanged和quantity
- 更新数据:减少商品数量,增加潮豆余额
- setState:触发界面刷新,显示最新数据
数据流向:
提取功能实现
void _goToDetail(Map<String, dynamic> item) async {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => WarehouseProductDetailPage(product: item)
),
);
if (result == true) {
setState(() => _userService.removeProduct(item['id']));
}
}
提取功能相对简单:
- 跳转详情页:在详情页可以查看商品信息并提取
- 返回true:表示提取成功
- 移除商品:从仓库中移除该商品
- 刷新界面:setState触发重新构建
潮盒网格布局
Widget _buildBoxGrid(List<Map<String, dynamic>> items) {
if (items.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inventory_2_outlined,
size: 64,
color: Colors.grey.shade300
),
const SizedBox(height: 16),
Text(
'暂无潮盒',
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade500
)
),
],
),
);
}
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 16,
childAspectRatio: 0.75,
),
itemCount: items.length,
itemBuilder: (context, index) => _buildBoxCard(items[index]),
);
}
潮盒网格与商品网格类似,但有细微差别:
- childAspectRatio: 0.75:潮盒卡片稍微方一些(0.75 vs 0.68)
- 空状态文案:“暂无潮盒"而不是"暂无物品”
- 其他参数相同:保持视觉一致性
潮盒卡片设计
Widget _buildBoxCard(Map<String, dynamic> item) {
final imageUrl = item['image'] as String?;
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFEEEEEE)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: const Color(0xFFF8F8F8),
borderRadius: const BorderRadius.vertical(
top: Radius.circular(8)
),
image: imageUrl != null && imageUrl.isNotEmpty
? DecorationImage(
image: NetworkImage(imageUrl),
fit: BoxFit.cover,
)
: null,
),
潮盒卡片的图片处理:
- 提取imageUrl:先获取图片URL,方便后续判断
- 双重判断:不仅判断null,还判断是否为空字符串
- 条件渲染:有图片显示图片,没图片显示自定义绘制的盲盒
child: imageUrl == null || imageUrl.isEmpty
? CustomPaint(
painter: BlindBoxPainter(),
size: Size.infinite
)
: null,
),
),
CustomPaint的使用:
- BlindBoxPainter:自定义绘制类,绘制盲盒图标
- Size.infinite:占满整个容器
- 条件显示:只在没有图片时显示
为什么用CustomPaint? 因为盲盒是一个特殊的3D立体图形,用图标或图片都不够灵活,自己绘制可以完全控制样式和颜色。
Padding(
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item['name'],
style: const TextStyle(
fontSize: 13,
color: Colors.black87
),
maxLines: 1,
overflow: TextOverflow.ellipsis
),
const SizedBox(height: 4),
Text(
'数量:${item['quantity']}',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600
)
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: GestureDetector(
onTap: () => _showOpenBoxDialog(item),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(20)
),
child: const Center(
child: Text(
'开启',
style: TextStyle(
fontSize: 14,
color: Colors.white,
fontWeight: FontWeight.w500
)
)
),
),
),
),
],
),
),
],
),
);
}
潮盒卡片的信息区:
- 名称和数量:与商品卡片相同的展示方式
- 开启按钮:全宽按钮,品牌色背景,圆角20
- SizedBox包裹:确保按钮占满宽度
- 点击事件:调用_showOpenBoxDialog显示确认对话框
按钮设计差异:
- 商品卡片:两个按钮(置换、提取)
- 潮盒卡片:一个按钮(开启)
- 原因:潮盒的主要操作就是开启,不需要其他操作
开启潮盒对话框
void _showOpenBoxDialog(Map<String, dynamic> item) {
showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16)
),
title: const Text('开启潮盒'),
content: Text('确定要开启「${item['name']}」吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
'取消',
style: TextStyle(color: Colors.grey)
)
),
TextButton(
onPressed: () {
Navigator.pop(context);
_showBoxResultDialog(item);
},
child: const Text('确定')
),
],
),
);
}
确认对话框的设计:
- 圆角对话框:16像素圆角,更现代
- 动态文案:显示具体的潮盒名称
- 两个按钮:取消(灰色)和确定(主题色)
- 连续操作:确定后关闭对话框,再显示结果对话框
用户体验考虑:开启潮盒是不可逆操作,必须有确认步骤,避免用户误操作。
开箱结果对话框
void _showBoxResultDialog(Map<String, dynamic> item) {
showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16)
),
title: const Row(
children: [
Icon(Icons.celebration, color: AppColors.primary),
SizedBox(width: 8),
Text('恭喜获得')
]
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.1),
shape: BoxShape.circle
),
child: const Icon(
Icons.card_giftcard,
size: 48,
color: AppColors.primary
),
),
const SizedBox(height: 16),
const Text(
'潮流运动鞋',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold
)
),
const SizedBox(height: 8),
Text(
'已存入您的仓库',
style: TextStyle(color: Colors.grey.shade600)
),
],
),
结果对话框的设计亮点:
- 庆祝图标:标题带celebration图标,增加喜悦感
- 圆形背景:淡黄色圆形背景突出礼物图标
- 大号图标:48像素的礼物图标,视觉焦点
- 商品名称:18号加粗字体,突出奖品
- 提示文案:告知用户商品已存入仓库
actions: [
SizedBox(
width: double.infinity,
child: TextButton(
onPressed: () {
Navigator.pop(context);
setState(() {
_userService.removeBox(item['id']);
_userService.addProduct({
'id': DateTime.now().millisecondsSinceEpoch.toString(),
'name': '潮流运动鞋',
'quantity': 1,
'image': 'https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=300&h=300&fit=crop',
});
});
},
style: TextButton.styleFrom(
backgroundColor: AppColors.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20)
),
padding: const EdgeInsets.symmetric(vertical: 12)
),
child: const Text(
'好的',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold
)
),
),
),
],
),
);
}
按钮和数据更新:
- 全宽按钮:SizedBox包裹让按钮占满宽度
- 品牌色背景:黄色背景,白色文字
- 圆角按钮:20像素圆角
- 数据更新:移除潮盒,添加商品到仓库
- 生成ID:使用时间戳生成唯一ID
数据流程:
实际项目建议:这里应该调用后端API,由服务器决定开出什么商品,而不是前端写死。这里为了演示简化了逻辑。
自定义绘制盲盒图标
这是整个页面最有技术含量的部分!我们要用CustomPaint绘制一个3D立体的盲盒。
class BlindBoxPainter extends CustomPainter {
void paint(Canvas canvas, Size size) {
final centerX = size.width / 2;
final centerY = size.height / 2;
final boxWidth = size.width * 0.7;
final boxHeight = size.height * 0.65;
final bodyPaint = Paint()
..shader = const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFF64B5F6),
Color(0xFF42A5F5),
Color(0xFF1E88E5)
],
).createShader(
Rect.fromCenter(
center: Offset(centerX, centerY),
width: boxWidth,
height: boxHeight
)
);
绘制准备工作:
- 计算中心点:centerX和centerY是盒子的中心
- 计算尺寸:盒子宽度是容器的70%,高度是65%
- 渐变画笔:使用蓝色渐变,从浅到深,营造立体感
- createShader:为指定区域创建渐变着色器
final path = Path();
final halfWidth = boxWidth / 2;
final halfHeight = boxHeight / 2;
final sideOffset = boxWidth * 0.15;
path.moveTo(
centerX – halfWidth + sideOffset,
centerY – halfHeight * 0.3
);
path.lineTo(
centerX – halfWidth + sideOffset,
centerY + halfHeight * 0.5
);
path.lineTo(
centerX,
centerY + halfHeight
);
path.lineTo(
centerX + halfWidth – sideOffset,
centerY + halfHeight * 0.5
);
path.lineTo(
centerX + halfWidth – sideOffset,
centerY – halfHeight * 0.3
);
path.lineTo(
centerX,
centerY – halfHeight
);
path.close();
canvas.drawPath(path, bodyPaint);
绘制盒子主体:
- 六边形路径:通过6个点连接成六边形,模拟3D盒子的正面
- sideOffset:侧面偏移量,让盒子看起来有厚度
- 高度调整:顶部和底部的高度不同,增强透视感
- 闭合路径:path.close()连接最后一点和起点
- 填充路径:用渐变画笔填充
绘制技巧:3D效果的关键是透视。通过调整各个点的位置,让盒子看起来有近大远小的效果。
final borderPaint = Paint()
..color = const Color(0xFFFFD700)
..style = PaintingStyle.stroke
..strokeWidth = 3;
canvas.drawPath(path, borderPaint);
final linePaint = Paint()
..color = const Color(0xFFFFD700)
..strokeWidth = 2;
canvas.drawLine(
Offset(centerX, centerY – halfHeight),
Offset(centerX, centerY + halfHeight),
linePaint
);
添加装饰:
- 金色边框:用金色描边,增加质感
- strokeWidth: 3:较粗的边框更明显
- 中线:从上到下的金色线,模拟盒子的中缝
final highlightPaint = Paint()
..color = Colors.white.withOpacity(0.3)
..style = PaintingStyle.fill;
final highlightPath = Path();
highlightPath.moveTo(
centerX – halfWidth + sideOffset + 5,
centerY – halfHeight * 0.25
);
highlightPath.lineTo(
centerX – 5,
centerY – halfHeight + 5
);
highlightPath.lineTo(
centerX – 5,
centerY
);
highlightPath.lineTo(
centerX – halfWidth + sideOffset + 5,
centerY + halfHeight * 0.2
);
highlightPath.close();
canvas.drawPath(highlightPath, highlightPaint);
}
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
高光效果:
- 半透明白色:opacity为0.3,不会太亮
- 左侧高光:在盒子左侧绘制一个四边形高光
- 增强立体感:高光让盒子看起来有光泽,更真实
- shouldRepaint: false:盒子是静态的,不需要重绘
CustomPaint的性能优化:
- shouldRepaint返回false表示不需要重绘
- 如果盒子需要动画,可以返回true并配合AnimationController
踩过的坑
在开发仓库管理功能时,我遇到了几个问题:
1. TabController未释放导致内存泄漏
问题:最初忘记在dispose中释放TabController,导致页面关闭后控制器还在内存中。
解决:
void dispose() {
_tabController.dispose(); // 必须释放!
super.dispose();
}
教训:任何使用了动画控制器的地方都要记得释放,包括TabController、AnimationController等。
2. GridView的childAspectRatio调试
问题:一开始设置的比例不合适,卡片要么太扁要么太高。
解决过程:
- 试过1.0:卡片太方,图片显示不全
- 试过0.5:卡片太高,一屏显示不了几个
- 最终0.68:图片和信息都能很好展示
经验:这种视觉参数没有标准答案,需要在真机上反复调试。
3. 空状态处理
问题:最初没有处理空状态,列表为空时显示空白页面,用户体验很差。
解决:
if (items.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(...),
Text('暂无物品'),
],
),
);
}
教训:任何列表页面都要考虑空状态,给用户友好的提示。
4. 图片加载失败处理
问题:网络图片加载失败时显示红色错误图标,很丑。
解决:使用条件渲染,没有图片时显示自定义的占位图标或绘制。
建议:生产环境应该使用cached_network_image包,它有更好的错误处理和缓存机制。
5. 数据更新后界面不刷新
问题:开启潮盒后,数据更新了但界面没变化。
原因:忘记调用setState。
解决:
setState(() {
_userService.removeBox(item['id']);
_userService.addProduct(...);
});
教训:在StatefulWidget中,任何需要更新界面的数据变化都要包在setState中。
功能扩展建议
基于当前的实现,还可以添加以下功能:
1. 搜索和筛选
// 添加搜索框
TextField(
decoration: InputDecoration(
hintText: '搜索商品',
prefixIcon: Icon(Icons.search),
),
onChanged: (value) {
setState(() {
_searchKeyword = value;
});
},
)
// 筛选逻辑
List<Map<String, dynamic>> get filteredProducts {
if (_searchKeyword.isEmpty) return _userService.products;
return _userService.products.where((item) {
return item['name'].toLowerCase().contains(_searchKeyword.toLowerCase());
}).toList();
}
2. 排序功能
// 按时间、名称、数量排序
enum SortType { time, name, quantity }
void _sortItems(SortType type) {
setState(() {
switch (type) {
case SortType.time:
items.sort((a, b) => b['time'].compareTo(a['time']));
break;
case SortType.name:
items.sort((a, b) => a['name'].compareTo(b['name']));
break;
case SortType.quantity:
items.sort((a, b) => b['quantity'].compareTo(a['quantity']));
break;
}
});
}
3. 批量操作
// 添加选择模式
bool _isSelectionMode = false;
Set<String> _selectedIds = {};
// 全选/取消全选
void _toggleSelectAll() {
setState(() {
if (_selectedIds.length == items.length) {
_selectedIds.clear();
} else {
_selectedIds = items.map((e) => e['id'] as String).toSet();
}
});
}
// 批量置换
void _batchExchange() {
// 实现批量置换逻辑
}
4. 下拉刷新
RefreshIndicator(
onRefresh: () async {
// 从服务器刷新数据
await _userService.refreshWarehouse();
setState(() {});
},
child: GridView.builder(...),
)
5. 商品详情预览
// 长按预览
GestureDetector(
onLongPress: () {
showModalBottomSheet(
context: context,
builder: (context) => ProductPreviewSheet(product: item),
);
},
child: _buildProductCard(item),
)
性能优化建议
1. 图片缓存
使用cached_network_image包:
dependencies:
cached_network_image: ^3.3.0
// 使用
CachedNetworkImage(
imageUrl: item['image'],
placeholder: (context, url) => CircularProgressIndicator(),
errorWidget: (context, url, error) => Icon(Icons.error),
fit: BoxFit.cover,
)
2. 列表优化
对于大量数据,考虑使用:
// 懒加载
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
if (index == items.length – 1) {
_loadMore(); // 加载更多
}
return _buildItem(items[index]);
},
)
3. 状态管理优化
对于复杂应用,建议使用Provider或Riverpod:
// 使用Provider
class WarehouseProvider extends ChangeNotifier {
List<Map<String, dynamic>> _products = [];
void addProduct(Map<String, dynamic> product) {
_products.add(product);
notifyListeners();
}
}
// 在Widget中使用
Consumer<WarehouseProvider>(
builder: (context, warehouse, child) {
return GridView.builder(
itemCount: warehouse.products.length,
itemBuilder: (context, index) => ...,
);
},
)
写在最后
仓库管理功能是盲盒App的核心功能之一,它连接了购买、开箱、置换等多个环节。通过这个功能的实现,我们学习了:
开发心得:
在实现这个功能时,我最大的感受是细节决定体验。比如:
- 空状态的友好提示
- 按钮的主次区分
- 对话框的圆角设计
- 高光效果的立体感
这些细节虽然不影响功能,但会大大提升用户体验。
技术亮点:
CustomPaint绘制盲盒是这个页面的技术亮点。通过Path和Paint的组合,我们可以绘制任何想要的图形。这比使用图片更灵活,也更容易适配不同尺寸。
后续优化方向:
与OpenHarmony的适配:
这个页面的代码完全兼容OpenHarmony平台。CustomPaint在OpenHarmony上也能正常工作,绘制效果一致。唯一需要注意的是网络图片加载,确保网络权限配置正确。
希望这篇文章能帮助你理解仓库管理功能的实现。下一篇文章我们将实现商品兑换功能,敬请期待!
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net






