Flutter 网络请求完全指南:Dio 封装与拦截器实战

Flutter 网络请求完全指南:Dio 封装与拦截器实战

在 Flutter 开发中,网络请求是连接前端与后端服务的核心桥梁,直接影响应用的交互体验与数据流转效率。Dio 作为 Flutter 生态中最主流的网络请求库,支持 RESTful API、FormData、拦截器、请求取消、超时设置等丰富功能,几乎能满足所有网络请求场景需求。本文将从 Dio 基础用法入手,逐步深入到企业级封装方案、拦截器实战技巧,再到异常处理、请求取消等高级用法,为开发者提供一份全面的 Dio 实战指南。

作者:爱吃大芒果

个人主页 爱吃大芒果

本文所属专栏 Flutter

更多专栏

Ascend C 算子开发教程(进阶)
鸿蒙集成
从0到1自学C++


一、Dio 基础:快速上手与核心配置

在进行复杂封装前,首先需要掌握 Dio 的基础用法与核心配置,快速实现简单的 GET/POST 请求。

1. 环境准备:添加依赖与权限

首先在 pubspec.yaml 中添加 Dio 依赖(推荐使用最新稳定版,可在 pub.dev 查看最新版本):

yaml 复制代码
dependencies:
  flutter:
    sdk: flutter
  dio: ^5.4.3  # 最新稳定版
  json_annotation: ^4.8.1  # 可选,用于 JSON 序列化(推荐配套使用)
  flutter_dotenv: ^5.1.0  # 可选,用于管理环境变量(如 baseUrl)

针对 Android 平台,需在 android/app/src/main/AndroidManifest.xml 中添加网络权限:

xml 复制代码
<uses-permission android:name="android.permission.INTERNET"/>

针对 iOS 平台,需在 ios/Runner/Info.plist 中添加以下配置(iOS 10+ 要求):

xml 复制代码
<key>NSAppTransportSecurity</key>
<dict>
  <key>NSAllowsArbitraryLoads</key>
  <true/>
</dict>

注意:生产环境中不建议直接开启 NSAllowsArbitraryLoads,应针对性配置 NSExceptionDomains 允许指定域名的 HTTP 访问,或直接使用 HTTPS。

2. 核心配置:Dio 实例初始化

Dio 支持通过构造函数或 options 属性配置全局参数,核心配置项包括:

  • baseUrl:基础请求地址(避免重复拼接 URL);

  • connectTimeout:连接超时时间(默认 5 秒);

  • receiveTimeout:接收超时时间(默认 30 秒);

  • headers:全局请求头(如 Token、Content-Type);

  • responseType:响应数据类型(默认 ResponseType.json)。

基础初始化示例:

dart 复制代码
import 'package:dio/dio.dart';

// 初始化 Dio 实例
final Dio dio = Dio()
  ..options = BaseOptions(
    baseUrl: "https://api.example.com/v1",  // 基础地址
    connectTimeout: const Duration(seconds: 5),  // 连接超时
    receiveTimeout: const Duration(seconds: 30),  // 接收超时
    headers: {
      "Content-Type": "application/json",
      "User-Agent": "Flutter-Dio-Client",
    },
    responseType: ResponseType.json,
  );

3. 基础请求:GET 与 POST 实现

Dio 对 GET/POST 等常用请求提供了简洁的 API,支持异步调用(使用 async/await)。

(1)GET 请求:查询参数传递

GET 请求通过 queryParameters 参数传递查询参数,适用于数据查询场景:

dart 复制代码
// 发起 GET 请求(获取用户列表)
Future<List<User>> getUserList({int page = 1, int size = 20}) async {
  try {
    final response = await dio.get(
      "/users",  // 接口路径(拼接 baseUrl 后为 https://api.example.com/v1/users)
      queryParameters: {"page": page, "size": size},  // 查询参数
    );
    // 解析响应数据(假设后端返回格式为 { "code": 200, "data": [...], "msg": "success" })
    if (response.data["code"] == 200) {
      return (response.data["data"] as List)
          .map((json) => User.fromJson(json))
          .toList();
    } else {
      throw Exception("获取用户列表失败:${response.data["msg"]}");
    }
  } catch (e) {
    throw Exception("请求异常:$e");
  }
}
(2)POST 请求:JSON 与 FormData 提交

