freeCodeCamp.org

How to Handle Errors the Right Way in Flutter: A Practical Guide to Sealed Classes, Records, and Result Types

8.5内容质量

TL;DR · AI 摘要

Flutter中使用Result类型、密封类和模式匹配能更清晰地处理错误,提升代码可维护性和错误可见性。

核心要点

  • 使用Result类型可以将错误作为值显式处理,避免隐藏的异常。
  • Dart 3的记录(Records)能简化错误信息的传递和处理。
  • 模式匹配使错误处理更直观,减少try/catch的滥用。

结构提纲

按章节快速跳转。

  1. try/catch在简单场景中有效,但无法解决错误不可见、异常传染性和错误类型模糊的问题。

  2. 将错误视为值,使错误处理更显式、可追踪和可维护。

  3. 使用密封类构建Result类型,使函数返回成功或失败状态,提升类型安全性。

  4. Dart 3 Records and What They Add

    Dart 3的记录(Records)简化了错误信息的结构化处理和传递。

  5. Pattern Matching on Errors

    模式匹配使错误处理更直观,减少try/catch的滥用。

  6. 通过完整示例展示如何在Flutter中结合Result类型、密封类和模式匹配处理错误。

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • Flutter错误处理
    • 问题
      • try/catch的局限性
      • 错误不可见
      • 异常传染性
      • 错误类型模糊
    • 解决方案
      • Result类型
      • 密封类
      • Dart 3记录
      • 模式匹配

金句 / Highlights

值得收藏与分享的关键句。

#Flutter#Dart#错误处理#密封类#Result类型
打开原文

在 Flutter 中正确处理错误的实践指南:密封类、记录和结果类型的使用

2026年6月20日

/

#Dart

Gidudu Nicholas

我曾经以为我在 Flutter 应用中处理错误的方式很好。我的应用中到处都有 try/catch 块。我捕获异常,记录它们,并向用户显示错误信息。这感觉很稳固。

但后来我开始更仔细地查看生产环境中实际发生的情况。有一些我从未意识到的静默失败。有些函数可能会抛出异常,但类型系统中没有任何东西提醒你这一点。错误处理在代码库中分布不一致——有些地方捕获了错误,有些地方没有。

团队中的一位初级开发人员添加了一个新的 API 调用,却完全忘记了 try/catch,而没有人发现这一点,因为代码中没有任何地方提示“这个函数可能会失败”。

从那时起,我开始认真对待错误处理作为一个架构决策,而不仅仅是一种防御性习惯。

本文将介绍我现在在生产 Flutter 应用中使用的模式——结果类型、密封类、Dart 3 记录和模式匹配——以及它们如何协同工作,使错误变得可见、明确且无法被忽视。

目录

  • 为什么单独使用 try/catch 是不够的
  • 将错误作为值:核心思想
  • 使用密封类构建结果类型
  • Dart 3 记录及其带来的优势
  • 对错误进行模式匹配
  • 应用于实际 Bloc 功能
  • 何时使用这种方法以及何时不使用
  • 端到端示例
  • 最后的想法

为什么单独使用 try/catch 是不够的

try/catch 是有效的。我不是说它无效。对于简单的情况,它完全没问题。但随着你的应用增长,将 try/catch 作为主要的错误处理策略会带来一组特定的问题,这些问题只有在大规模使用时才会变得明显。

问题是不可见性。

当一个函数可以抛出异常时,它的签名中没有任何东西告诉你这一点。看看这个例子:

code
Future<User> getUser(String userId) async {
  final response = await dio.get('/users/$userId');
  return User.fromJson(response.data);
}

这个函数看起来总是返回一个 User。它的签名中没有任何内容表明它可能会失败。调用这个函数的开发人员不知道是否需要将其包裹在 try/catch 中,除非他们阅读了实现或者之前已经因此而受过伤。

现在想象一下,这个函数在你的应用中被调用了十次。有些开发人员记得处理错误,而有些则没有。没有编译器警告,没有 lint 规则,没有任何东西可以捕获这种不一致性。错误直到用户报告崩溃时才变得可见。

第二个问题是异常具有传染性。

