我已经使用Flutter开发应用程序有几年时间了,至今仍然记得第一次发布自己的作品时,我是多么地感到自豪。那个应用拥有简洁的用户界面、流畅的动画效果,所有的功能都完全按照我的预期运行。当我把它交给真正的用户使用时,我感到非常满意。

然而仅仅一周后,各种错误报告就开始陆续出现了。

屏幕会突然卡住,API调用会无声无息地失败,用户们辛苦填写了十分钟的数据也会丢失;甚至还有用户反映,在地铁里穿过隧道后,应用程序就完全不再响应了。而我之前从未测试过这种情况……为什么我要去测试呢?在我的机器上,这个应用运行得好好的啊。

这次经历让我明白了一件事:一个能够正常运行的应用程序与一个真正适合投入生产环境的应用程序之间,存在着巨大的差距。

如今,我已经发布了多款使用Flutter开发的应用程序,也遇到了这篇文章中提到的几乎所有问题:网络故障、内存泄漏、在开发环境下看似正常的状态管理机制在大规模应用时却会引发严重问题,还有在开发者设备上表现良好的性能,在用户的旧设备上却会变得极差。

这篇文章总结的正是我从这些经验中所学到的一切。这里没有理论空谈,而是从实际问题中总结出来的实用方法。

目录

为什么“在我的机器上运行正常”在Flutter开发中却是危险的

你的开发环境应该是这样的:拥有快速的互联网连接、性能强大的机器或模拟器,每次热重载后应用程序的状态都能保持整洁,API的响应时间也在几毫秒之内;而你本身也是一名细心认真的开发者,会严格按照正确的开发流程进行操作。

然而,你的用户所处的环境却是另一番景象:他们的移动数据连接不稳定,使用的都是老旧的中端设备,同时还有其他六款应用程序在后台运行,对于那些无缘无故就无法正常加载的应用程序,他们完全没有耐心等待。

正是这种差异,导致了生产环境中各种问题的出现。

棘手的是,Flutter让开发过程显得如此顺利,以至于人们很容易将“在我的机器上可以正常运行”误认为是“已经可以供用户使用了”。

我也犯过这个错误,我认识的大多数Flutter开发者也都这么做过。应用程序看起来非常精致,动画效果也非常流畅。当你向同事演示时,一切似乎都完美无缺。但当有人在移动数据连接不稳定的情况下尝试使用它时,整个系统就会崩溃。

要想让应用程序真正适合生产环境使用,首先就必须接受一个令人不悦的事实:问题总是会发生的。网络可能会出故障,设备的内存可能会不足,用户也可能会在最不合时宜的时候将你的应用程序置于后台运行。关键不在于这些问题是否会发生,而在于你的应用程序在这些情况发生时能否妥善应对。

开发环境与生产环境:究竟有什么不同

在这里我需要具体说明一下,因为“生产环境与开发环境是不同的”这句话说起来容易,但真正体会到这种差异之前,往往已经吃了苦头。

在开发环境中,如果某个API调用失败了,你很快就会在终端中发现这个问题,并在几分钟内将其修复,然后继续接下来的工作。但在生产环境中,同样的API调用失败会导致用户看到空白屏幕,他们根本不知道为什么会出现这种情况,等待几秒钟后,要么会重新尝试连接,要么就会卸载应用程序。而你要直到三天后有人留下了一星评价时,才会意识到这个问题。

在开发环境中,不必要的组件重建可能只会消耗几毫秒的时间,你几乎感觉不到这种影响。但在生产环境中,尤其是在那些老旧或性能较低的设备上,当有多个应用程序在后台运行时,同样的不必要的重建操作很可能会使帧率超过16毫秒的限值,从而导致画面出现卡顿现象,用户就能明显感觉到这个问题了。

在开发环境中,如果某个程序存在内存泄漏问题,导致十分钟内内存使用量增加了5MB,这种问题也是难以被发现的。我曾经遇到过一个聊天功能中的内存泄漏问题,在测试过程中完全无法检测出来。但在生产环境中,当用户在内存不足的设备上使用了该应用程序一小时之后,操作系统就会在会话进行到一半时强制关闭它。用户以为这是应用程序随机崩溃了,而我花了很长时间才找到问题的根源。

