Andriod开发

Android Studio

Google将JDK、Android SDK都集成了,Android官方网就可以下载最新的开发工具:下载 Android Studio


创建Android项目

  1. New Project - Phone and Tablet - Empty Activity
image-20240821123557291
  1. Minimum SDK:设置项目最低能兼容的Android版本

Android模拟器

  1. Device Manager - Create Virtual Device
image-20240821124808648
  1. 选择目标参数的设备
image-20240821125040156
  1. 选择操作系统版本。其余配置全部默认。

    image-20240821125231735

  2. 点击Start按钮,就会启动模拟器。

    image-20240821125446010

  3. Run 'app' 将项目运行到模拟器上

    image-20240821135026657

  4. 项目成功运行,显示了Hollo Android。打开模拟器中的启动器列表,可以看到已经安装了对应的应用。

    image-20240821135414741

项目结构

任何一个新建的项目都会默认使用Android模式的项目结构,但这并不是项目真实的目录结构,而是被Android Studio转换过的。这种项目结构简洁明了,适合进行快速开发,但是对于新手来说可能不易于理解。可以切换成项目模式

image-20240821135823142

image-20240821140107800

  • .gradle.idea

    放置Android Studio自动生成的文件,无需关心。

  • app

    项目中的代码、资源等内容。

    • build

      编译时自动生成的文件,无需过多关心。

    • src

      • androidTest:编写Android Test测试用例,可以对项目进行一些自动化测试。

      • main

        • java:防止Java和Kotlin代码,默认会自动生成一个MainActivity.kt文件

        • res:项目中使用到的图片、布局、字符串等资源。

          • drawable:图片

            虽然Android Studio没有帮我们自动生成,但是应该自行创建drawable-xxhdpi等目录。最好能够给同一张图片提供几个不同分辨率的版本,分别放在这些目录下,然后程序运行的时候,会自动根据当前运行设备分辨率的高低选择加载哪个目录下的图片。但更多的时候美工只会提供一份图片,这时把所有图片都放在drawable-xxhdpi目录下就好了,这是最主流的设备分辨率目录

          • mipmap:图标

          • values:字符串、颜色、样式、尺寸

          • xml:布局、菜单等

      • test:编写单元测试用力,对项目进行自动化测试的另一种方式。

      • .gitignore:将app模块内指定的目录或文件排除在版本控制之外。

      • build.gradle.kts

        app模块的gradle构建脚本,通常这个文件中的内容无需修改。

      • proguard-rules.pro:指定项目代码的混淆规则。当代码开发完成后打包成安装包文件,如果不希望代码被哦破解,通常会将代码进行混淆。

  • gradle

    包含gradle wrapper的配置文件,使用gradle wrapper的方式,不需要提前把gradle下载好,而是根据本地的缓存情况,自动决定是否联网下载

    • libs.versions.toml定义各个依赖库的版本号,使得依赖管理更加清晰和一致。
  • .gitignore

    记录git版本控制之外的文件、目录。

  • build.gradle.kts

    项目全局的gradle构建脚本,通常这个文件中的内容无需修改。

  • gradle.properties

    项目全局的gradle配置文件。

  • gradlewgradlew.bat

    用于在命令行中执行gradle命令。

  • local.properties

    指定本机中的Andriod SDK路径,自动生成。

  • settings.gradle.kts

    指定项目中所有引入的模块。通常情况下都是自动引入。


AndroidManifest.xml

app\src\main\AndroidManifest.xml中有如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
<!--  注册MainActivity,没有注册的Activity是不能使用的  -->
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.HelloWorld">
<intent-filter>
<!-- 表示该activity是项目的主activity,点击应用图标,首先启动的就是这个Activity -->
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

MainActivity继承自ComponentActivityComponentActivity继承自Activity,**Activity 是一个提供用户界面的组件**。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 设置应用程序的UI内容,setContent是一个高阶函数,接受一个lambda表达式作为参数
setContent {
// Composable函数,定义了应用程序的主题,HelloWorldTheme包含了整个UI布局
HelloWorldTheme {
// A surface container using the 'background' color from the theme
// Surface 也是一个Composable 函数,它提供了一个绘制表面,用于渲染其他Composable 函数
// fillMaxsize修饰符来填充整个屏幕,并使用了主题中定义的背景色
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
// 接收一个字符串参数,在Surface中显示一个问候语
Greeting("Android")
}
}
}
Log.d("MainActivity", "onCreate execute")
}
}

项目中的资源使用

  • app\src\main\res\values\strings.xml

    1
    2
    3
    <resources>
    <string name="app_name">HelloWorld</string>
    </resources>

    这里定义了一个应用程序名的字符串,有以下两种方式可以用来引用:

    1. 在代码中使用通过R.string.app_name
    2. 在XML中使用@string/app_name

    其中string可以被替换,如果是引用的图片资源就可以替换成drawable,如果是引用的应用图标就可以替换成mipmap,如果是引用的布局文件就可以替换成xml,以此类推。


app目录下的 build.gradle.kts

Android Studio采用Gradle构建项目。它使用了一种领域特定语言DSL进行项目布置,摒弃了如Maven等基于XML文件的各种繁琐配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// 该项目使用的Gradle插件,通过 alias 方法引入,这意味着它们的确切 ID 和版本号在 libs.versions.toml 文件中定义。
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.jetbrainsKotlinAndroid)
}

// 项目的Android配置信息
android {
namespace = "com.hunter.helloworld"
compileSdk = 34

defaultConfig {
applicationId = "com.hunter.helloworld"
minSdk = 21
targetSdk = 34
versionCode = 1
versionName = "1.0"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
// 在低于minSdk的版本上使用支持库来支持向量图标
useSupportLibrary = true
}
}
// 配置不同的构建类型
buildTypes {
// 发布版本的构建类型
release {
// 是否对代码进行混淆
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.1"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
// 在 libs.versions.toml 文件中定义的依赖
dependencies {

implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}

settings.gradle.kts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// gradle插件管理
pluginManagement {
// 获取插件的仓库
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
// 依赖解析管理
dependencyResolutionManagement {
// 如果在子项目中自定义了仓库,构建将失败。确保所有子项目使用统一的仓库配置,避免潜在的版本冲突或依赖解析问题。
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}

rootProject.name = "HelloWorld"
// 包含一个名为 :app 的子项目。:app 通常指的是 Android 应用程序模块。
include(":app")

日志工具的使用

Android中的日志工具类是android.util.Log,它提供了5个打印日志的方法:

  1. Log.v():verbose,打印那些最琐碎、意义最小的日志信息。是Android中最低的日志级别。
  2. Log.d():debug,打印一些调试信息。
  3. Log.i():info,打印一些比较重要的数据,可以帮助分析用户行为。
  4. Log.w():warn。
  5. Log.e():error。

例如在MainActivity.ktonCreate()方法中添加一行日志:

1
2
3
4
5
6
7
8
9
10
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
...
}
// 第一个参数是tag,一般传入当前类名,便于对日志进行过滤;第二个参数是msg
Log.d("MainActivity", "onCreate execute")
}
}

运行程序,在Android Studio底部工具栏的Logcat中就能看到打印信息。

image-20240822142408233


Activity

Activity是一种可以包含用户界面的组件,主要用于和用户进行交互。一个应用程序中可以包含零个或多个Activity,但不包含任何Activity的应用程序很少见。


基本用法

新建一个Android项目,选择No Activity,其余选项和之前章节中创建Android项目-Empty Activity保持一致。等待Gradle构建完成后,项目就创建成功了。

image-20240822150739473


手动创建Activity

Compose 下的 Empty Activity: 使用 Jetpack Compose 的声明式 UI 编程,UI 是通过 Kotlin 代码直接编写的,没有 XML 文件。

Activity 下的 Empty Views Activity: 使用传统的视图系统,UI 是通过 XML 文件定义的,然后在 Activity 中引用这些 XML 布局。

适用场景:

  • Compose 下的 Activity: 适合希望使用现代 UI 工具包 Jetpack Compose 开发的项目,特别适合新项目或者希望采用最新技术的项目。
  • 传统 Views Activity: 适合已有的项目或需要兼容传统视图系统的项目,或者开发者更熟悉传统的视图系统。

学习曲线:

  • Jetpack Compose: 更现代,但需要学习新的编程范式(声明式 UI)。
  • 传统视图系统: 更传统,许多开发者已经熟悉。

项目创建成功后,仍然会默认使用Android模式的项目结构,手动改成Project模式。


传统的视图系统:Activity 下的 Empty Views Activity

右键选择要创建Activity的包,New - Activity - Empty Views Activity,会弹出一个创建Activity的对话框。

image-20241011224328104

不勾选Generate a Layout File,创建完成后,可以看到生成的是一个Kotlin文件:

1
2
3
4
5
class FirstActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
}
}

声明式 UI 编程:Compose 下的 Empty Activity

右键选择要创建Activity的包,New - Compose - Empty Activity,会弹出一个创建Activity的对话框。

image-20240822151537061

不勾选Launcher Activity,否则会将创建的Activity设置为当前项目的主Activity

image-20240822151714288

创建完成后,可以看到生成的是一个Kotlin文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class TestActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ActivityTestTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Greeting("Android")
}
}
}
}
}

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
ActivityTestTheme {
Greeting("Android")
}
}

创建和加载布局

传统的视图系统

Android程序的设计讲究逻辑和视图分离,最好每个Activity都能对应一个布局,布局用来显示界面内容。

右击app/src/main/res目录,New -> Directory,会弹出一个新建目录的窗口,创建一个layout目录。再对着layout目录右键,New -> Layout Resource File,将布局文件命名为first_layout,根元素默认选择为ConstraintLayout,改用LinearLayout