当一个函数抛出异常时,每个调用者都必须处理它。而每个调用者的调用者也必须处理它。错误处理的责任在你的代码库中向外扩散,通常不一致。有些层会静默地吞下异常,而有些层则会重新抛出它们。错误在你的应用中的流动变得难以理解。

第三个问题是并非所有错误都是异常。

在移动应用中,网络请求失败并不是一个异常事件。它是预期的。将其视为异常——一种中断正常流程的异常情况——是错误的思维模型。它是一个正常的输出结果,应该像其他任何输出结果一样进行处理。

这就是结果类型背后的核心见解:错误是值,而不是中断。

这个想法很简单。函数不再只是返回一个值或抛出异常,而是始终返回一个值 —— 但这个值可以表示成功或失败。

code
// 不是这样 —— 可能会抛出异常
Future<User> getUser(String userId);

// 我们这样写 —— 始终返回一个结果
Future<Result<User>> getUser(String userId);

现在函数签名变得诚实了。它告诉你“这个操作可能会成功或失败,你必须处理这两种情况。”编译器强制你处理这两种情况。你无法不小心忽略失败的情况。

这种模式来源于像 Rust 和 Kotlin 这样的语言,它们的标准库中已经内置了这种模式。在 Dart 中,我们自己构建这种模式 —— 而且借助 Dart 3 中的密封类和模式匹配,它比以往任何时候都更清晰。

使用密封类构建 Result 类型

这是我实际生产环境中使用的 Result 类型:

code
// result.dart

// sealed 表示所有可能的子类型都在这里定义。
// 编译器知道这里只有两种可能的结果 ——
// Success 和 Failure —— 没有其他可能性。
sealed class Result<T> {}

// Success 携带我们想要的值。
// T 是类型参数 —— Result<User> 表示
// Success 携带一个 User,Result<List<Post>> 携带一个列表。
class Success<T> extends Result<T> {
  final T data;
  const Success(this.data);
}

// Failure 携带一个 AppError,描述发生了什么错误。
// 我们使用一个类型化的错误类,而不是原始异常,
// 这样 UI 可以根据错误类型做出决策。
class Failure<T> extends Result<T> {
  final AppError error;
  const Failure(this.error);
}

现在我们需要一个类型化的错误类。而不是传递原始的异常信息,我们定义应用程序可能产生的具体错误:

code
// app_error.dart

// AppError 也是密封的 —— 所有我们应用程序可能产生的错误类型
// 都在这里定义。这使得无法出现未处理的错误类型。
sealed class AppError {}

// 没有网络连接
class NoInternetError extends AppError {}

// 服务器返回了错误响应
class ServerError extends AppError {
  final int statusCode;
  final String message;
  const ServerError({required this.statusCode, required this.message});
}

// 数据返回了意外的格式
class ParseError extends AppError {
  final String message;
  const ParseError(this.message);
}

// 发生了我们没有预料到的意外情况
class UnknownError extends AppError {
  final String message;
  const UnknownError(this.message);
}

现在让我们在仓库中使用它:

code
// post_repository.dart

import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:dio/dio.dart';
import 'result.dart';
import 'app_error.dart';
import 'post.dart';

class PostRepository {
  final Dio _dio;
  PostRepository(this._dio);

