第一章 第一行Android代码

1.1 Android系统架构和开发特色

1.1.1 Android系统架构

​ Android大致可以分为4层架构:Linux内核层、系统运行库层、应用框架层和应用层。

01.Linux内核层

​ Android系统是基于Linux内核的,这一层为Android设备的各种硬件提供了底层的驱动,如显示驱动、音频驱动、照相机驱动、蓝牙驱动、Wi-Fi驱动、电源管理等。

02.系统运行库层

​ 这一层通过一些C/C++库为Android系统提供了主要的特性支持。如SQLite库提供了数据库的支持,OpenGL|ES库提供了3D绘图的支持,Webkit库提供了浏览器内核的支持等。 ​ 在这一层还有Android运行时库,它主要提供了一些核心库,允许开发者使用Java语言来编写Android应用。另外,Android运行时库中还包含了Dalvik虚拟机(5.0系统之后改为ART运行环境),它使得每一个Android应用都能运行在独立的进程中,并且拥有一个自己的虚拟机实例。相较于Java虚拟机,Dalvik和ART都是专门为移动设备定制的,它针对手机内存、CPU性能有限等情况做了优化处理。

03.应用框架层

​ 这一层主要提供了构建应用程序时可能用到的各种API,Android自带的一些核心应用就是 使用这些API完成的,开发者可以使用这些API来构建自己的应用程序。

04.应用层

​ 所有安装在手机上的应用程序都是属于这一层的,比如系统自带的联系人、短信等程序, 或者是你从Google Play上下载的小游戏,当然还包括你自己开发的程序。

image-20260105133624240

1.1.2 Android开发特色

01.四大组件

​ Android系统四大组件分别是Activity、Service、BroadcastReceiver和ContentProvider。其中Activity是所有Android应用程序的门面,凡是在应用中你看得到的东西,都是放在Activity中的。而Service就比较低调了,你无法看到它,但它会在后台默默地运行,即使用户退出了应用,Service仍然是可以继续运行的BroadcastReceiver允许你的应用接收来自各处的广播消息,比如电话、短信等,当然,你的应用也可以向外发出广播消息。ContentProvider则为应用程序之间共享数据提供了可能,比如你想要读取系统通讯录中的联系人,就需要通过ContentProvider来实现。

02.系统控件、SQLite数据库、多媒体

​ 暂且按下不表,用到的时候细说

1.2创建一个Android项目并解析项目构成

1.2.1 创建一个Android项目

​ 这边我就不过多赘述了,总之,创建好Android项目之后想让它运行还需要打包为apk文件到Android系统上运行,可以使用AndroidStudio的虚拟机功能,也可以直接连到真机上,这边我是建议直接连真机。

1.2.2 分析项目构成

image-20260105140452732

01 .gradle和.idea

​ 这两个目录下放的都是AS自动生成的一些文件,不需要过多关心

02 app

​ 项目中的代码,资源等内容都是放在这个目录下的,我们后面的开发工作也基本是在这个目录下进行的,等会进行详细的讲解

03 gradle

​ 这个目录下包含gradle wrapper的配置文件,gradle wrapper是类似pyinstaller、CMake的打包程序。

04 .gitgnore

​ 这个文件是用来将指定的目录或文件排除在版本控制之外的。关于版本控制,我们将在第6章中开始正式的学习。

05 build.gradle

​ 这是项目全局的gradle构建脚本,通常这个文件中的内容是不需要修改的。稍后我们将会详细分析gradle构建脚本中的具体内容

06 gradle.properties

​ 这个文件是全局的gradle配置文件,在这里配置的属性将会影响到项目中所有的gradle编

译脚本

07 gradlew和gradlew.bat

​ 两个批处理脚本,用于执行gradle命令,gradlew用于Linux或者Mac,gradlew.bat用于Windows。

08 local.properties

​ 用于指定本机的Android SDK路径,通常自动生成,如果生成错误,手动调整路径即可

09 settings.gradle

​ 指定项目中所有引入的模块。

10 其他

​ 除此之外,有些版本的AS可能会存在iml文件,这是由于AS是基于IDEA开发的,而iml文件是IDEA中用于标识这是一个IDEA项目的。

1.2.3 app目录构成

​ 对项目有了总体的认识后,对app目录进行一个详解,因为不管是开发还是逆向,app部分都是编写程序或者脚本的主战场。

image-20260105154817571

01 build

​ 编译时自动生成的东西,不需要过多关心。

02 libs

​ 用于存放第三方jar包,包括自己编写jar库文件。这个目录下的jar包会被自动添加到项目构建路径中。

03 androidTest

​ 存放Android Test测试用例,可以对项目进行一些自动化测试。

04 java

​ 是编写和存放java和kotlin代码的地方。

05 res

​ 存放资源文件的地方。

06 AndroidManifest.xml

​ 整个Android项目的配置文件,四大组件都需要在此注册,还可以在这个文件中对应用添加权限声明。

07 Test

​ 用于编写Unit Test,是对项目进行自动化测试的另一种方法。

08 .gitgnore

​ 将app模块内部内指定的目录或者文件排除在控制之外。

09 build.gradle

​ app模块的gradle构建脚本,会指定很多项目构建相关的配置。

10 proguard-rules.pro

​ 指定项目代码的混淆规则,是防破解的手段。

接下来以默认的helloworld项目为例,理解Android项目如何运行起来的:

  1. 注册

    打开AndroidManifest.xml:

    <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.Test"
            tools:targetApi="31">
            <activity
                android:name=".MainActivity"
                android:exported="true">
                <intent-filter>
                    <action android:name="android.intent.action.MAIN" />
    				//ainActivity是这个项目的主Activity
                    <category android:name="android.intent.category.LAUNCHER" />
                    //在手机上点击应用图标,首先启动的就是这个Activity
                </intent-filter>
    
                <meta-data
                    android:name="android.app.lib_name"
                    android:value="" />
            </activity>
        </application>

    注意这段代码中的<activity>部分表示对MainActivity进行注册,没有在AndroidManifest中注册的activity组件是无法使用的。

  2. 编写组件并调用

    接下来去看MainActivity类:

    public class MainActivity extends AppCompatActivity {
      
          @Override
          protected void onCreate(Bundle savedInstanceState) {
              super.onCreate(savedInstanceState);
              setContentView(R.layout.activity_main);
          }
      }

    会发现activity的组件编写并不直接写在类中,而是通过调用资源文件中的布局文件,这和Windows开发时用RC文件保存资源文件,通过头文件引用很像。接下来看引用的资源文件activity_main.xml:

    <?xml version="1.0" encoding="utf-8"?>
    <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">
    
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Hello World!"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
    
    </androidx.constraintlayout.widget.ConstraintLayout>

    如果了解过Windows开发,就会发现这类布局文件中的控件名很熟悉。

1.2.4 res目录构成

01 解析res构成

image-20260105162724173

看到这么多的子目录也不用害怕,其实归纳一下,res目录中的内容就变得非常简单了。所有以“drawable”开头的目录都是用来放图片的,所有以“mipmap”开头的目录都是用来放应用图标的,所有以“values”开头的目录都是用来放字符串、样式、颜色等配置的,所有以“layout”开头的目录都是用来放布局文件的

之所以有这么多“mipmap”开头的目录,其实主要是为了让程序能够更好地兼容各种设备。drawable目录也是相同的道理,虽然Android Studio没有帮我们自动生成,但是我们应该自己创建drawable-hdpi、drawable-xhdpi、drawable-xxhdpi等目录。在制作程序的时候,最好能够给同一张图片提供几个不同分辨率的版本,分别放在这些目录下,然后程序运行的时候,会自动根据当前运行设备分辨率的高低选择加载哪个目录下的图片。当然这只是理想情况,更多的时候美工只会提供给我们一份图片,这时你把所有图片都放在drawable-xxhdpi目录下就好了,因为这是最主流的设备分辨率目录。

02 调用资源

打开一个资源文件,比如:

<resources>
    <string name="app_name">test</string>
</resources>

我们可以通过两种方式引用:

  • 在代码中通过R.string.app_name可以获得该字符串的引用。
  • 在XML中通过@string/app_name可以获得该字符串的引用。

基本的语法就是上面这两种方式,其中string部分是可以替换的,如果是引用的图片资源就可以替换为drawable,如果是引用的应用图标就可以替换成mipmap,如果是引用的布局文件就可以替换成layout,以此类推。

1.2.5 详解build.gradle文件

​ 不同于Eclipse,Android Studio是采用Gradle来构建项目的。Gradle是一个非常先进的项目构建工具,它使用了一种基于Groovy的领域特定语言(DSL)来进行项目设置,摒弃了传统基于XML(如Ant和Maven)的各种烦琐配置。

​ 在之前的项目构成详解中,可以看到在app模块外部和内部各有一个build.gradle文件,这两个文件都对构建AS项目起到至关重要的作用,接下来先看看外部的build.gradle,需要注意的是,书上的那种写法现在已经过时了,书上的build.gradle将变量、仓库等等都堆在一起,下面的

功能书上的旧写法现在的新写法
声明 Android 插件classpathplugins {}
指定插件版本classpath ...:3.5.2version '7.3.1'
仓库声明repositories {}移到 settings.gradle
Kotlin 插件手动 classpath自动 plugins
推荐程度❌ 已淘汰✅ 官方推荐

现在的结构被分解为更加低聚合的结构如下了:

Project
├── settings.gradle   ← 插件仓库 & 模块管理
├── build.gradle      ← 插件版本声明(apply false)
├── app/
│   └── build.gradle  ← 真正应用插件

接下来看app内部的build.gradle:

plugins {
    id 'com.android.application'
}

android {
    namespace 'com.example.test'
    compileSdk 32   //用于指定项目的SDK编译版本

    defaultConfig {
        applicationId "com.example.test" //包名,不可重复
        minSdk 21		//最低兼容SDK
        targetSdk 32	//目标SDK
        versionCode 1	//版本号
        versionName "1.0"	//版本名

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
        //启用JUNIT测试
    }

    buildTypes {
        release {
            minifyEnabled false  //是否混淆
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'   //指定混淆时使用的规则文件
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8 //语法级别
        targetCompatibility JavaVersion.VERSION_1_8	//字节码级别
    }
}

dependencies {

    implementation 'androidx.appcompat:appcompat:1.4.1'
    implementation 'com.google.android.material:material:1.5.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
    testImplementation 'junit:junit:4.13.2'
    androidTestImplementation 'androidx.test.ext:junit:1.1.3'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}

稍显复杂,分模块分析

01 plugin闭包

​ 这里表示项目构建用到的插件

02 android闭包

这里分闭包分析,具体的属性表示的含义以注释的形式标注在代码中。

  1. defaultConfig闭包:对项目的更多细节进行配置
  2. buildTypes闭包:一般只会有两个子闭包,debug和release,debug可以不配置。
  3. compileOptions闭包:指定java编译器版本

03 dependencies闭包

​ 一些依赖的声明

1.3 日志工具

Android中的日志工具类是Log(android.util.Log),这个类中提供了如下5个方法来供我们打印日志。

  1. Log.v()。用于打印那些最为琐碎的、意义最小的日志信息。对应级别verbose,是Android日志里面级别最低的一种。

  2. Log.d()。用于打印一些调试信息,这些信息对你调试程序和分析问题应该是有帮助的。对应级别debug,比verbose高一级。

  3. Log.i()。用于打印一些比较重要的数据,这些数据应该是你非常想看到的、可以帮你分析用户行为的数据。对应级别info,比debug高一级。

  4. Log.w()。用于打印一些警告信息,提示程序在这个地方可能会有潜在的风险,最好去修复一下这些出现警告的地方。对应级别warn,比info高一级。

  5. Log.e()。用于打印程序中的错误信息,比如程序进入了catch语句中。当有错误信息打印出来的时候,一般代表你的程序出现严重问题了,必须尽快修复。对应级别error,比warn高一级。

第二章 Kotlin快速入门

​ 其实IDEA也可以运行kotlin,但是作为学习用的话还是直接使用线上网页服务的来的方便快捷。https://play.kotlinlang.org

2.1 变量与函数

2.1.1 变量

kotlin的变量相对于java和其他类C语言来说有些许抽象,我总结一下大致是以下几点:

  1. 变量声明类似python,kotlin可以自推导变量类型。关键字也没有java那么多,只分为val(value)和var(variable)两种

    fun main(){
        val a = 10  //不可变,相当于const类型
        var b = 10  //可变
    }//kotlin结尾不需要加分号,类似python
  2. 变量也可以显式声明,但是kotlin中的变量抛弃了java中的基本数据类型,全部使用对象数据类型。也就是Int类型成为一个类,拥有自己的方法与继承结构。

    image-20260106155343718

    fun main(){
        var a: Int = 10
        a *= 10
        println(a)
    }

2.1.2 函数

函数和方法本质上是一个东西,只是翻译的不同,很多时候我会将方法和函数混用,不必在意。

kotlin当然支持自定义函数,规则如下:

fun methodName(param1:dataType, param2:dataType,……,): returnType {}

解释如下:

  1. fun是函数声明的关键字,无论什么函数都需要fun来声明
  2. methodName是函数名
  3. param是参数名,dataType是参数类型
  4. returnType是返回值类型,可选,不填相当于void
  5. {}是函数体

比如这里写一个max函数:

fun myMax(a:Int, b:Int): Int{
    if (a > b) return a
    return b
}
fun main() {
 	println(myMax(8,9))   
}
kotlin函数语法糖

这里补充一个kotlin语法糖:当一个函数体中只有一行代码时,kotlin允许不写函数体,而是通过等号将代码赋值给函数声明的方式定义函数。

import kotlin.math.max
fun myMax(a:Int, b:Int): Int = max(a,b)
fun main() {
 	println(myMax(8,9))   
}//虽然在实战中自定义max然后引用kotlin的max函数很难绷,但这里只是演示一下语法糖

另外,前面说到kotlin可以根据赋值的值的类型进行自推导变量类型,其实这个特性在函数这里也适用,在上述语法糖中,由于等号足以表达返回值的意思,也可以省略对于返回值类型的定义,简化如下:

import kotlin.math.max
fun myMax(a:Int, b:Int) = max(a,b)
fun main() {
 	println(myMax(8,9))   
}//虽然在实战中自定义max然后引用kotlin的max函数很难绷,但这里只是演示一下语法糖

2.2 逻辑控制

2.2.1 if条件控制

kotlin中的if和java语言中的if几乎没有区别,唯一的区别是kotlin的if表达式可以有返回值,if的返回值是执行分支的最后一条语句,满足这种用法需要几个条件,分支完整(至少必须要有if和else)、分支的最后一条语句必须类型相同。

fun myMax(a:Int, b:Int): Int{
    val value = if (a > b) {
        a
    } else{
        b
    }
    return value
}

简化一下:

fun myMax(a:Int, b:Int) = if (a>b) a else b

2.2.2 when条件控制

kotlin的when表达式有些类似java中的switch语句,但是比它方便得多,java中的switch只能传入特定类型的变量且每个case条件都需要在最后加上break,相当麻烦还有可能疏忽导致漏写。

假设现在需要实现一个简单的输入姓名就可以查询学生成绩的小函数,运用if表达式如下:

fun getScore(name: String) = if (name == "one") {
    86
} else if (name == "two") {
    85
} else {
    0
}

相当麻烦,特别是这还是人少的情况下,接下来用when表达式;

fun getScore(name: String) = when (name) {
    "one" -> 86
    "two" -> 85
    else -> 0
}

when表达式和if一样也可以有返回值,条件也和if相同,when的格式:

when (变量) {
    匹配值 -> {执行逻辑}
}

值得一提的是,除了精确匹配之外,when还允许类型匹配,比如:

fun checkNumber(num: Number) {
 when (num) {
 is Int -> println("number is Int")
 is Double -> println("number is Double")
 else -> println("number not support")
 }
}

其中Number是一个抽象类,有关数字的比如Int、Long、Float、Double等类都是其子类,is是一个关键字,类似java中的instanceof。

另外,when还有另一种写法:

when{
    条件语句 -> {}
    条件语句 -> {}
}

在某些情况下很好用,比如:

fun getScore(name: String) = when {
 name.startsWith("Tom") -> 86
 name == "Jim" -> 77
 name == "Jack" -> 95
 name == "Lily" -> 100
 else -> 0
}

2.2.3 循环控制

kotlin中的循环也分while和for,while和类C语言的while没有区别,不讲。这里着重讲for循环。

java和类C语言中的for-i循环被舍弃,取而代之的是类似python的for-in语言。在讲for-in循环前,补充一个kotlin的概念,区间:

val range = 0..10

这个看起来很怪,但是它是合法的,0..10表示一个从0到10的闭区间,数学表达为[0,10]。..是一个关键字,用于创造闭区间,指定..的左右端点即可创造一个闭区间。

接下来我们进行遍历:

fun main() {
    for (i in 0..10) {
        println(i)
    }
}

很多时候闭区间不如左闭右开区间好用,比如在遍历数组下标时,引入until关键字,用于创建左闭右开区间。

fun main() {
    for (i in 0 until 10) {
        println(i)
    }
}

另外,还有step关键字可以选择性遍历,等价于每次在分支中执行i += 2比如:

for (i in 0 until 10 step 2)

等价于python中的

for i in range(0,10,2)

之前的..关键字和until关键字都是升序的,如果有需要可以用downTo关键字创造降序区间

2.3 面向对象

面向对象是什么就不解释了,在python相关的笔记里阐释过很多遍了。

2.3.1 类与对象

这里以一个例子说明类的构建与实例化:

Person类的定义:

class Person {
    var name:String = ""
    var age:Int = 0

    fun eat(){
        println("eat")
    }
}

实例化:

fun main(){
    println("test")
    val p = Person()
    p.age = 18
    p.name = "Tom"

    p.eat()
}

和java的定义与实例化没什么区别,唯一的区别是不需要new关键字。

2.3.2 继承与构造函数

不同于java中的类天然可以被继承,除非声明了final关键字,kotlin的非抽象类默认不可被继承,从程序健壮性的角度来说,这是规范的,可变的变量也好,可被继承的类也好,可以在之后的程序中被改变就意味着风险的存在。说了这么多,如何使kotlin非抽象类可继承,以一个继承Person类的Student类为例

Student类:

class Student {
    var sno = ""
    var grade = 0
}

使用open关键字让Person类可被继承:

open class Person {
    var name:String = ""
    var age:Int = 0

    fun eat(){
        println("eat")
    }
}

不同于java中的extends,koelin使用:关键字继承:

class Student: Person() {
    var sno = ""
    var grade = 0
}

可能有人存在疑问,为什么这里的Person类带括号,在解释这个问题之前,需要先了解kotlin的构造函数。如同任何一门面向对象的语言,kotlin也存在构造函数,但是略显复杂,分为主构造函数和次构造函数。

主构造函数:

class Student(var sno:String, var grade:Int): Person(){}

如上一行的代码,主构造函数直接定义在类名后的括号中,其实每个类都默认有一个不带参数的主构造函数,无主构造函数的类声明时不写括号是kotlin做的减法。在主构造函数中可以显式地声明一些变量,可能有人会问,如果要在主构造函数中编写一些逻辑怎么办,kotlin提供了一个init结构体,这个init结构体中的逻辑也属于主构造函数:

class Student(var sno:String, var grade:Int): Person(){
    init{
        println("sno is" + sno)
        println("grade is" + grade)
    }
}

这就是之前继承Person类的时候要在Person后加括号,而明明Person类没有构造函数的原因。

次构造函数:

class Student(val sno: String, val grade: Int, name: String, age: Int) :
 Person(name, age) {
 constructor(name: String, age: Int) : this("", 0, name, age) {
 }
 constructor() : this("", 0) {
 }
}

次构造函数通过constructor关键字来声明,次构造函数拥有一个函数体和显式声明参数的括号,另外,kotlin规定次构造函数必须直接或者间接调用主构造函数,这也是this关键字的作用,第一个this调用主构造函数,第二个this调用第一个次构造函数来间接调用主构造函数。

有时还会出现没有主构造函数的情况,kotlin中没有显式定义主构造函数又定义了次构造函数的情况称之为没有主构造函数,比如:

class Student : Person {
 constructor(name: String, age: Int) : super(name, age) {
 }
}

此时由几个变化,一是没有主构造函数,次构造函数就往上一级找到父类的构造函数,通过super进行引用,正因为父类的主构造函数被子类次构造函数引用,所以声明子类时父类不需要加括号表示其主构造函数。这也是继承时有需要加括号有时不需要的原因。

2.3.3 接口

关于接口,这一点和java中一样,kotlin也是单继承语言,子类最多继承一个父类,但是可以实现多个接口。我们可以在接口中定义一系列抽象行为,然后由具体的类去实现,比如:

Study接口:

interface Study {
    fun readBook()
    fun doHomework()
}

Person类:

open class Person(var name:String, var age:Int) {
    init {
        println("eat")
    }
}

Student类:

class Student(var testonly:String ,name: String,age:Int) : Person(name,age),Study {
    override fun readBook(){
        println(name+"is reading")
    }

    override fun doHomework() {
        println(name+"is doing homework")
    }
}

主函数:

fun main(){
    val student = Student("testonly","Tom",18)
    doStudy(student)
}

fun doStudy(study: Study){
    study.readBook()
    study.doHomework()
}//这是多态的一种表现形式

值得一提的是,如果接口中的函数拥有函数体,这个函数体中的逻辑就是它的默认实现,当一个类调用接口时,拥有默认实现的函数不要求override,可以自行选择是否override。

讲完继承和多态,接下来要引入可见性修饰符,这将有助于我们实现封装。和java类似,简单总结如下:

image-20260108190633856

2.3.4 数据类与单例类

接下来介绍一些kotlin特色。

在一个规范的系统架构中,数据类通常占据着非常重要的角色,他们是服务器端或者数据库中的数据映射到内存中的重要“媒介”,为编程逻辑提供数据模型的支持。而实现数据类在java中需要实现equals,hanshCode,toString等方法,其中,equals()方法用于判断两个数据类是否相等。hashCode()方法作为equals()的配套方法,也需要一起重写,否则会导致HashMap、HashSet等hash相关的系统类无法正常工作。toString()方法用于提供更清晰的输入日志,否则一个数据类默认打印出来的就是一行内存地址。

多说无益,上例子,如果我们使用java实现一个手机数据类,只有品牌和价格这两个字段:

public class Cellphone {
 String brand;
 double price;
 public Cellphone(String brand, double price) {
 this.brand = brand;
 this.price = price;
 }
 @Override
 public boolean equals(Object obj) {
 if (obj instanceof Cellphone) {
 Cellphone other = (Cellphone) obj;
 return other.brand.equals(brand) && other.price == price;
 }
 return false;
 }
 @Override
 public int hashCode() {
 return brand.hashCode() + (int) price;
 }
 @Override
 public String toString() {
 return "Cellphone(brand=" + brand + ", price=" + price + ")";
 }
}

同样的功能在kotlin中如下:

data class CellPhone(val brand:String, val price:Double)

你没看错,只需要一行代码就可以实现了!神奇的地方就在于data这个关键字,当在一个类前面声明了data关键字时,就表明你希望这个类是一个数据类,Kotlin会根据主构造函数中的参数帮你将equals()、hashCode()、toString()等固定且无实际逻辑意义的方法自动生成,从而大大减少了开发的工作量。

fun main() {
 val cellphone1 = Cellphone("Samsung", 1299.99)
 val cellphone2 = Cellphone("Samsung", 1299.99)
 println(cellphone1)
 println("cellphone1 equals cellphone2 " + (cellphone1 == cellphone2))
}

没搞懂运行一次这个代码,再去掉data再试试会有更深的理解。

搞懂数据类之后我们看看另外一个特色,单例类。

单例模式,最常用、最基础的设计之一,它可以用于避免创建重复的对象。比如我们希望某一个类在全局下最多存在一个实例,这时候就可以使用单例模式。还是以java代码为例创建单例类:

public class Singleton {
 private static Singleton instance;
 private Singleton() {}
 public synchronized static Singleton getInstance() {
 if (instance == null) {
 instance = new Singleton();
 }
 return instance;
 }
 public void singletonTest() {
 System.out.println("singletonTest is called.");
 }
}

这段代码其实很好理解,首先为了禁止外部创建Singleton的实例,我们需要用private关键字将Singleton的构造函数私有化,然后给外部提供了一个getInstance()静态方法用于获取Singleton的实例。在getInstance()方法中,我们判断如果当前缓存的Singleton实例为null,就创建一个新的实例,否则直接返回缓存的实例即可,这就是单例模式的工作机制。

而如果我们想调用单例类中的方法,也很简单,比如想调用上述的singletonTest()方法,就可以这样写:

Singleton singleton = Singleton.getInstance();
singleton.singletonTest

而在kotlin中则大大简化:

object Singleton{
    fun singletonTest() {
 		println("singletonTest is called.")
 }
}

可以看到,在Kotlin中我们不需要私有化构造函数,也不需要提供getInstance()这样的静态方法,只需要把class关键字改成object关键字,一个单例类就创建完成了。而调用单例类中的函数也很简单,比较类似于Java中静态方法的调用方式:

Singleton.singletonTest()

这种写法虽然看上去像是静态方法的调用,但其实Kotlin在背后自动帮我们创建了一个Singleton类的实例,并且保证全局只会存在一个Singleton实例。

2.4 Lambda编程

作为新生代的编程语言,kotlin肯定是集百长的,kotlin也支持lambda编程。

2.4.1 集合的创建与遍历

集合的函数式API是用来入门Lambda编程的绝佳示例,不过在此之前,我们得先学习创建集合的方式才行。

传统意义上的集合主要就是List和Set,再广泛一点的话,像Map这样的键值对数据结构也可以包含进来。List、Set和Map在Java中都是接口,List的主要实现类是ArrayList和LinkedList,Set的主要实现类是HashSet,Map的主要实现类是HashMap,熟悉Java的人对这些集合的实现类一定不会陌生。

01 List

假设现在有一个创建水果名称的合集,在java中可以创建一个ArrayList的实例,再一个个添入名称,当然,在kotlin中也可以这样,比如:

val list = ArrayList<String>()
list.add("Apple")
list.add("Banana")
list.add("Orange")
list.add("Pear")
list.add("Grape")

但是略显麻烦,我们可以使用kotlin内置的函数listOf来实现:

val list = listOf("Apple","Banana","Orange","Pear","Grape")

接下来是集合的遍历,for-i循环除了区间,自然也可以用来遍历集合:

fun main() {
	val list = listOf("Apple", "Banana", "Orange", "Pear", "Grape")
 	for (fruit in list) {
 	println(fruit)
 	}
}

不过,值得注意的是listOf创建的是不可变集合,正如之前说到的kotlin设计理念,kotlin在不可变性方面控制的极为严格,如果要创建一个可变集合呢?可以用mutableListOf函数:

fun main() {
 val list = mutableListOf("Apple", "Banana", "Orange", "Pear", "Grape")
 list.add("Watermelon")
 for (fruit in list) {
 println(fruit)
 }
}
02 Set

Set和List基本一致,把listOf换成setOf,mutableListOf换成mutableSetOf即可。这里补一点数据结构的知识,set和list的区别就是list允许存在多个相同的元素,set的元素具有唯一性。

03 Map

最后再来看一下Map集合的用法。Map是一种键值对形式的数据结构,因此在用法上和List、Set集合有较大的不同。传统的Map用法是先创建一个HashMap的实例,然后将一个个键值对数据添加到Map中。比如这里我们给每种水果设置一个对应的编号,就可以这样写:

val map = HashMap<String, Int>()
map.put("Apple", 1)
map.put("Banana", 2)
map.put("Orange", 3)
map.put("Pear", 4)
map.put("Grape", 5)

之所以先用这种写法,是因为这种写法和Java语法是最相似的,因此可能最好理解。但其实在Kotlin中并不建议使用put()和get()方法来对Map进行添加和读取数据操作,而是更加推荐使用一种类似于数组下标的语法结构,比如向Map中添加一条数据就可以这么写:

map["Apple"] = 1

而从Map中读取一条数据就可以这么写:

val number = map["Apple"]

因此,上述代码经过优化过后就可以变成如下形式:

val map = HashMap<String, Int>()
map["Apple"] = 1
map["Banana"] = 2
map["Orange"] = 3
map["Pear"] = 4
map["Grape"] = 5

当然,这仍然不是最简便的写法,因为Kotlin毫无疑问地提供了一对mapOf()和mutableMapOf()函数来继续简化Map的用法。在mapOf()函数中,我们可以直接传入初始化的键值对组合来完成对Map集合的创建:

val map = mapOf("Apple" to 1, "Banana" to 2, "Orange" to 3, "Pear" to 4, "Grape" to 5)

这里的键值对组合看上去好像是使用to这个关键字来进行关联的,但其实to并不是关键字,而是一个infix函数,我们会在本书第9章的Kotlin课堂中深入探究infix函数的相关内容。接下来还是先看看map的遍历吧:

fun main() {
 val map = mapOf("Apple" to 1, "Banana" to 2, "Orange" to 3, "Pear" to 4, "Grape" to 5)
 for ((fruit, number) in map) {
 println("fruit is " + fruit + ", number is " + number)
 }
}

关于集合的遍历就到这里,接下来学习集合的函数API,从而正式入门lambda编程。

2.4.2 集合的函数式API

现在我们有了新的需求,在一个水果名字集合中找到单词最长的名字,如果不用任何集合的函数式API,一般写成下面的样子:

val list = listOf("Apple", "Banana", "Orange", "Pear", "Grape", "Watermelon")
var maxLenNumber = ""
for (i in list){
    if (i.length>maxLenNumber.length) {
        maxLenNumber = i
    }
}
println("max length is" + maxLenNumber)

如果使用集合的函数式API,可以写成下面的样子:

val list = listOf("Apple", "Banana", "Orange", "Pear", "Grape", "Watermelon")
var maxLenNumber = list.maxByOrNull {it.length}
println("max length is" + maxLenNumber)

也许现在没搞懂什么意思,但是当学习了lambda表达式的语法结构之后就会豁然开朗。

什么是lambda?自然语言表述就是一段可以作为参数传递的代码。接下来看看lambda的语法结构:

{param1:paramType, param2:paramType -> 函数体}

这是Lambda表达式最完整的语法结构定义。首先最外层是一对大括号,如果有参数传入到Lambda表达式中的话,我们还需要声明参数列表,参数列表的结尾使用一个->符号,表示参数列表的结束以及函数体的开始,函数体中可以编写任意行代码(虽然不建议编写太长的代码),并且最后一行代码会自动作为Lambda表达式的返回值。

当然lambda编程用到最多的是它的简化版本,而不是完整的语法结构,接下来回到之前的函数,我们将由繁入简从标准完整的lambda结构推导简化版本:

val list = listOf("Apple", "Banana", "Orange", "Pear", "Grape", "Watermelon")
val lambda = {fruit:String -> fruit.length}
val maxNumberFruit = list.maxByOrNull(lambda)

maxByOrNull函数只接受lambda形式参数,这个lambda参数作为其“规则”,返回值是使得规则结果最大的那个值本身。拿上面的例子来说,lambda定义规则“String类型的fruit的length”,maxByOrNull遍历list找到所有String类型的length并选出拥有最大length的那一个String元素返回。

接下来对上面的函数化简,首先lambda变量不是必须的:

val maxNumberFruit = list.maxByOrNull({fruit:String -> fruit.length})

然后kotlin规定,如果lambda参数是函数的最后一个参数,可以将lambda表达式移到函数括号外面:

val maxNumberFruit = list.maxByOrNull(){fruit:String -> fruit.length}

接下来,如果lambda参数是函数的唯一一个参数,可以省略括号:

val maxNumberFruit = list.maxByOrNull {fruit:String -> fruit.length}

由于kotlin优秀的参数推导机制,大多数参数无需声明参数类型:

val maxNumberFruit = list.maxByOrNull {fruit -> fruit.length}

最后,当lambda表达式的参数列表只有一个参数时,也不必声明参数名,可以用it关键字替代:

val maxNumberFruit = list.maxByOrNull {it.length}

正如本小节开头所说的,这里我们重点学习的是函数式API的语法结构,理解了语法结构之后,集合中的各种其他函数式API都是可以快速掌握的。接下来我们就再来学习几个集合中比较常用的函数式API。

集合中的map函数是最常用的一种函数式API,它用于将集合中的每个元素都映射成一个另外的值,映射的规则在Lambda表达式中指定,最终生成一个新的集合。比如,这里我们希望让所有的水果名都变成大写模式,就可以这样写:

fun main() {
 val list = listOf("Apple", "Banana", "Orange", "Pear", "Grape", "Watermelon")
 val newList = list.map { it.toUpperCase() }
 for (fruit in newList) {
 println(fruit)
 }
}

可以看到,我们在map函数的Lambda表达式中指定将单词转换成了大写模式,然后遍历这个新生成的集合。map函数的功能非常强大,它可以按照我们的需求对集合中的元素进行任意的映射转换,上面只是一个简单的示例而已。除此之外,你还可以将水果名全部转换成小写,或者是只取单词的首字母,甚至是转换成单词长度这样一个数字集合,只要在Lambda表示式中编写你需要的逻辑即可。

接下来我们再来学习另外一个比较常用的函数式API——filter函数。顾名思义,filter函数是用来过滤集合中的数据的,它可以单独使用,也可以配合刚才的map函数一起使用。比如我们只想保留5个字母以内的水果,就可以借助filter函数来实现,代码如下所示:

fun main() {
 val list = listOf("Apple", "Banana", "Orange", "Pear", "Grape", "Watermelon")
 val newList = list.filter { it.length <= 5 }.map { it.toUpperCase() }
 for (fruit in newList) {
 println(fruit)
 }
}

可以看到,这里同时使用了filter和map函数,并通过Lambda表示式将水果单词长度限制在5个字母以内。另外值得一提的是,上述代码中我们是先调用了filter函数再调用map函数。如果你改成先调用map函数再调用filter函数,也能实现同样的效果,但是效率就会差很多,因为这样相当于要对集合中所有的元素都进行一次映射转换后再进行过滤,这是完全不必要的。而先进行过滤操作,再对过滤后的元素进行映射转换,就会明显高效得多。

接下来我们继续学习两个比较常用的函数式API——any和all函数。其中any函数用于判断集合中是否至少存在一个元素满足指定条件,all函数用于判断集合中是否所有元素都满足指定条件。由于这两个函数都很好理解,我们就直接通过代码示例学习了:

fun main() {
 val list = listOf("Apple", "Banana", "Orange", "Pear", "Grape", "Watermelon")
 val anyResult = list.any { it.length <= 5 }
 val allResult = list.all { it.length <= 5 }
 println("anyResult is " + anyResult + ", allResult is " + allResult)
}

这里还是在Lambda表达式中将条件设置为5个字母以内的单词,那么any函数就表示集合中是否存在5个字母以内的单词,而all函数就表示集合中是否所有单词都在5个字母以内。

2.4.3 java函数式API使用

现在我们已经学习了Kotlin中函数式API的用法,但实际上在Kotlin中调用Java方法时也可以使用函数式API,只不过这是有一定条件限制的。具体来讲,如果我们在Kotlin代码中调用了一个Java方法,并且该方法接收一个Java单抽象方法接口参数,就可以使用函数式API。Java单抽象方法接口指的是接口中只有一个待实现方法,如果接口中有多个待实现方法,则无法使用函数式API。

光说肯定有点抽象,接下来举个例子,java原生API中有一个最常见的单抽象方法接口——Runnable接口,这个接口中只有一个待实现的run方法:

public interface Runnable(){
    void run()
}

根据前面的讲解,对于任何一个Java方法,只要它接收Runnable参数,就可以使用函数式API。那么什么Java方法接收了Runnable参数呢?这就有很多了,不过Runnable接口主要还是结合线程来一起使用的,因此这里我们就通过Java的线程类Thread来学习一下。Thread类的构造方法中接收了一个Runnable参数,我们可以使用如下Java代码创建并执行一个子线程:

new Thread(new Runnable() {
    @override
    public void run(){
        System.out.println("Thread is running");
    }
}).start()

注意,这里使用了匿名类的写法,我们创建了一个Runnable接口的匿名类实例,并将它传给了Thread类的构造方法,最后调用Thread类的start()方法执行这个线程。而如果直接将这段代码翻译成Kotlin版本,写法将如下所示:

Thread (object: Runnable {
    override fun run() {
        println("Thread is running")
    }
}).start()

Kotlin中匿名类的写法和Java有一点区别,由于Kotlin完全舍弃了new关键字,因此创建匿名类实例的时候就不能再使用new了,而是改用了object关键字。这种写法虽然算不上复杂,但是相比于Java的匿名类写法,并没有什么简化之处。

但是别忘了,目前Thread类的构造方法是符合Java函数式API的使用条件的,下面我们就看看如何对代码进行精简,如下所示:

Thread (Runnable{
    println("Thread is running")
}).start()

这段代码明显简化了很多,既可以实现同样的功能,又不会造成任何歧义。因为Runnable类中只有一个待实现方法,即使这里没有显式地重写run()方法,Kotlin也能自动明白Runnable后面的Lambda表达式就是要在run()方法中实现的内容。这个称之为SAM转换规则,即:如果一个接口 只有一个抽象方法, Kotlin 允许用 lambda 来直接创建它的实例。

另外,如果一个Java方法的参数列表中有且仅有一个Java单抽象方法接口参数,我们还可以将接口名进行省略,这样代码就变得更加精简了:

Thread ({
    println("Thread is running")
}).start()

还记得之前提到的kotlin函数式API吗,lambda参数作为方法的唯一一个参数时,可以省略括号:

Thread {
    println("Thread is running")
}.start()

总结一下,总的来说kotlin的强大推导能力决定了简化方向:凡是具有唯一性的都可以省略声明,因为kotlin会自动推导。

本小节中学习的Java函数式API的使用都限定于从Kotlin中调用Java方法,并且单抽象方法接口也必须是用Java语言定义的。你可能会好奇为什么要这样设计。这是因为Kotlin中有专门的高阶函数来实现更加强大的自定义函数式API功能,从而不需要像Java这样借助单抽象方法接口来实现。关于高阶函数的用法,我们会在本书的第6章进行学习。

2.5 空指针检查

空指针引发的错误向来是各种系统中崩溃错误中频率最高的部分。因为空指针是不受编程语言检查时的运行异常,只有通过程序员的逻辑判断对其进行人工避免。比如下面的java代码:

public void doStudy(Study study){
    study.readBook();
}

这段代码很显然是不安全的,为什么,假如我调用的时候传入一个空指针呢,这里并没有处理异常的代码,我们需要额外做一些处理。

2.5.1 可空类型系统

然而,kotlin利用编译时判空检查的机制几乎杜绝了空指针异常。然编译时判空检查的机制有时候会导致代码变得比较难写,但是不用担心,Kotlin提供了一系列的辅助工具,让我们能轻松地处理各种判空情况。

回到刚才的代码,写成kotlin版本:

fun doStudy(study:Study) {
    study.readBook()
}

这段代码就是没有空指针风险的,为什么?因为kotlin默认所有的参数和变量不为空,当尝试往参数或者变量中传入空指针或者null会直接报错,在编译时就检查空指针错误,但是这也带来了新的问题,就是当函数的参数是可选参数时,这不就有问题了吗,可能会说用无意义的值并在函数中做判断就行了,但是这样太麻烦,kotlin提供了另外一套可为空的类型系统,在类名后打一个?就表示类型可为空。比如:

fun doStudy(study:Study?) {
    study.readBook()
}

现在就可以往doStudy中传入空指针了,然而这个时候编译是无法通过的,因为kotlin认为study.readBook有造成空指针异常的风险,我们需要对空指针异常进行处理:

fun doStudy(study:Study?) {
    if (study!=null){
        study.readBook()
    }
}

到这里,kotlin可空类型系统以及空指针检查机制就了解的差不多了,但是为了在编译时就处理掉所有的空指针异常,通常需要编写很多检查代码,如果每处检查都用if就会使得代码冗杂,且if无法判断全局变量为空的问题。为此,kotlin专门提供了一系列的辅助工具,使开发者能更轻松的进行判空处理。

2.5.2 判空辅助工具

01 ?.操作符

?.操作符当对象不为空时正常调用方法,当对象为空时什么都不做。比如:

fun doStudy(study:Study) {
    study?.readBook()
    study?.doHmowork()
}
02 ?:操作符

?:操作符的左右两边都接收一个表达式,如果左边表达式的结果不为空就返回左边表达式的结果,否则返回右边表达式的结果。其实这个操作符在C中也有出现。以一段代码为例,我们写一个函数获取一段文本的长度:

fun getTextLength(text:String?):Int {
    if (text!=null){
        return text.length
    }
    return 0
}

上面的代码,可以简化为:

fun getTextLength(text:String?) = text?.length ?: 0
03 !!关键字

虽然kotlin已经相当现代化,但也并不是完全智能的。有的时候也许我们从逻辑上已经判断不可能出现空指针异常,但是kotlin编译器并不知道,那么还是会编译失败,比如观察下面的代码:

var content: String? = "hello"
fun main() {
 if (content != null) {
 printUpperCase()
 }
}
fun printUpperCase() {
 val upperCase = content.toUpperCase()
 println(upperCase)
}

理论上我们只会在content判断不为空的时候才会调用printUpperCase,从逻辑上杜绝了空指针异常,但是kotlin编译器认为printUpperCase函数中没有判空逻辑,编译失败。但是我们可以强行编译,只需要用到非空断言工具!!,写法是在对象的后面加上!!,比如:

var content: String? = "hello"
fun main() {
 if (content != null) {
 printUpperCase()
 }
}
fun printUpperCase() {
 val upperCase = content!!.toUpperCase()
 println(upperCase)
}
04 let函数

let既不是操作符,也不是什么关键字,而是一个函数。这个函数提供了函数式API的编程接口,并将原始调用对象作为参数传递到Lambda表达式中。示例代码如下:

obj.let {obj2 ->
        //具体逻辑
}

这里调用了对象的let方法,然后lambda表达式中的代码就会立即执行,并且这个obj对象本身还会作为参数传递到lambda表达式中。不过,为了防止变量重名,这里将参数名改成了obj2,但实际上它们是同一个对象。

光说有点抽象,接下来用一个具体例子说明:

fun doStudy(study:Study?) {
    study?.readBook()
    study?.doHomework()
}

像上面的例子固然方便,但是会拖慢实际编译的速度,为什么,因为上面的代码本质上相当于:

fun doStudy(study:Study?) {
    if (study!=null) study.readBook()
    if (study!=null) study?.doHomework()
}

但实际上只需要一个if就够了,多出来的if判断会拖累项目的编译,从代码优化的角度是不好的,这时可以用let方法:

fun doStudy(study:Study?) {
    study?.let {stu ->
    	stu.readBook()
        stu.doHomework()
    }
}

另外,在上一节说到的lambda编程特性,当lambda表达式的参数列表只有一个参数时,可以不用声明参数,用it关键字代替:

fun doStudy(study:Study?) {
    study?.let {
    	it.readBook()
        it.doHomework()
    }
}

值得注意的是,if由于其判断并不是全局的,是无法对全局变量进行判空处理的,但是let方法是可以的。

2.6 kotlin中的小魔术

2.6.1 字符串内嵌表达式

举个例子就明白了:

brand = "Samsung"
price = 1299
println("Cellphone(brand=${brand}, price=${price})")

可以看到,kotlin允许在字符串中嵌入${}这种语法结构的表达式,并在运行时使用表达式执行的结果代替这一部分内容,当表达式只有一个变量时可以省略{},比如:

println("Cellphone(brand=$brand, price=$price)")

2.6.2 参数默认值

接下来学习另外一个小技巧,给函数设置默认参数值。

在之前学次构造函数的时候提到过,次构造函数很少使用,因为kotlin提供的给函数设置默认值的功能很大程度上替代了次构造函数的作用。还是举个例子:

fun printParams(num: Int, str: String = "hello") {
 println("num is $num , str is $str")
}
fun main() {
 printParams(123)
}

拥有默认值的参数在被调用的时候可以填入参数覆盖默认值,也可以不填入使用默认值,这不是什么难以理解的事。

但是当第一个参数是默认值时,怎么在不改变默认值的情况下对其他参数进行赋值呢。kotlin可以通过“键值对”的方式进行传参:

fun printParams(num: Int = 100, str: String) {
 println("num is $num , str is $str")
}
fun main() {
 printParams(str = "world")
}

第三章 探究Activity

3.1 Activity是什么

Activity是一种可以包含用户界面的组件,主要用于和用户进行交互。一个程序可以包括零个或多个Activity。在第一章中我们创建了一个Activity,但是当时的重点是如何快速做一个软件,于是让AndroidStudio忙我们创建了一个空Activity,现在我们以手动创建一个Activity来引入Activity的基本用法。

3.2 Activity的基本用法

现在,我们创建一个新项目,选择No Activity,选择kotlin语言。

3.2.1 手动创建Activity

image-20260114165731120

如上图,在java.com.activity包下创建一个空Activity,不要勾选Generate a Layout File和Launcher Activity,如下图:

image-20260114165947872

需要知道的是,项目中的任何Activity都需要重写onCreate方法,这里AS自动帮我们完成了这个过程。代码如下:

class FirstActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
    }
}