这种规律始终是不变的:在开发环境下看似无关紧要的问题,在生产环境中就会变得非常严重;而在开发环境中表现良好的程序,在实际用户的设备上使用时,却可能会出现严重的问题。

网络可靠性与防御性请求处理

<如果让我从在多个应用程序中遇到的各种问题中挑选一个最令我困扰的类别,那肯定就是这个。移动网络确实存在很大的不可靠性,而Flutter开发的应用程序在编写代码时往往没有考虑到这一点。>

我最常看到的一种网络请求模式(我自己也使用过这种模式,时间长度长得让我都不愿意承认)是这样的:

final response = await dio.get('/user');

setState(() {
  user = response.data;
});

这种模式在开发环境中运行得非常顺利。但在生产环境中,它有四种可能会出问题的情况:

  1. 由于网络错误,请求失败,且异常没有被处理就直接传播了。

  2. 用户在响应数据到达之前就已经离开了当前页面,而此时`setState`方法被调用在已经被销毁的组件上。

  3. API返回的数据不符合预期,在运行时进行类型转换时会引发错误。

  4. 请求会无限期地阻塞下去,用户只能一直看到旋转图标,无法得到任何反馈。

我曾经遇到过这四种情况中的所有四种。下面是一个能够处理这些问题的版本:

Future loadUser(String userId) async {
  setState(() {
    isLoading = true;
    error = null;
  });

  try {
    final response = await dio.get('/user/$userId');

    // 如果组件已经被销毁,就不会执行后续代码。
    if (!mounted) return;

    setState(() {
      user = User.fromJson(response.data as Map

每个屏幕都需要的三种状态

过去,我在设计页面时总是只考虑成功的情况,而把加载数据和显示错误信息这些环节当作事后才需要处理的事情。这是一个错误的做法。任何需要从远程服务器获取数据的页面都需要这三种状态:

@override
Widget build(BuildContext context) {
  // 加载中:千万不要让用户看到空白的屏幕。旋转图标可以告诉他们数据正在加载中。
  if (isLoading) {
    return const Center(child: CircularProgressIndicator());
  }

  // 出现错误:向用户说明出了什么问题以及如何解决它。
  // 如果没有重试按钮,那么遇到错误时用户会感到非常沮丧。
  if (error != null) {
    return Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Text(error!, style: const TextStyle(color: Colors.red)),
          const SizedBox(height: 16),
          ElevatedButton(
            onPressed: () => loadUserwidget.userId),
            child: const Text('重试'),
          ),
        ],
      ),
    );
  }

  // 请求成功:显示用户信息。
  return UserProfileView(user: user!);
}

带有重试按钮的错误提示功能其实并不是什么必备的功能。这种设计会使得用户产生截然不同的反应:有些用户能够在网络暂时中断后继续使用应用,而另一些用户则会认为你的应用出现了故障。

重试逻辑与生产环境中的请求处理流程

移动网络经常会出现短暂性的故障。比如用户在信号不佳的区域行走、进入电梯,或者在发送请求的过程中从WiFi切换到移动数据网络,这些情况都可能导致请求失败。但如果在两秒后重新尝试,请求很可能会成功。

如果没有重试机制,对于用户来说,每一次暂时性的网络故障都会被视作永久性的失败。这种设计显然并不可取。

Future withRetry(  
  Future Function() request, {  
    int maxAttempts = 3;  
    Duration delay = const Duration(seconds: 1);  
  } async {  
    for (int i = 0; i < maxAttempts; i++) {  
      try {  
        return await request();  
      } catch (e) {  
        // 在最后一次尝试时,停止重试并让错误直接传递给调用者。  
        if (i == maxAttempts - 1) rethrow;  

        // 在再次尝试之前等待一段时间,这样可以让暂时性的网络问题得到解决,同时避免给已经负担过重的服务器带来额外的压力。  
        await Future.delayed(delay);  
      }  
    }  

    throw Exception('重试失败');  
  }  

使用方法非常简单:

final user = await withRetry(  
  () => dio.get('/user/$userId'),  
  maxAttempts: 3,  
  delay: const Duration(seconds: 2),  
);  

对于那些流量较大的生产环境应用,可以考虑使用dio_smart_retry。该方案采用了指数级退避策略,每次重试时等待时间会翻倍,这样在网络真正出现故障时,能够更好地减轻服务器的负担。

远程数据的缓存

这里的处理策略非常简单:每当网络请求成功时,就将结果保存到本地。这样,如果下一次请求失败了,系统就可以直接使用之前保存的数据,而不会显示错误页面。

class UserRepository {  
  final Dio _dio;  
  final Box _cache; // 使用Hive缓存库  

  UserRepository(this._dio, this._cache);  

  Future getUser(String userId) async {  
    try {  
      final response = await _dio.get('/user/$userId');  
      final user = User.fromJson(response.data as Map.from(cached));  
      }  

      // 没有缓存数据,只能显示错误信息了。  
      rethrow;  
    }  
  }  
}

保留用户输入的内容

这就是我为之提到的那个入门问题所提供的解决方案:

每当字段内容发生变化时,都会保存用户所输入的所有内容。
_contentController.addListener(() async {
  await _cache.put('draft_post', _contentController.text);
});

// 当页面重新加载时,会恢复之前保存的草稿内容。
@override
void initState() {
  super.initState();
  final draft = _cache.get('draft_post') as String?;
  if (draft != null && draft.isNotEmpty) {
    _contentController.text = draft;
  }
}

// 当用户成功提交内容后,会清除之前的草稿。
Future _submit() async {
  await _repository.createPost(_contentController.text);
  await _cache.delete('draft_post');
}

这三行代码能够确保用户的输入不会丢失。对于任何需要花费超过一分钟才能填写完的表单来说,这种做法都是非常必要的。

我用于实现本地数据持久化的工具包包括:

  1. Hive:适用于简单的键值存储场景

  2. Isar:当我需要执行更复杂的查询时使用

  3. sqflite:用于处理关系型数据

  4. shared_preferences:专门用于存储用户设置,不适用于存储其他重要数据

大规模状态管理

setState这个方法本身是没有问题的。我之所以要明确说明这一点,是因为在Flutter社区中,有些人倾向于认为使用setState总是错误的。但对于那些仅用于处理简单的UI状态变化(比如按钮状态的切换、表单字段的验证提示等)来说,setState确实是最佳选择。

然而,当您将setState用于管理多个组件共同依赖的状态、异步操作,或者需要确保状态在页面导航过程中仍然有效的场景时,问题就会出现了。我曾经遇到过这些情况,下面是具体会出现的问题:

这段setState代码位于组件树的高层位置。
// 它下面的所有组件都会被重新构建——包括那些与这次状态变化无关的、计算成本较高的组件。
setState(() {
  currentUser = updatedUser;
});

随着应用程序规模的扩大,这种问题会变得更加严重。重新构建操作会在整个应用系统中传播开来,各种副作用也会以不可预测的方式出现。最终,您会花费更多的时间来调试状态变化相关的问题,而不是去开发新的功能。

转向Riverpod框架

在我的第二个项目中遇到了这些瓶颈之后,我立即改用了Riverpod框架,从此再没有后悔过这个决定。Riverpod的核心理念非常简单:状态数据被存储在组件之外,各个组件只需订阅自己真正需要的状态信息即可。

@riverpod
class UserNotifier extends _$UserNotifier {
  @override
  AsyncValue build(String userId) {
    _load();
    return const AsyncValue.loading();
  }

  Future _load() async {
    state = const AsyncValueLoading();

    // AsyncValue.guard会负责执行异步操作,并在操作成功时将结果封装到AsyncValue.data中,
    // 在操作失败时则抛出异常。这样就可以避免每次都需要编写try/catch代码了。
    state = await AsyncValue_guard(
      () => ref.read(userRepositoryProvider).getUser(userId),
    );
  }

  Future refresh() => _load();
}

在这个小部件中:

@override
Widget build(BuildContext context) {
  // ref.watch会使得这个小部件与通知机制关联起来。
  // 只有当userAsync的状态发生变化时,才会重新构建这个小部件;
  // 而应用程序其他地方的状态变化并不会导致它被重新构建。
  final userAsync = ref.watch(userNotifierProvider(widget.userId));

  return userAsync.when(
    // when()强制要求你必须处理加载、出错以及数据更新这些情况。
    // 如果忽略了其中任何一种情况,都会导致编译错误,而不会在运行时才出现问题。
    loading: () => const CircularProgressIndicator(),
    error: (e, _) => Text('Error: $e'),
    data: (user) => UserProfileView(user: user),
  );
}

我最喜欢的一点是:when()这个机制确保了如果忽略了加载状态或错误处理,就会产生编译错误。编译器会强制要求开发者必须处理好这些情况。

不可变的状态

在实时聊天功能中,有一件事让我遇到了很大的麻烦:那就是在应用程序的多个部分之间共享一个可变的列表。

List〈Message〉 messages = [];

// 后来,在不同的地方对这个列表进行了操作:
messages.add(newMessage);       // 在socket处理逻辑中
messages.removeAt(0);          // 在分页功能中
messages.insert(0, pinned);    // 在推送通知功能中

有时候,会有消息出现两次,或者随机地从列表中消失;要找出到底是哪种操作导致了这种问题,真的非常麻烦。解决办法就是永远不要修改这个列表的内容,而应该每次都需要创建一个新的列表:

// 旧的列表保持不变,新的状态就是一个新的列表。
// 所有的变化都是明確的,而且可以追踪到。
state = [...state, newMessage];

在刚开始的时候,你可能觉得这只是一件小事,但当你花了两个小时去调试那些由于列表状态改变而引发的错误时,你就会意识到这件事其实非常重要。

小部件的重新构建与渲染性能

Flutter的速度确实很快。但是,如果不必要的重新构建操作频繁发生,在低端设备上,这种影响就会变得非常明显。

常量小部件可以完全避免重新构建

const关键字告诉Dart,这个小部件可以在编译时就被创建出来,并且可以被无限次地重复使用。任何内容永远不会发生变化的小部件,都适合使用这种写法。

// 不使用const关键字时:每次父小部件重新构建时,都会创建一个新的Text实例,
// 虽然它的内容其实从未发生过变化。
Text('Welcome to the app')

// 使用const关键字时:Flutter会重复使用同一个Text实例。
// 因此不会发生重新构建操作,也不会有内存分配的开销。
const Text('Welcome to the app')

这听起来可能是一件小事,但在一个包含许多静态元素的大型小部件结构中,这种写法的累积效果是非常显著的。养成使用常量小部件的习惯是非常重要的。

尽量缩小重新构建的范围

setState方法被调用在小部件结构的较高层级时,它下面的所有小部件都会被重新构建——即使这些小部件与状态变化无关也是如此。解决办法是将状态变更的信息尽可能向下传递,最好是将它们放入一个单独的小部件中进行处理。

// 问题在于:计数器的状态存储在父组件中,因此每次调用 setState 都会重新构建整个子树——包括与计数器无关的
// ExpensiveListWidget 组件。
class _BadExampleState extends State {
  int _counter = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('计数:$_counter'),
        ElevatedButton(
          onPressed: () => setState(() => _counter++),
          child: const Text('增加'),
        ),
        const ExpensiveListWidget(), // 不必要的重新构建
      ],
    );
  }
}