image-20241011220755549

创建出来的xml文件,默认代码为:

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

</LinearLayout>

通过<Button>标签,添加一个按钮:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

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

</LinearLayout>
  • android:id:给当前元素定义一个唯一的标识符,之后可以在代码中对其进行操作。

    如果需要在XML中引用一个id,使用@id/id_name语法,定义id的时候,需要使用@+id/id_name

  • android:layout_width指定当前宽度。

    match_parent表示和父元素一样宽。

  • android:layout_height="wrap_content"

    指定当前高度,wrap_content表示当前元素的高度能刚好包含里面的内容就行。

  • android:text指定元素中显示的文字内容。

一个简单的布局就编写完成了,然后在Activity中加载该布局:

1
2
3
4
5
6
7
class FirstActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 调用 setContentView 给当前Activity加载布局,传入布局文件的id
setContentView(R.layout.first_layout)
}
}

项目中添加的任何资源,都会在R文件中生成一个相应的资源id


声明式 UI 编程

当你使用 Jetpack Compose 时,布局不再通过传统的 XML 文件来定义,而是使用 Kotlin 代码进行声明式编程。Jetpack Compose 是一种现代的 Android UI 工具包,允许你以更直观和可组合的方式构建用户界面。

在 Compose 中,UI 通过 @Composable 注释的函数来定义。每个 @Composable 函数都是一个 UI 组件。

1
2
3
4
5
6
7
8
9
10
import androidx.compose.material.Text
import androidx.compose.runtime.Composable

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}

可以使用 @Preview 注解来预览 Composable 函数的效果,而无需运行整个应用。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
ActivityTestTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Greeting("Android")
}
}
}

image-20240822162401416


AndroidManifest.xml

所有的Activity都要在AndroidManifest.xml中进行注册才能生效。一般情况下,创建的Activity都会被自动注册进来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?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"
tools:targetApi="31" >
<activity
android:name=".FirstActivity"
android:exported="false" />
</application>

</manifest>
  • android:name用于指定具体注册哪一个Activity。.FirstActivity是相对引用。

不过,仅仅是这样注册了Activity,我们的程序仍然不能运行,因为还没有为程序配置主Activity。也就是说,程序运行起来的时候,不知道要首先启动哪个Activity。

1
2
3
4
5
6
7
8
9
<activity
android:name=".FirstActivity"
android:exported="true"
android:label="This is FirstActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

添加<intent-filter>标签并在其中添加<action android:name="android.intent.action.MAIN" /><category android:name="android.intent.category.LAUNCHER" />声明即可。

此外,可以使用android:label指定Activity中标题栏的内容。主Activity指定的label不仅会称为标题栏中的内容,还会成为Launcher中应用程序的名称


使用Toast

Toast是Android提供的一种很好的提醒方式,可以将一些短小的信息通知给用户,并在一段时间后自动消失,不占用屏幕空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class FirstActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 调用 setContentView 给当前Activity加载布局,传入布局文件的id
setContentView(R.layout.first_layout)

// 调用 findViewById 获取布局文件中定义的元素,返回一个继承自View的泛型对象
val button1: Button = findViewById(R.id.button1)
// 为按钮注册点击监听器
button1.setOnClickListener {
// 调用 makeText 创建Toast对象,再调用 show 进行展示
Toast.makeText(this, "You clicked Button 1", Toast.LENGTH_SHORT).show()
}
}
}

如果布局文件中的组件很多,使用findViewById就很繁琐,可以通过View Binding来代替,View Binding可以生成代码来直接访问视图,无需使用findViewById

要使用View Binding,首先需要在build.gradle.kts中添加如下内容:

1
2
3
4
5
6
7
8
9
10
android {
...
// viewBinding 用于避免编写findViewById
// Android Studio会自动为我们所编写的每一个布局文件都生成一个对应的Binding类。
// Binding类的命名规则是将布局文件按驼峰方式重命名后,再加上Binding作为结尾。
buildFeatures {
viewBinding = true
}
...
}

然后在代码中使用View Binding代替findViewById

1
2
3
4
5
6
7
8
9
10
11
12
13
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 调用first_layout.xml布局文件对应的Binding类的inflate函数加载该布局
val binding = FirstLayoutBinding.inflate(layoutInflater)
// 传入根元素的实例,就可以成功显示first_layout.xml这个布局的内容
setContentView(binding.root)

// 为按钮注册点击监听器
binding.button1.setOnClickListener {
// 调用 makeText 创建Toast对象,再调用 show 进行展示
Toast.makeText(this, "You clicked Button 1", Toast.LENGTH_SHORT).show()
}
}

使用Menu

首先在res目录下新建一个menu文件夹,右击res目录→New→Directory,输入文件夹名“menu”,点击“OK”。接着在这个文件夹下新建一个名叫“main”的菜单文件,右击menu文件夹→New→Menu resource file。

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

1
2
3
4
5
6
7
8
9
<?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>

回到FirstActivity中来重写onCreateOptionsMenu()方法,重写方法可以使用Ctrl + O快捷键:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
// menuInflater是语法糖,实际调用了父类的getMenuInflater方法,能够得到一个MenuInflater对象
// 调用其inflate方法就可以给当前Activity创建菜单。
menuInflater.inflate(R.menu.main, menu)
// 允许创建的菜单显示
return true
}

// 定义菜单响应事件
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
}

销毁一个Activity

只要按一下Back键就可以销毁当前的Activity。不过,如果你不想通过
按键的方式,而是希望在程序中通过代码来销毁Activity,当然也可以,Activity类提供了一个finish()方法,我们只需要调用一下这个方法就可以销毁当前的Activity

1
2
3
4
5
// 为按钮注册点击监听器
binding.button1.setOnClickListener {
// 点击按钮,当前的Activity就会被销毁
finish()
}

使用Intent在Activity之间穿梭

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

使用显式Intent

右键选择要创建Activity的包,New - Activity - Empty Views Activity,会弹出一个创建Activity的对话框,不勾选Generate a Layout File,创建SecondActivity

对着layout目录右键,New -> Layout Resource File,将布局文件命名为second_layout

1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

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

</LinearLayout>

相应地,重写SecondActivity,应用该布局:

1
2
3
4
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.second_layout)
}

Intent有多个构造函数的重载,其中一个是Intent(Context packageContext, Class<?> cls)

  • Context要求提供一个启动Activity的上下文。
  • Class用于指定想要启动的目标Activity

Activity类提供了一个startActivity()方法,专门用于启动Activity。

重写FirstActivity中的按钮监听代码:

1
2
3
4
5
6
7
// 为按钮注册点击监听器
binding.button1.setOnClickListener {
// 在当前上下文的基础上,打开SecondActivity
val intent = Intent(this, SecondActivity::class.java)
// 执行该intent
startActivity(intent)
}

就能实现在FirstActivity的基础上打开SecondActivity,按一下Back键就可以销毁当前Activity,从而回到上一个Activity


使用隐式Intent

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

通过在AndroidManifest.xml中的<activity>标签下配置<intent-filter>内容,可以指定当前Activity能够响应的action和category:

1
2
3
4
5
6
7
8
9
10
11
<activity
android:name=".SecondActivity"
android:exported="false">
<intent-filter>
<!-- 当前activity可以响应的action -->
<action android:name="com.huntr.myapplication.ACTION_START" />
<!-- 附加信息,更精确地指明当前activity能够响应地intent中还可能带有的category -->
<category android:name="android.intent.category.DEFAULT" />
<category android:name="com.hunter.myapplication.MY_CATEGORY" />
</intent-filter>
</activity>

相应地,修改FirstActivity.kt中的按钮点击事件:

1
2
3
4
5
6
7
8
9
// 为按钮注册点击监听器
binding.button1.setOnClickListener {
// 在当前上下文的基础上,打开指定的action能对应的activity
val intent = Intent("com.hunter.myapplication.ACTION_START")
// 指定category
intent.addCategory("com.hunter.myapplication.MY_CATEGORY")
// 执行该intent
startActivity(intent)
}

隐式Intent的更多用法

使用隐式Intent,不仅可以启动自己程序内的Activity,还可以启动其他程序的Activity,这就使多个应用程序之间的功能共享成为了可能


调用浏览器

比如你的应用程序中需要展示一个网页,这时你没有必要自己去实现一个浏览器(事实上也不太可能),只需要调用系统的浏览器来打开这个网页就行了。

1
2
3
4
5
6
7
// 为按钮注册点击监听器
binding.button1.setOnClickListener {
// 在当前上下文的基础上,打开指定的action能对应的activity,ACTION_VIEW是android内置的动作
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse("https://www.bing.com")
startActivity(intent)
}

同时,可以在AndroidManifest.xml中的activity标签下的<intent-filter>标签中配置<data>标签:

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

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


调用拨号界面
1
2
3
4
5
6
// 为按钮注册点击监听器
binding.button1.setOnClickListener {
val intent = Intent(Intent.ACTION_DIAL)
intent.data = Uri.parse("tel:10086")
startActivity(intent)
}
image-20241013210111954

向下一个Activity传递数据

在启动Activity时传递数据的思路很简单,Intent中提供了一系列putExtra()方法的重载,可以把我们想要传递的数据暂存在Intent中,在启动另一个Activity后,只需要把这些数据从Intent中取出就可以了。

例如在FirstActivity中:

1
2
3
4
5
6
7
8
// 为按钮注册点击监听器
binding.button1.setOnClickListener {
val data = "Hello SecondActivity"
val intent = Intent(this, SecondActivity::class.java)
// 通过putExtra方法传递一个字符串
intent.putExtra("extra_data", data)
startActivity(intent)
}

