☰
Flutter完整项目结构:目录分层、多环境与构建发布
2026/10/27 3:35:53 网站建设 项目流程

1. 先弄清楚“完整”这个词的分量

接手一个跑了大半年的 Flutter 项目,最容易出问题的时刻往往不是改需求,而是 clone 下来跑不起来。flutter pub get报一堆版本冲突,main.dart里塞了三百行初始化逻辑,图片资源路径写死在十几个 widget 里,android/目录里躺着三个版本的签名配置。这时候你会发现,一个完整的 Flutter 项目的基本构成,远不止flutter create吐出来的那个骨架。

我个人的判断标准很简单:一个“完整”的 Flutter 项目,应该满足四个条件。第一,新人在只看 README 的情况下,半小时内能把项目跑起来并且能连上测试环境;第二,任何一个业务改动,你能在三十秒内定位到它该改哪个目录、哪一层;第三,打包发版这件事不依赖某一个人的电脑;第四,删掉任何一个 feature 目录,其余代码还能编译通过。

这四条听起来朴素,但真正能做到的项目不多。大部分项目的演化路径是这样的:一开始图快,所有页面塞在lib/根目录,网络请求直接在initState里写,setState一把梭;等到业务量上来,代码变成一锅粥,改一个需求要翻七八个文件,还不敢删任何东西,因为不知道谁在引用谁。

这篇文章想聊的就是怎么避开这条路。内容会覆盖目录分层、pubspec.yaml的每个字段、多环境配置、运行时四大主干(路由/状态/网络/存储)、资源与主题、测试与静态分析、以及我踩过的那些坑。适合刚接触 Flutter 不久、准备起一个新项目的人,也适合接手遗留项目想动手重构的人。不涉及具体业务,全是结构层面的东西。

2. 目录与分层:骨架决定了三年的可维护性

2.1 从 flutter create 的默认产物逐个数

先把flutter create my_app生成的东西摊开看一遍,很多人用了很久也没仔细看过这些文件各自干什么。我把它们分成三类:必须提交的、自动生成不用管的、以及需要你手工改的。

