Flutter for OpenHarmony:从零搭建今日资讯App(三十)错误处理与异常管理

写代码的时候,最怕什么?

不是功能实现不了,而是程序突然崩了,用户一脸懵。

错误处理这事儿,说起来简单,做起来讲究。try-catch谁都会写,但怎么写得优雅、写得健壮,让用户感知不到异常的存在,这才是真本事。

今天咱们就来聊聊今日资讯App里的错误处理策略。从本地存储到网络请求,从UI降级到用户提示,把这套防御性编程的思路捋一遍。

为什么错误处理这么重要

先说个真实场景。

用户打开App,点了收藏按钮,结果App闪退了。为啥?因为SharedPreferences初始化失败,代码里直接调用了空对象的方法。

这种问题在开发阶段很难发现,因为你的测试环境一切正常。但到了用户手里,各种奇葩情况都可能出现:存储空间不足、权限被拒绝、系统版本不兼容......

所以,防御性编程不是可选项,是必选项。

本地存储的错误处理

本地存储是最容易出问题的地方之一。SharedPreferences看起来简单,但初始化可能失败,读写可能出错。

来看看收藏功能的Provider是怎么处理的:

dart 复制代码
class FavoritesProvider extends ChangeNotifier {
  List<NewsArticle> _favorites = [];
  bool _isLoaded = false;
  SharedPreferences? _prefs;
  
  List<NewsArticle> get favorites => List.unmodifiable(_favorites);
  bool get isLoaded => _isLoaded;

注意这里的SharedPreferences? _prefs,用的是可空类型。这不是偷懒,而是故意为之。因为SharedPreferences的初始化是异步的,而且可能失败,用可空类型可以明确表达这种不确定性。

_isLoaded这个标志位也很关键,它告诉UI层数据是否已经加载完成。在数据加载完成之前,UI可以显示loading状态,避免显示空白页面让用户困惑。

接下来看初始化逻辑:

dart 复制代码
Future<void> _initPrefs() async {
  try {
    _prefs = await SharedPreferences.getInstance();
  } catch (e) {
    debugPrint('初始化SharedPreferences失败: $e');
    _prefs = null;
  }
}

这段代码的精髓在于:即使初始化失败,也不会崩溃 。失败了就把_prefs设为null,后续的读写操作会检查这个值,做相应的降级处理。

debugPrint用于开发阶段的调试输出,正式发布时可以替换成日志收集服务,方便排查线上问题。

数据加载的容错机制

再看加载收藏数据的逻辑:

dart 复制代码
Future<void> _loadFavorites() async {
  try {
    if (_prefs == null) {
      await _initPrefs();
    }
    if (_prefs != null) {
      final favoritesJson = _prefs!.getStringList('favorites') ?? [];
      _favorites = favoritesJson
          .map((json) => NewsArticle.fromJson(jsonDecode(json)))
          .toList();
    }
  } catch (e) {
    debugPrint('加载收藏失败: $e');
    _favorites = [];
  } finally {
    _isLoaded = true;
    notifyListeners();
  }
}

这里有几个值得注意的点。

首先是双重检查 :先检查_prefs是否为null,如果是就尝试重新初始化。这种"懒加载+重试"的模式很实用,即使第一次初始化失败,后续调用时还有机会恢复。

其次是空值合并运算符?? []确保即使存储中没有数据,也能得到一个空列表,而不是null。

然后是finally块 :无论成功还是失败,都会执行_isLoaded = truenotifyListeners()。这保证了UI层一定能收到通知,不会卡在loading状态。

最后是异常时的降级 :catch块里把_favorites设为空列表。用户看到的是"暂无收藏",而不是崩溃页面。

数据保存的异步处理

保存数据时的错误处理同样重要:

dart 复制代码
Future<void> _saveFavorites() async {
  try {
    if (_prefs == null) {
      await _initPrefs();
    }
    if (_prefs != null) {
      final favoritesJson = _favorites
          .map((article) => jsonEncode(article.toJson()))
          .toList();
      await _prefs!.setStringList('favorites', favoritesJson);
    }
  } catch (e) {
    debugPrint('保存收藏失败: $e');
  }
}

保存失败了怎么办?这里选择了静默失败。为什么?因为收藏数据已经在内存中更新了,用户当前的操作不受影响。下次打开App时,这条收藏可能丢失,但总比App崩溃强。

当然,更完善的做法是加入重试机制,或者在失败时给用户一个轻量级的提示。但要注意,不要用弹窗打断用户的操作流程。

用户操作的响应策略

来看收藏切换的实现:

dart 复制代码
Future<void> toggleFavorite(NewsArticle article) async {
  if (isFavorite(article.id)) {
    _favorites.removeWhere((a) => a.id == article.id);
  } else {
    _favorites.insert(0, article);
  }
  notifyListeners();
  await _saveFavorites();
}

注意这里的顺序:先更新内存,再通知UI,最后保存到本地

这种顺序的好处是用户能立即看到反馈。点击收藏按钮,图标马上变红,不用等待存储操作完成。即使保存失败,用户当前的体验也不受影响。

这就是所谓的乐观更新策略,在移动端开发中非常常见。

主题设置的错误恢复

主题Provider的错误处理有个有趣的细节:

dart 复制代码
Future<void> _loadThemeMode() async {
  try {
    if (_prefs == null) {
      await _initPrefs();
    }
    if (_prefs != null) {
      final themeModeString = _prefs!.getString('themeMode') ?? 'system';
      _themeMode = ThemeMode.values.firstWhere(
        (e) => e.toString() == 'ThemeMode.$themeModeString',
        orElse: () => ThemeMode.system,
      );
    }
  } catch (e) {
    debugPrint('加载主题设置失败: $e');
    _themeMode = ThemeMode.system;
  }
  notifyListeners();
}

firstWhere方法的orElse参数是个保险措施。如果存储的主题值被篡改或损坏,找不到对应的枚举值,就回退到系统默认主题。

catch块里也做了同样的处理:出错就用默认值。这种优雅降级的思路贯穿整个App。

浏览历史的数据管理

浏览历史的Provider展示了更复杂的数据结构处理:

dart 复制代码
class HistoryItem {
  final NewsArticle article;
  final DateTime viewedAt;

