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"/>

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 对象。

生命周期

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

停止

表示 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

1