4. 定制退场效果
为了使得启动画面能完美过渡到目标画面,退场效果的定制格外重要。而且相较于进场只能定制Icon动画,退场则能定制整体和Icon的各式动画,空间更大,也更灵活。
4.1 执行退场动画
Splash Screen Window在退出的时候,是执行退场动画的时机。通过SplashScreen库提供的OnExitAnimationListener API可以拿到这个时机,它同时会返回启动画面视图,便于后续的定制。
不同于Android 12,SplashScreen库启动画面视图统一封装到了SplashScreenViewProvider中。这个API将依据版本返回对应视图:低版本是自定义的FrameLayout,12则是版本专属的SplashScreenView。
fun customizeSplashScreen() { ... customizeSplashScreenExit() } // Customize splash screen exit animator. private fun customizeSplashScreenExit() { splashScreen.setOnExitAnimationListener { splashScreenViewProvider -> val onExit = { splashScreenViewProvider.remove() } showSplashExitAnimator(splashScreenViewProvider.view, onExit) showSplashIconExitAnimator(splashScreenViewProvider.iconView, onExit) } }
无论运行在哪个版本,通过统一提供的SplashScreenViewProvider实例,可以执行一致的动画效果。
比如针对整体视图做缩放和淡出动画。
private fun showSplashExitAnimator(splashScreenView: View, onExit: () -> Unit = {}) { val alphaOut = ObjectAnimator.ofFloat( splashScreenView, ... ) val scaleOut = ObjectAnimator.ofFloat( ... ) AnimatorSet().run { duration = defaultExitDuration interpolator = AnticipateInterpolator() playTogether(scaleOut, alphaOut) start() } }
针对Icon视图做缩放、淡出和位移的动画。目的是让小鸟渐渐缩小并上移至游戏画面中,即无缝过渡到游戏区域。
private fun showSplashIconExitAnimator(iconView: View, onExit: () -> Unit = {}) { val alphaOut = ObjectAnimator.ofFloat( iconView, ... ) val scaleOut = ObjectAnimator.ofFloat( ... ) val slideUp = ObjectAnimator.ofFloat( ... ) AnimatorSet().run { ... playTogether(alphaOut, scaleOut, slideUp) doOnEnd { onExit() } start() } }
注意:退场动画结束后一定要调用remove()手动移除视图,否则可能会看到启动画面一直盖在App上,无法消失。对于12来说,这实际上是系统返回的SplashScreenView Surface一直残留在画面上,而低版本则是FrameLayout仍残存在ContentView树里。
4.2 优化退场动画时长
App开始描画的时候,无论进场动画是否执行完,Splash Screen Window都将退出,同时执行预设的退场动画。 而设备性能的优劣或负荷状态的不同,又会影响App描画的开始时机,所以退场动画的执行时机并不固定,随着设备的状况略微变化。
如果App描画得早,进场动画可能还没执行完。为了让用户更快看到目标内容,退场动画可以缩短执行时长,比如直接沿用进场动画的剩余时长
相反,如果描画得晚,进场动画早结束了。如果退场动画还再占用时间,那将严重耽误用户看到目标内容,造成启动卡顿的坏印象。所以这时候,退场动画可以执行很短的固定时长,甚至可以不执行
换句话说,退场动画本意是后台加载的缓冲和过渡,不能为了单纯展示动画牺牲启动体验,可以灵活控制退场的占用时长。
为了便于计算进场Icon动画的剩余时长,SplashScreen库提供了获取其开始时刻和总时长的API:
/** * Start time of the icon animation. * * On API 31+, returns the number of millisecond since the Epoch time (1970-1-1T00:00:00Z) * * Below API 31, returns 0 because the icon cannot be animated. */ public val iconAnimationStartMillis: Long get() = impl.iconAnimationStartMillis /** * Duration of the icon animation as provided in attr. */ public val iconAnimationDurationMillis: Long get() = impl.iconAnimationDurationMillis
下面的代码演示了在退场动画执行前判断下进场动画是否完毕,完毕的话沿用动画剩余的时间,否则放弃执行。
private fun getRemainingDuration(splashScreenView: SplashScreenViewProvider): Long { // Get the duration of the animated vector drawable. val animationDuration = splashScreenView.iconAnimationDurationMillis // Get the start time of the animation. val animationStart = splashScreenView.iconAnimationStartMillis // Calculate the remaining duration of the animation. return if (animationDuration == 0L || animationStart == 0L) defaultExitDuration else (animationDuration - SystemClock.uptimeMillis() + animationStart) .coerceAtLeast(0L) }
前面说过低版本在进场的时候不支持Icon动画,那自然没有必要计算剩余时长。所以运行在低版本上的话,这两个API总是返回默认值0,值得留意!
private open class ViewImpl(val activity: Activity) { open val iconAnimationStartMillis: Long get() = 0 open val iconAnimationDurationMillis: Long get() = 0 ... }
5. SplashScreen实现原理
Android 12的源码尚未公开,下面针对Jetpack SplashScreen库的源码进行解读。※之前觉得SplashScreen库提供的API简单明了,原理应该也不复杂。但深究源码后发现,不少细节和猜测存在出入,还是值得反复思考的。
5.1 总体架构
Activity通过SplashScreen库提供的SplashScreen实例可以获取到控制启动画面的入口,其内部将依据OS版本决定采用12的专属API还是自定义视图,来展示、延时或移除启动画面。
5.2 installSplashScreen
获取SplashScreen实例的installSplashScreen()将读取并设置目标Activity的Theme。如果运行在低版本上的话,还需要获取Icon和Background的配置。此刻只是获取,并未将退场用的自定义视图添加上来。
5.3 setKeepVisibleCondition
通过setKeepVisibleCondition()可以延长启动画面的展示,无关运行的版本,原理都是向ContentView的ViewTreeObserver注册OnPreDrawListener回调来实现。描画放行的时候,低版本额外需要手动执行退出的回调,12则由系统自行执行。
这个时候退场用的自定义视图仍然还没添加上来,只是延迟了Splash Screen Window的退出而已。
5.4 setOnExitAnimationListener
setOnExitAnimationListener()可以监听退场时机,SplashScreen会将启动画面视图准备好封装到SplashScreenViewProvider中,然后在启动画面需要退出的时候,通过OnExitAnimationListener接口回调。
低版本上视图相关的处理比较多,需要inflate一个自定义布局并添加到ContentView中,然后将install准备好的background和icon反映进来。
面向低版本添加的自定义布局 :
<FrameLayout ...> <ImageView android:id="@+id/splashscreen_icon_view" android:layout_width="@dimen/splashscreen_icon_size" android:layout_height="@dimen/splashscreen_icon_size" android:layout_gravity="center" /> </FrameLayout>
5.5 adjustInsets特殊处理
SplashScreenViewProvider初始化后会额外调用adjustInsets(),而且只有面向低版本才实现了具体逻辑。一起来研究下这个特殊处理的用意。
先来看下函数的注释:
Adjust the insets to avoid any jump between the actual splash screen and the SplashScreen View.
字面意思是为了避免启动画面和SplashView视图之间发生跳跃,需要调整下视图的参数。好像还不是很理解,再结合下函数的具体实现:
private class Impl23(activity: Activity) : Impl(activity) { override fun adjustInsets( view: View, splashScreenViewProvider: SplashScreenViewProvider ) { // Offset the icon if the insets have changed val rootWindowInsets = view.rootWindowInsets val ty = rootWindowInsets.systemWindowInsetTop - rootWindowInsets.systemWindowInsetBottom splashScreenViewProvider.iconView.translationY = -ty.toFloat() / 2f } }
默认的adjustInsets()没有具体实现,只有低版本才有具体实现,这又是为什么?
处理的内容很直白:监听SplashScreenViewProvider里存放的View的布局变化,从中获取rootWindowInsets,将覆盖在Window的状态栏和导航栏的高度差值取中值,然后将Icon视图向上或向下进行移动。
前面的原理提到过低版本的进场效果实际上是将一个Layer Drawable设置到了Window Background上,Icon在该Drawable里的位置是居中的,所以Icon在Splash Screen Window里也完全居中
但退场效果的画面视图并非Window Background Drawable,而是向ContentView中手动添加的Framelayout布局。对于布局来说,Icon视图是居中的,但对于它所属的Window来说,因顶部的Statusbar和底部的NavigationBar的高度不同,最终Icon视图在Window整体的位置并不完全居中。尤其当设备采用Gesture Navigation导航模式的话,Icon视图偏下的情况更明显
12的退场视图可以说是Splash Screen Window的完整拷贝,不是自定义的ContentView子布局,不存在这个问题
总结来说,如果不加干预的话,低版本上从进场的Window Background到退场的FrameLayout时,App的Icon会发生错位或跳跃的违和感!
默认的adjustInsets()没有具体实现,只有低版本才有具体实现,这又是为什么?
处理的内容很直白:监听SplashScreenViewProvider里存放的View的布局变化,从中获取rootWindowInsets,将覆盖在Window的状态栏和导航栏的高度差值取中值,然后将Icon视图向上或向下进行移动。
前面的原理提到过低版本的进场效果实际上是将一个Layer Drawable设置到了Window Background上,Icon在该Drawable里的位置是居中的,所以Icon在Splash Screen Window里也完全居中
但退场效果的画面视图并非Window Background Drawable,而是向ContentView中手动添加的Framelayout布局。对于布局来说,Icon视图是居中的,但对于它所属的Window来说,因顶部的Statusbar和底部的NavigationBar的高度不同,最终Icon视图在Window整体的位置并不完全居中。尤其当设备采用Gesture Navigation导航模式的话,Icon视图偏下的情况更明显
12的退场视图可以说是Splash Screen Window的完整拷贝,不是自定义的ContentView子布局,不存在这个问题
总结来说,如果不加干预的话,低版本上从进场的Window Background到退场的FrameLayout时,App的Icon会发生错位或跳跃的违和感!
5.6 remove
setOnExitAnimationListener调用后,SplashScreen库会将启动画面的视图回调回来,执行完退场动画后需要调用remove()手动移除视图,它的原理很简单:
12之前就是将FrameLayout从ContentView树上移除
12则是调用专用的API即SplashScreenView#remove()
6. API总结
对SplashScreen库几个关键API做个整理:
并和Android 12提供的版本做个简单对比:
7. 本文Demo
开源地址:https://github.com/ellisonchan/ComposeBird
适配Splash Screen功能后的Compose版Flappy Bird游戏的整体效果:
8. 未决事项
通过上面的阐述已经知道:低版本上展示退场效果的启动画面,实质上是临时添加到App内部的FrameLayout。而12上显然不是这种方式,通过Dump命令发现退场的时候也没有创建额外的Window。
那么12上启动画面Window退出后提供给App定制退场效果的SplashScreenView到底是什么?
假使这个“View”*并不属于App本身,那么App能够访问它并对属性做出改动,是如何实现的?
在决定搁置这个疑惑的时候,偶然发现下12上启动画面执行过程中会打印Splash相关关键字的系统日志:
StartingSurfaceDrawer: addSplashScreen com.ellison.flappybird: nonLocalizedLabel=null theme=7f1000e7 task= 334
StartingSurfaceDrawer: window attributes color: ffecfcdd icon android.graphics.drawable.AnimatedVectorDrawable…
StartingSurfaceDrawer: fillViewWithIcon surfaceWindowView android.window.SplashScreenView…
StartingSurfaceDrawer: Copying splash screen window view for task: 334 parcelable? android.window.SplashScreenView$SplashScreenViewParcelable
StartingSurfaceDrawer: Task start finish, remove starting surface for task 334
StartingSurfaceDrawer: Removing splash screen window for task: 334
于是我大胆猜测,StartingSurfaceDrawer负责通过WMS读取、展示和移除App设置的Splash Screen Window视图。并在Window退出前通过WMS通知目标Activity的PhoneWindow,从Splash Screen Window里复原视图,然后将复原得到的SplashScreenView添加到App画面上。因为PhoneWindow直接持有DecorView,所以可能直接附着到DecorView上去。
StartingSurfaceDrawer到底是谁、猜测是否正确以及具体的细节,还有待12的源码公开后再做深入地研究~
结语
起初推测因为Android 6以上的版本已是市场主流,才决定让SplashScreen库最早兼容到这个版本。后面探究原理的时候,发现其内部调用的adjustInsets()处理依赖一个版本6才加入的API,所以又感觉这才是兼容到6的真正原因。
兼容到Android 6版本是出于占用率的考虑还是源于API的限制已不再重要,关键是Splash Screen功能已支持足够多的Android设备。
之前你们说Android 12全新的Splash Screen功能虽然很酷,但不兼容低版本,略显鸡肋。如今有了Jetpack SplashScreen库的加持,总该适配起来了吧?
参考资料
A peek inside Jetpack Core Splashscreen
Meet the Jetpack Splashscreen API: a definitive guide for splash screens in Android