☰
Android闪屏页从零实现:冷启动白屏优化与启动流程详解
2026/9/30 6:18:43 网站建设 项目流程

上次搭建好工程之后,后台收到不少留言,问得最多的就是“点开App之后那张图是怎么蹦出来的”“为什么别人的App启动有一张广告页,我的啥也没有”。这些问题其实都指向同一个东西——闪屏页。这篇就专门把它讲透。

这篇适合两类人看:一是刚用Android Studio建完第一个空项目,还不知道下一步该写什么的新手;二是已经能写页面,但想把启动流程、冷启动白屏、转场动画这些细节理清楚的开发者。闪屏页虽然看着简单,但背后牵扯到主题切换、任务栈、线程管理一套逻辑,搞明白它,你对整个App启动机制的理解会上一个台阶。

1. 闪屏页不只是“一张图”,先想清楚它到底在解决什么问题

1.1 闪屏页的三大职责

很多人以为闪屏页就是放个Logo撑几秒,其实它承担的任务比表面看到的要重要得多。

第一是品牌展示。从用户点击桌面图标到第一个页面完全渲染出来,这中间有几百毫秒甚至更长的空白期。如果什么都不做,用户看到的就是一块白屏或者黑屏,体验非常糟糕。闪屏页在这段时间里充当视觉过渡,让用户感觉App“秒开”了。这也是为什么很多大厂的启动页要么是副精致的插画,要么是句朗朗上口的Slogan。

第二是初始化时机。你要想想,一个App启动之后通常要做什么?初始化推送SDK、检查更新、加载本地配置、判断是否登录、拉取首页数据……这些操作如果全部塞在第一个页面里做,页面会卡到没法看。闪屏页的存在给了这些初始化操作一个“缓冲地带”,它在展示品牌的同时,后台可以并行干活。

第三是业务入口。现在很多App的闪屏页同时承担广告位、活动入口的功能,点击闪屏页可以跳到对应的详情页或者H5页面。就算你现在不接广告SDK,在设计结构的时候也要预留这个能力,否则后面想加广告,代码得推倒重来。

1.2 两种实现方案,为什么我推荐独立Activity

闪屏页的实现方式业内主要有两种。

第一种是在MainActivity里用View或者Fragment做闪屏效果。简单是简单,但是问题很多:主页面代码会越来越臃肿,想加广告逻辑、埋点逻辑都往里面塞;而且用户在Android最近任务列表里看到的永远是那个主页面,点击回到App直接进主界面,闪屏页的展示时机和任务栈行为都不好控制。

第二种是单独开一个SplashActivity作为启动入口,展示完再跳转到MainActivity。这也是我推荐的做法,核心原因是职责单一。SplashActivity只负责启动展示和初始化调度,MainActivity只负责主界面业务,两者互不干扰。后续你想把闪屏页换成视频广告、加倒计时跳过按钮、或者接入第三方广告SDK,都只需要改SplashActivity一个文件,风险可控。

另外还有一点很多人忽略:独立Activity天然适配了Android的启动机制。Android系统在冷启动时会先创建启动Activity,再等待界面渲染。如果启动Activity是一个纯粹的闪屏页,你可以在它的onCreate里做轻量初始化,等界面准备好再跳转,流程非常自然。

1.3 动手之前先定好三个交互细节

闪屏页的交互决策直接影响用户感受,这几个细节我在设计的时候花了比较多心思。

停留时长。我见过很多App一上来闪屏页卡5秒,用户还以为手机死机了。主流做法是固定停留2到3秒,保证用户能看清品牌信息又不会觉得烦躁。如果闪屏页要等某些初始化任务完成才能进主页,就用“最大停留时间+任务完成回调”的双保险策略,也就是任务没做完最多等3秒,做完了就立刻进主页,两不耽误。

点击跳过。闪屏页上有广告或者用户急着用App的时候,必须提供跳过入口。这里有个细节:跳过按钮不要一上来就显示,最好过1秒再出现,给品牌展示留一点最低时间,同时也防止用户误触。倒计时数字显示“3、2、1”或者直接显示“跳过广告”,看产品需要。

返回键行为。闪屏页上点返回键到底退不退App?我的建议是:如果在闪屏阶段,点击返回键直接退出App;如果已经进入倒计时后期或者任务完成,就按正常流程跳转。千万不要出现“返回键退出了闪屏页却又跳进主页”的诡异现象,用户会以为App出bug了。

2. 冷启动如何不白屏:主题、窗口、注册顺序全要配合

2.1 新项目创建时怎么选语言和SDK版本

