1. 项目背景:为什么一个二手置换App要把闪屏当正经事做
先说这个项目本身的来头。我们团队接的是一个校园和社区场景下的二手物品置换App,核心功能包括闲置信息流、商品发布、站内聊天、交易订单这几块,无论是学生毕业季的旧书、电器转手,还是社区里闲置婴儿车、跑步机的流转,都需要一个稳定、可信的启动体验。
目标设备里有相当一部分是搭载OpenHarmony系统的智能终端,比如教学平板、手写本、某些线下门店的定制设备。这些设备跑不了标准Android生态,但用户确实有使用需求,所以我们的Flutter代码就必须能落到OpenHarmony上。这里说的“Flutter for OpenHarmony”,是指基于社区适配分支把Flutter引擎跑到OpenHarmony系统里,既保留Flutter跨端开发效率,又能覆盖鸿蒙生态设备。
那闪屏为什么值得单独写一篇?因为二手置换App的启动场景和工具类App完全不一样。工具类App打开之后用户可能还会犹豫一下,但交易类App的用户往往带着明确目的,可能是拍照上架闲置,也可能是收到消息进来确认交易,他希望在点击图标的瞬间App是“有反应”的、是“在干活”的。如果冷启动阶段直接黑屏几秒或者白屏闪烁,用户的第一反应就是这个App不靠谱,而交易场景最致命的就是失去信任感。
闪屏的本质,是启动链路里的一个“画面容器”。从用户点击桌面图标,到系统拉起应用进程,再到Flutter引擎初始化、首帧页面渲染完成,这中间有一段几百毫秒甚至更长的等待窗口。闪屏的作用就是把这段真空期用稳定的画面填满,给用户一个心理预期,同时把它作为预加载业务的窗口。在OpenHarmony设备上这个尤为重要,因为引擎初始化的耗时比主流旗舰机明显更长,处理不好,体验差距会非常扎眼。
这篇文章我会从环境搭建说起,把闪屏的两种实现方案做对比,完整贴出我这边落地的核心代码,解释每个关键参数为什么取这个值,最后是实测中踩过的坑和排查思路。如果你也在用Flutter for OpenHarmony做应用启动模块,可以直接对照着改。
2. 环境准备与工程结构:先把地基打牢
2.1 OpenHarmony侧开发环境的几个坑
先说一个容易忽略的大前提:Flutter for OpenHarmony并不是Flutter官方稳定版直接支持的能力,而是需要切换到社区适配分支。我这里用的是基于Flutter 3.7.x的适配版本,搭配OpenHarmony 4.0 Release的SDK来开发。
搭建环境时有几个点必须核对清楚,顺序错了后面全是坑:
- DevEco Studio版本要和OpenHarmony SDK版本对齐。我用的是DevEco Studio 4.0配套的SDK,版本不匹配时最典型的症状是构建时提示API level不识别,而且错误信息往往不直接指向版本问题。
- Flutter SDK必须切换到适配分支,而不是官方stable分支。区分方法很简单,在终端执行
flutter --version,看渠道信息里有没有ohos相关的标识。没有的话,说明SDK没有切对。 - 签名配置必须手工处理。OpenHarmony应用安装到真机前需要签名,而且它不等同于Android的签名机制。开发阶段要进入DevEco Studio的自动化签名面板,生成之后还要在Flutter工程侧确保
ohos目录里的签名配置引用正确。
我当时卡得最久的点就在签名上,一直报signature verification failed,排查了大半天,最后定位到是DevEco自动生成的签名profile没有同步到命令行构建的配置里。解决办法是在ohos/entry/build-profile.json5里手动指定签名storeFile和profile文件的路径,确保命令行和IDE共用同一套签名配置。
2.2 创建工程与目录定位
环境就绪后创建工程,命令如下:
flutter create -e hs -t app swap_app注意-e hs这个参数,只有适配分支的模板才支持,生成结果是带ohos目录的Flutter工程。如果你执行之后发现没有ohos目录,大概率是模板文件缺失,可以去适配仓库手动拉取ohos目录并配置对应的配置文件,但不推荐这么干,最好还是用模板生成,因为手工补目录非常容易漏掉native侧的构建脚本。
工程生成后,闪屏相关的内容主要分布在两个区域:
lib/目录:Flutter侧的逻辑代码,包括闪屏页Widget、路由配置、动画控制、启动数据预加载等。ohos/entry/src/main目录:OpenHarmony原生侧配置,包括Ability的启动窗口、资源目录、图标文件等。
理解这两个区域的边界很重要,我一开始就没分清,结果把原生的启动图资源放到了Flutter侧加载,等到真机运行发现闪屏出现的前后顺序完全错位,才意识到这里有两套资源系统,各有各的复用时机。
2.3 两个阶段的衔接逻辑
实际开发中,我把闪屏明确拆成了两段,这样每一段的职责都很清晰,排查问题时不会互相甩锅。
第一段是OpenHarmony原生启动窗口,它覆盖的是从系统拉起进程到Flutter引擎完成初始化的时间。这一段的页面由原生侧资源定义,不需要Flutter参与,技术上是Activity/Ability启动后、loadContent之前显示的系统启动窗口。
第二段是Flutter侧的路由闪屏页。当Flutter引擎完成渲染后,我不会直接进入首页,而是先加载一个全屏的Splash组件,利用这段可视时间做本地缓存的读取、登录态恢复、首页顶部数据的预取,然后通过可控的路由切换进入主框架。
这样划分之后,原生侧不需要写业务,Flutter侧不依赖原生资源,两边不会打架。用户看到的画面是连续的:冷启动原生闪屏先出现,紧接着Flutter闪屏接管,虽然是两套实现,但视觉上是一整段完整的过渡。
3. 闪屏实现方案的选型与取舍
3.1 三条路线摆在一起看
在动手写代码之前,我先把市面上常见的闪屏实现方式列了一遍,结合我们这个项目的实际情况做选择。大致有三条路:
方案A是纯原生静态闪屏。在OpenHarmony原生侧配置一张闪屏图,延迟指定时间后进入Flutter页面。这个方案实现最简单,但问题在于:延迟是写死的,用户看到的闪屏时间和实际初始化耗时并不一定吻合,经常出现闪屏已经消失但首页还没准备好的尴尬。而且静态图没有任何品牌交互感,对交易类App来说过于生硬。
方案B是原生动画闪屏。在原生侧用系统动画能力实现Logo放大、位移等效果,再用启动页Intent跳转到Flutter。优势是启动阶段画面表现力强,动画在原生侧执行,不占用Flutter引擎时间;劣势是开发和调试成本高,动画参数每次调整都要重新构建整个应用,对快速迭代的业务来说不太友好。
方案C是原生静态占位加Flutter侧动态闪屏。原生阶段只放一张简单的品牌底图和Logo,保证启动瞬间不白屏;等Flutter引擎起来之后,由Flutter路由加载真正的Splash页面,在页面里做动画、做数据预取、做动态跳转逻辑。
3.2 资源占用与开发效率的权衡
三条路线的区别,本质上是在“原生侧能做的”和“Flutter侧能做的”之间做资源分配。
方案A最省事,但可扩展性几乎为零。如果后端下发一个活动配置,想临时在闪屏上加一句运营文案,走原生静态方案就得发版。方案B看起来华丽,但对OpenHarmony适配分支来说原生动画的API稳定性存疑,我在调研阶段就看到不少开发者反馈鸿蒙侧动画接口在某些设备上有兼容问题,我不想在启动路径上引入这种不确定性。
方案C把核心逻辑收敛到Flutter侧,因为Flutter代码本身是跨端的,动画参数调整走热重载就能看到效果,不需要反复构建原生工程。而且闪屏阶段恰好是Flutter路由系统已经可用的阶段,数据预加载、跳转逻辑这些都可以用一套代码实现,代价仅仅是需要多维护一个Splash Widget。这个代价完全可接受。
3.3 最终结论:Flutter侧Splash加原生静态占位
最后我选了方案C。具体配置上,原生启动页只保留一个品牌绿色背景加Logo居中,不做任何动画;Flutter侧Splash页面负责完整的品牌展示动画、业务初始化进度提示、以及路由跳转。
这套组合有三个好处。第一,启动阶段永远有画面,不管系统初始化耗时是300毫秒还是1秒,用户都不会看到黑屏或白屏。第二,后续想调整动画效果或者接入运营配置,只需要改Flutter代码,开发效率高。第三,即便原生侧出了问题,Flutter侧闪屏依然可以兜底,不阻断启动流程。
4. 核心代码实现与参数详解
4.1 SplashPage与路由的注册方式
Flutter侧的Splash页我按照常规的StatelessWidget加动画控制器来实现。为了不把路由配置散落各处,我在应用入口处统一注册了路由表:
void main() { runApp(const SwapApp()); } class SwapApp extends StatelessWidget { const SwapApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: '置换淘', theme: ThemeData( primaryColor: const Color(0xFF1B812A), ), initialRoute: '/splash', routes: { '/splash': (context) => const SplashPage(), '/main': (context) => const MainTabPage(), '/login': (context) => const LoginPage(), }, onUnknownRoute: (settings) { return MaterialPageRoute( builder: (context) => const NotFoundPage(), ); }, ); } }这里有两个必须留意的细节。一个是initialRoute必须指到/splash,不能直接进/main,否则闪屏阶段被跳过,用户会直接面对一个等待中的首页,体验很差。另一个是路由表里要把登录页也注册上,因为闪屏阶段如果发现登录态失效,需要无缝跳转到登录页,不提前注册会导致黑屏报错。
SplashPage本身的骨架如下:
class SplashPage extends StatefulWidget { const SplashPage({super.key}); @override State<SplashPage> createState() => _SplashPageState(); } class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateMixin { late final AnimationController _controller; late final Animation<double> _fadeIn; // 人为延迟保底时间,整体最短显示时长 static const int _minSplashMillis = 1800; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 900), ); _fadeIn = CurvedAnimation( parent: _controller, curve: Curves.easeOut, ); _initialize(); } Future<void> _initialize() async { final Stopwatch watch = Stopwatch()..start(); // 并发执行业务初始化任务 final results = await Future.wait([ _loadLocalCache(), _restoreLoginState(), _preloadHomeData(), ]); // 如果任务提前完成,则等待剩余的最小展示时长 final elapsed = watch.elapsedMilliseconds; final remain = _minSplashMillis - elapsed; if (remain > 0) { await Future.delayed(Duration(milliseconds: remain)); } if (!mounted) return; _controller.forward().then((_) { _goNext(results); }); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFF1B812A), body: FadeTransition( opacity: _fadeIn, child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // 品牌Logo组件,大小按屏幕宽度比例控制 ], ), ), ), ); } }4.2 双状态驱动:初始化进度与动画解耦
闪屏页面最容易写崩的地方,是把业务初始化和动画时序搅在一起。比如让Logo动画等登录态恢复,或者反过来让路由跳转等动画播完,两件事一耦合,页面就难调试了。
我在设计上把两个状态彻底分开。业务初始化状态由Future驱动,决定了“什么时候可以离开闪屏页”;动画状态由AnimationController驱动,决定了“离开闪屏页时的视觉过渡怎么做”。这两者互不阻塞,只通过最终的路由跳转动作汇合。
这样做的好处从实际调试中就能感觉到。如果发现登录态接口响应慢了,我只要看_loadLoginState()的执行时间,就能定位是不是网络原因;如果发现Logo缩放动画卡顿,我只需要调整动画时长参数,完全不用碰数据逻辑。对于二手交易这种依赖登录状态的App来说,这个解耦结构让后期排查效率高了很多。
4.3 品牌Logo与文字的动画出场
二手置换App的视觉调性偏生活化和绿色环保,所以闪屏上我用了一个圆角方框Logo、一句品牌Slogan和一个底部版权小字。动画上做了两层:
第一层是Logo的透明度渐入,时间设在0到600毫秒之间,曲线用easeOut,让Logo从完全透明到完全可见,不会太跳跃。
第二层是Slogan文字的轻微位移动画,从下往上偏移20像素的同时透明度从0变到1,时间设置在300到900毫秒窗口,比Logo晚一点出现,形成视觉层次感。参数上我特意把位移控制在20像素,太大会显得浮夸,太小则没有可感知的动效。
动画的实现并不复杂,就两个AnimationController同时驱动不同区间的动画,或者用一个控制器配合Interval分段控制。实际项目里我推荐后者,因为控制器数量太多会拉高UI线程负载,尤其是低端OpenHarmony设备。用Interval可以省资源,也更方便统一管理。
final Animation<double> _logoOpacity = Tween<double>( begin: 0, end: 1, ).animate( CurvedAnimation( parent: _controller, curve: const Interval(0.0, 0.6, curve: Curves.easeOut), ), ); final Animation<double> _sloganOpacity = Tween<double>( begin: 0, end: 1, ).animate( CurvedAnimation( parent: _controller, curve: const Interval(0.3, 1.0, curve: Curves.easeOut), ), ); final Animation<double> _sloganOffset = Tween<double>( begin: 20, end: 0, ).animate( CurvedAnimation( parent: _controller, curve: const Interval(0.3, 1.0, curve: Curves.easeOut), ), );4.4 页面切换时机与延时计算
关于闪屏停留多久这个问题,我的结论是:不要写死一个固定延时,而要用“业务初始化耗时+最小展示时间”的组合逻辑。
原因是纯写死延时会出两种状况。如果延时短于业务初始化,页面跳过去才发现数据还没准备好,首页会白屏再加载一次,极其影响观感;如果延时长于业务初始化,用户会对着静止页面干等,同样浪费体验。
我最终采用的方案是“最短展示时长+任务完成时间”取最大值。代码里设置_minSplashMillis = 1800,表示闪屏至少展示1.8秒。业务初始化任务并发执行,耗时短则等待剩余时间,耗时超长则相应延长显示时间。这个策略可以保证大部分用户看到的闪屏时长稳定在1.8到2.5秒区间,不会太短显得仓促,也不会太长让用户着急。
在跳转逻辑里,我用了一个_goNext方法来承接初始化结果的判断:
void _goNext(List<dynamic> results) { final bool loginValid = results[1] as bool; if (!mounted) return; Navigator.of(context).pushReplacementNamed( loginValid ? '/main' : '/login', ); // 参数为true时,跳过当前页面的路由动画,让切换更干脆 }这里选择的跳转方式是pushReplacementNamed而不是pushNamed,两者的差异很关键。如果使用pushNamed,闪屏页会保留在路由栈中,用户从首页按返回键时会重新回到闪屏页,于是出现“按返回键看到了启动画面”这种很蠢的状态。pushReplacementNamed会把闪屏页直接替换掉,保证路由栈里只有主页面,行为上更干净。
5. 适配要点与品牌元素处理
5.1 不同屏幕尺寸下的Logo适配
OpenHarmony设备的屏幕尺寸跨度很大,有7寸平板、10寸手写本,也有相对较小的学习机。如果Logo固定用像素值写死,小屏大屏会出现明显的视觉比例失调。
我采用的方案是用MediaQuery获取屏幕宽度,按宽度比例计算Logo尺寸:
final double screenWidth = MediaQuery.of(context).size.width; final double logoSize = screenWidth * 0.24;控制Logo在屏幕宽度的24%左右,其次是圆角矩形内部的图标,约束整体视觉占比不超过屏幕高度的20%。这个比例的来源是常见移动端闪屏的视觉标准,太大容易造成压迫感,太小品牌元素显得过于单薄。
平板和手机共用一套代码,但动画时长我做了区分。平板设备因为屏幕更大,元素运动路径更长,我把动画时长从900毫秒调整到1100毫秒,视觉上更从容一些。
5.2 深浅色模式与品牌色的统一
二手置换App的品牌色是绿色,闪屏底色也是绿色。在浅色模式下图没问题,但在深色模式下如果直接沿用系统默认背景,绿底上的Logo可能被深色背景吃掉,层次全无。
我的处理方式是:闪屏页强制固定背景色,不做深浅色自适应。因为闪屏页的生命周期极短,用户停留时间在2秒左右,强行适配深色模式没有实际意义,反而容易因为主题切换导致闪屏颜色不一致。只需在Scaffold的backgroundColor里显式指定品牌色即可,同时把文字颜色固定为白色,避免依赖主题默认色。
5.3 文案与字体的动态化
对于交易类App,闪屏文案有可能随着运营活动变化,比如毕业季会换“毕业好物,带不走就留下”,社区活动时换成“邻里好物,闲置换新”。我在设计上把文案放在一个独立的常量配置文件里,不硬编码在SplashPage中:
class SplashConfig { static const String slogan = '毕业好物,带不走就留下'; static const String subTitle = '校园社区闲置置换平台'; static const String logoAsset = 'assets/images/logo.png'; }这样运营同学提需求的时候,前端只需要改配置文件重发版本,不用在代码里大海捞针。字体方面要注意OpenHarmony设备的中文字体库和Android不完全一致,部分低端设备可能没有理想字体文件,所以闪屏文案不建议用太细的字体,避免低分辨率屏幕上发虚。
6. 实测问题与排查记录
6.1 低端设备上的闪屏卡顿
问题现象是在某款低配手写本上,Logo渐入动画明显掉帧,看起来一卡一卡的,完全没有预期的平滑过渡。
排查思路分两步走。第一步先确认卡顿源,我打开Flutter的performance overlay,发现闪屏页UI线程的帧耗时有时候冲到40多毫秒,明显超过16毫秒的预算。第二步检查代码,发现是两个动画控制器同时运行,并且动画过程中还叠加了MediaQuery.of(context).size的重复读取,导致每次build都重新查一次屏幕参数。
解决方法是合并控制器,用Interval分段控制不同元素的动画;同时把屏幕宽度缓存到State字段里,只在初始化时读取一次。优化后帧耗时稳定在10毫秒左右,卡顿消失。
6.2 路由跳转时首页空壳闪现
闪屏结束后进入首页的瞬间,页面会闪一下空白框架,底部导航栏看起来已经存在,但内容区域还没有数据。这个问题在低端机上尤其明显,我一度以为是首页数据加载太慢,后来定位才确认是路由是否带过渡动画导致的。
默认的路由切换动画是淡入淡出加滑动,在低端机上动画执行期间首页已经开始build,但数据还没到位,于是产生了“空壳”视觉。解决方式是把闪屏到首页的路由过渡调整为无动画替换:
Navigator.of(context).pushReplacementNamed('/main');去掉动画后,首页直接在原页面之上替换显示,没有任何过渡帧,空壳问题不再出现。代价是切换会略显生硬,但这个生硬感相对于空壳闪现来说可以接受。
6.3 横屏设备下闪屏拉伸
某款横屏使用的设备在启动时,闪屏画面被明显拉伸,Logo从圆形被拉成椭圆。原因是该设备默认横屏,但原生启动窗口的资源配置里没有提供横屏适配的启动图,系统只能拉伸竖屏启动图来填充。
修复方式是分两步:第一步在原生侧的资源配置目录里,新建横屏的启动图资源,图片内容保持居中Logo,背景色与竖屏一致;第二步在Flutter侧通过MediaQuery检测方向,动态调整Logo的圆角修饰和间距比例。目前的设计上不强制锁定竖屏,让系统自动适配。
6.4 热重载与闪屏态的重置
开发过程中还发现一个非常影响效率的问题:使用热重载调试闪屏后的页面时,偶尔会遇到状态错乱,比如闪屏已经结束,但调试工具的断点仍然停在初始化逻辑里。
这是因为闪屏页的_initialize()是异步方法,热重载时会重新执行initState,旧异步任务可能还没取消,新的初始化逻辑又开始了,两个流程互相覆盖。解决方式是在控制器销毁时取消未完成的任务,并且给初始化逻辑加一个token:
int _generation = 0; Future<void> _initialize() async { final int gen = ++_generation; final results = await Future.wait([...]); if (gen != _generation) return; // 已经过期,放弃后续操作 ... }这样每次重新初始化都会让旧任务失效,不会出现闪屏逻辑交叉执行导致的诡异问题。
7. 一些收敛下来的项目心得
做完这套闪屏,我自己最大的感触是:闪屏不是“做个图然后等两秒”那么简单,它的核心是启动链路的编排。原生侧占位、Flutter侧动画、业务预加载、路由切换时机,要在一个微小的时间窗口里全部协作好,才算真正把闪屏做好。
如果你是第一次在Flutter for OpenHarmony上做这个模块,有几个点值得最后再提一次。第一,闪屏的视觉和逻辑要解耦,别让动画给业务加载让路,也别让业务把动画卡死。第二,最小展示时间不要写死,而是用“业务耗时+最小值”的组合,才能适配不同设备的性能差异。第三,热重载调试闪屏时务必处理异步任务的竞态,否则排查问题会非常痛苦。
这个模块后续我打算接上服务端下发的闪屏配置,比如根据用户所在校园或社区展示对应的活动文案,再配合首页信息流的预加载做数据预热,把启动阶段真正变成业务的抢跑时间。到时候再回来分享一波实盘数据。