  HistoryItem({
    required this.article,
    required this.viewedAt,
  });

  Map<String, dynamic> toJson() => {
    'article': article.toJson(),
    'viewedAt': viewedAt.toIso8601String(),
  };

  factory HistoryItem.fromJson(Map<String, dynamic> json) {
    return HistoryItem(
      article: NewsArticle.fromJson(json['article']),
      viewedAt: DateTime.parse(json['viewedAt']),
    );
  }
}

这里把文章和浏览时间封装成一个对象,序列化时用ISO 8601格式存储时间。这种标准格式的好处是跨平台兼容,不会因为时区问题出错。

加载历史记录时的处理:

dart 复制代码
Future<void> _loadHistory() async {
  try {
    if (_prefs == null) {
      await _initPrefs();
    }
    if (_prefs != null) {
      final historyJson = _prefs!.getStringList('browsingHistory') ?? [];
      _history = historyJson
          .map((json) => HistoryItem.fromJson(jsonDecode(json)))
          .toList();
      _history.sort((a, b) => b.viewedAt.compareTo(a.viewedAt));
    }
  } catch (e) {
    debugPrint('加载浏览历史失败: $e');
    _history = [];
  } finally {
    _isLoaded = true;
    notifyListeners();
  }
}

加载完成后还做了排序,确保最新的记录在前面。这个排序操作放在try块里,如果数据格式有问题导致排序失败,整个加载过程会被catch捕获,返回空列表。

添加历史记录的边界处理

dart 复制代码
Future<void> addToHistory(NewsArticle article) async {
  _history.removeWhere((item) => item.article.id == article.id);
  
  _history.insert(0, HistoryItem(
    article: article,
    viewedAt: DateTime.now(),
  ));
  
  if (_history.length > 100) {
    _history = _history.sublist(0, 100);
  }
  
  notifyListeners();
  await _saveHistory();
}

这段代码处理了几个边界情况。

去重:先删除已存在的相同文章,再添加新记录。这样同一篇文章多次浏览,只保留最新的一条记录。

数量限制:历史记录最多保存100条。这不仅是为了节省存储空间,也是为了避免列表过长导致的性能问题。

插入位置:新记录插入到列表开头,保证最新的在最前面。

网络请求的错误处理

网络请求是另一个错误高发区。来看API服务的实现:

dart 复制代码
Future<List<NewsArticle>> fetchSpaceNews({int limit = 20, int offset = 0}) async {
  try {
    final response = await http.get(
      Uri.parse('$spaceflightNewsUrl?limit=$limit&offset=$offset'),
    );

    if (response.statusCode == 200) {
      final data = json.decode(response.body);
      final results = data['results'] as List;
      return results.map((json) => NewsArticle.fromSpaceflightJson(json)).toList();
    }
    return [];
  } catch (e) {
    return [];
  }
}

这里的策略是:网络请求失败就返回空列表

为什么不抛出异常让上层处理?因为对于新闻列表这种场景,空数据和错误数据的处理方式是一样的------显示"暂无数据"或"加载失败,请重试"。把异常转换成空列表,可以简化上层的处理逻辑。

当然,如果需要区分"无数据"和"加载失败",可以用更复杂的返回类型,比如Result模式或者Either类型。

状态码的检查

dart 复制代码
if (response.statusCode == 200) {
  // 处理成功响应
}
return [];

只有状态码是200才处理数据,其他情况一律返回空列表。这种做法简单粗暴但有效。

更细致的做法是根据不同的状态码做不同处理:

  • 401/403:提示用户重新登录
  • 404:资源不存在
  • 500:服务器错误,稍后重试
  • 其他:通用错误提示

但对于一个资讯类App,这种细分可能过度设计了。

UI层的降级处理

新闻详情页的分享功能展示了UI层的错误处理:

dart 复制代码
Future<void> _shareArticle(BuildContext context) async {
  final shareText = '${widget.article.title}\n\n${widget.article.url}';
  
  try {
    await Share.share(
      shareText,
      subject: widget.article.title,
    );
  } catch (e) {
    debugPrint('分享失败: $e');
    try {
      await Clipboard.setData(ClipboardData(text: shareText));
      if (context.mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('链接已复制到剪贴板')),
        );
      }
    } catch (clipboardError) {
      if (context.mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('分享失败,请稍后重试')),
        );
      }
    }
  }
}

这里用了多级降级策略。

第一级:尝试调用系统分享功能。

第二级:分享失败,尝试复制到剪贴板,并提示用户。

第三级:剪贴板也失败了,显示通用错误提示。

用户的感知是:点击分享按钮,要么弹出分享面板,要么提示"已复制到剪贴板",要么提示"分享失败"。无论哪种情况,App都不会崩溃,用户都能得到反馈。

context.mounted检查

dart 复制代码
if (context.mounted) {
  ScaffoldMessenger.of(context).showSnackBar(...);
}

这个检查非常重要。异步操作完成时,用户可能已经离开了当前页面,context已经失效。如果不检查就直接使用context,会抛出异常。

context.mounted是Flutter 3.7引入的API,用于检查Widget是否还在树中。这是处理异步操作后更新UI的标准做法。

打开链接的容错

dart 复制代码
Future<void> _openUrl(BuildContext context) async {
  try {
    final uri = Uri.parse(widget.article.url);
    final canLaunch = await canLaunchUrl(uri);
    if (canLaunch) {
      await launchUrl(uri, mode: LaunchMode.externalApplication);
    } else {
      await Clipboard.setData(ClipboardData(text: widget.article.url));
      if (context.mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('无法打开链接,已复制到剪贴板')),
        );
      }
    }
  } catch (e) {
    debugPrint('打开链接失败: $e');
    // 降级处理...
  }
}

打开外部链接的处理更加谨慎。先用canLaunchUrl检查是否能打开,不能打开就降级到复制链接。

为什么要先检查?因为在某些设备上,直接调用launchUrl可能会抛出异常,而不是静默失败。先检查可以避免这种情况。

设置页面的错误处理

设置Provider展示了更多的错误处理模式:

dart 复制代码
Future<void> _saveBool(String key, bool value) async {
  try {
    if (_prefs == null) {
      await _initPrefs();
    }
    if (_prefs != null) {
      await _prefs!.setBool(key, value);
    }
  } catch (e) {
    debugPrint('保存设置失败: $e');
  }
}

把保存逻辑抽成通用方法,所有的布尔值设置都走这个方法。这样错误处理逻辑只需要写一次,减少了代码重复,也降低了出错的概率。

清除数据的安全处理

dart 复制代码
Future<void> clearBrowsingData({
  bool clearCache = true,
  bool clearHistory = true,
  bool clearSearchHistory = true,
}) async {
  try {
    if (_prefs == null) {
      await _initPrefs();
    }
    if (_prefs != null) {
      if (clearHistory) {
        await _prefs!.remove('browsingHistory');
      }
      if (clearSearchHistory) {
        await _prefs!.remove('searchHistory');
      }
    }
  } catch (e) {
    debugPrint('清除数据失败: $e');
  }
}

清除数据时用的是remove方法而不是setStringList(key, [])remove更干净,直接删除键值对,而不是存一个空列表。

参数用命名参数加默认值,调用时可以灵活选择要清除哪些数据。

记录浏览历史的时机

dart 复制代码
@override
void initState() {
  super.initState();
  WidgetsBinding.instance.addPostFrameCallback((_) {
    context.read<HistoryProvider>().addToHistory(widget.article);
  });
}

记录浏览历史放在addPostFrameCallback里,而不是直接在initState中调用。这是因为initState执行时,Widget还没有完全构建完成,此时访问context可能会有问题。