POST 请求适用于数据提交场景,支持 JSON 格式与 FormData 格式(文件上传常用):

dart 复制代码
// 1. JSON 格式提交(用户登录)
Future<LoginResponse> login({required String username, required String password}) async {
  try {
    final response = await dio.post(
      "/login",
      data: {"username": username, "password": password},  // JSON 数据
    );
    if (response.data["code"] == 200) {
      return LoginResponse.fromJson(response.data["data"]);
    } else {
      throw Exception("登录失败:${response.data["msg"]}");
    }
  } catch (e) {
    throw Exception("登录异常:$e");
  }
}

// 2. FormData 格式提交(文件上传)
Future<String> uploadFile({required String filePath}) async {
  try {
    final formData = FormData.fromMap({
      "file": await MultipartFile.fromFile(
        filePath,
        filename: filePath.split("/").last,  // 文件名
      ),
      "type": "avatar",  // 额外参数
    });
    final response = await dio.post(
      "/upload",
      data: formData,
      options: Options(
        headers: {"Content-Type": "multipart/form-data"},  // 自动设置,可省略
      ),
    );
    if (response.data["code"] == 200) {
      return response.data["data"]["fileUrl"];  // 返回文件 URL
    } else {
      throw Exception("文件上传失败:${response.data["msg"]}");
    }
  } catch (e) {
    throw Exception("上传异常:$e");
  }
}

二、企业级封装:高内聚低耦合的 Dio 工具类

基础用法虽简洁,但在大型项目中存在代码冗余、维护困难等问题。通过封装 Dio 工具类,可实现请求统一管理、全局配置复用、异常集中处理,提升代码可维护性。

1. 封装思路:分层设计与单一职责

采用"工具类 + 接口层 + 模型层"的分层设计:

  • 工具类(DioUtil):封装 Dio 实例、全局配置、拦截器、请求方法(get/post/upload 等);

  • 接口层(ApiService):集中管理所有接口地址与请求参数,避免硬编码;

  • 模型层(Model):通过 JSON 序列化工具生成数据模型,统一解析响应数据。

2. 完整封装实现:DioUtil 工具类

dart 复制代码
import 'package:dio/dio.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';

// 网络请求工具类
class DioUtil {
  // 单例模式(懒加载)
  static final DioUtil _instance = DioUtil._internal();
  factory DioUtil() => _instance;
  late Dio _dio;

  // 私有构造函数:初始化 Dio 配置
  DioUtil._internal() {
    _dio = Dio()
      ..options = BaseOptions(
        baseUrl: dotenv.env["BASE_URL"] ?? "https://api.example.com/v1",  // 从环境变量读取 baseUrl
        connectTimeout: const Duration(seconds: 5),
        receiveTimeout: const Duration(seconds: 30),
        headers: {"Content-Type": "application/json"},
      );

    // 添加拦截器(后续详解)
    _addInterceptors();
  }

  // 添加拦截器
  void _addInterceptors() {
    // 请求拦截器:添加 Token、日志打印等
    _dio.interceptors.add(
      InterceptorsWrapper(
        onRequest: (options, handler) {
          // 示例:添加 Token(从本地缓存获取)
          final String? token = _getLocalToken();
          if (token != null) {
            options.headers["Authorization"] = "Bearer $token";
          }
          print("请求信息:${options.method} ${options.uri},参数:${options.data}");
          return handler.next(options);  // 继续请求
        },
        onResponse: (response, handler) {
          print("响应信息:${response.statusCode},数据:${response.data}");
          return handler.next(response);  // 继续处理响应
        },
        onError: (DioException e, handler) {
          print("请求异常:${e.message}");
          return handler.next(e);  // 继续处理异常
        },
      ),
    );

    // 日志拦截器(可选,用于调试)
    _dio.interceptors.add(LogInterceptor(responseBody: true));
  }

  // 从本地缓存获取 Token(示例方法,实际需结合本地存储库如 shared_preferences)
  String? _getLocalToken() {
    // 实际场景:return await SharedPreferences.getInstance().then((prefs) => prefs.getString("token"));
    return "test_token_123456";
  }

  // 通用 GET 请求
  Future<T> get<T>(
    String path, {
    Map<String, dynamic>? queryParameters,
    Options? options,
  }) async {
    try {
      final response = await _dio.get(
        path,
        queryParameters: queryParameters,
        options: options,
      );
      return _handleResponse<T>(response);
    } on DioException catch (e) {
      _handleDioError(e);
      rethrow;  // 抛出异常,让业务层处理
    }
  }

