上个月我把一个跑了大半年的Flutter项目往鸿蒙设备上迁移。业务代码倒还好说,真正把我绊住的是启动那两秒:系统默认的启动页要么是白底黑字,要么是品牌色配一个光秃秃的Logo,然后没有任何过渡就硬切到Flutter首页。从原生页面关闭到Flutter首帧出现之间那一段,经常能看到明显的白屏闪烁。后来我把flutter_splash_screen这个三方库捡起来,配合鸿蒙ArkTS侧的原生启动页做了一套双层方案,问题才彻底解决。这篇就从我踩坑的过程说起,把为什么选这个库、它的运行机制、以及鸿蒙工程里怎么把它真正跑通讲清楚。
1. 为什么Flutter应用在鸿蒙上需要专门做启动页
1.1 冷启动时间窗口里究竟发生了什么
Flutter应用从冷启动到显示内容,并不是一蹴而就的。首先要启动鸿蒙的UIAbility,然后创建Flutter引擎、拉起Dart虚拟机、加载Dart代码、建立渲染管线,最后才产出第一帧。这些环节在低端设备上可以轻松吃掉1.5到2.5秒。在这个窗口内,Flutter本身没有任何能力往屏幕上画东西,必须靠原生页面先顶住。如果没有启动页,用户看到的就是白屏、黑屏或者系统默认的无聊界面。
为了量化这个时间,我当时在测试机上加了日志,分别在UIAbility创建、Flutter引擎初始化、首帧回调三个位置打了时间戳。从启动到首帧大概1.8秒,而Flutter的runApp在0.6秒左右就开始执行了,剩下1.2秒基本都耗在引擎和渲染管线的构建上。也就是说,启动页至少需要覆盖住这段将近两秒的“不可见窗口”。
1.2 启动页不只是“好看”,更是性能兜底
很多同学觉得启动页就是个形象工程,其实它还有一个重要作用:把用户等待时间和业务初始化并行。真正讲究的项目,会把配置拉取、本地缓存读取、数据预热塞进启动页展示的那段时间,等用户看到启动页结束时,首页数据已经准备好了。这个思路在鸿蒙上同样适用,甚至更关键,因为鸿蒙生态对应用启动阶段的视觉稳定性和流畅度要求往往更高。
如果启动页做得仓促,用户大概率会感觉到两次闪动:一次是系统冷启动时的原生页面,另一次是Flutter首页加载完成前的空白帧。这两次闪动叠加起来,体验影响会非常差。这也是我为什么宁愿多花两天时间,也要把启动链路彻底理顺的原因。
1.3 flutter_splash_screen在原生生态里的角色
Flutter官方一直没把“原生启动页”纳入框架内统一的跨端方案,所以每一次启动页的展示和隐藏都必须由开发者自己在原生工程里写。flutter_splash_screen这类库做的事情,就是把启动页的展示与隐藏封装成一组跨平台API,让Flutter层能精确掌控隐藏时机,避免原生启动页过早消失造成白屏。
它的核心价值不是帮你画启动页,而是解决“何时隐藏启动页”这个最难处理的状态同步问题。启动页素材、背景色、动画配置仍然放在原生侧;插件只负责在Flutter首帧渲染完成后,向原生侧发送“可以退场了”的指令。理解了这个边界,后面在鸿蒙上的适配思路就顺了。
2. 深入flutter_splash_screen的机制与配置入口
2.1 这个插件究竟“接管”了什么
从职责划分来看,flutter_splash_screen并不负责绘制启动页内容,它只负责两部分:原生启动页的注册配置,以及Flutter首帧后的隐藏回调。启动页本身的图片、背景色、动画都配置在原生工程里;插件只是让Flutter能在正确时机发出“可以隐藏了”的指令。
正常情况下的执行链路是这样:
- App启动后,系统先根据原生资源配置展示启动页;
- Flutter引擎运行到首帧渲染完毕,插件收到Flutter侧的first frame通知;
- 插件调用原生方法,淡出启动页,完成过渡。
这个链路有一个关键前提:原生侧必须能收到Flutter发来的通道消息。在Android和iOS上,这个前提天然成立。但到了鸿蒙的Flutter适配环境里,插件的原生实现并没有被注册到鸿蒙的消息通道上,直接用就会出现MissingPluginException。这不是flutter_splash_screen一个库的问题,而是所有依赖Android/iOS原生代码的三方插件,在鸿蒙上都会遇到的通病。
2.2 原生侧三大配置:启动页素材到底放在哪里
这个库和很多配置类插件一样,需要你在原生资源目录里准备启动页素材。Android这边主要在res/drawable下建launch_background.xml,里面可以配置渐变色背景、居中Logo位图,也可以使用layer-list叠加多个图层,实现文字加Logo的组合效果。iOS这边则是LaunchScreen.storyboard,通过UIImageView加约束保证Logo在不同尺寸上居中不变形。
这两个配置文件决定了启动页长什么样。对一个跨端项目来说,比较稳妥的做法是让Android、iOS、鸿蒙三端的启动页视觉保持一致,包括背景色、Logo比例、间距。之前我见过一个项目,Android启动页和iOS启动页各用各的素材,看起来像两个App,体验就很割裂。
2.3 鸿蒙场景下,插件原生代码的“失效边界”要提前认清
这里必须说实话:flutter_splash_screen的Android/iOS原生实现无法直接在鸿蒙的ArkTS运行时里生效。如果Flutter模块跑在鸿蒙Flutter适配层上,插件的MethodChannel请求会因为原生端没有注册对应处理而失败。所以,在鸿蒙工程里用这个库,第一步就是接受一个事实:我们继续使用它的Flutter侧API来组织代码,但“原生启动页的展示与隐藏”需要换成鸿蒙ArkTS侧自己的实现。
有人问我,那为什么不干脆放弃这个库?我的理由是:业务侧代码结构上flutter_splash_screen的API非常简洁,如果你把原生平台通道指向鸿蒙自定义实现,业务代码几乎不用改;而且团队后续如果继续发布Android/iOS版本,原生的插件逻辑仍然可以复用。迁移期间换掉库,反而要改一遍所有调用点,得不偿失。
3. 鸿蒙混合工程的双层启动链路设计
3.1 三种启动页方案的取舍
我先摆数据说话:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 纯ArkTS启动页 | 实现快,系统级支持好 | Flutter首帧后仍可能有白屏或硬切 | 新项目、无老Flutter包袱 |
| 纯flutter_splash_screen | 跨端API统一 | 鸿蒙原生不支持,需要额外适配 | 已有Flutter项目,尚未上鸿蒙 |
| ArkTS兜底+Flutter接管 | 启动无白屏,过渡可控 | 实现复杂度略高 | 鸿蒙Flutter混合项目,推荐 |
我的建议是第三套。纯ArkTS方案在简单场景够用,但一旦Flutter页面加载完成前原生启动页就收掉,视觉上会闪一下;而纯flutter_splash_screen在鸿蒙上根本跑不通,除非你自己把原生通道补上。双层方案的本质,是让ArkTS负责引擎冷启动阶段,让Flutter负责首帧后的过渡阶段,各管一段,谁也不会出现空窗。
3.2 双层链路的具体时序
我把启动分成四个阶段来设计:
第一阶段,UIAbility启动后立即加载ArkTS的StartUpPage,页面里展示Logo、背景色、入场动画。这个页面必须足够轻量,不能有任何网络请求或复杂计算。
第二阶段,在StartUpPage展示的同一时间,启动Flutter引擎并加载首页。这里要注意,Flutter引擎的创建建议复用已有的单例,避免每次冷启动都重新构建引擎。
第三阶段,Flutter首帧完成后,通过MethodChannel通知ArkTS侧“Flutter已准备好,可以隐藏启动页”。
第四阶段,ArkTS侧隐藏启动页,Flutter侧显示淡入过渡动画,用户平滑进入首页。
这套时序的本质是:用ArkTS页面兜住引擎初始化这段时间,用Flutter的首帧事件决定何时切换。两端的生命周期被一个明确的通道事件串联起来,而不是靠固定延时去猜。
3.3 启动期间的数据预热设计
我习惯把启动阶段的1.8秒充分利用起来。在StartUpPage展示的同一时间,启动一个初始化管理器,预拉远程配置、读取本地缓存、建立必要的数据通道。当Flutter首帧完成时,配置往往已经就绪,首页就不需要白屏转圈了。
具体做法是写一个GlobalInitializer,用标准异步模式跑初始化任务,每个任务都带超时和降级策略。比如拉取远程配置失败时,直接使用本地缓存;本地缓存也不存在时,返回空配置但绝不阻塞首页渲染。这一步能让应用从“启动后可交互”提前到“启动时已就绪”,体感差异非常明显。
4. 实战:在我的鸿蒙Flutter工程里接入flutter_splash_screen
4.1 pubspec里加入依赖并处理版本兼容
安装依赖本身很简单:
dependencies: flutter_splash_screen: ^0.3.0版本号要以pub.dev上实际最新的为准,我这里写的是我曾经用过的版本。真正需要注意的是Flutter版本和鸿蒙Flutter适配版本之间的兼容关系。鸿蒙Flutter适配层一般会滞后于官方Flutter版本,如果你在官方最新Flutter发布后立刻切过去,三方库很容易出现原生编译问题。我当时稳定在某个适配分支上跑了很久,几乎没有遇到插件冲突。
4.2 Flutter侧调用:别在首帧之前隐藏启动页
Flutter侧的调用代码非常简洁:
import 'package:flutter_splash_screen/flutter_splash_screen.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return const MaterialApp(home: HomePage()); } }隐藏启动页的时机放在首页首帧渲染完成之后:
class HomePage extends StatefulWidget { const HomePage({super.key}); @override State<HomePage> createState() => _HomePageState(); } class _HomePageState extends State<HomePage> { @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { FlutterSplashScreen.hide(); }); } @override Widget build(BuildContext context) { return const Scaffold( body: Center(child: Text('首页内容')), ); } }这里有个非常重要的细节:不要跳过addPostFrameCallback直接调用hide。因为首帧还没真正上屏时隐藏原生启动页,用户看到的就是一个空白Scaffold。这是我在这个库上踩的第一个坑,你换个设备多测几次就会明白,白屏闪烁就是这么来的。
4.3 ArkTS侧如何顶替原生启动页
既然鸿蒙不读Android原生启动页资源,那就在ArkTS里做一个同款启动页。在UIAbility的onWindowStageCreate里,先把窗口内容指向启动页组件:
onWindowStageCreate(windowStage: window.WindowStage): void { windowStage.loadContent('pages/StartUpPage').then(() => { windowStage.getMainWindow().then((win) => { win.setWindowBackgroundColor('#FFFFFF'); }); }); }StartUpPage是一个简单的容器组件:
@Entry @Component struct StartUpPage { @State opacity: number = 0; aboutToAppear(): void { this.opacity = 1; } build() { Column() { Image($r('app.media.app_logo')) .width(120) .height(120) .opacity(this.opacity) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .backgroundColor('#FFFFFF') } }这里的重点是:Logo、背景色、入场动画这三样视觉元素,必须和flutter_splash_screen在Android/iOS上的配置保持高度一致。尤其是背景色,如果Android用的纯白、鸿蒙用的浅灰,启动页切换的那一瞬间会产生肉眼可感知的色差。
4.4 用MethodChannel把隐藏启动页的权限交回Flutter
ArkTS页面显示后,不能一直占着屏幕。我在ArkTS侧创建一个通道监听:
import { MethodChannel } from '@ohos/flutter-plugin'; const channel = new MethodChannel('com.example.splash_channel'); channel.setMethodCallHandler((call) => { if (call.method === 'hideSplash') { // 关闭启动页,切换到Flutter容器页面 this.closeSplash(); } });这里只是一个结构示意,具体的通道API以你使用的鸿蒙Flutter适配层为准。Flutter侧同样通过MethodChannel发起调用:
const channel = MethodChannel('com.example.splash_channel'); await channel.invokeMethod('hideSplash');为了不让业务代码散落两套调用逻辑,我把flutter_splash_screen的调用封装了一层服务,在鸿蒙环境下自动切换到自定义通道,在Android/iOS环境下仍然使用插件原生的hide方法。这样团队其他成员永远只需要调用一个SplashService.hide(),换端时改动成本很低。
4.5 启动页到首页的过渡动画处理
最理想的效果是启动页淡出、首页淡入。ArkTS侧隐藏StartUpPage时,先做一个200毫秒的渐隐再销毁组件;Flutter首帧后的首页自身也从一个低透明度过渡到完整不透明。
class _HomePageState extends State<HomePage> with SingleTickerProviderStateMixin { late final AnimationController _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 300), ); @override void initState() { super.initState(); _controller.forward(); WidgetsBinding.instance.addPostFrameCallback((_) { FlutterSplashScreen.hide(); }); } @override Widget build(BuildContext context) { return FadeTransition( opacity: CurvedAnimation(parent: _controller, curve: Curves.easeOut), child: const Scaffold( body: Center(child: Text('首页内容')), ), ); } }这样启动页和首页之间就是双层交叉过渡,不会出现生硬的闪切。动画时长控制在200到300毫秒就好,太长会让用户觉得启动慢。
4.6 为什么不直接纯用ArkTS原生启动页
可能有人会说,都到鸿蒙了直接用ArkTS原生启动页不是更简单吗?为什么还要在Flutter侧做这一层?核心原因在于业务主体在Flutter里。如果首页是Flutter页面,那么原生启动页的收尾时机必须感知Flutter是否真的画出了内容。纯ArkTS方案很难拿到这个信号,只能靠固定延时,延时短了白屏,延时长了发呆。我设计的这个MethodChannel,本质上就是解决这个信息差。
5. 实测中遇到的那些坑与优化建议
5.1 坑一:启动页“一闪而过”还是“停留过久”
这个问题的根因,是隐藏启动页的指令时机不对。如果只监听Flutter引擎初始化完成,不监听首帧,那启动页可能在Flutter页面还没绘制时就消失。我在最开始就是监听引擎就绪事件,结果10台测试机里6台能看到白屏闪烁。
正确做法是双重保险:首帧回调作为触发条件,同时设置一个最小展示时长。
final stopwatch = Stopwatch()..start(); WidgetsBinding.instance.addPostFrameCallback((_) async { if (stopwatch.elapsedMilliseconds < 600) { await Future.delayed( Duration(milliseconds: 600 - stopwatch.elapsedMilliseconds), ); } FlutterSplashScreen.hide(); });为什么不等首帧直接就隐藏?因为如果设备启动极快,首帧200毫秒就完成了,用户几乎看不到启动页,品牌露出时间不够,视觉上会觉得这个应用没有启动页,像直接蹦出来的。加一个最小展示时长,既保证品牌露出,又不会让用户等待。
5.2 坑二:Logo图片模糊与多尺寸适配
启动页Logo如果不准备多套资源,在鸿蒙平板或大屏上会明显发虚。建议准备mdpi、hdpi、xhdpi、xxhdpi至少四套,比例按1:1.5:2:3准备。如果项目资源紧张只有一套图,也要保证它是逻辑分辨率下的2倍图,并且宽度不超过页面宽度的30%。
在ArkTS侧,我习惯用屏幕宽度来约束Logo尺寸,避免在平板上被放大拉伸:
let screenWidth = px2vp(display.getDefaultDisplaySync().width); Image($r('app.media.app_logo')) .width(screenWidth * 0.3)5.3 坑三:冷启动耗时统计与性能优化
建议给鸿蒙工程加一个完整的启动耗时埋点。起点是UIAbility的onCreate,终点是Flutter首帧回调,两端时间差就是完整冷启动耗时。我在测试机上测到的最差情况是2.3秒,其中1.4秒消耗在Flutter引擎初始化上。针对这个瓶颈,我做了三个优化:
- 不在主Isolate里做同步磁盘IO,所有文件读取全部异步化;
- 延迟注册不紧急的插件到首帧之后;
- 图片资源统一走压缩格式,减少首帧解码压力。
优化之后,冷启动降到了1.6秒左右,效果很明显。
5.4 坑四:在鸿蒙上为flutter_splash_screen做“兼容层”
很多团队希望直接在业务代码里使用这个三方库,并不想暴露底层平台判断。我建了一个splash_service.dart作为统一入口:
class SplashService { static Future<void> hide() async { if (isHarmonyOS) { const channel = MethodChannel('com.example.splash_channel'); await channel.invokeMethod('hideSplash'); } else { FlutterSplashScreen.hide(); } } }这样业务代码里永远只有一行SplashService.hide(),底层走原生插件还是走鸿蒙自定义通道,由服务自己决定。不过要提醒一下,isHarmonyOS这个判断在标准Flutter SDK里不一定存在,需要根据鸿蒙Flutter适配SDK提供的设备能力接口来判断。大家在各自工程里验证一下再落定,不要直接抄走。
启动页的优化里还有一个容易被忽略的点:它是一段免费的“品牌曝光窗口”,而不是纯等待时间。我在StartUpPage显示的这段时间里,会预加载用户配置和上次未读完的阅读进度,等首页出现后直接恢复到对应位置。用户感知到的是“启动又快又顺”,而不是“为了看Logo多等了一会儿”。如果你也在做鸿蒙Flutter混合应用,我建议先从一套ArkTS兜底启动页开始跑通,再逐步把Flutter侧接管逻辑加上去,不要一上来就追求复杂动画。先把时序稳定住,后面加什么效果都顺手。