addPostFrameCallback确保在第一帧渲染完成后再执行,这时候context是安全的。

图片加载的错误处理

dart 复制代码
CachedNetworkImage(
  imageUrl: widget.article.imageUrl!,
  fit: BoxFit.cover,
  placeholder: (context, url) => Container(
    color: Colors.grey[300],
    child: const Center(child: CircularProgressIndicator()),
  ),
  errorWidget: (context, url, error) => _buildDetailPlaceholder(),
)

CachedNetworkImage组件内置了错误处理。placeholder在加载中显示,errorWidget在加载失败时显示。

这种声明式的错误处理比命令式的try-catch更简洁,也更符合Flutter的设计理念。

占位图的实现:

dart 复制代码
Widget _buildDetailPlaceholder() {
  return Container(
    color: Colors.grey[200],
    child: Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(
            Icons.article_outlined,
            size: 80,
            color: Colors.grey[400],
          ),
          const SizedBox(height: 16),
          Text(
            '新闻图片',
            style: TextStyle(
              fontSize: 16,
              color: Colors.grey[500],
            ),
          ),
        ],
      ),
    ),
  );
}

占位图不是简单的灰色方块,而是有图标和文字说明。这样用户知道这里本来应该是什么内容,体验更好。

日期解析的防御

dart 复制代码
final publishedDate = DateTime.tryParse(widget.article.publishedAt);
final dateStr = publishedDate != null
    ? DateFormat('yyyy-MM-dd HH:mm').format(publishedDate)
    : '未知时间';

tryParse而不是parseparse在格式不对时会抛异常,tryParse会返回null。

然后用三元表达式处理null的情况,显示"未知时间"。这种防御性写法在处理外部数据时特别重要,因为你永远不知道API会返回什么奇怪的东西。

错误处理的最佳实践

总结一下今日资讯App中用到的错误处理策略:

可空类型表达不确定性 :用?明确标记可能为null的变量,编译器会强制你处理null的情况。

双重检查模式:先检查是否为null,是的话尝试初始化,再检查一次。这种模式兼顾了懒加载和错误恢复。

优雅降级:出错时不是直接崩溃,而是回退到一个可接受的状态。比如加载失败就显示空列表,分享失败就复制到剪贴板。

乐观更新:先更新UI,再执行耗时操作。即使后续操作失败,用户当前的体验也不受影响。

finally保证执行:无论成功失败,都要通知UI更新状态,避免卡在loading。

context.mounted检查:异步操作后使用context前,先检查Widget是否还在树中。

静默失败vs用户提示:后台操作失败可以静默处理,用户主动操作失败要给反馈。

写在最后

错误处理不是事后补救,而是设计的一部分。

写代码的时候,要时刻想着:这里可能出错吗?出错了怎么办?用户会看到什么?

好的错误处理是无感的。用户不知道后台发生了什么异常,他只知道App很稳定,从来不崩溃。

今日资讯App的错误处理策略不算复杂,但覆盖了大部分常见场景。在实际项目中,你可能还需要考虑:

  • 错误日志收集和上报
  • 网络请求的重试机制
  • 离线模式的支持
  • 更细粒度的错误类型区分

但核心思路是一样的:预见可能的错误,优雅地处理它们,让用户感知不到异常的存在

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

相关推荐
2401_zq136y032 小时前
Flutter for OpenHarmony:从零搭建今日资讯App(二十六)本地存储实现
flutter
2401_882351523 小时前
Flutter for OpenHarmony 商城App实战 - 会员中心实现
windows·flutter
鸣弦artha5 小时前
Flutter框架跨平台鸿蒙开发——Widget体系概览
flutter·华为·harmonyos
南村群童欺我老无力.5 小时前
Flutter 框架跨平台鸿蒙开发 - 打造安全可靠的密码生成器,支持强度检测与历史记录
flutter·华为·typescript·harmonyos
鸣弦artha6 小时前
Flutter 框架跨平台鸿蒙开发——Flutter引擎层架构概览
flutter·架构·harmonyos
时光慢煮7 小时前
基于 Flutter × OpenHarmony 图书馆管理系统之构建搜索栏
flutter·华为·开源·openharmony
kirk_wang7 小时前
Flutter艺术探索-Flutter生命周期:State生命周期详解
flutter·移动开发·flutter教程·移动开发教程
鸣弦artha8 小时前
Flutter框架跨平台鸿蒙开发——Build流程深度解析
开发语言·javascript·flutter
鸣弦artha8 小时前
Flutter框架跨平台鸿蒙开发——StatelessWidget基础
flutter·华为·harmonyos