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.ktsandroid.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_mainR.string.app_nameR.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/FancyListItemTextR.style.FancyListItemText 来引用。

生命周期

activity_lifecycle.png

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())。

补充:见 Android 如何管理 Activity

停止

表示 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 进行修改,在其构造方法中声明一个 SavedStateHandleviewModels() 方法在创建 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 的行为一致。

1