可以看到,默认实现的onCreate方法实现很简单,调用父类的onCreate方法就行,在之后,还需要加入一些自己的逻辑。

3.2.2 创建和加载布局

之前讲过,Android程序的设计讲究逻辑和视图分离,最好每一个Activity都对应一个布局。布局是用来显示界面内容的,现在来手动创建一个布局文件。

右击app/src/main/res目录→New→Directory,会弹出一个新建目录的窗口,这里先创建一个名为layout的目录。然后对着layout目录右键→New→Layout resource file,又会弹出一个新建布局资源文件的窗口,我们将这个布局文件命名为first_layout。

完成布局后,会发现如下图所示的布局编辑器:

image-20260114224226534

注意标红的地方,从左到右的三个按钮分别是以xml文件显示,xml文件和布局编辑器同时显示,以布局编辑器显示。我们让其以消灭了文本显示,发现:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

</LinearLayout>

由于刚才创建时选择LinearLayout作为根元素,因此现在布局文件中已经有一个LinearLayout元素了。现在对这个布局稍作编辑,添加一个按钮控件:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <Button
        android:id="@+id/button1"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="@+string/Button 1"
        />
</LinearLayout>

里添加了一个Button元素,并在Button元素的内部增加了几个属性。android:id是给当前的元素定义一个唯一的标识符,之后可以在代码中对这个元素进行操作。你可能会对@+id/button1这种语法感到陌生,但如果把加号去掉,变成@id/button1,你就会觉得有些熟悉了吧。这不就是在XML中引用资源的语法吗?只不过是把string替换成了id。是的,如果你需要在XML中引用一个id,就使用@id/id_name这种语法,而如果你需要在XML中定义一个id,则要使用@+id/id_name这种语法。随后android:layout_width指定了当前元素的宽度,这里使用match_parent表示让当前元素和父元素一样宽。android:layout_height指定了当前元素的高度,这里使用wrap_content表示当前元素的高度只要能刚好包含里面的内容就行。android:text指定了元素中显示的文字内容。关于编写布局的详细内容我会在下一章中重点讲解,本章只是先简单涉及一些。现在,点击右侧工具栏的Layout Validation,可以看到各种型号的设备的布局预览:

image-20260114225209647

可以看到,按钮已经显示出来了,接下来就是在Activity中加载这个布局,回到FirstActivity,在onCreate方法中添加如下代码:

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

在这里调用了setContentView方法来给当前的Activity加载一个布局,而在setContentView方法中,一般会传入一个布局文件的id,在第一章介绍项目资源文件时提到过,所有的资源都会在R文件中生成一个相应的资源id,因此,刚才创建的first_layout布局id已经添加到R文件中了。在代码中引用布局文件的方法也很简单,只需要调用R.layout.first_layout就可以得到first_layout.xml的布局id,传入setContentView即可。

3.2.3 在AndroidManifest.xml中注册

之前提到过,所有的Activity都需要在Manifest.xml中注册才能生效。事实上,AndroidStudio在我们创建新的Activity时会自动帮我们在AndroidManifest.xml中注册,打开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"
        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.ActivityTest">
        <activity
            android:name=".FirstActivity"
            android:exported="true" />
    </application>

</manifest>

这里着重讲一下这个android:exported=“true”,AndroidStudio会自动创建android:exported=“false”,由于我一直用的真机是Android 11系统,所以之前没有发现什么问题,但实际上在Android12及以上这里的属性值如果是flase会导致应用无法显示。所以为了以防万一,这里的属性值尽量都写true。

可以看到,Activity的注册声明要放在<application>标签内,这里是通过<activity>标签来对Activity进行注册的,在<activity>标签中,使用了android:name来指定具体注册哪一个Activity,这里的.FirstActivity是com.example.activitytest.FirstActivity的缩写,由于在最外层的<manifest>标签中已经通过package属性指定了程序的包名是com.example.activitytest,因此在注册Activity时,这一部分可以省略,直接使用.FirstActivity就足够了。

不过,仅仅是注册Activity,程序仍然不能运行,因为还没有为程序配置主Activity。配置主Activity的方法就是在<activity>标签的内部添加<intent-filter>标签,并在这个标签中添加如下声明:

<actionandroid:name="android.intent.action.MAIN"/><categoryandroid:name="android.intent.category.LAUNCHER" />

除此之外,还可以用adroid:label指定Activity标题栏的内容,标题栏是显示在Activity最顶部的。需要注意的是,给主Activity指定的label不仅会成为标题栏中的内容,还会成为启动器中应用程序显示的名称。

修改后的AndroidManifest.xml如下:

	<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.ActivityTest">
        <activity
            android:name=".FirstActivity"
            android:exported="true"
            android:label="This is first Activity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
	</application>

这样,接下来我们启动一个程序就能在主Activity中看到刚才的布局了,另外,即使没有主Activity,程序也是可以正常安装的,只是无法再启动器中看到或打开这个程序,像这种程序一般是作为第三方服务供其他应用在内部调用的。

image-20260115161038697

3.2.4 在Activity中使用Toast

Toast是Android系统提供的一种好用的提醒方式,在程序中可以使用它将一些短小的消息提供给客户,这些消息会在一段时间后自动消失,并且不会占用任何屏幕空间。接下来我们在Activity中使用Toast。

首先需要定义一个弹出Toast的触发点,在刚才的Activity中我们可以将按钮的点击事件作为Toast的触发点。在onCreate方法中添加如下代码:

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.first_layout)
        val button1: Button = findViewById(R.id.button1)
        button1.setOnClickListener {
            Toast.makeText(this,"you click button1", Toast.LENGTH_SHORT).show()
        }
    }

在Activity中,可以通过findViewById方法获取在布局文件中定义的元素,这里传入R.id.button1来得到按钮实例,这个值是刚才在布局文件中通过android:id指定的。findViewById返回的是一个继承自View的泛型对象,因此kotlin无法推导它具体是哪一个控件,需要手动指定。得到按钮实例后通过setOnClickListener方法为按钮注册一个监听器,点击按钮就会调用监听器中的onClick方法,因此,弹出Toast的功能放在onClick方法中编写。

Toast的用法也很简单,通过静态方法makeText创建一个Toast对象,调用show方法将其显示出来就可以了。这里需要注意的是,makeText方法需要传入三个参数,一是Context对象,由于Activity本身就是一个Context对象,这里直接传入this,二是Toast显示的文本内容,三是Toast显示的时间长短,Toast方法内置了两个常量Toast.LENGTH_SHORT和Toast.LENGTH_LONG。

image-20260115162900100

3.2.5 在Activity中使用Menu

菜单是一个图形化界面中很好的拓展选项,即使在电脑上也很常见,在屏幕更小的手机上就更重要了。接下来我们创建一个Menu。

在res目录下新建一个menu文件夹,这里需要说明的是Android的资源目录是强约束性的,每种资源目录的名字都是固定的。在Menu文件夹下创建一个main资源文件:

image-20260115165020229

在xml.main中添加如下代码:

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <item
        android:id="@+id/add_item"
        android:title="Add"/>
    <item
        android:id="@+id/remove_item"
        android:title="Remove"/>
</menu>

这里我们创建了两个菜单项,其中<item>标签用来创建具体的某一个菜单项,然后通过android:id给这个菜单项指定一个唯一的标识符,通过android:title给这个菜单项指定一个名称。

接下来回到Activity中重写onCreateOptionsMenu方法:

override fun onCreateOptionsMenu(menu: Menu?): Boolean {
        menuInflater.inflate(R.menu.main, menu)
        return true
    }

在讲解这段代码之前,需要介绍一下kotlin的语法糖。以java Bean为例,它是一个非常简单的java类,会根据类中的字段自动生成相应的Getter和Setter方法比如:

public class Book{
    private int pages;
    
    public int getPages(){
        return pages;
    }
    
    public void setPages(int pages) {
        this.pages = pages
    }
}

在kotlin中调用这种语法结构的java方法时,可以使用一种更为简便的写法,比如用以下代码来设置和读取Book类中的pages字段:

val book = Book()
book.pages = 500
val bookPages = book.pages

这里看上去好像没有调用Book类的set和get方法,而是对pages字段直接进行了赋值和取值。其实这就是kotlin的语法糖,它会在背后自动将上述代码转换成调用set和get方法。

而我们刚才在onCreateOptionMenu方法中编写的menuInflater就使用了这种语法糖,它实际上时调用了父类的getMenuInflater方法。getMenuInflater方法可以得到一个MenuInflater对象,再调用它的inflate方法,就可以给Activity创建菜单了。inflate方法接收两个参数,第一个参数用于指定我们通过哪一个资源文件来创建菜单;第二个参数用于指定我们的菜单项将添加到哪一个Menu对象中。最后给这个方法返回true,表示允许创建的菜单显示出来,如果返回了false,菜单无法显示。

当然,这只是让Menu显示出来,为了让Menu具有实际功能,我们还需要重写onOptionsItemSelectd方法如下:

override fun onOptionsItemSelected(item: MenuItem): Boolean {
        when(item.itemId) {
                R.id.add_item -> Toast.makeText(this, "You clicked Add",
            Toast.LENGTH_SHORT).show()
            R.id.remove_item -> Toast.makeText(this, "You clicked Remove",
                Toast.LENGTH_SHORT).show()
        }
        return true
    }

由于我们其实目前来说并没有什么实际的业务逻辑,于是调用了两个Toast。

image-20260115174646023

3.2.6 销毁一个Activity

关于销毁Activity也很简单,对于用户来说,点击Back键就可以了,如果我们需要在代码中实现这种功能,可以调用Activity的finish方法。比如我们将button的点击事件绑上finish,这样一点击button1就会销毁主Activity。

button1.setOnClickListener {
            finish()
        }

3.3 使用Intent在Activity中穿梭

一个成熟的应用不可能只有一个主Activity,问题在于,打开应用只会进入主Activity,如何通过主Activity跳转到其他的Activity,这就是本小节学习的东西。

3.3.1 使用显示Intent

首先,我们先创建一个新的Activity,命名为SecondActivity,AndroidStudio会自动生成一个xml文件,我们将其重命名为second_layout,如下定义一个按钮:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
 android:orientation="vertical"
 android:layout_width="match_parent"
 android:layout_height="match_parent">
 <Button
 android:id="@+id/button2"
 android:layout_width="match_parent"
 android:layout_height="wrap_content"
 android:text="Button 2"
 />
</LinearLayout>

在SecondActivity.kt中重写onCreate:

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

在AndroidManifest.xml中注册Activity:

		<activity
            android:name=".SecondActivity"
            android:exported="true" />

经过上面三个步骤,第二个Activity就算创建完毕了,剩下的就是如何将Activity之间关联起来。这里引入一个新概念:Intent。

Intent是Android程序中各组件进行交互的一种重要方式,它不仅可以指明当前组件想要执行的动作,还可以在不同的组件之间传递数据。Intent一般可以用于启动Activity、启动Service以及发送广播等场景。

Intent分为两种:显示Intent和隐式Intent。先来看看显示Intent的使用方式。

Intent有多个构造函数的重载,其中一个是Intent(Context packageContext, Class<?> cls)。这个构造函数接受两个参数:第一个参数Context要求提供一个启动Activity的上下文;第二个参数Class用于指定想要启动的目标Activity,通过这个构造函数就可以构建出Intent的“意图”。Intent构造完成后,接下来就是Intent的使用了。Activity类中提供了一个startActivity方法,,专门启动Activity,它接受一个Intent类型参数,这里将构建好的Intent传入即可:

button1.setOnClickListener {
            val intent = Intent(this, SecondActivity::class.java)
            startActivity(intent)
        }

我们首先构建一个intent对象,第一个参数传入this表示FirstActivity作为上下文,第二个参数传入SecondActivity::class.java作为目标Activity。kotlin中SecondActivity::class.java等价于java中的SecondActivity.class写法。接下来通过startActivity方法执行Intent即可。

3.3.2 使用隐式Intent

相比于显示Intent,隐式Intent则含蓄很多,它并不知名想要启动哪一个Activity,而是制定了一系列更为抽象的action和category等信息,交由系统去分析这个Intent,并帮助我们找出合适的Activity去启动。

什么叫做合适的Activity?简单来说就是可以响应这个隐式Intent的Activity,以我们目前的SecondActivity为例,嗯……好像目前的这个Activity还不能响应,不过没关系,只需要向AndroidManifest.xml中的activity标签添加<intent-filter>内容即可:

<intent-filter>
     <action android:name="com.example.activitytest.ACTION_START" />
     <category android:name="android.intent.category.DEFAULT" />
</intent-filter>

<action>标签中我们指明了当前Activity可以响应com.example.activitytest.ACTION_START这个action,而<category>标签则包含了一些附加信息,更加精确地指出了当前Activity能够响应地Intent中还可能带有的category。只有<action>和<category>中的内容同时匹配Intent指定的action和category时,这个Activity才能响应Intent。

修改FirstActivity中按钮的点击事件:

button1.setOnClickListener {
	val intent = Intent("com.example.activitytest.ACTION_START")
    startActivity(intent)
}

可以看到,我们使用了Intent的另外一个构造函数,直接将action的字符串传了进去,表明我们想要启动能够响应com.example.activitytest.ACTION_START这个action的Activity。但是前面提到过,action和category要同时匹配才能响应,这里为什么没有指定category?这是因为android.intent.category.DEFAULT是一种默认的category,在调用startActivity方法时会自动将这个category添加到Intent中。

每个Intent中只能指定一个action,但是能指定多个category。目前我们的Intent中只有一个category,接下来尝试添加新的category:

		button1.setOnClickListener {
            val intent = Intent("com.example.activitytest.ACTION_START")
            intent.addCategory("android.intent.category.MY_CATEGORY")
            startActivity(intent)
        }

这里通过Intent中的addCategory方法添加一个自定义的属性值为android.intent.category.MY_CATEGORY的category。现在重新运行程序,程序毫无疑问会崩溃,查看Logcat:

image-20260118182208679

发现错误原因是没有Activity能够响应Intent,只需要在刚才的SecondActivity的<intent-filter>标签中加上对应的category属性值即可:

			<intent-filter>
                <action android:name="com.example.activitytest.ACTION_START" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.MY_CATEGORY"/>
            </intent-filter>

3.3.3 更多隐式Intent用法

上一节中学习了隐式Intent的基础用法,但是隐式Intent还有更多玩法。使用隐式Activity不仅可以启动自己程序内的Activity,还可以启动其它程序的Activity,这就使得多个应用程序之间的功能共享成为可能。比如假设应用中需要展示一个网页,这时就没有必要自己实现一个浏览器,只需要调用系统的浏览器来打开这个网页就可以了。

修改FirstActivity中按钮点击事件的代码:

		button1.setOnClickListener {
            val intent = Intent(Intent.ACTION_VIEW)
            intent.data = Uri.parse("https://www.baidu.com")
            startActivity(intent)
        }

这里指定了Intent的action是Intent.ACTION_VIEW,这是一个Android系统内置的动作,其常量值为android.intent.action.VIEW。然后通过Uri.parse方法将一个网址字符解析为一个Uri对象,再调用Intent的setData方法将这个Uri对象传递进去,当然,这里用到了前面讲过的语法糖,所以看起来像是对data赋值。

在上述代码中,关于setData方法其实并不复杂,它接收一个Uri对象,主要用于指定当前Intent正在操作的数据,而这些数据通常是以字符串形式传入Uri.parse方法中解析产生的。

与此对应,我们还可以在<intent-filter>标签下配置一个<data>标签,用于更精准地指定当前Activity能够响应的数据。<data>标签中主要可以配置以下内容:

  • android:scheme,用于指定数据的协议部分,比如https
  • android:host,用于指定数据的域名部分,比如www.baidu.com
  • android:port,用于指定数据的端口
  • android:path,用于指定数据的路径,比如域名和端口后跟的路径
  • android:mimeType,用于指定可以处理的数据类型,允许使用通配符的方式进行指定。

只有当<data>标签中指定的内容和Intent中携带的Data完全一致时,当前的Activity才能响应该Intent。不过,<data>标签一般不会指定太多内容。

接下来我们创建一个名为ThirdActivity的Activity,让其可以响应打开网页的Intent。更改third_layout.xml:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <Button
        android:id="@+id/button3"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Button 3"
        />
</LinearLayout>

更改AndroidManifest.xml:

		<activity 
            android:name=".ThirdActivity"
            android:exported="true">
            <intent-filter tools:ignore="AppLinkUrlError">
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <data android:scheme="https" />
            </intent-filter>
        </activity>

我们在ThirdActivity的<intent-filter>中配置了当前Activity能够响应的action是Intent.ACTION_VIEW的常量值,而category则毫无疑问地指定了默认的category值,另外在<data>标签中,我们通过android:scheme指定了数据的协议必须是https协议,这样ThirdActivity应该就和浏览器一样,能够响应一个打开网页的Intent了。另外,由于AndroidStudio认为所有能够响应ACTION_VIEW的Activity都应该加上BROWSABLE的category,否则就会给出一段警告提醒。加上BROWSABLE的category是为了实现deep link功能,和我们目前学习的东西无关,所以这里直接在<intent-filter>标签上使用tools:ignore属性将警告忽略即可。

点击之后,系统自动弹出了一个列表,显示了目前能够响应这个Intent的所有程序。选择Chrome还会像之前一样打开浏览器,并显示百度的主页,而如果选择了ActivityTest,则会启动ThirdActivity。JUST ONCE表示只是这次使用选择的程序打开,ALWAYS则表示以后一直使用这次选择的程序打开。需要注意的是,虽然我们声明了ThirdActivity是可以响应打开网页的Intent的,但实际上这个Activity并没有加载并显示网页的功能,所以在真正的项目中尽量不要出现这种有可能误导用户的行为。另外需要说明的是,目前在一些模拟器,尤其是API31+的模拟器上面可能不会弹出选择,有条件还是用真机实验,模拟器总是会出现各种各样的问题。

除了https协议之外,还可以指定其他的协议,比如geo表示显示地理位置,tel表示拨打电话:

		button1.setOnClickListener {
            val intent = Intent(Intent.ACTION_DIAL)
            intent.data = Uri.parse("tel:10086")
            startActivity(intent)
        }

首先指定了Intent的action是Intent.ACTION_DIAL,这又是一个Android系统的内置动作。然后在data部分指定了协议是tel,号码是10086。

3.3.4 向下一个Activity传递数据

前面几节已经对Intent的用法有了一定了解,但是目前为止仍然是简单的使用Intent来启动Activity,其实Intent在启动Activity时还可以传递数据。

在启动Activity时传递数据很简单,Intent中提供了一系列putExtra方法的重载,可以把我们想要传递的数据暂存在Intent中,在启动另一个Activity后,只需要把这些数据从Intent中取出即可。比如在FirstActivity中有一个字符串,想将其传入SecondActivity中,就可以这样写:

		button1.setOnClickListener {
            val data  = "Hello SecondActivity"
            val intent = Intent(this, SecondActivity::class.java)
            intent.putExtra("Extra data", data)
            startActivity(intent)
        }

这里我们还是使用显式Intent的方式来启动SecondActivity,并通过putExtra()方法传递了一个字符串。注意,这里putExtra()方法接收两个参数,第一个参数是键,用于之后从Intent中取值,第二个参数才是真正要传递的数据。然后在SecondActivity中将传递的数据取出并打印:

class SecondActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.second_layout)
        val extraData = intent.getStringExtra("Extra data")
        Log.d("SecondActivity", "extra data is $extraData")
    }
}

上述代码中的intent实际上调用的是父类的getIntent()方法,该方法会获取用于启动SecondActivity的Intent,然后调用getStringExtra()方法并传入相应的键值,就可以得到传递的数据了。这里由于我们传递的是字符串,所以使用getStringExtra()方法来获取传递的数据。如果传递的是整型数据,则使用getIntExtra()方法;如果传递的是布尔型数据,则使用getBooleanExtra()方法,以此类推。

image-20260118201741717

可以看到,我们在SecondActivity中成功得到了从FirstActivity传递过来的数据。

3.3.5 返回数据给上一个Activity

我们可以传递数据给下一个Activity,那么自然也可以传递数据给上一个Activity,不同的是,返回上一个Activity只需要按一下back键即可,没有一个用于启动Activity的Intent来传递数据。这时,我们可以使用Activity类的startActivityForResult方法,它用于启动Activity,但是期望在Activity销毁时能够返回一个结果给上一个Activity。

startActivityForResult方法接收两个参数,第一个参数还是Intent;第二个参数是请求码,用于在之后的回调中判断数据的来源。修改FirstActivity中按钮 点击事件:

button1.setOnClickListener {
            val intent = Intent(this, SecondActivity::class.java)
            startActivityForResult(intent,1)
        }

这里我们使用了startActivityForResult方法来启动SecondActivity,请求码只要是唯一值即可,这里传入1。接下来我们在SecondActivity中给按钮注册点击事件,并在点击事件中添加返回数据的逻辑:

class SecondActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.second_layout)
        val button2: Button = findViewById(R.id.button2)
        button2.setOnClickListener {
            val intent = Intent()
            intent.putExtra("data_return", "Hello FirstActivity")
            setResult(RESULT_OK, intent)
            finish()
        }
    }
}

可以看到,我们还是构建了一个Intent,只不过这个Intent仅仅用于传递数据而已,它没有指定任何的“意图”,紧接着把要传递的数据存放在Intent中,然后调用setResult方法,这个方法专门用于向上一个Activity返回数据。setResult方法接收两个参数,第一个参数用于向上一个Activity返回处理结果,一般只使用RESULT_OK或者RESULT_CANCEL这两个值;第二个参数则把带有数据的Intent传递回去。最后调用finish方法来销毁当前Activity。

由于我们是使用startActivityForResult方法来启动SecondActivity的,在SecondActivity被销毁后会回调上一个Activity的onActivityResult方法,因此我们需要在FirstActivity中重写这个方法来得到返回的数据:

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        when (requestCode) {
            1 -> if (resultCode == RESULT_OK) {
                val returnedData = data?.getStringExtra("data_return")
                Log.d("FirstActivity", "returned data is $returnedData")
            }
        }
    }

onActivityResult方法带有3个参数,第一个参数requestCode,即我们在启动Activity时传入的请求码;第二个参数resultCode,即我们在返回数据时传入的处理结果;第三个参数data,即携带着返回数据的Intent。由于在一个Acticity中有可能调用startActivityForResult方法去启动很多不同的Activity,每一个Activity返回的数据都会回调到onActivityResult方法中,因此我们首先要做的就是通过检查requestCode的值判断数据来源,确定是从SecondActivity返回的之后,我们再通过resultCode的的值判断处理结果是否成功。最后从data中取值并打印出来,这样就完成了向上一个Activity中返回数据的工作。

image-20260131142502934

可能有人会问,如果用户不通过button2按钮点击事件返回而是通过Back键返回FirstActivity怎么办?我们可以通过在SecondActivity中onBackPressedDispatcher的addCallback方法来解决这个问题:

onBackPressedDispatcher.addCallback(
            this,
            object : OnBackPressedCallback(true) {
                override fun handleOnBackPressed() {
                    val intent = Intent()
                    intent.putExtra("data_return", "Hello FirstActivity")
                    setResult(RESULT_OK, intent)
                    finish()
                }
            }
        )

3.4 Activity的生命周期

掌握Activity的生命周期对任何Android开发和逆向工程师都至关重要,本节就来深入了解Activity的生命周期。

3.4.1 返回栈

经过前面几节的学习,我们发现Activity是可以层叠的,每启动一个新的Activity,就会覆盖在原来的Activity之上,然后点击Back键会销毁最上面的Activity,下面的Activity会显示出来。

其实Android是使用任务(task)来管理Activity的,一个任务就是一组存放在栈里的Activity的集合,这个栈也称之为返回栈(back stack)。在默认情况下,每当我们启动了一个新的Activity,它就会在返回栈中入栈,,每当按下Back键或者调用finish方法去销毁一个Activity时,处于栈顶的Activity就会出栈,前一个入栈的Activity就会重新处于栈顶的位置。系统总是会显示处于栈顶的Activity给用户。

image-20260131152042507

3.4.2 Activity状态

每个Activity在其生命周期最多可能会有4种状态。

01 运行状态

当一个Activity位于返回栈的栈顶时,Activity就处于运行状态。系统最不愿意回收的就是处于运行状态的Activity,因为这会带来非常差的用户体验。

02 暂停状态

当一个Activity不再处于栈顶位置,但仍然可见时,Activity就进入了暂停状态。你可能会觉得,既然Activity已经不在栈顶了,怎么会可见呢。这是因为并不是每一个Activity都会占满整个屏幕,比如对话框形式的Activity只会占用屏幕中间的部分区域。处于暂停状态的Activity仍然是完全存活着的,系统也不愿意回收这种Activity,只有在内存极低的情况下才会考虑回收这种Activity。

03 停止状态

当一个Activity不再处于栈顶位置,并且完全不可见时,就进入停止状态。系统仍然会为这种Activity保存相应的状态和成员变量,但这并不完全可靠,当其他地方需要内存时,处于停止状态的Activity可能会被系统回收。

04 销毁状态

一个Activity从返回栈种移除后就变成了销毁状态。系统最倾向于回收处于这种状态的Activity,以保证手机的内存充足。

3.4.3 Activity的生存期

Activity类种定义了7个回调方法,覆盖Activity生命周期的每一个环节。

  • onCreate:这个方法已经看到过很多次了,我们在每一个Activity种都重写了这个方法,它会在Activity第一次被创建时调用。我们应该在这个方法中完成Activity的初始化操作,比如加载布局,绑定事件等。
  • onStart:这个方法在Activity由不可见变为可见时调用。
  • onResume:这个方法在Activity准备好和用户进行交互时调用。此时的Activity一定处于返回栈的栈顶,并处于运行状态。
  • onPause:这个方法在系统准备启动或者恢复另一个Activity时调用。我们通常会在这个方法中将一些消耗CPU的资源释放掉,以及保存一些关键数据,但这个方法的执行速度一定要快,否则会影响到新的栈顶Activity的使用。
  • onStop:这个方法在Activity完全不可见时调用。它和onPause方法的主要区别在于如果新启动的Activity是对话框式的Activity,那么onPause方法会得到执行,而onStop方法并不会执行。
  • onDestroy:这个方法在Activity被销毁之前调用,之后Activity的状态将变为销毁状态。
  • onRestart:这个方法在Activity由停止状态变为运行状态之前调用,也就是Activity被重新启动了。

以上7种方法种除了onRestart方法,其他都是两两对应的,从而又可以将Activity分为以下3种生存期。

  • 完整生存期:Activity在onCreate方法和onDestroy方法之间所经历的就是完整生存期。一般情况下,一个Activity会在onCreate方法种完成各种初始化操作,而在onDestroy方法种完成释放内存的操作。
  • 可见生存期:Activity在onStart方法和onStop方法之间所经历的就是可见生存期。在可见生存期内,Activity对于用户总是可见的,即便有可能无法和用户进行交互。我们可以通过这两个方法,合理地管理那些对用户可见的资源。比如在onStart方法中对资源进行加载,而在onStop方法中对资源进行释放,从而保证处于停止状态的Actvity不会占用过多内存。
  • 前台生存期:Activity在onResume方法和onPause方法之间所经历的就是前台生存期。前台生存期内,Activity总是处于运行状态,此时的Activity是可以和用户进行交互的,我们平时看到和接触最多的就是这个状态下的Activity。

image-20260131183850242

3.4.4 体验Activity的生命周期

为了更直观体验Activity的生命周期,我们新开一个拥有空Activity的项目,命名为ActivityLifeCycleTest。

接下来我们再创建两个子Activity——NormalActivity和DialogActivity。右击com.example.activitylifecycletest包→New→Activity→Empty Activity,新建NormalActivity,布局起名为normal_layout。然后使用同样的方式创DialogActivity,布局起名为dialog_layout。

编辑normal_layout.xml文件:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="This is a normal activity"
        />
</LinearLayout>

在这个布局中,我们非常简单地使用一个TextView,用于显示一行文字,在下一章将会讲述更多TextView的用法。

然后编辑dialog_layout.xml:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="This is a dialog activity"
        />
</LinearLayout>

两个布局文件的代码几乎没有区别,只有显示文字的不同。

从名字上看,这两个Activity一个是普通Activity,一个是对话框式的Activity。但目前我们并没有修改任何Activity的代码,两个Activity的代码几乎一样,如何将Activity设置成对话框式?这里需要修改AndroidManifest.xml,修改其<activity>标签:

		<activity android:name=".DialogActivity"
            android:theme="@style/Theme.AppCompat.Dialog">
        </activity>
        <activity android:name=".NormalActivity">
        </activity>

这是两个Activity的注册代码,但是DialogActivity的代码有些不同,我们给它使用了一个android:theme属性,用于给当前Activity指定主题,Android系统内置有很多主题可以选择,当然也支持自定义主题。而这里的@style/Theme.AppCompat.Dialog则毫无疑问是让DialogActivity使用对话框的主题。

接下来修改activity_main.xml,重新定制主Activity的布局:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
 android:orientation="vertical"
 android:layout_width="match_parent"
 android:layout_height="match_parent">
 <Button
 android:id="@+id/startNormalActivity"
 android:layout_width="match_parent"
 android:layout_height="wrap_content"
 android:text="Start NormalActivity" />
 <Button
 android:id="@+id/startDialogActivity"
 android:layout_width="match_parent"
 android:layout_height="wrap_content"
 android:text="Start DialogActivity" />
</LinearLayout>

可以看到,我们在LinearLayout中添加了两个按钮,一个用于启动NormalActivity,一个用于启动DialogActivity。最后修改MainActivity中的代码:

class MainActivity : AppCompatActivity() {
    private val tag = "MainActivity"
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Log.d(tag, "onCreate")
        setContentView(R.layout.activity_main)
        
        findViewById<Button>(R.id.startNormalActivity).setOnClickListener {
            val intent = Intent(this, NormalActivity::class.java)
            startActivity(intent)
        }
        findViewById<Button>(R.id.startDialogActivity).setOnClickListener {
            val intent = Intent(this, DialogActivity::class.java)
            startActivity(intent)
        }
    }
    override fun onStart() {
        super.onStart()
        Log.d(tag, "onStart")
    }
    override fun onResume() {
        super.onResume()
        Log.d(tag, "onResume")
    }
    override fun onPause() {
        super.onPause()
        Log.d(tag, "onPause")
    }
    override fun onStop() {
        super.onStop()
        Log.d(tag, "onStop")
    }
    override fun onDestroy() {
        super.onDestroy()
        Log.d(tag, "onDestroy")
    }
    override fun onRestart() {
        super.onRestart()
        Log.d(tag, "onRestart")
    }
}

在onCreate方法中,我们分别为两个按钮注册了点击事件,点击第一个按钮会启动NormalActivity,点击第二个按钮会启动DialogActivity。然后在Activity的7个回调方法中分别打印了一句话,这样可以通过Logcat日志来直观展现Activity的生命周期。

现在启动程序:

image-20260202143000277

可以看到,当MainActivity第一次被创建时会依次执行onCreate、onStart、onResume方法,然后点击第一个按钮,启动NormalActivity:

image-20260202143147144

由于NormalActivity已经把MainActivity完全遮挡住,因此onPause和onStop方法都会得到执行。然后按下Back键返回MainAActivity:

image-20260202143326792

由于之前MainActivity已经进入停止状态,所以onRestart方法会执行,之后会依次执行onStart和onResume方法,注意此时onCreate方法没有执行,因为MainActivity没有被销毁,只是进入了停止状态。接下来点击第二个按钮进入DialogActivity:

image-20260202143821312

可以看到,只有onPause方法被调用,因为MainActivity仍然可见,处于暂停状态。相应地,按下Back键时,只有onResume方法被调用:

image-20260202144145286

最后在MainActivity按下Back键退出程序:

image-20260202144329681

依次执行onPause、onStop和onDestroy,最终销毁MainActivity。

3.4.5 Activity被回收怎么办

之前我们说过,当一个Activity进入停止状态,就有可能被系统回收,那么问题来了,假设我们在Activity A基础上跳转到Activity B,但是由于内存不足,A被回收了,当我们按下Back键的时候,虽然会执行A的onCreate方法,看上去好像没问题,但是这个新创建的A已经不是原来的A了,里面存放的临时数据和状态会丢失。如何解决这个问题呢?Activity中还有一个onSaveInstanceState回调方法,这个方法可以保证在Activity被回收之前一定会被调用。

onSaveInstanceState方法会携带一个Bundle类型的参数,Bundle提供了一系列的方法用于保存数据,比如可以使用putString方法保存字符串,putInt用于保存整型数据/每个保存方法需要传入两个参数,第一个是键,用于后面从Bundle中取值,第二个参数是真正要保存的内容。

在MainActivity中添加如下代码就可以将临时数据进行保存了:

override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        val tempData = "Something you just typed"
        outState.putString("data_key", tempData)
    }

数据确实保存下来了,在哪里恢复呢?回到之前重写的onCreate方法,我们发现这里也有一个Bundle类型参数。这个参数在一般情况下是null,但是如果在Activity被回收之前通过onSaveInstanceState保存数据,那么这个参数就会带有之前保存的全部数据,我们只需要通过相应的取值方法将数据取出来即可。

修改MainActivity的onCreate方法:

override fun onCreate(savedInstanceState: Bundle?) {
 super.onCreate(savedInstanceState)
 Log.d(tag, "onCreate")
 setContentView(R.layout.activity_main)
 if (savedInstanceState != null) {
 val tempData = savedInstanceState.getString("data_key")
 Log.d(tag, "tempData is $tempData")
 }
 ...
}

发现了吗,Bundle的保存和取出数据和Intent有些像,Intent还可以结合Bundle一起用于传递数据。首先我们可以把需要传递的数据都保存在Bundle对象中,然后再将Bundle对象存放在Intent里。到了目标Activity之后,先从Intent中取出Bundle,再从Bundle中一一取出数据。

另外,当手机屏幕发生旋转时,Activity也会历经一个重新创建的过程,这个情况下也可以用onSaveInstanceState来解决,但是不太建议这样做,这部分内容将在13.2节中学习。

3.5 Activity的启动模式

在实际项目中,我们应该根据特定的需求为每个Activity指定恰当的启动模式。启动模式主要是4种,分别是standard、singleTop、singleTask和singleInstance,可以在AndroidManifest.xml种通过给activity标签指定android:launchMode来指定。

3.5.1 standard

standard是Activity的默认启动模式,在不进行显式指定的情况下,所有Activity都是用的这种启动模式。在standard模式下,每当启动一个新Activity,它就会在返回栈中入栈,对于使用standard模式的Activity,系统不在乎Activity是否已经存在于返回栈中,每次启动都会创建一个该Activity的新实例。

接下来通过实践体会一下standard模式,这次在ActivityTest项目上进行更改,修改FirstActivity中onCreate方法:

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Log.d("FirstActivity", this.toString())
        setContentView(R.layout.first_layout)
        findViewById<Button>(R.id.button1).setOnClickListener {
            val intent = Intent(this, FirstActivity::class.java)
            startActivity(intent)
        }
    }

代码看上去有点奇怪,在FirstActivity的基础上启动FirstActivity,但是我们的重点在于研究standard模式,所以不必在意这段代码有什么实际意义。另外,我们还在onCreate方法中添加了一行打印信息,用于打印当前Activity的实例。现在重新运行程序,然后在FirstActivity中连续点击两次按钮,查看Logcat中的打印信息:

image-20260202160938103

从打印信息中可以看出,每点击一次按钮,就会创建出一个新的FirstActivity实例。此时返回栈中也会存在3个FirstActivity的实例,因此你需要连按3次Back键才能退出程序。

standard模式原理如下:

image-20260202161026025

3.5.2 singleTop

在有些情况下,standard是不合理的,Activity明明已经在栈顶,为什么再次启动时还要创建一个Activity实例平白占据内存空间呢?这时,我们可以使用singleTop模式。修改AndroidManifest.xml中FirstActivity的启动模式:

<activity
            android:name=".FirstActivity"
            android:exported="true"
            android:label="This is first Activity"
            android:launchMode="singleTop">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

重新运行程序,查看Logcat,不管地洞多少次FirstActivity,都只会启用栈顶的Activity,只会有一个实例。

image-20260202162156406

但是当FirstActivity不处于栈顶时,还是会创建一个新的Activity实例进入栈顶。接下来试验一下,修改FristActivity中的onCreate方法的代码:

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Log.d("FirstActivity", this.toString())
        setContentView(R.layout.first_layout)
        findViewById<Button>(R.id.button1).setOnClickListener {
            val intent = Intent(this, SecondActivity::class.java)
            startActivity(intent)
        }
    }

修改SecondActivity的onCreate方法:

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Log.d("SecondActivity",this.toString())
        setContentView(R.layout.second_layout)

        findViewById<Button>(R.id.button2).setOnClickListener {
            val intent = Intent(this, FirstActivity::class.java)
            startActivity(intent)
        }
}

我们在SecondActivity中添加了一行打印日志,并且在按钮点击事件里加入了启动FirstActivity的代码。现在重新运行程序,在FirstActivity界面点击按钮进入SecondActivity,然后在SecondActivity界面点击按钮,又会重新进入FirstActivity。

image-20260202164513721

singleTop启动模式原理如下:

image-20260202164548833

3.5.3 singleTask

使用singleTop模式固然可以解决栈顶Activity重复创建的情况,但是如果Activity没有处于栈顶,仍然会重复创建Activity实例,这样太占内存了,对优化不好,有没有办法让某个Activity在整个应用程序的上下文中只存在一个实例?这里引入singleTask模式。当Activity的启动模式为singleTask时,每次启动该Activity时,系统首先会在返回栈中检查是否存在该Activity的实例,如果发现已存在则直接使用该实例,并把这个Activity之上的所有其他Activity全部出栈,如果没有发现则创建一个新的实例。

还是通过代码直观理解一下。修改启动模式:

<activity
            android:name=".FirstActivity"
            android:exported="true"
            android:label="This is first Activity"
            android:launchMode="singleTask">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

在FirstActivity中重写onRestart方法:

override fun onRestart() {
        super.onRestart()
        Log.d("mine", "FirstActivity onRestart")
    }

在SecondActivity中重写onDestroy方法:

override fun onDestroy() {
        super.onDestroy()
        Log.d("mine","SecondActivity onDestroy")
    }

现在重新运行程序,在FirstActivity界面点击按钮进入SecondActivity,然后在SecondActivity界面点击按钮,重新进入FirstActivity。

image-20260202193103092

从打印信息中就可以明显看出,在SecondActivity中启动FirstActivity时,会发现返回栈中已经存在一个FirstActivity的实例,并且是在SecondActivity的下面,于是SecondActivity会从返回栈中出栈,而FirstActivity重新成为了栈顶Activity,因此FirstActivity的onRestart()方法和SecondActivity的onDestroy()方法会得到执行。现在返回栈中只剩下一个FirstActivity的实例了,按一下Back键就可以退出程序。

singleTask模式的原理如下:

image-20260202193229696

3.5.4 singleInstance

singleInstance模式不同于以上3种方式,它启用了一个新的返回栈来管理这个Activity(其实如果singleTask模式指定了不同的taskAffinity,也会启动一个新的返回栈)。

