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还是Bloc
3.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 Future<List<Article>> build() async { final repository = ref.watch(articleRepositoryProvider); return repository.fetchArticles(); } Future<void> refresh() async { state = const AsyncLoading(); state = await AsyncValue.guard(() async { final repository = ref.read(articleRepositoryProvider); return repository.fetchArticles(); }); } }跑一次dart run build_runner build,Riverpod会自动生成对应的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会监听变化并重建Widget,read只读取一次不监听。在回调函数里(比如按钮点击)应该用read而不是watch,否则会造成不必要的重建。
实操心得:Riverpod的
ref.listen非常适合做页面间的副作用联动,比如登录状态变化后自动跳转、网络断开后弹出提示。但要注意在listen的回调里不要直接修改状态,否则可能触发循环。
4. 路由与导航:从Navigator 1.0到go_router
4.1 为什么我最终选择了go_router
Flutter原生的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?keyword=flutter&page=1。通过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而不是http
Flutter自带的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 ApiResponse<T> { 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( Map<String, 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层统一处理:
Future<T> request<T>(Future<Response> 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上是SharedPreferences,iOS上是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.openBox<Article>('articles');之后就可以像操作Map一样操作Hive:
final box = Hive.box<Article>('articles'); await box.put('article_123', article); final article = box.get('article_123');6.3 缓存策略的设计
缓存不是简单地存和取,需要考虑过期策略和更新时机。我的做法是给每条缓存数据加一个时间戳,读取时判断是否过期:
class CacheEntry<T> { 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构造函数与RepaintBoundary
Flutter的渲染性能瓶颈通常来自不必要的重建。两个最有效的优化手段:
第一,尽可能使用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前判断mounted:
if (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分支直接升级,结果一个三方库不兼容,回滚折腾了半天。