冷启动
- 前言
- 应用启动
- 冷启动流程
- 问题原因
- 源码解析
- WindowManagerService.setAppStartingWindow
- PhoneWindowManager.addStartingWindow
- 闪屏解决方法
- 优化
前言
做过APP开发,尤其是复杂项目的同学应该会经历过APP在桌面点击冷启动的时候,你以为会顺利打开应用首页,但是出现在你眼前的不是白屏就是黑屏的情况,也不会停留多久,可能就一闪而过;不管哪种情况,对于用户体验来说肯定是不行的,对于一个优秀程序员来说,怎么能看到自己开发的软件出现这种情况呢?那这到底怎么回事呢?今天就来说道说道
应用启动
应用启动一般分为冷启动,热启动,要注意这不是官方定义的
- 冷启动:这其实是一个从无到有的过程,就是在启动应用前,系统中没有该应用的任何进程信息;比如启动设备后第一次启动这个应用,或者杀死该应用后再启动这个应用,这些情况都属于冷启动;这种启动方式所消耗的启动时间是最长的,所做的工作也是最多的
- 热启动:这种就是启动应用前,系统中已经存在该应用的进程,比如你按back,home键回到桌面,再进入应用,这种情况就是热启动
我们今天要说的就是冷启动中出现的一些问题
冷启动流程
应该说冷启动是无法避免的,因为一个应用要想展示出来,系统需要为它做很多事,而这些总是需要一定的时间,开发者能做的就是将这段等待时间尽可能优雅的给用户缩短或者隐藏起来
那冷启动过程到底发生了什么导致了这些并发症呢,说到这里就要了解下应用的启动流程了:
当用户在Launcher内点击应用图标,Launcher将这个动作发送给AMS,AMS判断这个应用所在进程不存在,那就需要创建一个新的进程;然后AMS发送消息给Zygote进程,让它创建进程;Zygote进程经过fork之后,分配好应用需要的内存,就通过反射加载ActivityThread类的main方法,接下来进入应用进程了;ActivityThread的main方法会创建应用的Application,加载主题,创建应用第一个Activity,加载布局,进行绘制显示,这样你就看到第一个Activity了,应用就这样启动起来了
问题原因
但是进程创建,类加载及Activity启动需要时间,如果啥都不做一直等到Activity渲染完显示,那这个空白期就很尴尬了,用户点了之后,啥都没反应,还停留在手机屏幕,出现了假死现象,用户可能会再次点击,非常影响用户体验
于是Android就推出了一个预览窗口,这个预览窗口是AMS在启动应用的过程中判断如果我们要切换到一个新的栈,或者下一个Activity的进程不存在,那我们就需要告诉WindowManager展示一个预览窗口;给用户反馈,表明你点击有效;但是这个窗口展示什么呢?
这时候就会将你在manifest文件设置的主题中android:windowBackground 属性来设置到窗口中,你设置的是颜色,那就显示颜色;设置图片背景,那就显示图片;如果没有设置,那就默认是一个白屏或者黑屏
看到这里发现好像严格来说这不是问题,反而是个优化的结果;但是对于追求完美的程序猿来说,这种有损应用体验的情况怎么能存在呢,一定要解决
我们接下来从源码看下到底是怎么显示一个预览窗口的
源码解析
AMS的那一段初始代码就不贴了,可以参考上面文章,这里直接从WindowManager里开始看
WindowManagerService.setAppStartingWindow
@Override
public boolean setAppStartingWindow(IBinder token, String pkg,
int theme, CompatibilityInfo compatInfo,
CharSequence nonLocalizedLabel, int labelRes, int icon, int logo,
int windowFlags, IBinder transferFrom, boolean createIfNeeded) {
if (!checkCallingPermission(android.Manifest.permission.MANAGE_APP_TOKENS,
"setAppStartingWindow()")) {
throw new SecurityException("Requires MANAGE_APP_TOKENS permission");
}
synchronized(mWindowMap) {
if (DEBUG_STARTING_WINDOW) Slog.v(
TAG_WM, "setAppStartingWindow: token=" + token + " pkg=" + pkg
+ " transferFrom=" + transferFrom);
AppWindowToken wtoken = findAppWindowToken(token);
if (wtoken == null) {
return false;
}
// 如果显示被冻结,我们将不会执行任何操作,直到显示实际窗口
if (!okToDisplay()) {
return false;
}
if (wtoken.startingData != null) {
return false;
}
if (theme != 0) {
AttributeCache.Entry ent = AttributeCache.instance().get(pkg, theme,
com.android.internal.R.styleable.Window, mCurrentUserId);
if (ent == null) {
// 如果应用程序不存在 那就当做没看到
return false;
}
final boolean windowIsTranslucent = ent.array.getBoolean(
com.android.internal.R.styleable.Window_windowIsTranslucent, false);
final boolean windowIsFloating = ent.array.getBoolean(
com.android.internal.R.styleable.Window_windowIsFloating, false);
final boolean windowShowWallpaper = ent.array.getBoolean(
com.android.internal.R.styleable.Window_windowShowWallpaper, false);
final boolean windowDisableStarting = ent.array.getBoolean(
com.android.internal.R.styleable.Window_windowDisablePreview, false);
//如果设置透明 那就不显示
if (windowIsTranslucent) {
return false;
}
//如果禁用预览窗口 那就不显示
if (windowIsFloating || windowDisableStarting) {
return false;
}
//以系统壁纸为背景
if (windowShowWallpaper) {
if (mWallpaperControllerLocked.getWallpaperTarget() == null) {
// 如果此主题正在请求壁纸,并且壁纸当前不可见,则这实际上用作不透明窗口,
// 并且我们的起始窗口过渡动画仍然可以工作。 我们只需要确保起始窗口也显示壁纸
windowFlags |= FLAG_SHOW_WALLPAPER;
} else {
return false;
}
}
}
//显示预览窗口
if (transferStartingWindow(transferFrom, wtoken)) {
return true;
}
// 没有现有的预览窗口 开发者又不想我们创建
if (!createIfNeeded) {
return false;
}
//发送消息显示窗口
wtoken.startingData = new StartingData(pkg, theme, compatInfo, nonLocalizedLabel,
labelRes, icon, logo, windowFlags);
Message m = mH.obtainMessage(H.ADD_STARTING, wtoken);
mH.sendMessageAtFrontOfQueue(m);
}
return true;
}
这里主要会通过我们设置的主题中读取相关属性然后做相应操作,第一次通过transferStartingWindow方法显示窗口
private boolean transferStartingWindow(IBinder transferFrom, AppWindowToken wtoken) {
wtoken.startingData = ttoken.startingData;
ttoken.startingData = null;
ttoken.startingMoved = true;
Message m = mH.obtainMessage(H.ADD_STARTING, wtoken);
mH.sendMessageAtFrontOfQueue(m);
}
final WindowManagerPolicy mPolicy = new PhoneWindowManager();
final class H extends Handler {
case ADD_STARTING: {
final AppWindowToken wtoken = (AppWindowToken)msg.obj;
final StartingData sd = wtoken.startingData;
if (sd == null) {
// Animation has been canceled... do nothing.
return;
}
View view = null;
try {
final Configuration overrideConfig = wtoken != null && wtoken.mTask != null
? wtoken.mTask.mOverrideConfig : null;
view = mPolicy.addStartingWindow(wtoken.token, sd.pkg, sd.theme,
sd.compatInfo, sd.nonLocalizedLabel, sd.labelRes, sd.icon, sd.logo,
sd.windowFlags, overrideConfig);
} catch (Exception e) {
Slog.w(TAG_WM, "Exception when adding starting window", e);
}
if (view != null) {
boolean abort = false;
synchronized(mWindowMap) {
if (wtoken.removed || wtoken.startingData == null) {
// 如果成功添加了,那这里就要移出了
if (wtoken.startingWindow != null) {
wtoken.startingWindow = null;
wtoken.startingData = null;
abort = true;
}
} else {
wtoken.startingView = view;
}
}
if (abort) {
try {
mPolicy.removeStartingWindow(wtoken.token, view);
} catch (Exception e) {
Slog.w(TAG_WM, "Exception when removing starting window", e);
}
}
}
} break;
}
接下来就要进入PhoneWindowManager
PhoneWindowManager.addStartingWindow
@Override
public View addStartingWindow(IBinder appToken, String packageName, int theme,
CompatibilityInfo compatInfo, CharSequence nonLocalizedLabel, int labelRes,
int icon, int logo, int windowFlags, Configuration overrideConfig) {
if (!SHOW_STARTING_ANIMATIONS) {
return null;
}
if (packageName == null) {
return null;
}
WindowManager wm = null;
View view = null;
try {
Context context = mContext;
if (theme != context.getThemeResId() || labelRes != 0) {
try {
context = context.createPackageContext(packageName, 0);
context.setTheme(theme);
} catch (PackageManager.NameNotFoundException e) {
// Ignore
}
}
if (overrideConfig != null && overrideConfig != EMPTY) {
final Context overrideContext = context.createConfigurationContext(overrideConfig);
overrideContext.setTheme(theme);
final TypedArray typedArray = overrideContext.obtainStyledAttributes(
com.android.internal.R.styleable.Window);
//获取开发者设置的windowBackground属性
final int resId = typedArray.getResourceId(R.styleable.Window_windowBackground, 0);
if (resId != 0 && overrideContext.getDrawable(resId) != null) {
// 如果可用那就用你的,不可用的话就用默认的
context = overrideContext;
}
}
//创建PhoneWindow窗口
final PhoneWindow win = new PhoneWindow(context);
win.setIsStartingWindow(true);
CharSequence label = context.getResources().getText(labelRes, null);
if (label != null) {
win.setTitle(label, true);
} else {
win.setTitle(nonLocalizedLabel, false);
}
win.setType(WindowManager.LayoutParams.TYPE_APPLICATION_STARTING);
synchronized (mWindowManagerFuncs.getWindowManagerLock()) {
// 在隐藏键盘锁的同时显示一个应用的预览窗口是安全的
// 因为预览窗口不会显示秘密信息
if (mKeyguardHidden) {
windowFlags |= FLAG_SHOW_WHEN_LOCKED;
}
}
......
wm = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
view = win.getDecorView();
//显示预览窗口
wm.addView(view, params);
// 只有在成功添加到window manager的情况下才返回视图
return view.getParent() != null ? view : null;
} catch (WindowManager.BadTokenException e) {
// ignore
} catch (RuntimeException e) {
Log.w(TAG, appToken + " failed creating starting window", e);
} finally {
if (view != null && view.getParent() == null) {
wm.removeViewImmediate(view);
}
}
return null;
}
这样一个预览窗口就显示出来了
闪屏解决方法
- 第一种
既然是这个预览窗口导致的黑白屏情况,那我们就禁用这个预览窗口,使用如下这个属性
<item name="android:windowDisablePreview">true</item>
这种做法虽然没有预览窗口了,但是会出现在桌面点击应用图标后,短暂停滞一段时间才打开APP
这个做法个人不推荐,直接把Google做的优化给取消了;但是我发现平时用的一些APP就是这种做法
- 第二种
将背景设置透明
<item name="android:windowBackground">@android:color/transparent</item>
这种做法也不推荐,会出现点击图标后,到出现Activity内容是一个闪烁的过程;以及按home键回到应用列表也是一个闪烁的过程,影响用户体验
- 第三种
如果你的启动页有一张背景图,那将windowBackground属性设置成一张和启动页一样的背景图,这样给用户的感觉是APP秒启动,因为预览窗口跟启动页几乎一模一样
<item name="android:windowBackground">@mipmap/img_app</item>
但是要注意,这里设置的背景图是常驻内存的,所以在离开预览窗口后,比如在欢迎页或者应用首页需要将背景图置null
getWindow().setBackgroundDrawable(null);
- 第四种
如果你的启动页没有背景图,比如很多APP都有广告业务,像微博客户端启动页就是屏幕上方大概3/5的区域是放广告的,屏幕下方是微博Logo,这样要是还像第三种方法做,预览窗口和启动页的切换给人有太突兀的感觉;这时候就可以通过写layer-list资源文件,然后把这个资源文件作为windowBackground属性
这个文件怎么写呢,首先底部是跟启动页一样的静态logo图,上方就是灰色背景,不放任何东西,这样从预览窗口到启动页就会呈现一种加载的感觉,因为只会有一个广告图的不同,用户体验能提升不少
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"
android:opacity="opaque">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/background_grey"/>
<size android:height="384dp" />
</shape>
</item>
<item>
<bitmap android:src="@drawable/app_logo"
android:gravity="bottom|center">
</bitmap>
</item>
</layer-list>
然后给启动页Activity设置主题
<style name="Launcher" parent="AppTheme">
<item name="android:windowBackground">@drawable/list_launcher</item>
<item name="android:windowFullscreen">true</item>
</style>
最后离开启动页后要将背景置null
getWindow().setBackgroundDrawable(null);
- 第五种
接着第四种方法继续讲,第四种方法比较适合广告业务就是一张图片;假如你的广告仅仅用一个Imageview展示行不通,需要通过Webview来展示一个H5广告或者播放一个短视频;但是我们知道Webview或者视频控件的初始化是比较费时间的,这就会使启动页加载变慢,这样肯定是不行的
再或者你的广告业务是需要实时选择放H5广告还是视频广告,那你肯定不能直接在XML中即放webview控件又放视频控件,这样很费资源又费时间
这时候就需要使用一个或多个ViewStub控件来实现延迟选择性加载,让启动页来的更快点
至于ViewStub的好处:ViewStub其实只相当于一个占位View而已,在未inflate之前,高宽都是0,只保存了在parent中的index和layoutParmas而已,在inflate时,用具体View来替换掉自己;这样在Activity进行setContentView后解析XML文件时提高绘制效率
启动页的XML文件可以这样
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ViewStub
android:id="@+id/viewstub"
android:layout_width="match_parent"
android:layout_height="384dp"
android:gravity="center"
android:layout="@layout/ad_webview"/>
</LinearLayout>
引用一个webview
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<WebView
android:id="@+id/webview"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</LinearLayout>
然后在Activity中加载
ViewStub viewStub = (ViewStub) findViewById(R.id.viewstub);
View view = viewStub.inflate();
WebView webView = (WebView) view.findViewById(R.id.webview);
webView.setWebViewClient(new WebViewClient(){
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
return true;
}
});
webView.loadUrl("https://www.baidu.com");
其实APP引导页也可以用ViewStub做
优化
上面说的几种方法都是很直接的,其实还有一些优化可以让启动页来的更快点
- 在开发应用的时候,基本上都会继承Application类,写一写自己的逻辑;熟悉应用启动流程的应该知道,应用启动后,是先回调Application的onCreate方法,然后再去加载Activity;但是我们在开发过程中难免会依赖一些第三方库或者组件,同时还会初始化一些资源等操作,这机会导致Activity的加载时间更加延后了
所以这里就需要进行一些优化:
比如不要做IO操作或者其它耗时操作,将它们放到子线程操作(比如AsyncTask,HandlerThread,IntentService,线程池);尽量不要执行太多静态数据操作;尽量不要做单例对象的初始化操作;反正能往后做的事情尽量别放到Application做 - 对于Activity的布局,不要嵌套过多,影响绘制效率;过多的控件可以使用ViewStub,针对指定的业务场景,初始化指定的控件;在欢迎页尽量少做文件操作,比如数据库,SharedPreferences等
其实每个应用都有每个应用的特点,优化也不能以一概全,大家还是得根据自己的APP的特点去做针对性的处理