三年前我接了个活,客户的需求写得很朴素:iOS 和 Android 各出一版,功能一样,视觉一样,一个月交付。我当时拍着胸脯说没问题,Flutter 双端开发嘛,一套代码的事。真上手才发现,"一套代码"这四个字里面藏着的东西,比业务逻辑本身多得多。iOS 那边卡在证书和开发者模式上折腾了一整天,Android 这边被 Gradle 插件的声明方式变更和文件路径问题绊住,两边加起来,写 Dart 的时间可能只占了一半。后来项目多了,踩的坑也攒够了,从建工程、搭结构、对齐双端体验,一直到打包上架,这条路我基本走顺了。下面按我实际推进的顺序,把 Flutter 双端开发从开发到上架的全流程拆开讲,重点放在那些官方文档里不会写、但真做项目一定会撞上的地方,尤其是 Windows 环境构建报错、iOS 真机调试、Android 文件 URI、双端性能差异和上架审核这几段。
1. "一套代码"的真实边界:Flutter 到底替你省了哪些活
刚接触 Flutter 的人容易走两个极端:要么觉得它能搞定一切,要么被几个原生交互劝退后觉得它啥也干不了。实际情况在中间,而且这条分界线其实很清晰,摸清了就能少走很多弯路。
1.1 自绘渲染带来的双端一致性红利
Flutter 不走系统原生控件,它自己带一套渲染引擎,把 Widget 树算成绘制指令,直接画到画布上。老版本底层是 Skia,新版本在 iOS 上已经默认切到 Impeller,Android 也在逐步跟进。这意味着什么?你在 iOS 上看到的一个Container,圆角半径、阴影扩散、边框绘制方式,和 Android 上看到的是同一套代码算出来的,像素级一致。
这件事的价值,只有做过原生开发的人才体会得到。原生双端里,同一个按钮,iOS 是UIButton,Android 是MaterialButton,圆角、阴影、点击态、字体基线全都不同,你还得写两遍样式去逼近一个"看起来一样"的效果。Flutter 里这些都是RenderObject的确定性计算,不用对齐。
但自绘也有代价。凡是"系统级"的东西,Flutter 得自己模拟:软键盘的弹出时机、输入法候选框的位置、滚动惯性曲线、日期选择器、系统返回手势。这些地方经常出现"看着像,手感差一点"的情况。最典型的是 iOS 的侧滑返回——只有CupertinoPageRoute才带那个带阻尼的回弹,用MaterialPageRoute在 iOS 上就没有这个手势。所以我的习惯是:路由层按平台分两套构造函数,iOS 走 Cupertino,Android 走 Material,一处配置,两边都舒服。
1.2 一条清晰的分界线:Dart 管业务,原生管能力
我的判断标准很简单:能用纯 Dart 解决的,一律走 Dart;必须调系统能力的,才动桥接。中间的灰度地带,优先找成熟插件,找不到再自己写MethodChannel。
| 能力需求 | 常见落地方式 | 容易忽略的点 |
|---|---|---|
| 相机 / 相册 | camera、image_picker | iOS 必须写Info.plist用途描述,Android 13 起读媒体权限拆成了READ_MEDIA_IMAGES |
| 文件读写 | path_provider +dart:io | Android 10 起分区存储,外部路径不能直接写 |
| 系统分享 | share_plus | iPad 上不给锚点矩形会直接崩 |
| 视频播放 | video_player | iOS 底层 AVPlayer、Android 底层 ExoPlayer,编码支持范围不一样 |
| 定位 | geolocator | 后台定位两个平台都要单独声明,审核会查 |
| 推送 | firebase_messaging 或各厂商通道 | 国内 Android 机型保活策略差异极大,别指望一套配置通吃 |
| 内嵌网页 | webview_flutter | 两个平台的 Cookie 存储和 JS 桥接行为不同 |
这张表看着简单,但每一条背后都有故事。比如分享这个功能,我在 iPad 上第一次遇到崩溃时完全懵——代码在 iPhone 和 Android 上跑得好好的,一上 iPad 就挂。原因就是 iPad 的分享面板是个弹出气泡,必须给一个锚点位置,否则系统不知道从哪儿弹。
1.3 开工前的技术盘点清单
在动手写第一行代码前,我会先回答几个问题,这几个答案基本决定了项目的架构走向:
- 有没有必须接入的第三方 SDK 只提供了 Objective-C 或 Java 版本?如果有,桥接成本要提前算进排期。
- 有没有页面必须用原生实现(比如复杂的相机滤镜、地图深度定制)?有的话,平台视图和纯 Flutter 页面的混合路由怎么切?
- 包体积有没有硬性上限?Flutter 的引擎本身会占十几兆,加上字体和图片,很容易超。
- 团队里有没有人会写 Swift 或 Kotlin?一个人都不会,那技术选型就得往"零原生代码"的方向靠。
这些问题里,第一个最容易出事。很多行业 SDK(支付、识别、打印、蓝牙设备)只给原生库,接入就得自己写通道,而通道的另一头是两套完全不同的实现,调试成本是普通功能的几倍。
2. 环境搭建:卡住最多人的四个节点
环境这关,我见过太多人第一天就放弃了。其实报错信息本身不难,难的是不知道它在说什么。下面四个节点是出现频率最高的。
2.1 flutter doctor 到底要绿到什么程度
先纠正一个误区:flutter doctor不是非要全绿。如果你只做 iOS + Android 双端,Windows 机器上那些 Chrome、Visual Studio 相关的红叉可以完全忽略。真正要关心的是三项:Flutter SDK 版本、Android 工具链、Xcode(只在 macOS 上)。
Android 这块的标准流程是:装 Android Studio,在 SDK Manager 里勾选对应 API 级别的 Platform 和 Build-Tools,再装 cmdline-tools,最后跑一次:
flutter doctor --android-licenses一路y敲下去。这一步不做,后面构建会报许可证未接受。
还有个容易被忽略的点是 JDK 版本。新版 Android Gradle Plugin 要求 JDK 17,如果你机器上还挂着 JDK 8 或者 11,Gradle 会直接报编译版本不匹配。默认用 Android Studio 自带的那份 JDK 最省事,IDE 里设置一次就行。
2.2 Windows 上那个 "unable to find suitable Visual Studio toolchain"
这个报错我收到过不下十次提问。先说结论:它跟 Android 构建没关系,它属于 Windows 桌面目标(desktop)的构建链。报错原文的意思是没有找到合适的 Visual Studio 工具链,因为编译 Windows 原生代码需要 Visual Studio 2022 并且勾选"使用 C++ 的桌面开发"工作负载。
那为什么很多人明明在跑 Android 项目却看到它?两个常见原因:
第一,运行目标选错了。在 VS Code 里点运行,如果没指定设备,Flutter 会挑一个可用设备,有时候就挑到了 Windows 桌面。解决办法是显式指定:
flutter devices flutter run -d <你的安卓设备ID>第二,项目目录里存在windows/文件夹。如果你用flutter create在 Windows 上建的工程,默认会带上五个平台目录,IDE 的启动配置有可能指到桌面上去。如果确定不做 Windows 桌面版,最干脆的做法是删掉windows/,或者在创建时就用--platforms限定:
flutter create --platforms=android,ios my_app如果你确实要做 Windows 桌面版,那就老老实实装 Visual Studio 2022,安装器里勾上 C++ 桌面开发工作负载,装完重启终端再跑flutter doctor。这一步没什么取巧空间。
2.3 iOS 真机调试:开发者模式与签名
在 iOS 16 和 Xcode 15 之后,真机调试多了一道新门槛:开发者模式。这个开关默认不显示,只有设备通过数据线连过 Xcode、并且信任过这台电脑之后,才会在"设置 → 隐私与安全性"底部冒出来。打开它需要重启设备,重启后还会再确认一次。
很多人的排查顺序是反的:先怀疑证书,再怀疑描述文件,最后才发现开关没开。我建议的顺序是——线插上,Xcode 里能看到设备,再去看那个开关。
关于签名,用三个词理解就够了:
- 证书:证明"你是谁",开发者账号的身份凭证。
- 描述文件:说明"哪个 App、装在哪几台设备、用哪张证书签名"。
- Bundle Identifier:App 的唯一身份证,全局不能重复,上架后基本不能改。
个人开发者用免费账号也能真机跑,但签名有效期只有 7 天,到期要重新构建安装,而且不能上架。要正式发布,还是得加入开发者计划。
2.4 Gradle 插件声明方式变了:apply plugin 那行报错别硬改
新版本 Flutter 把 Android 侧的 Gradle 插件改成了声明式引入。如果你从旧工程升级,会看到类似"正在用 apply 脚本方式强制应用 Flutter 主 Gradle 插件,这种方式已废弃"的提示。这个时候不要试图去改那一行apply,正确的做法是整体迁移。
settings.gradle要长这样:
pluginManagement { def flutterSdkPath = { def properties = new Properties() file("local.properties").withInputStream { properties.load(it) } def flutterSdkPath = properties.getProperty("flutter.sdk") assert flutterSdkPath != null, "flutter.sdk not set in local.properties" return flutterSdkPath }() includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") repositories { google() mavenCentral() gradlePluginPortal() } } plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "com.android.application" version "8.1.0" apply false id "org.jetbrains.kotlin.android" version "1.8.22" apply false } include ":app"app/build.gradle的顶部改成:
plugins { id "com.android.application" id "kotlin-android" id "dev.flutter.flutter-gradle-plugin" } android { namespace "com.example.myapp" compileSdkVersion flutter.compileSdkVersion }这是我的实操建议:与其一行行改老工程,不如新建一个空项目,把新生成的settings.gradle、build.gradle、gradle-wrapper.properties三个文件对照着抄过来,五分钟搞定,比逐行 debug 快得多。namespace这个字段是新版 AGP 强制的,老工程的AndroidManifest.xml里如果还写着package,两个地方要保持一致。
3. 工程骨架:从 main.dart 到一个能长期维护的双端项目
一个能跑起来的 Demo 和一个能维护两年的项目,差距全在结构上。这部分我想讲的是那些"当时看不出来、半年后救命"的决策。
3.1 目录分层该按功能切还是按类型切
按类型切长这样:models/、views/、controllers/、services/。项目小的时候很清爽,一旦模块超过十个,找代码就变成了来回跳目录。
我更推荐按功能切:
lib/ ├── app/ 全局配置:路由表、主题、多语言、启动引导 ├── core/ │ ├── network/ 网络客户端、拦截器、错误模型 │ ├── storage/ 本地数据库、键值存储 │ ├── utils/ 日期、格式化、校验 │ └── constants/ 常量、环境配置 ├── features/ │ ├── home/ │ │ ├── data/ 仓储实现、数据源、DTO │ │ ├── domain/ 实体、用例 │ │ └── presentation/ 页面、组件、状态 │ └── profile/ └── shared/ 跨功能复用的组件好处是删功能的时候,直接删一个目录,不用担心遗漏。团队协作时冲突也少——两个人做两个功能,几乎不会改到同一个文件。
3.2 状态管理选型:先看团队,再看技术
这块没有标准答案,只有适配答案。我的判断依据排序是:团队人数 > 项目复杂度 > 个人偏好。
| 方案 | 上手曲线 | 适合场景 | 我的看法 |
|---|---|---|---|
| Provider | 低 | 中小项目、状态不复杂 | 够用,但大项目里容易变成"谁都能改" |
| Riverpod | 中 | 中大型项目、依赖注入需求多 | 编译期安全,测试友好,我个人现在首选 |
| Bloc | 中高 | 大型项目、多人协作、事件驱动 | 模板代码多,但边界最清晰 |
| GetX | 低 | 快速原型、小团队 | 一把梭很爽,长期维护要克制使用 |
一个真实教训:我接手过一个用 GetX 的项目,路由、状态、依赖注入、弹窗全用它,改一个页面要同时动五个地方,新人进来两天上不了手。工具本身没问题,是用法失控了。
3.3 网络层封装:拦截器里该放什么,不该放什么
网络层我一般收在一个单例里,用 Dio。核心是BaseOptions和拦截器链。
final dio = Dio(BaseOptions( baseUrl: Env.baseUrl, connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 15), sendTimeout: const Duration(seconds: 15), )); dio.interceptors.add(AuthInterceptor()); // 注入 token dio.interceptors.add(RetryInterceptor()); // 幂等请求重试 if (kDebugMode) { dio.interceptors.add(LogInterceptor(responseBody: true)); }三个必须记住的点:
第一,日志拦截器只能在调试模式加。我见过一个项目把它无条件加上了,结果 release 包里所有请求头和响应体都打进了系统日志,token 全在里面,这是实打实的安全事故。
第二,统一错误模型。上层业务不应该感知 Dio 的存在,所以要在拦截器里把DioException翻译成自己的ApiException,带上业务错误码和可读消息。这样以后换网络库,业务代码一行不动。
第三,token 刷新的并发问题。当访问令牌过期,同时有三个请求返回 401,如果每个都独立去刷新令牌,会触发三次刷新,后两次很可能因为刷新令牌已被作废而失败。正确的做法是加一个待刷新队列:第一个请求触发刷新,其余挂起等待,刷新完成后一起重放。这个坑不踩一次很难想到。
3.4 本地数据库与后端同步的落地方式
本地存储分两类需求:一类是键值配置(用shared_preferences就够),一类是结构化数据的离线可用(要上数据库)。
| 方案 | 类型 | 特点 |
|---|---|---|
| sqflite | 关系型 | 直接写 SQL,生态成熟,性能可控 |
| drift | 关系型 | 编译期校验 SQL,类型安全,推荐新项目 |
| hive | 键值 / 对象 | 极快,但复杂查询能力弱 |
| isar | 文档型 | 查询能力强,适合数据量大且结构灵活的场景 |
真正麻烦的不是选哪个,是同步策略。我的做法是每条本地记录带上四个字段:local_id(本地生成的唯一标识)、remote_id(服务端返回的 ID,未同步时为空)、updated_at(UTC 时间戳)、sync_state(干净 / 待上传 / 待删除)。
同步流程大致是:
Future<void> sync() async { final pending = await dao.findByState(SyncState.pendingUpload); for (final row in pending) { final remote = await api.push(row.toRemoteJson()); await dao.markSynced(row.localId, remote.id); } final since = await prefs.getLastSyncTime() ?? DateTime.utc(2000); final changes = await api.pullSince(since); for (final item in changes) { await dao.upsertRemote(item); } await prefs.setLastSyncTime(DateTime.now().toUtc()); }冲突解决我一般用"服务端优先 + 本地待上传优先写入"的混合策略:本地刚改还没上传的,先推上去;已经在服务端被改过的,以服务端为准。别小看这个规则,早期我用纯粹的"最后写入获胜",结果用户在一台设备上改的内容被另一台设备的旧数据覆盖过。
时间戳一定要用 UTC。设备时区不一致、系统时间被用户手动改过,都会让同步逻辑错乱。有条件的话以服务端返回的时间为准。
4. 双端体验对齐:一套代码跑出来却不一样的地方
写完代码在两端跑一遍,往往会发现一堆"为什么这边是这样"。这些差异不是 Bug,是平台机制不同,得逐个处理。
4.1 文件与路径:content:// 是怎么把新手绕进去的
Android 10 引入分区存储之后,从相册、下载目录拿到的文件,很多时候给你的是content://开头的 URI,比如content://com.android.providers.media.documents/...这种形式。这种 URI 用dart:io的File是打不开的,会直接抛异常。
稳妥的处理方式是:拿到文件后先复制到应用自己的临时目录,再拿真实路径去操作。这样既绕开了 URI 权限问题,也避免了用户在外部删除原文件导致后续读取失败。
Future<File> ensureLocalFile(XFile picked) async { final isContentUri = picked.path.startsWith('content://'); if (!isContentUri) return File(picked.path); final tmpDir = await getTemporaryDirectory(); final target = File('${tmpDir.path}/${DateTime.now().millisecondsSinceEpoch}_${picked.name}'); final bytes = await picked.readAsBytes(); await target.writeAsBytes(bytes); return target; }注意:复制大文件会额外占内存和存储,视频这类场景建议只拿 URI 去做流式上传,别整个读进内存。
4.2 权限申请:两个平台是两套写法
Android 侧:先在AndroidManifest.xml声明,再在运行时申请。Android 6 之后危险权限都是运行时申请。Android 13 起,读图片和视频的权限从READ_EXTERNAL_STORAGE拆成了READ_MEDIA_IMAGES和READ_MEDIA_VIDEO,如果不做版本判断,在新机型上会直接拿不到权限。
iOS 侧最坑的地方在于:Info.plist里缺用途描述不是报权限被拒,而是直接崩溃。而且崩溃日志不会直白告诉你缺哪个键。常用的几个键:
<key>NSCameraUsageDescription</key> <string>用于拍摄商品照片</string> <key>NSPhotoLibraryUsageDescription</key> <string>用于从相册选择图片</string> <key>NSMicrophoneUsageDescription</key> <string>录制视频时需要采集声音</string>用途描述要写具体用途,不能写"需要此权限以提供服务"这种空话,审核会因此驳回。而且只有真正用到的权限才写,多写一样会被问。
4.3 分享、视频、进度条:跟着平台走而不是硬掰
分享用share_plus,一行代码的事,但 iPad 上必须传锚点:
await Share.share( '看看这个内容', sharePositionOrigin: Rect.fromLTWH( box.localToGlobal(Offset.zero).dx, box.localToGlobal(Offset.zero).dy, box.size.width, box.size.height, ), );没有这个矩形,iPad 上弹窗找不到锚点,系统会直接崩掉。
视频播放用video_player,底层 iOS 是 AVPlayer、Android 是 ExoPlayer。同一个视频文件,两端对编码格式的支持范围是有差别的,尤其是某些封装格式和非主流编码。我的经验是:如果视频是用户上传的,服务端做一次统一转码(H.264 + AAC + MP4),能省掉后面一大堆"这个机型播不了"的投诉。
进度指示器这种小细节,Android 用户习惯线性进度条,iOS 用户对旋转菊花更熟悉。用Platform.isIOS判断是常见做法,但记住在 Web 上dart:io的Platform会报错,如果你以后可能编 Web 版,就得先判断kIsWeb,或者干脆用主题配置来切换。
4.4 内存:两端都会涨,但涨的地方不一样
Flutter 的内存问题,八成出在图片和监听器上。
图片这块最重要的一条:Image默认按原图尺寸解码。一张 4000×3000 的照片,解码成位图是 4000×3000×4 字节,差不多 48MB,如果列表里放了十张,直接崩。解决办法是给图片指定解码尺寸:
Image.network( url, cacheWidth: (200 * devicePixelRatio).round(), cacheHeight: (200 * devicePixelRatio).round(), )再配合PaintingBinding.instance.imageCache的上限配置,控制整体占用:
PaintingBinding.instance.imageCache.maximumSize = 100; // 最多缓存 100 张 PaintingBinding.instance.imageCache.maximumSizeBytes = 100 << 20; // 约 100MB| 常见泄漏点 | 症状 | 处理方式 |
|---|---|---|
| 控制器未释放 | 页面来回切,内存阶梯上升 | dispose()里释放 AnimationController、TextEditingController |
| 流订阅未取消 | 后台持续接收数据 | 保存StreamSubscription,在dispose里cancel() |
| 定时器未停止 | 页面退出后仍在跑 | Timer.periodic要显式cancel() |
| 全局单例持有页面引用 | 页面无法回收 | 控制器不要塞进全局容器,或退出时清理 |
| JSON 大对象解析 | 解析瞬间卡顿、峰值高 | 用compute丢到 Isolate |
排查工具就用 DevTools 的 Memory 面板。一个实用技巧:来回切同一个页面十次,看 heap 曲线,如果每次峰值都比上一次高、且不回落,基本就是泄漏了。
5. 打包与上架:最后一个环节,也是最多人卡在门口的地方
代码写完了,能不能顺利送到用户手上,是另一套技能。
5.1 Android 签名、混淆与产物格式
签名文件生成一次,之后一直用,丢了就得换包名重新上架,一定要备份。
keytool -genkey -v -keystore ~/upload-keystore.jks \ -keyalg RSA -keysize 2048 -validity 10000 -alias upload然后在android/key.properties里写:
storePassword=你的密码 keyPassword=你的密码 keyAlias=upload storeFile=/绝对路径/upload-keystore.jksapp/build.gradle里读取并配置签名,同时打开混淆和资源压缩:
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 proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } }混淆这一块有个坑:如果你的原生依赖里有反射调用,混淆后会在运行时崩,必须在proguard-rules.pro里加 keep 规则。判断方法很简单——release 包崩、debug 包不崩,八成就是混淆。
构建产物:
flutter build appbundle --release # 用于 Google Play flutter build apk --release --split-per-abi # 用于国内多数商店--split-per-abi会按 CPU 架构拆成几个 APK,每个体积小很多,但上传时要注意商店是否支持多 APK。国内各家应用市场还有各自的资质审核材料要求,周期往往不短,建议提前几周就开始准备,别等版本做完才发现卡在材料上。
5.2 iOS 打包与 TestFlight
先说一个新手最常见的错误:打开的是ios/Runner.xcworkspace,不是Runner.xcodeproj。有 CocoaPods 依赖的项目必须打开前者,否则依赖找不到,编译一堆报错。
流程是:Bundle Identifier 与 App Store Connect 上创建的 App 记录保持一致 → 选真机或 Any iOS Device 作为目标 →Product → Archive→ 在 Organizer 里Distribute App→ 选 App Store Connect → 上传。
上传完成后不会马上能测,要去 App Store Connect 的 TestFlight 页面等构建处理完,通常几分钟到半小时。处理完还要填"出口合规信息"之类的东西,然后就能加内部测试员了。
注意:
pubspec.yaml里的version: 1.2.3+45,1.2.3对应 iOS 的CFBundleShortVersionString,45对应CFBundleVersion。上传完一个构建后,想再传一个必须把45往上加,否则会被拒收。
5.3 审核被拒的常见原因
| 被拒原因 | 典型表现 | 处理方式 |
|---|---|---|
| 权限用途描述不具体 | 审核备注要求说明用途 | 在描述里写清"用于什么场景",不要写套话 |
| 存在崩溃或白屏 | 审核人员一打开就挂 | release 模式真机完整走一遍主流程,别只测 debug |
| 核心功能无法体验 | "需要账号才能看" | 提供可用的测试账号,并在备注里写清步骤 |
| 缺失隐私说明文件 | 引用的部分 SDK 需要 | 按平台要求补齐隐私清单 |
| Android 目标版本过低 | 商店提示不满足要求 | 及时跟进targetSdkVersion的年度要求 |
| 内容或文案问题 | 描述与实际功能不符 | 商店描述、截图与实际功能严格一致 |
被拒不可怕,可怕的是不知道怎么复现。我的做法是:每次提交前自己用 release 包,从全新安装开始,把主要路径走一遍,尤其是那些依赖权限和网络的功能。
5.4 版本号与灰度发布
版本号只能往前,不能往回。versionCode在 Google Play 上永远只能增加,减了会被拒。灰度方面,Google Play 支持分阶段发布(先放 5%,观察再扩),iOS 侧是分阶段自动发布,国内各商店的灰度能力差异较大,有的干脆没有,所以更依赖后端开关来做功能控制。
一个实用建议:把"能不能用"和"发不发版"解耦。新功能背后挂一个服务端下发的开关,出问题直接关掉,比等审核快得多。
6. 上线之后:把"能跑"变成"跑得住"
提交上架不是终点。真正耗精力的部分是上线后那段时间的监控和迭代。
6.1 崩溃与性能监控:符号表这件事必须做
崩溃收集用 crashlytics 或 sentry 都行,接入本身不难,难的是符号化。Flutter 的 release 包是 AOT 编译的,崩溃堆栈如果没有对应的符号文件,你看到的是一堆地址,完全没用。
- Android 侧需要上传
mapping.txt(在build/app/outputs/mapping/release/下)。 - iOS 侧需要上传 dSYM 文件。
这两步如果不做,你会收到"有崩溃"的通知,但永远定位不到是哪一行代码。我见过团队上线两周,崩溃率 3%,硬是不知道问题在哪,最后回头补符号上传才定位到一个插件版本冲突。
6.2 为什么 Flutter 不能像网页那样热更新
这件事值得说清楚,因为经常有人问。Flutter 的 release 构建是 AOT(提前编译)成机器码的,Dart 代码在打包那一步就已经编译进二进制里了,运行时没有脚本引擎去解释新代码,所以改一行代码就必须重新打包上架。
那能不能动态化?可以,但思路不同:把多变的业务逻辑放到服务端下发配置(规则、文案、布局描述),客户端只做渲染。或者在 App 内嵌一个容器来承载高频变动的页面。这两条路都能走,但都要额外设计,不是加个库就完事。
6.3 迭代节奏与自动化构建
分支策略我用得比较土但稳定:main放可发布代码,develop做日常集成,功能分支从develop切出去,发布前合并到main打 tag。
CI 用 GitHub Actions 的话,有个关键限制要先知道:iOS 构建必须跑在 macOS 的 runner 上,Windows 和 Linux 的 runner 编不了 iOS。Android 则随便哪个平台都能跑。
name: build on: push: tags: ['v*'] jobs: android: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2 with: flutter-version: 'stable' - run: flutter pub get - run: flutter build apk --release --split-per-abi - uses: actions/upload-artifact@v4 with: name: android-apk path: build/app/outputs/flutter-apk/*.apk ios: runs-on: macos-latest steps: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2 with: flutter-version: 'stable' - run: flutter pub get - run: flutter build ios --release --no-codesign--no-codesign是先产出不带签名的产物,签名和上传交给后续步骤处理(比如用自动化发布工具)。把构建时间从本机挪到 CI,最大的好处是环境一致——不会出现"我机器上能打出来,别人打不出来"这种事。
6.4 几个我长期在用的小习惯
最后分享几个实操层面的习惯,都是被坑出来的:
- 每次升级 Flutter SDK 大版本,先在
develop分支上跑一遍完整回归,再合并到主分支。跨大版本升级经常伴随插件不兼容,别在主分支上试。 - 插件的版本号在
pubspec.yaml里锁死,不要用any。某个插件在你没动代码的情况下悄悄发新版,构建挂掉的事我遇到过两次。 - 项目根目录维护一个
TROUBLESHOOTING.md,每次踩坑解决后记一行。团队新人接手时,这个文件的价值比任何文档都高。 - 定期清理
ios/Pods、build/、.dart_tool/再重新拉依赖,很多"莫名其妙编译不过"的问题会自己消失。这个操作我一般叫"三板斧",不解决再查别的。
这套流程走下来,一个中等复杂度的双端项目,从零到上架大概需要三到五周,其中真正写业务逻辑的时间可能只占一半,剩下的是环境、适配、调试和上架流程。听起来效率不高,但对比原生双端开发的人力投入,还是省了不少——尤其是后续迭代阶段,一套代码改一次,两端同时更新,这个优势会随着版本越滚越大。