Android 笔记:Activity
Activity 是 Android 应用的 UI 界面,使用 XML 来管理界面结构和布局(相当于 HTML),Activity 类本身放置交互相关的代码(相当于 JavaScript)。
View
Activity 上放置着许多 View(视图),显示在屏幕上的一切都是 View(android.view.View
的子类)。
Activity 通常建议使用 AppCompatActivity
作为父类,可以为旧版的系统提供最新的组件和主题以及 API 的支持。
ViewGroup
另外还有一个 ViewGroup,通常用于布局,当作容器来使用(类似 HTML 的 div)。例如 ConstraintLayout
就是一个 ViewGroup(android.view.ViewGroup
的子类)。
布局文件
布局文件存放在 res/layout 目录下。xml 文件命名约定是将 activity 前移,例如 SplashScreenActivity
对应的 xml 配置文件名应该为 activity_splash_screen.xml。
可以使用 android:id="@+id/some_id"
来给控件设置一个 id,例如:<Button android:id="@+id/true_button"/>
。
同一个布局文件中,不允许出现重复的 id。但是在不同的布局文件中,id 是可以重复的。
Manifest
创建 Activity 时,Android Studio 会自动生成好 AndroidManifest.xml 文件中对应的配置。
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application android:allowBackup="true" ...>
<activity .../>
<activity
android:name=".activities.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
这里的 android:name
表示对应的 Activity 代码所在的类,用的是相对路径。省略的部分被配置在了 build.gradle.kts
的 android.namespace
里。
android {
namespace = "me.duron600.myapplication"
...
}
在旧版本的代码中,也可能会看到它被配置在 <manifest>
的 package
属性里。
<manifest package='me.duron600.myapplication' ...>
...
</manifest>
android:exported='true'
表示该 Activity 可以被其它应用启动。API 31 (Android 12) 之后,配置了 intent-filter
的 Activity,android:exported
属性必须是 true
。
<intent-filter>
声明了该 Activity 能够响应的 Intent 类型。
资源文件
Android 代码中的 res 目录用于放置资源文件,其中包含布局文件、图片、图标、国际化用的字符串等。
代码中的资源引用
在 Java/Kotlin 代码中要引用资源时,要通过 R
这个类来引用,例如:R.layout.activity_main
,R.string.app_name
,R.drawable.ic_launcher_background
等。
布局文件中的资源引用
在布局文件中,使用 @
来引用资源,例如:@string/app_name
。
@+
则表示创建一个资源,例如:android:id="@+id/some_id"
。在布局文件中创建了 some_id
之后,同样可以在 Java/Kotlin 代码中通过 R.id.some_id
来引用。通常需要通过 findViewById(Int)
来配合使用:findViewById(R.id.true_button)
,该方法返回一个 View 对象。
自定义样式
为了统一应用的外观风格,许多控件存在类似或相同的样式,但给每个控件各自定义样式的话,会引入许多重复代码,造成维护上的困难。可以把重复的样式分好组,起个名字放在 res/values/styles.xml 里:
<style name="FancyListItemText">
<item name="android:textSize">20sp</item>
<item name="android:textColor">@color/black</item>
</style>
然后在代码中,通过 @style/FancyListItemText
和 R.style.FancyListItemText
来引用。
生命周期
Activity 的状态
以下状态是按启动时的状态变化顺序从上到下排列,反过来则是从运行状态退出的顺序。
不存在
表示 Activity 未启动或者已销毁。Activity 启动时,由“不存在”状态进入“停止”状态,会触发 onCreate(Bundle?)
回调方法;退出时,由“停止”状态进入“不存在”状态会触发 onDestroy()
回调方法。
onDestroy() 的执行时机
注意:Activity 退出并不一定会触发 onDestroy()
,有可能不会立即调用,也有可能完全不调用(资源不足时直接杀死进程)。
实际上我在我的 Pixel 3 和 Xperia 5 II (都是 API 31) 上测试时,从最近的应用里划去某应用,onDestroy()
也没有被调用,而是直接结束了进程。在模拟器(Pixel 3 API 31)中测试,结果也一样,只是相对实机来说表现得更随机。但 Android 12 的教程中说这种情况下会立即执行 onDestroy()
,能看到 log 输出,这与测试结果有出入,暂时没搞明白原因。
同时我在 Pixel 9 API 35 模拟器上做了同样的测试,测试结果倒是符合教程所说的情况。我猜测可能 API 31 的资源释放更激进吧。如果实在要模拟 Activity 正常结束的流程,可以调用 Activity.finish()
方法。
该回调方法的触发是不可预测的,因此尽量不要依赖 onDestroy()
来完成资源释放之类的清理任务和数据保存。
从 Android 12 (API 31) 开始,返回按钮不会马上触发 onDestroy()
,而是在系统需要回收内存时才被调用。如果要观察 onDestroy()
相关的行为,可以在 Android 系统的“开发者选项”中,将“不保留活动(用户离开后即销毁每个活动)”的选项打开(这里的“活动”指的就是 Activity)。也可以直接使用带有低版本 Android 的手机来调试。
另外,设备配置的变化(例如旋转屏幕)一定会触发 onDestroy()
的执行。
综合看来,我的结论是尽量不要让代码依赖 onDestroy()
(尽量不要使用 onDestroy()
)。
停止
表示 Activity 实例在内存中,但用户在屏幕上看不到。在 Activity 界面出现前或者消失后,作为瞬间的状态存在。由“停止”状态进入“暂停”状态时,触发 onStart()
回调方法。如果 Activity 不是在销毁后初次启动,还会在 onStart()
之前触发 onRestart()
回调方法;由“暂停”状态进入“停止”状态时,触发 onStop()
回调方法。
与 onDestroy()
同样的,在某些情况下,进程直接被杀死,onStop()
也不一定会执行。
暂停
表示 Activity 处于前台非活动状态,界面可见或者部件可见。由“暂停”状态进入“运行中”状态时,触发 onResume()
回调方法,由“运行中”状态进入“暂停”状态时,触发 onPause()
回调方法。
运行中
表示 Activity 处于前台,并且用户正与之交互。设备中有很多应用,但任何时候只有一个 activity 处于“运行中”状态。
设备配置改变
旋转设备会改变设备配置(device configuration)。设备配置是一系列特征组合,包括屏幕方向、屏幕像素密度、屏幕尺寸、键盘类型、底座模式及语言等。
设备配置变化时,Android 会销毁当前 Activity,为新配置寻找合适的资源,然后使用这些资源创建实例。
何时刷新 UI
这里,刷新 UI 指的是界面内容随着用户的操作或者数据的更新而变化。
在多窗口模式出现之前,只需要在 onResume()
和 onPause()
之间的生命周期中刷新 UI。但有了多窗口模式之后,用户看得到的 Activity 不一定是正在运行的 Activity。因此,需要在 onStop()
到 onStart()
之间的生命周期中刷新 UI。
获取控件实例
findViewById(Int)
可以用于获取控件的引用。
class MainActivity : AppCompatActivity() {
private lateinit var trueButton: Button
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
trueButton = findViewById(R.id.true_button)
trueButton.setOnClickListener { _: View ->
checkAnswer(true)
}
}
}
View Binding
控件多的时候,声明变量并通过 findViewById(Int)
赋值给它,会产生不少样板代码。View Binding 可以用于消除这些重复。
使用方法,引入 View Binding:
// build.gradle.kts
android {
buildFeatures {
viewBinding = true
}
}
以上的代码改为:
class MainActivity : AppCompatActivity() {
// private lateinit var trueButton: Button
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//setContentView(R.layout.activity_main)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
//trueButton = findViewById(R.id.true_button)
binding.trueButton.setOnClickListener { _: View ->
checkAnswer(true)
}
}
}
示例虽然没减少多少重复代码,但如果控件一多,减少的样板代码还是比较多的。(不过说真的没觉得 ViewBinding 有什么用。)
ViewModel
重置 UI 状态,指的是当前 UI 上的数据全部重置到初始状态。例如某试题 App,用户做题做到第 3 题,退出该 App 后重新打开,用户填写的答案被清空,并且当前题目回到第 1 题,这种情况就是 UI 状态被重置了。
如前文所述,Activity 在退出时有可能被销毁,在屏幕旋转时也会被销毁。一般情况下,在退出应用时,重置 UI 状态是用户预期或允许的行为;但在旋转屏幕时,重置 UI 则是不可接受的行为。
ViewModel 的特性是,在用户旋转屏幕时不被销毁,但在用户主动退出应用时会销毁。因此,可以用来解决上述问题。
ViewModel 存在于 androidx.lifecycle:lifecycle-viewmodel-ktx
包里。
引入 ViewModel
# libs.versions.toml
[versions]
# ...
androidxViewModel="2.8.6"
[libraries]
# ...
androidx-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "androidxViewModel" }
# ...
// build.gradle.kts
plugins {
// ...
}
android {
// ...
}
dependencies {
// ...
implementation(libs.androidx.lifecycle.viewmodel)
}
创建 ViewModel
private const val TAG = "QuizViewModel"
class QuizViewModel : ViewModel() {
init {
Log.d(TAG, "ViewModel instance created")
}
override fun onCleared() {
super.onCleared()
Log.d(TAG, "ViewModel instance about to be destroyed")
}
}
onCleared()
方法会在 ViewModel 销毁前被调用。但是同 Activity 的 onDestroy()
和 onStop()
一样:如果线程被直接杀死,它最终是否会被执行无法得到保障。
在 Activity 里使用 ViewModel
private const val TAG = "MainActivity"
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val quizViewModel by viewModels<QuizViewModel>()
Log.d(TAG, "Got a QuizViewModel: $quizViewModel")
}
}
viewModels()
方法会根据传入的 QuizViewModel
类,创建、缓存并返回对应的 ViewModel 实例,在 Activity 结束 (finish) 之前始终返回同一个实例。你不应当在 Activity 里手动实例化 ViewModel,而应当始终通过 viewModels()
方法创建和获取 ViewModel 实例。
ViewModel 与 Activity 应该是单向关联的,Activity 持有 ViewModel 的引用,但 ViewModel 则不应该持有任何 Activity 以及 View 的引用。通过 viewModels()
创建 ViewModel 实例时,该实例就与当前 Activity 建立起了关联。
我可以禁止旋转 APP 吗
禁止旋转 APP 看起来似乎是个解决该问题的思路。但引起设备配置变化的不止旋转屏幕这一种因素,调节窗口大小、切换 APP 的明亮/黑暗模式等,都会引起设备配置变化。当然,也可以对该 APP 选择禁止这些操作,不允许调节窗口大小、不允许切换明亮/黑暗模式。但这是一个 bad practice,它抛弃了系统自动选择正确资源文件的特性。其次,禁用旋转功能也不解决系统资源不足的情况下进程被杀的问题。
即使 APP 本身被设计为仅横屏或者仅竖屏模式,也有预防设备配置的变化以及进程被杀的情况。
进程结束时
在 Android 系统中,拥有处于“运行中”或者“暂停”状态(简单地说就是界面可见状态)的 Activity 的进程,比其它进程拥有更高的存活优先级。当系统资源不足时,会选择杀死更低存活优先级的进程,包含进程中的所有 Activity。Android 不会单独杀死一个 Activity。
Saved instance state
由前面的知识可以知道,进程结束时,Activity 和 ViewModel 的回调方法的调用是得不到保证的。因此,需要一个保存和恢复被销毁的进程 UI 状态的方法,saved instance state 便应运而生。
要模拟系统资源不足杀死进程,可以在“开发者选项”中找到“不保留活动 (Activity)”这一项,将其打开。此时,运行一个 APP,点击几次按钮改变一下 ViewModel 里的状态,然后按后退按钮或者 Home 按钮,会直接导致进程被杀死。再次打开该 APP,会发现界面回到了初始状态。
此时可以对 ViewModel 进行修改,在其构造方法中声明一个 SavedStateHandle
,viewModels()
方法在创建 ViewModel 时,会将 SavedStateHandle
实例传入。接下来用它来存储状态,便可以解决这个问题:
const val CURRENT_INDEX_KEY = "CURRENT_INDEX_KEY"
//class QuizViewModel: ViewModel() {
class QuizViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() {
//private var currentIndex = 0
private var currentIndex: Int
get() = savedStateHandle[CURRENT_INDEX_KEY] ?: 0
set(value) = savedStateHandle.set(CURRENT_INDEX_KEY, value)
}
此时再用上面的步骤测试,会发现 UI 状态被保留了下来。
Saved instance state 中只应该保存足以恢复界面状态的最少量的数据,如果数据 b 能用数据 a 计算出来,那么只保存数据 a 即可。更不应该用来保存复杂的对象。
持久地保存数据
ViewModel 和 saved instance state 都不适合用于长期保存数据。如果有这方面的需求,要考虑选择一种其它的持久存储的方案,例如数据库,或者 "shared preferences"。也可以考虑存储的远程的服务器上,通过 web 服务来访问。
关于 Jetpack
前面用到的 androidx.lifecycle
库,是 Android Jetpack Components (简称 Jetpack)的一部分。Jetpack 给 Android 程序开发带来了不少的便利,是 Google 提供的一套代码库。这些代码库的包都以 androidx
开头。可以在 https://developer.android.com/jetpack 上查看其它的 Jetpack 库。
有些 Jetpack 是全新的,有些则以被叫作 Support Library 的更大的代码库集合存在了一段时间。如果看到使用 Support Library 的代码,要有使用 Jetpack 替代掉它的意识。
Activity.onSaveInstanceState()
已经是过去式
在以前,SavedStateHandle
还没有发布。人们用的是 onSaveInstanceState(Bundle)
和 onCreate(Bundle?)
来保存和处理 UI 状态。现在这些方法已经被淘汰,如果一份代码中存在这些 API 的调用,尽量尝试改为使用 SavedStateHandle
。
Android如何管理Activity
启动 Activity
当桌面上的 App 图标被点击时,启动的不是该 App,而该 App 的一个 Activity。确切地说,启动的是该 App 中某个被标记为 launcher 的 Activity。在使用 Android Studio 新建一个项目时,默认会把 MainActivity
设置为 launcher。具体的配置是在 manifest 文件中,Activity 定义的 <intent-filter>
元素中:
<activity
android:name=".activities.MainActivity"
android:exported="true">
<intent-filter>
<!--定义应用的主入口-->
<action android:name="android.intent.action.MAIN" />
<!-- 决定 Activity 是否在应用列表中显示,标记这个 Activity 可以被用户直接启动,不一定是程序的实际入口 -->
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
Activity 栈
这个 MainActivity 被启动之后,会被放到一个 Activity 回退栈中。点击 MainActivity 界面上的某个按钮,启动了另一个 Activity B。此时,一个 Activity B 的实例诞生,并被置于回退栈中前一个 Activity 实例的上方。
按下回退按钮,Activity B 从栈中弹出并销毁,前一个 MainActivity 回到栈顶。
在 MainActivity 上按下回退按钮,MainActivity 会进入“停止”状态,并进入后台。同时界面切换到进入 MainActivity 之前的 Activity(也就是回退栈中,MainActivity 之前的 Activity)。
可以看出,ActivityManager 维护着的回退栈,不只是用于你编写的 App 中的 Activity,所有 App 的 Activity 共享着同一个回退栈。
这里存在的一点区别是:退出 Activity B 时,该 Activity 会从栈中弹出,并从系统内存中销毁;但退出 MainActivity 时,它不会从内存中销毁(起码不会立即销毁),处于“停止”状态。因为 MainActivity 是个 Launcher Activity。因此,在前面描述的关于 Activity 的 onStop()
, onDestroy()
以及 ViewModel 的 onCleared()
执行时机的特殊情况,只存在于 Launcher Activity 中,非 launcher 的 Activity 是能正常回调这些方法的。
这个区别的出现开始于 Android 12(API 31)。在之前的 Android 版本中,Launcher Activity 与 非 Launcher Activity 的行为一致。