闪屏页技术上没有任何难点,但冷启动阶段的配置如果搞错了,白屏问题会一直阴魂不散。我先把基础工程说清楚。

用Android Studio新建项目的时候,语言建议选Kotlin,这是目前的主流也是Google官方主推,网上能找到的参考资料最多。当然你写Java也一样能实现,只是后续查资料的时候会觉得有点脱节。

Minimum SDK的选择上,如果只是学习实战,选API 24或者API 26都行,覆盖面已经非常广。如果你的项目要上架,再去根据应用市场的覆盖数据调整。这里多说一句:Android 12(API 31)开始系统强制走了启动画面机制,跟旧版的闪屏页实现方式有差异,后面专门用一节来讲,你先记着这个知识点。

2.2 白屏的根源:默认主题和启动背景没配合好

新建工程默认用的主题是系统Theme,Android系统在冷启动时,会先用主题里的windowBackground画一个窗口背景,然后再去加载你的Activity布局。如果你没做任何处理,windowBackground就是白色(或黑色),那么从点击图标到Activity布局加载完成之间,用户看到的就是一块纯白或者纯黑的画面——这就是传说中的“白屏”。

解决方案是给闪屏页单独定义一个主题,把windowBackground设置成你的启动背景图,这样系统在画窗口背景的时候就直接画你的图了,视觉上做到了无缝衔接。

我在工程里是这么配的。先建一个启动主题,放在values/themes.xml里:

<style name="Theme.Splash" parent="Theme.AppCompat.DayNight.NoActionBar"> <item name="android:windowBackground">@drawable/splash_background</item> <!-- 如果你的AppCompatActivity依赖了AppCompat主题,这行配合使用 --> <item name="windowActionBar">false</item> </style>

splash_background是什么呢?我习惯用layer-list来做,因为比直接放一张全屏图更灵活,也方便适配不同分辨率。比如让背景色是品牌色,中间放一个Logo:

<?xml version="1.0" encoding="utf-8"?> <layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <!-- 底层用品牌色填充全屏 --> <item android:drawable="@color/brand_blue" /> <!-- 中间层放Logo,做了上下偏移让视觉重心更舒服 --> <item android:gravity="center" android:top="80dp" android:bottom="80dp"> <bitmap android:src="@drawable/ic_logo" android:gravity="center" /> </item> </layer-list>

这样配置完之后,冷启动的阶段系统画的就是这个背景,不会闪白。等SplashActivity布局加载完成后,才切到真正的布局。如果你发现启动的时候闪了一下白再显示图片,十有八九是主题没配好,或者windowBackground和Activity布局设置了不同的背景色。

2.3 AndroidManifest里注册成启动入口,同时设置noHistory

接着在AndroidManifest.xml里注册SplashActivity。关键点是让它成为LAUNCHER,另一个值得注意的参数是android:noHistory。

<activity android:name=".SplashActivity" android:exported="true" android:theme="@style/Theme.Splash" android:noHistory="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity>

noHistory的作用是:当用户从闪屏页跳到主页面之后,SplashActivity会从任务栈中移除。这样用户按返回键时,不会退回到已经展示过的闪屏页,而是直接退到桌面。不设置这个属性的话,你需要手动在跳转后用finish()把SplashActivity结束掉,两种方式都可以,但既然系统提供了这个机制,我建议直接用,少写一行代码是一行。

这里还有一个细节,android:exported必须设为true,否则有些Android版本在安装或者启动的时候直接报错。这是Android 12之后的新要求,启动入口Activity必须能被外部系统组件调用,所以exported必须为true。

2.4 闪屏页布局是不是需要写Logo、文字、版本号?

很多人纠结闪屏页布局里到底放什么。我的经验是:背景图那部分交给主题的windowBackground,而布局里只放那些需要动态控制的元素。

比如说版本号,你希望它显示在右下角的小角落,数值从BuildConfig里读出来。再比如跳过按钮,它需要在1秒后动态出现,那就必须在布局里。如果你的Logo要加一个放大渐入的动画效果,也得放到布局里才好控制。

一个非常典型的差错是:背景和布局的内容重叠了。比如windowBackground已经放了Logo,布局里又放了一个Logo,启动的时候Logo就叠影,或者闪一下。我一般遵循这样一个原则:静态内容全部放windowBackground,动态内容才放Activity布局,各管各的,互不重叠。

3. SplashActivity从零实现:倒计时、跳转、转场动画一步到位

3.1 布局文件:竖屏锁定、深色模式适配和视觉元素

SplashActivity的布局不宜复杂,它是给用户“第一眼”的印象,要干净、克制。我通常就放三样东西:居中的标题或者品牌文案(可选)、右下角的版本号、右上角的跳过按钮(初始隐藏)。

