资讯动态

Flutter跨平台开发实战:架构、状态管理与性能优化全指南

发布时间:2026/10/17 13:32:02 来源:尧图企业网站定制
1. 从零搭建一个Flutter应用我踩过的那些坑移动端跨平台开发这件事做了几年之后你会发现真正让人头疼的从来不是“能不能跑起来”而是“跑起来之后能不能稳住”。Flutter这套框架从早期版本一路迭代到现在生态已经相当成熟但每次新建一个项目从环境配置到状态管理选型从路由设计到打包发布依然有一堆细节能把人折腾得够呛。这篇笔记不是官方文档的复述也不是什么入门教程而是我自己在多个实际项目中反复验证、反复踩坑之后沉淀下来的一套完整开发思路和实操记录。如果你正在用Flutter做应用开发或者准备把某个已有项目迁移到Flutter上又或者你刚学完Dart语法但不知道一个真实项目该怎么组织那这篇内容应该能帮你省下不少试错时间。我会从项目架构设计讲起把状态管理选型、路由方案、网络层封装、本地存储、性能优化、打包配置这些核心环节逐一拆开每个环节都会说清楚“为什么这么做”以及“不这么做会怎样”。全文基于我实际写过的代码和调过的bug能直接抄作业的地方我会把代码贴出来需要根据业务调整的地方我会说明判断依据。2. 项目架构设计别一上来就写页面2.1 为什么架构设计要放在第一步很多人拿到需求之后的第一反应是打开IDE新建项目然后开始写首页。这个习惯在小demo里没问题但一旦项目超过十个页面、涉及三四个开发者协作没有提前设计架构的代价就会成倍放大。我见过太多项目写到一半发现状态管理选错了、路由跳转乱成一锅粥、网络请求散落在各个Widget里最后只能推倒重来。Flutter的架构设计核心要解决三个问题代码怎么分层、状态怎么流转、模块怎么解耦。这三个问题不想清楚后面每加一个功能都是在给技术债添砖加瓦。我目前用的方案是经典的三层架构表现层Presentation、业务逻辑层Domain、数据层Data。表现层只负责UI渲染和用户交互业务逻辑层承载用例和状态管理数据层处理网络请求、本地存储和模型转换。层与层之间通过抽象接口通信上层不直接依赖下层的具体实现。2.2 目录结构怎么划分才合理目录结构没有绝对标准但有一个原则按功能模块划分而不是按文件类型划分。什么意思呢就是不要把所有页面放在一个pages文件夹、所有模型放在一个models文件夹而是按照业务模块来组织。我通常采用这样的结构lib/ ├── core/ # 核心基础设施 │ ├── network/ # 网络请求封装 │ ├── storage/ # 本地存储封装 │ ├── router/ # 路由配置 │ ├── theme/ # 主题与样式 │ └── utils/ # 通用工具函数 ├── features/ # 功能模块 │ ├── home/ │ │ ├── data/ # 数据层repository实现、数据源 │ │ ├── domain/ # 业务层实体、用例 │ │ ├── presentation/ # 表现层页面、Widget、状态管理 │ │ └── home.dart # 模块导出文件 │ ├── profile/ │ └── settings/ ├── shared/ # 跨模块共享的Widget和工具 │ ├── widgets/ │ └── extensions/ └── main.dart # 入口文件这样划分的好处是当你需要修改某个功能时所有相关代码都在同一个目录下不需要在多个文件夹之间来回跳。团队协作时每个人负责不同的feature目录冲突概率大幅降低。2.3 分层之间的依赖规则有一个铁律依赖只能从外层指向内层。表现层可以依赖业务逻辑层业务逻辑层可以依赖数据层但反过来绝对不行。数据层不知道业务逻辑层的存在业务逻辑层不知道UI长什么样。具体到代码层面我会在业务逻辑层定义抽象接口比如UserRepository然后在数据层实现它UserRepositoryImpl。表现层通过依赖注入拿到接口的实例调用方法获取数据。这样做的直接好处是写单元测试时可以轻松替换成Mock实现切换数据源比如从REST API换成GraphQL时只需要改数据层的实现上层代码一行不动。注意不要为了分层而分层。如果项目只有三五个页面搞一套完整的三层架构反而增加维护成本。架构的复杂度应该和项目规模匹配小项目用简单的Provider加目录划分就够了。3. 状态管理选型Provider、Riverpod还是Bloc3.1 三种主流方案的适用场景对比状态管理是Flutter开发中争议最多的话题没有之一。官方推荐的Provider、社区流行的Riverpod、老牌劲旅Bloc每个都有自己的拥趸。我三个都用过说下实际感受。方案学习曲线适用规模核心优势主要痛点Provider低中小型官方推荐、上手快、社区资源多类型安全弱、嵌套深时难维护Riverpod中中大型编译期安全、不依赖BuildContext、可测试性强概念多、学习成本高Bloc高中大型事件驱动清晰、状态可追溯、团队规范强模板代码多、简单场景过度设计我的选择逻辑是这样的如果项目页面少于20个、团队只有一两个人直接用Provider别折腾。如果项目预期会长期迭代、团队超过三人、状态逻辑复杂比如涉及多步表单、实时数据流上Riverpod。Bloc适合那种对状态流转有严格审计需求的场景比如金融类应用普通项目用它有点杀鸡用牛刀。3.2 Riverpod的实战配置我现在的主力方案是Riverpod这里展开说下具体怎么用。首先在pubspec.yaml里加依赖dependencies: flutter_riverpod: ^2.4.0 riverpod_annotation: ^2.3.0 dev_dependencies: riverpod_generator: ^2.3.0 build_runner: ^2.4.0然后在入口用ProviderScope包裹整个应用void main() { runApp(const ProviderScope(child: MyApp())); }定义状态时我习惯用代码生成的方式减少手写模板代码riverpod class HomeNotifier extends _$HomeNotifier { override FutureListArticle build() async { final repository ref.watch(articleRepositoryProvider); return repository.fetchArticles(); } Futurevoid refresh() async { state const AsyncLoading(); state await AsyncValue.guard(() async { final repository ref.read(articleRepositoryProvider); return repository.fetchArticles(); }); } }跑一次dart run build_runner buildRiverpod会自动生成对应的Provider。在Widget里这样消费class HomePage extends ConsumerWidget { override Widget build(BuildContext context, WidgetRef ref) { final articlesAsync ref.watch(homeNotifierProvider); return articlesAsync.when( data: (articles) ArticleList(articles: articles), loading: () const Center(child: CircularProgressIndicator()), error: (error, stack) ErrorView(error: error), ); } }这套写法的好处是类型安全编译期就能发现大部分错误而且AsyncValue天然处理了加载、成功、失败三种状态不用自己写一堆if-else。3.3 状态管理的常见误区第一个误区是把所有状态都塞进全局状态管理。页面内部的临时状态比如一个开关的选中状态、一个输入框的内容用StatefulWidget的setState就够了没必要上升到全局。判断标准很简单这个状态是否需要跨页面共享不需要的话就留在页面内部。第二个误区是在build方法里做副作用操作。比如在build里调用网络请求、修改状态这会导致无限重建。正确的做法是在initState或者Provider的build方法里做初始化用ref.listen监听状态变化来触发副作用。第三个误区是过度依赖ref.watch。watch会监听变化并重建Widgetread只读取一次不监听。在回调函数里比如按钮点击应该用read而不是watch否则会造成不必要的重建。实操心得Riverpod的ref.listen非常适合做页面间的副作用联动比如登录状态变化后自动跳转、网络断开后弹出提示。但要注意在listen的回调里不要直接修改状态否则可能触发循环。4. 路由与导航从Navigator 1.0到go_router4.1 为什么我最终选择了go_routerFlutter原生的Navigator用起来不难但有几个硬伤路由配置分散在各个页面、深链接处理麻烦、Web端URL不友好、嵌套路由需要手动管理。go_router是官方维护的第三方路由库把这些问题都解决了。核心优势有三个声明式路由配置、类型安全的参数传递、开箱即用的深链接支持。配置方式如下final router GoRouter( initialLocation: /home, routes: [ GoRoute( path: /home, builder: (context, state) const HomePage(), routes: [ GoRoute( path: detail/:id, builder: (context, state) { final id state.pathParameters[id]!; return DetailPage(articleId: id); }, ), ], ), GoRoute( path: /login, builder: (context, state) const LoginPage(), ), ], redirect: (context, state) { final isLoggedIn ref.read(authProvider).isLoggedIn; if (!isLoggedIn state.matchedLocation ! /login) { return /login; } return null; }, );redirect回调是go_router最实用的功能之一可以在这里统一处理登录拦截、权限校验、路由重定向等逻辑不用在每个页面里写判断。4.2 路由参数传递的几种方式go_router支持三种参数传递方式各有适用场景路径参数适合传递资源标识符比如/article/123里的123。通过state.pathParameters获取参数会出现在URL里适合Web端分享链接。查询参数适合传递可选的筛选条件比如/search?keywordflutterpage1。通过state.uri.queryParameters获取同样会出现在URL里。额外参数适合传递复杂对象比如一个完整的用户模型。通过state.extra获取不会出现在URL里但页面刷新后会丢失。所以额外参数只适合传递临时数据需要持久化的数据还是走路径参数或查询参数。// 传递 context.go(/detail/123, extra: articleObject); // 接收 final article state.extra as Article?; final id state.pathParameters[id];4.3 嵌套路由与底部导航的配合底部导航栏是移动端最常见的布局用go_router的StatefulShellRoute可以优雅地实现每个Tab独立维护导航栈StatefulShellRoute.indexedStack( builder: (context, state, navigationShell) { return ScaffoldWithNavBar(navigationShell: navigationShell); }, branches: [ StatefulShellBranch(routes: [ GoRoute(path: /home, builder: (_, __) const HomePage()), ]), StatefulShellBranch(routes: [ GoRoute(path: /discover, builder: (_, __) const DiscoverPage()), ]), StatefulShellBranch(routes: [ GoRoute(path: /profile, builder: (_, __) const ProfilePage()), ]), ], )这样每个Tab有自己的导航历史切换Tab时不会丢失之前的页面栈用户体验和原生应用一致。注意使用StatefulShellRoute时页面内的Navigator.pop只会影响当前分支的栈不会影响其他Tab。如果需要在某个Tab内跳转到全屏页面比如登录页要用parentNavigatorKey指定根导航器。5. 网络层封装Dio加拦截器的正确姿势5.1 为什么选Dio而不是httpFlutter自带的http包够用但不够好用。Dio提供了拦截器、请求取消、超时配置、文件上传下载、FormData等企业级功能而且API设计更符合直觉。在一个真实项目里拦截器几乎是刚需——你需要统一添加Token、统一处理错误码、统一打印日志、统一做重试。基础配置如下final dio Dio(BaseOptions( baseUrl: https://api.example.com, connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 15), headers: {Content-Type: application/json}, ));5.2 拦截器的分层设计我通常把拦截器分成四层按顺序执行第一层请求头拦截器。负责添加公共请求头比如设备信息、版本号、语言设置。dio.interceptors.add(InterceptorsWrapper( onRequest: (options, handler) { options.headers[X-Device-Id] deviceId; options.headers[X-App-Version] appVersion; handler.next(options); }, ));第二层认证拦截器。负责添加Token并在Token过期时自动刷新。dio.interceptors.add(InterceptorsWrapper( onRequest: (options, handler) async { final token await storage.read(access_token); if (token ! null) { options.headers[Authorization] Bearer $token; } handler.next(options); }, onError: (error, handler) async { if (error.response?.statusCode 401) { final refreshed await refreshToken(); if (refreshed) { // 重试原请求 final response await dio.fetch(error.requestOptions); return handler.resolve(response); } } handler.next(error); }, ));第三层日志拦截器。只在Debug模式下启用打印请求和响应内容方便排查问题。第四层错误转换拦截器。把Dio的DioException转换成项目自定义的异常类型上层业务代码不需要关心底层用的是Dio还是别的库。5.3 统一响应模型与错误处理后端返回的数据结构通常是这样的{ code: 0, message: success, data: { ... } }我会定义一个泛型响应模型来统一解析class ApiResponseT { final int code; final String message; final T? data; ApiResponse({required this.code, required this.message, this.data}); bool get isSuccess code 0; factory ApiResponse.fromJson( MapString, dynamic json, T Function(dynamic) fromJsonT, ) { return ApiResponse( code: json[code] as int, message: json[message] as String, data: json[data] ! null ? fromJsonT(json[data]) : null, ); } }然后在Repository层统一处理FutureT requestT(FutureResponse Function() call, T Function(dynamic) parser) async { try { final response await call(); final apiResponse ApiResponse.fromJson(response.data, parser); if (!apiResponse.isSuccess) { throw BusinessException(apiResponse.code, apiResponse.message); } return apiResponse.data as T; } on DioException catch (e) { throw NetworkException.fromDio(e); } }这样上层业务代码只需要try-catch两种异常BusinessException业务错误和NetworkException网络错误处理逻辑清晰很多。实操心得Token刷新的并发问题很容易被忽略。如果同时有多个请求返回401会触发多次刷新请求。解决办法是用一个Completer做锁第一个请求触发刷新后续请求等待刷新完成后再重试。6. 本地存储与数据持久化6.1 三种存储方案的选型Flutter的本地存储方案主要有三种shared_preferences、hive、sqflite。选择依据是数据量和查询需求。shared_preferences适合存少量的键值对比如用户偏好设置、登录Token、引导页标记。底层在Android上是SharedPreferencesiOS上是NSUserDefaults读写速度很快但不适合存大量数据。hive是一个纯Dart实现的NoSQL数据库不需要原生依赖性能很好适合存结构化对象。我通常用它来缓存API返回的列表数据、用户信息等。sqflite是SQLite的Flutter封装适合需要复杂查询、关联查询的场景比如本地通讯录、离线笔记。但它的API比较底层写起来繁琐一般项目用不到。6.2 Hive的实战用法Hive的用法分三步定义模型、注册适配器、打开Box。HiveType(typeId: 0) class Article extends HiveObject { HiveField(0) final String id; HiveField(1) final String title; HiveField(2) final String content; HiveField(3) final DateTime publishedAt; Article({required this.id, required this.title, required this.content, required this.publishedAt}); }跑build_runner生成适配器后在入口初始化await Hive.initFlutter(); Hive.registerAdapter(ArticleAdapter()); await Hive.openBoxArticle(articles);之后就可以像操作Map一样操作Hivefinal box Hive.boxArticle(articles); await box.put(article_123, article); final article box.get(article_123);6.3 缓存策略的设计缓存不是简单地存和取需要考虑过期策略和更新时机。我的做法是给每条缓存数据加一个时间戳读取时判断是否过期class CacheEntryT { final T data; final DateTime cachedAt; final Duration ttl; bool get isExpired DateTime.now().difference(cachedAt) ttl; }缓存策略我通常分三种网络优先先请求网络失败时读缓存、缓存优先先读缓存没有或过期时请求网络、仅缓存只读缓存不请求网络。列表页用缓存优先详情页用网络优先配置类数据用仅缓存。注意Hive的Box在应用关闭后数据依然存在但如果你在开发过程中修改了模型字段需要升级typeId或者清除应用数据否则会报适配器不匹配的错误。这个坑我踩过不止一次。7. 性能优化从启动速度到渲染帧率7.1 启动优化延迟初始化与懒加载Flutter应用的启动时间主要花在三个地方引擎初始化、Dart VM启动、首帧渲染。引擎和VM的启动时间我们控制不了但首帧渲染可以优化。核心思路是延迟初始化非必要组件。比如Hive、推送SDK、统计SDK这些不需要在main函数里同步初始化可以放到首帧渲染之后异步执行void main() { WidgetsFlutterBinding.ensureInitialized(); runApp(const MyApp()); // 首帧渲染后再初始化 WidgetsBinding.instance.addPostFrameCallback((_) async { await initHive(); await initPush(); }); }另一个技巧是用SplashScreen争取时间。在原生层配置一个启动图等Flutter首帧准备好之后再移除用户感知上会快很多。7.2 渲染优化const构造函数与RepaintBoundaryFlutter的渲染性能瓶颈通常来自不必要的重建。两个最有效的优化手段第一尽可能使用const构造函数。const Widget在编译期就确定了不会参与重建。const Text(Hello)比Text(Hello)性能好因为前者在Widget树重建时会被复用。第二用RepaintBoundary隔离频繁重绘的区域。比如一个列表里每个item都有动画给每个item包一层RepaintBoundary这样动画重绘时不会影响其他item。ListView.builder( itemBuilder: (context, index) { return RepaintBoundary( child: AnimatedItem(data: items[index]), ); }, )7.3 列表性能ListView.builder的正确用法长列表一定要用ListView.builder而不是ListView因为前者是懒加载的只构建可见区域的item。但有几个细节容易忽略itemExtent如果固定一定要设置。这样ListView不需要动态计算每个item的高度滚动性能会好很多。cacheExtent控制预加载的区域大小默认是250像素。如果item高度较大可以适当增大减少滚动时的白屏。addAutomaticKeepAlives默认是true会保持item的状态。如果item不需要保持状态比如纯展示的文本设为false可以节省内存。ListView.builder( itemCount: items.length, itemExtent: 80, cacheExtent: 500, addAutomaticKeepAlives: false, itemBuilder: (context, index) ItemWidget(item: items[index]), )7.4 图片加载与内存管理图片是内存占用的大头。几个关键点使用cached_network_image做网络图片缓存避免重复下载。设置合理的memCacheWidth和memCacheHeight让图片在内存中按显示尺寸解码而不是按原始尺寸。一张4000x3000的图片如果按原始尺寸解码占用内存是48MB按400x300解码只需要0.48MB差距是100倍。CachedNetworkImage( imageUrl: url, memCacheWidth: (400 * MediaQuery.of(context).devicePixelRatio).round(), memCacheHeight: (300 * MediaQuery.of(context).devicePixelRatio).round(), fit: BoxFit.cover, placeholder: (_, __) const ShimmerPlaceholder(), errorWidget: (_, __, ___) const ErrorImage(), )实操心得用DevTools的Memory面板可以实时查看内存占用。如果发现内存持续增长不回落大概率是图片缓存没有设置上限或者有Widget泄漏比如在initState里注册了监听但忘记在dispose里取消。8. 打包与发布从Debug到Release的最后一公里8.1 Android打包配置要点Android打包的核心是build.gradle配置和签名文件。几个容易出问题的地方签名配置。不要把签名文件提交到版本控制用key.properties文件管理密码并在.gitignore里排除def keystoreProperties new Properties() def keystorePropertiesFile rootProject.file(key.properties) if (keystorePropertiesFile.exists()) { keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) } android { signingConfigs { release { keyAlias keystoreProperties[keyAlias] keyPassword keystoreProperties[keyPassword] storeFile file(keystoreProperties[storeFile]) storePassword keystoreProperties[storePassword] } } buildTypes { release { signingConfig signingConfigs.release minifyEnabled true shrinkResources true } } }混淆配置。minifyEnabled true会开启代码混淆但Flutter的反射调用可能被误删需要在proguard-rules.pro里加保留规则-keep class io.flutter.** { *; } -keep class com.example.app.** { *; }ABI拆分。如果应用体积较大可以用--split-per-abi分别打出arm64和armeabi-v7a的包每个包体积能减少30%到40%。8.2 iOS打包注意事项iOS打包的坑主要集中在证书和描述文件上。用Xcode的自动签名管理大部分情况够用但团队协作时建议用手动签名避免证书冲突。Info.plist里需要配置权限描述否则审核会被拒。相机、相册、定位、通知这些权限都要写清楚用途说明而且描述要具体不能写“需要此权限以提供更好服务”这种模糊表述。Podfile里的平台版本要和Xcode项目设置一致否则pod install会报错。Flutter项目默认是iOS 12.0如果用了需要更高版本的三方库记得同步修改。8.3 包体积优化Flutter应用的包体积主要来自三部分Flutter引擎、Dart代码、资源文件。引擎部分优化空间不大但代码和资源可以压缩。移除未使用的资源。用flutter pub run flutter_launcher_icons和flutter pub run flutter_native_splash生成图标和启动图避免手动放置多套尺寸的图片。按需引入字体。中文字体文件动辄十几MB如果只需要几个字重用fonttools裁剪字体文件只保留用到的字符集。开启代码压缩。Release模式下Flutter默认会做Tree Shaking移除未使用的代码。但要注意通过反射调用的代码不会被自动移除需要在main.dart里用pragma(vm:entry-point)标注。# 查看包体积构成 flutter build apk --analyze-size这个命令会生成一个JSON报告清楚列出每个部分的体积占比方便定位优化点。注意--analyze-size需要配合--target-platform使用而且生成的报告要在DevTools里查看。我第一次用的时候找了半天报告文件在哪后来才发现是在命令行输出里有个链接。9. 常见问题与排查技巧实录9.1 编译与依赖问题问题一build_runner卡住不动。通常是代码生成器之间有循环依赖或者某个生成器版本不兼容。解决办法是先flutter clean删除.dart_tool目录然后flutter pub get重新拉依赖再跑build_runner。如果还不行用--verbose参数看详细日志。问题二iOS上pod install报错。九成是CocoaPods版本太旧或者仓库索引过期。先pod repo update更新索引再pod install。如果报的是某个三方库的Swift版本不兼容在Podfile的post_install里统一设置Swift版本post_install do |installer| installer.pods_project.targets.each do |target| target.build_configurations.each do |config| config.build_settings[SWIFT_VERSION] 5.0 end end end问题三Android编译报Duplicate class错误。通常是两个三方库引用了同一个类的不同版本。用./gradlew app:dependencies查看依赖树找到冲突的库用exclude排除掉旧版本。9.2 运行时问题问题一setState() called after dispose()。这是异步操作完成后调用了已经销毁的Widget的setState。解决办法是在setState前判断mountedif (mounted) { setState(() { ... }); }或者在dispose里取消所有异步操作。问题二键盘弹出导致布局溢出。在Scaffold里设置resizeToAvoidBottomInset: true默认就是true然后给内容区域包一层SingleChildScrollView。如果还是溢出检查是否有固定高度的容器没有适配键盘高度。问题三页面返回后数据没刷新。用Navigator.pop返回时传参或者用RouteObserver监听页面返回事件。go_router的话可以用refreshListenable配合ChangeNotifier实现自动刷新。9.3 性能问题排查速查表现象可能原因排查工具解决方案滚动卡顿列表item构建耗时过长DevTools Performance用const、简化item布局、设置itemExtent内存持续增长图片缓存无上限或Widget泄漏DevTools Memory设置图片缓存上限、检查dispose启动白屏时间长初始化操作阻塞首帧DevTools Timeline延迟初始化、用SplashScreen动画掉帧重绘区域过大DevTools Performance用RepaintBoundary隔离网络请求慢没有连接复用或DNS解析慢网络抓包工具用Dio的连接池、配置DNS缓存实操心得DevTools的Performance面板里红色帧表示耗时超过16毫秒即掉帧黄色帧表示接近阈值。排查时先看红色帧对应的操作用火焰图定位到具体的Widget构建或布局计算然后针对性优化。10. 一些零散但重要的经验10.1 代码规范与团队协作analysis_options.yaml一定要配置把团队约定的规则写进去。比如强制使用const构造函数、禁止使用print、要求公开API写文档注释。配合CI流程每次提交自动跑flutter analyze不合规的代码直接打回。Git提交规范用commitlint加husky做钩子提交信息格式统一为type(scope): subject方便生成变更日志。10.2 测试策略单元测试覆盖业务逻辑层和数据层Widget测试覆盖关键交互流程集成测试覆盖核心用户路径。不要追求100%覆盖率把精力放在容易出错的边界条件上。Mock数据用mockito或者mocktail配合build_runner生成Mock类。Riverpod的Provider在测试时可以用ProviderContainer加overrides替换成Mock实现。10.3 持续集成与自动化发布用CI工具配置自动化流程代码提交后自动跑测试、自动打包、自动上传到分发平台。Android用fastlane的supply上传到应用市场iOS用deliver上传到App Store Connect。版本号管理用pubspec.yaml里的version字段配合CI的构建号自动递增。每次发布打一个Git tag方便回溯。10.4 我个人的一些习惯新项目初始化时我会先建一个dev分支把所有基础设施网络层、路由、状态管理、主题搭好写一个简单的示例页面跑通全流程然后再合并到main分支开始业务开发。这样做的目的是尽早发现架构层面的问题避免写到一半才发现某个方案行不通。每次遇到一个bug解决之后我会在项目里建一个docs/troubleshooting.md把问题现象、排查过程、解决方案记下来。下次遇到类似问题直接查文档不用重新排查。这个习惯坚持了两年文档已经攒了几十条团队新人的上手速度明显快了很多。还有一点Flutter的版本升级不要盲目追新。每次升级前先看Release Notes里的Breaking Changes在dev分支上升级并跑完所有测试确认没问题再合并。我吃过一次亏在main分支直接升级结果一个三方库不兼容回滚折腾了半天。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