在SecondActivity中获取传递的数据:

1
2
val stringExtraData = intent.getStringExtra("extra_data")
Log.d("SecondActivity", "extra data is $stringExtraData")

返回数据给上一个Activity

返回上一个Activity只需要按一下Back键就可以了,没有一个用于启动Activity的Intent来传递数据。可以通过registerForActivityResult()注册对Activity结果的处理。

在FirstActivity做如下改动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class FirstActivity : AppCompatActivity() {
// 定义 ActivityResultLauncher,registerForActivityResult用于处理活动结果的注册
private val resultLauncher = registerForActivityResult(
// 这个 resultLauncher 将用于启动一个新活动并接收其结果
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == RESULT_OK) {
val data: Intent? = result.data
// 处理返回的数据,?用于安全地操作可能的空值
val returnedData = data?.getStringExtra("data_return")
Log.d("FirstActivity", "returned data is $returnedData")
}
}
...
override fun onCreate(savedInstanceState: Bundle?) {
...

// 为按钮注册点击监听器
binding.button1.setOnClickListener {
val intent = Intent(this, SecondActivity::class.java)
// 启动SecondActivity,并等待结果
resultLauncher.launch(intent)
}
}
}

在SecondActivity中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class SecondActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 调用second_layout.xml布局文件对应的Binding类的inflate函数加载该布局
val binding = SecondLayoutBinding.inflate(layoutInflater)
// 传入根元素的实例,就可以成功显示second_layout.xml这个布局的内容
setContentView(binding.root)

// 为 button2 注册点击监听器
binding.button2.setOnClickListener {
returnDataToFirstActivity()
}

// 注册 OnBackPressedCallback 来处理后退按钮事件
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
returnDataToFirstActivity()
}
})
}

private fun returnDataToFirstActivity() {
val intent = Intent()
intent.putExtra("data_return", "Hello FirstActivity")
// setResult 专门用于向上一个Activity返回数据
setResult(RESULT_OK, intent)
finish()
}
}

Activity的生命周期

返回栈

Android中的Activity是可以层叠的。我们每启动一个新的Activity,就会覆盖在原Activity之上,然后点击Back键会销毁最上面的Activity,下面的一个Activity就会重新显示出来。

Android使用任务(task)来管理Activity,一个任务就是一组存放在栈里的Activity的集合,这个栈也被称作返回栈(back stack)。


Activity状态

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

  1. 运行状态

  2. 暂停状态

    当一个Activity不再处于栈顶,但仍然可见时(并不是每个Activity都会占满整个屏幕,比如对话框),就进入了暂停状态。只有在内存极低的情况下,系统才会考虑回收这种Activity

  3. 停止状态

    当一个Activity不再处于栈顶,并且完全不可见,就进入了停止状态。其他地方需要内存时,处于停止状态的Activity有可能会被系统回收。

  4. 销毁状态

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


Activity的生存期

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

  1. onCreate():在Activity被创建的时候调用。应该在该方法中完成初始化操作,比如加载布局、绑定事件等。
  2. onStart():在Activity由不可见变为可见时调用
  3. onResume():在Activity准备好和用户交互时调用。此时Activity一定位于返回栈的栈顶,并处于运行状态。
  4. onPause():在系统准备去启动或恢复另一个Activity时调用。通常在这个方法中,将一些消耗CPU的资源放掉、保存一些关键数据。但执行速度一定要快,避免影响新的栈顶Activity使用
  5. onStop():在Activity完全不可见时调用。和onPause()方法的主要区别在于,如果启动的新Activity是一个对话框式的Activity,那么onPause()方法会得到执行,onStop()方法并不会执行
  6. onDestroy():在Activity被销毁之前调用,之后Activity的状态变为销毁状态。
  7. onRestart():在Activity由停止变为运行状态之前调用

上述7个方法,除了onRestart()之外,都是两两相对,从而可以将Activity分为以下3种生存期:

  • 完整生存期。ActivityonCreate()onDestroy()方法之间所经历的就是完整生存期。
  • 可见生存期。ActivityonStart()onStop()之间所经历的就是可见生存期。在可见生存期内,Activity对于用户总是可见的,即使可能无法和用户进行交互。可以通过这两个方法合理地管理那些对用户可见的资源。比如onStart()方法中对资源进行加载,而在onStop()方法中对资源进行释放,从而保证处于停止状态的Activity不会占用过多内存
  • 前台生存期。ActivityonResume()方法和onPause()方法之间所经历的就是前台生存期。在前台生存期内,Activity总是处于运行状态,此时的Activity是可以和用户进行交互的
image-20241014134452549

新建一个项目ActivityLifeCycleTest为例子,New Project -> Empty Views Activity

image-20241014163751536

会默认生成MainActivityactivity_main.xml。继续创建两个子Activity:NormalActivityDialogActivity。同样地,以Empty Views Activity形式创建,不勾选Generate a Layout File。之后在res/layout目录下,手动创建normal_layout.xmldialog_layout.xml两个布局文件:

1
2
3
4
5
6
7
8
9
10
11
12
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

<!-- 显示一行文字 -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="This is a normal activity" />
</LinearLayout>
1
2
3
4
5
6
7
8
9
10
11
12
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="This is a dialog activity" />

</LinearLayout>

相应地,NormalActivityDialogActivity中添加对应的布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
class NormalActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.normal_layout)
}
}

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

为了将DialogActivity设置成对话框的样式,需要在AndroidManifest.xml中配置<activity>标签:

1
2
3
4
5
<!-- android:theme属性用于指定主题 -->
<activity
android:name=".DialogActivity"
android:exported="false"
android:theme="@style/Theme.AppCompat.Dialog" />

接着,修改activity_main.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

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

最后,修改MainActivity中的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class MainActivity : AppCompatActivity() {
private val tag = "MainActivity"

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d(tag, "onCreate")
// 调用activity_main.xml布局文件对应的Binding类的inflate函数加载布局
val binding = ActivityMainBinding.inflate(layoutInflater)
// 传入根元素的实例,就可以成功显示activity_main.xml这个布局的内容
setContentView(binding.root)

binding.startNormalActivity.setOnClickListener {
val intent = Intent(this, NormalActivity::class.java)
startActivity(intent)
}

binding.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")
}
}

在Activity的7个回调方法中分别打印了一句话,这样就可以通过观察日志来更直观地理解Activity的生命周期


Activity被回收了怎么办

当一个Activity进入了停止状态,是有可能被系统回收的。那么想象以下场景:应用中有一个Activity A,用户在Activity A的基础上启动了Activity B,Activity A就进入了停止状态,这个时候由于系统内存不足,将Activity A回收掉了,然后用户按下Back键返回Activity A,会出现什么情况呢?其实还是会正常显示Activity A的,只不过这时并不会执行onRestart()方法,而是会执行Activity A的onCreate()方法,因为Activity A在这种情况下会被重新创建一次

但是Activity A中是可能存在临时数据和状态的。打个比方,MainActivity中如果有一个文本输入框,现在你输入了一段文字,然后启动NormalActivity,这时MainActivity由于系统内存不足被回收掉,过了一会你又点击了Back键回到MainActivity,你会发现刚刚输入的文字都没了,因为MainActivity被重新创建了。

Activity中还提供了一个onSaveInstanceState()回调方法,这个方法可以保证在Activity被回收之前一定会被调用,因此我们可以通过这个方法来解决问题。onSaveInstanceState()方法会携带一个Bundle类型的参数,Bundle提供了一系列的方法用于保存数据,比如可以使用putString()方法保存字符串,使用putInt()方法保存整型数据,以此类推。每个保存方法需要传入两个参数,第一个参数是键,用于后面从Bundle中取
值,第二个参数是真正要保存的内容

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

我们一直使用的onCreate()方法其实也有一个Bundle类型的参数。这个参数在一般情况下都是null,但是如果在Activity被系统回收之前,通过onSaveInstanceState()方法保存数据,这个参数就会带有保存的全部数据,我们只需要再通过相应的取值方法将数据取出即可,比如将文本内容重新赋值到文本输入框上。

1
2
3
4
5
6
7
override fun onCreate(savedInstanceState: Bundle?) {
...
if (savedInstanceState != null) {
val tempData = savedInstanceState.getString("data_key")
Log.d(tag, "tempData is $tempData")
}
}

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

另外,当手机的屏幕发生旋转的时候,Activity也会经历一个重新创建的过程,虽然这个问题同样可以通过onSaveInstanceState()方法来解决,但是一般不太建议这么做,因为对于横竖屏旋转的情况,有更加优雅的解决方
案,后续介绍。


Activity的启动模式

启动模式一共有4种,分别是standard、singleTop、singleTask和singleInstance,可以在AndroidManifest.xml中通过给<activity>标签指定android:launchMode属性来选择启动模式。

standard

standard是Activity默认的启动模式。在standard模式下,每当启动一个新的Activity,就会在返回栈中入栈,并处于栈顶的位置。系统不在乎Activity是否已经在返回栈中,每次启动都会创建一个Activity的新实例。


singleTop

当Activity的启动模式指定为singleTop,在启动Activity时如果发现返回栈的栈顶已经是该Activity,则认为可以直接使用它,不会再创建新的Activity实例。当返回栈的栈顶并不是该Activity时,再次启动该Activity还是会创建新实例。


singleTask

使用singleTop模式,如果Activity没有处于栈顶位置,还是可能创建多个Activity实例。而使用singleTask,每次启用该Activity时,系统首先会在返回栈中检查是否存在该Activity的实例,如果存在则直接使用,并把该Activity之上的所有其他Activity都出栈,如果没有发现就创建新的Activity实例。


singleInstance

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

