1. 整体设计与思路拆解
1.1 为什么要把项目升级到最新的 Flutter 3.44
今年年中左右,我接手了一个内部业务工具 App,它原本跑在 Flutter 2.x 的老版本上,混合开发模式也是早期那套:原生壳子包着多个 Flutter 页面,页面之间用 EventChannel 做通信。项目不复杂,但迭代到后期有两个痛点越来越明显:一是老版本 Skia 渲染下,复杂列表滚动时偶尔出现掉帧和文字边缘发虚;二是 Gradle 插件还在用旧式的apply script方式,每升级一次 Android Gradle Plugin 都要改一遍脚本,特别痛苦。
刚好部门要做鸿蒙版本的前期技术验证,我就顺势把 Flutter 主分支升级到了 3.44。这个版本最大的变化是 Impeller 渲染引擎在 Android 上默认开启,iOS 上已经跑了好几个版本,渲染稳定性和抗锯齿效果好很多。实测下来,原来列表页滚动掉帧的问题确实缓解了。
但升级不是点一下按钮就完事。Flutter 3.44 的 Android 构建对 AGP 版本和 Java 版本都有新要求,而且官方已经不建议再用apply方式手动应用 Gradle 插件。如果你旧项目里还写着:
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"升级后会遇到一个新的提示,大意是“you are applying flutter’s main gradle plugin imperatively using the apply script”,意思是你正在用命令式脚本应用 Flutter 的 Gradle 插件,这种方式在新版本里已不被推荐。正确做法是改成声明式插件,在 Gradle 的settings.gradle里加上插件管理,然后每个模块的build.gradle里只写id "dev.flutter.flutter-plugin-loader"和对应的 Flutter 插件 ID。
我这次踩坑的最大感悟是:升级本身不难,难的是项目里的旧依赖和新架构之间互相拉扯。你不能只盯着 Flutter SDK 版本,还得把周边生态一起梳理清楚,否则就会陷入“按下葫芦浮起瓢”的循环。
1.2 混合架构的状态管理和导航规划
因为项目是“原生壳 + 多 Flutter 页面”的混合模式,所以一开始就要在架构上想清楚:Flutter 页面之间的导航由谁来管?原生和 Flutter 之间的数据同步怎么做?页面被切换走之后,Flutter 的状态还在不在?
我这边最终选用了flutter_bloc里的 Cubit,而不是完整的 Bloc 事件驱动。原因很简单:业务里大部分交互是异步请求和状态覆盖,用 Cubit 写起来更轻,团队里新同学也能快速上手。状态管理上,我把每个业务模块抽象成独立的 Cubit,按flutter_bloc原生的BlocProvider挂在模块页面的根部。
导航这块,我试过两种方案:一种是原生那边用Navigator.push直接推一个新的 Flutter Activity,但这样每个页面都是单独的 FlutterEngine,内存和启动时耗都比较大;另一种是只保留一个 FlutterEngine,用 Flutter 内部的 Navigator 做页面切换。后者虽然在原生和 Flutter 之间跳转时需要多做一层桥接,但页面切换更顺滑,状态保持也更容易。
很多人担心Navigator切换页面后状态会丢失,其实 Flutter 的典型行为是:通过Navigator.push推到新页面后,原页面并没有被销毁,只是被移出了视图树,它的 State 对象还在内存里。但如果你用Navigator.pushReplacement或返回栈被清掉,那就真的销毁了。对于像列表滚动位置这类轻量状态,可以配合PageStorage或者AutomaticKeepAliveClientMixin来保留。我们项目中大部分场景就是直接压栈,返回时状态自然还在。
2. 核心细节解析与实操要点
2.1 别乱用 part,但用对时真香
Flutter 热词里有“part”这个词,很多刚接触 Dart 的同学会纠结part和import的区别。part允许把一个库拆到多个文件里,它们共享同一个库作用域,也就是说这些文件里的私有变量(下划线开头)互相可见。
我见过有些团队把一堆工具函数放在同一个part文件里,结果耦合越滚越大,最后改一个函数要连带看十几个文件。我的建议是:part只用于两种情况,一是单个类实在太长,需要把代码拆到多个文件,但逻辑上还是同一个类;二是代码生成器需要把生成的代码“插入”到主文件里,比如 JSON 序列化时用part 'xxx.g.dart'。
在实际开发中,我更多是用part来处理枚举扩展和抽象基类的实现拆分。举个例子,我有一个订单状态的枚举,状态判断逻辑特别多,如果都写在主文件里,主文件会有六百多行。拆一个order_status_part.dart,里面写状态展示文案和操作按钮的映射逻辑,因为共用同一个库作用域,枚举定义里的私有字段可以直接访问,不需要额外暴露 getter。
要注意的是,part的文件不能通过import单独使用,只能被part声明所在的主库引用。如果你发现某个part文件被多个库引用,说明设计有问题,应该把公共部分抽成独立的库再用import。我从这次重构里得到的教训是:能用import就不要用part,part是最后手段,不是组织代码的首选。
2.2 EventChannel 和 MethodChannel,一个都不能少
Flutter 和原生通信有两个常用通道:MethodChannel 用来做一次性调用,比如请求相机权限、拿设备型号;EventChannel 用来做持续事件流,比如监听网络状态、传感器数据或者原生模块发出的实时消息。
最近热搜里有很多人问 EventChannel 怎么用,我在项目里刚好踩过一些坑。基础用法是这样的,在 Flutter 侧:
static const EventChannel _channel = EventChannel('app/network_status'); Stream<String> get onNetworkStatusChanged => _channel.receiveBroadcastStream().cast<String>();然后在原生侧(以 Android 为例),实现StreamHandler:
object NetworkStatusHandler : EventChannel.StreamHandler { override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { // 这里开始监听系统广播,把事件通过 events?.success(data) 发送出去 } override fun onCancel(arguments: Any?) { // 移除监听器 } }坑点主要在生命周期。如果页面销毁后,原生那边还在发事件,Flutter 侧已经没人接收,那么EventSink会被 Flutter 引擎报错。我这里用了一个简单的方式:在 Cubit 的close()里取消订阅,同时原生侧也根据页面 Lifecycle 判断是否暂停事件发送。光是这个逻辑,我就调试了快一天,最后发现是 Flutter 引擎的 Channel 还在,但 Stream 的监听者已经没有方法调用了,需要在onCancel里彻底清理原生资源。
另外,如果你的应用需要从 Flutter 主动触发原生并收到返回值,用 MethodChannel 就够了。比如调起原生分享面板,返回值是用户是否完成分享。EventChannel 不要用来做这类“请求-应答”,语义上它是一条单向管道,硬用来做应答会让时序管理变得很复杂。
2.3 PlatformView 在混合开发里的那些坑
因为我们的原生壳里要嵌入一个原生地图 SDK,所以不可避免要用到 PlatformView。Flutter 3.44 里 Android 端默认已经是混合视图模式,但 iOS 端的UiKitView还是会有一些层级问题。
第一条建议:能不用 PlatformView 就别用,如果非要嵌入,优先用纹理模式,也就是官方说的Texture hybrid composition。纹理模式是让原生视图离屏渲染到纹理上,然后 Flutter 再合成到 UI 上。它的性能更好,但问题在于原生视图里的手势响应可能会有几毫秒的延迟。混合模式正好反过来,原生视图直接上屏,手势问题也没有了,但性能开销大,滚动时会明显卡顿。
我在 Android 上嵌入地图时,把 PlatformView 放进一个滚动列表里,结果列表 fling 的时候,地图区域有明显的撕裂感。后来看了官方 issue,发现是沉浸式状态栏导致 view 的尺寸计算错误,需要手动给 PlatformView 设置 padding。用AndroidView的layoutDirection参数也踩过坑,如果原生的 view 是 RTL 方向,Flutter 侧必须保持一致,否则地图手势和点击区域会偏移。
另一个坑是键盘弹出时 PlatformView 的onVisibilityChanged没有被正确调用。我最后的解法是监听 Flutter 侧的MediaQuery的 viewInsets 变化,动态调用原生 view 的adjustResize逻辑。调试这种问题最有效的手段是开 platform view 的 debug 开关,把原生 view 的布局信息打出来,再和 Flutter 侧做对比,基本能定位到是尺寸问题还是位置问题。
2.4 Future 的 then 回调到底是不是微任务
Flutter 异步编程里,Future的 then 回调确实是放在微任务队列(microtask queue)里执行的。这意味着它会优先于事件队列(比如手势事件、Timer 回调)执行。我用一个例子说明:
Future.delayed(Duration.zero, () { print('event queue'); }); Future.microtask(() { print('microtask'); }); // 输出顺序:microtask 然后 event queue这里需要注意一个细节:Future.delayed的回调是事件队列,而then注册的回调是微任务。如果你在then里又返回一个Future,那么新Future的回调会再次进入微任务队列,直到链条结束。如果你在then里同步抛异常,会立刻进入下一个catchError,不会占用额外的事件循环。
很多人写代码时觉得反正都是异步,用Future.microtask和then没区别。实际上在 UI 线程繁忙时,大量微任务可能导致界面卡顿,因为微任务队列会阻塞事件循环。我之前在网络层做数据解析时,用了一长串then链条,每次进来都做字符串拼接梦,结果列表滚动时掉帧。后来改成用await并在每次 await 之间加入一个EventQueue的主动调度,让 UI 有机会先渲染一帧,体验立刻不一样了。
3. 实操过程与关键环节实现
3.1 环境搭建:从下载 Windows 版 Flutter 到 Android Studio 初始化
新手接触 Flutter,第一步就是环境搭建。以 Windows 最新稳定版为例,访问 Flutter 官网,下载页面会自动推荐最新的稳定包,比如当前是flutter_windows_3.47.5-stable.zip。下载完不要解压到 C 盘根目录或带空格的路径,我一般解压到D:\dev\flutter,这样后面不会有权限问题。
配环境变量时,把flutter/bin加到 Path。然后在命令行执行flutter doctor,它会检查 Android SDK、Android Studio、VS Code 等依赖。很多人卡在 Android SDK 找不到,其实是因为 Android Studio 安装路径和 Flutter 的默认检查路径不一致。用flutter config --android-sdk <path>手动指定一下就好。
创建 Flutter 项目我在 Android Studio 里操作,新建项目时会让你填项目名、包名、组织名,注意项目名不能用下划线开头,包名尽量保持三段式,比如com.example.flutter_demo。Android Studio 生成的模板已经带了 Android/iOS/web 目录,如果你像我一样还需要鸿蒙,需要单独跑一条指令flutter create --platforms=harmonyos .来生成鸿蒙工程。要是你用的是命令行,也可以用flutter create --platforms=android,ios,harmonyos my_app。
3.2 安卓原生项目嵌入 Flutter 页面的标准姿势
把自己已有的 Android 原声项目集成 Flutter 页面,官方叫 embed,我试过两种方式。第一种是缓存一个 FlutterEngine,然后用 FlutterActivity 或 FlutterFragment 展示。这种方式适合需要频繁进入 Flutter 页面的场景,启动速度快,内存开销可控。
步骤如下:
- 在原生项目的
MainApplication里初始化 FlutterEngine:
class App : Application() { lateinit var flutterEngine: FlutterEngine override fun onCreate() { super.onCreate() flutterEngine = FlutterEngine(this) flutterEngine.dartExecutor.executeDartEntrypoint( DartExecutor.DartEntrypoint.createDefault() ) // 缓存引擎 FlutterEngineCache.getInstance().put("my_engine", flutterEngine) } }- 跳转时使用 FlutterActivity:
startActivity( FlutterActivity.withCachedEngine("my_engine") .build(this) )第二种是通过 FlutterFragment 嵌入到当前 Activity 的一部分。这种方式更接近“页面内嵌”,适合把 Flutter 视图作为原生页面的一个局部区域。这里需要手动处理 FlutterEngine 的 attach 和 detach,否则 fragment 销毁时引擎还在运行,会导致内存泄漏。
在嵌入时有个关键点:Flutter 页面如果用了路由命名,比如Navigator.pushNamed(context, "/home"),那你需要在 Flutter 侧初始化路由。我自己的做法是,在 Flutter 入口文件里根据原生传来的初始路由参数,用Router统一接管,这样从原生任何入口进来,Flutter 侧不会迷路。
3.3 TabBar 点击取消动画效果,一个细节救了一整个交互
项目里有个首页需要做 TabBar,产品要求点击同一个 tab 时不要有动画切换效果,只有切换不同 tab 时才有动画。Flutter 自带的TabBar切换总是有动画,而且会带动页面背景滑动。这个问题看起来小,但真调起来很麻烦。
我最后用的方案是自定义动画控制。在TabController上加一个监听,判断如果新 index 和旧 index 相同,就用jumpTo而不是animateTo。具体代码如下:
_controller.addListener(() { if (_controller.indexIsChanging) { if (_controller.previousIndex == _controller.index) { _controller.jumpTo(_controller.index); } } });但上面这个写法有个坑:indexIsChanging只在动画开始时触发,如果你通过点击同一个 tab,previousIndex和index相等,此时jumpTo会中断正在进行的动画,但可能会残留半透明的过渡层。更稳妥的做法是,干脆重写TabBar的dispose逻辑,不用动画的 tab 直接调用TabBar的_handleTap(index)的默认实现前,把动画回去。
其实官方 Widget 库里有TabBarView的physics参数,你可以设置为NeverScrollableScrollPhysics()来禁止滑动触发动画,但点击 tab 还是有动画。所以最终我是放弃用TabBarView,改成PageView加自定义 controller 配合AnimatedSwitcher来达到需求。虽然代码量多了,但控制力提升很多,动画的时长和缓动函数都能按业务需要调整。
3.4 用 PageStorage 和 AutomaticKeepAlive 保住页面状态
导航切页的状态保留,我上面的方案里提到用AutomaticKeepAliveClientMixin。它有一个细节,就是必须在build里调用super.build(context),否则 keepAlive 不会生效。如果你的页面是一个列表,并且需要保存滚动位置,除了 mixin,还需要把ListView的 controller 用PageStorageKey包装:
ListView( key: PageStorageKey('home_list'), controller: _scrollController, // ... )PageStorage的机制是把可保存的ScrollPosition放到一个全局的存储桶里,当页面重新构建时通过 key 恢复。但注意,如果你把页面放在了TabBarView里,默认是不保活的,切走 tab 后页面会被销毁。所以需要TabBarView的每个子页面都套一个KeepAlive类组件,或者在父级用IndexedStack一次性构建所有 tab 页面,这样状态天然保留。
IndexedStack的缺点是全部页面会被同时构建,如果页面重会增初始时有性能压力。但本项目页面不多,我选择了IndexedStack,因为实现简单,状态管理也最直观。如果你的页面很重,还是用AutomaticKeepAlive按需保活更合理。
4. 常见问题与排查技巧实录
4.1 安卓打包报 “could not close …” 的 AssertionError
升级后第一次打包就遇到了报错,信息类似java.lang.AssertionError: java.lang.Exception: could not close ...。这个属于 Flutter 工具层在打包过程中无法关闭某个文件流导致的。常见原因有两个:一个是磁盘空间不足,构建过程中dex和assets文件太大,临时文件被系统中断;另一个是杀毒软件或文件占用导致 Flutter 无法删除旧缓存。
我当时的磁盘其实还有 50GB,排除了空间问题。最终定位到问题在build目录下的app缓存文件权限被设置成了只读。解决办法很简单,在项目根目录执行:
flutter clean rm -rf build rm -rf .dart_tool然后重新构建。如果还不行,检查你的gradle-wrapper.properties里distributionUrl是不是指向了过高的 Gradle 版本,导致兼容问题。Flutter 3.44 推荐使用 Gradle 8.x,过高的 8.5 以上版本也会触发一些文件锁定的问题。把 Gradle 版本降到官方推荐值,一般就能解决。
4.2 The current configured Flutter SDK is not known to be fully supported
升级完 SDK 后,每次执行flutter run都会看到这行警告,意思是你当前项目锁定的 Flutter SDK 版本不被当前所用的 Flutter 工具完全支持。这个问题的原因主要有两个:一是你用了 beta 或 master 分支的 Flutter SDK 去运行一个用 stable 分支创建的项目;二是项目里的pubspec.yaml指定的environment: sdk范围过宽或过窄。
我用的 Flutter 是 3.44 stable,但项目里pubspec.yaml还写着sdk: '>=2.19.0 <4.0.0',这本来没有问题。问题出在项目根目录的.dart_tool/package_config.json里缓存了旧版本的 SDK 路径。执行flutter clean和pub get后警告就消失了。如果不想每次手动清理,可以升级flutter_flutter这个 CLI 工具,或者用 fvm 来统一管理多版本 SDK,这样就不容易出现版本错乱。
4.3 Flutter Web 引擎启动慢,首屏白屏太抽象
新版本做 web 打包,首屏加载速度一直被人吐槽。我试过把web/index.html里的 manifest 和 service worker 去掉,同时把 assets 压缩方式改成 gzip。这样首屏资源减少大概 30%,但真正快的还是走canvaskit的 CDN 加速。如果你部署在内网,需要把 CanvasKit 本地化,在index.html里设置window.flutterConfiguration的 canvasKitBaseUrl 指向本地资源。
另外,如果 web 端只是给内部演示用,可以打开flutter run -d chrome --web-debug的 debug 模式,启动会快很多,但生产构建还是要用flutter build web --release。注意 release 模式下资源的缓存策略,要把 index.html 设置为 no-cache,否则用户永远访问不到你新发的版本。
4.4 Xcode 27 很多 Flutter 包报版本低
热搜里提到“xcode27 很多 flutter 包报版本低”,这应该是说最新的 Xcode 27 下,很多 Flutter 原生插件因为 Swift 版本或者 API 级别的兼容问题报出“the current configured Flutter SDK is not known”或构建失败。
这种问题最直接的解决办法是升级插件到最新版本。我遇到permission_handler和path_provider这两个插件在新 Xcode 下都会报could not build module 'XXX'。升级之后就好了。如果某个插件已经没人维护,可以用编辑原生代码的方式,把报错文件中@available的版本号提高,或者把Swift编译版本改成5.0。在 Xcode 里设置SWIFT_VERSION为 5.0,能解决很大一部分兼容性问题。
4.5 Okta 平台插件适配鸿蒙的流程
由于我们项目要适配鸿蒙,之前用的 Okta 登录插件在 OpenHarmony 上没有现成的实现。我们拿到的是别人写好的鸿蒙适配包,但对接过程中还是遇到不少问题。大流程是这样的:
- 在 Flutter 层用
MethodChannel统一封装signIn、signOut方法,返回类型统一为Future<String>。 - 鸿蒙侧需要实现一个对应
MethodChannel的 Handler,并在EntryAbility中注册。 - 因为 Okta 依赖系统浏览器进行 OAuth 跳转,鸿蒙侧要自己处理
Want跳转和回调的监听,不能像 Android 那样直接 reuseActivityResult.
我在适配时最大的坑是鸿蒙的MethodChannel注册时机过早,导致 Okta 插件还没初始化完成,Flutter 侧调用就报MissingPluginException。后面改成在鸿蒙应用onStart完成后再注册 channel,问题解决。适配鸿蒙一定要多看官方文档,大部分 Android 的实现思路可以复用,但生命周期和系统 API 的命名差距不小。
5. 一些实操心得和后续扩展建议
这次整个项目从升级到适配,我最大的体会就是 Flutter 的更新节奏越来越快,但社区里很多坑还停留在旧版本时代。如果你准备升级版本,先列一个原生依赖清单,把所有插件的版本号在升级前全部更新到兼容新 SDK 的版本,不然就会遇到我上面说的“东边修好西边炸”的情况。
最后再分享一个小技巧:每次升级 Flutter SDK 后,执行flutter pub upgrade --major-versions之前,先跑一次flutter analyze,让静态检查把所有已废弃的 API 调用指出来,你会少掉很多头发。像TextStyle的fontSize被统一到textScaler这类变化,听上去无关紧要,但全局生效后布局会变很多。所以升级版本这件事,真的不是“一键完成”的,需要给它足够的耐心。