  Future<Result<List<Post>>> getPosts() async {
    try {
      final response = await _dio.get(
        'https://jsonplaceholder.typicode.com/posts',
      );

      // 将响应解析为 Post 对象的列表。
      // 我们在这里使用自己的 try/catch,因为解析
      // 可以独立于网络调用失败 ——
      // API 可能返回有效的 JSON,但格式可能不符合预期。
      try {
        final List<dynamic> data = response.data as List<dynamic>;
        final posts = data
            .map((json) => Post.fromJson(json as Map<String, dynamic>))
            .toList();

// 将成功值包装在 Success<List<Post>> 中 // 并返回。调用者接收到一个 Result, // 而不是原始列表,因此他们知道必须 // 检查操作是否成功或失败。 return Success(posts); } catch (e) { return Failure(ParseError('Failed to parse posts: $e')); } } on DioException catch (e) { // 将 Dio 的异常类型映射到我们自己的 AppError 类型。 // 这样可以避免将 Dio 特定的类型暴露给应用的其他部分。 // 如果我们将来将 Dio 替换为其他 HTTP 客户端, // 只需要修改这个文件即可。 if (e.type == DioExceptionType.connectionError) { return Failure(NoInternetError()); }

return Failure( ServerError( statusCode: e.response?.statusCode ?? 0, message: e.message ?? 'Server error', ), ); } catch (e) { // 用于捕获所有意外情况 return Failure(UnknownError(e.toString())); } } }

code

注意发生了什么变化。函数签名 Future<Result<List<Post>>> 现在更加诚实。任何调用 getPosts() 的人都知道他们得到的是一个 Result —— 他们不能假装它总是成功。并且 try/catch 完全包含在仓库内部。没有任何内容泄露到调用者那里。

## Dart 3 元组及其带来的变化

在进入模式匹配之前,值得谈谈 Dart 3 的元组,因为它们与 Result 类型自然地搭配。

元组是一种轻量级、匿名对象,可以将多个值组合在一起,而无需定义完整的类。可以将其视为从函数中快速返回多个值的一种方式。

// 在元组出现之前 —— 你需要一个类或一个 Map // 来返回多个值 Map<String, dynamic> getUserInfo() { return {'name': 'Nicholas', 'age': 28}; // 没有类型安全 —— 'age' 可以是任何东西 }

// 使用元组 —— 类型安全,不需要类 (String name, int age) getUserInfo() { return ('Nicholas', 28); // 编译器知道 name 是一个 String,age 是一个 int }

code

当需要返回一个值及其一些元数据时,元组在错误处理中变得非常有用:

// 一个返回帖子及其获取时间戳的函数 Future<Result<(Post, DateTime)>> getPostWithTimestamp( String postId, ) async { try { final response = await _dio.get('/posts/$postId'); final post = Post.fromJson(response.data);

// 元组 (post, DateTime.now()) 将两个值组合在一起 // 而无需使用包装类 return Success((post, DateTime.now())); } catch (e) { return Failure(UnknownError(e.toString())); } }

code

使用方式如下:

final result = await repository.getPostWithTimestamp('1');

switch (result) { case Success(:final data): // 直接在模式中解构元组 final (post, fetchedAt) = data; print('Got \({post.title} at \)fetchedAt'); case Failure(:final error): print('Failed: $error'); }

code

元组对于 Result 类型并不是必需的,但它们消除了为仅用于携带两个或三个值的辅助类的需求。我经常在需要返回数据以及分页游标或缓存元数据的仓库方法中使用它们。

## 对错误进行模式匹配

这就是所有内容汇聚的地方。密封类加上模式匹配意味着编译器强制你处理每一种可能的结果。你不能意外地忽略失败的情况。

final result = await repository.getPosts();

code

switch (result) {
  // 命名字段模式 —— 直接从 Success 中提取 'data',
  // 而无需手动转换
  case Success(:final data):
    print('Got ${data.length} posts');

  case Failure(:final error):
    // 现在对错误类型进行模式匹配,
    // 以向用户显示正确的信息
    switch (error) {
      case NoInternetError():
        print('没有网络连接。请检查您的连接。');
      case ServerError(:final statusCode, :final message):
        print('服务器错误 $statusCode: $message');
      case ParseError(:final message):
        print('数据解析时出错: $message');
      case UnknownError(:final message):
        print('意外错误: $message');
    }
}

这两个 switch 语句都是详尽的。如果您添加了一个新的 Result 子类型但忘记在此处处理,就会得到一个编译错误。添加一个新的 AppError 子类型但忘记在此处处理,也会得到一个编译错误。编译器就像您的质量控制一样在工作。

您还可以使用 when 扩展模式来实现更简洁的处理:

code
// 一个辅助扩展,使 Result 更易于使用
extension ResultExtension<T> on Result<T> {
  // 如果是 Success,运行 onSuccess;
  // 如果是 Failure,运行 onFailure
  R when<R>({
    required R Function(T data) onSuccess,
    required R Function(AppError error) onFailure,
  }) {
    return switch (this) {
      Success(:final data) => onSuccess(data),
      Failure(:final error) => onFailure(error),
    };
  }

  // 如果是 Success,返回数据;如果是 Failure,返回 null
  T? getOrNull() => switch (this) {
    Success(:final data) => data,
    Failure() => null,
  };

  // 如果是 Success,返回 true
  bool get isSuccess => this is Success<T>;

  // 如果是 Failure,返回 true
  bool get isFailure => this is Failure<T>;
}

使用方式变得非常清晰:

code
final result = await repository.getPosts();

final posts = result.when(
  onSuccess: (data) => data,
  onFailure: (error) => <Post>[],
);

将其应用于一个真实的 Bloc 功能

让我们将所有内容连接成一个完整的 Bloc。我们将使用上一节中构建的 posts 功能,并将其升级为使用 Result 类型。

带有密封类的状态:

code
// post_state.dart

sealed class PostState {}

class PostInitial extends PostState {}

class PostLoading extends PostState {}

// Success 状态直接携带 posts
class PostLoaded extends PostState {
  final List<Post> posts;
  const PostLoaded(this.posts);
}

// Error 状态携带一个 AppError 类型的错误,而不仅仅是字符串。
// 这意味着 UI 可以根据错误类型做出决策 —— 显示“没有网络”的信息,
// 或“服务器错误”的信息,或“请重试”的信息。
class PostError extends PostState {
  final AppError error;
  const PostError(this.error);
}

Bloc:

code
// post_bloc.dart

class PostBloc extends Bloc<PostEvent, PostState> {
  final PostRepository _repository;

  PostBloc(this._repository) : super(PostInitial()) {
    on<LoadPosts>(_onLoadPosts);
  }

  Future<void> _onLoadPosts(
    LoadPosts event,
    Emitter<PostState> emit,
  ) async {
    emit(PostLoading());

    // getPosts() 现在返回 Result<List<Post>>
    // 我们直接对结果进行模式匹配 ——
    // 这里不需要 try/catch,因为仓库
    // 已经处理了所有错误情况,并将其包装在 Failure 中。
    // Bloc 只需读取结果。
    final result = await _repository.getPosts();
code
switch (result) {
  case Success(:final data):
    emit(PostLoaded(data));
  case Failure(:final error):
    emit(PostError(error));
}
}

请注意,Bloc 中根本没有使用 try/catch。错误处理由仓库层负责。Bloc 只是读取 Result 并发出对应的状态。这种方式干净、简单,每一层都只做一件事。

UI 部分:

code
// post_screen.dart

BlocBuilder<PostBloc, PostState>(
  builder: (context, state) {
    return switch (state) {
      PostInitial() => const Center(
          child: Text('Press the button to load posts'),
        ),

      PostLoading() => const Center(
          child: CircularProgressIndicator(),
        ),

      PostLoaded(:final posts) => ListView.builder(
          itemCount: posts.length,
          itemBuilder: (context, index) {
            final post = posts[index];
            return ListTile(
              leading: Text('${post.id}'),

              subtitle: Text(post.body),
            );
          },
        ),

      // 对错误类型进行模式匹配,以显示
      // 每种特定错误的正确信息。
      // 这是 try/catch 无法提供的 ——
      // 可以让 UI 做出响应的类型化、结构化的错误。
      PostError(:final error) => Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Text(
                switch (error) {
                  NoInternetError() =>
                    '没有网络连接。请检查您的连接。',
                  ServerError(:final statusCode) =>
                    '服务器错误 ($statusCode)。请重试。',
                  ParseError() =>
                    '发生了一些错误。请重试。',
                  UnknownError() =>
                    '发生了意外错误。',
                },
              ),
              const SizedBox(height: 16),
              ElevatedButton(
                onPressed: () {
                  context.read<PostBloc>().add(LoadPosts());
                },
                child: const Text('重试'),
              ),
            ],
          ),
        ),
    };
  },
)

现在,UI 对每种错误类型显示不同的信息。没有网络的用户会看到与服务器错误用户不同的提示信息。这比通用的“发生了一些错误”提供了更好的用户体验。这种效果直接来自于使用了类型化的错误,而不是原始的异常信息。

这种方法何时适用,何时不适用

我想在这里坦率一点,因为我看到过一些开发者为了追求良好的架构而过度设计简单的问题。

使用 Result 类型的场景包括:

  • 函数可能以多种不同的方式失败,调用者需要分别处理这些失败
  • 你正在构建一个多个功能依赖的仓库或服务层
  • 你在一个团队中工作,错误处理不一致是一个实际存在的问题
  • 功能涉及金钱、用户数据,或任何静默失败可能带来危险的场景

使用 try/catch 的场景包括:

  • 它是一个简单的一次性操作,属于小功能的一部分
  • 无论发生什么错误,错误处理方式都是一样的:显示信息,记录日志,完成
  • 你正在做原型设计或早期开发,架构仍在变化中
  • 增加的复杂性无法由代码库的规模合理证明

结果类型模式增加了仪式感。这一点毋庸置疑。一个简单的 try/catch 代码更少。但权衡在于,try/catch 是不可见的 —— 没有东西强制调用者处理错误。而结果类型是显式的 —— 类型系统强制执行这一点。

对于那些服务于真实用户并且有多个开发者参与的生产应用程序来说,这种显式性值得额外的代码。但对于你独自开发的个人项目来说,这可能过于繁琐。

端到端示例

以下是将所有内容整合成一个完整功能的示例。将以下代码复制到一个新的 Flutter 项目中并运行。

文件结构:

code
lib/
  core/
    result.dart
    app_error.dart
  models/
    post.dart
  data/
    post_repository.dart
  bloc/
    post_bloc.dart
    post_event.dart
    post_state.dart
  ui/
    post_screen.dart
  main.dart

result.dart:

code
sealed class Result<T> {}

class Success<T> extends Result<T> {
  final T data;
  const Success(this.data);
}

class Failure<T> extends Result<T> {
  final AppError error;
  const Failure(this.error);
}

extension ResultExtension<T> on Result<T> {
  R when<R>({
    required R Function(T data) onSuccess,
    required R Function(AppError error) onFailure,
  }) {
    return switch (this) {
      Success(:final data) => onSuccess(data),
      Failure(:final error) => onFailure(error),
    };
  }
}

app_error.dart:

code
sealed class AppError {}

class NoInternetError extends AppError {}

class ServerError extends AppError {
  final int statusCode;
  final String message;
  const ServerError({required this.statusCode, required this.message});
}

class ParseError extends AppError {
  final String message;
  const ParseError(this.message);
}

class UnknownError extends AppError {
  final String message;
  const UnknownError(this.message);
}

post.dart:

code
class Post {
  final int id;
  final String title;
  final String body;
  final int userId;

  const Post({
    required this.id,
    required this.title,
    required this.body,
    required this.userId,
  });

  factory Post.fromJson(Map<String, dynamic> json) {
    return Post(
      id: json['id'] as int,

      body: json['body'] as String,
      userId: json['userId'] as int,
    );
  }
}

post_repository.dart:

code
import 'package:dio/dio.dart';
import '../core/result.dart';
import '../core/app_error.dart';
import '../models/post.dart';

class PostRepository {
  final Dio _dio;
  PostRepository(this._dio);

  Future<Result<List<Post>>> getPosts() async {
    try {
      final response = await _dio.get(
        'https://jsonplaceholder.typicode.com/posts',
      );

      try {
        final List<dynamic> data = response.data as List<dynamic>;
        final posts = data
            .map((json) => Post.fromJson(json as Map<String, dynamic>))
            .toList();
        return Success(posts);
      } catch (e) {
        return Failure(ParseError('Failed to parse posts: $e'));
      }
    } on DioException catch (e) {
      if (e.type == DioExceptionType.connectionError) {
        return Failure(NoInternetError());
      }
      return Failure(
        ServerError(
          statusCode: e.response?.statusCode ?? 0,
          message: e.message ?? 'Server error',
        ),
      );
    } catch (e) {
      return Failure(UnknownError(e.toString()));
    }
  }
}

post_event.dart:

code
sealed class PostEvent {}

class LoadPosts extends PostEvent {}

post_state.dart:

code
import '../core/app_error.dart';
import '../models/post.dart';

sealed class PostState {}

class PostInitial extends PostState {}
class PostLoading extends PostState {}

class PostLoaded extends PostState {
  final List<Post> posts;
  const PostLoaded(this.posts);
}

class PostError extends PostState {
  final AppError error;
  const PostError(this.error);
}

post_bloc.dart:

code
import 'package:flutter_bloc/flutter_bloc.dart';
import '../core/result.dart';
import '../data/post_repository.dart';
import 'post_event.dart';
import 'post_state.dart';

class PostBloc extends Bloc<PostEvent, PostState> {
  final PostRepository _repository;

  PostBloc(this._repository) : super(PostInitial()) {
    on<LoadPosts>(_onLoadPosts);
  }

  Future<void> _onLoadPosts(
    LoadPosts event,
    Emitter<PostState> emit,
  ) async {
    emit(PostLoading());

    final result = await _repository.getPosts();

    switch (result) {
      case Success(:final data):
        emit(PostLoaded(data));
      case Failure(:final error):
        emit(PostError(error));
    }
  }
}

post_screen.dart:

code
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/post_bloc.dart';
import '../bloc/post_event.dart';
import '../bloc/post_state.dart';
import '../core/app_error.dart';

class PostScreen extends StatelessWidget {
  const PostScreen({super.key});

  String _errorMessage(AppError error) {
    return switch (error) {
      NoInternetError() =>
        'No internet connection. Please check your connection.',
      ServerError(:final statusCode) =>
        'Server error ($statusCode). Please try again.',
      ParseError() => 'Something went wrong. Please try again.',
      UnknownError() => 'An unexpected error occurred.',
    };
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Posts')),
      body: BlocBuilder<PostBloc, PostState>(
        builder: (context, state) {
          return switch (state) {
            PostInitial() => const Center(
                child: Text('Press the button to load posts'),
              ),
            PostLoading() => const Center(
                child: CircularProgressIndicator(),
              ),
            PostLoaded(:final posts) => ListView.builder(
                itemCount: posts.length,
                itemBuilder: (context, index) {
                  final post = posts[index];
                  return ListTile(
                    leading: Text('${post.id}'),

                    subtitle: Text(post.body),
                  );
                },
              ),
            PostError(:final error) => Center(
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Text(_errorMessage(error)),
                    const SizedBox(height: 16),
                    ElevatedButton(
                      onPressed: () {
                        context.read<PostBloc>().add(LoadPosts());
                      },
                      child: const Text('Try again'),
                    ),
                  ],
                ),
              ),
          };
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => context.read<PostBloc>().add(LoadPosts()),
        child: const Icon(Icons.download),
      ),
    );
  }
}

main.dart:

code
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'bloc/post_bloc.dart';
import 'data/post_repository.dart';
import 'ui/post_screen.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(