假设程序中有一个Activity允许其他程序调用,如果想实现其他程序和当前程序共享这个Activity的实例,就可以使用singleInstance模式解决这个问题,不管哪个应用程序来访问这个Activity,都共用一个返回栈,解决了共享Activity实例的问题。


Activity的实践技巧

知晓当前是在哪一个Activity

创建一个BaseActivity类,由于不需要让BaseActivity在AndroidManifest.xml中注册,所以创建一个普通的Kotlin类即可。然后让BaseActivity继承AppCompatActivity,并重写onCreate()方法:

1
2
3
4
5
6
7
open class BaseActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// javaClass相当于在java中调用getClass()方法,获取实例的class对象,调用simpleName获取当前实例的类名
Log.d("BaseActivity", javaClass.simpleName)
}
}

再将FirstActivity、SecondActivity和ThirdActivity由继承AppCompatActivity改为继承BaseActivity。这样,每当进入一个Activity界面时,其类名就会被打印出来,就能知道当前所在的Activity。


随时退出程序

只需要一个专门的集合对所有的Activity进行管理

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* Object: 单例类
* 使用单例类是因为全局只需要一个Activity集合
*/
object ActivityCollector {
// 通过ArrayList暂存Activity
private val activities = ArrayList<Activity>()

fun addActivity(activity: Activity) {
activities.add(activity)
}

fun removeActivity(activity: Activity) {
activities.remove(activity)
}

/**
* 将ArrayList中存储的Activity全部销毁
*/
fun finishAll() {
for (activity in activities) {
// 是否正在销毁中
if (!activity.isFinishing) {
activity.finish()
}
}
activities.clear()
}
}

在BaseActivity的onCreate()方法中调用了ActivityCollector的addActivity()方法,表明将当前正在创建的Activity添加到集合里。然后在BaseActivity中重写onDestroy()方法,并调用了ActivityCollector的removeActivity()方法,表明从集合里移除一个马上要销毁的Activity。

1
2
3
4
5
6
7
8
9
10
11
12
13
open class BaseActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// javaClass相当于在java中调用getClass()方法,获取实例的class对象,调用simpleName获取当前实例的类名
Log.d("BaseActivity", javaClass.simpleName)
ActivityCollector.addActivity(this)
}

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

不管你想在什么地方退出程序,只需要调用ActivityCollector.finishAll()
方法就可以了。例如在ThirdActivity界面想通过点击按钮直接退出程序,只需将代码改成如下形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class ThirdActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d("ThirdActivity", "Task id is $taskId")
// 调用third_layout.xml布局文件对应的Binding类的inflate函数加载该布局
val binding = ThirdLayoutBinding.inflate(layoutInflater)
// 传入根元素的实例,就可以成功显示third_layout.xml这个布局的内容
setContentView(binding.root)

binding.button3.setOnClickListener {
ActivityCollector.finishAll()
// 杀掉当前进程,以保证程序完全退出,killProcess只能用于杀掉当前程序的进程,不能杀掉其他程序
android.os.Process.killProcess(android.os.Process.myPid())
}
}
}

启动Activity的最佳写法

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
class SecondActivity : BaseActivity() {
...
companion object {
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)
}
}
}

这样写,可以非常清晰地知道启动SecondActivity需要传递哪些数据。同时,还能简化启动Activity的代码:

1
2
3
button1.setOnClickListener {
SecondActivity.actionStart(this, "data1", "data2")
}

UI开发

常用控件的使用

创建一个名为UIWidgetTest的Empty Views Activity项目。

TextView

TextView用于在界面显示文本信息。修改默认创建的activity_main.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="#00ff00"
android:textSize="24sp"
android:text="This is TextView" />

</LinearLayout>
  • android:gravity:TextView中的文字默认居左上角对齐,通过android:gravity显式指定对齐方式:top、bottom、start、end、center等。可以用“|”来同时指定多个值,这里我们指定的是”center”,效果等同于”center_vertical|center_horizontal”,表示文字在垂直和水平方向都居中对齐。
  • android:textColor="#00ff00":指定文字颜色
  • android:textSize="24sp":指定文字大小,要使用sp作为单位(这样,当用户在系统中修改了文字显式尺寸时,应用程序中的文字大小才会跟着变化)

Button

1
2
3
4
5
<Button
android:id="@+id/button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Button" />

Android系统默认会将按钮上的英文字母全部转换成大写。可以在XML中添加android:textAllCaps="false"这个属性,这样系统就会保留原始文字内容

接着可以为Button的点击事件注册一个监听器:

1
2
3
4
5
6
7
8
9
10
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)

binding.button.setOnClickListener {
// 添加逻辑
}
}

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class MainActivity : AppCompatActivity(), View.OnClickListener {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)

binding.button.setOnClickListener(this)
}

override fun onClick(v: View?) {
when (v?.id) {
R.id.button -> {
// 添加逻辑
}
}
}
}

让MainActivity实现View.OnClickListener接口,并重写了onClick()方法,然后在调用button的setOnClickListener()方法时将MainActivity的实例传进去。这样每当点击按钮时,就会执行onClick()方法中的代码了。


EditText

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

1
2
3
4
5
6
<EditText
android:id="@+id/editText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Type something here"
android:maxLines="2" />
  • android:hint属性用于设定提示性的文本。当我们输入任何内容时,这段文本就会自动消失。

随着输入的内容不断增多,EditText会被不断地拉长。这是由于EditText的高度指定的是wrap_content,因此它总能包含住里面的内容,但是当输入的内容过多时,界面就会变得非常难看。可以使用android:maxLines属性来解决这个问题。这里通过android:maxLines指定了EditText的最大行数为两行,这样当输入的内容超过2行时,文本就会向上滚动,EditText则不会再继续拉伸

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class MainActivity : AppCompatActivity(), View.OnClickListener {
private lateinit var binding: ActivityMainBinding

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)

binding.button.setOnClickListener(this)
}

override fun onClick(v: View?) {
when (v?.id) {
R.id.button -> {
val inputText = binding.editText.text.toString()
Toast.makeText(this, inputText, Toast.LENGTH_SHORT).show()
}
}
}
}

ImageView

ImageView是用于在界面上展示图片的一个控件,它可以让程序界面更丰富多彩。

图片通常放在以drawable开头的目录下,并且要带上具体的分辨率。现在最主流的手机屏幕分辨率大多是xxhdpi的,所以我们在res目录下再新建一个drawable-xxhdpi目录:右键 -> New -> Android Resource Directory。

image-20241015225933054

再准备好两张图片,放入该目录下,注意图片的命名要符合Android规范:只能包含小写字母、数字、下划线和点号,并且不能以数字开头

如果放入图片后,该图片被Android Studio标红,尝试重启Android Studio: File -> Invalidate Caches / Restart,然后选择 Invalidate and Restart。之后,就能通过<ImageView>标签往应用中插入图片。

1
2
3
4
5
<ImageView
android:id="@+id/imageView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/cy1a9741" />

还可以在程序中通过代码动态地更改ImageView中的图片

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class MainActivity : AppCompatActivity(), View.OnClickListener {
private lateinit var binding: ActivityMainBinding

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)

binding.button.setOnClickListener(this)
}

override fun onClick(v: View?) {
when (v?.id) {
...
binding.imageView.setImageResource(R.drawable.cy1a9740)
}
}
}
}

ProgressBar

ProgressBar用于在界面上显示一个进度条,表示我们的程序正在加载一些数据。

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

如何才能让进度条在数据加载完成时消失呢?所有的Android控件都具有可见属性,可以通过android:visibility进行指定,可选值有3种:visible、invisible和gone。

  • visible表示控件是可见的,这个值是默认值,不指定android:visibility时,控件都是可见的。
  • invisible表示控件不可见,但是它仍然占据着原来的位置和大小,可以理解成控件变成透明状态了。
  • gone则表示控件不仅不可见,而且不再占用任何屏幕空间。

也可以通过代码来设置控件的可见性,setVisibility()方法,允许传入View.VISIBLEView.INVISIBLEView.GONE这三种值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class MainActivity : AppCompatActivity(), View.OnClickListener {
private lateinit var binding: ActivityMainBinding

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)

binding.button.setOnClickListener(this)
}

override fun onClick(v: View?) {
when (v?.id) {
R.id.button -> {
// 点击让进度条消失,再点击让进度条出现
if (binding.progressBar.visibility == View.VISIBLE) {
binding.progressBar.visibility = View.GONE
} else {
binding.progressBar.visibility = View.VISIBLE
}
}
}
}
}

还可以通过style指定不同的进度条样式、通过android:max设置进度条的最大值:

1
2
3
4
5
6
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="100" />

在代码中可以动态地更改进度条的进度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class MainActivity : AppCompatActivity(), View.OnClickListener {
private lateinit var binding: ActivityMainBinding

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)

binding.button.setOnClickListener(this)
}

override fun onClick(v: View?) {
when (v?.id) {
R.id.button -> {
// 在当前进度上加10
binding.progressBar.progress += 10
}
}
}
}

AlertDialog

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

比如为了防止用户误删重要内容,在删除前弹出一个确认对话框:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class MainActivity : AppCompatActivity(), View.OnClickListener {
private lateinit var binding: ActivityMainBinding

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)

binding.button.setOnClickListener(this)
}

override fun onClick(v: View?) {
when (v?.id) {
R.id.button -> {
// 构建对话框
AlertDialog.Builder(this).apply {
setTitle("This is Dialog") // 设置标题
setMessage("Something important.") // 设置内容
setCancelable(false) // 能否使用Back键关闭对话框
// 设置确定按钮的点击事件
setPositiveButton("OK") { _, _ -> // 目前没有具体的处理逻辑
}
// 设置取消按钮的点击事件
setNegativeButton("Cancel") { _, _ ->
}
show() // 显示对话框
}
}
}
}