把屏幕方向锁成竖屏,避免转屏导致闪屏页重新创建:

<activity android:name=".SplashActivity" android:screenOrientation="portrait" ... />

深色模式的问题也值得注意。如果App主题用了DayNight模式,那么windowBackground可能会随着系统切换深色/浅色而变化。为了避免深色模式下闪屏页背景被系统换成黑色,我为闪屏页单独定义了values-night里的对应资源。最简单的做法是:闪屏页主题的windowBackground始终用固定的品牌色背景,不跟随模式变化。在values-night目录下的themes.xml里,把Splash主题的windowBackground覆盖成同一张图的深色版本,或者保持同一个drawable,都可以。具体看你想要的效果。

布局代码大致长这样:

<?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/brand_blue"> <TextView android:id="@+id/tv_slogan" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerInParent="true" android:text="欢迎来到我的App" android:textColor="@android:color/white" android:textSize="22sp" android:textStyle="bold" /> <TextView android:id="@+id/tv_version" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentEnd="true" android:layout_alignParentBottom="true" android:layout_marginEnd="16dp" android:layout_marginBottom="20dp" android:textColor="@android:color/white" android:textSize="12sp" android:text="v1.0.0" /> <TextView android:id="@+id/tv_skip" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentEnd="true" android:layout_alignParentTop="true" android:layout_marginEnd="16dp" android:layout_marginTop="16dp" android:padding="10dp" android:visibility="invisible" android:text="跳过 3" android:textColor="@android:color/white" android:textSize="14sp" /> </RelativeLayout>

3.2 核心跳转逻辑:Handler延迟、协程、还有怎么处理内存泄漏

SplashActivity的核心逻辑其实就三件事:倒计时、判断跳转目标、结束自己。

我第一次写闪屏页的时候图省事,直接在onCreate里用Thread.sleep做延迟,结果不仅界面卡住,倒计时也不显示,后来才明白不能在主线程里阻塞。正确做法是使用Handler.postDelayed或者协程延迟。

Handler的写法是入门最简单直接的,代码如下:

class SplashActivity : AppCompatActivity() { private lateinit var tvSkip: TextView private val handler = Handler(Looper.getMainLooper()) private var isFinishingJob = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_splash) tvSkip = findViewById(R.id.tv_skip) // 中间有个1秒后显示跳过按钮的逻辑,用postDelayed实现 // 主流程延迟2秒跳转 handler.postDelayed({ enterMainPage() }, 2000L) } private fun enterMainPage() { if (isFinishingJob) return isFinishingJob = true // 这里实际项目中可能会根据是否登录跳转不同页面 val intent = Intent(this, MainActivity::class.java) startActivity(intent) overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out) finish() } override fun onDestroy() { super.onDestroy() // 移除回调,防止内存泄漏 handler.removeCallbacksAndMessages(null) } }

这个写法有一个非常经典的坑:Handler持有Activity引用导致内存泄漏。非静态内部类或者Lambda会在延迟任务未执行时引用Activity,如果这时Activity被销毁了(比如用户按了返回),任务还在队列里,Activity就无法被回收。

我常用的处理方式有三种:

  • 在onDestroy里调用handler.removeCallbacksAndMessages(null),把还没执行的任务清掉。
  • 把延迟跳转任务封装成静态内部类,用WeakReference引用Activity。
  • 直接改用Kotlin协程,协程的取消机制天然规避了这个问题。

既然项目用了Kotlin,我建议完全用协程实现,代码看着也清爽:

class SplashActivity : AppCompatActivity() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_splash) scope.launch { // 1秒后显示跳过按钮 delay(1000L) tvSkip.visibility = View.VISIBLE // 模拟倒计时:把“跳过 3”改成“跳过 2”“跳过 1” for (i in 3 downTo 1) { tvSkip.text = "跳过 $i" delay(500L) } enterMainPage() } } private fun enterMainPage() { ... } override fun onDestroy() { super.onDestroy() scope.cancel() } }

在这里我还要强调一个细节:进度条、跳过数字这些UI更新必须切回主线程。协程里我们把scope定义在Dispatchers.Main,所以UI更新是安全的;如果用Handler,也要确保postDelayed的回调跑在主线程Looper上。

3.3 从闪屏页到主页面的转场:淡入淡出还是硬切?

闪屏页跳主页面默认的动画往往是从右往左滑入,那是Android标准的Activity切换动画。但闪屏页转主页面更适合用淡入淡出,因为闪屏页本身就是一张静态图,淡出到主界面相比滑动更柔和,视觉上也没有那么突兀。

上面代码里的overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)就是在跳转的同时设置进入动画和退出动画。前一个参数是主页面进入的动画,后一个参数是闪屏页退出的动画。这段代码一定要写在startActivity和finish之间,或者紧跟其后,顺序不对动画可能不生效。