现在,只有当计数值发生变化时,ExpensiveListWidget才会被重新构建,其他组件则不会受到影响。

适用于长度未知的列表的 ListView.builder

Column组件会预先创建列表中的所有项目,无论这些项目是否真正可见。对于一个包含200个项目的列表来说,在用户开始滚动之前,系统就会创建200个对应的 widgets。

// 这种方式会预先创建列表中的所有项目。
// 对于一个包含200个项目的列表,第一次渲染时就会创建200个 widgets,
// 而其中大部分在用户查看屏幕时其实并不可见。
Column(
  children: items.map((item) => ItemCard(item: item)).ToList(),
)

// 这种方式只会创建可见的项目,同时还会预留一些缓冲空间。
// 即使列表中有10,000个项目,使用 ListView.builder 也不会消耗比处理10个项目更多的内存。
ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) {
    return ItemCard(items[index]);
  },
)

ListView.builder并不是为处理大型列表而设计的优化方案。对于那些长度未知或会变化的列表来说,它才是正确的默认选择。只有当我确定列表中的项目数量始终很少时,才会使用Column组件和映射列表。

异步操作中的陷阱以及被销毁的 widgets带来的问题

这种错误在开发阶段是完全看不到的,但一旦进入生产环境,就会频繁出现。

假设某个异步操作开始执行了,但在操作完成之前用户已经离开了当前页面,那么当操作最终完成并尝试调用 setState 时,会发现对应的 widget 已经被销毁了,从而导致错误发生。