      home: BlocProvider(
        create: (_) => PostBloc(PostRepository(Dio())),
        child: const PostScreen(),
      ),
    );
  }
}

最后的想法

这并不是为了显得聪明,或者仅仅是为了遵循某种模式。而是为了让错误变得可见。

使用 try/catch 作为唯一的错误处理工具存在一个根本性的问题,即它将失败的可能性隐藏在看似正常的函数签名背后。Result 类型则在类型系统中揭示了这种可能性,编译器可以帮助你一致地处理它。

密封类、类型化错误、模式匹配和 Dart 3 的记录功能的结合,为你提供了一个系统:

  • 函数诚实地表明它们可以返回的内容
  • 每种错误类型都明确地被处理
  • 添加新的错误类型会自动破坏所有未处理它的 switch 语句
  • UI 可以根据正确的错误显示相应的信息

我希望我第一次开发生产应用时就能用这种方式。这会为我节省很多时间去追踪那些无声的失败和不一致的错误状态。

如果你已经习惯了使用 try/catch,并希望将错误处理提升到更高的层次,可以从小处开始。先在其中一个仓库中添加一个 Result 类型。看看感觉如何。一旦你体验到这种清晰度带来的好处,这种模式往往会自然地传播开来。

你好,我是一名经验丰富的 Flutter 开发者,拥有丰富的开发移动应用的经验。我是一名经验丰富的社区组织者,曾在 GDG Bugiri Uganda 和 Flutter Kampala 下成功启动和建设了 Google 开发者社区。

如果这篇文章对你有帮助,请分享它。

免费学习编程。freeCodeCamp 的开源课程已帮助超过 40,000 人成为开发人员。立即开始学习

ADVERTISEMENT