如果你想做得更有质感一点,可以给Logo加一个从透明到不透明、从小到大的动画。不过我的经验是:闪屏页动画别做得太复杂,更不要让用户等待动画播完才能操作。Logo渐入控制在600毫秒以内比较舒服,而且不要阻塞跳转逻辑。动画和跳转是并行关系,不是串行关系,否则用户会感觉App慢。

3.4 根据是否登录决定跳转目标,提前为项目扩展铺路

如果你在做一个需要登录的App,闪屏页还有一个职责:判断用户是不是登录过,进而跳转到登录页还是主页。

最简单的方案是用SharedPreferences存一个标记位。在闪屏页的enterMainPage方法里,读取本地标记:

private fun enterMainPage() { val isLogin = PreferenceUtils.isLogin(this) val targetClass = if (isLogin) MainActivity::class.java else LoginActivity::class.java val intent = Intent(this, targetClass) startActivity(intent) overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out) finish() }

这个判断也可以放到主页面的onCreate里做,但放在闪屏页的好处是转场动画只播一次,体验更顺畅。等以后项目引入了路由框架或者单Activity多Fragment的架构,这里的跳转逻辑可以替换成路由跳转,整体思路不变。

提示:如果闪屏页需要调用隐私合规相关的弹窗(比如隐私政策协议弹窗),流程会复杂一些。现阶段先不做,等以后项目涉及合规的时候再回来补充一个版本。你要知道闪屏页是很多合规逻辑的入口,这就够了。

3.5 跳过按钮一点就进主页:注意移除未执行的任务

跳过按钮的实现比想象中容易踩坑。

首先,点击事件里调用enterMainPage(),但由于Handler里还有一个延迟2秒的任务没执行,进主页后这个任务还会触发一次enterMainPage(),这就导致MainActivity被创建两次。解决办法就是我在上面代码里加的isFinishingJob标记位,进入主页前先判断,如果已经跳转过了就直接返回,避免重复跳转。

如果用协程版本,也可以在onDestroy里取消协程,或者在跳转前判断isActive。我还是那句话:标记位+取消回调双保险,写代码别太依赖某一种机制,实际项目里情况千变万化,多点防御总没错。

另外,跳过按钮1秒后才可见,但这个延时任务如果没执行,或者执行完显示按钮后又产生了新的任务,都要考虑清楚。我的习惯是把所有延迟任务都放在同一个协程scope或者Handler实例里,这样取消的时候可以一把清,不会漏掉任何一个。

4. 那些我排查过的闪屏页疑难杂症

4.1 为什么点了图标屏幕白了几秒才显示内容

这一个问题每天能在各种技术群里看到。原因基本都是主题没有被正确配置,或者SplashActivity没有引用Splash主题。

排查路径很简单,三步走:

  • 打开AndroidManifest.xml,看SplashActivity的theme是不是指向了自定义启动主题。
  • 确认自定义主题里windowBackground是有效的drawable资源,并且这个drawable本身没有语法错误。
  • 确认SplashActivity的布局文件里没有设置完全不同的背景色,覆盖了windowBackground的效果。

如果检查完还有白屏,试一下在SplashActivity的onCreate里setContentView之前调用setTheme(R.style.Theme_Splash),强制应用启动主题。有些时候父主题被全局替换导致子Activity主题没生效,这个办法能救急。

4.2 退出闪屏页后内存泄漏怎么查

内存泄漏最常见的就是Handler和Activity的生命周期不一致。尤其是你用了自定义Handler,又在postDelayed里写了耗时操作,稍微处理不当,Activity销毁后这个Handler还在引用它。

简单的自查方式:在SplashActivity的onDestroy里加一行日志,然后反复进出App,看logcat里有没有“Leaked activity”的提示,或者用Android Studio的Profile工具看Memory。最干脆的做法还是我上面说的:要么onDestroy里removeCallbacksAndMessages,要么协程版本里scope.cancel()。

还有一个很多人忽略的问题:如果闪屏页里初始化了第三方SDK,第三方SDK内部可能持有Activity引用。解决办法是在onDestroy里调用对应的反注册方法。

4.3 按返回键退出后,重新打开App看到了主页而不是闪屏页

这个问题非常经典,原因是SplashActivity已经设置了noHistory="true",用户按返回键退出App时,SplashActivity已经被移出任务栈。再次点击App图标,系统恢复的是之前未销毁的MainActivity任务栈,所以直接就看到了主页。