Future _loadData() async {
  final data = await repository.fetchData();

  // 如果在等待数据的过程中用户离开了页面,
  // 这个 widget 就会消失,此时调用 setState 会抛出错误:
  // "setState() called after dispose()"
  setState(() => this.data = data);
}

解决这个问题的方法只需要一行代码:

Future _loadData() async {
  final data = await repository.fetchData();

  // 当 widget 还在页面上显示时,mounted 的值为 true;
  // 而在 widget 被销毁后,mounted 的值会变为 false。
  if (!mounted) return;

  setState(() => this.data = data);
}

现在,我在每次使用 await 后都会自动进行这个检查,然后再调用 setState。这种做法很快就成了我的习惯。

切勿在构建过程中创建Future对象

这是一个很容易被忽视的问题。当你在build方法内部直接创建Future对象时,每次重新构建应用时都会生成一个新的Future对象——这意味着FutureBuilder会将其视为每一次新的操作,并因此不必要的地重置到加载状态。

// 错误的做法:每次重建应用都会生成一个新的Future对象。
// FutureBuilder会认为每次都是不同的Future对象,
// 从而不必要的地重置到加载状态。
@override
Widget build(BuildContext context) {
  return FutureBuilder(
    future: repository.fetchUser(userId), // 每次重建都会生成新的Future对象
    builder: (context, snapshot) { ... },
  );
}
// 正确的做法:在 initState方法中只创建一次Future对象。
// FutureBuilder会在多次重建过程中使用同一个Future对象。
late final Future _userFuture;

@override
void initState() {
  super.initState();
  _userFuture = repository.fetchUser(widget.userId);
}

@override
Widget build(BuildContext context) {
  return FutureBuilder(
    future: _userFuture,
    builder: (context, snapshot) { ... },
  );
}

将耗时较长的操作从UI线程中分离出来

Dart会在主隔离线程上渲染用户界面。任何会占用大量CPU资源并导致渲染进程受阻的操作,都可能导致帧率下降。

// 在主隔离线程上同步解析庞大的API响应数据,
// 在性能较弱的设备上,这一操作可能会导致50到200毫秒的渲染延迟。
final users = (response.data as List)
    .map((json) => User.fromJson(json))
    .ToList();
// `compute()`方法会在单独的隔离线程中执行该函数。
// 这样主隔离线程就可以继续进行渲染工作。
// 注意:这个函数必须是顶级函数或静态函数,
// 因为捕获局部状态的闭包无法被发送到另一个隔离线程中。
final users = await compute.parseUsers, response.data);