  // 通用 POST 请求
  Future<T> post<T>(
    String path, {
    dynamic data,
    Map<String, dynamic>? queryParameters,
    Options? options,
  }) async {
    try {
      final response = await _dio.post(
        path,
        data: data,
        queryParameters: queryParameters,
        options: options,
      );
      return _handleResponse<T>(response);
    } on DioException catch (e) {
      _handleDioError(e);
      rethrow;
    }
  }

  // 通用文件上传请求
  Future<T> upload<T>(
    String path, {
    required FormData formData,
    Options? options,
  }) async {
    try {
      final response = await _dio.post(
        path,
        data: formData,
        options: options ?? Options(
          headers: {"Content-Type": "multipart/form-data"},
        ),
      );
      return _handleResponse<T>(response);
    } on DioException catch (e) {
      _handleDioError(e);
      rethrow;
    }
  }

  // 响应处理:统一解析后端返回格式
  T _handleResponse<T>(Response response) {
    final Map<String, dynamic> data = response.data;
    // 假设后端统一返回格式:{ "code": int, "data": T, "msg": String }
    if (data["code"] == 200) {
      return data["data"] as T;
    } else {
      throw Exception("业务异常:${data["msg"] ?? "未知错误"}");
    }
  }

  // Dio 异常处理:分类处理超时、网络错误、404/500 等
  void _handleDioError(DioException e) {
    switch (e.type) {
      case DioExceptionType.connectionTimeout:
        throw Exception("连接超时,请检查网络");
      case DioExceptionType.receiveTimeout:
        throw Exception("接收超时,服务器响应缓慢");
      case DioExceptionType.connectionError:
        throw Exception("网络错误,请检查网络连接");
      case DioExceptionType.notFound:
        throw Exception("接口不存在(404)");
      case DioExceptionType.badResponse:
        throw Exception("服务器错误(${e.response?.statusCode})");
      default:
        throw Exception("请求失败:${e.message}");
    }
  }

  // 取消请求(需配合 CancelToken 使用)
  void cancelRequest(CancelToken cancelToken) {
    cancelToken.cancel("请求已取消");
  }
}

3. 接口层与模型层配合使用

(1)接口层(ApiService):集中管理接口
dart 复制代码
import 'package:json_annotation/json_annotation.dart';
import 'dio_util.dart';

// 接口地址常量
class ApiPath {
  static const String login = "/login";
  static const String getUserList = "/users";
  static const String uploadFile = "/upload";
}

// 接口服务类
class ApiService {
  static final DioUtil _dio = DioUtil();

  // 登录接口
  static Future<LoginResponse> login({required String username, required String password}) async {
    final data = await _dio.post<Map<String, dynamic>>(
      ApiPath.login,
      data: {"username": username, "password": password},
    );
    return LoginResponse.fromJson(data);
  }

  // 获取用户列表接口
  static Future<List<User>> getUserList({int page = 1, int size = 20}) async {
    final data = await _dio.get<List<dynamic>>(
      ApiPath.getUserList,
      queryParameters: {"page": page, "size": size},
    );
    return data.map((json) => User.fromJson(json)).toList();
  }

  // 文件上传接口
  static Future<UploadResponse> uploadAvatar({required String filePath}) async {
    final formData = FormData.fromMap({
      "file": await MultipartFile.fromFile(filePath, filename: filePath.split("/").last),
      "type": "avatar",
    });
    final data = await _dio.upload<Map<String, dynamic>>(
      ApiPath.uploadFile,
      formData: formData,
    );
    return UploadResponse.fromJson(data);
  }
}
(2)模型层(通过 json_serializable 生成)

首先创建模型类(如 login_response.dart),并通过注解定义序列化规则:

dart 复制代码
import 'package:json_annotation/json_annotation.dart';

part 'login_response.g.dart';  // 生成的代码文件

@JsonSerializable()
class LoginResponse {
  final String token;
  final String username;
  final String avatar;

  LoginResponse({
    required this.token,
    required this.username,
    required this.avatar,
  });

  // 从 JSON 解析模型
  factory LoginResponse.fromJson(Map<String, dynamic> json) => _$LoginResponseFromJson(json);