为什么这样做?假设我们现在的程序中存在一个Activity允许别的程序调用,如果想让这个Activity是“共享”的,前面3种启动模式是做不到的,因为每个应用程序都有自己的返回栈,同一个Activity在不同的返回栈中入栈时必定创造了新的实例。而使用singleInstance就能解决这个问题,在这种模式下,会有一个单独的返回栈来管理这个Activity,不管是哪个应用程序来访问这个Activity,都共用一个返回栈。

还是来实践一下,修改SecondActivity的启动模式:

<activity
            android:name=".SecondActivity"
            android:exported="true"
            android:launchMode="singleInstance">
            <intent-filter>
                <action android:name="com.example.activitytest.ACTION_START" />

                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.MY_CATEGORY" />
            </intent-filter>
        </activity>

修改FirstActivity的onCreate方法:

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Log.d("mine", "FirstActivity Task id is $taskId")
        setContentView(R.layout.first_layout)
        findViewById<Button>(R.id.button1).setOnClickListener {
            val intent = Intent(this, SecondActivity::class.java)
            startActivity(intent)
        }
    }

这里我们在onCreate方法中打印了当前返回栈的id。上述代码中的taskId是前面提到过的语法糖,实际是调用了父类的getIaskId方法。修改SecondActivity中的onCreate方法:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    Log.d("mine", "SecondActivity Task id is $taskId")
    setContentView(R.layout.first_layout)
    findViewById<Button>(R.id.button1).setOnClickListener {
        val intent = Intent(this, ThirdActivity::class.java)
        startActivity(intent)
    }
}

同样在onCreate()方法中打印了当前返回栈的id,然后又修改了按钮点击事件的代码,用于启动ThirdActivity。最后修改ThirdActivity中的onCreate()方法:

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Log.d("mine", "ThirdActivity Task id is $taskId")
        setContentView(R.layout.third_layout)
    }

目前为止,三个Activity的启动模式分别为singleTask、singleInstance和standard。

image-20260202201500652

可以看到,SecondActivity的Task id不同于FirstActivity和ThirdActivity,这说明SecondActivity确实是存放在一个单独的返回栈里的,而且这个栈中只有SecondActivity这一个Activity。

然后我们按下Back键进行返回,你会发现ThirdActivity竟然直接返回到了FirstActivity,再按下Back键又会返回到SecondActivity,再按下Back键才会退出程序,这是为什么呢?其实原理很简单,由于FirstActivity和ThirdActivity是存放在同一个返回栈里的,当在ThirdActivity的界面按下Back键时,ThirdActivity会从返回栈中出栈,那么FirstActivity就成为了栈顶Activity显示在界面上,因此也就出现了从ThirdActivity直接返回到FirstActivity的情况。然后在FirstActivity界面再次按下Back键,这时当前的返回栈已经空了,于是就显示了另一个返回栈的栈顶Activity,即SecondActivity。最后再次按下Back键,这时所有返回栈都已经空了,也就自然退出了程序。

singleInstance原理如下:

image-20260202201705082

3.6 Activity的小tips

关于Activity的东西介绍的差不多了,但是距离灵活运用还有一段距离,这里介绍几个常用小技巧。

3.6.1 知晓当前在哪一个Activity

这个技巧主要用于接手一个新项目和逆向分析时使用,还是在ActivityTest的基础上修改。

首先需要新建一个BaseActivity类。右击com.example.activitytest包→New→Kotlin File/Class,在弹出的窗口中输入BaseActivity,创建类型选择Class。

这里的BaseActivity和普通的Activity不一样,我们不需要让BaseActivity在AndroidManifest.xml中注册,创建一个普通的kotlin类即可。这里需要说明的是,BaseActivity确实是一个Activity,其继承自AppCompatActivity,但是由于没有在AndroidManifest.xml中注册,它不是一个Activity组件。除此之外,还有可能存在没有可视化界面的Activity组件。

让BaseActivity继承AppCompatActivity,并重写onCreate()方法:

open class BaseActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Log.d("BaseActivity", javaClass.simpleName)
    }
}

我们在onCreate方法中加了一行日志,用于打印当前实例的类名。这里需要额外说明的是,kotlin中的javaClass表示获取当前实例的Class对象,相当于在java中调用getClass方法;而Kotlin中的BaseActivity::class.java表示获取BaseActivity类的Class对象,相当于在Java中调用BaseActivity.class。在上述代码中,我们先是获取了当前实例的Class对象,然后再调用simpleName获取当前实例的类名。

接下来需要让BaseActivity成为ActivityTest项目中所有Activity的父类,这时,由于BaseActivity又是继承自AppCompatActivity的,所以项目中所有Activity的现有功能并不受影响,它们仍然继承了Activity中的所有特性。至于为什么在子类中重写了onCreate,为什么父类中的Log.d会被调用,因为调用了super.onCreate。

重新运行,通过点击按钮分别进入几个Activity:

image-20260203105128812

3.6.2 随时退出程序

当我们的返回栈中存在太多Activity时,这时通过Back键退出程序是很麻烦的,即使是Home键也只是把程序挂起,而非退出。如果想要快速退出也很简单,只需要用一个专门的集合对所有的Activity进行管理即可。

新建单例类ActivityCollector作为Activity的集合:

object ActivityCollector{
    private val activities = ArrayList<Activity>()
    
    fun addActivity(activity: Activity) {
        activities.add(activity)
    }
    
    fun removeActivity(activity: Activity) {
        activities.remove(activity)
    }
    
    fun finishAll() {
        for (activity in activities) {
            if (!activity.isFinishing) {
                activity.finish()
            }
        }
    }
}

这里创建了一个单例类,是因为在全局只需要用到一个Activity集合。集合中,我们通过一个ArrayList来暂存Activity,然后提供了一个addActivity()方法,用于向ArrayList中添加Activity;提供了一个removeActivity()方法,用于从ArrayList中移除Activity;最后提供了一个finishAll()方法,用于将ArrayList中存储的Activity全部销毁。注意在销毁Activity之前,我们需要先调用activity.isFinishing来判断Activity是否正在销毁中,因为Activity还可能通过按下Back键等方式被销毁,如果该Activity没有正在销毁中,我们再去调用它的finish()方法来销毁它。

修改BaseActivity中的代码:

open class BaseActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Log.d("BaseActivity", javaClass.simpleName)
        ActivityCollector.addActivity(this)
    }

    override fun onDestroy() {
        super.onDestroy()
        ActivityCollector.removeActivity(this)
    }
}

在BaseActivity的onCreate()方法中调用了ActivityCollector的addActivity()方法,表明将当前正在创建的Activity添加到集合里。然后在BaseActivity中重写onDestroy()方法,并调用了ActivityCollector的removeActivity()方法,表明从集合里移除一个马上要销毁的Activity。这是为了使返回栈中的Activity状态和单例类集合中的Activity状态相同,因为activities集合本来就是用来模拟返回栈的,如果不同,会导致程序崩溃。

之后,不管想在什么地方退出程序,只需要调用ActivityCollector.finishAll()即可。注意,这里本质上还是一个个退出返回栈,但是不需要手动点击Back键,而且速度会快很多。我们将ThirdActivity稍作更改:

class ThirdActivity : BaseActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Log.d("ThirdActivity", "Task id is $taskId")
        setContentView(R.layout.third_layout)
        findViewById<Button>(R.id.button3).setOnClickListener {
            ActivityCollector.finishAll()
        }
    }
}

为了彻底杀死程序,我们还可以加上一行代码:

android.os.Process.killProcess(android.os.Process.myPid())

KillProcess()方法用于杀掉一个进程,它接收一个进程id参数,我们可以通过myPid()方法来获得当前程序的进程id。需要注意的是,killProcess()方法只能用于杀掉当前程序的进程,不能用于杀掉其他程序。

3.6.3 启动Activity的最佳写法

启动Activity的方法相信你已经非常熟悉了,首先通过Intent构建出当前的“意图”,然后调用startActivity()或startActivityForResult()方法将Activity启动起来,如果有数据需要在Activity之间传递,也可以借助Intent来完成。

假设SecondActivity中需要用到两个非常重要的字符串参数,在启动SecondActivity的时候必须传递过来,那么我们很容易会写出如下代码:

val intent = Intent(this, SecondActivity::class.java)
intent.putExtra("param1", "data1")
intent.putExtra("param2", "data2")
startActivity(intent)

虽然这样写是完全正确的,但是在真正的项目开发中经常会出现对接的问题。比如SecondActivity并不是由你开发的,但现在你负责开发的部分需要启动SecondActivity,而你却不清楚启动SecondActivity需要传递哪些数据。这时无非就有两个办法:一个是你自己去阅读SecondActivity中的代码,另一个是询问负责编写SecondActivity的同事。你会不会觉得很麻烦呢?其实只需要换一种写法,就可以轻松解决上面的窘境。

修改SecondActivity中的代码:

class SecondActivity : BaseActivity() {
     ...
     companion object {
         fun actionStart(context: Context, data1: String, data2: String) {
             val intent = Intent(context, SecondActivity::class.java)
             intent.putExtra("param1", data1)
             intent.putExtra("param2", data2)
             context.startActivity(intent)
         }
     }
}

在这里我们使用了一个新的语法结构companion object,并在companion object中定义了一个actionStart()方法。之所以要这样写,是因为Kotlin规定,所有定义在companion object中的方法都可以使用类似于Java静态方法的形式调用。关于companion object的更多内容,我会在本章的Kotlin课堂中进行讲解。

接下来我们重点看actionStart()方法,在这个方法中完成了Intent的构建,另外所有SecondActivity中需要的数据都是通过actionStart()方法的参数传递过来的,然后把它们存储到Intent中,最后调用startActivity()方法启动SecondActivity。

这样写的好处在哪里呢?最重要的一点就是一目了然,SecondActivity所需要的数据在方法参数中全部体现出来了,这样即使不用阅读SecondActivity中的代码,不去询问负责编写SecondActivity的同事,你也可以非常清晰地知道启动SecondActivity需要传递哪些数据。另外,这样写还简化了启动Activity的代码,现在只需要一行代码就可以启动SecondActivity,如下所示:

findViewById<Button>(R.id.button1).setOnClickListener {
            SecondActivity.actionStart(this, "data1","data2")
        }

3.7 Kotlin补全:标准函数和静态方法

尽管在第二章我们已经学习了kotlin的基础用法,但还有许多高级技巧没有涉猎,在每一章的Kotlin补全中,将一一介绍这些内容。

3.7.1 标准函数with、run和apply

Kotlin的标准函数指的是Standard.kt文件中定义的函数,任何Kotlin代码都可以自由地调用所有的标准函数。

这里我们只学习几个最常用的标准函数。首先是let,其主要作用是配合?.操作符来进行辅助判空处理,这里不再赘述。

01 with

下面我们从with函数开始,with接收两个参数:第一个参数可以是任意类型的对象,第二个参数是一个Lambda表达式。with函数会在Lambda表达式中提供第一个参数对象的上下文,并使用Lambda表达式中的最后一行代码作为返回值返回。比如:

val result = with(obj) {
 // 这里是obj的上下文
 "value" // with函数的返回值
}

这个函数主要用于连续调用同一个对象的多个方法时让代码更精简。比如有一个水果列表,现在我们想吃完所有水果,并将结果打印出来,可以这样写:

fun main() {
    val list = listOf("Apple", "Banana", "Orange", "Pear", "Grape")
    val builder = StringBuilder()
    builder.append("Start eating fruits.\n")
    for (fruit in list) {
        builder.append(fruit).append("\n")
    }
    builder.append("Ate all fruits.")
    val result = builder.toString()
    println(result)
}

观察上述代码,你会发现我们连续调用了很多次builder对象的方法。其实,这个时候就可以考虑使用with函数来让代码变得更精简:

fun main() {
    val list = listOf("Apple", "Banana", "Orange", "Pear", "Grape")
    val result = with(StringBuilder()) {
        append("start to eat\n")
        for (fruit in list) {
            append(fruit).append("\n")
        }
        append("eat all")
        toString()
    }
    println(result)
}

首先我们给with函数的第一个参数传入了一个StringBuilder对象,那么接下来整个Lambda表达式的上下文就会是这个StringBuilder对象。于是我们在Lambda表达式中就不用再像刚才那样调用builder.append()和builder.toString()方法了,而是可以直接调用append()和toString()方法。Lambda表达式的最后一行代码会作为with函数的返回值返回。

02 run

接下来是另一个标准函数run,run函数的使用场景和with非常类似,只是稍微有一些语法改动。首先run函数通常不会直接调用,而是要在某个对象的基础上调用,其次run函数只接受一个Lambda参数,并且会在Lambda表达式中提供调用对象的上下文。其他方面和with函数是一样的,包括也会使用Lambda表达式中的最后一行代码作为返回值返回。举个例子:

val result = obj.run {
 // 这里是obj的上下文
 "value" // run函数的返回值
}

用run函数修改上面的代码:

fun main() {
    val list = listOf("Apple", "Banana", "Orange", "Pear", "Grape")
    val result = StringBuilder().run {
        append("start to eat\n")
        for (fruit in list) {
            append(fruit).append("\n")
        }
        append("eat all")
        toString()
    }
    println(result)
}
03 apply

apply函数和run函数也极其类似,都要在某个对象上调用,并且只接收一个Lambda参数,也会在Lambda参数种果提供调用对象的上下文,但是apply函数无法返回指定值,只能自动返回调用对象本身。比如:

val result = obj.apply {
    // 这里是obj的上下文
}
//result == obj

现在用apply对上述代码进行修改:

fun main() {
    val list = listOf("Apple", "Banana", "Orange", "Pear", "Grape")
    val result = StringBuilder().apply {
        append("start to eat\n")
        for (fruit in list) {
            append(fruit).append("\n")
        }
        append("eat all")
    }
    println(result.toString())
}

注意这里的代码变化,由于apply函数无法指定返回值,只能返回调用对象本身,因此这里的result实际上是一个StringBuilder对象,所以我们在最后打印的时候还要再调用它的toString()方法才行。

这里回到在之前的最佳实践环节编写的启动Activity的代码,我们可以进一步精简:

fun actionStart(context: Context, data1: String, data2: String) {
    val intent = Intent(context, SecondActivity::class.java).apply {
        putExtra("param1", data1)
        putExtra("param2", data2)
    }
    context.startActivity(intent)
}

3.7.2 定义静态方法

静态方法在某些编程语言中被称为类方法,指的是那种不需要创建实例就可以调用的方法。在java中定义一个静态方法很简单,只需要在方法上声明关键字static:

public class Util {
    public static void doAction() {
        System.out.println("do action");
    }
}

这是一个很简单的工具类,上述代码中的doAction就是一个静态方法。调用静态方法不需要创建类的实例,可以直接以Util.doAction()这种写法来调用。因而静态方法非常适合用于编写一些工具类的功能,因为工具类通常没有创建实例的必要,基本是全局通用的。

但是和绝大多数主流编程语言不同的是,Kotlin却极度弱化了静态方法这个概念,想要在Kotlin中定义一个静态方法反倒不是一件容易的事。

那么Kotlin为什么要这样设计呢?因为Kotlin提供了比静态方法更好用的语法特性,并且我们在上一节中已经学习过了,那就是单例类。

像工具类这种功能,在Kotlin中就非常推荐使用单例类的方式来实现,比如上述的Util工具类,如果使用Kotlin来实现的话就可以这样写:

object Util {
    fun doAction() {
        println("do action")
    }
}

但是单例类也有问题,因为单例类会直接将整个类中的函数都变为类似静态方法的调用方式,这个时候就用到了之前在SecondActivity中的companion object了:

class Util {
    
    fun doAction1() {
        println("do action1")
    }
    
    companion object {
        fun doAction2(){
        	println("do action2")
        }
    }
}

不过,doAction2()方法其实也并不是静态方法,companion object这个关键字实际上会在Util类的内部创建一个伴生类,而doAction2()方法就是定义在这个伴生类里面的实例方法。只是Kotlin会保证Util类始终只会存在一个伴生类对象,因此调用Util.doAction2()方法实际上就是调用了Util类中伴生对象的doAction2()方法。

由此可以看出,Kotlin确实没有直接定义静态方法的关键字,但是提供了一些语法特性来支持类似于静态方法调用的写法,这些语法特性基本可以满足我们平时的开发需求了。

然而如果你确确实实需要定义真正的静态方法, Kotlin仍然提供了两种实现方式:注解和顶层方法。

先来看注解,前面使用的单例类和companion object都只是在语法的形式上模仿了静态方法的调用方式,实际上它们都不是真正的静态方法。因此如果你在Java代码中以静态方法的形式去调用的话,你会发现这些方法并不存在。而如果我们给单例类或companion object中的方法加上@JvmStatic注解,那么Kotlin编译器就会将这些方法编译成真正的静态方法:

class Util {
    
    fun doAction1() {
        println("do action1")
    }
    
    companion object {
        @JvmStatic
        fun doAction2(){
        	println("do action2")
        }
    }
}

注意,@JvmStatic注解只能加在单例类或companion object中的方法上,如果你尝试加在一个普通方法上,会直接提示语法错误。由于doAction2()方法已经成为了真正的静态方法,那么现在不管是在Kotlin中还是在Java中,都可以使用Util.doAction2()的写法来调用了。

接下来看看顶层方法,顶层方法指的是那些没有定义在任何类中的方法,比如我们在上一节中编写的main()方法。kotlin编译器会将所有的顶层方法全部编译为静态方法,因此只要定义了一个顶层方法,那么它一定是静态方法。

想要定义一个顶层方法,首先创建一个kotlin文件,对着任意包名右击 → New → Kotlin File/Class,在弹出的对话框中输入文件名即可,注意创建类型要选择File。

这里定义一个doSomething函数:

fun doSomething() {
    println("do something")
}

关于如何调用的问题,如果是在kotlin中直接键入方法名即可,因为kotlin中的所有顶层方法在任何位置都可以被调用,不用管路径包名,也不用创建实例。

如果在java中没有顶层方法这个概念,必须要在类中才能调用。回顾刚才创建的kotlin文件,名字是Helper.kt,于是kotlin编译器会自动创建一个叫做HelperKt的java类,doSomething方法就是以静态方法的形式定义在HelperKt类中的。

image-20260203143423939

第四章 UI开发

4.1 如何编写程序界面

在过去,Android应用程序的界面主要是通过编写XML的方式来实现的。写XML的好处是,我们不仅能够了解界面背后的实现原理,而且编写出来的界面还可以具备很好的屏幕适配性。等你完全掌握了使用XML来编写界面的方法之后,不管是进行高复杂度的界面实现,还是分析和修改当前现有的界面,都将手到擒来。

不过最近几年,Google又推出了一个全新的界面布局:ConstraintLayout。和以往传统的布局不同,ConstraintLayout不是非常适合通过编写XML的方式来开发界面,而是更加适合在可视化编辑器中使用拖放控件的方式来进行操作,并且Android Studio中也提供了非常完备的可视化编辑器。

但是ConstraintLayout不讲,感兴趣可以去博主郭霖的公众号回复或“约束布局”或者“ConstraintLayout”即可。而我的评价是,知道基础用法即可,作为个人开发者或者逆向工程师,这个面子工程是最不重要的,个人开发者来说可以AI定制说得过去的界面,逆向工程师更是懒得管UI长什么样。

4.2 常用的控件使用方法

Android给我们提供了大量的UI控件,合理地使用这些控件可以轻松地编写出相当不错的界面,下面挑几种常用的控件,详细介绍一下其使用方法。

首先新建一个UIWidgetTest项目。简单起见,还是允许Android Studio自动创建Activity,Activity名和布局名都使用默认值。

4.2.1 TextView

TextView在之前其实已经接触过,它主要用于在界面上显示一段文本信息,比如在第一章看到的Hello world。

下面我们接着看TextView的更多用法,更改activity_main.xml中的代码:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="bottom">
    <TextView
        android:id="@+id/textView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="This is TextView"/>
</LinearLayout>

外面的LinearLayout先忽略不看,在TextView中我们使用android:id给当前控件定义了一个唯一标识符,这个属性在上一章中已经讲解过了。然后使用android:layout_width和android:layout_height指定了控件的宽度和高度。Android中所有的控件都具有这两个属性,可选值有3种:match_parent、wrap_content和固定值。match_parent表示让当前控件的大小和父布局的大小一样,也就是由父布局来决定当前控件的大小。wrap_content表示让当前控件的大小能够刚好包含住里面的内容,也就是由控件内容决定当前控件的大小。固定值表示表示给控件指定一个固定的尺寸,单位一般用dp,这是一种屏幕密度无关的尺寸单位,可以保证在不同分辨率的手机上显示效果尽可能地一致,如50 dp就是一个有效的固定值。

所以上面的代码就表示让TextView的宽度和父布局一样宽,也就是手机屏幕的宽度,让TextView的高度足够包含住里面的内容就行。

image-20260203152226979

虽然指定的文本内容正常显示了,不过我们好像没看出来TextView的宽度是和屏幕一样宽的。其实这是由于TextView中的文字默认是居左上角对齐的,虽然TextView的宽度充满了整个屏幕,可是由于文字内容不够长,所以从效果上完全看不出来。现在我们修改TextView的文字对齐方式:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="bottom">
    <TextView
        android:id="@+id/textView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="This is TextView"
        android:gravity="center"/>
</LinearLayout>

我们使用android:gravity来指定文字的对齐方式,可选值有top、bottom、start、end、center等,可以用“|”来同时指定多个值,这里我们指定的是”center”,效果等同于”center_vertical|center_horizontal”,表示文字在垂直和水平方向都居中对齐。现在重新运行程序:

image-20260203152422858

另外还可以对TextView中文字的颜色和大小进行设置:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="bottom">
    <TextView
        android:id="@+id/textView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textColor="#00ff00"
        android:textSize="24sp"
        android:text="This is TextView"
        android:gravity="center"/>
</LinearLayout>

通过android:textColor属性可以指定文字的颜色,通过android:textSize属性可以指定文字的大小。文字大小要使用sp作为单位,这样当用户在系统中修改了文字显示尺寸时,应用程序中的文字大小也会跟着变化。

image-20260203153515387

其他的一些属性就不说了,用到再查。

4.2.2 Button

Button的可配置属性和TextView差不多:

<Button
 android:id="@+id/button"
 android:layout_width="match_parent"
 android:layout_height="wrap_content"
 android:text="Button" />

image-20260203153914620

如果你很细心的话,可能会发现我们在XML中指定按钮上的文字明明是Button,可是为什么界面上显示的却是BUTTON呢?这是因为Android系统默认会将按钮上的英文字母全部转换成大写,可能是认为按钮上的内容都比较重要吧。如果这不是你想要的效果,可以在XML中添加android:textAllCaps=“false”这个属性,这样系统就会保留你指定的原始文字内容了。

接下来在MainActivity中为Button的点击事件注册一个监听器:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContentView(R.layout.activity_main)
        findViewById<Button>(R.id.button).setOnClickListener { 
            
        }
    }
}

这里调用button的setOnClickListener()方法时利用了Java单抽象方法接口的特性,从而可以使用函数式API的写法来监听按钮的点击事件。这样每当点击按钮时,就会执行Lambda表达式中的代码,我们只需要在Lambda表达式中添加待实现的逻辑就行了。关于Java函数式API的讲解,可以参考2.4.3小节。

除了使用函数式API的方式来注册监听器,也可以使用实现接口的方式来进行注册:

class MainActivity : AppCompatActivity(), View.OnClickListener {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        findViewById<Button>(R.id.button).setOnClickListener(this)
    }
    override fun onClick(v: View?) {
        when (v?.id) {
            R.id.button -> {
                // 在此处添加逻辑
            }
        }
    }
}

这里我们让MainActivity实现了View.OnClickListener接口,并重写了onClick()方法,然后在调用button的setOnClickListener()方法时将MainActivity的实例传了进去。这样每当点击按钮时,就会执行onClick()方法中的代码了。关于Kotlin接口这部分知识的讲解可以参考2.3.3小节。

4.2.3 EditText

EditText是程序用于和用户进行交互的重要控件,它允许用户在控件里输入和编辑内容,并在程序中对这些内容进行处理。

修改activity_main.xml的代码:

<EditText
        android:id="@+id/editText"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        />

其实看到这里,估计你已经总结出Android控件的使用规律了。用法都很相似,给控件定义一个id,指定控件的宽度和高度,然后再适当加入些控件特有的属性就差不多了,所以使用XML来编写界面其实一点都不难。现在重新运行一下程序,EditText就已经在界面上显示出来了,并且我们是可以在里面输入内容的:

image-20260203165110605

一些做得比较人性化的软件会在输入框里显示一些提示性的文字,一旦用户输入了任何内容,这些提示性的文字就会消失。这种提示功能在Android里是非常容易实现的,我们甚至不需要做任何逻辑控制,因为系统已经帮我们都处理好了。修activity_main.xml:

<EditText
        android:id="@+id/editText"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="Type something here"/>

可以看到,EditText中显示了一段提示性文本,然后当我们输入任何内容时,这段文本就会自动消失。不过,随着输入的内容不断增多,EditText会被不断地拉长。这是由于EditText的高度指定的是wrap_content,因此它总能包含住里面的内容,但是当输入的内容过多时,界面就会变得非常难看。我们可以使用android:maxLines属性来解决这个问题,修改activity_main.xml:

<EditText
        android:id="@+id/editText"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="Type something here"
        android:maxLines="2"/>

这里通过android:maxLines指定了EditText的最大行数为两行,这样当输入的内容超过两行时,文本就会向上滚动,EditText则不会再继续拉伸。

我们还可以结合使用EditText与Button来完成一些功能,比如通过点击按钮获取EditText中输入的内容。修改MainActivity中的代码:

class MainActivity : AppCompatActivity(), View.OnClickListener {
 ...
 override fun onClick(v: View?) {
        when (v?.id) {
            R.id.button -> {
                val inputText = findViewById<EditText>(R.id.editText).text.toString()
                Toast.makeText(this, inputText, Toast.LENGTH_SHORT).show()
            }
        }
    }
}

们在按钮的点击事件里调用EditText的getText()方法获取输入的内容,再调用toString()方法将内容转换成字符串,最后使用Toast将输入的内容显示出来。

当然,上述代码再次使用了Kotlin调用Java Getter和Setter方法的语法糖,在代码中好像调用的是EditText的text属性,实际上调用的却是EditText的getText()方法。在之后的学习中,将不会每次都讲解这个语法糖。

现在来重新运行程序测试一下:

image-20260203170346860

4.2.4 ImageView

ImageView是用于在界面上展示图片的一个控件,它可以让我们的程序界面变得更加丰富多彩。学习这个控件需要提前准备好一些图片,你可以自己准备任意的图片,也可以使用随书源码附带的图片资源https://www.ituring.com.cn/book/2744。图片通常放在drawable开头的目录下,并带上具体的分辨率。现在最主流的手机屏幕分辨率大多是xxhdpi的,所以我们在res目录下再新建一个drawable-xxhdpi目录,然后将事先准备好的两张图片img_1.png和img_2.png复制到该目录当中。

修改activity_main.xml:

<ImageView
        android:id="@+id/imageView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:src="@drawable/img_1"
        />

可以看到,这里使用android:src属性给ImageView指定了一张图片。由于图片的宽和高都是未知的,所以将ImageView的宽和高都设定为wrap_content,这样就保证了不管图片的尺寸是多少,都可以完整地展示出来。

image-20260204133851763

我们还可以在代码中动态地更改ImageView中地图片,修改MainActivity:

class MainActivity : AppCompatActivity(), View.OnClickListener {
 ...
override fun onClick(v: View?) {
        when (v?.id) {
            R.id.button -> {
                findViewById<ImageView>(R.id.imageView).setImageResource(R.drawable.img_2)
            }
        }
    }
}

4.2.5 ProgressBar

ProgressBar用于在界面上显示一个进度条,表示我们的程序正在加载一些数据。它的用法也非常简单,修改activity_main.xml中的代码:

<ProgressBar
        android:id="@+id/progressBar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        />

image-20260204134417840

