欢迎光临
我们一直在努力

Flutter for OpenHarmony 盲盒抽奖App应用实战 - 仓库管理实现

请添加图片描述

写在前面

今天我们来实现盲盒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:触发界面刷新,显示最新数据

数据流向:

  • 用户点击置换按钮
  • 跳转到置换页面,传入商品信息
  • 用户在置换页面确认操作
  • 返回置换结果(是否成功、置换数量)
  • 更新UserService中的数据
  • 刷新界面显示
  • 提取功能实现

    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

    数据流程:

  • 用户确认开启潮盒
  • 从boxes列表中移除该潮盒
  • 向products列表添加新商品
  • setState刷新界面
  • 用户在商品Tab中看到新商品
  • 实际项目建议:这里应该调用后端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的核心功能之一,它连接了购买、开箱、置换等多个环节。通过这个功能的实现,我们学习了:

  • Tab切换:使用TabController和TabBarView实现多Tab页面
  • 网格布局:使用GridView.builder创建响应式网格
  • 自定义绘制:使用CustomPaint绘制复杂图形
  • 对话框交互:使用AlertDialog实现确认和结果展示
  • 数据流转:页面间的数据传递和状态更新
  • 空状态处理:提供友好的空状态提示
  • 资源管理:正确释放控制器避免内存泄漏
  • 开发心得:

    在实现这个功能时,我最大的感受是细节决定体验。比如:

    • 空状态的友好提示
    • 按钮的主次区分
    • 对话框的圆角设计
    • 高光效果的立体感

    这些细节虽然不影响功能,但会大大提升用户体验。

    技术亮点:

    CustomPaint绘制盲盒是这个页面的技术亮点。通过Path和Paint的组合,我们可以绘制任何想要的图形。这比使用图片更灵活,也更容易适配不同尺寸。

    后续优化方向:

  • 添加搜索和筛选功能
  • 支持批量操作
  • 优化图片加载性能
  • 添加下拉刷新
  • 使用状态管理库
  • 与OpenHarmony的适配:

    这个页面的代码完全兼容OpenHarmony平台。CustomPaint在OpenHarmony上也能正常工作,绘制效果一致。唯一需要注意的是网络图片加载,确保网络权限配置正确。

    希望这篇文章能帮助你理解仓库管理功能的实现。下一篇文章我们将实现商品兑换功能,敬请期待!


    欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

    赞(0)
    未经允许不得转载:171主机测评 » Flutter for OpenHarmony 盲盒抽奖App应用实战 - 仓库管理实现
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址