List parseUsers(dynamic data) {
  return (data as List)
      .map((json) => User.fromJson(json as Map

每当需要解析庞大的JSON数据、进行图像处理,或者执行那些在性能测试中显示为耗时较长的操作时,我都会使用compute方法。在我看来,如果某个操作的执行时间可能超过16毫秒,那么它就不应该在主隔离线程上执行。

从未被释放的控制器

在我见过的各种内存泄漏情况中,最常见的原因就是那些在initState中被创建却从未被释放的控制器。Flutter并不会自动清理这些对象。

class _ProfileScreenState extends State {
late final TextEditingController _nameController;
late final AnimationController _fadeController;
late final ScrollController _scrollController;

@override
void initState() {
super.initState();
_nameController = TextEditingController();
_fadeController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_scrollController = ScrollController();
}

@override
void dispose() {
// 在initeState中创建的每一个控制器都需要在这里被释放。
// 这一步骤是必须执行的——它能够释放系统资源,并移除那些会导致该组件内存持续占用的监听器。
_nameController.dispose();
_fadeControllerdispose();
_scrollController.dispose();
super.dispose(); // 最后一定要执行这一步
}
}

一个未被释放的AnimationController》尤其危险。它会持续运行,在每一帧都会被触发,因此即使它所对应的界面已经从屏幕上消失,它仍然会继续消耗CPU资源。我见过这种情况会导致电池电量迅速减少,同时还会引发内存问题。

流订阅

class _ChatScreenState extends State {
StreamSubscription? _messageSubscription;

@override
void initState() {
super.initState();
_messageSubscription = messageStream.listen((message) {
// 如果不取消订阅,即使界面已经消失,这个回调函数也会继续被执行。
// 这会导致已释放的组件仍然被调用setState方法,同时那些消息对象也会一直占用内存。
if (mounted) setState(() => messages.add(message));
});
}

@override
void dispose() {
_messageSubscription?.cancel();
super.dispose();
}
}

定时器

@override
void dispose() {
// 如果在dispose之后定时器仍然被执行,它将会尝试在一个已经不存在的组件上调用回调函数。
_dismissTimer?.cancel();
super.dispose();
}

我有一条原则,从来不会违反:任何在initState中被创建,并且具有disposecancelclose方法的对象,都必须在dispose方法中被被正确释放。没有任何例外,也绝对不能说“以后再处理”。

可观测性与崩溃报告

在我将崩溃报告功能集成到我的第一个正式发布的应用程序之前,调试工作真的非常麻烦。当有用户报告应用程序崩溃时,我会询问他们当时在做什么,他们会回答“我只是打开了它”。然后我就会仔细检查代码,试图找出导致崩溃的原因,但很多时候我都无法找到答案。

有了崩溃报告功能,情况就完全不同了。

在发布前进行配置

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();

  // 捕捉 Flutter 框架中的错误——比如组件构建错误、渲染错误等
  FlutterError onError =
      FirebaseCrashlytics.instance.recordFlutterFatalError;

  // 捕捉 Flutter 无法捕捉到的异步代码中的错误——例如事件处理程序或定时器中出现的错误
  PlatformDispatcher.instance.onError = (error, stack) {
    FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
    return true;
  };

  runApp(const MyApp());
}

绝不让失败无声无息

// 这是我以前编写代码的方式。如果 submitOrder 方法抛出异常,
// 用户根本不会知道发生了什么,我也不知道。
await api.submitOrder(order);
// 现在我是这样编写的:
try {
  await api.submitOrder(order);
  setState(() => orderStatus = OrderStatus.confirmed);
} catch (e, stackTrace) {
  // recordError 方法会将完整的异常信息及堆栈跟踪发送到 Crashlytics,
  // 并自动附加设备信息和用户的最近操作记录。
  FirebaseCrashlytics.instance.recordError(e, stackTrace);
  setState(() => orderStatus = OrderStatus_FAILED);
}

路径导航功能

原始的崩溃日志只能告诉你哪里出了问题,而路径导航功能则能显示用户在出现问题时正在做什么。这两者是不同的。

FirebaseCrashlytics.instance.log('用户打开了结账页面');
FirebaseCrashlytics.instance.log('支付页面已显示');
FirebaseCrashlytics.instance.log('用户完成了支付操作');
// 在这里发生了崩溃——现在我清楚地知道事件发生的具体顺序了

测试生产环境的 Flutter 应用程序

老实说,我的第一个应用程序测试得不够充分。当时我进展得太快,各项功能都能正常使用,所以觉得编写测试代码很浪费时间。后来我在价格计算逻辑中进行了重构,结果引入了一个起初并不明显的错误,最终还是把应用发布了出去。结果有个用户在发现这个问题之前就先发现了它。

现在我会更加仔细地进行测试了。虽然不会测试所有功能,但一定会重点测试那些关键的部分。

对业务逻辑进行单元测试

test('折扣百分比的计算是否正确', () {
  final result = calculateDiscountedPrice(
    price: 100.0,
    discountPercent: 10,
  );

  // 100.00 元打九折应该等于 90.00 元
  expect(result, equals(90.0));
});

test('当折扣百分比为负数时是否会抛出异常', () {
  expect(
    () => calculateDiscountedPrice(price: 100, discountPercent: -5),
    throwsA(isA<ArgumentError>>()),
  );
});

业务逻辑部分——比如价格计算、数据验证、授权处理等——应该用纯 Dart 代码来实现,且不应依赖于 Flutter 框架。这样就可以在几毫秒内完成这些测试,而且根本不需要任何专门的测试环境。

小部件测试界面状态

Flutter的小部件测试功能确实是其最出色的特性之一。你无需使用设备或模拟器,就可以测试加载状态、错误状态以及用户交互行为。

testWidgets('在加载失败时显示错误信息并提供重试按钮',
    (tester) async {
  final mockRepo = MockUserRepository();
  when(mockRepo.getUser(any)).thenThrow(Exception('网络错误'));
  
  await tester.pumpWidget(
    ProviderScope(
      overrides: [
        userRepositoryProvider.overrideWithValue(mockRepo),
      ],
      child: const MaterialApp(home: ProfileScreen(userId: 'test')),
    ),
  );

  // pumpAndSettle会等待所有动画效果及异步操作完成后再进行断言。
  await tester.pumpAndSettle();

  expect(find.text('无法加载个人资料,请重试。'), findsOneWidget);
  expect(find.text('再试一次'), findsOneWidget);
});

在我进行的测试中,我会优先关注以下方面:核心业务逻辑、错误状态与加载过程、任何涉及金钱或用户无法恢复的数据的处理流程,以及我的应用程序与后端之间的接口集成。对于那些不包含任何逻辑的静态UI小部件,我通常不会专门进行测试。

架构设计与长期可维护性

我最初发布的那个应用并没有真正的架构设计。所有的功能都是通过小部件实现的,业务逻辑与UI代码混杂在一起,状态管理也显得非常零散。

这个应用运行了六个月都没有出现问题。但后来我需要添加一个涉及多个现有界面的新功能,结果原本应该一天就能完成的工作却花了一周时间,因为任何修改都可能会引发其他问题。

对于第二个应用程序,我在设计上更加谨慎。将各个功能分别放在不同的文件夹中,数据存储库与小部件也分开管理,状态信息则不在UI层进行处理。当需求发生变化时——而需求总是会变化的——这些变更也能得到有效的控制。

在层次边界处分离不同功能

lib/
  features/
    profile/
      data/
        profile_repository.dart     # 负责网络请求与缓存逻辑
      domain/
        user.dart                   # 简洁的数据模型
      presentation/
        profile_screen.dart         # UI小部件
        profile notifier.dart       # 负责状态更新

小部件不应该进行网络请求,数据存储库也不应该依赖于Flutter框架,它们之间更不应该了解对方的内部实现细节。

当你需要更换数据来源、用模拟对象测试状态更新逻辑,或者修改UI界面而不影响业务逻辑时,这种分离结构正是使这些操作成为可能的关键。

技术债务的累积速度比你想象的要快

今天能节省三十分钟的捷径,未来可能会让你每月多花费几小时的时间。在Flutter开发中,那些会迅速导致技术债务积累的捷径主要包括……

  • 组件内部的业务逻辑——既无法进行测试,也无法被重复使用。

  • 使用动态模型而非类型固定的模型,从而导致运行时错误而非编译时错误。

  • 复制粘贴而来的验证逻辑:在一个地方修改了这些代码后,其他地方却仍然保留着原来的版本。

  • 存在可变的全局状态,但却没有明确的负责者来管理这些状态。

这些变化在最初并不会造成灾难性的后果。但它们都会使后续的变更变得更加困难,而之后的变更难度更是会进一步增加。

端到端示例:一个适用于生产环境的功能模块

本文中提到的所有内容都被整合成了一个完整的功能模块。这个模块包括具备缓存和重试机制的仓库系统、支持乐观更新机制的Riverpod通知器、能够处理三种不同状态的小部件,以及完善的生命周期管理机制。

仓库系统

class ProfileRepository {
  final Dio _dio;
  final Box _cache;

  ProfileRepository(this._dio, this._cache);

  Future getUser(String userId) async {
    try {
      final response = await withRetry(
        () => _dio.get('/users/$userId'),
      );

      final user = User.fromJson(
        response.data as Map,
      );

      // 将成功的响应结果缓存起来,以便在离线情况下使用。
      await _cache.put('user_$userId', user.toJson());

      return user;
    } on DioException catch (e) {
      final cached = _cache.get('user_$userId');

      if (cached != null) {
        return User.fromJson(Map.from(cached));
      }

      if (e.type == DioExceptionType.connectionError) {
        throw NoInternetException();
      }

      throw ServerException(e.response?.statusCode ?? 0);
    }
  }

  Future updateDisplayName(String userId, String name) async {
    await withRetry(
      () => _dio.patch('/users/$userId', data: {'displayName': name}),
    );

    // 清除缓存,以便下次读取时能够获取到最新数据。
    await _cache.delete('user_$userId');
  }
}

通知器

@riverpod
class ProfileNotifier extends _$ProfileNotifier {
  @override
  AsyncValue build(String userId) {
    _load();
    return const AsyncValue.loading();
  }

  Future _load() async {
    state = const AsyncValueloading();
    state = await AsyncValue.guard(
      () => ref.read(profileRepositoryProvider).getUser(userId),
    );
  }

  Future refresh() => _load();

  Future updateName(String newName) async {
    final current = state.valueOrNull;
    if (current == null) return;

    try {
      await ref
          .read(profileRepositoryProvider)
          .updateDisplayName(userId, newName);

      // 立即更新用户界面,无需等待页面重新加载。
      state = AsyncValue.data(current.copyWith(displayName:出新名));
    } catch (e, st) {
      FirebaseCrashlytics.instance.recordError(e, st);
      // 如果更新失败,恢复之前的状态。
      state = AsyncValue.data(current);
      rethrow;
    }
  }
}

小部件

class ProfileScreen extends ConsumerWidget {
final String userId;
const ProfileScreen({required this.userId, super.key});

@override
Widget build(BuildContext context, WidgetRef ref) {
final profileAsync = ref.watch(profileNotifierProvider(userId));

return Scaffold(
appBar: AppBar(title: const Text('个人资料')),
body: profileAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => _ErrorView(
message: e is NoInternetException
? '没有网络连接。'
: '无法加载个人资料。'
onRetry: () => ref
.read(profileNotifierProvider(userId).notifier)
.refresh(),
),
data: (user) => _ProfileView(user: user, userId: userId),
),
);
}
}

class _ProfileView extends ConsumerStatefulWidget {
final User user;
final String userId;
const _ProfileView({required this.user, required this.userId});

@override
ConsumerState〈_ProfileView〉 createState() => _ProfileViewState();
}

class _ProfileViewState extends ConsumerState〈_ProfileView〉 {
late final TextEditingController _nameController;

@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.user.displayName);
}