旋转的进度条表明我们的程序正在加载数据,那数据总会有加载完的时候吧,如何才能让进度条在数据加载完成时消失呢?这里我们就需要用到一个新的知识点:Android控件的可见属性。所有的Android控件都具有这个属性,可以通过android:visibility进行指定,可选值有3种:visible、invisible和gone。visible表示控件是可见的,这个值是默认值,不指定android:visibility时,控件都是可见的。invisible表示控件不可见,但是它仍然占据着原来的位置和大小,可以理解成控件变成透明状态了。gone则表示控件不仅不可见,而且不再占用任何屏幕空间。我们可以通过代码来设置控件的可见性,使用的是setVisibility()方法,允许传入View.VISIBLE、View.INVISIBLE和View.GONE这3种值。

接下来我们就来尝试实现一种效果:点击一下按钮让进度条消失,再点击一下按钮让进度条出现。修改MainActivity中的代码:

class MainActivity : AppCompatActivity(), View.OnClickListener {
 ...
 override fun onClick(v: View?) {
        when (v?.id) {
            R.id.button -> {
                val progressBar: ProgressBar = findViewById<ProgressBar>(R.id.progressBar)
                if (progressBar.visibility == View.VISIBLE){
                    progressBar.visibility = View.GONE
                }
                else {
                    progressBar.visibility = View.VISIBLE
                }
            }
        }
    }
}

在按钮的点击事件中,我们通过getVisibility()方法来判断ProgressBar是否可见,如果可见就将ProgressBar隐藏掉,如果不可见就将ProgressBar显示出来。重新运行程序,然后不断地点击按钮,你就会看到进度条在显示与隐藏之间来回切换了。

另外,我们还可以给ProgressBar指定不同的样式,刚刚是圆形进度条,通过style属性可以将它指定成水平进度条,修改activity_main.xml中的代码:

<ProgressBar
        android:id="@+id/progressBar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        style="?android:attr/progressBarStyleHorizontal"
        android:max="100"/>

指定成水平进度条后,我们还可以通过android:max属性给进度条设置一个最大值,然后在代码中动态地更改进度条的进度。修改MainActivity中的代码:

override fun onClick(v: View?) {
        when (v?.id) {
            R.id.button -> {
                findViewById<ProgressBar>(R.id.progressBar).progress += 10
            }
        }
    }

每点击一次按钮,我们就获取进度条的当前进度,然后在现有的进度上加10作为更新后的进度。

4.2.6 AlertDialog

AlertDialog可以在当前界面弹出一个对话框,这个对话框是置顶于所有界面元素之上的,能够屏蔽其他控件的交互能力,因此AlertDialog一般用于提示一些非常重要的内容或者警告信息。

修改MainActivity:

override fun onClick(v: View?) {
        when (v?.id) {
            R.id.button -> {
                AlertDialog.Builder(this).apply {
                    setTitle("This is Dialog")
                    setMessage("Something important.")
                    setCancelable(false)
                    setPositiveButton("OK") { dialog, which ->
                    }
                    setNegativeButton("Cancel") { dialog, which ->
                    }
                    show()
                }
            }
        }
    }

首先通过AlertDialog.Builder构建一个对话框,这里我们使用了Kotlin标准函数中的apply函数。在apply函数中为这个对话框设置标题、内容、可否使用Back键关闭对话框等属性,接下来调用setPositiveButton()方法为对话框设置确定按钮的点击事件,调用setNegativeButton()方法设置取消按钮的点击事件,最后调用show()方法将对话框显示出来就可以了。

4.3 详解3种基本布局

一个丰富的界面是由很多个控件组成的,那么我们如何才能让各个控件都有条不紊地摆放在界面上,而不是乱糟糟的呢?这就需要借助布局来实现了。布局是一种可用于放置很多控件的容器,它可以按照一定的规律调整内部控件的位置,从而编写出精美的界面。当然,布局的内部除了放置控件外,也可以放置布局,通过多层布局的嵌套,我们就能够完成一些比较复杂的界面实现。

image-20260204140239544

4.3.1 LinearLayout

LinearLayout又称作线性布局,是一种非常常用的布局。正如它的名字所描述的一样,这个布局会将它所包含的控件在线性方向上依次排列。相信你之前也已经注意到了,我们在上一节中学习控件用法时,所有的控件就都是放在LinearLayout布局里的,因此上一节中的控件也确实是在垂直方向上线性排列的。

4.3.2 RelativeLayout

RelativeLayout又称作相对布局,也是一种非常常用的布局。和LinearLayout的排列规则不同,RelativeLayout显得更加随意,它可以通过相对定位的方式让控件出现在布局的任何位置。也正因为如此,RelativeLayout中的属性非常多,不过这些属性都是有规律可循的,其实并不难理解和记忆。

4.3.3 FrameLayout

FrameLayout又称作帧布局,它相比于前面两种布局就简单太多了,因此它的应用场景少了很多。这种布局没有丰富的定位方式,所有的控件都会默认摆放在布局的左上角。

这一小节我就略过,因为不论是开发还是逆向目前都不需要对UI部分有过多了解。包括之后也有可能有略过的地方。

4.4 创建自定义控件

在前两节我们学习了Android中的一些常用控件和基本布局的用法,不过当时我们并没有关注这些控件和布局的继承结构,现在是时候来看一下了 :

image-20260204143333488

可以看到,我们所用的所有控件都是直接或间接继承自View的,所用的所有布局都是直接或间接继承自ViewGroup的。View是Android中最基本的一种UI组件,它可以在屏幕上绘制一块矩形区域,并能响应这块区域的各种事件,因此,我们使用的各种控件其实就是在View的基础上又添加了各自特有的功能。而ViewGroup则是一种特殊的View,它可以包含很多子View和子ViewGroup,是一个用于放置控件和布局的容器。这个时候我们就可以思考一下,当系统自带的控件并不能满足我们的需求时,可不可以利用上面的继承结构来创建自定义控件呢?答案是肯定的,下面我们就来学习一下创建自定义控件的两种简单方法。先将准备工作做好,创建一个UICustomViews项目。

4.4.1 引入布局

我们现在先来制作一个标题栏,为了提高复用率,我们可以使用引入布局的方式来制作。在layout目录下新建一个title.xml布局:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@drawable/title_bg">
    <Button
        android:id="@+id/titleBack"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_margin="5dp"
        android:background="@drawable/back_bg"
        android:text="Back"
        android:textColor="#fff" />
    <TextView
        android:id="@+id/titleText"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_weight="1"
        android:gravity="center"
        android:text="Title Text"
        android:textColor="#fff"
        android:textSize="24sp" />
    <Button
        android:id="@+id/titleEdit"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_margin="5dp"
        android:background="@drawable/edit_bg"
        android:text="Edit"
        android:textColor="#fff" />
</LinearLayout>

android:background用于为布局或控件指定一个背景,可以使用颜色或图片来进行填充。这里我提前准备好了3张图片——title_bg.png、back_bg.png和edit_bg.png,分别用于作为标题栏、返回按钮和编辑按钮的背景。另外,在两个Button中我们都使用了android:layout_margin这个属性,它可以指定控件在上下左右方向上的间距。当然也可以使用android:layout_marginLeft或android:layout_marginTop等属性来单独指定控件在某个方向上的间距

现在标题布局已经完成,可以在程序中引用标题布局了,只需要通过include标签即可:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent" >
    <include layout="@layout/title" />
</LinearLayout>

接下来把系统自带的标题栏隐藏掉:

class MainActivity : AppCompatActivity() {
 override fun onCreate(savedInstanceState: Bundle?) {
 super.onCreate(savedInstanceState)
 setContentView(R.layout.activity_main)
 supportActionBar?.hide()
 }
}

这里我们调用了getSupportActionBar()方法来获得ActionBar的实例,然后再调用它的hide()方法将标题栏隐藏起来。由于ActionBar有可能为空,所以这里还使用了?.操作符。关于ActionBar的更多用法,将在第12章中讲解。

4.4.2 创建自定义控件

引入布局的技巧确实解决了重复编写布局代码的问题,但是如果布局中有一些控件要求能够响应事件,我们还是需要在每个Activity中为这些控件单独编写一次事件注册的代码。比如标题栏中的返回按钮,其实不管是在哪一个Activity中,这个按钮的功能都是相同的,即销毁当前Activity。而如果在每一个Activity中都需要重新注册一遍返回按钮的点击事件,无疑会增加很多重复代码,这种情况最好是使用自定义控件的方式来解决。

新建TitleLayout类继承自LinearLayout:

class TitleLayout(context: Context, attrs: AttributeSet) : LinearLayout(context, attrs) {
    init {
        LayoutInflater.from(context).inflate(R.layout.title, this)
    }
}

这里我们在TitleLayout的主构造函数中声明了Context和AttributeSet这两个参数,在布局中引入TitleLayout控件时就会调用这个构造函数。然后在init结构体中需要对标题栏布局进行动态加载,这就要借助LayoutInflater来实现了。通过LayoutInflater的from()方法可以构建出一个LayoutInflater对象,然后调用inflate()方法就可以动态加载一个布局文件。inflate()方法接收两个参数:第一个参数是要加载的布局文件的id,这里我们传入R.layout.title;第二个参数是给加载好的布局再添加一个父布局,这里我们想要指定为TitleLayout,于是直接传入this。

现在自定义控件就创造好了,接下来在布局文件中添加这个自定义控件,修改acivity_main.xml:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent" >
    <com.example.uicustomviews.TitleLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />
</LinearLayout>

添加自定义控件和添加普通控件的方式基本是一样的,只不过在添加自定义控件的时候,我们需要指明控件的完整类名,包名在这里是不可以省略的。重新运行程序,你会发现此时的效果和使用引入布局方式的效果是一样的。

下面我们尝试为标题栏中的按钮注册点击事件,修改TitleLayout中的代码:

class TitleLayout(context: Context, attrs: AttributeSet) : LinearLayout(context, attrs) {
    init {
        LayoutInflater.from(context).inflate(R.layout.title, this)
        findViewById<Button>(R.id.titleBack).setOnClickListener {
            val activity = context as Activity
            activity.finish()
        }
        findViewById<Button>(R.id.titleEdit).setOnClickListener {
            Toast.makeText(context, "You clicked Edit button", Toast.LENGTH_SHORT).show()
        }
    }
}

这里我们分别给返回和编辑这两个按钮注册了点击事件,当点击返回按钮时销毁当前Activity,当点击编辑按钮时弹出一段文本。

注意,TitleLayout中接收的context参数实际上是一个Activity的实例,在返回按钮的点击事件里,我们要先将它转换成Activity类型,然后再调用finish()方法销毁当前的Activity。Kotlin中的类型强制转换使用的关键字是as,由于是第一次用到,所以这里单独讲解一下。

这样的话,每当我们在一个布局中引入TitleLayout时,返回按钮和编辑按钮的点击事件就已经自动实现好了,这就省去了很多编写重复代码的工作。

4.5 ListView

ListView在过去绝对可以称得上是Android中最常用的控件之一,几乎所有的应用程序都会用到它。由于手机屏幕空间比较有限,能够一次性在屏幕上显示的内容并不多,当我们的程序中有大量的数据需要展示的时候,就可以借助ListView来实现。ListView允许用户通过手指上下滑动的方式将屏幕外的数据滚动到屏幕内,同时屏幕上原有的数据会滚动出屏幕。你其实每天都在使用这个控件,比如查看QQ聊天记录,翻阅微博最新消息,等等。

4.5.1 ListView的简单用法

再创建一个ListViewTest项目,还是默认空Activity,更改activity_main.xml:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <ListView
        android:id="@+id/listView"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</LinearLayout>

在布局中加入ListView控件还算非常简单,先为ListView指定一个id,然后将宽度和高度都设置为match_parent,这样ListView就占满了整个布局的空间。

接下来修改MainActivity中的代码:

class MainActivity : AppCompatActivity() {
    private val data = listOf("Apple", "Banana", "Orange", "Watermelon",
        "Pear", "Grape", "Pineapple", "Strawberry", "Cherry", "Mango",
        "Apple", "Banana", "Orange", "Watermelon", "Pear", "Grape",
        "Pineapple", "Strawberry", "Cherry", "Mango")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val adapter = ArrayAdapter<String>(this, android.R.layout.simple_list_item_1, data)
        findViewById<ListView>(R.id.listView).adapter = adapter
    }
}

既然ListView是用于展示大量数据的,那我们就应该先将数据提供好。这些数据可以从网上下载,也可以从数据库中读取,应该视具体的应用程序场景而定。这里我们就简单使用一个data集合来进行测试,里面包含了很多水果的名称,初始化集合的方式使用的是之前在第2章学过的listOf()函数。

不过,集合中的数据是无法直接传递给ListView的,我们还需要借助适配器来完成。Android中提供了很多适配器的实现类,这里使用ArrayAdapter。它可以通过泛型来指定要适配的数据类型,然后在构造函数中把要适配的数据传入。ArrayAdapter有多个构造函数的重载,你应该根据实际情况选择最合适的一种。由于我们这里提供的数据都是字符串,因此将ArrayAdapter的泛型指定为String,然后在ArrayAdapter的构造函数中依次传入Activity的实例、ListView子项布局的id,以及数据源。注意,我们使用了android.R.layout.simple_list_item_1作为ListView子项布局的id,这是一个Android内置的布局文件,里面只有一个TextView,可用于简单地显示一段文本。这样适配器对象就构建好了。

最后,还需要调用ListView的setAdapter()方法,将构建好的适配器对象传递进去,这样ListView和数据之间的关联就建立完成了。

image-20260204161234354

4.5.2 定制ListView的界面

只能显示一段文本的ListView实在是太单调了,我们现在就来对ListView的界面进行定制,让它可以显示更加丰富的内容。

首先需要准备好一组图片资源(资源下载地址见前言),分别对应上面提供的每一种水果,待会我们要让这些水果名称的旁边都有一张相应的图片。

接着定义一个实体类,作为ListView适配器的适配类型。新建Fruit类:

class Fruit(val name:String, val imageId:Int) {}

Fruit类中只有两个字段:name表示水果的名字,imageId表示水果对应图片的资源id。

然后需要为ListView的子项指定一个我们自定义的布局,在layout目录下新建fruit_item.xml:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="60dp">
    <ImageView
        android:id="@+id/fruitImage"
        android:layout_width="40dp"
        android:layout_height="40dp"
        android:layout_gravity="center_vertical"
        android:layout_marginLeft="10dp"/>
    <TextView
        android:id="@+id/fruitName"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_vertical"
        android:layout_marginLeft="10dp" />
</LinearLayout>

在这个布局中,我们定义了一个ImageView用于显示水果的图片,又定义了一个TextView用于显示水果的名称,并让ImageView和TextView都在垂直方向上居中显示。接下来需要创建一个自定义的适配器,这个适配器继承自ArrayAdapter,并将泛型指定为Fruit类。新建类FruitAdapter

FruitAdapter定义了一个主构造函数,用于将Activity的实例、ListView子项布局的id和数据源传递进来。另外又重写了getView()方法,这个方法在每个子项被滚动到屏幕内的时候会被调用。

在getView()方法中,首先使用LayoutInflater来为这个子项加载我们传入的布局。LayoutInflater的inflate()方法接收3个参数,前两个参数我们已经知道是什么意思了,第三个参数指定成false,表示只让我们在父布局中声明的layout属性生效,但不会为这个View添加父布局。因为一旦View有了父布局之后,它就不能再添加到ListView中了。

我们继续往下看,接下来调用View的findViewById()方法分别获取到ImageView和TextView的实例,然后通过getItem()方法得到当前项的Fruit实例,并分别调用它们的setImageResource()和setText()方法设置显示的图片和文字,最后将布局返回,这样我们自定义的适配器就完成了。

修改MainActivity:

class MainActivity : AppCompatActivity() {
    private val fruitList = ArrayList<Fruit>()
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        initFruits() // 初始化水果数据
        val adapter = FruitAdapter(this, R.layout.fruit_item, fruitList)
        findViewById<ListView>(R.id.listView).adapter = adapter
    }

    private fun initFruits() {
        repeat(2) {
            fruitList.add(Fruit("Apple", R.drawable.apple_pic))
            fruitList.add(Fruit("Banana", R.drawable.banana_pic))
            fruitList.add(Fruit("Orange", R.drawable.orange_pic))
            fruitList.add(Fruit("Watermelon", R.drawable.watermelon_pic))
            fruitList.add(Fruit("Pear", R.drawable.pear_pic))
            fruitList.add(Fruit("Grape", R.drawable.grape_pic))
            fruitList.add(Fruit("Pineapple", R.drawable.pineapple_pic))
            fruitList.add(Fruit("Strawberry", R.drawable.strawberry_pic))
            fruitList.add(Fruit("Cherry", R.drawable.cherry_pic))
            fruitList.add(Fruit("Mango", R.drawable.mango_pic))
        }
    }
}

可以看到,这里添加了一个initFruits()方法,用于初始化所有的水果数据。在Fruit类的构造函数中将水果的名字和对应的图片id传入,然后把创建好的对象添加到水果列表中。另外,我们使用了一个repeat函数将所有的水果数据添加了两遍,这是因为如果只添加一遍的话,数据量还不足以充满整个屏幕。repeat函数是Kotlin中另外一个非常常用的标准函数,它允许你传入一个数值n,然后会把Lambda表达式中的内容执行n遍。接着在onCreate()方法中创建了FruitAdapter对象,并将它作为适配器传递给ListView,这样定制ListView界面的任务就完成了。

虽然目前我们定制的界面还很简单,但是只要修改fruit_item.xml中的内容,就可以定制出各种复杂的界面了。

4.5.3 提升ListView的运行效率

之所以说ListView这个控件很难用,是因为它有很多细节可以优化,其中运行效率就是很重要的一点。目前我们ListView的运行效率是很低的,因为在FruitAdapter的getView()方法中,每次都将布局重新加载了一遍,当ListView快速滚动的时候,这就会成为性能的瓶颈。

仔细观察你会发现,getView()方法中还有一个convertView参数,这个参数用于将之前加载好的布局进行缓存,以便之后进行重用,我们可以借助这个参数来进行性能优化。修改FruitAdapter中的代码

class FruitAdapter(activity: Activity, val resourceId: Int, data: List<Fruit>) :
    ArrayAdapter<Fruit>(activity, resourceId, data) {
    override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
        val view: View
        if (convertView == null) {
            view = LayoutInflater.from(context).inflate(resourceId, parent, false)
        } else {
            view = convertView
        }
        val fruitImage: ImageView = view.findViewById(R.id.fruitImage)
        val fruitName: TextView = view.findViewById(R.id.fruitName)
        val fruit = getItem(position) // 获取当前项的Fruit实例
        if (fruit != null) {
            fruitImage.setImageResource(fruit.imageId)
            fruitName.text = fruit.name
        }
        return view
    }
}

可以看到,现在我们在getView()方法中进行了判断:如果convertView为null,则使用LayoutInflater去加载布局;如果不为null,则直接对convertView进行重用。这样就大大提高了ListView的运行效率,在快速滚动的时候可以表现出更好的性能。

不过,目前我们的这份代码还是可以继续优化的,虽然现在已经不会再重复去加载布局,但是每次在getView()方法中仍然会调用View的findViewById()方法来获取一次控件的实例。我们可以借助一个ViewHolder来对这部分性能进行优化,修改FruitAdapter中的代码:

class FruitAdapter(activity: Activity, val resourceId: Int, data: List<Fruit>) :
    ArrayAdapter<Fruit>(activity, resourceId, data) {
    inner class ViewHolder(val fruitImage: ImageView, val fruitName: TextView)
    override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
        val view: View
        val viewHolder: ViewHolder
        if (convertView == null) {
            view = LayoutInflater.from(context).inflate(resourceId, parent, false)
            val fruitImage: ImageView = view.findViewById(R.id.fruitImage)
            val fruitName: TextView = view.findViewById(R.id.fruitName)
            viewHolder = ViewHolder(fruitImage, fruitName)
            view.tag = viewHolder
        } else {
            view = convertView
            viewHolder = view.tag as ViewHolder
        }
        val fruit = getItem(position) // 获取当前项的Fruit实例
        if (fruit != null) {
            viewHolder.fruitImage.setImageResource(fruit.imageId)
            viewHolder.fruitName.text = fruit.name
        }
        return view
    }
}

我们新增了一个内部类ViewHolder,用于对ImageView和TextView的控件实例进行缓存,Kotlin中使用inner class关键字来定义内部类。当convertView为null的时候,创建一个ViewHolder对象,并将控件的实例存放在ViewHolder里,然后调用View的setTag()方法,将ViewHolder对象存储在View中。当convertView不为null的时候,则调用View的getTag()方法,把ViewHolder重新取出。这样所有控件的实例都缓存在了ViewHolder里,就没有必要每次都通过findViewById()方法来获取控件实例了。

4.5.4 ListView的点击事件

话说回来,ListView的滚动毕竟只是满足了我们视觉上的效果,可是如果ListView中的子项不能点击的话,这个控件就没有什么实际的用途了。因此,本小节我们就来学习一下ListView如何才能响应用户的点击事件。

修改MainActivity:

class MainActivity : AppCompatActivity() {
    private val fruitList = ArrayList<Fruit>()
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        initFruits() // 初始化水果数据
        val adapter = FruitAdapter(this, R.layout.fruit_item, fruitList)
        val listView:ListView = findViewById<ListView>(R.id.listView)
        listView.adapter = adapter
        listView.setOnItemClickListener { parent, view, position, id ->
            val fruit = fruitList[position]
            Toast.makeText(this, fruit.name, Toast.LENGTH_SHORT).show()
        }
    }
	……
}

可以看到,我们使用setOnItemClickListener()方法为ListView注册了一个监听器,当用户点击了ListView中的任何一个子项时,就会回调到Lambda表达式中。这里我们可以通过position参数判断用户点击的是哪一个子项,然后获取到相应的水果,并通过Toast将水果的名字显示出来。

上述代码的Lambda表达式在参数列表中声明了4个参数,那么我们如何知道需要声明哪几个参数呢?按住Ctrl键(Mac系统是command键)点击setOnItemClickListener()方法查看它的源码,你会发现setOnItemClickListener()方法接收一个OnItemClickListener参数,这当然就是一个Java单抽象方法接口了,要不然这里我们也无法使用函数式API的写法。OnItemClickListener接口的定义如图:

image-20260205140453217

可以看到,它的唯一待实现方法onItemClick()中接收4个参数,这些就是我们要在Lambda表达式的参数列表中声明的参数了。另外你会发现,虽然这里我们必须在Lambda表达式中声明4个参数,但实际上却只用到了position这一个参数而已。针对这种情况,Kotlin允许我们将没有用到的参数使用下划线来替代,因此下面这种写法也是合法且更加推荐的:

listView.setOnItemClickListener { _, _, position, _ ->
            val fruit = fruitList[position]
            Toast.makeText(this, fruit.name, Toast.LENGTH_SHORT).show()
        }

4.6 RecyclerView

ListView由于强大的功能,在过去的Android开发当中可以说是贡献卓越,直到今天仍然还有不计其数的程序在使用ListView。不过ListView并不是完美无缺的,比如如果不使用一些技巧来提升它的运行效率,那么ListView的性能就会非常差。还有,ListView的扩展性也不够好,它只能实现数据纵向滚动的效果,如果我们想实现横向滚动的话,ListView是做不到的。

为此,Android提供了一个更强大的滚动控件——RecyclerView。它可以说是一个增强版ListView,不仅可以轻松实现和ListView同样的效果,还优化了ListView存在的各种不足之处。目前Android官方更加推荐使用RecyclerView,未来也会有更多的程序逐渐从ListView转向RecyclerView,那么本节我们就来详细讲解一下RecyclerView的用法。首先新建一个RecyclerViewTest项目,并让Android Studio自动帮我们创建好Activity。

4.6.1 RecyclerView的基本用法

之前我们所学的所有控件不同,RecyclerView属于新增控件,那么怎样才能让新增的控件在所有Android系统版本上都能使用呢?为此,Google将RecyclerView控件定义在了AndroidX当中,我们只需要在项目的build.gradle中添加RecyclerView库的依赖,就能保证在所有Android系统版本上都可以使用RecyclerView控件了。

打开app/build.gradle文件,在dependencies闭包中添加如下内容:

dependencies {
    implementation("androidx.recyclerview:recyclerview:1.3.2")
}

注意,上面的代码是使用Kotlin DSL写的,如果你的gradle使用的是Groovy DSL,应该这样写:

dependencies {
    implementation "androidx.recyclerview:recyclerview:1.3.2"
}

接下来修改activity_main.xml:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</LinearLayout>

在布局中加入RecyclerView控件也是非常简单的,先为RecyclerView指定一个id,然后将宽度和高度都设置为match_parent,这样RecyclerView就占满了整个布局的空间。需要注意的是,由于RecyclerView并不是内置在系统SDK当中的,所以需要把完整的包路径写出来。

这里我们想要使用RecyclerView来实现和ListView相同的效果,因此就需要准备一份同样的水果图片。简单起见,我们就直接从ListViewTest项目中把图片复制过来,另外顺便将Fruit类和fruit_item.xml也复制过来,省得将同样的代码再写一遍。

接下来需要为RecyclerView准备一个适配器,新建FruitAdapter类,让这个适配器继承自RecyclerView.Adapter,并将泛型指定为FruitAdapter.ViewHolder。其中,ViewHolder是我们在FruitAdapter中定义的一个内部类:

class FruitAdapter(val fruitList: List<Fruit>) :
    RecyclerView.Adapter<FruitAdapter.ViewHolder>() {
    inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        val fruitImage: ImageView = view.findViewById(R.id.fruitImage)
        val fruitName: TextView = view.findViewById(R.id.fruitName)
    }
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.fruit_item, parent, false)
        return ViewHolder(view)
    }
    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val fruit = fruitList[position]
        holder.fruitImage.setImageResource(fruit.imageId)
        holder.fruitName.text = fruit.name
    }
    override fun getItemCount() = fruitList.size
}

这是RecyclerView适配器标准的写法,虽然看上去好像多了好几个方法,但其实它比ListView的适配器要更容易理解。这里我们首先定义了一个内部类ViewHolder,它要继承自RecyclerView.ViewHolder。然后ViewHolder的主构造函数中要传入一个View参数,这个参数通常就是RecyclerView子项的最外层布局,那么我们就可以通过findViewById()方法来获取布局中ImageView和TextView的实例了。

FruitAdapter中也有一个主构造函数,它用于把要展示的数据源传进来,我们后续的操作都将在这个数据源的基础上进行。

继续往下看,由于FruitAdapter是继承自RecyclerView.Adapter的,那么就必须重写onCreateViewHolder()、onBindViewHolder()和getItemCount()这3个方法。onCreateViewHolder()方法是用于创建ViewHolder实例的,我们在这个方法中将fruit_item布局加载进来,然后创建一个ViewHolder实例,并把加载出来的布局传入构造函数当中,最后将ViewHolder的实例返回。onBindViewHolder()方法用于对RecyclerView子项的数据进行赋值,会在每个子项被滚动到屏幕内的时候执行,这里我们通过position参数得到当前项的Fruit实例,然后再将数据设置到ViewHolder的ImageView和TextView当中即可。getItemCount()方法就非常简单了,它用于告诉RecyclerView一共有多少子项,直接返回数据源的长度就可以了。

适配器准备好了之后,我们就可以开始使用RecyclerView了,修改MainActivity中的代码:

class MainActivity : AppCompatActivity() {
    private val fruitList = ArrayList<Fruit>()
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        initFruits() // 初始化水果数据
        val layoutManager = LinearLayoutManager(this)
        val recyclerView: RecyclerView = findViewById<RecyclerView>(R.id.recyclerView)
        recyclerView.layoutManager = layoutManager
        val adapter = FruitAdapter(fruitList)
        recyclerView.adapter = adapter
    }
    private fun initFruits() {
        repeat(2) {
            fruitList.add(Fruit("Apple", R.drawable.apple_pic))
            fruitList.add(Fruit("Banana", R.drawable.banana_pic))
            fruitList.add(Fruit("Orange", R.drawable.orange_pic))
            fruitList.add(Fruit("Watermelon", R.drawable.watermelon_pic))
            fruitList.add(Fruit("Pear", R.drawable.pear_pic))
            fruitList.add(Fruit("Grape", R.drawable.grape_pic))
            fruitList.add(Fruit("Pineapple", R.drawable.pineapple_pic))
            fruitList.add(Fruit("Strawberry", R.drawable.strawberry_pic))
            fruitList.add(Fruit("Cherry", R.drawable.cherry_pic))
            fruitList.add(Fruit("Mango", R.drawable.mango_pic))
        }
    }
}