  // 模型转换为 JSON
  Map<String, dynamic> toJson() => _$LoginResponseToJson(this);
}

执行以下命令生成序列化代码:

bash 复制代码
flutter pub run build_runner build

三、拦截器实战:请求/响应增强与异常拦截

Dio 拦截器是其核心特性之一,支持在请求发起前、响应返回后、异常发生时插入自定义逻辑,实现 Token 自动添加、日志打印、刷新 Token、缓存处理等功能。

1. 拦截器核心原理

Dio 拦截器基于"责任链模式"设计,支持添加多个拦截器,按添加顺序执行:

  • 请求拦截器(onRequest):在请求发起前执行,可修改请求参数(如添加 Token、动态修改 baseUrl);

  • 响应拦截器(onResponse):在响应返回后执行,可统一解析响应数据、处理缓存;

  • 异常拦截器(onError):在请求异常时执行,可统一处理错误(如 Token 过期刷新、网络错误提示)。

2. 实战场景 1:Token 自动添加与过期刷新

在请求拦截器中自动添加 Token,在异常拦截器中处理 Token 过期(401 错误)并刷新 Token 后重试请求:

dart 复制代码
void _addInterceptors() {
  _dio.interceptors.add(
    InterceptorsWrapper(
      onRequest: (options, handler) async {
        // 1. 添加 Token
        final String? token = await _getLocalToken();
        if (token != null) {
          options.headers["Authorization"] = "Bearer $token";
        }
        handler.next(options);
      },
      onError: (DioException e, handler) async {
        // 2. 处理 Token 过期(401 错误)
        if (e.response?.statusCode == 401) {
          // 2.1 锁定拦截器,避免并发请求重复刷新 Token
          _dio.lock();
          try {
            // 2.2 调用刷新 Token 接口
            final String newToken = await _refreshToken();
            if (newToken.isNotEmpty) {
              // 2.3 保存新 Token 到本地
              await _saveLocalToken(newToken);
              // 2.4 重新设置请求头中的 Token
              e.requestOptions.headers["Authorization"] = "Bearer $newToken";
              // 2.5 重试原请求
              final Response response = await _dio.fetch(e.requestOptions);
              return handler.resolve(response);  // 重试成功,返回新响应
            }
          } catch (refreshError) {
            // 2.6 刷新 Token 失败,跳转登录页
            _navigateToLogin();
          } finally {
            // 2.7 解锁拦截器
            _dio.unlock();
          }
        }
        handler.next(e);  // 继续处理其他错误
      },
    ),
  );
}

// 刷新 Token 接口(实际需对接后端)
Future<String> _refreshToken() async {
  final response = await _dio.post(
    "/refreshToken",
    data: {"refreshToken": await _getLocalRefreshToken()},
  );
  if (response.data["code"] == 200) {
    return response.data["data"]["token"];
  } else {
    throw Exception("刷新 Token 失败");
  }
}

3. 实战场景 2:请求缓存拦截器

通过拦截器实现 GET 请求缓存,减少重复网络请求,提升离线体验(需配合本地存储库如 hiveshared_preferences):

dart 复制代码
import 'package:hive/hive.dart';

// 缓存拦截器
class CacheInterceptor extends Interceptor {
  final Box _cacheBox = Hive.box("network_cache");  // 初始化 Hive 缓存箱

  @override
  void onRequest(RequestOptions options, RequestHandler handler) {
    // 仅对 GET 请求启用缓存
    if (options.method == "GET") {
      final String cacheKey = _generateCacheKey(options);
      final dynamic cacheData = _cacheBox.get(cacheKey);
      // 缓存存在且未过期,直接返回缓存数据
      if (cacheData != null && !_isCacheExpired(cacheData["timestamp"])) {
        return handler.resolve(
          Response(
            requestOptions: options,
            statusCode: 200,
            data: cacheData["data"],
          ),
        );
      }
    }
    handler.next(options);
  }

  @override
  void onResponse(Response response, ResponseHandler handler) {
    // 对 GET 请求结果进行缓存
    if (response.requestOptions.method == "GET") {
      final String cacheKey = _generateCacheKey(response.requestOptions);
      _cacheBox.put(
        cacheKey,
        {
          "data": response.data,
          "timestamp": DateTime.now().millisecondsSinceEpoch,  // 缓存时间戳
        },
      );
    }
    handler.next(response);
  }

