【Android进阶】6、多 Activity 间用 intent、startActivityForResult、setResult 和 onActivityResult 通信

本次我们会开发 多 Activity 的 APP。其中会涉及,启动一个新的 Activity,并从一个 Activity 向另一个的 Activity 传递数据。

我们添加一个新的Activity,可看到题目答案, 效果如下:
在这里插入图片描述

6.1 设计子Activity

首先在 strings.xml 中添加字符串资源:

    <string name="warning_text">您确定要这样吗?</string>
    <string name="show_answer_button">查看答案</string>
    <string name="cheat_button">让我看一眼答案吧!</string>
    <string name="judgment_toast">作弊是不对的.</string>

创建新的 CheatActivity,如下图所示:

在这里插入图片描述
填入新 Activity 的名称,如下:

在这里插入图片描述

6.1.1 设计布局

我们将 CheatActivity 的布局设计为如下:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    android:orientation="vertical"
    tools:context="com.bignerdranch.android.geoquiz.CheatActivity">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:padding="24dp"
        android:text="@string/warning_text" />

    <TextView
        android:id="@+id/answer_text_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:padding="24dp"
        tools:text="Answer" />

    <Button
        android:id="@+id/show_answer_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/show_answer_button" />
</LinearLayout>

整体布局如下:

在这里插入图片描述

拓扑结构如下:

在这里插入图片描述

其中,点预览界面上方的工具栏,可以切换横竖屏的预览效果:

在这里插入图片描述

6.1.2 声明 manifest

在 AndroidStudio 添加 Activity 后,其会自动在 AndroidManifest.xml 中添加 CheatActivity 的声明:

<activity android:name=".CheatActivity" android:exported="false" />

这里的android:name属性是必需的。属性值前面的点号(.)告诉操作系统:activity类文件位于manifest配置文件头部包属性值指定的包路径下。

android:name属性值也可以设置成完整的包路径,比如android:name=“com.bignerdranch.android.geoquiz.CheatActivity”。

6.1.3 为 MainActivity 添加新按钮

添加一个 cheat_button 按钮,如下:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    android:orientation="vertical">

    <TextView
        android:id="@+id/question_text_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:gravity="center"
        android:padding="24dp"
        tools:text="@string/question_text" />

    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <Button
            android:id="@+id/true_button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/true_button" />

        <Button
            android:id="@+id/false_button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/false_button" />
    </LinearLayout>

    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content">

        <ImageButton
            android:id="@+id/prev_button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:src="@drawable/arrow_left" />

        <ImageButton
            android:id="@+id/next_button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:src="@drawable/arrow_right" />
    </LinearLayout>

    <Button
        android:id="@+id/cheat_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="24dp"
        android:text="@string/cheat_button">

    </Button>
</LinearLayout>

布局如下图所示:

在这里插入图片描述

6.2 启动子Activity

当调用StartActivity(Intent)时, 会发请求给操作系统的ActivityManager, 其会创建Activity实例并调用起onCreate(Bundle?)函数,代码如下:

    private lateinit var cheatButton: Button
    cheatButton = findViewById(R.id.cheat_button)
    cheatButton.setOnClickListener {
        val intent = Intent(this, CheatActivity::class.java)
        startActivity(intent)
    }

通信流程图如下:

在这里插入图片描述

Intent 的构造函数是 Intent(packageContext: Context, class: Class<?>),调用方式如下:

cheatButton.setOnClickListener {
	// Start CheatActivity
	val intent = Intent(this, CheatActivity::class.java)
	startActivity(intent)
}

Intent 对象是 component 用来与操作系统通信的一种媒介工具。我们可用 intent 用来告诉 ActivityManager 该启动哪个 activity,因此可使用以下构造函数:Intent(packageContext: Context, class: Class<?>)

其中,传入 Intent 构造函数的 Class 类型参数,会告诉 ActivityManager 应该启动哪个 activity。其中 Context 参数告诉 ActivityManager 在哪里可以找到它。

在启动 activity 前,ActivityManager 会确认指定的 Class 是否已在 manifest 配置文件中声明:

  • 如果已完成声明,则启动 activity,应用正常运行。
  • 反之,则抛出 ActivityNotFoundException 异常,应用崩溃。
  • 这就是必须在 manifest 配置文件中声明应用的全部 activity 的原因。

6.2.1 传递参数

可以把extra信息,附加在传入 startActivity(Intent) 函数的 Intent上发送出去。

传参的流程图如下:
在这里插入图片描述

CheatActivity 通过 伴生对象的newIntent() 创建 Intent,代码如下:

const val EXTRA_ANSWER_IS_TRUE = "com.bignerdranch.android.geoquiz.answer_is_true"

class CheatActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_cheat)
    }

    companion object {
        fun newIntent(packageContext: Context, answerIsTrue: Boolean): Intent {
            return Intent(packageContext, CheatActivity::class.java).apply {
                putExtra(EXTRA_ANSWER_IS_TRUE, answerIsTrue)
            }
        }
    }
}

在 MainActivity,可通过调用 CheatActivity 的 newIntent() 方法,启动 CheatActivity 并向其传递参数,代码如下:

cheatButton.setOnClickListener {
	val answerIsTrue = quizViewModel.currentQuestionAnswer
	val intent = CheatActivity.newIntent(this@MainActivity, answerIsTrue)
	startActivity(intent)
}

CheatActivity 被创建后,可接收传入的参数,并显示到对应的 TextView 控件上,代码如下:

package com.bignerdranch.android.geoquiz

import android.content.Context
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Button
import android.widget.TextView

const val EXTRA_ANSWER_IS_TRUE = "com.bignerdranch.android.geoquiz.answer_is_true"

class CheatActivity : AppCompatActivity() {
    private lateinit var answerTextView: TextView
    private lateinit var showAnswerButton: Button
    private var answerIsTrue = false
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_cheat)
        answerIsTrue = intent.getBooleanExtra(EXTRA_ANSWER_IS_TRUE, false) // 题目的正确答案,从 MainActivity 传来
        answerTextView = findViewById(R.id.answer_text_view)
        showAnswerButton = findViewById(R.id.show_answer_button)
        showAnswerButton.setOnClickListener {
            val answerText = when {
                answerIsTrue -> R.string.true_button
                else -> R.string.false_button
            }
            answerTextView.setText(answerText)
        }
    }

    companion object {
        fun newIntent(packageContext: Context, answerIsTrue: Boolean): Intent {
            return Intent(packageContext, CheatActivity::class.java).apply {
                putExtra(EXTRA_ANSWER_IS_TRUE, answerIsTrue)
            }
        }
    }
}

效果如下,当点击 “让我看一眼答案吧!”按钮 时,MainActivity 传递正确答案 到 CheatActivity,当在 CheatActivity 点击 “查看答案” 按钮时,在 TextView 处显示正确答案。

在这里插入图片描述

6.3 从子Activity返回结果

现在,用户可以毫无顾忌地偷看答案了。如果CheatActivity能把用户是否看过答案的情况通知给MainActivity就更好了。下面来解决这个问题。

通过 Activity.startActivityForResult(Intent, Int) 函数,可以获得子 Activity 的返回结果。

  • 其第一个参数是 intent。
  • 第二个参数是请求代码。请求代码是先发送给子 activity,然后再返回给父 activity 的整数值,由用户定义。当一个 activity 启动多个不同类型的子 activity,且需要判断消息回馈方时,就会用到该请求代码。

虽然 MainActivity 只启动一种类型的子 activity,但为应对未来的需求变化,现在就应设置请求代码常量。

private const val REQUEST_CODE_CHEAT = 0
startActivityForResult(intent, REQUEST_CODE_CHEAT)

在父 activity 需要依据子 activity 的完成结果采取不同操作时,设置结果代码就非常有用。

例如,假设子 activity 有一个 OK 按钮和一个 Cancel 按钮,并且每个按钮的点击动作分别设置有不同的结果代码。那么,根据不同的结果代码,父 activity 就能采取不同的操作。

子 Activity 可通过如下两种方式返回给父 Activity,其中 resultCode 可为一下常量之一(Activity.RESULT_OK 和 Activity.RESULT_CANCELED)。

setResult(resultCode: Int)
setResult(resultCode: Int, data: Intent)

CheatActivity 中,当用户点击 SHOW ANSWER 按钮时,CheatActivity 调用 setResult(Int, Intent) 函数将结果代码以及 intent 打包,代码如下:

    showAnswerButton.setOnClickListener {
        val answerText = when {
            answerIsTrue -> R.string.true_button
            else -> R.string.false_button
        }
        answerTextView.setText(answerText)
        setAnswerShownButton(true)
    }

    private fun setAnswerShownButton(isAnswerShown: Boolean) {
        val data = Intent().apply{
            putExtra(EXTRA_ANSWER_SHOWN, isAnswerShown)
        }
        setResult(Activity.RESULT_OK, data)
    }

当按Back键返回桌面时, ActivityManager会调用父Activity的 onActivityResult(requestCode: Int, resultCode: Int, data: Intent)函数, 如下图:

在这里插入图片描述

6.4 父Activity处理返回的结果

首先,我们在 QuizViewModel 中添加 isCheater 变量,代码如下:

package com.bignerdranch.android.geoquiz

import android.util.Log
import androidx.lifecycle.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")
    }

    val questionBank = listOf(
        Question(R.string.question_australia, true),
        Question(R.string.question_oceans, true),
        Question(R.string.question_mideast, false),
        Question(R.string.question_africa, false),
        Question(R.string.question_americas, true),
        Question(R.string.question_asia, true),
    )
    var currentIndex = 0
    var isCheater = false

    val currentQuestionAnswer: Boolean
        get() = questionBank[currentIndex].answer

    val currentQuestionText: Int
        get() = questionBank[currentIndex].textResId

    fun moveToNext(step: Int) {
        val sz = questionBank.size
        currentIndex = (currentIndex + step + sz) % sz
    }

    fun setCurrentQuestionAnswered() {
        questionBank[currentIndex].answered = true
    }
}

