Android 屏幕旋转 处理 AsyncTask 和 ProgressDialog 的最佳方案
源代码参考:360云盘中---自己的学习资料---Android总结过的项目---FragmentDemo.rar 一、概述 众所周知,Activity 在不明确指定屏幕方向和 configChanges 时,当用户旋转屏幕会重新启动。当然了,应对这种情况,Android 给出了几种方案: 1、如果是少量数据,可以通过 onSaveInstanceState() 和 onRestoreInstanceState() 进行保存与恢复。 Android 会在销毁你的 Activity 之前调用 onSaveInstanceState() 方法,于是,你可以在此方法中存储关于应用状态的数据。然后你可以在 onCreate() 或onRestoreInstanceState() 方法中恢复。 2、如果是大量数据,使用 Fragment 保持需要恢复的对象。 3、自已处理配置变化。 注:getLastNonConfigurationInstance() 已经被弃用,被上述方法二替代。 -------------------------------------------------------------------------------------------- 二、难点 假设当前 Activity 在 onCreate 中启动一个异步线程去加在数据,当然为了给用户一个很好的体验,会有一个 ProgressDialog,当数据加载完成,ProgressDialog 消失,设置数据。 这里,如果在异步数据完成加载之后,旋转屏幕,使用上述1、2两种方法都不会很难,无非是保存数据和恢复数据。 但是,如果正在线程加载的时候,进行旋转,会存在以下问题: 1.此时数据没有完成加载,onCreate 重新启动时,会再次启动线程;而上个线程可能还在运行,并且可能会更新已经不存在的控件,造成错误。 2.关闭 ProgressDialog 的代码在线程的 onPostExecutez 中,但是上个线程如果已经杀死,无法关闭之前 ProgressDialog。 3.谷歌的官方不建议使用 ProgressDialog,这里我们会使用官方推荐的 DialogFragment 来创建我的加载框,如果你不了解:请看 Android 官方推荐 : DialogFragment 创建对话框。这样,其实给我们带来一个很大的问题,DialogFragment 说白了是 Fragment,和当前的 Activity 的生命周期会发生绑定,我们旋转屏幕会造成 Activity 的销毁,当然也会对 DialogFragment 造成影响。 下面我将使用几个例子,分别使用上面的 3 种方式,和如何最好的解决上述的问题。 -------------------------------------------------------------------------------------------- 三、使用 onSaveInstanceState() 和 onRestoreInstanceState() 进行数据保存与恢复 /** * 不考虑加载时,进行旋转的情况,有意的避开这种情况,后面例子会介绍解决方案 */ public class SavedInstanceStateUsingActivity extends ListActivity { private static final String TAG = "MainActivity"; private ListAdapter mAdapter; private ArrayList<String> mDatas; private DialogFragment mLoadingDialog; private LoadDataAsyncTask mLoadDataAsyncTask; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.e(TAG, "onCreate"); initData(savedInstanceState); } /** * 初始化数据 */ private void initData(Bundle savedInstanceState) { if (savedInstanceState != null) mDatas = savedInstanceState.getStringArrayList("mDatas"); if (mDatas == null) { mLoadingDialog = new LoadingDialog(); mLoadingDialog.show(getFragmentManager(), "LoadingDialog"); mLoadDataAsyncTask = new LoadDataAsyncTask(); mLoadDataAsyncTask.execute(); } else { initAdapter(); } } /** * 初始化适配器 */ private void initAdapter() { mAdapter = new ArrayAdapter<String>( SavedInstanceStateUsingActivity.this, android.R.layout.simple_list_item_1, mDatas); setListAdapter(mAdapter); } @Override protected void onRestoreInstanceState(Bundle state) { super.onRestoreInstanceState(state); Log.e(TAG, "onRestoreInstanceState"); } @Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); Log.e(TAG, "onSaveInstanceState"); outState.putSerializable("mDatas", mDatas); } /** * 模拟耗时操作 * * @return */ private ArrayList<String> generateTimeConsumingDatas() { try { Thread.sleep(2000); } catch (InterruptedException e) { } return new ArrayList<String>(Arrays.asList("通过Fragment保存大量数据", "onSaveInstanceState保存数据", "getLastNonConfigurationInstance已经被弃用", "RabbitMQ", "Hadoop", "Spark")); } private class LoadDataAsyncTask extends AsyncTask<Void, Void, Void> { @Override protected Void doInBackground(Void... params) { mDatas = generateTimeConsumingDatas(); return null; } @Override protected void onPostExecute(Void result) { mLoadingDialog.dismiss(); initAdapter(); } } @Override protected void onDestroy() { Log.e(TAG, "onDestroy"); super.onDestroy(); } } 界面为一个 ListView,onCreate 中启动一个异步任务去加载数据,这里使用 Thread.sleep 模拟了一个耗时操作;当用户旋转屏幕发生重新启动时,会 onSaveInstanceState 中进行数据的存储,在 onCreate 中对数据进行恢复,免去了不必要的再加载一遍。 运行结果: 当正常加载数据完成之后,用户不断进行旋转屏幕,log会不断打出:onSaveInstanceState->onDestroy->onCreate->onRestoreInstanceState,验证我们的确是重新启动了,但是我们没有再次去进行数据加载。 如果在加载的时候,进行旋转,则会发生错误,异常退出(退出原因:dialog.dismiss() 时发生 NullPointException,因为与当前对话框绑定的 FragmentManager 为 null,又有兴趣的可以去 Debug,这个不是关键)。 效果图: -------------------------------------------------------------------------------------------- 四、使用 Fragment 来保存对象,用于恢复数据 如果重新启动你的 Activity 需要恢复大量的数据,重新建立网络连接,或者执行其他的密集型操作,这样因为配置发生变化而完全重新启动可能会是一个慢的用户体验。并且,使用系统提供的 onSaveIntanceState() 的回调中,使用 Bundle 来完全恢复你 Activity 的状态是可能是不现实的(Bundle 不是设计用来携带大量数据的(例如 bitmap),并且Bundle 中的数据必须能够被序列化和反序列化),这样会消耗大量的内存和导致配置变化缓慢。在这样的情况下,当你的 Activity 因为配置发生改变而重启,你可以通过保持一个Fragment 来缓解重新启动带来的负担。这个 Fragment 可以包含你想要保持的有状态的对象的引用。 当 Android 系统因为配置变化关闭你的 Activity 的时候,你的 Activity 中被标识保持的 Fragments 不会被销毁。你可以在你的 Activity 中添加这样的 Fragements 来保存有状态的对象。 在运行时配置发生变化时,在 Fragment 中保存有状态的对象 1.继承 Fragment,声明引用指向你的有状态的对象 2.当 Fragment 创建时调用 setRetainInstance(boolean) 3.把 Fragment 实例添加到 Activity 中 4.当 Activity 重新启动后,使用 FragmentManager 对 Fragment 进行恢复 /** * 使用本 Fragment 保存大数据 */ public class RetainedFragment extends Fragment { // data object we want to retain private Bitmap mData; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // retain this fragment setRetainInstance(true); } public Bitmap getmData() { return mData; } public void setmData(Bitmap mData) { this.mData = mData; } } 比较简单,只需要声明需要保存的数据对象,然后提供 getter 和 setter,注意,一定要在 onCreate 调用 setRetainInstance(true); /** * 使用 Fragment 保存大数据的主 Activity */ public class FragmentRetainDataActivity extends Activity { private static final String TAG = "FragmentRetainDataActivity"; private RetainedFragment mDataFragment; private DialogFragment mLoadingDialog; private LoadDataAsyncTask mLoadDataAsyncTask; private ImageView mImageView; private Bitmap mBitmap; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_fragment_retain); Log.e(TAG, "onCreate"); // find the retained fragment on activity restarts FragmentManager fm = getFragmentManager(); mDataFragment = (RetainedFragment) fm.findFragmentByTag("data"); // create the fragment and data the first time if (mDataFragment == null) { // add the fragment mDataFragment = new RetainedFragment(); fm.beginTransaction().add(mDataFragment, "data").commit(); } mBitmap = collectMyLoadedData(); initData(); // the data is available in mDataFragment.getData() } @Override public void onDestroy() { Log.e(TAG, "onDestroy"); super.onDestroy(); // store the data in the fragment mDataFragment.setmData(mBitmap); } /** * 初始化数据 */ private void initData() { mImageView = (ImageView) findViewById(R.id.ivFragmentRetain); if (mBitmap == null) { mLoadingDialog = new LoadingDialog(); mLoadingDialog.show(getFragmentManager(), "LOADING_DIALOG"); mLoadDataAsyncTask = new LoadDataAsyncTask(); mLoadDataAsyncTask.execute(); // RequestQueue tRequestQueue = Volley // .newRequestQueue(FragmentRetainDataActivity.this); // ImageRequest imageRequest = new ImageRequest( // "//img-my.csdn.net/uploads/201407/18/1405652589_5125.jpg", // new Response.Listener<Bitmap>() { // // // @Override // public void onResponse(Bitmap response) { // mBitmap = response; // mImageView.setImageBitmap(mBitmap); // // load the data from the web // mDataFragment.setmData(mBitmap); // mLoadingDialog.dismiss(); // } // }, 0, 0, Config.RGB_565, null); // tRequestQueue.add(imageRequest); } else { mImageView.setImageBitmap(mBitmap); } } private Bitmap collectMyLoadedData() { return mDataFragment.getmData(); } private class LoadDataAsyncTask extends AsyncTask<Void, Void, Void> { @Override protected Void doInBackground(Void... params) { mBitmap = getImg(); return null; } @Override protected void onPostExecute(Void result) { mLoadingDialog.dismiss(); initImg(); } } private Bitmap getImg() { try { Thread.sleep(2000); } catch (InterruptedException e) { } return FileUtils.getImage(); } private void initImg() { mImageView.setImageBitmap(mBitmap); } } -------------------------------------------------------------------------------------------- 五、配置 configChanges,自己对屏幕旋转的变化进行处理 在menifest中进行属性设置: <activity android:name="com.xjl.fragmentdemo.rotate_screen.config.ConfigChangesTestActivity" android:configChanges="screenSize|orientation" android:label="配置 configChanges,自己对屏幕旋转的变化进行处理" > <intent-filter> <action android:name="fragment_demo" /> </intent-filter> </activity> 低版本的 API 只需要加入 orientation,而高版本的则需要加入 screenSize。 /** * 配置 configChanges,自己对屏幕旋转的变化进行处理 */ public class ConfigChangesTestActivity extends Activity { private static final String TAG = "MainActivity"; private DialogFragment mLoadingDialog; private LoadDataAsyncTask mLoadDataAsyncTask; private ImageView mImageView; private Bitmap mBitmap; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.e(TAG, "onCreate"); initData(savedInstanceState); } @Override protected void onDestroy() { Log.e(TAG, "onDestroy"); super.onDestroy(); } /** * 初始化数据 */ private void initData(Bundle savedInstanceState) { setContentView(R.layout.activity_fragment_retain); mImageView = (ImageView) findViewById(R.id.ivFragmentRetain); mLoadingDialog = new LoadingDialog(); mLoadingDialog.show(getFragmentManager(), "LoadingDialog"); mLoadDataAsyncTask = new LoadDataAsyncTask(); mLoadDataAsyncTask.execute(); } /** * 当配置发生变化时,不会重新启动Activity。但是会回调此方法,用户自行进行对屏幕旋转后进行处理 */ @Override public void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) { Toast.makeText(this, "landscape", Toast.LENGTH_SHORT).show(); } else if (newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) { Toast.makeText(this, "portrait", Toast.LENGTH_SHORT).show(); } } private class LoadDataAsyncTask extends AsyncTask<Void, Void, Void> { @Override protected Void doInBackground(Void... params) { mBitmap = getImg(); return null; } @Override protected void onPostExecute(Void result) { mLoadingDialog.dismiss(); initImg(); } } /** * 模拟耗时操作 * * @return */ private Bitmap getImg() { try { Thread.sleep(2000); } catch (InterruptedException e) { } return FileUtils.getImage(); } /** * 加载图片 */ private void initImg() { mImageView.setImageBitmap(mBitmap); } } 对第一种方式的代码进行了修改,去掉了保存与恢复的代码,重写了 onConfigurationChanged;此时,无论用户何时旋转屏幕都不会重新启动 Activity,并且onConfigurationChanged 中的代码可以得到调用。从效果图可以看到,无论如何旋转不会重启 Activity. -------------------------------------------------------------------------------------------- 六、旋转屏幕的最佳实践 下面要开始今天的难点了,就是处理文章开始时所说的,当异步任务在执行时,进行旋转,如果解决上面的问题。 首先说一下探索过程: 起初,我认为此时旋转无非是再启动一次线程,并不会造成异常,我只要即使的在onDestroy里面关闭上一个异步任务就可以了。事实上,如果我关闭了,上一次的对话框会一直存在;如果我不关闭,但是 activity 是一定会被销毁的,对话框的 dismiss 也会出异常。真心很蛋疼,并且即使对话框关闭了,任务关闭了;用户旋转还是会造成重新创建任务,从头开始加载数据。 下面我们希望有一种解决方案:在加载数据时旋转屏幕,不会对加载任务进行中断,且对用户而言,等待框在加载完成之前都正常显示: 当然我们还使用 Fragment 进行数据保存,毕竟这是官方推荐的: /** * 保存对象的 Fragment */ public class OtherRetainedFragment extends Fragment { // data object we want to retain // 保存一个异步的任务 private MyAsyncTask mData; // this method is only called once for this fragment @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // retain this fragment setRetainInstance(true); } public MyAsyncTask getmData() { return mData; } public void setmData(MyAsyncTask mData) { this.mData = mData; } } 和上面的差别不大,唯一不同的就是它要保存的对象编程一个异步的任务了,相信看到这,已经知道经常上述问题的一个核心了,保存一个异步任务,在重启时,继续这个任务。 public class MyAsyncTask extends AsyncTask<Void, Void, Void> { private FixProblemsActivity mActivity; /** * 是否完成 */ private boolean mBolCompleted; /** * 进度框 */ private LoadingDialog mLoadingDialog; private List<String> mItems; public MyAsyncTask(FixProblemsActivity mActivity) { this.mActivity = mActivity; } /** * 开始时,显示加载框 */ @Override protected void onPreExecute() { mLoadingDialog = new LoadingDialog(); mLoadingDialog.show(mActivity.getFragmentManager(), "LOADING"); } /** * 加载数据 */ @Override protected Void doInBackground(Void... params) { mItems = loadingData(); return null; } /** * 加载完成回调当前的mActivity */ @Override protected void onPostExecute(Void unused) { mBolCompleted = true; notifymActivityTaskCompleted(); if (mLoadingDialog != null) { mLoadingDialog.dismiss(); } } public List<String> getmItems() { return mItems; } private List<String> loadingData() { try { Thread.sleep(5000); } catch (InterruptedException e) { } return new ArrayList<String>(Arrays.asList("通过Fragment保存大量数据", "onSaveInstanceState保存数据", "getLastNonConfigurationInstance已经被弃用", "RabbitMQ", "Hadoop", "Spark")); } /** * 设置mActivity,因为mActivity会一直变化 * * @param mActivity */ public void setmActivity(FixProblemsActivity mActivity) { // 如果上一个mActivity销毁,将与上一个mActivity绑定的DialogFragment销毁 if (mActivity == null) { mLoadingDialog.dismiss(); } // 设置为当前的mActivity this.mActivity = mActivity; // 开启一个与当前mActivity绑定的等待框 if (mActivity != null && !mBolCompleted) { mLoadingDialog = new LoadingDialog(); mLoadingDialog.show(mActivity.getFragmentManager(), "LOADING"); } // 如果完成,通知mActivity if (mBolCompleted) { notifymActivityTaskCompleted(); } } private void notifymActivityTaskCompleted() { if (null != mActivity) { mActivity.onTaskCompleted(); } } } 异步任务中,管理一个对话框,当开始下载前,进度框显示,下载结束进度框消失,并为A ctivity 提供回调。当然了,运行过程中 Activity 不断的重启,我们也提供了setActivity 方法,onDestory 时,会 setActivity(null)防止内存泄漏,同时我们也会关闭与其绑定的加载框;当 onCreate 传入新的 Activity 时,我们会在再次打开一个加载框,当然了因为屏幕的旋转并不影响加载的数据,所有后台的数据一直继续在加载。是不是很完美 /** * 主 Activity */ public class FixProblemsActivity extends ListActivity { private static final String TAG = "MainActivity"; private OtherRetainedFragment mDataFragment; private MyAsyncTask mMyTask; private ListAdapter mAdapter; private List<String> mDatas; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.e(TAG, "onCreate"); initControl(); // 加载控件 } private void initControl() { // find the retained fragment on activity restarts FragmentManager tFManager = getFragmentManager(); mDataFragment = (OtherRetainedFragment) tFManager.findFragmentByTag("data"); // create the fragment and data the first time if (mDataFragment == null) { // add the fragment mDataFragment = new OtherRetainedFragment(); tFManager.beginTransaction().add(mDataFragment, "data").commit(); } mMyTask = mDataFragment.getmData(); if (mMyTask != null) { mMyTask.setmActivity(this); } else { mMyTask = new MyAsyncTask(this); mDataFragment.setmData(mMyTask); mMyTask.execute(); } // the data is available in mDataFragment.getData() } @Override protected void onDestroy() { Log.e(TAG, "onDestroy"); super.onDestroy(); } @Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); mMyTask.setmActivity(null); Log.e(TAG, "onSaveInstanceState"); } @Override protected void onRestoreInstanceState(Bundle state) { super.onRestoreInstanceState(state); Log.e(TAG, "onRestoreInstanceState"); } /** * 回调 */ public void onTaskCompleted() { mDatas = mMyTask.getmItems(); mAdapter = new ArrayAdapter<String>(FixProblemsActivity.this, android.R.layout.simple_list_item_1, mDatas); setListAdapter(mAdapter); } } 在 onCreate 中,如果没有开启任务(第一次进入),开启任务;如果已经开启了,调用 setActivity(this); 在 onSaveInstanceState 把当前任务加入 Fragment 我设置了等待5秒,足够旋转三四个来回了,可以看到虽然在不断的重启,但是丝毫不影响加载数据任务的运行和加载框的显示 可以看到我在加载的时候就三心病狂的旋转屏幕~~但是丝毫不影响显示效果与任务的加载~~ 最后,说明一下,其实不仅是屏幕旋转需要保存数据,当用户在使用你的 app 时,忽然接到一个来电,长时间没有回到你的 app 界面也会造成 Activity 的销毁与重建,所以一个行为良好的 App,是有必要拥有恢复数据的能力的