基本布局

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

image-20241016165219944

新建一个Empty Views Activity的UILayoutTest项目,并让Android Studio自动帮我们创建好Activity,Activity名和布局名都使用默认值。


LinearLayout

LinearLayout,线性布局,这个布局会将它所包含的控件在线性方向上依次排列。通过android:orientation属性指定排列方向为vertical则垂直排列,指定的是horizontal,控件就在水平方向上排列。

  • android:layout_gravity:指定控件在布局中的对齐方式。当LinearLayout的排列方向是horizontal时,只有垂直方向上的对齐方式才会生效。因为此时水平方向上的长度不固定,每添加一个控件,水平方向上的长度都会改变,因此无法指定该方向上的对齐方式,vertical时同理。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal">

    <Button
    android:id="@+id/button1"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="top"
    android:text="Button 1" />

    <Button
    android:id="@+id/button2"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="center_vertical"
    android:text="Button 2" />

    <Button
    android:id="@+id/button3"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="bottom"
    android:text="Button 3" />

    </LinearLayout>
    image-20241016172001737
  • android:layout_weight:允许使用比例的方式指定控件大小,在手机屏幕的适配性方面可以起到非常重要的作用。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal">

    <EditText
    android:id="@+id/input_message"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:layout_weight="1"
    android:hint="Type something" />

    <Button
    android:id="@+id/send"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:layout_weight="1"
    android:text="Send" />

    </LinearLayout>

    由于使用了android:layout_weight属性,此时控件的宽度
    不再由android:layout_width来决定,指定成0 dp是一种比较规范的写法。在EditText和Button里将android:layout_weight属性的值都指定为1,表示EditText和Button将在水平方向平分宽度(系统会先把LinearLayout下所有控件指定的layout_weight值相加,得到一个总值,然后每个控件所占大小的比例就是用该控件的layout_weight值除以刚才算出的总值)。

    还可以通过指定部分控件的layout_weight值来实现更好的效果:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <EditText
    android:id="@+id/input_message"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:layout_weight="1"
    android:hint="Type something" />

    <Button
    android:id="@+id/send"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="Send" />

    仅指定了EditText的android:layout_weight属性,并将Button的宽度改回了wrap_content。这表示Button的宽度仍然按照wrap_content来计算,而EditText则会占满屏幕所有的剩余空间。使用这种方式编写的界面,不仅可以适配各种屏幕,而且看起来也更加舒服


RelativeLayout

RelativeLayout,相对布局,它可以通过相对定位的方式让控件出现在布局的任何位置。也正因为如此,RelativeLayout中的属性非常多。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">

<Button
android:id="@+id/button1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_alignParentTop="true"
android:text="Button 1" />

<Button
android:id="@+id/button2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:layout_alignParentEnd="true"
android:text="Button 2" />

<Button
android:id="@+id/button3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:text="Button 3" />

<Button
android:id="@+id/button4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_alignParentBottom="true"
android:text="Button 4" />

<Button
android:id="@+id/button5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_alignParentBottom="true"
android:text="Button 5" />

</RelativeLayout>

效果如下:

image-20241016223814163

上面例子中的每个控件都是相对于父布局进行定位,如果要相对于控件进行定位,则如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">

<Button
android:id="@+id/button3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:text="Button 3" />

<Button
android:id="@+id/button1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_above="@id/button3"
android:layout_toStartOf="@id/button3"
android:text="Button 1" />

<Button
android:id="@+id/button2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_above="@id/button3"
android:layout_toEndOf="@id/button3"
android:text="Button 2" />



<Button
android:id="@+id/button4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/button3"
android:layout_toStartOf="@id/button3"
android:text="Button 4" />

<Button
android:id="@+id/button5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/button3"
android:layout_toEndOf="@id/button3"
android:text="Button 5" />

</RelativeLayout>

android:layout_above属性可以让一个控件位于另一个控件的上方,需要为这个属性指定相对控件id的引用,其他属性也是相似。当一个控件去引用另一个控件的id时,该控件一定要定义在被引用控件的后面,不然会出现找不到id的情况

效果如下:

image-20241016224713577

RelativeLayout中还有另外一组相对于控件进行定位的属性,android:layout_alignStart表示让一个控件的左边缘和另一个控件的左边缘对齐,android:layout_alignEnd表示让一个控件的右边缘和另一个控件的右边缘对齐。此外,还有android:layout_alignTopandroid:layout_alignBottom,道理都是一样的。


FrameLayout

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">

<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="This is TextView" />

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

</FrameLayout>

image-20241016225647004

可以看到,文字和按钮都位于布局的左上角。由于Button是在TextView之后添加的,因此按钮压在了文字的上面。除了这种默认效果之外,还可以使用layout_gravity属性来指定控件在布局中的对齐方式,这和LinearLayout中的用法是相似的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">

<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:text="This is TextView" />

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

</FrameLayout>

TextView在FrameLayout中居左对齐,Button在FrameLayout中居右对齐:

image-20241016230650265

总体来讲,由于定位方式的欠缺,FrameLayout的应用场景相对偏少一些。


自定义控件

常用控件和布局的继承结构:

image-20241016230815706

可以看到,所有控件都是直接或间接继承自View所有布局都是直接或间接继承自ViewGroupView是Android中最基本的一种UI组件,它可以在屏幕上绘制一块矩形区域,并能响应这块区域的各种事件。因此,我们使用的各种控件其实就是在View的基础上又添加了各自特有的功能。而ViewGroup则是一种特殊的View,它可以包含很多子View和子ViewGroup,是一个用于放置控件和布局的容器

当系统自带的控件并不能满足我们的需求时,可以利用上面的继承结构来创建自定义控件。新创建一个Empty Views Activity的UICustomViews项目。


引入布局

创建一个包含左右两个按钮用于返回、编辑操作的标题栏布局。一般我们的程序中可能有很多个Activity需要这样的标题栏,如果在每个Activity的布局中都编写一遍同样的标题栏代码,会导致代码冗余。可以通过引入布局的方式提取共同代码。

先去src/main/res/values/themes.xml,将默认的主题Theme.Material3.DayNight.NoActionBar改为Theme.AppCompat.Light.NoActionBar,否则后续的布局会有部分内容被主题的配置所覆盖:

1
2
3
4
5
6
7
8
9
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Base.Theme.UICustomViews" parent="Theme.AppCompat.Light.NoActionBar">
<!-- Customize your light theme here. -->
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
</style>

<style name="Theme.UICustomViews" parent="Base.Theme.UICustomViews" />
</resources>

在res目录下,新建一个drawable-xxhdpi目录,将title_bg.pngback_bg.pngedit_bg.png(资源下载地址见
前言)(第一行代码——Android(第3版) (ituring.com.cn)界面的随书下载中的随书资源.zip下载后,就能得到相应的资源)放入,分别用于作为标题栏、返回按钮和编辑按钮的背景

在layout目录下,新建一个title.xml布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<?xml version="1.0" encoding="utf-8"?>
<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:layout_margin这个属性,它可以指定控件在上下左右方向上的间距。当然也可以使用android:layout_marginStartandroid:layout_marginTop等属性来单独指定控件在某个方向上的间距。

然后,通过在activity_main.xml使用<include>标签引入布局,就能使用上该标题栏:

1
2
3
4
5
6
7
8
<?xml version="1.0" encoding="utf-8"?>
<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>

创建自定义控件

如果布局中有一些控件要求能够响应事件,还是需要在每个Activity中为这些控件单独编写一次事件注册的代码。比如标题栏中的返回按钮,其实不管是在哪一个Activity中,这个按钮的功能都是相同的,即销毁当前Activity。而如果在每一个Activity中都需要重新注册一遍返回按钮的点击事件,也会导致代码冗余,这种情况最好是使用自定义控件的方式来解决。

创建TitileLayout继承LinearLayout,成为自定义的标题栏控件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class TitleLayout(context: Context, attrs: AttributeSet) : LinearLayout(context, attrs) {
init {
/**
* 对标题栏布局进行动态加载。from方法能构建一个LayoutInflater对象,
* 然后调用inflate方法可以动态加载一个布局文件,第二个参数是给加载的布局文件添加一个父布局
*/
val binding = TitleBinding.inflate(LayoutInflater.from(context), this, true)
binding.titleBack.setOnClickListener {
// TitleLayout中接收的context参数实际上是一个Activity的实例
// Kotlin中的类型强制转换使用的关键字是as
val activity = context as Activity
activity.finish()
}
binding.titleEdit.setOnClickListener {
Toast.makeText(context, "You clicked Edit button", Toast.LENGTH_SHORT).show()
}
}
}

在TitleLayout的主构造函数中声明了ContextAttributeSet这两个参数,在布局中引入TitleLayout控件时就会调用这个构造函数。然后在init结构体中需要对标题栏布局进行动态加载,这就要借助LayoutInflater来实现。

现在自定义控件已经创建好了,接下来我们需要在布局文件中添加这个自定义控件,修改activity_main.xml中的代码:

1
2
3
4
5
6
7
8
9
10
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">

<!-- 添加自定义控件 -->
<com.hunter.uicustomviews.TitleLayout
android:layout_width="match_parent"
android:layout_height="wrap_content" />

</LinearLayout>

重新运行程序,你会发现此时的效果和使用引入布局方式的效果是一样的。


最常用且最难用的控件:ListView

由于手机屏幕空间比较有限,能够一次性在屏幕上显示的内容并不多,当程序中有大量的数据需要展示的时候,就可以借助ListView来实现。ListView允许用户通过手指上下滑动的方式将屏幕外的数据滚动到屏幕内,同时屏幕上原有的数据会滚动出屏幕


