Flutter适配鸿蒙三方库(piu_animation)实战-加载失败场景
摘要: 本文探讨了异步任务失败场景的动画处理方案。在实际项目中,网络波动、服务器过载等常见问题会导致操作失败,用户需要清晰的视觉反馈。文章介绍了piu_animation的失败动画机制:当异步任务返回false时,动画会显示失败图标(✗)并返回起点,同时触发失败回调。通过模拟30%失败率的购物车案例,演示了如何结合状态管理(如防止重复点击)和错误提示(如SnackBar)优化用户体验。关键点包括:
为什么要专门讲失败场景
上一篇文章讲了 loading 动画的成功场景,异步任务返回 true,动画飞向终点,皆大欢喜。但现实往往没那么美好。
在实际项目中,我遇到过各种各样的失败情况:
- 用户在地铁里,网络信号时断时续
- 双十一抢购,服务器扛不住了
- 商品太火爆,库存瞬间没了
- 用户登录态过期,需要重新登录
- 后端接口改了,前端还没更新
这些情况都会导致异步任务返回 false。如果不做好失败处理,用户会一脸懵:我点了按钮,动画飞回来了,然后呢?发生了什么?
piu_animation 的失败动画设计得很巧妙:Widget 会显示一个失败图标,然后返回起点。这给了用户一个明确的视觉反馈——操作失败了。但光有动画还不够,我们还需要告诉用户为什么失败,以及怎么解决。
失败动画是怎么运作的
先来理解一下失败场景的动画流程,和成功场景对比着看会更清楚。
起飞阶段
点击按钮后,Widget 从起点开始移动和缩放,这部分和成功场景一样。
悬停阶段
飞到一半的时候,Widget 停下来,显示 loading 状态。这时候异步任务开始执行。
关键分叉点
异步任务执行完毕,返回结果。如果返回 false:
- Widget 上的 loading 图标变成失败图标(✗)
- 短暂停留,让用户看清楚
- Widget 开始往回飞,返回起点
- 到达起点后消失
回调通知
最后触发 doSomethingFinishCallBack(false),参数是 false,告诉你任务失败了。
🎯 和成功场景的区别:
- 成功:显示 ✓ → 继续飞向终点 → 回调参数
true- 失败:显示 ✗ → 返回起点 → 回调参数
false这个设计很直观,用户一看就知道操作有没有成功。
快速上手:模拟一个失败场景
定义一个会失败的异步任务
最简单的方式,直接返回 false:
Future<bool> loadingFailFunction() {
return Future.delayed(const Duration(milliseconds: 2000), () {
return false; // 直接返回失败
});
}
这个函数模拟了一个 2 秒的异步操作,然后返回失败。实际项目中,这个 false 可能来自网络请求失败、业务逻辑校验不通过等等。
触发失败动画
和成功场景的代码几乎一样,只是 loadingCallback 传入的函数会返回 false:
void addToCartWithPossibleFailure() {
Widget piuWidget = Container(
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.shopping_cart,
color: Colors.white,
size: 40,
),
);
Widget 的定义和之前一样,没什么特别的。
RenderBox box = cartButtonKey.currentContext!.findRenderObject() as RenderBox;
var offset = box.localToGlobal(Offset.zero);
Offset endOffset = Offset(
offset.dx + box.size.width / 2,
offset.dy + box.size.height / 2,
);
坐标计算也是老套路了。
PiuAnimation.addAnimation(
rootKey,
piuWidget,
endOffset,
maxWidth: 100,
minWidth: 50,
millisecond: 1500,
loadingCallback: loadingFailFunction, // 这个函数会返回 false
doSomethingBeginCallBack: () {
print("开始添加到购物车");
},
doSomethingFinishCallBack: (success) {
if (success) {
print("添加成功");
} else {
print("添加失败");
showErrorMessage(); // 失败时显示错误提示
}
},
);
}
⚠️ 关键点:
loadingCallback返回false时,动画会自动显示失败图标并返回起点doSomethingFinishCallBack的参数success会是false- 在回调里处理失败逻辑,比如显示错误提示、提供重试选项等
运行这段代码,你会看到:Widget 飞到一半停下来,转圈,然后显示一个 ✗,接着飞回起点消失。整个过程很流畅,用户一看就知道操作失败了。
实战案例:模拟真实的失败场景
在真实项目中,失败不是 100% 发生的,而是有一定概率。我们来做一个更接近真实情况的案例:模拟 30% 的失败率。
页面状态管理
先定义需要的状态变量:
class RobustShoppingCartDemo extends StatefulWidget {
const RobustShoppingCartDemo({Key? key}) : super(key: key);
State<RobustShoppingCartDemo> createState() => _RobustShoppingCartDemoState();
}
class _RobustShoppingCartDemoState extends State<RobustShoppingCartDemo> {
GlobalKey rootKey = GlobalKey();
GlobalKey cartButtonKey = GlobalKey();
int cartCount = 0;
bool isProcessing = false; // 防止重复点击
isProcessing 这个变量很重要,用来防止用户在动画执行期间重复点击。失败场景下这个问题更突出,因为用户看到失败可能会疯狂点击重试。
模拟随机失败的 API
用 Random 来模拟 30% 的失败率:
Future<bool> addToCartApi(int productId) async {
await Future.delayed(const Duration(milliseconds: 1500));
// 模拟随机失败(30% 失败率)
final random = Random();
final success = random.nextDouble() > 0.3;
if (!success) {
print('商品 $productId 添加失败:库存不足');
}
return success;
}
🎲 为什么用随机失败?
- 更接近真实场景,网络请求本来就有不确定性
- 方便测试失败处理逻辑
- 可以调整失败率来模拟不同的网络环境
实际项目中,把这个函数替换成真实的 API 调用就行。
构建页面 UI
加一个提示条,告诉用户可能会失败:
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('商品列表'),
actions: [
// 购物车图标和徽章,代码省略...
],
),
body: Container(
key: rootKey,
child: Column(
children: [
// 提示信息
Container(
padding: const EdgeInsets.all(16),
color: Colors.amber[100],
child: Row(
children: const [
Icon(Icons.info_outline, color: Colors.orange),
SizedBox(width: 8),
Expanded(
child: Text(
'提示:部分商品可能库存不足,添加失败时会返回起点',
style: TextStyle(fontSize: 12),
),
),
],
),
),
// 商品列表
Expanded(
child: GridView.builder(
// GridView 配置省略...
),
),
],
),
),
);
}
这个提示条不是必须的,但在测试阶段很有用,让测试人员知道这是预期行为。
核心:加入购物车逻辑
void addToCart(int productId) {
// 防止重复点击
if (isProcessing) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请稍候,正在处理中...'),
duration: Duration(seconds: 1),
),
);
return;
}
setState(() {
isProcessing = true;
});
先检查是否正在处理,如果是就直接返回。这个检查在失败场景下特别重要,因为用户看到失败后可能会立刻重试。
Widget piuWidget = Container(
decoration: BoxDecoration(
color: Colors.deepPurple,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.deepPurple.withOpacity(0.5),
blurRadius: 8,
spreadRadius: 2,
),
],
),
child: const Icon(
Icons.add_shopping_cart,
color: Colors.white,
size: 40,
),
);
我用了深紫色,和成功场景的橙色区分开。你可以根据自己的 UI 风格调整。
RenderBox box = cartButtonKey.currentContext!.findRenderObject() as RenderBox;
var offset = box.localToGlobal(Offset.zero);
Offset endOffset = Offset(
offset.dx + box.size.width / 2,
offset.dy + box.size.height / 2,
);
PiuAnimation.addAnimation(
rootKey,
piuWidget,
endOffset,
maxWidth: 100,
minWidth: 50,
millisecond: 1200,
loadingCallback: () => addToCartApi(productId),
doSomethingBeginCallBack: () {
print("商品 $productId 开始添加到购物车");
},
doSomethingFinishCallBack: (success) {
setState(() {
isProcessing = false; // 无论成功失败,都要解除锁定
});
if (success) {
setState(() {
cartCount++;
});
// 成功提示
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: const [
Icon(Icons.check_circle, color: Colors.white),
SizedBox(width: 8),
Text('已成功添加到购物车'),
],
),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
} else {
// 失败处理
showErrorDialog(productId);
}
},
);
}
🔑 关键点:
isProcessing = false要放在回调的最前面,无论成功失败都要执行- 成功和失败用不同颜色的 SnackBar,让用户一眼就能区分
- 失败时调用
showErrorDialog,提供更详细的信息和重试选项
失败对话框设计
失败时弹出一个对话框,告诉用户可能的原因,并提供重试选项:
void showErrorDialog(int productId) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: const [
Icon(Icons.error_outline, color: Colors.red),
SizedBox(width: 8),
Text('添加失败'),
],
),
content: Text(
'商品 ${productId + 1} 添加到购物车失败\n\n'
'可能原因:\n'
'• 库存不足\n'
'• 网络连接异常\n'
'• 服务器繁忙'
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('知道了'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
addToCart(productId); // 重试
},
child: const Text('重试'),
),
],
),
);
}
💡 对话框设计建议:
- 标题要简洁明了,"添加失败"比"操作异常"更直白
- 列出可能的原因,让用户知道不是自己的问题
- 提供"重试"按钮,给用户一个简单的解决方案
- "知道了"按钮让用户可以关闭对话框,不强制重试
处理各种失败场景
实际项目中,失败的原因各种各样。我们需要针对不同的失败类型做不同的处理。
网络超时
网络超时是最常见的失败原因之一,特别是在移动网络环境下:
import 'dart:async';
Future<bool> addToCartWithTimeout(int productId) async {
try {
return await addToCartApi(productId).timeout(
const Duration(seconds: 5),
onTimeout: () {
print('请求超时');
return false;
},
);
} catch (e) {
print('网络错误: $e');
return false;
}
}
⏱️ 超时设置建议:
- 普通接口:5 秒
- 上传文件:10-15 秒
- 下载大文件:根据文件大小动态设置
超时后返回
false,动画会显示失败图标并返回起点。
timeout 方法的 onTimeout 回调会在超时时执行,我们在这里返回 false。外层的 try-catch 用来捕获其他可能的异常。
库存不足
电商场景下,库存不足是很常见的失败原因:
Future<bool> checkStockAndAddToCart(int productId) async {
try {
// 先检查库存
final hasStock = await checkStock(productId);
if (!hasStock) {
showStockOutDialog();
return false;
}
// 再添加到购物车
return await addToCartApi(productId);
} catch (e) {
return false;
}
}
这里我把库存检查和加入购物车分成两步。如果库存不足,直接返回 false,不用等到加入购物车才失败。
库存不足的对话框可以做得更友好一些:
void showStockOutDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('库存不足'),
content: const Text('抱歉,该商品暂时缺货,您可以订阅到货通知'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('知道了'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
subscribeStockNotification(); // 订阅到货通知
},
child: const Text('到货通知'),
),
],
),
);
}
🛒 用户体验提升:
- 不要只说"失败",要告诉用户具体原因
- 提供替代方案,比如"到货通知"
- 让用户感觉被关心,而不是被拒绝
登录态过期
用户可能在浏览商品的时候登录态过期了,这时候加入购物车会失败:
Future<bool> addToCartWithAuth(int productId) async {
// 检查用户是否登录
if (!isUserLoggedIn()) {
showLoginDialog();
return false;
}
return await addToCartApi(productId);
}
登录对话框:
void showLoginDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('需要登录'),
content: const Text('请先登录后再添加商品到购物车'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
navigateToLogin(); // 跳转到登录页
},
child: const Text('去登录'),
),
],
),
);
}
🔐 登录态检查的时机:
- 可以在调用 API 之前检查,提前拦截
- 也可以在 API 返回 401 时处理
- 建议两者都做,双重保险
服务器错误
服务器错误需要根据 HTTP 状态码做不同处理:
import 'package:http/http.dart' as http;
import 'dart:convert';
Future<bool> addToCartWithErrorHandling(int productId) async {
try {
final response = await http.post(
Uri.parse('https://api.example.com/cart/add'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'productId': productId}),
).timeout(const Duration(seconds: 10));
先发送请求,设置 10 秒超时。
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return data['success'] == true;
} else if (response.statusCode == 400) {
showErrorMessage('请求参数错误');
return false;
} else if (response.statusCode == 401) {
showLoginDialog();
return false;
} else if (response.statusCode == 404) {
showErrorMessage('商品不存在');
return false;
} else if (response.statusCode >= 500) {
showErrorMessage('服务器繁忙,请稍后重试');
return false;
}
return false;
根据不同的状态码给出不同的提示。
} on TimeoutException {
showErrorMessage('网络超时,请检查网络连接');
return false;
} on http.ClientException {
showErrorMessage('网络连接失败');
return false;
} catch (e) {
showErrorMessage('未知错误:$e');
return false;
}
}
🌐 HTTP 状态码处理建议:
- 200:成功
- 400:客户端错误,检查请求参数
- 401:未授权,引导用户登录
- 403:禁止访问,可能是权限问题
- 404:资源不存在
- 500+:服务器错误,建议稍后重试
不要把所有错误都显示"网络错误",用户会很困惑。
显示错误消息的通用方法
写一个通用的错误提示方法:
void showErrorMessage(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error, color: Colors.white),
const SizedBox(width: 8),
Expanded(child: Text(message)),
],
),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
action: SnackBarAction(
label: '关闭',
textColor: Colors.white,
onPressed: () {},
),
),
);
}
红色背景 + 错误图标,让用户一眼就知道出问题了。
进阶:错误分类和智能处理
在大型项目中,我建议把错误分类处理,这样代码更清晰,也更容易维护。
定义错误类型
enum AddToCartError {
networkError,
stockOut,
unauthorized,
serverError,
unknown,
}
用枚举来定义所有可能的错误类型,比用字符串更安全。
封装返回结果
class AddToCartResult {
final bool success;
final AddToCartError? error;
final String? message;
AddToCartResult({
required this.success,
this.error,
this.message,
});
}
把成功/失败、错误类型、错误消息都封装在一个对象里,方便传递和处理。
实现带详细错误的 API 调用
Future<AddToCartResult> addToCartWithDetailedError(int productId) async {
try {
final response = await http.post(
Uri.parse('https://api.example.com/cart/add'),
body: jsonEncode({'productId': productId}),
).timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
return AddToCartResult(success: true);
} else if (response.statusCode == 400) {
final data = jsonDecode(response.body);
if (data['code'] == 'STOCK_OUT') {
return AddToCartResult(
success: false,
error: AddToCartError.stockOut,
message: '商品库存不足',
);
}
} else if (response.statusCode == 401) {
return AddToCartResult(
success: false,
error: AddToCartError.unauthorized,
message: '请先登录',
);
}
return AddToCartResult(
success: false,
error: AddToCartError.serverError,
message: '服务器错误',
);
} on TimeoutException {
return AddToCartResult(
success: false,
error: AddToCartError.networkError,
message: '网络超时',
);
} catch (e) {
return AddToCartResult(
success: false,
error: AddToCartError.unknown,
message: '未知错误',
);
}
}
这个函数返回的不是简单的 bool,而是一个包含详细信息的对象。
根据错误类型做不同处理
void handleError(AddToCartError error, String message) {
switch (error) {
case AddToCartError.networkError:
showRetryDialog(message); // 网络错误,提供重试
break;
case AddToCartError.stockOut:
showStockOutDialog(); // 库存不足,提供到货通知
break;
case AddToCartError.unauthorized:
showLoginDialog(); // 未登录,引导登录
break;
case AddToCartError.serverError:
showErrorMessage(message); // 服务器错误,显示消息
break;
case AddToCartError.unknown:
showErrorMessage(message); // 未知错误,显示消息
break;
}
}
🎯 分类处理的好处:
- 代码结构清晰,每种错误有专门的处理逻辑
- 容易扩展,新增错误类型只需要加一个 case
- 便于统计,可以记录每种错误的发生频率
- 用户体验更好,不同错误有不同的解决方案
在动画中使用
void addToCartWithDetailedFeedback(int productId) {
PiuAnimation.addAnimation(
rootKey,
piuWidget,
endOffset,
loadingCallback: () async {
final result = await addToCartWithDetailedError(productId);
if (!result.success) {
// 在这里处理错误,但要注意时机
// 这个回调是在动画悬停期间执行的
// 所以错误处理会在动画返回起点之前触发
handleError(result.error!, result.message!);
}
return result.success;
},
doSomethingFinishCallBack: (success) {
if (success) {
setState(() => cartCount++);
}
// 失败的处理已经在 loadingCallback 里做了
},
);
}
⚠️ 注意时机:
loadingCallback里的代码在动画悬停期间执行- 如果在这里弹出对话框,对话框会在动画返回之前显示
- 如果想等动画完全结束再处理,把逻辑放到
doSomethingFinishCallBack里
实现重试机制
失败后让用户手动点击重试是一种方式,但有时候我们希望自动重试几次。
简单的自动重试
Future<bool> addToCartWithRetry(
int productId, {
int maxRetries = 3,
Duration retryDelay = const Duration(seconds: 1),
}) async {
int attempts = 0;
while (attempts < maxRetries) {
try {
final result = await addToCartApi(productId);
if (result) {
return true; // 成功了,直接返回
}
attempts++;
if (attempts < maxRetries) {
print('第 $attempts 次失败,${retryDelay.inSeconds} 秒后重试...');
await Future.delayed(retryDelay);
}
} catch (e) {
attempts++;
if (attempts >= maxRetries) {
return false; // 重试次数用完了
}
await Future.delayed(retryDelay);
}
}
return false;
}
🔄 重试策略建议:
- 最多重试 3 次,太多会让用户等太久
- 每次重试间隔 1-2 秒,给服务器喘息的机会
- 只对网络错误重试,业务错误(如库存不足)不要重试
- 重试期间可以显示进度,让用户知道在干什么
指数退避重试
更高级的重试策略是指数退避,每次重试的间隔时间翻倍:
Future<bool> addToCartWithExponentialBackoff(int productId) async {
int attempts = 0;
int delayMs = 1000; // 初始延迟 1 秒
while (attempts < 3) {
try {
final result = await addToCartApi(productId);
if (result) return true;
attempts++;
if (attempts < 3) {
print('第 $attempts 次失败,${delayMs}ms 后重试...');
await Future.delayed(Duration(milliseconds: delayMs));
delayMs *= 2; // 延迟时间翻倍
}
} catch (e) {
attempts++;
if (attempts >= 3) return false;
await Future.delayed(Duration(milliseconds: delayMs));
delayMs *= 2;
}
}
return false;
}
第一次失败后等 1 秒,第二次等 2 秒,第三次等 4 秒。这样可以避免在服务器压力大的时候雪上加霜。
离线支持:失败也能成功
有时候网络确实不行,但我们不想让用户的操作白费。可以先把操作缓存到本地,等网络恢复后再同步。
检测网络状态
import 'package:connectivity_plus/connectivity_plus.dart';
Future<bool> isNetworkAvailable() async {
final result = await Connectivity().checkConnectivity();
return result != ConnectivityResult.none;
}
离线时保存到本地
import 'package:shared_preferences/shared_preferences.dart';
Future<bool> addToCartWithOfflineSupport(int productId) async {
if (!await isNetworkAvailable()) {
// 离线时保存到本地
await saveToLocalCart(productId);
showOfflineMessage();
return true; // 返回 true 让动画完成
}
return await addToCartApi(productId);
}
Future<void> saveToLocalCart(int productId) async {
final prefs = await SharedPreferences.getInstance();
final offlineCart = prefs.getStringList('offline_cart') ?? [];
offlineCart.add(productId.toString());
await prefs.setStringList('offline_cart', offlineCart);
}
💡 离线支持的关键:
- 返回
true让动画正常完成,用户看到的是成功- 在本地记录这个操作,等网络恢复后同步
- 给用户一个提示,让他知道数据会在联网后同步
提示用户
void showOfflineMessage() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('当前离线,商品已保存到本地,联网后将自动同步'),
duration: Duration(seconds: 3),
),
);
}
网络恢复后同步
void initNetworkListener() {
Connectivity().onConnectivityChanged.listen((result) {
if (result != ConnectivityResult.none) {
// 网络恢复了,同步离线数据
syncOfflineCart();
}
});
}
Future<void> syncOfflineCart() async {
final prefs = await SharedPreferences.getInstance();
final offlineCart = prefs.getStringList('offline_cart') ?? [];
if (offlineCart.isEmpty) return;
for (final productId in offlineCart) {
try {
await addToCartApi(int.parse(productId));
} catch (e) {
print('同步失败: $productId');
}
}
// 清空离线缓存
await prefs.remove('offline_cart');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('离线数据已同步')),
);
}
鸿蒙平台特殊处理
网络状态监听
在鸿蒙平台上,网络状态监听和 Android/iOS 类似,但要注意一些细节:
class NetworkManager {
static final Connectivity _connectivity = Connectivity();
static Future<bool> isConnected() async {
final result = await _connectivity.checkConnectivity();
return result != ConnectivityResult.none;
}
static Stream<ConnectivityResult> get onConnectivityChanged {
return _connectivity.onConnectivityChanged;
}
}
📱 鸿蒙网络监听注意事项:
connectivity_plus包支持鸿蒙,但要确保版本兼容- 在
module.json5里配置网络权限- 真机测试时注意不同网络环境的表现
错误日志收集
在鸿蒙设备上收集错误日志,方便排查问题:
class ErrorLogger {
static void logAddToCartError(
int productId,
String error,
StackTrace? stackTrace,
) {
print('Add to cart failed: productId=$productId, error=$error');
// 上报到错误监控平台
// 可以用 Sentry、Bugly 等
reportToMonitoring({
'event': 'add_to_cart_failed',
'productId': productId,
'error': error,
'platform': 'harmonyos',
'timestamp': DateTime.now().toIso8601String(),
});
}
}
在 API 调用失败时记录日志:
Future<bool> addToCartApi(int productId) async {
try {
// API 调用...
} catch (e, stackTrace) {
ErrorLogger.logAddToCartError(productId, e.toString(), stackTrace);
return false;
}
}
性能优化
失败场景下的性能优化和成功场景类似,但有几点额外注意:
1. 失败对话框不要太复杂
对话框里不要放太多内容,加载慢会让用户更烦躁:
// ❌ 不推荐:对话框里放网络图片
AlertDialog(
content: Column(
children: [
Image.network('https://...'), // 网络图片加载慢
Text('...'),
],
),
)
// ✅ 推荐:简洁的文字和图标
AlertDialog(
title: Row(
children: [
Icon(Icons.error_outline, color: Colors.red),
Text('添加失败'),
],
),
content: Text('商品添加失败,请稍后重试'),
)
2. 重试不要太频繁
自动重试时,间隔不要太短:
// ❌ 不推荐:间隔太短
retryDelay: const Duration(milliseconds: 100),
// ✅ 推荐:至少 1 秒
retryDelay: const Duration(seconds: 1),
3. 离线缓存不要太大
离线缓存的数据量要控制:
Future<void> saveToLocalCart(int productId) async {
final prefs = await SharedPreferences.getInstance();
final offlineCart = prefs.getStringList('offline_cart') ?? [];
// 限制离线缓存数量
if (offlineCart.length >= 100) {
offlineCart.removeAt(0); // 移除最早的
}
offlineCart.add(productId.toString());
await prefs.setStringList('offline_cart', offlineCart);
}
测试失败场景
模拟各种失败
写一个测试辅助类,方便切换不同的失败场景:
enum TestScenario {
success,
networkTimeout,
serverError,
stockOut,
unauthorized,
}
class TestHelper {
static TestScenario currentScenario = TestScenario.success;
static Future<bool> mockAddToCart(int productId) async {
await Future.delayed(const Duration(milliseconds: 1500));
switch (currentScenario) {
case TestScenario.success:
return true;
case TestScenario.networkTimeout:
throw TimeoutException('Network timeout');
case TestScenario.serverError:
return false;
case TestScenario.stockOut:
return false;
case TestScenario.unauthorized:
return false;
}
}
}
在开发模式下加一个测试面板:
Widget buildTestControls() {
return Column(
children: [
Text('当前场景: ${TestHelper.currentScenario}'),
Wrap(
spacing: 8,
children: [
ElevatedButton(
onPressed: () {
setState(() {
TestHelper.currentScenario = TestScenario.success;
});
},
child: const Text('成功'),
),
ElevatedButton(
onPressed: () {
setState(() {
TestHelper.currentScenario = TestScenario.networkTimeout;
});
},
child: const Text('超时'),
),
ElevatedButton(
onPressed: () {
setState(() {
TestHelper.currentScenario = TestScenario.stockOut;
});
},
child: const Text('库存不足'),
),
],
),
],
);
}
🧪 测试建议:
- 每种失败场景都要测试
- 测试快速连续点击的情况
- 测试网络切换的情况(WiFi → 4G → 无网络)
- 在不同配置的鸿蒙设备上测试
写在最后
失败场景的处理往往比成功场景更复杂,但也更重要。一个好的失败处理能让用户感觉被关心,而不是被抛弃。
我的一些经验
1. 失败提示要具体
不要只说"操作失败",要告诉用户为什么失败。"库存不足"比"添加失败"更有信息量。
2. 提供解决方案
告诉用户失败了还不够,要告诉他怎么解决。重试按钮、到货通知、联系客服,都是好的选择。
3. 不要让用户等太久
设置合理的超时时间,自动重试次数不要太多。用户的耐心是有限的。
4. 离线支持是加分项
如果能做到离线也能用,用户体验会好很多。特别是在网络不稳定的环境下。
5. 日志很重要
记录失败日志,方便排查问题。线上出了问题,没有日志就是抓瞎。
piu_animation 的失败动画设计
piu_animation 的失败动画设计得很巧妙:
- 失败图标清晰可见
- 返回起点的动画流畅自然
- 回调参数明确告诉你成功还是失败
配合完善的错误处理逻辑,可以让你的应用在各种异常情况下都能优雅地应对。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐


所有评论(0)