  // 生成缓存 Key(基于 URL + 查询参数)
  String _generateCacheKey(RequestOptions options) {
    return "${options.uri.toString()}?${options.queryParameters.toString()}";
  }

  // 判断缓存是否过期(假设缓存有效期为 5 分钟)
  bool _isCacheExpired(int timestamp) {
    const int cacheDuration = 5 * 60 * 1000;  // 5 分钟(毫秒)
    return DateTime.now().millisecondsSinceEpoch - timestamp > cacheDuration;
  }
}

添加缓存拦截器到 Dio 实例:

dart 复制代码
_dio.interceptors.add(CacheInterceptor());

4. 实战场景 3:日志拦截器与调试优化

Dio 内置 LogInterceptor,可快速打印请求/响应日志,便于调试:

dart 复制代码
_dio.interceptors.add(
  LogInterceptor(
    request: true,  // 打印请求信息
    requestHeader: true,  // 打印请求头
    requestBody: true,  // 打印请求体(POST 数据)
    responseHeader: true,  // 打印响应头
    responseBody: true,  // 打印响应体(敏感数据需注意屏蔽)
    error: true,  // 打印异常信息
    logPrint: (object) {
      // 自定义日志打印方式(如写入文件、上传到服务器)
      print("Dio Log: $object");
    },
  ),
);

四、高级用法:请求取消、超时设置与并发控制

在复杂场景(如列表下拉刷新、页面销毁时),需要对请求进行精细化控制,避免无效请求导致的性能问题或数据错乱。

1. 请求取消:CancelToken 用法

Dio 支持通过 CancelToken 取消单个或多个请求,适用于"页面销毁时取消未完成请求""快速切换标签时取消前一个请求"等场景:

dart 复制代码
// 1. 创建 CancelToken 实例
final CancelToken _cancelToken = CancelToken();

// 2. 发起请求时关联 CancelToken
Future<List<User>> getUserList() async {
  try {
    return await ApiService.getUserList(
      cancelToken: _cancelToken,  // 关联取消令牌
    );
  } on DioException catch (e) {
    if (CancelToken.isCancel(e)) {
      print("请求已取消:${e.message}");
    } else {
      throw e;
    }
  }
}

// 3. 取消请求(如页面销毁时)
@override
void dispose() {
  _cancelToken.cancel("页面已销毁,取消请求");  // 取消请求并添加原因
  super.dispose();
}

2. 超时设置:全局与局部结合

Dio 支持全局超时设置(初始化时配置)与局部超时设置(单个请求单独配置),局部配置会覆盖全局配置:

dart 复制代码
// 1. 全局超时(已在 DioUtil 初始化时配置)
// connectTimeout: Duration(seconds: 5),
// receiveTimeout: Duration(seconds: 30),

// 2. 局部超时(单个请求单独设置,适用于大文件上传/下载)
Future<String> downloadFile({required String url, required String savePath}) async {
  final response = await DioUtil().dio.download(
    url,
    savePath,
    options: Options(
      sendTimeout: const Duration(minutes: 5),  // 发送超时(大文件上传)
      receiveTimeout: const Duration(minutes: 5),  // 接收超时(大文件下载)
    ),
  );
  return savePath;
}

3. 并发控制:限制同时请求数量

在"批量上传文件""同时发起多个接口请求"等场景,过多并发请求可能导致网络阻塞,可通过 dio-queue 等第三方库实现并发控制:

dart 复制代码
// 添加依赖
dependencies:
  dio_queue: ^1.0.0

// 初始化队列拦截器(限制最大并发数为 3)
final QueueInterceptor queueInterceptor = QueueInterceptor(
  maxConcurrentRequests: 3,  // 最大并发请求数
);

// 添加到 Dio 拦截器
DioUtil().dio.interceptors.add(queueInterceptor);

五、异常处理:全面覆盖网络与业务错误

网络请求过程中可能出现多种异常(如网络错误、超时、404、500、业务错误等),需通过统一的异常处理机制提升用户体验。

1. 异常分类与处理思路

将异常分为三类,分别处理:

  • 网络异常:无网络、连接超时、接收超时等,提示用户"检查网络连接";

  • HTTP 异常:404(接口不存在)、500(服务器错误)、401(未授权)等,根据状态码给出对应提示;

  • 业务异常:后端返回的业务错误(如"用户名或密码错误""参数校验失败"),直接显示后端返回的错误信息。