ListView的简单用法

新建一个名为ListViewTest的Empty Views Activity项目,修改activity_main.xml,添加ListView控件:

1
2
3
4
5
6
7
8
9
10
11
<?xml version="1.0" encoding="utf-8"?>
<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>

再修改MainActivity:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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)
val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// ArrayAdapter 将集合数据通过适配器传递给ListView
// 将simple_list_item_1作为ListView子项布局的id,这是一个内置的布局文件,里面只有一个TextView,可以简单地显示一段文本
val adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, data)
binding.listView.adapter = adapter
}
}

即可通过滚动的方式查看屏幕外的数据。


定制ListView界面


Jetpack Compose

Jetpack Compose 是一种声明式 UI 工具包,与传统的基于 XML 的布局系统不同,它允许开发者使用 Kotlin 代码直接构建 UI


Android UI Toolkit

在代码中,布局由ViewGroup对象表示,它是控制子节点在屏幕上的位置和行为的容器。小部件由View对象表示,它显示单个UI组件,比如按钮和文本框

image-20240822214736405

View 表示 UI 组件的基本构建块。它在屏幕上占据一个矩形区域,在其中绘制特定的 UI 组件,如按钮或文本字段。**View也支持交互和事件专用视图通常会公开一组用于管理交互式事件的特定事件监听器**。


Composable 函数

使用XML构建UI的问题:

  1. UI不具备可扩展性
  2. 难以创建自定义视图
  3. 状态的所有权通常分散在多个所有者之间。

所有这些问题的根本原因在于 Android View 构建其状态和绘制自身及其子类的方式。为了避免这些问题,你需要重新开始,使用不同的基本构建块。在 Jetpack Compose 中,这个构建块被称为composable 函数

具有@Composable注解的函数,几乎就是创建新的小部件所需的全部内容。在Compose的世界里,这些小部件称为Composables

1
2
3
4
5
6
7
8
9
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
// 渲染一个文本视图
Text(
text = "Hello $name!",
// modifier 参数是可选的,用于自定义视图的外观和行为。
modifier = modifier
)
}

注解类通过向代码附加元数据来简化代码。通过使用注解,可以向类添加行为,并生成有用的代码,无需编写大量样板文件。

在Compose中,调用在屏幕上显示某些内容的函数称为发出UI。要发出消息,就需要调用Text函数。Text也是一个Composable函数,它是构成 Jetpack Compose 的默认Composable函数之一。需要注意的一点是,Composable函数只能从其他Composable函数中调用——如果你尝试删除@Composable,你会收到一个错误,阻止你使用 Text()

image-20240822222312278

Composable注解类有三个自己的注解:

1
2
3
4
5
6
7
8
9
@MustBeDocumented
@Retention(AnnotationRetention.BINARY)
@Target(
AnnotationTarget.FUNCTION,
AnnotationTarget.TYPE,
AnnotationTarget.TYPE_PARAMETER,
AnnotationTarget.PROPERTY_GETTER
)
annotation class Composable
  • @MustBeDocumented:表示注解是公共API的一部分,应该包含在生成的文档中。
  • Retention:告诉编译器注解应该存在多长事件。使用AnnotationRetention.BINARY,处理器将在编译过程中,将代码存储在二进制文件中。
  • @Target:描述应用类型的上下文。@Composable可以应用于类型、参数、函数和属性。

显示Composables

可以使用Activity或Fragment作为起点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Greeting("World")
}
}
}

@Composable
fun Greeting(name: String) {
Text(
text = "Hello $name!"
)
}

可以使用**content块**将composableactivity连接。无需使用XML文件定义布局内容,只需要调用composable函数即可。

1
2
3
4
public fun ComponentActivity.setContent(
parent: CompositionContext? = null,
content: @Composable () -> Unit
) { ... }

可以看到,setContent()ComponentActivity扩展函数扩展函数能在不更改源代码的情况下向类添加其他功能。这意味着可以在任何ComponentActivity及其子类上使用setContent()

调用setContent()会将名为content的composable 函数设置为根视图,可以看作一个容器,可以在其中添加任意数量的元素,在此容器内调用其余的composable函数。


基本的Composable函数

Text

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package androidx.compose.material

@Composable
fun Text(
text: String,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
textAlign: TextAlign? = null,
lineHeight: TextUnit = TextUnit.Unspecified,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current
)

例如创建一个如下的Composable函数:

1
2
3
4
5
6
7
8
9
10
@Composable
fun MyText() {
Text(
text = stringResource(id = R.string.jetpack_compose), // jetpack_compose是app/src/main/res/values/strings.xml中定义的字符串
fontStyle = FontStyle.Italic,
color = colorResource(id = R.color.colorPrimary), // colorPrimary是app/src/main/res/values/colors.xml中定义的颜色
fontSize = 30.sp,
fontWeight = FontWeight.Bold
)
}

预览

在Composable函数上使用@Preview注解即可,不通过运行应用程序就能看到修改效果。

image-20240823161953571

使用预览,Composable函数需要满足如下任一条件:

  1. 没有参数。
  2. 所有参数都有默认参数。
  3. 提供一个@PreviewParameter以及一个特殊工厂,提供想要在UI上绘制的参数。

TextField

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package androidx.compose.material

@Composable
fun TextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
readOnly: Boolean = false,
textStyle: TextStyle = LocalTextStyle.current,
label: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
isError: Boolean = false,
visualTransformation: VisualTransformation = VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions(),
singleLine: Boolean = false,
maxLines: Int = Int.MAX_VALUE,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape =
MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize),
colors: TextFieldColors = TextFieldDefaults.textFieldColors()
)

TextField是用于输入字段的组件。TextField允许用户输入文本,并且如果TextField重新组合,输入的文本也不能消失或变更。要使TextField正常工作,必须提供一个重构期间不会更改的值,即状态值使用mutableStateOf()函数,可以将一个空String包装到一个状态容器中,将使用它来存储和显示输入字段中的文本

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Composable
fun MyTextField() {
val textValue = remember {
mutableStateOf("")
}
TextField(
// 显示TextField内的当前文本
value = textValue.value,
// 每次输入新内容时,触发的回调。提供新的 TextFieldValue,以便更新显示的文本。
onValueChange = { textValue.value = it },
// 标签:容器内显示的标签。当用户输入文本时,标签将在书写光标上方动态显示。
label = {}
)
}
  • remember()能在改变状态时,保留设置的内容。如果不使用remember(),在改变状态(每次用户点击键盘上的键,内部状态都会发生变化)时,会丢失设置,并使用默认值(空字符串)。
image-20240825173101612 image-20240825173029465
使用 OutlinedTextField 添加Email字段

OutlinedTextField 是一个样式化的 TextField,它使用一个特殊的内部函数来绘制和动画化字段周围的边框和描述文本。

要添加提示或 Compose 中已知的标签,使用 label 属性并传入另一个composable函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Composable
@Preview(showBackground = true)
fun MyTextField() {
val textValue = remember {
mutableStateOf("")
}

val primaryColor = colorResource(id = R.color.colorPrimary)

OutlinedTextField(
// 显示TextField内的当前文本
value = textValue.value,
// 每次输入新内容时,触发的回调。提供新的 TextFieldValue,以便更新显示的文本。
onValueChange = { textValue.value = it },
// 标签:容器内显示的标签。当用户输入文本时,标签将在书写光标上方动态显示。
label = {
// 通过Text函数向用户提供输入数据的提示
Text(
text = stringResource(id = R.string.email)
)
},
colors = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = primaryColor, // 边框颜色
focusedLabelColor = primaryColor, // 处于聚焦状态的标签颜色
cursorColor = primaryColor // 光标颜色
),
// 显示一个适合输入电子邮件地址的虚拟键盘,通常会包含一个方便输入“@”符号和“.”符号的布局
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Email)
)
}

效果:

image-20240825172421221

Button

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package androidx.compose.material

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun Button(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
elevation: ButtonElevation? = ButtonDefaults.elevation(),
shape: Shape = MaterialTheme.shapes.small,
border: BorderStroke? = null,
colors: ButtonColors = ButtonDefaults.buttonColors(),
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
content: @Composable RowScope.() -> Unit
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Composable
fun MyButton() {
Button(
// 空的lambda表达式,点击按钮时不执行操作,但会保持onClick的启用状态
onClick = {},
// 设置按钮背景色
colors = ButtonDefaults.buttonColors(backgroundColor = colorResource(id = R.color.colorPrimary)),
// 设置按钮边框,每个BorderStroke都必须定义一个宽度和颜色
border = BorderStroke(1.dp, color = colorResource(id = R.color.colorPrimaryDark))
) {
Text(
text = stringResource(id = R.string.button_text),
color = Color.White
)
}
}

RadioButton

目前,Jetpack Compose 没有单选组的实现,必须自己创建自定义组。

1
2
3
4
5
6
7
8
9
@Composable
fun RadioButton(
selected: Boolean,
onClick: (() -> Unit)?,
modifier: Modifier = Modifier,
enabled: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
colors: RadioButtonColors = RadioButtonDefaults.colors()
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Composable
fun MyRadioGroup() {
val radioButtons = listOf(0, 1, 2) // 单选按钮的索引
val selectedButton = remember { // remember 用于在组合过程中记住状态,确保在重组时保持状态不变。
mutableStateOf(radioButtons.first()) // mutableStateOf 用于创建一个可变的状态对象,默认选择第一个按钮
}
Column { // 一个垂直布局容器
radioButtons.forEach { index -> // 遍历 radioButtons 列表中的每个索引,执行如下操作
val isSelected = index == selectedButton.value
// 设置不同状态的颜色
val colors = RadioButtonDefaults.colors(
selectedColor = colorResource(id = R.color.colorPrimary),
unselectedColor = colorResource(id = R.color.colorPrimaryDark),
disabledColor = Color.LightGray
)
RadioButton( // 向Column添加一个按钮
selected = isSelected,
// 当按钮被点击时,更新 selectedButton 的值为当前按钮的索引,表示用户选择了这个按钮。
onClick = { selectedButton.value = index },
colors = colors
)
}
}
}

FloatingActionButton

浮动操作按钮之所以被称为“浮动”,是因为它们具有更高的高度,使其位于所有内容之上。它们被用来将应用程序的主要操作放在用户触手可及的地方。

1
2
3
4
5
6
7
8
9
10
11
12
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun FloatingActionButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50)),
backgroundColor: Color = MaterialTheme.colors.secondary,
contentColor: Color = contentColorFor(backgroundColor),
elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(),
content: @Composable () -> Unit
)
1
2
3
4
5
6
7
8
9
10
11
12
@Composable
fun MyFloatingActionButton() {
FloatingActionButton(
onClick = {}, // 空的lambda表达式,点击按钮时不执行操作,但会保持onClick的启用状态
backgroundColor = colorResource(id = R.color.colorPrimary),
contentColor = Color.White,
content = {
// contentDescription 用于无障碍支持(如屏幕阅读器),帮助视觉障碍用户理解按钮的功能。
Icon(Icons.Filled.Favorite, contentDescription = "Test FAB")
}
)
}