其次,在 MainActivity 中处理返回数据,首先处理 resultCode 判断是否正常,其次判断 requestCode 判断 CheatActivity 的业务意图,代码如下:

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if (requestCode != Activity.RESULT_OK) {
            return
        }
        if (requestCode == REQUEST_CODE_CHEAT) {
            quizViewModel.isCheater = data?.getBooleanExtra(EXTRA_ANSWER_IS_TRUE, false) ?: false
        }
    }
    private fun checkAnswer(userAnswer: Boolean) {
        val correctAnswer = quizViewModel.currentQuestionAnswer
        val messageResId = when {
            quizViewModel.isCheater -> R.string.judgment_toast
            userAnswer == correctAnswer -> R.string.correct_toast
            else -> R.string.incorrect_toast
        }
        val t = Toast.makeText(this, messageResId, Toast.LENGTH_SHORT)
        t.setGravity(Gravity.TOP, 0, 0)
        t.show()
    }

效果如下,当查看答案后,CheatActivity 向 MainActivity 传码,MainActivity 判断,并显示对应的 UI:

在这里插入图片描述

6.5 Activity的管理和使用

来看看当我们在各activity间往返的时候,操作系统层面到底发生了什么。首先,在桌面启动器中点击GeoQuiz应用时,操作系统并没有启动应用,而只是启动了应用中的一个activity。确切地说,它启动了应用的launcher activity。在 GeoQuiz 应用中,MainActivity就是它的launcher activity。

使用应用向导创建 GeoQuiz 应用以及 MainActivity 时,MainActivity 默认被设置为 launcher activity。配置文件中,MainActivity 声明的intent-filter元素节点下,可看到 MainActivity 被指定为 launcher activity。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"
    package="com.bignerdranch.android.geoquiz">

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.GeoQuiz"
        tools:targetApi="31">
        <activity
            android:name=".CheatActivity"
            android:exported="false" />
        <activity
            android:name=".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>

各 Activity 的栈关系如下图:

在这里插入图片描述

按回退键 或 在CheatActivity中调用Activity.finish()函数,都可以把 CheatActivity实例弹出栈外,使 MainActivity 重新回到栈顶部。

如果运行GeoQuiz应用,在MainActivity界面按回退键,MainActivity将从栈里弹出,我们将退回到GeoQuiz应用运行前的画面, 如下两张图在这里插入图片描述

如果从桌面启动器启动GeoQuiz应用,在MainActivity界面按回退键,将退回到桌面启动器界面,如下图:

在这里插入图片描述

其实, ActivityManager 维护着一个非特定应用独享的后退栈。所有应用的activity都共享该后退栈。这也是将ActivityManager设计成 操作系统级的 activity管理器,来负责启动应用activity的原因之一。显然,后退栈是作为一个整体共享于操作系统及设备,而不单单用于某个应用。

6.6 挑战:堵住作弊漏洞

现在,GeoQuiz 应用有个大漏洞。用户作弊后,可以旋转CheatActivity来清除作弊痕迹,然后回到MainActivity界面,假装什么也没发生过,效果如下:

在这里插入图片描述

我们希望在设备旋转或进程销毁时,设法保存 CheatActivity 的 UI 状态数据,堵住这个漏洞。

需求其实是数据持久化,我们可用 ViewModel 或 onSaveInstanceState 实现,我们这次选 onSaveInstanceState。

当 CheatActivity 的 showAnswerButton 按钮被点击时,置位 cheatViewModel.isAnswerShown 状态,代码如下:

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_cheat)

        if (savedInstanceState != null) {
            cheatViewModel.isAnswerShown = savedInstanceState.getBoolean(isAnswerShowedKey, false)
        }
        answerIsTrue = intent.getBooleanExtra(EXTRA_ANSWER_IS_TRUE, false) // 题目的正确答案
        answerTextView = findViewById(R.id.answer_text_view)
        showAnswerButton = findViewById(R.id.show_answer_button)

        showAnswerButton.setOnClickListener {
            val answerText = when {
                answerIsTrue -> R.string.true_button
                else -> R.string.false_button
            }
            answerTextView.setText(answerText)
            cheatViewModel.isAnswerShown = true
            setAnswerShowResult(cheatViewModel.isAnswerShown)
        }
    }

当旋转屏幕时,CheatActivity 在 Stop 之前会自动调用 onSaveInstanceState 回调函数存储状态,代码如下:

    override fun onSaveInstanceState(savedInstanceState: Bundle) {
        super.onSaveInstanceState(savedInstanceState)
        savedInstanceState.putBoolean(isAnswerShowedKey, cheatViewModel.isAnswerShown)
    }

在这里插入图片描述

此时,当用户按下 Back 键(企图假装并没有作弊偷看答案时)回到 MainActivity 时,虽然 CheatActivity 已被 Stop,但 CheatViewModel 的数据一直常驻内存,则可通过 onBackPressed() 从 CheatActivity 返回 MainActivity,代码如下:

    override fun onBackPressed() {
        super.onBackPressed()
        setAnswerShowResult(cheatViewModel.isAnswerShown)
    }

针对,MainActivity,首先引入 CheatViewModel,代码如下:



版权声明:本文为jiaoyangwm原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。