@override
void dispose() {
_nameController.dispose();
superdispose();
}

Future〈void〉 _saveName() async {
try {
await ref
.read(profileNotifierProvider(widget.userId).notifier)
.updateName(_nameController.text);

if (!mounted) return;

ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('姓名已更新。')),
);
} catch (_) {
if (!mounted) return;

ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('无法更新姓名。'),
);
}
}

@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
TextField(
controller: _nameController,
decoration: const InputDecoration(labelText: '显示名称'),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _saveName,
child: const Text('保存'),
),
],
);
}
}

最后的思考

这些做法其实并没有什么特别高深之处。它们大多都属于一些常规习惯:检查设备是否已成功连接、正确处理控制器的状态、妥善管理错误情况,以及为离线使用进行数据缓存。每一项这样的习惯都能有效预防某一类生产环境中的故障,而当这些习惯被综合运用在一起时,就能让应用程序显得更加可靠,让用户在使用过程中感到安心。

我真希望自己当初开发第一个应用程序时就能够采用这样的方法。但实际上并没有,因为那时我还不知道自己有哪些知识是缺失的。这也很正常。

不过,如果你在发布自己的第一个正式上线的应用程序之前读到了这些内容,那么你就拥有了我在经历了多次应用发布以及收到了大量用户反馈之后才学到的这些经验。

在这些开发模式中,最好的应用时机是在开始开发某个新功能的时候;其次好的时机就是现在。

Comments are closed.