大多数情况下,需要对content添加Icon()

1
2
3
4
5
6
7
8
@Composable
@NonRestartableComposable
fun Icon(
imageVector: ImageVector,
contentDescription: String?,
modifier: Modifier = Modifier,
tint: Color = LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
)

Icon的主要特点是允许设置ImageVector类型的向量用作图标。


其他Button
  • IconButton:类似FloatingActionButton,但它没有浮动。通常用于导航。
  • OutlinedButton:与OutlinedTextField类似,提供额外的功能,如边框。
  • IconToggleButton:具有两种图标状态,可以打开和关闭它们。
  • TextButton:最常见于卡片和对话框中,可以执行不太明显的操作。

Progress Bars

当执行长时间的操作(例如从服务器或数据库获取数据)时,最好显示进度条。进度条通过显示动画来减少等待时间过长的感觉,并让用户感觉到正在发生某些事情。


CircularProgressIndicator
1
2
3
4
5
6
7
8
@Composable
fun CircularProgressIndicator(
/*@FloatRange(from = 0.0, to = 1.0)*/
progress: Float,
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colors.primary,
strokeWidth: Dp = ProgressIndicatorDefaults.StrokeWidth
)

如果您不设置进度progress,进度条将运行无限旋转动画。


LinearProgressIndicator
1
2
3
4
5
6
7
8
@Composable
fun LinearProgressIndicator(
/*@FloatRange(from = 0.0, to = 1.0)*/
progress: Float,
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colors.primary,
backgroundColor: Color = color.copy(alpha = IndicatorBackgroundOpacity)
)

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Composable
fun ProgressIndicatorScreen() {

Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator( // 旋转动画进度条
color = colorResource(id = R.color.colorPrimary),
strokeWidth = 5.dp
)
LinearProgressIndicator(progress = 0.5f) // 线性进度条,进度设置为0.5
}

BackButtonHandler {
JetFundamentalsRouter.navigateTo(Screen.Navigation)
}
}

AlertDialog

对话框用于提醒用户有关操作的信息,或请求确认。例如,您可以使用对话框来确认用户是否要删除某个项目、请求他们对应用程序进行评分等。使用对话框最重要的部分是处理确定何时显示或关闭该对话框的状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Composable
fun AlertDialog(
onDismissRequest: () -> Unit,
confirmButton: @Composable () -> Unit,
modifier: Modifier = Modifier,
dismissButton: @Composable (() -> Unit)? = null,
title: @Composable (() -> Unit)? = null,
text: @Composable (() -> Unit)? = null,
shape: Shape = MaterialTheme.shapes.medium,
backgroundColor: Color = MaterialTheme.colors.surface,
contentColor: Color = contentColorFor(backgroundColor),
properties: DialogProperties = DialogProperties()
)
  • onDismissRequest:回调函数,当对话框被请求关闭时执行(点击对话框外部、按下返回键
  • confirmButton:确认操作
  • dismissButton:关闭操作

Layout 布局

Linear Layouts

LinearLayout 的特征是将其子项定位在线性流中。此流称为方向,可以是水平或垂直的。在 Jetpack Compose 中,有两个不同的可组合函数可以替换 LinearLayout,每个方向一个。

Row 行
1
2
3
4
5
6
7
@Composable
inline fun Row(
modifier: Modifier = Modifier,
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
verticalAlignment: Alignment.Vertical = Alignment.Top,
content: @Composable RowScope.() -> Unit
)
  • horizontal Arrangement包括:
    • SpaceBetween:为每个子对象放置相等的间距,不计算第一个子对象之前或最后一个子对象之后的间距
    • SpaceEvenly:为每个子对象放置相等的间距,包括起始和结束间距
    • SpaceAround:和SpaceEvenly一样放置子项,但连续子项之间的间距减少一半
    • CenterStartEnd:将子项置于中心、开头、末尾。子项之间没有空格
  • vertical Alignment包括:
    • Top:将子项与父项的顶部对齐。
    • CenterVertically:将子项垂直对齐父项的中心。
    • Bottom:将子项与父项的底部对齐。

在 Row 中定位子项的最后一种方法是使用权重。要添加权重,需要使用特殊方式从 Compose 访问 weight() 修饰符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Composable
fun MyRow() {
Row(
verticalAlignment = Alignment.CenterVertically, // 垂直居中
horizontalArrangement = Arrangement.SpaceEvenly, // 水平等距离排列
modifier = Modifier.fillMaxSize() // 布局填满屏幕。如果不填充屏幕大小,排列和对齐就无关紧要,所有项目都会一个接一个地放在屏幕的左上角。
) {
THREE_ELEMENT_LIST.forEach { textResId ->
Text(
text = stringResource(id = textResId),
fontSize = 18.sp
)
}
}
}

Column 列
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Composable
fun MyColumn() {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.fillMaxSize()
) {
THREE_ELEMENT_LIST.forEach { textResId ->
Text(
text = stringResource(id = textResId),
fontSize = 22.sp
)
}
}
}

Box

Box用于相对于父组件的边缘显示子组件,并允许叠放子组件。当需要在特定位置显示元素或希望显示重叠元素(如对话框)时,非常有用。

1
2
3
4
5
6
7
@Composable
inline fun Box(
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
propagateMinConstraints: Boolean = false,
content: @Composable BoxScope.() -> Unit
)
  • contentAlignment:用于修改子组件的外观和行为。如果想在每个子项之间有不同的Alignment,需要在子项上使用Modifier.align()设置Alignment。
    • TopStart
    • TopCenter
    • TopEnd
    • CenterStart
    • Center
    • CenterEnd
    • BottomStart
    • BottomCenter
    • BottomEnd
  • propagateMinConstraints:是否传递最小约束并将其用于内容。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Composable
fun MyBox(
modifier: Modifier = Modifier, // 用于修改组件的外观和行为
contentModifier: Modifier = Modifier // 用于修改子组件的外观和行为
) {
Box(modifier = modifier.fillMaxSize()) {
Text(
text = stringResource(id = R.string.first),
fontSize = 22.sp,
modifier = contentModifier.align(Alignment.TopStart)
)

Text(
text = stringResource(id = R.string.second),
fontSize = 22.sp,
modifier = contentModifier.align(Alignment.Center)
)

Text(
text = stringResource(id = R.string.third),
fontSize = 22.sp,
modifier = contentModifier.align(Alignment.BottomEnd)
)
}
}

当 Box 中有多个子项时,它们的渲染顺序与你将它们放置在 Box 中的顺序相同


Surface

Surface 的独特之处在于它一次只能容纳一个子对象,但它为子对象的内容提供了许多样式处理,例如高度、边框等。

1
2
3
4
5
6
7
8
9
10
@Composable
fun Surface(
modifier: Modifier = Modifier,
shape: Shape = RectangleShape, // 定义Surface的形状及其阴影。
color: Color = MaterialTheme.colors.surface,
contentColor: Color = contentColorFor(color),
border: BorderStroke? = null,
elevation: Dp = 0.dp, // 设置标高并绘制适当的阴影。
content: @Composable () -> Unit
)

使用 Surface 的最常见方式是作为组件的根布局。由于它只能容纳一个 child,因此该 child 通常是定位其余元素的另一个布局。**Surface() 不处理定位,它的子项处理**。

1
2
3
4
5
6
7
8
9
10
11
12
@Composable
fun MySurface(modifier: Modifier) {
Surface(
modifier = modifier.size(100.dp), // 设置高宽
color = Color.LightGray,
contentColor = colorResource(id = R.color.colorPrimary),
elevation = 1.dp, // 设置标高,将 Surface 升高到其他元素之上
border = BorderStroke(1.dp, Color.Black)
) {
MyColumn() // 自定义的 Column
}
}

有一种名为 Card 的常用自定义 Surface 实现。一个Card有完全相同的五个用途,只能容纳一个子组件。Card 和 Surface 之间的唯一区别是其默认参数。Card 具有预定义的标高,并使用带圆角的 Material 主题形状。


Scaffold