可以看到,这里使用了一个同样的initFruits()方法,用于初始化所有的水果数据。接着在onCreate()方法中先创建了一个LinearLayoutManager对象,并将它设置到RecyclerView当中。LayoutManager用于指定RecyclerView的布局方式,这里使用的LinearLayoutManager是线性布局的意思,可以实现和ListView类似的效果。接下来我们创建了FruitAdapter的实例,并将水果数据传入FruitAdapter的构造函数中,最后调用RecyclerView的setAdapter()方法来完成适配器设置,这样RecyclerView和数据之间的关联就建立完成了。

image-20260205152407279

4.6.2 实现横向滚动和瀑布流布局

我们已经知道,ListView的扩展性并不好,它只能实现纵向滚动的效果,如果想进行横向滚动的话,ListView就做不到了。那么RecyclerView就能做得到吗?当然可以,不仅能做得到,还非常简单。接下来我们就尝试实现一下横向滚动的效果。

首先要对fruit_item布局进行修改,因为目前这个布局里面的元素是水平排列的,适用于纵向滚动的场景,而如果我们要实现横向滚动的话,应该把fruit_item里的元素改成垂直排列才比较合理。修改fruit_item.xml中的代码:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="80dp"
    android:layout_height="wrap_content">
    <ImageView
        android:id="@+id/fruitImage"
        android:layout_width="40dp"
        android:layout_height="40dp"
        android:layout_gravity="center_horizontal"
        android:layout_marginTop="10dp" />
    <TextView
        android:id="@+id/fruitName"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:layout_marginTop="10dp" />
</LinearLayout>

可以看到,我们将LinearLayout改成垂直方向排列,并把宽度设为80 dp。这里将宽度指定为固定值是因为每种水果的文字长度不一致,如果用wrap_content的话,RecyclerView的子项就会有长有短,非常不美观,而如果用match_parent的话,就会导致宽度过长,一个子项占满整个屏幕。

然后我们将ImageView和TextView都设置成了在布局中水平居中,并且使用layout_marginTop属性让文字和图片之间保持一定距离。

接下来修改MainActivity中的代码:

class MainActivity : AppCompatActivity() {
    private val fruitList = ArrayList<Fruit>()
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        initFruits() // 初始化水果数据
        val layoutManager = LinearLayoutManager(this)
        layoutManager.orientation = LinearLayoutManager.HORIZONTAL
        val recyclerView: RecyclerView = findViewById<RecyclerView>(R.id.recyclerView)
        recyclerView.layoutManager = layoutManager
        val adapter = FruitAdapter(fruitList)
        recyclerView.adapter = adapter
    }
    ……
}

MainActivity中只加入了一行代码,调用LinearLayoutManager的setOrientation()方法设置布局的排列方向。默认是纵向排列的,我们传入LinearLayoutManager.HORIZONTAL表示让布局横行排列,这样RecyclerView就可以横向滚动了。

image-20260205153044153

为什么ListView很难或者根本无法实现的效果在RecyclerView上这么轻松就实现了呢?这主要得益于RecyclerView出色的设计。ListView的布局排列是由自身去管理的,而RecyclerView则将这个工作交给了LayoutManager。LayoutManager制定了一套可扩展的布局排列接口,子类只要按照接口的规范来实现,就能定制出各种不同排列方式的布局了。

除了LinearLayoutManager之外,RecyclerView还给我们提供了GridLayoutManager和StaggeredGridLayoutManager这两种内置的布局排列方式。GridLayoutManager可以用于实现网格布局,StaggeredGridLayoutManager可以用于实现瀑布流布局。这里我们来实现一下效果更加炫酷的瀑布流布局。

修改fruit_item.xml:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
 android:orientation="vertical"
 android:layout_width="match_parent"
 android:layout_height="wrap_content"
 android:layout_margin="5dp">
 <ImageView
 android:id="@+id/fruitImage"
 android:layout_width="40dp"
 android:layout_height="40dp"
 android:layout_gravity="center_horizontal"
 android:layout_marginTop="10dp" />
 <TextView
 android:id="@+id/fruitName"
 android:layout_width="wrap_content"
 android:layout_height="wrap_content"
 android:layout_gravity="left"
 android:layout_marginTop="10dp" />
</LinearLayout>

这里做了几处小的调整,首先将LinearLayout的宽度由80 dp改成了match_parent,因为瀑布流布局的宽度应该是根据布局的列数来自动适配的,而不是一个固定值。其次我们使用了layout_margin属性来让子项之间互留一点间距,这样就不至于所有子项都紧贴在一些。最后还将TextView的对齐属性改成了居左对齐,因为待会我们会将文字的长度变长,如果还是居中显示就会感觉怪怪的。

接着修改MainActivity:

class MainActivity : AppCompatActivity() {
    private val fruitList = ArrayList<Fruit>()
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        initFruits() // 初始化水果数据
        val layoutManager = StaggeredGridLayoutManager(
            3,
            StaggeredGridLayoutManager.VERTICAL
        )
//        layoutManager.orientation = LinearLayoutManager.HORIZONTAL
        val recyclerView: RecyclerView = findViewById<RecyclerView>(R.id.recyclerView)
        recyclerView.layoutManager = layoutManager
        val adapter = FruitAdapter(fruitList)
        recyclerView.adapter = adapter
    }
    private fun initFruits() {
        repeat(2) {
            fruitList.add(Fruit(getRandomLengthString("Apple"), R.drawable.apple_pic))
            fruitList.add(Fruit(getRandomLengthString("Banana"), R.drawable.banana_pic))
            fruitList.add(Fruit(getRandomLengthString("Orange"), R.drawable.orange_pic))
            fruitList.add(Fruit(getRandomLengthString("Watermelon"), R.drawable.watermelon_pic))
            fruitList.add(Fruit(getRandomLengthString("Pear"), R.drawable.pear_pic))
            fruitList.add(Fruit(getRandomLengthString("Grape"), R.drawable.grape_pic))
            fruitList.add(Fruit(getRandomLengthString("Pineapple"), R.drawable.pineapple_pic))
            fruitList.add(Fruit(getRandomLengthString("Strawberry"), R.drawable.strawberry_pic))
            fruitList.add(Fruit(getRandomLengthString("Cherry"), R.drawable.cherry_pic))
            fruitList.add(Fruit(getRandomLengthString("Mango"), R.drawable.mango_pic))
        }
    }

    private fun getRandomLengthString(str: String): String {
        val n = (1..20).random()
        val builder = StringBuilder()
        repeat(n) {
            builder.append(str)
        }
        return builder.toString()
    }
}

首先,在onCreate()方法中,我们创建了一个StaggeredGridLayoutManager的实例。StaggeredGridLayoutManager的构造函数接收两个参数:第一个参数用于指定布局的列数,传入3表示会把布局分为3列;第二个参数用于指定布局的排列方向,传入StaggeredGridLayoutManager.VERTICAL表示会让布局纵向排列。最后把创建好的实例设置到RecyclerView当中就可以了,就是这么简单!

没错,仅仅修改了一行代码,我们就已经成功实现瀑布流布局的效果了。不过由于瀑布流布局需要各个子项的高度不一致才能看出明显的效果,为此我又使用了一个小技巧。这里我们把眼光聚焦到getRandomLengthString()这个方法上,这个方法中调用了Range对象的random()函数来创造一个1到20之间的随机数,然后将参数中传入的字符串随机重复几遍。在initFruits()方法中,每个水果的名字都改成调用getRandomLengthString()这个方法来生成,这样就能保证各水果名字的长短差距比较大,子项的高度也就各不相同了。

image-20260205154341344

4.6.3 RecyclerView的点击事件

和ListView一样,RecyclerView也必须能响应点击事件才可以,不然的话就没什么实际用途了。不过不同于ListView的是,RecyclerView并没有提供类似于setOnItemClickListener()这样的注册监听器方法,而是需要我们自己给子项具体的View去注册点击事件。这相比于ListView来说,实现起来要复杂一些。

那么你可能就有疑问了,为什么RecyclerView在各方面的设计都要优于ListView,偏偏在点击事件上却没有处理得非常好呢?其实不是这样的,ListView在点击事件上的处理并不人性化,setOnItemClickListener()方法注册的是子项的点击事件,但如果我想点击的是子项里具体的某一个按钮呢?虽然ListView也能做到,但是实现起来就相对比较麻烦了。为此,RecyclerView干脆直接摒弃了子项点击事件的监听器,让所有的点击事件都由具体的View去注册,就再没有这个困扰了。

下面我们来具体学习一下如何在RecyclerView中注册点击事件,修改FruitAdapter中的代码:

class FruitAdapter(val fruitList: List<Fruit>) :
    RecyclerView.Adapter<FruitAdapter.ViewHolder>() {
    ……
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.fruit_item, parent, false)
        val viewHolder = ViewHolder(view)
        viewHolder.itemView.setOnClickListener {
            val position = viewHolder.adapterPosition
            val fruit = fruitList[position]
            Toast.makeText(parent.context, "you clicked view ${fruit.name}",
                Toast.LENGTH_SHORT).show()
        }
        viewHolder.fruitImage.setOnClickListener {
            val position = viewHolder.adapterPosition
            val fruit = fruitList[position]
            Toast.makeText(parent.context, "you clicked image ${fruit.name}",
                Toast.LENGTH_SHORT).show()
        }
        return viewHolder
    }
    ……
}

可以看到,这里我们是在onCreateViewHolder()方法中注册点击事件。上述代码分别为最外层布局和ImageView都注册了点击事件,itemView表示的就是最外层布局。RecyclerView的强大之处也在于此,它可以轻松实现子项中任意控件或布局的点击事件。我们在两个点击事件中先获取了用户点击的position,然后通过position拿到相应的Fruit实例,再使用Toast分别弹出两种不同的内容以示区别。

现在重新运行代码,并点击苹果的图片部分:

image-20260205162843899

然后点击橘子的文字部分,由于TextView并没有注册点击事件,因此点击文字这个事件会被子项的最外层布局捕获:

image-20260205162955760

4.7 编写界面的最佳实践

既然已经学习了那么多UI开发的知识,是时候实战一下了。这次我们要综合运用前面所学的大量内容来编写出一个较为复杂且相当美观的聊天界面,先创建一个UIBestPractice项目。

4.7.1 制作9-Patch图片

在实战正式开始之前,我们需要先学习一下如何制作9-Patch图片。你之前可能没有听说过这个名词,它是一种被特殊处理过的png图片,能够指定哪些区域可以被拉伸、哪些区域不可以。

那么9-Patch图片到底有什么实际作用呢?我们还是通过一个例子来看一下吧。首先在UIBestPractice项目中放置一张气泡样式的图片message_left.png:

image-20260206134810069

我们将这张图片设置为LinearLayout的背景图片,修改activity_main.xml中的代码:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="50dp"
    android:background="@drawable/message_left">
</LinearLayout>

这里将LinearLayout的宽度指定为match_parent,将它的背景图设置为message_left。现在运行程序:

image-20260206134922455

可以看到,由于message_left的宽度不足以填满整个屏幕的宽度,整张图片被均匀地拉伸了!这种效果非常差,用户肯定是不能容忍的,这时就可以使用9-Patch图片来进行改善。

制作9-Patch图片其实并不复杂,只要掌握好规则就行了,那么现在我们就来学习一下。在Android Studio中,我们可以将任何png类型的图片制作成9-Patch图片。首先对着message_left.png图片右击→Create 9-Patch file。创建好后在AndroidStudio中就是这样:

image-20260206135202772

我们可以在图片的4个边框绘制一个个的小黑点,在上边框和左边框绘制的部分表示当图片需要拉伸时就拉伸黑点标记的区域,在下边框和右边框绘制的部分表示内容允许被放置的区域。使用鼠标在图片的边缘拖动就可以进行绘制了,按住Shift键拖动可以进行擦除。

最后记得要将原来的message_left.png图片删除,只保留制作好的message_left.9.png图片即可,因为Android项目中不允许同一文件夹下有两张相同名称的图片(即使后缀名不同也不行。

4.7.2 编写精美的聊天界面

然是要编写一个聊天界面,那肯定要有收到的消息和发出的消息。上一小节中我们制作的message_left.9.png可以作为收到消息的背景图,那么毫无疑问你还需要再制作一张message_right.9.png作为发出消息的背景图。制作过程是完全一样的,我就不再重复演示了。

图片都准备好了之后,就可以开始编码了。由于待会我们会用到RecyclerView,因此首先需要在app/build.gradle当中添加依赖库,详见4.6.1。

接下来编写主界面,修改activity_main.xml:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#d8e0e8" >
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1" />
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content" >
        <EditText
            android:id="@+id/inputText"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:hint="Type something here"
            android:maxLines="2" />
        <Button
            android:id="@+id/send"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Send" />
    </LinearLayout>
</LinearLayout>

我们在主界面中放置了一个RecyclerView用于显示聊天的消息内容,又放置了一个EditText用于输入消息,还放置了一个Button用于发送消息。

然后定义消息的实体类,新建Msg:

class Msg(val content: String, val type: Int) {
    companion object {
        const val TYPE_RECEIVED = 0
        const val TYPE_SENT = 1
    }
}

Msg类中只有两个字段:content表示消息的内容,type表示消息的类型。其中消息类型有两个值可选:TYPE_RECEIVED表示这是一条收到的消息,TYPE_SENT表示这是一条发出的消息。这里我们将TYPE_RECEIVED和TYPE_SENT定义成了常量,定义常量的关键字是const,注意只有在单例类、companion object或顶层方法中才可以使用const关键字。

接下来开始编写RecyclerView的子项布局,新建msg_left_item.xml:

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="10dp" >
    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="left"
        android:background="@drawable/message_left" >
        <TextView
            android:id="@+id/leftMsg"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:layout_margin="10dp"
            android:textColor="#fff" />
    </LinearLayout>
</FrameLayout>

这是接收消息的子项布局。这里我们让收到的消息居左对齐,并使用message_left.9.png作为背景图。

类似地,我们还需要再编写一个发送消息的子项布局,新建msg_right_item.xml:

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="10dp" >
    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="right"
        android:background="@drawable/message_right" >
        <TextView
            android:id="@+id/rightMsg"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:layout_margin="10dp"
            android:textColor="#000" />
    </LinearLayout>
</FrameLayout>

这里我们让发出的消息居右对齐,并使用message_right.9.png作为背景图,基本上和刚才的msg_left_item.xml是差不多的。

接下来创建RecyclerView的适配器类,新建MsgAdapter:

class MsgAdapter(val msgList: List<Msg>) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
    inner class LeftViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        val leftMsg: TextView = view.findViewById(R.id.leftMsg)
    }
    inner class RightViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        val rightMsg: TextView = view.findViewById(R.id.rightMsg)
    }
    override fun getItemViewType(position: Int): Int {
        val msg = msgList[position]
        return msg.type
    }
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = if (viewType ==
        Msg.TYPE_RECEIVED) {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.msg_left_item,
            parent, false)
        LeftViewHolder(view)
    } else {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.msg_right_item,
            parent, false)
        RightViewHolder(view)
    }
    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        val msg = msgList[position]
        when (holder) {
            is LeftViewHolder -> holder.leftMsg.text = msg.content
            is RightViewHolder -> holder.rightMsg.text = msg.content
            else -> throw IllegalArgumentException()
        }
    }
    override fun getItemCount() = msgList.size
}

上述代码中用到了一个新的知识点:根据不同的viewType创建不同的界面。首先我们定义了LeftViewHolder和RightViewHolder这两个ViewHolder,分别用于缓存msg_left_item.xml和msg_right_item.xml布局中的控件。然后要重写getItemViewType()方法,并在这个方法中返回当前position对应的消息类型。

接下来的代码你应该就比较熟悉了,和我们之前学习的RecyclerView用法是比较相似的,只是要在onCreateViewHolder()方法中根据不同的viewType来加载不同的布局并创建不同的ViewHolder。然后在onBindViewHolder()方法中判断ViewHolder的类型:如果是LeftViewHolder,就将内容显示到左边的消息布局;如果是RightViewHolder,就将内容显示到右边的消息布局。

最后修改MainActivity中的代码,为RecyclerView初始化一些数据,并给发送按钮加入事件响应:

我们先在initMsg()方法中初始化了几条数据用于在RecyclerView中显示,接下来按照标准的方式构建RecyclerView,给它指定一个LayoutManager和一个适配器。然后在发送按钮的点击事件里获取了EditText中的内容,如果内容不为空字符串,则创建一个新的Msg对象并添加到msgList列表中去。之后又调用了适配器的notifyItemInserted()方法,用于通知列表有新的数据插入,这样新增的一条消息才能够在RecyclerView中显示出来。或者你也可以调用适配器的notifyDataSetChanged()方法,它会将RecyclerView中所有可见的元素全部刷新,这样不管是新增、删除、还是修改元素,界面上都会显示最新的数据,但缺点是效率会相对差一些。接着调用RecyclerView的scrollToPosition()方法将显示的数据定位到最后一行,以保证一定可以看得到最后发出的一条消息。最后调用EditText的setText()方法将输入的内容清空。

这样所有的工作都完成了,终于可以检验一下我们的成果了。

image-20260206145824340

4.8 Kotlin补全:延迟初始化和密封类

4.8.1 对变量延迟初始化

前面我们已经学习了Kotlin语言的许多特性,包括变量不可变,变量不可为空,等等。这些特性都是为了尽可能地保证程序安全而设计的,但是有些时候这些特性也会在编码时给我们带来不少的麻烦。

比如,如果你的类中存在很多全局变量实例,为了保证它们能够满足Kotlin的空指针检查语法标准,你不得不做许多的非空判断保护才行,即使你非常确定它们不会为空。

下面我们通过一个具体的例子来看一下吧,就使用刚刚的UIBestPractice项目来作为例子。如果你仔细观察MainActivity中的代码,会发现这里适配器的写法略微有点特殊:

class MainActivity : AppCompatActivity(), View.OnClickListener {
 private var adapter: MsgAdapter? = null
 override fun onCreate(savedInstanceState: Bundle?) {
 ...
 adapter = MsgAdapter(msgList)
 ...
 }
 override fun onClick(v: View?) {
 ...
 adapter?.notifyItemInserted(msgList.size - 1)
 ...
 }
}

这里我们将adapter设置为了全局变量,但是它的初始化工作是在onCreate()方法中进行的,因此不得不先将adapter赋值为null,同时把它的类型声明成MsgAdapter?。

虽然我们会在onCreate()方法中对adapter进行初始化,同时能确保onClick()方法必然在onCreate()方法之后才会调用,但是我们在onClick()方法中调用adapter的任何方法时仍然要进行判空处理才行,否则编译肯定无法通过。

而当你的代码中有了越来越多的全局变量实例时,这个问题就会变得越来越明显,到时候你可能必须编写大量额外的判空处理代码,只是为了满足Kotlin编译器的要求。

幸运的是,这个问题其实是有解决办法的,而且非常简单,那就是对全局变量进行延迟初始化。

延迟初始化使用的是lateinit关键字,它可以告诉Kotlin编译器,我会在晚些时候对这个变量进行初始化,这样就不用在一开始的时候将它赋值为null了。对于上面的代码,我们可以优化如下:

class MainActivity : AppCompatActivity(), View.OnClickListener {
 private lateinit var adapter: MsgAdapter
 override fun onCreate(savedInstanceState: Bundle?) {
 ...
 adapter = MsgAdapter(msgList)
 ...
 }
 override fun onClick(v: View?) {
 ...
 adapter.notifyItemInserted(msgList.size - 1)
 ...
 }
}

可以看到,我们在adapter变量的前面加上了lateinit关键字,这样就不用在一开始的时候将它赋值为null,同时类型声明也就可以改成MsgAdapter了。由于MsgAdapter是不可为空的类型,所以我们在onClick()方法中也就不再需要进行判空处理,直接调用adapter的任何方法就可以了。

当然,使用lateinit关键字也不是没有任何风险,如果我们在adapter变量还没有初始化的情况下就直接使用它,那么程序就一定会崩溃,并且抛出一个UninitializedPropertyAccessException异常。

所以,当你对一个全局变量使用了lateinit关键字时,请一定要确保它在被任何地方调用之前已经完成了初始化工作,否则Kotlin将无法保证程序的安全性。

另外,我们还可以通过代码来判断一个全局变量是否已经完成了初始化,这样在某些时候能够有效地避免重复对某一个变量进行初始化操作:

class MainActivity : AppCompatActivity(), View.OnClickListener {
 private lateinit var adapter: MsgAdapter
 override fun onCreate(savedInstanceState: Bundle?) {
 ...
 if (!::adapter.isInitialized) adapter = MsgAdapter(msgList)
 ...
 }
 override fun onClick(v: View?) {
 ...
 adapter.notifyItemInserted(msgList.size - 1)
 ...
 }
}

具体语法就是这样,::adapter.isInitialized可用于判断adapter变量是否已经初始化。虽然语法看上去有点奇怪,但这是固定的写法。然后我们再对结果进行取反,如果还没有初始化,那么就立即对adapter变量进行初始化,否则什么都不用做。

4.8.2 使用密封类优化代码

由于密封类通常可以结合RecyclerView适配器中的ViewHolder一起使用,因此我们就正好借这个机会在本节学习一下它的用法。当然,密封类的使用场景远不止于此,它可以在很多时候帮助你写出更加规范和安全的代码,所以非常值得一学。

首先来了解一下密封类具体的作用,这里我们来看一个简单的例子。新建一个Kotlin文件,文件名就叫Result.kt好了,然后在这个文件中编写如下代码:

interface Result
class Success(val msg: String) : Result
class Failure(val error: Exception) : Result

这里定义了一个Result接口,用于表示某个操作的执行结果,接口中不用编写任何内容。然后定义了两个类去实现Result接口:一个Success类用于表示成功时的结果,一个Failure类用于表示失败时的结果,这样就把准备工作做好了。接下来再定义一个getResultMsg()方法,用于获取最终执行结果的信息:

fun getResultMsg(result: Result) = when (result) {
    is Success -> result.msg
    is Failure -> result.error.message
    else -> throw IllegalArgumentException()
}

etResultMsg()方法中接收一个Result参数。我们通过when语句来判断:如果Result属于Success,那么就返回成功的消息;如果Result属于Failure,那么就返回错误信息。到目前为止,代码都是没有问题的,但比较让人讨厌的是,接下来我们不得不再编写一个else条件,否则Kotlin编译器会认为这里缺少条件分支,代码将无法编译通过。但实际上Result的执行结果只可能是Success或者Failure,这个else条件是永远走不到的,所以我们在这里直接抛出了一个异常,只是为了满足Kotlin编译器的语法检查而已。

另外,编写else条件还有一个潜在的风险。如果我们现在新增了一个Unknown类并实现Result接口,用于表示未知的执行结果,但是忘记在getResultMsg()方法中添加相应的条件分支,编译器在这种情况下是不会提醒我们的,而是会在运行的时候进入else条件里面,从而抛出异常并导致程序崩溃。

不过好消息是,Kotlin的密封类可以很好地解决这个问题,下面我们就来学习一下。密封类的关键字是sealed class,它的用法同样非常简单,我们可以轻松地将Result接口改造成密封类的写法:

sealed class Result
class Success(val msg: String) : Result()
class Failure(val error: Exception) : Result()

那么改成密封类之后有什么好处呢?你会发现现在getResultMsg()方法中的else条件已经不再需要了。为什么这里去掉了else条件仍然能编译通过呢?这是因为当在when语句中传入一个密封类变量作为条件时,Kotlin编译器会自动检查该密封类有哪些子类,并强制要求你将每一个子类所对应的条件全部处理。这样就可以保证,即使没有编写else条件,也不可能会出现漏写条件分支的情况。而如果我们现在新增一个Unknown类,并也让它继承自Result,此时getResultMsg()方法就一定会报错,必须增加一个Unknown的条件分支才能让代码编译通过。

这就是密封类主要的作用和使用方法了。另外再多说一句,密封类及其所有子类只能定义在同一个文件的顶层位置,不能嵌套在其他类中,这是被密封类底层的实现机制所限制的。

了解了这么多关于密封类的知识,接下来我们看一下它该如何结合MsgAdapter中的ViewHolder一起使用,并顺便优化一下MsgAdapter中的代码。

观看MsgAdapter现在的代码,你会发现onBindViewHolder()方法中就存在一个没有实际作用的else条件,只是抛出了一个异常而已。对于这部分代码,我们就可以借助密封类的特性来进行优化。首先删除MsgAdapter中的LeftViewHolder和RightViewHolder,然后新建一个MsgViewHolder.kt文件,在其中加入如下代码:

sealed class MsgViewHolder(view: View) : RecyclerView.ViewHolder(view)
class LeftViewHolder(view:View) : MsgViewHolder(view) {
    val leftMsg: TextView = view.findViewById<TextView>(R.id.leftMsg)
}

class rightViewHolder(view: View) : MsgViewHolder(view) {
    val rightMsg:TextView = view.findViewById<TextView>(R.id.rightMsg)
}

这里我们定义了一个密封类MsgViewHolder,并让它继承自RecyclerView.ViewHolder,然后让LeftViewHolder和RightViewHolder继承自MsgViewHolder。这样就相当于密封类MsgViewHolder只有两个已知子类,因此在when语句中只要处理这两种情况的条件分支即可。

现在修改MsgAdapter中的代码:

override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        val msg = msgList[position]
        when (holder) {
            is LeftViewHolder -> holder.leftMsg.text = msg.content
            is RightViewHolder -> holder.rightMsg.text = msg.content
        }
    }

这里我们将RecyclerView.Adapter的泛型指定成刚刚定义的密封类MsgViewHolder,这样onBindViewHolder()方法传入的参数就变成了MsgViewHolder。然后我们只要在when语句当中处理LeftViewHolder和RightViewHolder这两种情况就可以了,那个讨厌的else终于不再需要了,这种RecyclerView适配器的写法更加规范也更加推荐。

第五章 探究Fragment

当今是移动设备发展非常迅速的时代,不仅手机已经成为了生活必需品,而且平板也变得越来越普及。平板和手机最大的区别就在于屏幕的大小:一般手机屏幕的大小在3英寸到6英寸之间,平板屏幕的大小在7英寸到10英寸之间。屏幕大小差距过大有可能会让同样的界面在视觉效果上有较大的差异,比如一些界面在手机上看起来非常美观,但在平板上看起来可能会有控件被过分拉长、元素之间空隙过大等情况。

对于一名专业的Android开发人员而言,能够兼顾手机和平板的开发是我们尽可能要做到的事情。Android自3.0版本开始引入了Fragment的概念,它可以让界面在平板上更好地展示。

5.1 Fragment是什么

Fragment是一种可以嵌入在Activity当中的UI片段,它能让程序更加合理和充分地利用大屏幕的空间,因而在平板上应用得非常广泛。虽然Fragment对你来说是个全新的概念,但我相信你学习起来应该毫不费力,因为它和Activity实在是太像了,同样都能包含布局,同样都有自己的生命周期。你甚至可以将Fragment理解成一个迷你型的Activity,虽然这个迷你型的Activity有可能和普通的Activity是一样大的。

那么究竟要如何使用Fragment才能充分地利用平板屏幕的空间呢?想象我们正在开发一个新闻应用,其中一个界面使用RecyclerView展示了一组新闻的标题,当点击其中一个标题时,就打开另一个界面显示新闻的详细内容。如果是在手机中设计,我们可以将新闻标题列表放在一个Activity中,将新闻的详细内容放在另一个Activity中:

image-20260206161405069

可是如果在平板上也这么设计,那么新闻标题列表将会被拉长至填充满整个平板的屏幕,而新闻的标题一般不会太长,这样将会导致界面上有大量的空白区域:

image-20260206161601767

因此,更好的设计方案是将新闻标题列表界面和新闻详细内容界面分别放在两个Fragment中,然后在同一个Activity里引入这两个Fragment,这样就可以将屏幕空间充分地利用起来了:

image-20260206161542977

5.2 Fragment的使用方式

创建一个平板模拟器,或者用真实的平板。在AndroidStudio中创建一个FragmentTest的空Activity项目。

5.2.1 Fragment的简单用法