如果你希望再次进入App还是先看闪屏页,有两个方向:

  • 不要使用noHistory,改成在enterMainPage里finish()。
  • 在MainActivity的onCreate里判断是从哪里来的,如果不是从闪屏页跳转过来的就重定向到闪屏页。

但我个人认为,从用户体验来说,回到App直接进主页反而更合理。用户已经付过一次启动成本了,没道理让你每次进App都看广告闪屏。所以这个问题的解决方式,取决于产品策略,不是技术bug。

4.4 倒计时还没结束,App切到后台再回来,跳转乱了

闪屏页倒计时期间,用户把App切到后台睡了一会儿,再切回来,发现跳转到主页了,但是主页参数丢了,或者闪屏页已经finish了,界面卡在半空中。这种场景做闪屏页的时候必须考虑。

我采取的方案是:利用Activity的onSaveInstanceState和onRestoreInstanceState保存状态,或者干脆在onStop/onPause里把跳转任务取消掉,回到前台如果还在闪屏页就重新开始倒计时。对于用户和开发人员来说,这种处理最直接,虽然闪屏页的展示时间被拉长了一点,但避免了各种异常情况。

4.5 Android 12及以上系统闪屏页变了,旧代码还能用吗

从Android 12(API 31)开始,系统强制引入了SplashScreen机制。系统会读取App的主题、启动图标等资源,自动生成一个系统级的启动画面。如果你没有做适配,旧版App在Android 12手机上首次打开时,会先显示一个系统默认的启动画面(比如圆形图标+背景色),然后再跳到你自己的闪屏页,视觉上会闪两次,非常割裂。

Google官方提供了兼容库androidx.core:core-splashscreen,用法是:

  • 引入依赖。
  • 用Theme.SplashScreen作为启动父主题,并设置postSplashScreenTheme为你原来的主题。
  • 在MainActivity之前的Activity里调用installSplashScreen()。

它的实质是用系统能力替代旧的windowBackground方案。你在老版本的App里做的那些启动示意图工作,可以逐步迁移到新体系下。不过对于本篇的入门项目,如果targetSdkVersion还没到31,旧方案完全可行,暂时不用着急适配。等你准备上架、提高targetSdkVersion的时候,再回来处理兼容问题。

4.6 模拟器跑没问题,真机上倒计时卡顿

真机卡顿原因一般不是闪屏页代码,而是初始化逻辑太重。有一些SDK在Application的onCreate里做了很多IO操作,拖慢了整个启动流程,导致闪屏页的Handler任务虽然定时2秒触发,但主线程繁忙,执行被推迟。

排查方式很简单,在App启动打点就行。Application.onCreate开头打一个时间戳,SplashActivity.onCreate再打一个时间戳,看差距有多大。如果Application的初始化超过了500ms,就需要考虑把初始化操作放到子线程,或者使用App Startup库做异步初始化。这个属于启动优化话题,等后面的文章我会专门展开。

5. 写到最后:几个提高闪屏页完成度的小习惯

闪屏页这个东西,越是新手越容易做简单,越是有经验的开发者越会抠细节。我整理几个平时容易被忽略的点,你在自己动手的时候可以对照着检查。

  • 布局里能写死的资源都写到dimens、colors、strings里,不要硬编码。后面做深色模式适配或者屏幕适配会轻松很多。
  • 启动主题和正常主题是两个主题,不要把闪屏相关的windowBackground直接写到全局theme里,否则整个应用都会沿用启动背景。
  • 图片资源放到mipmap或drawable时注意分辨率覆盖,至少要放xxhdpi和xxxhdpi两个版本,不然高端机型上Logo会发虚。
  • 闪屏页的布局根节点不要用太复杂的View层级,这个页面生命周期极短,用不到的东西都不要放进去。

按照现在的思路实现下来,你的App启动流程应该是:点击图标,系统画启动背景,SplashActivity显示品牌信息,初始化任务并行执行,2秒后淡入淡出切换到主页面,用户点击返回键回到桌面,整个过程干净利落。

还有一点,跳过按钮的点击区域别做太小,很多人为了好看把跳过按钮做成了个细条,真机上手根本点不准。给它至少44x44dp的点击面积,这是Android规范里的最小触摸目标要求。

下一次更新,我会接着这个工程做主界面的架子,包括底部导航栏、Fragment切换和常用基础库的封装。工程代码吃得越透,后面加功能的时候你就越有底气。闪屏页这块有遇到其他问题的,可以留言说说你的描述和现象,我来帮你看看是配置问题还是逻辑问题。

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

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

立即咨询