Android学习笔记四(JAVA):日志、Activity生命周期、界面状态保存、Jetpack库初体验(ViewModel)
上篇笔记实现了QuizDemo的主要功能,但是有个bug,只要旋转屏幕,问题就又回到了第一道题目,而不是继续当前的题目。这是因为屏幕旋转后,当前的activity实例就被销毁了,并会新建一个activity实例。那么当然会重新显示第一道题目了。本篇笔记旨在实现Activity的界面状态在整个配置变更(例如旋转或切换到多窗口模式)期间保持不变。如下面的动图所示。首先要学习Activity的生命周期。为了更好地理解Activity的生命周期,先来介绍日志的概念。(tip:旋转屏幕可能需要先把模拟器的允许旋转按钮打开)
图1 QuizDemo示意图
3.重写onSaveInstanceState()方法保存界面状态
1.日志 https://developer.android.google.cn/reference/android/util/Log?hl=zh-cn
android.util.Log类提供日志功能。Android将日志划分为5个等级,每个等级的重要性不一样,这些日志等级按照从高到低的顺序如下:
Log.e:表示错误信息,比如可能导致程序崩溃的异常;
Log.w:表示警告信息;
Log.i:表示一般消息;
Log.d:表示调试信息,可把程序运行时的变量值打印出来,方便跟踪调试;
Log.v:表示冗余信息。
一般而言,日常开发使用Log.d即可。Log.d方法定义如下:
本学习笔记主要使用第二个。该方法中第一个参数tag是日志的来源,通常为一个值为类名的字符串常量;第二个参数msg是日志的具体内容。在Android Studio下方工具栏中有LogCat栏,这是Android SDK工具中的一款日志查看器,可以查看日志。下面来亲自操作如何查看日志。在QuizDemo中的MainActivity.java中添加以下两句代码:
MainActivity.java代码清单(不变的代码用省略号替代了):
public class MainActivity extends AppCompatActivity implements View.OnClickListener { private static final String TAG="MainActivity"; ... @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.d(TAG,"onCreate() called"); ... } ... }
运行项目,点开LogCat,点击右上角的下拉框,选择Edit Filter Configuration,在新打开的页面中,如下图所示新建一个日志过滤器。
图2 新建日志过滤器
选择Debug,可以看到输出了我们刚才指定的日志msg。(或者直接在中间的搜索框里输入MainActivity,以便从大量的日志记录中根据TAG挑选出指定的日志)
图3 查看日志
https://developer.android.google.cn/reference/android/app/Activity?hl=zh-cn
https://developer.android.google.cn/guide/components/activities/activity-lifecycle?hl=zh-cn
图4 Activity生命周期
一个Activity实例在其生命周期中会经历多种状态。为了在Activity生命周期的各个状态之间转换,Activity类提供六个核心回调方法:
onCreate() 会在系统首次创建Activity时触发,初始化Activity的基本组件,例如应用应该在此处创建视图并将数据绑定到列表,实例化某些类作用域变量。最重要的是,需要在此处调用setContentView()来定义Activity页面的布局。因此该回调必须实现,并且在Activity的整个生命周期中只应发生一次。此时,Activity从“已创建”状态(the Created state)过渡到“已开始”状态(the Started state)。
onStart(),当Activity进入“已开始”状态时,系统会调用此回调方法,使Activity对用户可见。此回调方法包含Activity进入前台与用户进行互动之间的最后准备工作,例如初始化维护界面的代码。onStart()方法会非常快速地完成,使Activity从“已开始”状态转为“已恢复”状态(the Resumed state)。
onResume(),Activity会在进入“已恢复”状态时来到前台,然后系统调用该回调。这是应用与用户互动的状态。应用会一直保持这种状态,捕获用户输入与用户交互。直到某些事件发生,让焦点远离应用(例如,接到来电,用户导航到另一个Activity,或设备屏幕关闭等)。
onPause(),当Activity失去焦点进入“已暂停”状态(the Paused state)时,系统就会调用onPause()。系统将此方法视为用户将要离开Activity的第一个标志;表示Activity不再位于前台(在多窗口模式时Activity仍然对用户可见)。onPause()执行非常简单,而且不一定要有足够的时间来执行保存操作。因此,不应使用onPause()来保存应用或用户数据、进行网络调用或执行数据库事务。onPause()执行完毕后,下一个回调方法为onStop()或onResume()方法,具体取决于Activity进入“已暂停”状态后发生的情况。
onStop(),当Activity对用户不再可见时,即进入“已停止”状态(the Stopped state),系统会调用onStop()。出现这种情况的原因可能是Activity被销毁,新的Activity启动,或者现有的Activity正在进入“已恢复”状态并覆盖了已停止的Activity。停止的Activity将完全不再可见。如果Activity重新与用户互动,则下一个回调方式是onRestart();如果Activity彻底终止,则下一个回访方式是onDestroy()。在onStop()方法中,应用应释放或调整在应用对用户不可见时的无用资源。例如,应用可以暂停动画效果,或从精确位置更新切换到粗略位置更新。还应使用onStop()执行CPU相对密集的关闭操作。例如,如果无法找到更合适的时机来将信息保存到数据库,可以在onStop()期间执行此操作。
onDestroy(),系统会在销毁Activity前调用此方法。该方法是Activity接收的最后一个回调方法。通常,实现onDestroy()是为了确保在销毁Activity或者包含Activity的进程时释放该Activity的所有资源。
下面使用日志工具,观察activity生命周期内状态的转变。在MainActivity.java中插入下方代码。
MainActivity.java代码清单:
public class MainActivity extends AppCompatActivity implements View.OnClickListener { private static final String TAG="MainActivity"; ... @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.d(TAG,"onCreate() called"); ... } ... @Override protected void onStart(){ super.onStart(); Log.d(TAG,"onStart() called"); } @Override protected void onResume(){ super.onResume(); Log.d(TAG,"onResume() called"); } @Override protected void onPause(){ super.onPause(); Log.d(TAG,"onPause() called"); } @Override protected void onStop(){ super.onStop(); Log.d(TAG,"onStop() called"); } @Override protected void onDestroy(){ super.onDestroy(); Log.d(TAG,"onDestroy() called"); } }
项目运行后,可以看到onCreate()、onStart()、onResume()方法被依次调用。QuizDemo的页面呈现在我们面前,并且我们可以与它交互。
点击返回键,可见onPause()、onStop()、onDestroy()方法被依次调用。MainActivity实例被销毁了。
再次点击QuizDemo应用,重新启动,可见onCreate()、onStart()、onResume()方法被依次调用。
点击HOME键,onPause()、onStop()方法被依次调用,activity进入停止状态。这里值得注意的是,如图4所示,此时如果设备内存不足,activity就会被销毁。
从最近的应用把再次把QuizDemo调出来,onStart()、onResume()方法被依次调用,QuizDemo的页面再次呈现在我们面前,并且我们可以与它交互。
2023.03.20补充内容,Android12/API 31以上版本,点击返回键之后并不会调用onDestroy()销毁Activity,而是跟按下HOME键一样,将Activity保存在内存中。这里可以在Activity中重写onBackPress()方法,调用finish()方法。
@Override public void onBackPressed() { super.onBackPressed(); finish(); }
另外,建议在onPause()或onDestroy()方法中调用isFinishing()方法,通过日志观察旋转屏幕和按下返回键两种情况下isFinishing()的返回值。
3.重写onSaveInstanceState()方法保存界面状态
旋转设备会改变设备配置(device configuration)。设备配置实际是一系列特征组合,用来描述设备当前状态。特征包括:屏幕方向、屏幕像素密度、屏幕尺寸、键盘类型、底座模式以及语言等。在运行时配置变更发生时,可能会有更合适的资源来匹配新的设备配置。于是,Android销毁当前activity实例,为新配置寻找最佳资源,创建新Activity实例使用这些资源。拿QuizDemo为例看一下。运行QuizDemo,回答两道题之后,旋转屏幕,发现又回到第一道题了。
在LogCat中查看日志,如下图所示。可见,当屏幕旋转后,activity先被销毁,然后再次启动。那么当然会重新回到第一题了。如果能在销毁activity时保存当前界面状态,并在再次启动activity时把界面状态传输过去,那么就可以毫无顾忌的旋转屏幕了。
在说界面状态保存之前,先说个其他的事情。这时我们还没有创建一个水平方向的布局,因此旋转屏幕后使用的还是activity_main.xml布局。QuizDemo中水平布局看起来也没什么不妥,但是很多情况下,垂直布局旋转之后并不好看,比如计算器。也就是说,最好有个相应的水平布局。res文件夹处右键,依次点击New->Android Resource Directory。按照下图所示,在res文件夹下创建layout-land文件夹。
刚创建的时候,文件夹是空的,而且在Android视角下也看不到该文件夹。在资源管理器中从layout文件夹中复制activity_main.xml到layout-land文件夹。然后就能在Android视角下看到一个activity_main.xml(land)文件了。
-land后缀名是一种配置限定符。Android依靠res子目录的配置限定符定位最佳资源以匹配当前设备配置。下方链接可查看Android的配置限定符及代码的设备配置信息。
有了-land.xml文件后,当设备处于水平方向时,Android会找到并使用res/layout-land目录下的布局资源。其他情况下,则默认使用res/layout目录下的布局资源。为了区分activity_main.xml和activity_main.xml(land),这里小小地改一下水平布局,把按钮部件放置在题目的TextView部件的上方。好了,现在再来测试一下,旋转屏幕后,可见水平布局已经是activity_main.xml(land)了。
现在来看一下Activity的回调方法中的onCreate()方法,注意到它有一个Bundle类型的参数—savedInstanceState。
@Override protected void onCreate(Bundle savedInstanceState) { ... }
https://developer.android.google.cn/reference/android/os/Bundle?hl=zh-cn
Bundle对象会在主线程上进行序列化处理,占用系统进程内存来保存数据。这也就是说,在旧activity实例销毁的时候,把界面状态保存在Bundle对象中,然后新activity实例创建时,从Bundle对象中读取界面状态,就完成了新旧activity切换之间的界面状态的保存。
Bundle类在Android中主要用于传递数据,它用字典的形式保存数据,也就是key-value键值对。key是String类型,value支持多种基本类型。可以用put**方法保存数据,用get**方法获取数据。下图是一些put**方法的截图。
在MainActivity.java中重写onSaveInstanceState(Bundle)方法以便保留界面状态(这里只需要保存当前题目的索引mCurrentIdx和记录回答正确的题目数量的count即可),并添加几个日志方法。
MainActivity.java代码清单:
public class MainActivity extends AppCompatActivity implements View.OnClickListener { private static final String TAG="MainActivity"; private static final String KEY_INDEX="currentIdx"; private static final String KEY_COUNT="count"; ... @Override protected void onCreate(Bundle savedInstanceState) { ...if(savedInstanceState!=null){ mCurrentIdx=savedInstanceState.getInt(KEY_INDEX,0); mCount=savedInstanceState.getInt(KEY_COUNT,0); } showQuestion(); } ... @Override protected void onSaveInstanceState(Bundle savedInstanceState){ super.onSaveInstanceState(savedInstanceState); Log.i(TAG,"onSaveInstanceState(Bundle) called"); savedInstanceState.putInt(KEY_INDEX,mCurrentIdx); savedInstanceState.putInt(KEY_COUNT,mCount); } ... }
运行项目,并查看下日志。当屏幕旋转后,旧activity实例因屏幕旋转依次调用了onPause()、onStop()、onSaveInstanceState()、onDestroy()方法,完成了销毁。创建新activity实例时,根据给定的键值(KEY_INDEX)从savedInstanceState中获取了当前题目的索引的值。
至此,已经实现了QuizDemo旋转屏幕时界面状态保持不变。这部分源代码见 https://gitee.com/larissaLiu/quiz-demo_v2
但是使用onSaveInstanceState()方法只适用于保存简单轻量的界面状态,并不适合保留大量数据,毕竟它需要在主线程上进行序列化处理并占用系统进程内存。那么对于大量数据保存,怎么办呢?可以组合使用数据库、onSaveInstanceState()方法和ViewModel类来保存数据。
https://developer.android.google.cn/topic/libraries/architecture/viewmodel
在笔记三中介绍MVC架构模式时提到过MainActivity.java文件扮演着controller的角色,用于显示界面数据、对用户操作做出响应或处理操作系统通信。如果要求界面控制器也负责从数据库或网络加载数据,那么会使类越发膨胀。为界面控制器分配过多的责任可能会导致单个类尝试自己处理应用的所有工作,而不是将工作委托给其他类。以这种方式为界面控制器分配过多的责任也会大大增加测试的难度。因此从界面控制器逻辑中分离出视图数据所有权的操作更容易且更高效。Android为界面控制器提供了ViewModel辅助程序类,该类负责为界面准备数据。在配置更改期间会自动保留ViewModel对象,以便它们存储的数据立即可供下一个Activity实例使用。ViewModel类在Jetpack库的lifecycle包里。Jetpack包含一系列Android库,它的命名空间都是androidx开头的。
既然是从界面控制器中分离出视图数据所有权的操作逻辑,那就需要新建一个类。在java包处右键,新建一个名为QuizViewModel的java类。该类需要继承ViewModel类。先在QuizViewModel.java中添加以下两个方法,因为我们要用日志先来看一下ViewModel类的生命周期,尤其是它与Activity之间的交互关系。
QuizViewModel.java代码清单:
public class QuizViewModel extends ViewModel { private static final String TAG="QuizViewModel";
public QuizViewModel(){ Log.d(TAG,"ViewModel instance created"); } @Override protected void onCleared(){ super.onCleared(); Log.d(TAG,"ViewModel instance about to be destroyed"); } }
MainActivity.java代码清单:
public class MainActivity extends AppCompatActivity{ private static final String TAG="MainActivity"; private QuizViewModel mQuizViewModel; private TextView mQuestionTextView; private Button mTrueButton; private Button mFalseButton; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main);
Log.d(TAG,"onCreate() called"); mQuizViewModel=new ViewModelProvider(this).get(QuizViewModel.class); Log.d(TAG,String.format("Got a QuizViewModel:%s",mQuizViewModel)); } @Override public void onStart(){ super.onStart(); Log.d(TAG,"onStart() called"); } @Override public void onResume(){ super.onResume(); Log.d(TAG,"onResume() called"); } @Override public void onPause(){ super.onPause(); Log.d(TAG,"onPause() called"); } @Override public void onStop(){ super.onStop(); Log.d(TAG,"onStop() called"); } @Override public void onDestroy(){ super.onDestroy(); Log.d(TAG,"onDestroy() called"); } }
注意,QuizViewModel的实例是通过ViewModelProvider完成的。ViewModelProvider是个注册领用ViewModel的地方。在MainActivity首次访问QuizViewModel时,ViewModelProvider会创建并返回一个QuizViewModel新实例。在设备配置改变之后,MainActivity再次访问QuizViewModel对象时,它返回的是之前创建的QuizViewModel实例。在MainActivity完成使命销毁时(比如用户按了返回键),QuizViewModel实例就从内存里被处理掉了。
运行项目,并观察日志(在搜索框中输入MainActivity|QuizViewModel以筛选日志)
旋转设备屏幕,可见旧activity销毁后,新建activity实例时,activity得到的还是之前的那个QuizViewModel实例。
点击返回键,退出应用。在Activity销毁之前,QuizViewModel实例被销毁了。更多关于ViewModel的生命周期的内容可以参见给出链接的官方文档。
值得注意的是,QuizViewModel和MainActivity的关系是单向的。某个activity会引用其关联的ViewModel,反过来则不行。一个ViewModel绝不能引用activity或view,否则会引发内存泄漏。
了解了ViewModel的生命周期后,来向ViewModel中添加数据,让它完成视图数据所有权的操作者的角色。把之前MainActivity.java中跟数据有关的代码都挪到QuizViewModel.java中。
QuizViewModel.java代码清单:
public class QuizViewModel extends ViewModel { private static final String TAG="QuizViewModel"; int mCurrentIdx=0; int mCount=0; private Question[] mQuestions=new Question[]{ new Question(R.string.test_bj,true), new Question(R.string.test_dc,false), new Question(R.string.test_london,true), new Question(R.string.test_tokyo,true) }; boolean mCurrentQuestionAnswer=mQuestions[mCurrentIdx].isQuestionAnswer(); int mCurrentQuestionText=mQuestions[mCurrentIdx].getQuestionId(); public void moveToNext(){ mCurrentIdx++; if(mCurrentIdx>=mQuestions.length){ mCurrentQuestionText=mQuestions[mCurrentIdx-1].getQuestionId(); mCurrentIdx=-1; }else{ mCurrentQuestionAnswer=mQuestions[mCurrentIdx].isQuestionAnswer(); mCurrentQuestionText=mQuestions[mCurrentIdx].getQuestionId(); } } }
MainActivity.java代码清单:
public class MainActivity extends AppCompatActivity{ private static final String TAG="MainActivity"; private QuizViewModel mQuizViewModel; private TextView mQuestionTextView; private Button mTrueButton; private Button mFalseButton; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main);
mQuizViewModel=new ViewModelProvider(this).get(QuizViewModel.class); mQuestionTextView=(TextView) findViewById(R.id.tx_question); mTrueButton=(Button) findViewById(R.id.btn_true); mFalseButton=(Button) findViewById(R.id.btn_false); mTrueButton.setOnClickListener(view -> { if(mQuizViewModel.mCurrentQuestionAnswer) mQuizViewModel.mCount++; updateQuestion(); }); mFalseButton.setOnClickListener(view -> { if(!mQuizViewModel.mCurrentQuestionAnswer) mQuizViewModel.mCount++; updateQuestion(); }); showQuestion(); } private void updateQuestion(){ mQuizViewModel.moveToNext(); showQuestion(); } private void showQuestion(){ int mCurrentIdx=mQuizViewModel.mCurrentIdx; int mQuestionId=mQuizViewModel.mCurrentQuestionText; if(mCurrentIdx==-1){ mTrueButton.setEnabled(false); mFalseButton.setEnabled(false); String toastText=String.format("%s questions are right.",mQuizViewModel.mCount); Toast.makeText(MainActivity.this,toastText,Toast.LENGTH_SHORT).show(); mQuestionTextView.setText(mQuestionId); } else{ mQuestionTextView.setText(mQuestionId); } } }
OK,至此,已经完成了使用ViewModel保存界面状态了。源代码见 https://gitee.com/larissaLiu/quiz-demo_v3
Moreover,继续分析,如果点了HOME键之后,QuizDemo因为内存不足而被销毁了,答题状态也相应地被销毁了。再次运行QuizDemo又只能从头开始做题了。如果有100道题,前99道都答完了,却因为内存不足被销毁了,这也太亏了。针对这种情况,可以在ViewModel上结合使用保存实例方法。https://developer.android.google.cn/topic/libraries/architecture/viewmodel-savedstate?hl=zh-cn
这里需要使用到的是SavedStateHandle类。与本文第三部分很像,需要保存的数据也以key-value的形式保存在SavedStateHandle类中。QuizViewModel的构造函数中注入一个SavedStateHandle类型的参数。QuizViewModel.java代码清单如下:
public class QuizViewModel extends ViewModel { private static final String TAG="QuizViewModel"; private static final String CURRENT_INDEX="Current_index"; private static final String CURRENT_COUNT="Current_count"; private SavedStateHandle mSavedStateHandle; // int mCurrentIdx=0; // int mCount=0; int mCurrentIdx; int mCount; private boolean mCurrentQuestionAnswer; private int mCurrentQuestionText;
// 添加getter方法 public boolean isCurrentQuestionAnswer() { if(mCurrentIdx==-1){ return mQuestions[mQuestions.length-1].isQuestionAnswer(); }else return mQuestions[mCurrentIdx].isQuestionAnswer(); } public int getCurrentQuestionText() { if(mCurrentIdx==-1) return mQuestions[mQuestions.length-1].getQuestionId(); else return mQuestions[mCurrentIdx].getQuestionId(); }
...
// QuizViewModel的构造函数,注入SavedStateHandle类型参数 public QuizViewModel(SavedStateHandle savedStateHandle){ Log.d(TAG,"ViewModel instance created"); mSavedStateHandle=savedStateHandle;
// 从SavedStateHandle中获取当前题目的索引值 mCurrentIdx=mSavedStateHandle.get(CURRENT_INDEX)==null? 0: mSavedStateHandle.get(CURRENT_INDEX); Log.i(TAG,String.format("current index:%s",mCurrentIdx));
// 从SavedStateHandle中获取已经答对的题目的数量 mCount=mSavedStateHandle.get(CURRENT_COUNT)==null?0:mSavedStateHandle.get(CURRENT_COUNT); Log.i(TAG,String.format("current count:%s",mCount)); } // boolean mCurrentQuestionAnswer=mQuestions[mCurrentIdx].isQuestionAnswer(); // int mCurrentQuestionText=mQuestions[mCurrentIdx].getQuestionId(); public void moveToNext(){ mCurrentIdx++; if(mCurrentIdx>=mQuestions.length){ mCurrentQuestionText=mQuestions[mCurrentIdx-1].getQuestionId(); mCurrentIdx=-1; }else{ mCurrentQuestionAnswer=mQuestions[mCurrentIdx].isQuestionAnswer(); mCurrentQuestionText=mQuestions[mCurrentIdx].getQuestionId(); }
// 将索引和数量保存在SavedStateHandle中 mSavedStateHandle.set(CURRENT_INDEX,mCurrentIdx); mSavedStateHandle.set(CURRENT_COUNT,mCount); } @Override protected void onCleared(){ super.onCleared(); Log.d(TAG,"ViewModel instance about to be destroyed"); } }
因为对mCurrentQuestionAnswer和mCurrentQuestionText field进行了封装,添加了它们的getter方法,因此MainActivity.java需要相应的更改,不过很简单,这里就不贴出MainActivity.java的代码了。好了,现在我们需要模拟按下HOME键后,QuizDemo被销毁。在模拟器上依次点击设置->系统->关于模拟设备->版本号,连续点击7次,进入开发者模式。点击返回键,在系统页面上点击高级->开发者选项,往下拉,在应用处把不保留活动选上,如下图所示。(不同版本的模拟器可能操作不一样)
好了,现在再次运行QuizDemo,答几道题之后,点HOME键,从日志上可以看出,activity被销毁了。然后再次启动QuizDemo时,QuizViewModel从SavedStateHandle处获得了保留的索引和答对的题目数量,因此屏幕上显示的是activity被销毁前的状态。
再来看下日志,源代码可见:https://gitee.com/larissaLiu/quiz-demo_v3
For the more curious: https://developer.android.google.cn/topic/libraries/architecture/saving-states?hl=zh-cn
已保存实例状态(onSaveInstanceState()方法)和ViewModel都不是长期存储解决方案,不能代替本地存储空间,例如数据库。只应该使用这些机制来暂存瞬时界面状态,对于其他应用数据,应使用持久性存储空间。可以通过在各种类型的持久性机制之间划分工作,高效地保存和恢复界面状态。也就是说,合理的组合使用数据库,ViewModel和onSaveInstanceState()方法。
- 本地持久性存储(数据库):存储在打开和关闭Activity时不希望丢失的所有数据,例如歌曲对象的集合。
- ViewModel:在内存中存储显示关联界面控制器所需的所有数据。例如,最近搜索的歌曲对象和最近的搜索查询。
- onSaveInstanceState()方法:存储当系统停止后又重新创建界面控制器时轻松重新加载Activity状态所需的少量数据。
三种方式的对比如下所示:
如果编译的时候显示如下错误:Duplicate class androidx.lifecycle.ViewModelLazy found in modules lifecycle-viewmodel-2.5.0-runtime (androidx.lifecycle:lifecycle-viewmodel:2.5.0) and lifecycle-viewmodel-ktx-2.3.1-runtime (androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1)
就在build.gradle(module)中添加配置节点,把不需要的包exclude掉。添加之后,别忘了同步gradle。
configurations {
implementation.exclude group:'androidx.lifecycle',module:'lifecycle-viewmodel-ktx'
}