在 Android 开发或面试过程中,屏幕旋转是一个容易让人忽视的知识点。在我之前经历的项目中,App 通常是为竖屏状态设置的,所以通常我们会对每个页面都设置竖屏方向,这时候我们不需要考虑旋转屏问题。但是最近项目中,我们的 App 是为平板设计的,而横竖屏旋转是属于客户的一个需求,当然平板上横竖屏的确比较常用。所以就借此机会研究了下 Android 横竖屏问题。
横竖屏之所以需要引起开发者的注意,是因为 App 在横竖屏切换的过程中,页面会重绘,那么页面上已有的数据(比如登录页面已经输入的用户名)如何保存成为了一个问题。按照官方的推荐,Activity本身的确有处理旋转屏事件的函数,但是当一个页面中需要保存的数据很多的时候(比如很多 EditText),还是手工处理,就显得有些繁琐了。下面我们将循序渐进地探索 Android 屏幕旋转之最佳实践。
Activity 旋转中的保存与恢复
在解决问题或者探索最佳实践的时候,我们可以从简单的问题入手,慢慢衍生至复杂的情形,最后再抽象出一些比较通用的解决方案。这一步我们单纯的探索一个 Activity(没有 Fragment)的数据保存与恢复。
Activity 的生命周期
Activity 从创建到呈现在用户面前到消亡,有着自己完整的生命周期:
旋转屏幕时,打印相关 Log 如下:
从 Log 可以看到,在屏幕旋转时,原来的 Activity 调用了 onDestroy,随后重新实例化了一个 MainActivity。重新实例化的 MainActivity 也会经历 “onCreate -> onStart -> onResume” 的生命周期。
Activity 中窗口保存与恢复
为了进一步探索 Activity 在旋转屏过程中的数据保存及恢复的逻辑,我们构造了一个具有用户名和密码的登录界面。当用户在用户名中输入用户名时,这时候旋转屏幕,此时期望的操作应该是输入的用户名仍然能够保留:
在不添加任何额外代码的情况下,我们可以看到输入框中的数据在旋转屏后仍然能够保留,这些控件基本状态的值是如何被保留的呢?
Activity 中方法 protected void onSaveInstanceState(Bundle outState) {...}
主要做状态保存相关的处理,如果我们有需要特地保存的变量等,我们可以在 onSaveInstanceState
中保存,保存后的 bundle 以 outState Bundle 的格式保存。当 Activity 再次被初始化时,onCreate(Bundle savedInstanceState)
会将保存的 bundle 传递给 Activity 主页面,Activity 主页面接收到这些状态保存的数据后,能够根据保存中的控件的ID信息,状态数据等对页面进行自动的初始化。当我们转屏时,会主动触发 onSaveInstanceState
被调用。Log 打印如下:
根据 Log 很明确看出在旋转屏之后,随着 onPause 的执行,onSaveInstanceState
也被执行。当 Activity 再次初始化时,onCreate(Bundle savedInstanceState)
会传递回一个非空的 savedInstanceState(而当 Activity 第一次初始化时此值为空),同时 onRestoreInstanceState
也会被调用,用来将保存的窗口状态信息重新应用:
生命周期:onCreate
-> onStart
-> onResume
-> Running 转屏 -> onPause
-> onSaveInstanceState
-> onStop
-> onDestroy
-> onCreate
-> onStart
-> onRestoreInstanceState
-> onResume
;
通过对 savedInstanceState
的查看,发现 savedInstanceState 中包含有 Key 为 android:viewHierarchyState
的 bundle 数据,此 bundle 数据具体内容如下:
Bundle[{android:views={
16908290=android.view.AbsSavedState$1@347440f8, 2131492927=android.view.AbsSavedState$1@347440f8, 2131492928=android.view.AbsSavedState$1@347440f8, 2131492929=android.support.v7.widget.Toolbar$SavedState@391b69d1, 2131492930=android.view.AbsSavedState$1@347440f8, 2131492944=TextView.SavedState{138fec36 start=8 end=8 text=gongming}, 2131492945=TextView.SavedState{16043737 start=0 end=0 text=}, 2131492946=android.view.AbsSavedState$1@347440f8
},
android:focusedViewId=2131492944}
]
可以看到 username 的输入框 EditText,其 ID 以及当前的值都已经保存至 savedInstanceState 中。
那么,对于 Activity.java 的而言,最重要的保存数据和恢复数据的源代码如下:
private static final String WINDOW_HIERARCHY_TAG = "android:viewHierarchyState";
protected void onSaveInstanceState(Bundle outState) {
outState.putBundle(WINDOW_HIERARCHY_TAG, mWindow.saveHierarchyState());
Parcelable p = mFragments.saveAllState();
if (p != null) {
outState.putParcelable(FRAGMENTS_TAG, p);
}
getApplication().dispatchActivitySaveInstanceState(this, outState);
}
protected void onRestoreInstanceState(Bundle savedInstanceState) {
if (mWindow != null) {
Bundle windowState = savedInstanceState.getBundle(WINDOW_HIERARCHY_TAG);
if (windowState != null) {
mWindow.restoreHierarchyState(windowState);
}
}
}
这就是为什么我们没有做过多的处理却可以让 App 在旋转屏幕时仍然自动保存恢复输入框中的文字。
Activity 中数据保存与恢复
既然 Activity 本身对窗口(控件)的状态信息进行了保存及恢复处理。那么我们在屏幕切换时最应该关心的就是页面数据的保存与恢复。页面数据主要有两种:
- API 请求的数据:在横竖屏切换时对API数据进行保存及恢复能够防止 API 的重复调用;
- 页面中的状态值:在 Activity 运行过程中被改变的状态值,在恢复时需要手动保存及恢复,以便不影响状态值相关的页面逻辑;
在 Activity 中进行变量的存储及读取时,使用 onSaveInstanceState
进行数据的存储,使用 onRestoreInstanceState
进行数据的读取:
private boolean shouldSaveData = false;
private static final String KEY_SHOULD_SAVE_DATA = "save_data_key";
@Override
protected void onSaveInstanceState(Bundle outState) {
outState.putBoolean(KEY_SHOULD_SAVE_DATA, shouldSaveData);
super.onSaveInstanceState(outState);
Log.i(TAG, "@@Activity onSaveInstanceState@@" + this.toString());
}
@Override
protected void onRestoreInstanceState(Bundle savedInstanceState) {
super.onRestoreInstanceState(savedInstanceState);
shouldSaveData = savedInstanceState.getBoolean(KEY_SHOULD_SAVE_DATA);
Log.i(TAG, "@@Activity onRestoreInstanceState@@" + this.toString());
}
Activity 中弹框的状态保存及恢复
在 Activity 中经常会遇到 Dialog,甚至像下面具有输入框的 Dialog:
当用户点击 Login 按钮,弹出弹框需要用户签名,这时如果屏幕不小心进行了一次旋转,那么这个弹出的 Dialog 便消失,随之消失的还有用户输入了一半的输入框文字。Activity 在旋转过程中对于 Dialog 自身的生命周期进行很好的管理,如果为了达到更好的用户体验,转屏时也需要保存输入框状态,那么此处我们强烈推荐用户使用 DialogFragment 代替 Dialog。
由于 Fragment 也具有生命周期,使用 DialogFragment 之后,我们结合 Activity 与 Fragment 的生命周期,查看整个过程经历了哪些流程。
结合 DialogFragment 在 Activity 中旋转的重新初始化及数据恢复,我们可以看到执行顺序如下:
在转屏时,Activity 和 Fragment 都会重新实例化,并且都通过 onSaveInstanceState
进行状态保存。值得注意的是不同于 Activity, Fragment 并没有 onRestoreInstanceState
方法,Fragment 的状态恢复在 onActivityCreated
方法中。查看 DialogFragment 源码,我们可以看到如下调用:
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
...
if (savedInstanceState != null) {
Bundle dialogState = savedInstanceState.getBundle(SAVED_DIALOG_STATE_TAG);
if (dialogState != null) {
mDialog.onRestoreInstanceState(dialogState);
}
}
}
...
@Override
public void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
if (mDialog != null) {
Bundle dialogState = mDialog.onSaveInstanceState();
if (dialogState != null) {
outState.putBundle(SAVED_DIALOG_STATE_TAG, dialogState);
}
}
...
}
由于 DialogFragment 其实就是展示一个 Dialog,而 DialogFragment 对 Dialog 的状态保存及恢复使得 Dialog 的状态得以保存。
Fragment 状态的保存及恢复
有了上面对 DialogFragment 转屏时状态保存及恢复的研究,那么在一个普通的 Fragment(DialogFragment 是一种特殊的 Fragment) 中状态保存及恢复又是怎样的呢?
实际上通过 DialogFragment 我们可以知道保存状态值还是通过 onSaveInstanceState
方法,而 onActivityCreated
中则可以获取状态值。
在转屏时,我们会有很多特殊的考虑。所以如果你的 App 需要支持横竖屏切换,你可以留意如下几点:
1. Dialog 转屏消失问题
Dialog 转屏消失在现实中是一个很常见的情形,对应的解决方案就是利用 DialogFragment 来替代 Dialog。这样旋转屏幕时弹起的 Dialog 就不会消失。
2. Fragment 保存组件信息的坑
最近在项目中发现,有时候放置在 Fragment 中的 ListView 转屏后不能自动回到转屏之前的位置。后来发现导致原因是 Activity 的 Layout 在添加 Fragment 时候没有指定 id 或 tag。于是该 Fragment 在 Activity 重绘时不能被系统当作 “同一个” Fragment,所以旋转时控件的一些基本状态信息没办法恢复。
其中的 android:id="@+id/home_fragment"
是重点。一旦 Fragment 的状态保存出现问题,可先确认 Fragment 是不是设置了 id 或 tag。
3. 使用 setRetainInstance
关于 Fragment,我们发现 setRetainInstance
方法经常被用到,那么这个方法的作用是什么呢?我们看看官方的解释:
/**
* Control whether a fragment instance is retained across Activity
* re-creation (such as from a configuration change). This can only
* be used with fragments not in the back stack. If set, the fragment
* lifecycle will be slightly different when an activity is recreated:
*
* - {@link #onDestroy()} will not be called (but {@link #onDetach()} still
* will be, because the fragment is being detached from its current activity).
*
- {@link #onCreate(Bundle)} will not be called since the fragment
* is not being re-created.
*
- {@link #onAttach(Activity)} and {@link #onActivityCreated(Bundle)} will
* still be called.
*
*/
结合方法名以及方法的解释,可以知道一旦我们设置 setRetainInstance(true)
,意味着在 Activity 重绘时,我们的 Fragment 不会被重复绘制,也就是它会被“保留”。为了验证其作用,我们发现在设置为 true
状态时,旋转屏幕,Fragment 依然是之前的 Fragment。而如果将它设置为默认的 false,那么旋转屏幕时 Fragment 会被销毁,然后重新创建出另外一个 fragment 实例。并且如官方所说,如果 Fragment 不重复创建,意味着 Fragment 的 onCreate
和 onDestroy
方法不会被重复调用。所以在旋转屏 Fragment 中,我们经常会设置 setRetainInstance(true)
,这样旋转时 Fragment 不需要重新创建。
如果你的 App 恰好可以不做转屏,那么你可以很省事的在 Manifest 文件中添加标注,强制所有页面使用竖屏/横屏。如果你的页面不幸的需要支持横竖屏切换,那么你在预估工作量或者给客户报价时一定要考虑到。虽然加入转屏支持不会导致工作量翻倍,但是却有可能引起许多问题。尤其当页面有很多业务逻辑,有状态值的时候。所以我们在项目开发过程中,应该知道什么时候需要考虑状态保存,当状态保存出现问题时,应该怎么解决之。
补充:关于 getChildFragmentManager()
最近开发过程中,遇到了一个 ViewPager 嵌套的问题。可以描述为, 首页 A Activity 通过 ViewPager 展现出 3个Fragment(Fragment A, Fragment B, Fragment C)。然后 Fragment A 又通过 TabLayout 嵌套另外 Fragment (Fragment A1, Fragment A2)。
我期望能够正确展现各个 Fragment, 然后奇怪的是,当我在 A1, A2 以及 B,C 之前切换时。A1或者A2的布局页面会随机丢失。百思不得其解,最后发现是 FragmentManager 的锅。
对于 Fragment 中嵌套 ViewPager 展现另外的子 Fragment 时候,一定要使用 getChildFragmentManager
。例子中,我在 Activity A 中使用 getFragmentManager()
获取 FragmentManager 的实例,但是在 Fragment A 中必须通过 getChildFragmentManager()
的实例,从而避免了子 Fragment 布局丢失问题。有兴趣的可以看看官方对于 Fragment.getChildFragmentManager 的解释:
/**
* Return a private FragmentManager for placing and managing Fragments
* inside of this Fragment.
*/
final public FragmentManager getChildFragmentManager() {
...
return mChildFragmentManager;
}