
上次搭建好工程之后后台收到不少留言问得最多的就是“点开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 12API 31开始系统强制走了启动画面机制跟旧版的闪屏页实现方式有差异后面专门用一节来讲你先记着这个知识点。2.2 白屏的根源默认主题和启动背景没配合好新建工程默认用的主题是系统ThemeAndroid系统在冷启动时会先用主题里的windowBackground画一个窗口背景然后再去加载你的Activity布局。如果你没做任何处理windowBackground就是白色或黑色那么从点击图标到Activity布局加载完成之间用户看到的就是一块纯白或者纯黑的画面——这就是传说中的“白屏”。解决方案是给闪屏页单独定义一个主题把windowBackground设置成你的启动背景图这样系统在画窗口背景的时候就直接画你的图了视觉上做到了无缝衔接。我在工程里是这么配的。先建一个启动主题放在values/themes.xml里style nameTheme.Splash parentTheme.AppCompat.DayNight.NoActionBar item nameandroid:windowBackgrounddrawable/splash_background/item !-- 如果你的AppCompatActivity依赖了AppCompat主题这行配合使用 -- item namewindowActionBarfalse/item /stylesplash_background是什么呢我习惯用layer-list来做因为比直接放一张全屏图更灵活也方便适配不同分辨率。比如让背景色是品牌色中间放一个Logo?xml version1.0 encodingutf-8? layer-list xmlns:androidhttp://schemas.android.com/apk/res/android !-- 底层用品牌色填充全屏 -- item android:drawablecolor/brand_blue / !-- 中间层放Logo做了上下偏移让视觉重心更舒服 -- item android:gravitycenter android:top80dp android:bottom80dp bitmap android:srcdrawable/ic_logo android:gravitycenter / /item /layer-list这样配置完之后冷启动的阶段系统画的就是这个背景不会闪白。等SplashActivity布局加载完成后才切到真正的布局。如果你发现启动的时候闪了一下白再显示图片十有八九是主题没配好或者windowBackground和Activity布局设置了不同的背景色。2.3 AndroidManifest里注册成启动入口同时设置noHistory接着在AndroidManifest.xml里注册SplashActivity。关键点是让它成为LAUNCHER另一个值得注意的参数是android:noHistory。activity android:name.SplashActivity android:exportedtrue android:themestyle/Theme.Splash android:noHistorytrue intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activitynoHistory的作用是当用户从闪屏页跳到主页面之后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:screenOrientationportrait ... /深色模式的问题也值得注意。如果App主题用了DayNight模式那么windowBackground可能会随着系统切换深色/浅色而变化。为了避免深色模式下闪屏页背景被系统换成黑色我为闪屏页单独定义了values-night里的对应资源。最简单的做法是闪屏页主题的windowBackground始终用固定的品牌色背景不跟随模式变化。在values-night目录下的themes.xml里把Splash主题的windowBackground覆盖成同一张图的深色版本或者保持同一个drawable都可以。具体看你想要的效果。布局代码大致长这样?xml version1.0 encodingutf-8? RelativeLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent android:backgroundcolor/brand_blue TextView android:idid/tv_slogan android:layout_widthwrap_content android:layout_heightwrap_content android:layout_centerInParenttrue android:text欢迎来到我的App android:textColorandroid:color/white android:textSize22sp android:textStylebold / TextView android:idid/tv_version android:layout_widthwrap_content android:layout_heightwrap_content android:layout_alignParentEndtrue android:layout_alignParentBottomtrue android:layout_marginEnd16dp android:layout_marginBottom20dp android:textColorandroid:color/white android:textSize12sp android:textv1.0.0 / TextView android:idid/tv_skip android:layout_widthwrap_content android:layout_heightwrap_content android:layout_alignParentEndtrue android:layout_alignParentToptrue android:layout_marginEnd16dp android:layout_marginTop16dp android:padding10dp android:visibilityinvisible android:text跳过 3 android:textColorandroid:color/white android:textSize14sp / /RelativeLayout3.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已经设置了noHistorytrue用户按返回键退出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 12API 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切换和常用基础库的封装。工程代码吃得越透后面加功能的时候你就越有底气。闪屏页这块有遇到其他问题的可以留言说说你的描述和现象我来帮你看看是配置问题还是逻辑问题。