2. 统一异常处理实现

在 DioUtil 中封装异常处理方法,并在业务层通过 try/catch 捕获并处理:

dart 复制代码
// 1. 定义异常类型枚举(便于业务层判断)
enum NetworkErrorType {
  networkError,  // 网络错误
  timeout,       // 超时
  httpError,     // HTTP 错误
  businessError, // 业务错误
  cancel,        // 请求取消
  unknown,       // 未知错误
}

// 2. 自定义异常类
class NetworkException implements Exception {
  final NetworkErrorType type;
  final String message;

  NetworkException({required this.type, required this.message});

  @override
  String toString() => "NetworkException: $type, message: $message";
}

// 3. 在 DioUtil 中统一转换异常
void _handleDioError(DioException e) {
  if (CancelToken.isCancel(e)) {
    throw NetworkException(type: NetworkErrorType.cancel, message: e.message ?? "请求已取消");
  }

  switch (e.type) {
    case DioExceptionType.connectionError:
      throw NetworkException(type: NetworkErrorType.networkError, message: "网络错误,请检查网络连接");
    case DioExceptionType.connectionTimeout:
    case DioExceptionType.receiveTimeout:
    case DioExceptionType.sendTimeout:
      throw NetworkException(type: NetworkErrorType.timeout, message: "请求超时,请稍后重试");
    case DioExceptionType.badResponse:
      final int statusCode = e.response?.statusCode ?? 0;
      throw NetworkException(
        type: NetworkErrorType.httpError,
        message: "服务器错误($statusCode),请稍后重试",
      );
    default:
      throw NetworkException(type: NetworkErrorType.unknown, message: e.message ?? "未知错误");
  }
}

// 4. 业务层处理异常
Future<void> fetchData() async {
  try {
    final data = await ApiService.getUserList();
    // 处理正常数据
  } on NetworkException catch (e) {
    // 根据异常类型显示不同提示
    switch (e.type) {
      case NetworkErrorType.networkError:
        _showToast(e.message);
        break;
      case NetworkErrorType.timeout:
        _showToast(e.message);
        break;
      case NetworkErrorType.cancel:
        // 无需提示
        break;
      default:
        _showToast(e.message);
    }
  }
}

六、总结与最佳实践

Dio 作为 Flutter 网络请求的首选库,其强大的功能与灵活的扩展性能够满足从简单到复杂的所有网络场景需求。结合本文内容,总结以下最佳实践:

  • 采用"工具类 + 接口层 + 模型层"的分层封装方案,提升代码可维护性;

  • 合理使用拦截器实现 Token 管理、日志打印、缓存处理,减少重复代码;

  • 通过 CancelToken 取消无效请求,避免内存泄漏与数据错乱;

  • 统一异常处理机制,区分网络异常、HTTP 异常与业务异常,提升用户体验;

  • 使用环境变量管理 baseUrl,区分开发/测试/生产环境,避免硬编码;

  • 配合 json_serializable 实现 JSON 序列化,减少手动解析错误。

通过以上方案,可构建一套稳定、高效、易维护的 Flutter 网络请求体系,为应用的后续迭代与扩展奠定坚实基础。

相关推荐
刘发财1 小时前
弃用html2pdf.js,这个html转pdf方案能力是它的几十倍
前端·javascript·github
ssshooter8 小时前
看完就懂 useSyncExternalStore
前端·javascript·react.js
Live000009 小时前
在鸿蒙中使用 Repeat 渲染嵌套列表,修改内层列表的一个元素,页面不会更新
前端·javascript·react native
柳杉9 小时前
使用Ai从零开发智慧水利态势感知大屏(开源)
前端·javascript·数据可视化
忆江南9 小时前
iOS 深度解析
flutter·ios
球球pick小樱花10 小时前
游戏官网前端工具库:海内外案例解析
前端·javascript·css
喝水的长颈鹿10 小时前
【大白话前端 02】网页从解析到绘制的全流程
前端·javascript
明君8799710 小时前
Flutter 实现 AI 聊天页面 —— 记一次 Markdown 数学公式显示的踩坑之旅
前端·flutter
用户145369814587810 小时前
VersionCheck.js - 让前端版本更新变得简单优雅
前端·javascript
codingWhat10 小时前
整理「祖传」代码,就是在开发脚手架?
前端·javascript·node.js