为什么要专门讲失败场景

上一篇文章讲了 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

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