这里我们准备先写一个最简单的Fragment示例来练练手。在一个Activity当中添加两个Fragment,并让这两个Fragment平分Activity的空间。

新建一个左侧Fragment的布局left_fragment.xml:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <Button
        android:id="@+id/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:text="Button"
        />
</LinearLayout>

这个布局非常简单,只放置了一个按钮,并让它水平居中显示。然后新建右侧Fragment的布局right_fragment.xml:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:background="#00ff00"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:textSize="24sp"
        android:text="This is right fragment"
        />
</LinearLayout>

可以看到,我们将这个布局的背景色设置成了绿色,并放置了一个TextView用于显示一段文本。接着新建一个LeftFragment类,并让它继承自Fragment。注意,这里可能会有两个不同包下的Fragment供你选择:一个是系统内置的android.app.Fragment,一个是AndroidX库中的androidx.fragment.app.Fragment。这里请一定要使用AndroidX库中的Fragment,因为它可以让Fragment的特性在所有Android系统版本中保持一致,而系统内置的Fragment在Android 9.0版本中已被废弃。使用AndroidX库中的Fragment并不需要在build.gradle文件中添加额外的依赖,只要你在创建新项目时勾选了Use androidx.* artifacts选项,AndroidStudio会自动帮你导入必要的AndroidX库。

现在编写一下LeftFragment中的代码:

class LeftFragment : Fragment() {
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                              savedInstanceState: Bundle?): View? {
        return inflater.inflate(R.layout.left_fragment, container, false)
    }
}

修改activity_main.xml:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="match_parent" 
    android:fitsSystemWindows="true">
    <fragment
        android:id="@+id/leftFrag"
        android:name="com.example.fragmenttest.LeftFragment"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1" />
    <fragment
        android:id="@+id/rightFrag"
        android:name="com.example.fragmenttest.RightFragment"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1" />
</LinearLayout>

可以看到,我们使用了<fragment>标签在布局中添加Fragment,其中指定的大多数属性你已经非常熟悉了,只不过这里还需要通过android:name属性来显式声明要添加的Fragment类名,注意一定要将类的包名也加上。

注意这里的android:fitsSystemWindows=“true”,如果是模拟器,必须加上这个。如果是真机则不需要。

这样最简单的Fragment示例就已经写好了,现在运行一下程序:

image-20260206165858301

正如我们预期的一样,两个Fragment平分了整个Activity的布局。不过这个例子实在是太简单了,在真正的项目中很难有什么实际的作用,因此下面我们马上来看一看,关于Fragment更加高级的使用技巧。

5.2.2 动态添加Fragment

在上一节当中,你已经学会了在布局文件中添加Fragment的方法,不过Fragment真正的强大之处在于,它可以在程序运行时动态地添加到Activity当中。根据具体情况来动态地添加Fragment,你就可以将程序界面定制得更加多样化。

我们在上一节代码的基础上继续完善,新建another_right_fragment.xml:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:background="#ffff00"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:textSize="24sp"
        android:text="This is another right fragment"
        />
</LinearLayout>

这个布局文件的代码和right_fragment.xml中的代码基本相同,只是将背景色改成了黄色,并将显示的文字改了改。然后新建AnotherRightFragment作为另一个右侧Fragment:

class AnotherRightFragment : Fragment() {
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                              savedInstanceState: Bundle?): View? {
        return inflater.inflate(R.layout.another_right_fragment, container, false)
    }
}

修改activity_main.xml:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="match_parent" >
    <fragment
        android:id="@+id/leftFrag"
        android:name="com.example.fragmenttest.LeftFragment"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1" />
    <FrameLayout
        android:id="@+id/rightLayout"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1" >
    </FrameLayout>
</LinearLayout>

可以看到,现在将右侧Fragment替换成了一个FrameLayout。还记得这个布局吗?在上一章中我们学过,这是Android中最简单的一种布局,所有的控件默认都会摆放在布局的左上角。由于这里仅需要在布局里放入一个Fragment,不需要任何定位,因此非常适合使用FrameLayout。

下面我们将在代码中向FrameLayout里添加内容,从而实现动态添加Fragment的功能。修改MainActivity中的代码:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        findViewById<Button>(R.id.button).setOnClickListener {
            replaceFragment(AnotherRightFragment())
        }
        replaceFragment(RightFragment())
    }
    private fun replaceFragment(fragment: Fragment) {
        val fragmentManager = supportFragmentManager
        val transaction = fragmentManager.beginTransaction()
        transaction.replace(R.id.rightLayout, fragment)
        transaction.commit()
    }
}

可以看到,首先我们给左侧Fragment中的按钮注册了一个点击事件,然后调用replaceFragment()方法动态添加了RightFragment。当点击左侧Fragment中的按钮时,又会调用replaceFragment()方法,将右侧Fragment替换成AnotherRightFragment。结合replaceFragment()方法中的代码可以看出,动态添加Fragment主要分为5步。

(1) 创建待添加Fragment的实例。

(2) 获取FragmentManager,在Activity中可以直接调用getSupportFragmentManager()方法获取。

(3) 开启一个事务,通过调用beginTransaction()方法开启。

(4) 向容器内添加或替换Fragment,一般使用replace()方法实现,需要传入容器的id和待添加的Fragment实例。

(5) 提交事务,调用commit()方法来完成。

5.2.3 在Fragment中实现返回栈

在上一小节中,我们成功实现了向Activity中动态添加Fragment的功能。不过你尝试一下就会发现,通过点击按钮添加了一个Fragment之后,这时按下Back键程序就会直接退出。如果我们想实现类似于返回栈的效果,按下Back键可以回到上一个Fragment,该如何实现呢?其实很简单,FragmentTransaction中提供了一个addToBackStack()方法,可以用于将一个事务添加到返回栈中。修改MainActivity中的代码:

private fun replaceFragment(fragment: Fragment) {
        val fragmentManager = supportFragmentManager
        val transaction = fragmentManager.beginTransaction()
        transaction.replace(R.id.rightLayout, fragment)
        transaction.addToBackStack(null)
        transaction.commit()
    }

这里我们在事务提交之前调用了FragmentTransaction的addToBackStack()方法,它可以接收一个名字用于描述返回栈的状态,一般传入null即可。现在重新运行程序,并点击按钮将AnotherRightFragment添加到Activity中,然后按下Back键,你会发现程序并没有退出,而是回到了RightFragment界面。继续按下Back键,RightFragment界面也会消失,再次按下Back键,程序才会退出。

5.2.4 Fragment和Activity之间的交互

虽然Fragment是嵌入在Activity中显示的,可是它们的关系并没有那么亲密。实际上,Fragment和Activity是各自存在于一个独立的类当中的,它们之间并没有那么明显的方式来直接进行交互。如果想要在Activity中调用Fragment里的方法,或者在Fragment中调用Activity里的方法,应该如何实现呢?为了方便Fragment和Activity之间进行交互,FragmentManager提供了一个类似于findViewById()的方法,专门用于从布局文件中获取Fragment的实例:

val fragment = supportFragmentManager.findFragmentById(R.id.leftFrag) as LeftFragment

调用FragmentManager的findFragmentById()方法,可以在Activity中得到相应Fragment的实例,然后就能轻松地调用Fragment里的方法了。掌握了如何在Activity中调用Fragment里的方法,那么在Fragment中又该怎样调用Activity里的方法呢?这就更简单了,在每个Fragment中都可以通过调用getActivity()方法来得到和当前Fragment相关联的Activity实例:

if (activity != null) {
 val mainActivity = activity as MainActivity
}

这里由于getActivity()方法有可能返回null,因此我们需要先进行一个判空处理。有了Activity的实例,在Fragment中调用Activity里的方法就变得轻而易举了。另外当Fragment中需要使用Context对象时,也可以使用getActivity()方法,因为获取到的Activity本身就是一个Context对象。

这时不知道你心中会不会产生一个疑问:既然Fragment和Activity之间的通信问题已经解决了,那么不同的Fragment之间可不可以进行通信呢?

说实在的,这个问题并没有看上去那么复杂,它的基本思路非常简单:首先在一个Fragment中可以得到与它相关联的Activity,然后再通过这个Activity去获取另外一个Fragment的实例,这样就实现了不同Fragment之间的通信功能。因此,这里我们的回答是肯定的。

5.3 Fragment的生命周期

5.3.1 Fragment的状态和回调

还记得每个Activity在其生命周期内可能会有哪几种状态吗?没错,一共有运行状态、暂停状态、停止状态和销毁状态这4种。类似地,每个Fragment在其生命周期内也可能会经历这几种状态,只不过在一些细小的地方会有部分区别。

  • 01 运行状态:当一个Fragment所关联的Activity正处于运行状态时,该Fragment也处于运行状态。
  • 02 暂停状态:当一个Activity进入暂停状态时,与它相关联的Fragment也处于暂停状态。
  • 03 停止状态:当一个Activity进入停止状态时,与它相关联的Fragment就会进入停止状态,或者通过调用FragmentTransaction的remove()、replace()方法将Fragment从Activity中移除,但在事务提交之前调用了addToBackStack()方法,这时的Fragment也会进入停止状态。总的来说,进入停止状态的Fragment对用户来说是完全不可见的,有可能会被系统回收。
  • 04 销毁状态:Fragment总是依附于Activity而存在,因此当Activity被销毁时,与它相关联的Fragment就会进入销毁状态。或者通过调用FragmentTransaction的remove()、replace()方法将Fragment从Activity中移除,但在事务提交之前并没有调用addToBackStack()方法,这时的Fragment也会进入销毁状态。

合之前的Activity状态,相信你理解起来应该毫不费力吧。同样地,Fragment类中也提供了一系列的回调方法,以覆盖它生命周期的每个环节。其中,Activity中有的回调方法,Fragment中基本上也有,不过Fragment还提供了一些附加的回调方法,下面我们就重点看一下这几个回调:

  • onAttach():当Fragment和Activity建立关联时调用。
  • onCreateView():为Fragment创建视图、加载布局时调用。
  • onActivityCreate():确保与Fragment相关联的Activity已经创建完毕时调用。
  • onDestroyView():当与Fragment关联的视图被移除时调用。
  • onDetach():当Fragment和Activity接触关系时调用。

image-20260208132916709

5.3.2 体验Fragment的生命周期

为了让你能够更加直观地体验Fragment的生命周期,我们还是通过一个例子来实践一下。例子很简单,仍然是在FragmentTest项目的基础上改动的。

修改RightFragment中的代码:

class RightFragment : Fragment() {
    companion object {
        const val TAG = "RightFragment"
    }
    override fun onAttach(context: Context) {
        super.onAttach(context)
        Log.d(TAG, "onAttach")
    }
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Log.d(TAG, "onCreate")
    }
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                              savedInstanceState: Bundle?): View? {
        Log.d(TAG, "onCreateView")
        return inflater.inflate(R.layout.right_fragment, container, false)
    }
    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        Log.d(TAG, "onActivityCreated")
    }
    override fun onStart() {
        super.onStart()
        Log.d(TAG, "onStart")
    }
    override fun onResume() {
        super.onResume()
        Log.d(TAG, "onResume")
    }
    override fun onPause() {
        super.onPause()
        Log.d(TAG, "onPause")
    }
    override fun onStop() {
        super.onStop()
        Log.d(TAG, "onStop")
    }
    override fun onDestroyView() {
        super.onDestroyView()
        Log.d(TAG, "onDestroyView")
    }
    override fun onDestroy() {
        super.onDestroy()
        Log.d(TAG, "onDestroy")
    }
    override fun onDetach() {
        super.onDetach()
        Log.d(TAG, "onDetach")
    }
}

注意,这里为了方便日志打印,我们先定义了一个TAG常量。Kotlin中定义常量都是使用的这种方式,在companion object、单例类或顶层作用域中使用const关键字声明一个变量即可。

接下来,我们在RightFragment中的每一个回调方法里都加入了打印日志的代码,然后重新运行程序。这时观察Logcat中的打印信息:

image-20260208133700865

可以看到,当RightFragment第一次被加载到屏幕上时,会依次执行onAttach()、onCreate()、onCreateView()、onActivityCreated()、onStart()和onResume()方法。然后点击LeftFragment中的按钮:

image-20260208134024647

由于AnotherRightFragment替换了RightFragment,此时的RightFragment进入了停止状态,因此onPause()、onStop()和onDestroyView()方法会得到执行。当然,如果在替换的时候没有调用addToBackStack()方法,此时的RightFragment就会进入销毁状态,onDestroy()和onDetach()方法就会得到执行。

接着按下Back键,RightFragment会重新回到屏幕:

image-20260208134108248

由于RightFragment重新回到了运行状态,因此onCreateView()、onActivityCreated()、onStart()和onResume()方法会得到执行。注意,此时onCreate()方法并不会执行,因为我们借助了addToBackStack()方法使得RightFragment并没有被销毁。

现在再次按下Back键:

image-20260208134218073

依次执行onPause()、onStop()、onDestroyView()、onDestroy()和onDetach()方法,最终将Fragment销毁。

另外值得一提的是,在Fragment中你也可以通过onSaveInstanceState()方法来保存数据,因为进入停止状态的Fragment有可能在系统内存不足的时候被回收。保存下来的数据在onCreate()、onCreateView()和onActivityCreated()这3个方法中你都可以重新得到,它们都含有一个Bundle类型的savedInstanceState参数。具体的代码我就不在这里展示了,如果你忘记了该如何编写,可以参考3.4.5小节。

5.4 动态加载布局的技巧

虽然动态添加Fragment的功能很强大,可以解决很多实际开发中的问题,但是它毕竟只是在一个布局文件中进行一些添加和替换操作。如果程序能够根据设备的分辨率或屏幕大小,在运行时决定加载哪个布局,那我们可发挥的空间就更多了。因此本节我们就来探讨一下Android中动态加载布局的技巧。

5.4.1 使用限定符

如果你经常使用平板,应该会发现很多平板应用采用的是双页模式(程序会在左侧的面板上显示一个包含子项的列表,在右侧的面板上显示内容),因为平板的屏幕足够大,完全可以同时显示两页的内容,但手机的屏幕就只能显示一页的内容,因此两个页面需要分开显示。

那么怎样才能在运行时判断程序应该是使用双页模式还是单页模式呢?这就需要借助限定符(qualifier)来实现了。下面我们通过一个例子来学习一下它的用法,修改FragmentTest项目中的activity_main.xml文件:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="match_parent" >
    <fragment
        android:id="@+id/leftFrag"
        android:name="com.example.fragmenttest.LeftFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>
</LinearLayout>

这里将多余的代码删掉,只留下一个左侧Fragment,并让它充满整个父布局。接着在res目录下新建layout-large文件夹,在这个文件夹下新建一个布局,也叫作activity_main.xml:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <fragment
        android:id="@+id/leftFrag"
        android:name="com.example.fragmenttest.LeftFragment"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1" />
    <fragment
        android:id="@+id/rightFrag"
        android:name="com.example.fragmenttest.RightFragment"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="3" />
</LinearLayout>

可以看到,layout/activity_main布局只包含了一个Fragment,即单页模式,而layout-large/ activity_main布局包含了两个Fragment,即双页模式。其中,large就是一个限定符,那些屏幕被认为是large的设备就会自动加载layout-large文件夹下的布局,小屏幕的设备则还是会加载layout文件夹下的布局。

然后将MainActivity中replaceFragment()方法里的代码注释掉,并在平板模拟器上重新运行程序:

image-20260208140842638

image-20260208140953445

分类限定符含义 / 说明示例目录
语言与地区en英语values-en/
zh中文values-zh/
zh-rCN中国大陆中文values-zh-rCN/
en-rUS美国英语values-en-rUS/
屏幕方向port竖屏layout-port/
land横屏layout-land/
屏幕尺寸(已废弃)small小屏设备layout-small/
normal普通屏幕layout-normal/
large大屏设备layout-large/
xlarge超大屏设备layout-xlarge/
屏幕宽高sw<N>dp最小可用宽度layout-sw600dp/
w<N>dp当前可用宽度layout-w360dp/
h<N>dp当前可用高度layout-h480dp/
屏幕密度ldpi低密度 (~120dpi)drawable-ldpi/
mdpi中密度 (~160dpi)drawable-mdpi/
hdpi高密度 (~240dpi)drawable-hdpi/
xhdpi超高密度 (~320dpi)drawable-xhdpi/
xxhdpi超超高密度 (~480dpi)drawable-xxhdpi/
xxxhdpi超超超高密度 (~640dpi)drawable-xxxhdpi/
nodpi不随密度缩放drawable-nodpi/
anydpi任意密度drawable-anydpi/
UI 模式night深色模式values-night/
notnight浅色模式values-notnight/
car车载模式layout-car/
watch手表设备layout-watch/
televisionTV 设备layout-television/
API 版本v21API 21 及以上values-v21/
v33API 33 及以上values-v33/
输入设备keysexposed有实体键盘layout-keysexposed/
keyshidden键盘隐藏layout-keyshidden/
nokeys无硬件按键layout-nokeys/
finger触摸屏layout-finger/
notouch无触摸layout-notouch/
布局方向ldltr从左到右layout-ldltr/
ldrtl从右到左layout-ldrtl/

5.4.2 使用最小宽度限定符

在上一小节中我们使用large限定符成功解决了单页双页的判断问题,不过很快又有一个新的问题出现了:large到底是指多大呢?有时候我们希望可以更加灵活地为不同设备加载布局,不管它们是不是被系统认定为large,这时就可以使用最小宽度限定符(smallest-widthqualifier)。

最小宽度限定符允许我们对屏幕的宽度指定一个最小值(以dp为单位),然后以这个最小值为临界点,屏幕宽度大于这个值的设备就加载一个布局,屏幕宽度小于这个值的设备就加载另一个布局。

在res目录下新建layout-sw600dp文件夹,然后在这个文件夹下新建activity_main.xml布局:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <fragment
        android:id="@+id/leftFrag"
        android:name="com.example.fragmenttest.LeftFragment"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1" />
    <fragment
        android:id="@+id/rightFrag"
        android:name="com.example.fragmenttest.RightFragment"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="3" />
</LinearLayout>

这就意味着,当程序运行在屏幕宽度大于等于600 dp的设备上时,会加载layout-sw600dp/activity_main布局,当程序运行在屏幕宽度小于600 dp的设备上时,则仍然加载默认的layout/activity_main布局。

5.5 Fragment实践:一个简易版的新闻应用

现在你已经将关于Fragment的重要知识点掌握得差不多了,不过在灵活运用方面可能还有些欠缺,因此下面该进入我们本章的最佳实践环节了。

前面提到过,Fragment很多时候是在平板开发当中使用的,因为它可以解决屏幕空间不能充分利用的问题。那是不是就表明,我们开发的程序都需要提供一个手机版和一个平板版呢?确实有不少公司是这么做的,但是这样会耗费很多的人力物力财力。因为维护两个版本的代码成本很高:每当增加新功能时,需要在两份代码里各写一遍;每当发现一个bug时,需要在两份代码里各修改一次。因此,今天我们最佳实践的内容就是教你如何编写兼容手机和平板的应用程序。

还记得我们在本章开始的时候提到的一个新闻应用吗?现在我们就运用本章所学的知识来编写一个简易版的新闻应用,并且要求它可以兼容手机和平板。新建好一个FragmentBestPractice项目,然后开始动手吧!

由于待会在编写新闻列表时会使用到RecyclerView,因此首先需要在app/build.gradle当中添加依赖库,添加依赖库详见4.6.1。

整个项目框架结构如下:

com.example.newsapp

├── MainActivity                // 唯一 Activity,承载 Fragment

├── ui
│   ├── title
│   │   ├── NewsTitleFragment   // 左侧:新闻列表
│   │   └── NewsAdapter         // RecyclerView Adapter
│   │
│   └── content
│       └── NewsContentFragment // 右侧:新闻内容

├── viewmodel
│   └── NewsViewModel           // 核心:共享状态

├── model
│   └── News                    // 数据模型

└── res
    ├── layout
    │   ├── activity_main.xml           // Fragment 容器
    │   ├── fragment_news_title.xml     // 列表 Fragment
    │   ├── fragment_news_content.xml   // 内容 Fragment
    │   ├── item_news.xml               // RecyclerView item

    └── layout-sw600dp
        └── activity_main.xml           // 平板双页布局

01 News数据类

data class News(val title:String, val content:String) {}

02 NewsViewModel

在说这个之前,我们需要引入一个软件开发的架构模式——MVVM,即Model-View-ViewModel,Model 层负责数据的存储和管理,是应用程序的数据模型部分,例如用户信息、商品列表等数据,它不涉及任何的 UI 逻辑。View 层是用户界面部分,它负责展示数据给用户,例如一个列表显示商品信息,一个文本框显示用户输入的内容等。ViewModel 层是连接 Model 和 View 的桥梁,它将 Model 层的数据转换为 View 层可以显示的格式,并且将 View 层的用户输入等操作反馈给 Model 层,同时它还负责处理一些业务逻辑。在 Fragment + RecyclerView + MVVM 的协作中,Fragment 可以作为 View 层的一部分,负责展示 RecyclerView,RecyclerView 用于显示数据列表,而 MVVM 协作机制使得数据的更新和界面的刷新更加高效和解耦,例如当 Model 层的数据发生变化时,ViewModel 可以通知 View 层进行更新,而不需要 View 层直接去操作 Model 层的数据。这里的NewsViewModel就是其中的ViewModel部分。

通过这种模式,我们实现了两个Fragment的通信,而不需要先经过Activity再进行通信。

class NewsViewModel : ViewModel() {

    // 新闻列表
    private val _newsList = MutableLiveData<List<News>>()
    val newsList: LiveData<List<News>> = _newsList

    // 当前选中的新闻
    private val _selectedNews = MutableLiveData<News>()
    val selectedNews: LiveData<News> = _selectedNews

    //构造函数初始化代码块 当 ViewModel 被创建时执行。
    init {
        // 模拟数据
        _newsList.value = listOf(
            News("Android 学习路线", "这里是 Android 学习路线的详细内容……"),
            News("Jetpack 简介", "Jetpack 是一套官方推荐的组件……"),
            News("Kotlin 协程", "协程可以简化异步代码……")
        )
    }

    fun selectNews(news: News) {
        _selectedNews.value = news
    }
}

需要注意的是,前面带有下划线的变量命名表示这个变量是可修改变量,仅仅是约定俗成的命名方式。

03 UI-title部分

由于我们的新闻app由两部分组成,title和content,只有点击了title才会出现content,所以我们先来说title部分。

title部分分为两个kotlin类,NewsAdapter和NewsTitleFragment,先来说一下NewsTitleFragment:

class NewsTitleFragment : Fragment() {

    private var _binding: FragmentNewsTitleBinding? = null
    private val binding get() = _binding!!

    //使用的是 Activity 级别的 ViewModel
    private val viewModel: NewsViewModel by activityViewModels()

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentNewsTitleBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        val adapter = NewsAdapter { news ->

            viewModel.selectNews(news)

            // 如果是手机(没有内容容器)
            if (requireActivity().findViewById<View?>(R.id.contentContainer) == null) {

                parentFragmentManager.beginTransaction()
                    .replace(R.id.fragmentContainer,
                        com.example.fragmentbestpractice.ui.content.NewsContentFragment()
                    )
                    .addToBackStack(null)
                    .commit()
            }
        }

        binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
        binding.recyclerView.adapter = adapter