路径是否提交作用与说明
lib/是业务代码主目录,唯一的源码入口
test/是单元测试与 widget 测试
integration_test/是端到端测试(需要额外加依赖)
pubspec.yaml是依赖清单与资源配置,项目的“总开关”
pubspec.lock是锁定版本,应用型项目必须提交
analysis_options.yaml是静态分析规则
.metadata是记录创建时的渠道与迁移信息,别删
.gitignore是需要按项目补充规则
android/ios/是平台宿主工程,需要改签名、权限、图标
web/macos/linux/windows/视情况不做的平台可以直接删,减少构建干扰
build/否构建产物
.dart_tool/否依赖解析缓存与包配置,删了重新pub get即可
.idea/*.iml否IDE 配置,团队里各人不同

这里有个经常被忽略的点:pubspec.lock到底该不该提交。答案取决于项目类型。应用型项目(要发布到应用商店的)必须提交,因为你要保证测试环境和线上环境跑的是完全一致的依赖树。纯库项目(给别人当依赖用的)不提交,让下游自己解析。我见过有团队把 lock 文件加进 gitignore,结果 CI 上跑出来的产物和本地行为不一致,查了两天才发现是某个间接依赖的小版本差异。

另一个点是平台目录的清理。默认flutter create会把六个平台全生成出来。如果你只做移动端,macos/、linux/、windows/留着不会报错,但会让 IDE 的索引变慢,也会让flutter build的可用目标列表变得很长。直接删掉,需要的时候flutter create --platforms=windows .就能补回来,这个命令只补平台目录,不会覆盖lib/。

2.2 lib 内部怎么分层才不至于半年后失控

lib/里的组织方式,是整个项目构成里最需要提前想清楚的部分。常见的有三种路子,我逐个说优缺点。

第一种是按类型分,pages/、widgets/、models/、services/、utils/。这是绝大多数人起步的写法,因为直观。问题是当项目有三十个页面之后,pages/目录下会平铺三十个文件,widgets/里堆着上百个组件,你根本分不清哪个组件属于哪个业务。改一个功能要横跨四个目录,脑内上下文切换成本极高。

第二种是三层架构,presentation/、domain/、data/。这是 Android 圈流传过来的写法,好处是职责极其清晰,domain层纯 Dart、不依赖 Flutter,单元测试跑得飞快。缺点是样板代码多,一个简单的列表页要写 entity、model、repository 接口、repository 实现、usecase、controller、page、widget,八个文件。小团队做小项目,这套东西容易把人累死。

第三种是 feature-first,按业务模块划分,每个模块内部再分层。这是我目前最推荐的,也是社区里越来越主流的做法。结构大致长这样:

lib/ ├── main.dart ├── bootstrap.dart ├── app.dart ├── core/ │ ├── constants/ │ ├── errors/ │ ├── network/ │ ├── storage/ │ ├── router/ │ ├── theme/ │ └── utils/ ├── features/ │ ├── auth/ │ │ ├── data/ │ │ │ ├── datasources/ │ │ │ ├── models/ │ │ │ └── repositories/ │ │ ├── domain/ │ │ │ ├── entities/ │ │ │ ├── repositories/ │ │ │ └── usecases/ │ │ └── presentation/ │ │ ├── pages/ │ │ ├── widgets/ │ │ └── controllers/ │ └── order/ │ └── ...(同上) ├── shared/ │ ├── widgets/ │ └── extensions/ └── l10n/

feature-first 的核心价值在于“可删除性”。一个业务下线,你直接删掉整个features/xxx/目录,编译一下就知道有没有残留引用,全在编译错误里列出来了。按类型分的结构做不到这一点,你得靠全局搜索一个个确认。

core和shared的区别也值得说一下。core放的是“基础设施”,网络客户端、存储封装、路由表、主题、错误类型,这些东西跟具体业务无关,但整个 App 都要用。shared放的是“通用 UI 组件和扩展方法”,比如封装好的空状态页、加载骨架屏、BuildContext的扩展。分清楚这两个,好处是当你看到core里的代码引用了features里的东西时,立刻就能意识到这是架构违规。

2.3 依赖方向这条线,比目录名字重要得多

目录只是形式,真正约束架构的是依赖方向。三层架构里那条铁律是:presentation依赖domain,data依赖domain,domain谁也不依赖。也就是说domain层定义接口(抽象类),data层去实现它,presentation层只认接口不认实现。

这个约束带来的实际好处,我在一个项目里体会特别深。当时后端接口从 REST 换成了 GraphQL,改动范围理论上应该只在data层。但因为之前有人图省事,在页面里直接import了 Dio 发请求,结果一搜发现十七个页面要改。如果一开始就守住依赖方向,页面只调OrderRepository这个抽象接口,换实现只需要改依赖注入的那一行。

domain层不依赖 Flutter 这点也很有价值。不是说不能用dart:core,而是不要import 'package:flutter/material.dart'。因为一旦domain里出现了BuildContext或者Color,你的业务逻辑就绑死在 UI 框架上了,写单元测试还得起一个 widget 环境,测试从毫秒级变成秒级,跑几百个用例就是几分钟的差距。

提示:不必一上来就把三层全建全。我的做法是先建features/xxx/presentation/,把页面写出来,等这个模块确实需要复用或者需要写复杂测试的时候,再抽domain和data。过度设计比不设计更消耗团队耐心。

3. 配置文件:pubspec.yaml 里每个字段都不是摆设

3.1 逐字段拆解,以及那几个必须改的

pubspec.yaml是项目的总开关,但很多人只改dependencies那一段。我按顺序过一遍。

name: my_app # 包名,只能小写下划线,导入时用 package:my_app/xxx description: 一句话说明 publish_to: 'none' # 关键:应用型项目必须加,防止误发布到 pub.dev version: 1.2.3+45 # 前面是版本名,加号后面是构建号,商店用它判断升级 environment: sdk: '>=3.3.0 <4.0.0' flutter: '>=3.19.0'

version那个加号很多人不当回事。1.2.3+45里,1.2.3是给用户看的版本名,45是内部构建号。应用商店(尤其是 iOS)判断是否为“新版本”看的是构建号,如果你只改版本名不改构建号,上传会被拒。我建议在 CI 里用命令行动态注入:flutter build apk --build-name=1.2.3 --build-number=$CI_PIPELINE_ID,这样永远不会有重复。

environment里的sdk约束也别随手写any。写了any,某天有人在 Dart 2.x 的机器上跑,编译器会给你一堆莫名其妙的错误。写具体范围,让pub get在版本不满足时直接报错,比事后排查省事得多。

flutter:那个小节是资源配置,最容易踩坑:

flutter: uses-material-design: true assets: - assets/images/ - assets/icons/ - assets/config/env.json fonts: - family: AppSans fonts: - asset: assets/fonts/AppSans-Regular.ttf - asset: assets/fonts/AppSans-Medium.ttf weight: 500 - asset: assets/fonts/AppSans-Bold.ttf weight: 700

这里有两个必须记住的细节。第一,目录声明后面必须带斜杠,assets/images/有效,assets/images无效,而且前者只包含该目录下的直接文件,不递归子目录。第二,family的名字是你在TextStyle(fontFamily: 'AppSans')里用的字符串,写错一个大小写就是默认字体,而且不会报错,只是默默不生效。

3.2 多环境:别再手改 baseUrl 了

一个真实项目至少有开发、测试、生产三套环境。我见过最原始的方案是提交前手动改常量文件,出过好几次把测试地址发到线上的事故。

比较稳妥的做法是用--dart-define配合编译期常量。先定义一个环境配置类:

class EnvConfig { static const String apiBaseUrl = String.fromEnvironment( 'API_BASE_URL', defaultValue: 'https://dev-api.example.com', ); static const String envName = String.fromEnvironment('ENV', defaultValue: 'dev'); static bool get isProd => envName == 'prod'; }

然后构建时注入:

flutter build apk --release \ --dart-define=ENV=prod \ --dart-define=API_BASE_URL=https://api.example.com

String.fromEnvironment是编译期常量,能被 tree shaking 正确优化,也比运行时读配置文件安全——因为参数没进去的话,编译出来就是一个错误的默认值,很容易在测试阶段暴露,而不是上线后才炸。

配合 flavor 可以做得更彻底。Android 侧在build.gradle.kts里配productFlavors,每个 flavor 设置不同的applicationIdSuffix和应用名,这样开发包和正式包能同时装在一台手机上,图标下面还能显示不同的名字。iOS 侧需要在 Xcode 里建对应的 scheme 和 configuration,稍微麻烦一点,但一次配好长期受益。

android { flavorDimensions += "env" productFlavors { create("dev") { dimension = "env" applicationIdSuffix = ".dev" resValue("string", "app_name", "MyApp Dev") } create("prod") { dimension = "env" resValue("string", "app_name", "MyApp") } } }

注意:resValue生成的应用名会覆盖AndroidManifest.xml里的android:label,如果你两边都配了,以 Gradle 为准。我第一次用的时候在 manifest 里改了名字没生效,折腾了半小时才发现是被 flavor 覆盖了。

3.3 平台目录里真正该动的地方

android/和ios/这两个目录,日常开发基本不用碰,但有几处必须改,而且改错了很难查。

Android 侧重点看三个文件。android/app/build.gradle.kts里的namespace、applicationId、minSdk、targetSdk、compileSdk,还有签名配置。namespace是 Kotlin 包名,applicationId才是设备上的唯一标识,两者可以不同,改applicationId会导致用户升级时被当成新应用。minSdk决定了你能用哪些系统 API,也决定了部分插件的兼容性,选之前先查一遍主力依赖的要求。签名配置建议从key.properties读,把这个文件加进.gitignore,CI 上用环境变量注入,别把 keystore 和明文密码提交上去。

AndroidManifest.xml里要关注权限声明、<queries>(Android 11 之后查外部应用需要)、以及android:usesCleartextTraffic。开发环境用 http 需要开这个,生产环境务必关掉,否则应用商店审核会挑毛病。

iOS 侧主要看Info.plist和签名。相机、相册、定位这些权限的说明文案必须写,不写会直接闪退,而且文案要具体,写“需要相机权限”这种含糊描述有被审核打回的风险。CFBundleDisplayName是桌面显示名,CFBundleShortVersionString和CFBundleVersion对应版本名和构建号,用--build-name传参时会自动同步,不用手动改。

还有两个容易被忘的:应用图标和启动页。手写各尺寸图标是体力活,用flutter_launcher_icons这个工具,配置好一张 1024x1024 的源图,一条命令生成全套。启动页用flutter_native_splash,能生成 Android 12 要求的 splash 格式。

4. 运行时四大主干:路由、状态、网络、存储

4.1 路由:从 Navigator 到声明式,取决于你有没有深链接需求

早期项目基本都是Navigator.push(context, MaterialPageRoute(...))。简单直接,适合页面少于十个的小应用。问题是路由信息散落在各个页面里,想统一加个登录拦截、想做页面埋点统计、想支持深链接,都得一个个改。

go_router是我目前的主力选择。核心是把所有路由集中成一张表:

final router = GoRouter( initialLocation: '/home', refreshListenable: authStateNotifier, redirect: (context, state) { final loggedIn = authStateNotifier.value.isLoggedIn; final goingToLogin = state.matchedLocation == '/login'; if (!loggedIn && !goingToLogin) return '/login'; if (loggedIn && goingToLogin) return '/home'; return null; }, routes: [ GoRoute(path: '/login', builder: (_, __) => const LoginPage()), GoRoute( path: '/order/:id', name: 'orderDetail', builder: (_, state) => OrderDetailPage(id: state.pathParameters['id']!), routes: [ GoRoute(path: 'refund', builder: (_, __) => const RefundPage()), ], ), ], );

redirect这个回调是精髓。登录校验、强制更新、维护公告这些全局逻辑,在这里集中处理一次,所有页面自动生效。我之前做过的项目是在每个页面的initState里判断登录状态,结果是新增页面时经常忘,测试同学总能找到漏网之鱼。

深链接也靠路由表。/order/123这个路径天然对应一个 URL,配置好 Android 的 intent-filter 和 iOS 的 associated domains,用户点推送通知能直达详情页。如果用传统的 push 方式,你得自己在通知处理逻辑里判断该 push 哪个页面,那不是路由,那是硬编码。

4.2 状态管理:没有银弹,只有匹配度

这块的争论能吵三天三夜。我的观点是:看团队规模和状态复杂度,别看博客排行。

setState在单个页面内部完全够用。一个页面内几个 bool 控制显隐,用setState最直接,引入任何框架都是负担。ValueNotifier配合ValueListenableBuilder是轻量升级版,适合把某个值共享给两三个组件,还不用引第三方包。

Provider是入门门槛最低的共享方案,语法接近原生InheritedWidget。缺点是需要手动管理notifyListeners,状态多了容易漏调,也容易在一处notify触发大量无关重建。

Riverpod是我现在默认的选择。核心优势是编译期安全——不用BuildContext,拿不到 provider 是编译错误而不是运行时异常;autoDispose让生命周期管理自动化,页面销毁时状态自动释放;还有AsyncValue这个类型,把加载中、成功、失败三种状态收进一个密封类型,配合when方法强制你处理全部分支,彻底消灭“忘记写错误状态”这个经典 bug。

final orderListProvider = FutureProvider.autoDispose<List<Order>>((ref) async { final repo = ref.watch(orderRepositoryProvider); return repo.fetchOrders(); }); // 页面里 final asyncOrders = ref.watch(orderListProvider); return asyncOrders.when( loading: () => const OrderSkeleton(), error: (e, st) => ErrorView(message: e.toString(), onRetry: () => ref.invalidate(orderListProvider)), data: (orders) => OrderList(orders: orders), );

Bloc适合业务逻辑复杂、事件流清晰的场景,尤其是需要严格追溯“什么事件导致了什么状态变化”的团队。代价是样板代码多,一个功能要写 event、state、bloc 三个文件。如果是三五个人的小团队做一个中等复杂度的 App,我倾向于 Riverpod 或者干脆 Provider,Bloc的成本回收周期太长了。

提示:无论选哪个,别混用。我见过一个项目里setState、Provider、GetX三套并存,原因是三个开发各写各的模块。后期任何一个跨模块的状态问题都变成三方会诊。

4.3 网络层:封装的价值在于统一处理横切关注点

直接在每个页面里Dio().get(...)是最常见的起步写法,也是最容易出事的写法。一旦需要加 token、加日志、加统一错误提示、加重试,你就得改几十处。

标准做法是封装一个ApiClient,把 Dio 藏在里面,业务层只依赖抽象接口。拦截器负责横切逻辑:

class AuthInterceptor extends Interceptor { final TokenStore store; AuthInterceptor(this.store); @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) async { final token = await store.read(); if (token != null) options.headers['Authorization'] = 'Bearer $token'; handler.next(options); } @override void onError(DioException err, ErrorInterceptorHandler handler) async { if (err.response?.statusCode == 401) { final refreshed = await store.tryRefresh(); if (refreshed) return handler.resolve(await _retry(err.requestOptions)); } handler.next(err); } }

这里有个设计决策值得展开:错误要映射成自己的类型,不要让DioException泄漏到 UI 层。定义一个AppException的密封类,分成网络异常、业务异常、认证异常、未知异常几种,UI 层用switch处理,每种给不同的提示和操作按钮。好处是将来换 HTTP 客户端(Dio 换 http 换 retrofit),UI 层一行不用改。

JSON 序列化建议用json_serializable生成,别手写fromJson。手写的问题是一个字段名拼错、一个可空性判断漏掉,在数据缺字段时就是运行时崩溃,而且崩溃位置离出错位置很远。用生成器,字段类型不匹配在编译期就报错。配置build_runner的时候记得把生成的.g.dart提交上去,CI 上就不用跑生成步骤,能省两分钟。

4.4 本地存储:按数据形态选,别一把梭 shared_preferences

存储选型的核心问题是“存什么形态的数据、有多大、要不要加密”。我按使用频率排一下。

shared_preferences适合少量键值对,比如“是否首次启动”“上次选中的 Tab”“用户 ID”。底层是 Android 的 SharedPreferences 和 iOS 的 NSUserDefaults,同步读、速度快,但不适合存大块 JSON,也不加密。

敏感数据(token、 refresh token、设备指纹)必须用flutter_secure_storage,底层走 Keychain 和 Keystore。我见过把 token 存 shared_preferences 的项目,导出应用数据就能看到明文,这是实打实的安全问题。

结构化数据用drift(SQLite 的现代封装)或者sqflite。有几个实体、需要关联查询、数据量大,就该上数据库。drift的好处是类型安全、支持响应式查询(数据变了 UI 自动刷新),代价是要学它那套 DSL。

对象缓存用isar或者hive。适合存用户信息、商品列表这类整体读写的对象,比数据库轻,比 JSON 字符串快。isar性能很好但二进制体积不小,小项目要权衡一下。

path_provider是基础工具,拿应用沙盒路径用的。下载文件、导出日志、缓存图片都靠它。

还有一个常被忽略的点:缓存的失效策略。我踩过的坑是把首页数据缓存了,但没设过期时间,用户换了账号还能看到上一个账号的数据。后来统一加了cachedAt时间戳和“账号变化时清空缓存”的逻辑,才算彻底解决。

5. 资源、主题与国际化:那些“最后再补”往往补不上

5.1 资源管理:路径硬编码是技术债的起点

直接在代码里写Image.asset('assets/images/avatar_placeholder.png'),看起来没问题,实际上埋了三颗雷。一是路径拼错只在运行时才发现;二是某天要换图,得全局搜索替换;三是没人敢删某张图,因为不知道还有没有引用。

解法是用代码生成资源常量。flutter_gen这个包能扫描pubspec.yaml里声明过的资源,自动生成Assets.images.avatarPlaceholder这样的静态字段。好处是编译期检查、IDE 自动补全、改名时能全局重命名。

图片适配也要提前规划。Flutter 支持2.0x/、3.0x/这样按屏幕密度分目录,声明时只写基础目录,系统自动按设备选择。如果不做适配,一张 200KB 的图在低端机上下载和解码都吃亏。我的经验是图标类资源做两档,照片类做三档,别全做,包体积会失控。

字体文件还有个实际问题:中文字体动辄十几 MB。如果只是想让数字用个特殊字体,别把整个字体文件塞进去。可以用fonttools做子集化,只保留用到的字符集,能压到几百 KB。

5.2 主题与设计 token:让“改个主色”变成一行事

颜色写死是另一个经典问题。Color(0xFF2196F3)在三十个文件里出现,产品说主色要换,你就得干一下午。

Flutter 3 之后的标准做法是用ColorScheme.fromSeed生成一整套配色:

final lightTheme = ThemeData( useMaterial3: true, colorScheme: ColorScheme.fromSeed( seedColor: const Color(0xFF1E6FD9), brightness: Brightness.light, ), textTheme: const TextTheme( titleLarge: TextStyle(fontFamily: 'AppSans', fontWeight: FontWeight.w700), ), );

但 Material 的语义色只有那么几个,业务上总会有“成功绿”“警告橙”“分割线灰”这类自定义 token。用ThemeExtension把它们挂到主题上,就能享受和内置属性同等的访问方式:

class AppColors extends ThemeExtension<AppColors> { final Color success; final Color warning; final Color divider; const AppColors({required this.success, required this.warning, required this.divider}); @override AppColors copyWith({Color? success, Color? warning, Color? divider}) => AppColors( success: success ?? this.success, warning: warning ?? this.warning, divider: divider ?? this.divider, ); @override AppColors lerp(ThemeExtension<AppColors>? other, double t) { if (other is! AppColors) return this; return AppColors( success: Color.lerp(success, other.success, t)!, warning: Color.lerp(warning, other.warning, t)!, divider: Color.lerp(divider, other.divider, t)!, ); } }

lerp那个方法别偷懒返回this,否则主题切换的过渡动画在自定义颜色上会硬切,视觉上很跳。这个坑我调了半天才反应过来。

5.3 国际化:早做比晚做省一百倍力气

如果项目有出海打算,第一天就把国际化框架搭上。等界面写了两百个页面再回头抽文案,工作量是灾难级的。

现在的标准流程是配置l10n.yaml,把文案写成 ARB 文件,用flutter gen-l10n生成访问类:

arb-dir: lib/l10n template-arb-file: app_en.arb output-localization-file: app_localizations.dart

app_en.arb长这样:

{ "@@locale": "en", "orderCount": "{count, plural, =0{No orders} =1{1 order} other{{count} orders}}", "@orderCount": { "description": "Number of orders shown on the list header", "placeholders": { "count": { "type": "int" } } } }

复数处理交给 ICU 语法,别自己写count == 1 ? 'order' : 'orders'。中文没有复数变化,但其他语言有,而且不止两三种形式,阿拉伯语有六种。这类逻辑自己写一定会漏。

日期和数字格式化用intl的DateFormat和NumberFormat,指定locale之后自动适配。有个细节:MaterialApp上要配localizationsDelegates和supportedLocales,否则日期选择器这类内置组件会一直是英文。

6. 质量保障:测试、静态分析与构建流水线

6.1 测试目录:重点不是覆盖率数字,是测试金字塔的比例

test/目录默认只有一个widget_test.dart,很多人删了就再也没写过。实际上测试是项目结构的一部分,它决定了你敢不敢重构。

我的分层比例大概是这样的:单元测试占七成,widget 测试占两成半,集成测试占半成。单元测试跑的是domain层的用例和工具函数,纯 Dart,几百个用例几秒钟跑完。widget 测试验证关键组件在特定输入下渲染成什么样,比如“错误状态时是否显示重试按钮”。集成测试只覆盖最核心的一两条主流程,比如登录到下单,跑得慢但信心最强。

testWidgets('订单为空时显示空状态', (tester) async { await tester.pumpWidget( ProviderScope( overrides: [orderListProvider.overrideWith((ref) async => [])], child: const MaterialApp(home: OrderListPage()), ), ); await tester.pumpAndSettle(); expect(find.text('暂无订单'), findsOneWidget); });

这里的关键是overrides。用 Riverpod 或者 Provider 的时候,测试里可以覆盖依赖,把网络请求换成假数据,不用起 mock server。这是我坚持用依赖注入的核心理由之一——不是为了让代码看起来“架构良好”,是为了测试能跑起来。

还有一个实用技巧是 golden test,把组件渲染结果存成图片,下次跑的时候对比像素差异。适合 UI 稳定的基础组件库,防止有人改公共组件时不小心影响别处。但要注意 golden 文件跟平台和字体渲染强相关,CI 环境和本地环境差异会导致大面积失败,建议只在本机跑或者用 Docker 固定环境。

6.2 analysis_options:让机器管住低级错误

flutter_lints是官方推荐的默认规则集,analysis_options.yaml里一行include: package:flutter_lints/flutter.yaml就生效了。但光有默认规则不够,我一般会额外加几条:

include: package:flutter_lints/flutter.yaml analyzer: errors: invalid_annotation_target: ignore # json_serializable 相关的误报 exclude: - "**/*.g.dart" - "**/*.freezed.dart" linter: rules: avoid_print: true # 禁止 print,必须用 logger prefer_const_constructors: true prefer_const_literals_to_create_immutables: true use_build_context_synchronously: true # 异步后用 context 必须判 mounted unawaited_futures: true always_declare_return_types: true

use_build_context_synchronously这条尤其值得开。它会在你await之后直接用context时报错。原因是异步返回时页面可能已经被销毁,这时用 context 会抛异常。正确写法是先if (!mounted) return;。这个 bug 在测试环境很难复现,线上会真实发生,因为用户真的会在加载时按返回键。

avoid_print也要开。print在 release 模式下不会输出,但代码里留着就是隐患,尤其是误把 token 打出来的情况。统一换成logger包,能分级、能格式化、能关闭。

6.3 构建与发布:把不可复现的步骤全部脚本化

flutter build apk谁都会敲,但完整的发布流程包含太多细碎步骤:改版本号、打标签、混淆、上传符号表、生成变更日志。这些全靠手工,迟早出事。

混淆这块必须做。--obfuscate --split-debug-info=build/symbols这组参数能把 Dart 代码混淆,同时把符号表单独输出。混淆后的崩溃堆栈全是乱码,必须用符号表还原。所以关键点是:符号表要存档,每次发布的符号表对应一个构建号,出问题时按版本号取对应的文件来还原。我见过没存符号表的团队,线上崩溃完全无从查起。

flutter build appbundle --release \ --flavor prod \ --dart-define=ENV=prod \ --obfuscate \ --split-debug-info=build/symbols/prod

包体积也值得定期看一眼。flutter build apk --analyze-size会生成一份详细的体积分析,告诉你哪个依赖占了多少。我遇到过某个分析 SDK 占了 4MB 的情况,换成轻量版之后安装包小了一圈,低端机上的启动速度也有明显改善。

7. 常见问题与排查技巧实录

7.1 依赖地狱:版本冲突怎么破

Flutter 的依赖冲突比想象中常见,尤其是间接依赖。典型报错是version solving failed,说 A 要求http ^0.13.0,B 要求http ^1.0.0。

排查顺序我总结成三步。第一步flutter pub deps --style=compact,看清楚是谁引入了冲突的那个包。第二步,如果冲突在间接依赖,先尝试flutter pub upgrade --major-versions,让能升的都升上去,很多时候冲突就自动解开了。第三步,如果确实是两个直接依赖互斥,才考虑dependency_overrides:

dependency_overrides: http: ^1.2.0

dependency_overrides是最后手段。它强制覆盖版本,可能让某个包在不兼容的 API 上运行,问题会以运行时异常的形式出现,更难查。用了之后必须在 README 里写清楚为什么用、什么时候能删。

还有一种情况是版本锁死不动。pubspec.lock里的版本如果一直不更新,会错过安全补丁。我一般每季度跑一次flutter pub outdated,看看哪些包落后了,评估一下升级成本。

7.2 那些看起来像玄学的问题

有一类问题特别消耗时间,因为它们不报错,或者报错信息指向完全无关的地方。我整理几个高频的。

改了资源文件不生效。九成是路径写错或者pubspec.yaml缩进不对。检查两件事:目录声明有没有结尾斜杠,flutter:下面的assets:缩进是不是两个空格。如果都对,执行flutter clean && flutter pub get再热重启(注意是重启不是热重载,资源是在启动时打包的)。

iOS 构建报 CocoaPods 相关错误。先cd ios && pod install --repo-update。如果还不行,删掉ios/Pods和ios/Podfile.lock重来。还有一个隐蔽原因是Podfile里的platform :ios, '12.0'版本太低,新插件要求更高的最低版本,改一下就好。

Android 构建失败,报 Gradle 或 JDK 版本问题。检查三处:android/gradle/wrapper/gradle-wrapper.properties里的 Gradle 版本、android/build.gradle.kts里的 AGP 版本、以及本机 JDK 版本。这三者要匹配,用 Android Studio 的 JDK 最保险。

热重载之后行为没变。热重载只重建 widget 树,不重新执行main、不重新初始化全局变量、不重新跑initState里的某些逻辑。改了main.dart、改了全局单例、改了状态管理的 provider 定义,都必须热重启(大写 R)。

白屏但没报错。常见原因是异步初始化抛了异常被吞掉,或者路由跳转到了不存在的路径。开发阶段建议在runZonedGuarded里包一层全局异常捕获,把错误打到日志里。另外FlutterError.onError也要接管,否则 release 模式下很多错误是静默的。

7.3 问题速查表

把上面这些整理成一张表,方便出问题时对照。

现象最可能的原因快速验证方式
图片显示不出来路径错误或未在 pubspec 声明检查目录结尾斜杠,热重启
自定义字体不生效family 名称大小写不一致全局搜索fontFamily与 pubspec 对比
依赖解析失败间接依赖版本冲突flutter pub deps --style=compact
iOS 编译报 Pod 错误Pod 仓库过期或最低版本不满足pod install --repo-update
Android 编译报 JDK 错误Gradle/AGP/JDK 三者不匹配查看 wrapper 配置与本机 JDK
修改代码后无变化热重载不覆盖的场景改用热重启或完整重跑
线上崩溃堆栈是乱码未用符号表还原找对应构建号的 symbols 目录
切换主题后颜色突变ThemeExtension 的 lerp 实现有问题检查 lerp 是否真的做了插值
换账号后看到旧数据缓存未随账号清理检查缓存 key 是否含用户标识

8. 我个人在这件事上的几个判断

写到这里,回到最开始那个问题:什么叫“完整”。我的答案不是“目录齐全”,也不是“用了某个架构”,而是“任何人接手都能在可预期的时间内做出安全的改动”。

有几个判断是我做了几年之后才慢慢形成的,跟主流教程的说法不完全一样。

一是不要为了分层而分层。三层架构在教科书里是标准答案,但在一个只有五个页面的工具类应用里,它就是纯粹的负担。我现在的做法是先写 feature 目录,等某个模块的 domain 逻辑真的复杂到需要独立测试了,再抽出去。架构应该跟着复杂度长,而不是预先铺满。

二是配置文件值得花时间。pubspec.yaml、analysis_options.yaml、build.gradle.kts、l10n.yaml这几个文件,加起来可能不到两百行,但它们决定了后面两年的开发体验。我见过太多项目在这几个文件上偷懒,然后在依赖冲突、漏写资源、构建不可复现上反复吃亏。

三是自动化优先于规范文档。写一份《开发规范》贴在 wiki 上,一个月后就没人看了。但把 lint 规则写进analysis_options.yaml,把构建步骤写进脚本,把测试跑进 CI,这些东西会强制执行。规范只有变成机器的约束才有效。

最后分享一个具体的做法。我在每个项目的lib/core/下会放一个README.md,不长,就三部分:这个项目的分层约定是什么、新增一个功能该建哪些文件、依赖注入的入口在哪。新同事来了先看这个,比看代码快得多。这份文档的维护成本很低,但能省下无数次“这个该放哪”的讨论。项目结构这件事,最终目的不是好看,是让沟通成本降下来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询