可以使用Scaffold来实现一个可视化布局,该布局遵循标准 Material Design 结构。它结合了几种不同的组件来构建整个屏幕。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
@Composable
fun MyScaffold() {
// 这种状态管理允许控制 Scaffold 的行为,例如打开或关闭抽屉(Drawer)或显示 SnackBar。
val scaffoldState: ScaffoldState = rememberScaffoldState()
// 该作用域可以用于启动协程,通常用于执行异步任务,例如在抽屉打开时执行某些操作
val scope: CoroutineScope = rememberCoroutineScope()

Scaffold(
scaffoldState = scaffoldState,
contentColor = colorResource(id = R.color.colorPrimary),
content = { paddingValues -> // paddingValues 参数的使用确保内容不会被应用程序栏遮挡
MyRow(modifier = Modifier.padding(paddingValues))
},
// 定义顶部应用栏
topBar = { MyTopAppBar(scaffoldState = scaffoldState, scope = scope) },
// 定义底部应用栏
bottomBar = { MyBottomAppBar() },
// 定义抽屉内容
drawerContent = { MyColumn() }
)
}

@Composable
fun MyTopAppBar(
scaffoldState: ScaffoldState, // 管理 Scaffold 的状态,包括抽屉的状态。
scope: CoroutineScope // 用于启动协程的作用域,允许在按钮点击时执行异步操作。
) {
val drawerState = scaffoldState.drawerState // 以便后续操作可以控制抽屉的打开与关闭状态。

TopAppBar( // 顶部应用栏,通常用于显示应用程序的标题和导航图标。
navigationIcon = { // 定义顶部应用栏的导航图标部分
IconButton( // 创建一个图标按钮,用户可以点击它来打开或关闭抽屉。
onClick = {
scope.launch { // 启动一个协程
if (drawerState.isClosed)
drawerState.open()
else
drawerState.close()
}
},
content = {
Icon( // 创建一个图标表示菜单
imageVector = Icons.Default.Menu,
contentDescription = stringResource(id = R.string.menu),
tint = Color.White
)
}
)
},
title = { // 顶部应用栏的标题
Text(
text = stringResource(id = R.string.app_name),
color = Color.White
)
},
backgroundColor = colorResource(id = R.color.colorPrimary)
)
}

@Composable
fun MyBottomAppBar() {
BottomAppBar(
content = {},
backgroundColor = colorResource(id = R.color.colorPrimary)
)
}
image-20240827195217341

构建Lists

Scrolling Modifier

在Jetpack Compose中,可以将Column支持滚动的modifier一起使用。

实现一个简单的滚动Column:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Composable
fun MyScrollingScreen(modifier: Modifier = Modifier) {
Column(
// 根据滚动方向创建滚动状态,并保留滚动位置
modifier = modifier.verticalScroll(rememberScrollState())
) {
BookImage(
imageResId = R.drawable.advanced_architecture_android,
contentDescriptionResId = R.string.advanced_architecture_android
)
BookImage(
imageResId = R.drawable.kotlin_aprentice,
contentDescriptionResId = R.string.kotlin_apprentice
)
BookImage(
imageResId = R.drawable.kotlin_coroutines,
contentDescriptionResId = R.string.kotlin_coroutines
)
}
}

@Composable
fun BookImage(@DrawableRes imageResId: Int, @StringRes contentDescriptionResId: Int) {
Image(
bitmap = ImageBitmap.imageResource(imageResId),
contentDescription = stringResource(contentDescriptionResId),
contentScale = ContentScale.FillBounds,
modifier = Modifier.size(476.dp, 616.dp)
)
}
  • 垂直滚动:

    1
    2
    3
    4
    Column(
    // 根据滚动方向创建滚动状态,并保留滚动位置
    modifier = modifier.verticalScroll(rememberScrollState())
    )
  • 水平滚动:

    1
    2
    3
    4
    Row(
    // 根据滚动方向创建滚动状态,并保留滚动位置
    modifier = modifier.horizontalScroll(rememberScrollState())
    )

当拥有静态内容时,可滚动的列和行非常有用。但是,对于动态的数据收集,它们不是一个好主意。这是因为可滚动可组合项会急切地组合和渲染其中的所有元素,当您要显示大量元素时,这可能是一个繁重的操作


LazyColumn 和 LazyRow

当使用 LazyColumn 或 LazyRow 时,框架仅会组合能够在屏幕上显示的元素。当你滚动时,会组合新的元素,并且旧的元素会被销毁。当你向回滚动时,旧的元素会重新组合。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Composable
fun LazyColumn(
modifier: Modifier = Modifier,
state: LazyListState = rememberLazyListState(),
contentPadding: PaddingValues = PaddingValues(0.dp),
reverseLayout: Boolean = false,
verticalArrangement: Arrangement.Vertical =
if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
userScrollEnabled: Boolean = true,
content: LazyListScope.() -> Unit
)

其中最重要的参数content,它表示列表中的内容。此内容属于 LazyListScope 类型,而不是通常的 Composable 类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@LazyScopeMarker
@JvmDefaultWithCompatibility
interface LazyListScope {
fun item(
key: Any? = null,
contentType: Any? = null,
content: @Composable LazyItemScope.() -> Unit
) {
error("The method is not implemented")
}

fun items(
count: Int,
key: ((index: Int) -> Any)? = null,
contentType: (index: Int) -> Any? = { null },
itemContent: @Composable LazyItemScope.(index: Int) -> Unit
) {
error("The method is not implemented")
}

@ExperimentalFoundationApi
fun stickyHeader(
key: Any? = null,
contentType: Any? = null,
content: @Composable LazyItemScope.() -> Unit
)
}
  • item()允许向列表中添加新的组合项。每次都可以使用不同的组合项类型
  • items()允许设置一个 包含希望在每个列表项中使用的数据 的列表。一旦设置了数据,还需要提供一个 itemContent,它是一个用于显示列表中每个项的可组合项。
  • stickyHeader()允许设置一个头部可组合项,它会一直显示在列表的顶部,即使你向下滚动查看新项目时也是如此。

示例:

image-20240827201534145

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
private val bookInfo = listOf(
BookCategory(
R.string.android,
listOf(
R.drawable.android_aprentice,
R.drawable.saving_data_android,
R.drawable.advanced_architecture_android
)
),
BookCategory(
R.string.kotlin,
listOf(
R.drawable.kotlin_coroutines,
R.drawable.kotlin_aprentice
)
)
)

@Composable
fun ListScreen() {
MyList()

BackButtonHandler {
JetFundamentalsRouter.navigateTo(Screen.Navigation)
}
}

@Composable
fun MyList() {
LazyColumn { // 垂直滚动的列表
items(bookInfo) {
// 将items中的每个对象转换为可组合的函数。
item ->
ListItem(bookCategory = item)
}
}
}

@Composable
fun ListItem(bookCategory: BookCategory, modifier: Modifier = Modifier) {
Column( // 垂直排列
modifier = Modifier.padding(8.dp) // 在边框附近添加8.dp空间
) {
Text(
text = stringResource(id = bookCategory.categoryResourceId), // 类别标题
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
color = colorResource(id = R.color.colorPrimary)
)
Spacer(modifier = modifier.height(8.dp)) // 在类名和其余内容之间添加一些空格,在水平书籍列表的顶部显示类别名称。

LazyRow { // 水平滚动的列表,书籍图像
items(bookCategory.bookImageResources) { item ->
BookImage(item)
}
}
}
}

data class BookCategory(@StringRes val categoryResourceId: Int, val bookImageResources: List<Int>)

@Composable
fun BookImage(imageResource: Int) {
Image( // 显示图像
painter = painterResource(id = imageResource), // 通过id检索目标资源
contentDescription = stringResource(id = R.string.book_image),
modifier = Modifier.size(170.dp, 200.dp), // 显示的宽高
contentScale = ContentScale.Fit // 使图像适应指定的宽高
)
}

Compose中的Grid

如图,该网格包含10个元素,分为3列。最后一行的两个元素被标记为不可见,从而达到视觉上10个元素的效果。

image-20240828134043557
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
private val filledItems = listOf(
Icons.Filled.Check,
Icons.Filled.Close,
Icons.Filled.ThumbUp,
Icons.Filled.Build,
Icons.Filled.Delete,
Icons.Filled.Home,
Icons.Filled.Close,
Icons.Filled.ThumbUp,
Icons.Filled.Build,
Icons.Filled.ThumbUp,
)

@Composable
fun GridScreen() {
LazyVerticalGrid( // 创建垂直网格的容器
columns = GridCells.Fixed(3), // 3列
modifier = Modifier.fillMaxSize(), // 网格占据屏幕中的所有可用空间
content = {
items(filledItems.size) { index ->
GridIcon(iconResource = filledItems[index]) // 将图标作为参数传入
}
}
)

BackButtonHandler {
JetFundamentalsRouter.navigateTo(Screen.Navigation)
}
}

@Composable
fun GridIcon(iconResource: ImageVector) {
Icon(
imageVector = iconResource,
contentDescription = stringResource(id = R.string.grid_icon),
modifier = Modifier
.size(80.dp)
.padding(20.dp),
tint = colorResource(id = R.color.colorPrimary) // 为图标指定颜色
)
}

搭建应用Jet Notes

自下而上的构建方法

使用 Jetpack Compose 构建应用时,明智的做法是从较小的可组合项开始,然后逐步构建到设计中。从最小的组件构建应用程序可以从一开始就解耦和复用代码。

image-20240828145326151


Note

先构建Note组件,方便后续复用。

image-20240828145440911

image-20240828163426316

image-20240828163358139

C:\Users\Hunter\AndroidStudioProjects\jet-materials\08-applying-material-design-to-compose\projects\starter

是这个笔记App最终的代码。