        viewModel.newsList.observe(viewLifecycleOwner) {
            adapter.submitList(it)
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

activityViewModels()意思是:这个 Fragment 使用的是 Activity 级别的 ViewModel也就是说:

Activity
   ├── TitleFragment
   └── ContentFragment

     共享同一个 ViewModel

如果改成by viewModels(),那就是每个 Fragment 一个独立 ViewModel(不能通信)

onViewCreated()这个方法是在View 创建完成之后这里是做 UI 初始化的最佳位置。

val adapter = NewsAdapter {
    viewModel.selectNews(it)
}

创建 Adapter,这里非常关键,你传进去的是:

(News) -> Unit

一个 Lambda 回调。意思是当 Adapter 中的 item 被点击时调用viewModel.selectNews()

binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())

RecyclerView 必须设置 LayoutManager,否则不显示。LinearLayoutManager 表示垂直列表

binding.recyclerView.adapter = adapter

设置 Adapter

viewModel.newsList.observe(viewLifecycleOwner) {
    adapter.submitList(it)
}

这是 MVVM 核心。observe()意思是:只要 newsList 变化,就执行大括号里的代码viewLifecycleOwner很重要。如果写:

observe(this)

会导致内存泄漏风险。因为Fragment 可能还活着,但 View 已经销毁。

adapter.submitList(it)

当数据变化时,更新 RecyclerView。

Adapter类:

class NewsAdapter(
    private val onItemClick: (News) -> Unit
) : RecyclerView.Adapter<NewsAdapter.ViewHolder>() {

    private val newsList = mutableListOf<News>()

    fun submitList(list: List<News>) {
        newsList.clear()
        newsList.addAll(list)
        notifyDataSetChanged()
    }

    inner class ViewHolder(private val binding: NewsItemBinding) :
        RecyclerView.ViewHolder(binding.root) {

        fun bind(news: News) {
            binding.titleText.text = news.title
            binding.root.setOnClickListener {
                onItemClick(news)
            }
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val binding = NewsItemBinding.inflate(
            LayoutInflater.from(parent.context),
            parent,
            false
        )
        return ViewHolder(binding)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.bind(newsList[position])
    }

    override fun getItemCount(): Int = newsList.size
}

和之前讲到的RecycleView的Adapter用法类似,不再赘述。

04 UI-content部分

NewsContentFragement相对来说比较简单,只是根据点击事件来变更content的内容。这里贴出实现代码,不再赘述:

class NewsContentFragment : Fragment() {

    private var _binding: FragmentNewsContentBinding? = null
    private val binding get() = _binding!!

    private val viewModel: NewsViewModel by activityViewModels()

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentNewsContentBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        viewModel.selectedNews.observe(viewLifecycleOwner) {
            binding.titleText.text = it.title
            binding.contentText.text = it.content
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

05 MainActivity

class MainActivity : AppCompatActivity() {


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

        if (savedInstanceState == null) {

            // 平板模式
            if (findViewById<View?>(R.id.contentContainer) != null) {

                supportFragmentManager.beginTransaction()
                    .replace(R.id.titleContainer, NewsTitleFragment())
                    .replace(R.id.contentContainer, NewsContentFragment())
                    .commit()

            } else {
                // 手机模式
                supportFragmentManager.beginTransaction()
                    .replace(R.id.fragmentContainer, NewsTitleFragment())
                    .commit()
            }
        }
    }
}

第六章 详解广播机制

如果你了解网络通信原理,应该会知道,在一个IP网络范围中,最大的IP地址是被保留作为广播地址来使用的。比如某个网络的IP范围是192.168.0.XXX,子网掩码是255.255.255.0,那么这个网络的广播地址就是192.168.0.255。广播数据包会被发送到同一网络上的所有端口,这样该网络中的每台主机都会收到这条广播。

为了便于进行系统级别的消息通知,Android也引入了一套类似的广播消息机制。

6.1 广播机制简介

什么说Android中的广播机制更加灵活呢?这是因为Android中的每个应用程序都可以对自己感兴趣的广播进行注册,这样该程序就只会收到自己所关心的广播内容,这些广播可能是来自于系统的,也可能是来自于其他应用程序的。Android提供了一套完整的API,允许应用程序自由地发送和接收广播。发送广播的方法其实之前稍微提到过,如果你记性好的话,可能还会有印象,就是借助我们第3章学过的Intent。而接收广播的方法则需要引入一个新的概念——BroadcastReceiver。

BroadcastReceiver的具体用法将会在下一节介绍,这里我们先来了解一下广播的类型。Android中的广播主要可以分为两种类型:标准广播和有序广播。

  • 标准广播(normal broadcasts)是一种完全异步执行的广播,在广播发出之后,所有的

    BroadcastReceiver几乎会在同一时刻收到这条广播消息,因此它们之间没有任何先后顺

    序可言。这种广播的效率会比较高,但同时也意味着它是无法被截断的。

    image-20260224152501467

  • 有序广播(ordered broadcasts)则是一种同步执行的广播,在广播发出之后,同一时刻只会有一个BroadcastReceiver能够收到这条广播消息,当这个BroadcastReceiver中的逻辑执行完毕后,广播才会继续传递。所以此时的BroadcastReceiver是有先后顺序的,优先级高的BroadcastReceiver就可以先收到广播消息,并且前面的BroadcastReceiver还可以截断正在传递的广播,这样后面的BroadcastReceiver就无法收到广播消息了。

    image-20260224152614087

6.2 接收系统广播

Android内置了很多系统级别的广播,我们可以在应用程序中通过监听这些广播来得到各种系统的状态信息。比如手机开机完成后会发出一条广播,电池的电量发生变化会发出一条广播,系统时间发生改变也会发出一条广播,等等。如果想要接收这些广播,就需要使用BroadcastReceiver,下面我们就来看一下它的具体用法。

6.2.1 动态注册监听时间变化

注册BroadcastReceiver的方式有两种:在代码中注册和在AndroidManifest.xml中注册。其中前者称为动态注册,后者称为静态注册。

创建动态注册很简单,只需要新建一个类,让其继承自BroadcastReceiver,并重写父类onReceive方法即可,当有广播到来时,onReceive方法会执行。

下面先以动态注册为例,新建BroadcastTest项目,修改MainActivity:

class MainActivity : AppCompatActivity() {
    lateinit var timeChangeReceiver: TimeChangeReceiver
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val intentFilter = IntentFilter()
        intentFilter.addAction("android.intent.action.TIME_TICK")
        timeChangeReceiver = TimeChangeReceiver()
        registerReceiver(timeChangeReceiver, intentFilter)
    }
    override fun onDestroy() {
        super.onDestroy()
        unregisterReceiver(timeChangeReceiver)
    }
    inner class TimeChangeReceiver : BroadcastReceiver() {
        override fun onReceive(context: Context, intent: Intent) {
            Toast.makeText(context, "Time has changed", Toast.LENGTH_SHORT).show()
        }
    }
}

可以看到,我们在MainActivity中定义了一个内部类TimeChangeReceiver,这个类是继承自BroadcastReceiver的,并重写了父类的onReceive()方法。这样每当系统时间发生变化时,onReceive()方法就会得到执行,这里只是简单地使用Toast提示了一段文本信息。

然后观察onCreate()方法,首先我们创建了一个IntentFilter的实例,并给它添加了一个值为android.intent.action.TIME_TICK的action,为什么要添加这个值呢?因为当系统时间发生变化时,系统发出的正是一条值为android.intent.action.TIME_TICK的广播,也就是说我们的BroadcastReceiver想要监听什么广播,就在这里添加相应的action。接下来创建了一个TimeChangeReceiver的实例,然后调用registerReceiver()方法进行注册,将TimeChangeReceiver的实例和IntentFilter的实例都传了进去,这样TimeChangeReceiver就会收到所有值为android.intent.action.TIME_TICK的广播,也就实现了监听系统时间变化的功能。

最后要记得,动态注册的BroadcastReceiver一定要取消注册才行,这里我们是在onDestroy()方法中通过调用unregisterReceiver()方法来实现的。

整体来说,代码还是非常简单的。现在运行一下程序,然后静静等待时间发生变化。系统每隔一分钟就会发出一条android.intent.action.TIME_TICK的广播,因此我们最多只需要等待一分钟就可以收到这条广播了。

image-20260224155919760

这就是动态注册BroadcastReceiver的基本用法,虽然这里我们只使用了一种系统广播来举例,但是接收其他系统广播的用法是一模一样的。Android系统还会在亮屏熄屏、电量变化、网络变化等场景下发出广播。如果你想查看完整的系统广播列表,可以到如下的路径中去查看:

<Android SDK>/platforms/<任意android api版本>/data/broadcast_actions.txt

6.2.2 静态注册实现开机启动

动态注册的BroadcastReceiver可以自由地控制注册与注销,在灵活性方面有很大的优势。但是它存在着一个缺点,即必须在程序启动之后才能接收广播,因为注册的逻辑是写在onCreate()方法中的。那么有没有什么办法可以让程序在未启动的情况下也能接收广播呢?这就需要使用静态注册的方式了。

其实从理论上来说,动态注册能监听到的系统广播,静态注册也应该能监听到,在过去的Android系统中确实是这样的。但是由于大量恶意的应用程序利用这个机制在程序未启动的情况下监听系统广播,从而使任何应用都可以频繁地从后台被唤醒,严重影响了用户手机的电量和性能,因此Android系统几乎每个版本都在削减静态注册BroadcastReceiver的功能。

在Android 8.0系统之后,所有隐式广播都不允许使用静态注册的方式来接收了。隐式广播指的是那些没有具体指定发送给哪个应用程序的广播,大多数系统广播属于隐式广播,但是少数特殊的系统广播目前仍然允许使用静态注册的方式来接收。这些特殊的系统广播列表详见https://developer.android.google.cn/guide/components/broadcastexceptions.html。

在这些特殊的系统广播当中,有一条值为android.intent.action.BOOT_COMPLETED的广播,这是一条开机广播,那么就使用它来举例学习吧。这里我们准备实现一个开机启动的功能。在开机的时候,我们的应用程序肯定是没有启动的,因此这个功能显然不能使用动态注册的方式来实现,而应该使用静态注册的方式来接收开机广播,然后在onReceive()方法里执行相应的逻辑,这样就可以实现开机启动的功能了。

上一小节中我们是使用内部类的方式创建的BroadcastReceiver,其实还可以通过Android Studio提供的快捷方式来创建。右击com.example.broadcasttest包→New→Other→Broadcast Receiver。这里创建一个BootCompleteReceiver,勾选Enabled和Exported。需要注意的是,如果自己手动创建创建,还需要去AndroidManifest.xml中注册receiver。修改BootCompleteReceiver的代码:

class BootCompleteReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        Toast.makeText(context, "Boot Complete", Toast.LENGTH_LONG).show()
    }
}

接下来对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">

    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
    <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.BroadcastTest">
        <receiver
            android:name=".BootCompleteReceiver"
            android:enabled="true"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.BOOT_COMPLETED" />
            </intent-filter>
        </receiver>

        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:label="@string/app_name"
            android:theme="@style/Theme.BroadcastTest">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

由于Android系统启动完成后会发出一条值为android.intent.action.BOOT_COMPLETED的广播,因此我们在<receiver>标签中又添加了一个<intent-filter>标签,并在里面声明了相应的action。另外,这里有非常重要的一点需要说明。Android 系统为了保护用户设备的安全和隐私,做了严格的规定:如果程序需要进行一些对用户来说比较敏感的操作,必须在AndroidManifest.xml文件中进行权限声明,否则程序将会直接崩溃。比如这里接收系统的开机广播就是需要进行权限声明的,所以我们在上述代码中使用<uses-permission>标签声明了android.permission.RECEIVE_BOOT_COMPLETED权限。

这是你第一次遇到权限的问题,其实 Android中的许多操作是需要声明权限才可以进行的,后面我们还会不断使用新的权限。不过目前这个接收系统开机广播的权限还是比较简单的,只需要在 AndroidManifest.xml 文件中声明一下就可以了。Android 6.0 系统中引入了更加严格的运行时权限,从而能够更好地保证用户设备的安全和隐私。关于这部分内容我们将在第 8章中学习。

重新运行程序,现在我们的程序已经可以接收开机广播了。重启后即可接收开机广播。

到目前为止,我们在BroadcastReceiver的onReceive()方法中只是简单地使用Toast提示了一段文本信息,当你真正在项目中使用它的时候,可以在里面编写自己的逻辑。需要注意的是,不要在onReceive()方法中添加过多的逻辑或者进行任何的耗时操作,因为BroadcastReceiver中是不允许开启线程的,当onReceive()方法运行了较长时间而没有结束时,程序就会出现错误。

6.3 发送自定义广播

之前学习了如何接收系统广播,接下来学习如何在应用程序中发送自定义的广播。

6.3.1 发送标准广播

在发送广播之前,我们还是需要先定义一个BroadcastReceiver来准备接收此广播,不然发出去也是白发。因此新建一个MyBroadcastReceiver,并在onReceive()方法中加入如下代码:

class MyBroadcastReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        Toast.makeText(context, "received in MyBroadcastReceiver",
            Toast.LENGTH_SHORT).show()
    }
}

修改AndroidManifest.xml:

<receiver
            android:name=".MyBroadcastReceiver"
            android:enabled="true"
            android:exported="true">
            <intent-filter>
                <action android:name="com.example.broadcasttest.MY_BROADCAST" />
            </intent-filter>
        </receiver>

可以看到,这里让MyBroadcastReceiver接收一条值为com.example.broadcasttest.MY_BROADCAST的广播,因此待会儿在发送广播的时候,我们就需要发出这样的一条广播。

修改activity_main.xml:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true">

    <Button
        android:id="@+id/button"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Send Broadcast"
        />
</LinearLayout>

这里在布局文件中定义了一个按钮,用于作为发送广播的触发点。然后修改MainActivity中的代码:

class MainActivity : AppCompatActivity() {
    lateinit var timeChangeReceiver: TimeChangeReceiver
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        findViewById<Button>(R.id.button).setOnClickListener { 
            val intent = Intent("com.example.broadcasttest.MY_BROADCAST")
            intent.setPackage(packageName)
            sendBroadcast(intent)
        }
        val intentFilter = IntentFilter()
        intentFilter.addAction("android.intent.action.TIME_TICK")
        timeChangeReceiver = TimeChangeReceiver()
        registerReceiver(timeChangeReceiver, intentFilter)
    }
    override fun onDestroy() {
        super.onDestroy()
        unregisterReceiver(timeChangeReceiver)
    }
    inner class TimeChangeReceiver : BroadcastReceiver() {
        override fun onReceive(context: Context, intent: Intent) {
            Toast.makeText(context, "Time has changed", Toast.LENGTH_SHORT).show()
        }
    }
}

可以看到,我们在按钮的点击事件里面加入了发送自定义广播的逻辑。

首先构建了一个Intent对象,并把要发送的广播的值传入。然后调用Intent的setPackage()方法,并传入当前应用程序的包名。packageName是getPackageName()的语法糖写法,用于获取当前应用程序的包名。最后调用sendBroadcast()方法将广播发送出去,这样所有监听com.example.broadcasttest.MY_BROADCAST这条广播的BroadcastReceiver就会收到消息了。此时发出去的广播就是一条标准广播。

这里我还得对第2步调用的setPackage()方法进行更详细的说明。前面已经说过,在Android 8.0系统之后,静态注册的BroadcastReceiver是无法接收隐式广播的,而默认情况下我们发出的自定义广播恰恰都是隐式广播。因此这里一定要调用setPackage()方法,指定这条广播是发送给哪个应用程序的,从而让它变成一条显式广播,否则静态注册的BroadcastReceiver将无法接收到这条广播。

这样我们就成功完成了发送自定义广播的功能。

另外,由于广播是使用Intent来发送的,因此你还可以在Intent中携带一些数据传递给相应的BroadcastReceiver,这一点和Activity的用法是比较相似的。

6.3.2 发送有序广播

有序广播可以被截断,为了验证这一点,先新建一个AnotherBroadcastReceiver:

class AnotherBroadcastReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        Toast.makeText(
            context, "received in AnotherBroadcastReceiver",
            Toast.LENGTH_SHORT
        ).show()
    }
}

修改AndroidManifest.xml:

<receiver
    android:name=".AnotherBroadcastReceiver"
    android:enabled="true"
    android:exported="true">
    <intent-filter>
        <action android:name="com.example.broadcasttest.MY_BROADCAST" />
    </intent-filter>
</receiver>

可以看到,AnotherBroadcastReceiver同样接收的是com.example.broadcasttest.MY_BROADCAST这条广播。现在重新运行程序,并点击“Send Broadcast”按钮,就会分别弹出两次提示信息。

不过,到目前为止,程序发出的都是标准广播,现在我们来尝试一下发送有序广播。重新回到BroadcastTest项目,然后修改MainActivity中的代码:

sendOrderedBroadcast(intent,null)

可以看到,发送有序广播只需要改动一行代码,即将sendBroadcast()方法改成sendOrderedBroadcast()方法。sendOrderedBroadcast()方法接收两个参数:第一个参数仍然是Intent;第二个参数是一个与权限相关的字符串,这里传入null就行了。现在重新运行程序,并点击“Send Broadcast”按钮,你会发现,两个BroadcastReceiver仍然都可以收到这条广播。

看上去好像和标准广播并没有什么区别嘛。不过别忘了,这个时候的BroadcastReceiver是有先后顺序的,而且前面的BroadcastReceiver还可以将广播截断,以阻止其继续传播。

那么该如何设定BroadcastReceiver的先后顺序呢?当然是在注册的时候进行设定了,修改AndroidManifest.xml中的代码:

<receiver
    android:name=".MyBroadcastReceiver"
    android:enabled="true"
    android:exported="true">
    <intent-filter android:priority="100">
        <action android:name="com.example.broadcasttest.MY_BROADCAST" />
    </intent-filter>
</receiver>

以看到,我们通过android:priority属性给BroadcastReceiver设置了优先级,优先级比较高的BroadcastReceiver就可以先收到广播。这里将MyBroadcastReceiver的优先级设成了100,以保证它一定会在AnotherBroadcastReceiver之前收到广播。

既然已经获得了接收广播的优先权,那么MyBroadcastReceiver就可以选择是否允许广播继续传递了。修改MyBroadcastReceiver中的代码:

class MyBroadcastReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        Toast.makeText(context, "received in MyBroadcastReceiver",
            Toast.LENGTH_SHORT).show()
        abortBroadcast()
    }
}

果在onReceive()方法中调用了abortBroadcast()方法,就表示将这条广播截断,后面的BroadcastReceiver将无法再接收到这条广播。

现在重新运行程序,并点击“Send Broadcast”按钮,你会发现只有MyBroadcastReceiver中的Toast信息能够弹出,说明这条广播经过MyBroadcastReceiver之后确实终止传递了。

6.4 广播实践:实现强制下线功能

强制下线应该算是一个比较常见的功能,比如如果你的QQ号在别处登录了,就会将你强制挤下线。其实实现强制下线功能的思路比较简单,只需要在界面上弹出一个对话框,让用户无法进行任何其他操作,必须点击对话框中的“确定”按钮,然后回到登录界面即可。可是这样就会存在一个问题:当用户被通知需要强制下线时,可能正处于任何一个界面,难道要在每个界面上都编写一个弹出对话框的逻辑?如果你真的这么想,那思路就偏远了。我们完全可以借助本章所学的广播知识,非常轻松地实现这一功能。新建一个BroadcastBestPractice项目,然后开始动手吧。

强制下线功能需要先关闭所有的Activity,然后回到登录界面。如果你的反应足够快,应该会想到我们在第3章的最佳实践部分已经实现过关闭所有Activity的功能了,因此这里使用同样的方案即可。先创建一个ActivityCollector类用于管理所有的Activity:

object ActivityCollector {
    private val activities = ArrayList<Activity>()
    fun addActivity(activity: Activity) {
        activities.add(activity)
    }
    fun removeActivity(activity: Activity) {
        activities.remove(activity)
    }
    fun finishAll() {
        for (activity in activities) {
            if (!activity.isFinishing) {
                activity.finish()
            }
        }
        activities.clear()
    }
}

创建BaseActivity类作为所有Activity的父类:

open class BaseActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        ActivityCollector.addActivity(this)
    }
    override fun onDestroy() {
        super.onDestroy()
        ActivityCollector.removeActivity(this)
    }
}

创建一个LoginActivity作为登录界面,编辑布局activity_login .xml:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <LinearLayout
        android:orientation="horizontal"
        android:layout_width="match_parent"
        android:layout_height="60dp">
        <TextView
            android:layout_width="90dp"
            android:layout_height="wrap_content"
            android:layout_gravity="center_vertical"
            android:textSize="18sp"
            android:text="Account:" />
        <EditText
            android:id="@+id/accountEdit"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:layout_gravity="center_vertical" />
    </LinearLayout>
    <LinearLayout
        android:orientation="horizontal"
        android:layout_width="match_parent"
        android:layout_height="60dp">
        <TextView
            android:layout_width="90dp"
            android:layout_height="wrap_content"
            android:layout_gravity="center_vertical"
            android:textSize="18sp"
            android:text="Password:" />
        <EditText
            android:id="@+id/passwordEdit"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:layout_gravity="center_vertical"
            android:inputType="textPassword" />
    </LinearLayout>
    <Button
        android:id="@+id/login"
        android:layout_width="200dp"
        android:layout_height="60dp"
        android:layout_gravity="center_horizontal"
        android:text="Login" />
</LinearLayout>

修改LoginActivity:

class LoginActivity : BaseActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_login)
        findViewById<Button>(R.id.login).setOnClickListener {
            val account = findViewById<TextView>(R.id.accountEdit).text.toString()
            val password = findViewById<TextView>(R.id.passwordEdit).text.toString()
            if (account == "admin" && password == "123456") {
                val intent = Intent(this, MainActivity::class.java)
                startActivity(intent)
                finish()
            } else {
                Toast.makeText(this, "account or password error", Toast.LENGTH_SHORT)
                    .show()
            }
        }
    }
}

修改activity_main.xml:

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/forceOffline"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Send force offline broadcast" />
</androidx.constraintlayout.widget.ConstraintLayout>

修改MainActivity:

class MainActivity : BaseActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        findViewById<Button>(R.id.forceOffline).setOnClickListener {
            val intent = Intent("com.example.broadcastbestpractice.FORCE_OFFLINE")
            sendBroadcast(intent)
        }
    }
}

同样非常简单,不过这里有个重点,我们在按钮的点击事件里发送了一条广播,广播的值为com.example.broadcastbestpractice.FORCE_OFFLINE,这条广播就是用于通知程序强制用户下线的。也就是说,强制用户下线的逻辑并不是写在MainActivity里的,而是应该写在接收这条广播的BroadcastReceiver里。这样强制下线的功能就不会依附于任何界面了,不管是在程序的任何地方,只要发出这样一条广播,就可以完成强制下线的操作了。

那么毫无疑问,接下来我们就需要创建一个BroadcastReceiver来接收这条强制下线广播。唯一的问题就是,应该在哪里创建呢?由于BroadcastReceiver中需要弹出一个对话框来阻塞用户的正常操作,但如果创建的是一个静态注册的BroadcastReceiver,是没有办法在onReceive()方法里弹出对话框这样的UI控件的,而我们显然也不可能在每个Activity中都注册一个动态的BroadcastReceiver。

那么到底应该怎么办呢?答案其实很明显,只需要在BaseActivity中动态注册一个BroadcastReceiver就可以了,因为所有的Activity都继承自BaseActivity。

修改BaseActivity中的代码:

open class BaseActivity : AppCompatActivity() {
    lateinit var receiver: ForceOfflineReceiver
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        ActivityCollector.addActivity(this)
    }

    override fun onResume() {
        super.onResume()
        val intentFilter = IntentFilter()
        intentFilter.addAction("com.example.broadcastbestpractice.FORCE_OFFLINE")
        receiver = ForceOfflineReceiver()
        registerReceiver(receiver, intentFilter)
    }
    override fun onPause() {
        super.onPause()
        unregisterReceiver(receiver)
    }
    override fun onDestroy() {
        super.onDestroy()
        ActivityCollector.removeActivity(this)
    }
    inner class ForceOfflineReceiver : BroadcastReceiver() {
        override fun onReceive(context: Context, intent: Intent) {
            AlertDialog.Builder(context).apply {
                setTitle("Warning")
                setMessage("You are forced to be offline. Please try to login again.")
                setCancelable(false)
                setPositiveButton("OK") { _, _ ->
                    ActivityCollector.finishAll() // 销毁所有Activity
                    val i = Intent(context, LoginActivity::class.java)
                    context.startActivity(i) // 重新启动LoginActivity
                }
                show()
            }
        }
    }
}

先来看一下ForceOfflineReceiver中的代码,这次onReceive()方法里可不再是仅仅弹出一个Toast了,而是加入了较多的代码,那我们就来仔细看看吧。首先是使用AlertDialog.Builder构建一个对话框。注意,这里一定要调用setCancelable()方法将对话框设为不可取消,否则用户按一下Back键就可以关闭对话框继续使用程序了。然后使用setPositiveButton()方法给对话框注册确定按钮,当用户点击了“OK”按钮时,就调用ActivityCollector的finishAll()方法销毁所有Activity,并重新启动LoginActivity。

再来看一下我们是怎么注册ForceOfflineReceiver这个BroadcastReceiver的。可以看到,这里重写了onResume()和onPause()这两个生命周期方法,然后分别在这两个方法里注册和取消注册了ForceOfflineReceiver。

为什么要这样写呢?之前不都是在onCreate()和onDestroy()方法里注册和取消注册BroadcastReceiver的吗?这是因为我们始终需要保证只有处于栈顶的Activity才能接收到这条强制下线广播,非栈顶的Activity不应该也没必要接收这条广播,所以写在onResume()和onPause()方法里就可以很好地解决这个问题,当一个Activity失去栈顶位置时就会自动取消BroadcastReceiver的注册。

这样的话,所有强制下线的逻辑就已经完成了,接下来我们还需要对AndroidManifest.xml文件进行修改:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <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.BroadcastBestPractice">
        <activity android:name=".MainActivity"/>
        <activity
            android:name=".LoginActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

6.5 Kotlin补全:高阶函数详解

6.5.1 定义高阶函数

高阶函数和Lambda的关系是密不可分的。在第2章快速入门Kotlin编程的时候,我们已经学习了Lambda编程的基础知识,并且掌握了一些与集合相关的函数式API的用法,如map、filter函数等。另外,在第3章的Kotlin课堂中,我们又学习了Kotlin的标准函数,如run、apply函数等。

你有没有发现,这几个函数有一个共同的特点:它们都会要求我们传入一个Lambda表达式作为参数。像这种接收Lambda参数的函数就可以称为具有函数式编程风格的API,而如果你想要定义自己的函数式API,那就得借助高阶函数来实现了,这也是我们本节Kotlin课堂所要重点学习的内容。

首先来看一下高阶函数的定义。如果一个函数接收另一个函数作为参数,或者返回值的类型是另一个函数,那么该函数就称为高阶函数。

这个定义可能有点不太好理解,一个函数怎么能接收另一个函数作为参数呢?这就涉及另外一个概念了:函数类型。我们知道,编程语言中有整型、布尔型等字段类型,而Kotlin又增加了一个函数类型的概念。如果我们将这种函数类型添加到一个函数的参数声明或者返回值声明当中,那么这就是一个高阶函数了。

接下来我们就学习一下如何定义一个函数类型。不同于定义一个普通的字段类型,函数类型的语法规则是有点特殊的,基本规则如下:

(String, Int) -> Unit

既然是定义一个函数类型,那么最关键的就是要声明该函数接收什么参数,以及它的返回值是什么。因此,->左边的部分就是用来声明该函数接收什么参数的,多个参数之间使用逗号隔开,如果不接收任何参数,写一对空括号就可以了。而->右边的部分用于声明该函数的返回值是什么类型,如果没有返回值就使用Unit,它大致相当于Java中的void。

现在将上述函数类型添加到某个函数的参数声明或者返回值声明上,那么这个函数就是一个高阶函数了:

fun example(func: (String, Int) -> Unit) {
    func("hello", 123)
}

可以看到,这里的example()函数接收了一个函数类型的参数,因此example()函数就是一个高阶函数。而调用一个函数类型的参数,它的语法类似于调用一个普通的函数,只需要在参数名的后面加上一对括号,并在括号中传入必要的参数即可。

现在我们已经了解了高阶函数的定义方式,但是这种函数具体有什么用途呢?由于高阶函数的用途实在是太广泛了,这里如果要让我简单概括一下的话,那就是高阶函数允许让函数类型的参数来决定函数的执行逻辑。即使是同一个高阶函数,只要传入不同的函数类型参数,那么它的执行逻辑和最终的返回结果就可能是完全不同的。为了详细说明这一点,下面我们来举一个具体的例子。

这里我准备定义一个叫作num1AndNum2()的高阶函数,并让它接收两个整型和一个函数类型的参数。我们会在num1AndNum2()函数中对传入的两个整型参数进行某种运算,并返回最终的运算结果,但是具体进行什么运算是由传入的函数类型参数决定的。

写一个HigherOrderFunction.kt文件:

fun num1AndNum2(num1:Int,num2:Int,operation: (Int, Int) -> Int): Int{
    val result = operation(num1,num2)
    return result
}

fun plus(num1: Int, num2: Int): Int{
    return num1 + num2
}

fun minus(num1: Int, num2: Int): Int {
    return num1 - num2
}

fun main() {
    val num1 = 100
    val num2 = 80
    val result1 = num1AndNum2(num1,num2,::plus)
    val result2 = num1AndNum2(num1,num2, ::minus)
    println("result1 is $result1")
    println("result2 is $result2")
}

根据刚才的讲解,上述代码并不难理解。运行:

image-20260224194949765

这和我们预期的结果是一致的。

使用这种函数引用的写法虽然能够正常工作,但是如果每次调用任何高阶函数的时候都还得先定义一个与其函数类型参数相匹配的函数,这是不是有些太复杂了?

没错,因此Kotlin还支持其他多种方式来调用高阶函数,比如Lambda表达式、匿名函数、成员引用等。其中,Lambda表达式是最常见也是最普遍的高阶函数调用方式,也是我们接下来要重点学习的内容。

上述代码如果使用Lambda表达式的写法来实现的话,代码如下:

fun main() {
    val num1 = 100
    val num2 = 80
    val result1 = num1AndNum2(num1,num2) {n1,n2 ->
        n1+n2
    }
    val result2 = num1AndNum2(num1,num2) {n1,n2 ->
        n1-n2
    }
    println("result1 is $result1")
    println("result2 is $result2")
}

Lambda表达式的语法规则我们在2.6.2小节已经学习过了,因此这段代码对于你来说应该不难理解。你会发现,Lambda表达式同样可以完整地表达一个函数的参数声明和返回值声明(Lambda表达式中的最后一行代码会自动作为返回值),但是写法却更加精简。现在你就可以将刚才定义的plus()和minus()函数删掉了,重新运行一下代码,你会发现结果是一模一样的。

下面我们继续对高阶函数进行探究。回顾之前在第3章学习的apply函数,它可以用于给Lambda表达式提供一个指定的上下文,当需要连续调用同一个对象的多个方法时,apply函数可以让代码变得更加精简,比如StringBuilder就是一个典型的例子。接下来我们就使用高阶函数模仿实现一个类似的功能。

修改HigherOrderFunction.kt文件,在其中加入如下代码:

fun StringBuilder.build(block: StringBuilder.() -> Unit): StringBuilder {
 block()
 return this
}

这里我们给StringBuilder类定义了一个build扩展函数,这个扩展函数接收一个函数类型参数,并且返回值类型也是StringBuilder。

注意,这个函数类型参数的声明方式和我们前面学习的语法有所不同:它在函数类型的前面加上了一个StringBuilder. 的语法结构。这是什么意思呢?其实这才是定义高阶函数完整的语法规则,在函数类型的前面加上ClassName. 就表示这个函数类型是定义在哪个类当中的。

那么这里将函数类型定义到StringBuilder类当中有什么好处呢?好处就是当我们调用build函数时传入的Lambda表达式将会自动拥有StringBuilder的上下文,同时这也是apply函数的实现方式。

现在我们就可以使用自己创建的build函数来简化StringBuilder构建字符串的方式了。这里仍然用吃水果这个功能来举例:

fun main() {
    val list = listOf("Apple", "Banana", "Orange", "Pear", "Grape")
    val result = StringBuilder().build {
        append("Start eating fruits.\n")
        for (fruit in list) {
            append(fruit).append("\n")
        }
        append("Ate all fruits.")
    }
    println(result.toString())
}

可以看到,build函数的用法和apply函数基本上是一模一样的,只不过我们编写的build函数目前只能作用在StringBuilder类上面,而apply函数是可以作用在所有类上面的。如果想实现apply函数的这个功能,需要借助于Kotlin的泛型才行,我们将在第8章学习泛型的相关内容。

现在,你已经完全掌握了高阶函数的基本功能,接下来我们要学习一些更加高级的知识。

6.5.2 内联函数的作用( 未完成)

第七章 数据存储全方案,详解持久化技术