此 Codelab 是“Android Kotlin 基础知识”课程的一部分。如果您按顺序学习这些 Codelab,您将会充分发掘此课程的价值。“Android Kotlin 基础知识”Codelab 着陆页列出了所有课程 Codelab。
简介
大多数应用都有需要保存的数据,即使在用户关闭应用后仍要继续留存数据。例如,应用可能会存储歌曲播放列表、游戏道具目录、支出和收入记录、星座目录或个人睡眠数据历史记录。在大多数此类情况下,您都会使用数据库来存储这种持久性数据。
Room
是一个数据库,是 Android Jetpack 的一部分。在后台,Room library
是 SQLite 数据库之上的一个抽象层。SQLite 使用一种专门的语言 (SQL) 来执行数据库操作。Room 并不直接使用 SQLite,而是负责简化数据库设置和配置以及与数据库交互方面的琐碎工作。此外,它还让应用可以使用普通的函数调用与数据库进行交互。Room 还具有查询语法,可用于搜索数据。
下图展示了 Room
数据库如何融入本课程中推荐的总体架构。
您应当已掌握的内容
您应熟悉以下内容:
- 为 Android 应用构建基本界面
- 使用 activity、fragment 和视图。
- 在 fragment 之间导航,并使用 Safe Args(一个 Gradle 插件)在 fragment 之间传递数据。
- 视图模型、视图模型工厂、
LiveData
及其观察者。此课程中的其他 Codelab 介绍了这些架构组件主题。 - 并发方面的基础知识。如需简要了解,请参阅“多线程和回调入门指南”。
- SQL 数据库和 SQLite 语言方面的基础知识。如需大致了解或回顾,请参阅 SQLite 入门指南。
学习内容
- 如何创建
Room
数据库并与之交互以保留数据。 - 如何创建用于在数据库中定义表的数据类。
- 如何使用数据访问对象 (DAO) 将 Kotlin 函数映射到 SQL 查询。
- 如何测试数据库是否在正常运行。
实践内容
- 创建一个包含每晚睡眠数据接口的
Room
数据库。 - 使用提供的测试来测试该数据库。
在此 Codelab 中,您将为一款用于跟踪睡眠质量的应用构建数据库部分。该应用使用数据库来存储一段时间的睡眠数据。
该应用有两个屏幕,以 fragment 表示,如下图所示。
左侧所示的第一个屏幕包含用于开始和停止跟踪的按钮。这个屏幕会显示用户的所有睡眠数据。CLEAR 按钮用于永久删除应用针对用户收集的所有数据。
右侧所示的第二个屏幕用于选择睡眠质量评分。在该应用中,评分用数字表示。出于开发目的,该应用同时显示人脸图标及其对应的数字。
用户的流程如下所示:
- 用户打开该应用,并看到睡眠跟踪屏幕。
- 用户点按 START 按钮。系统会记录开始时间并显示该时间。START 按钮会停用,而 STOP 按钮会启用。
- 用户点按 STOP 按钮。系统会记录结束时间,并打开睡眠质量屏幕。
- 用户选择一个睡眠质量图标。这个屏幕会关闭,跟踪屏幕会显示睡眠结束时间和睡眠质量。STOP 按钮会停用,而 START 按钮会启用。该应用已为下一晚运行做好准备。
- 只要数据库中有数据,CLEAR 按钮就会处于启用状态。如果用户点按 CLEAR 按钮,系统会清空其所有数据,并且不予追偿,也就是说,系统不会显示“您确定吗?”这类消息。
该应用在完整架构的基础上采用简化的架构,如下所示。该应用仅使用以下组件:
- 界面控制器
- 视图模型及
LiveData
- Room 数据库
第 1 步:下载并运行初始应用
- 从 GitHub 下载 TrackMySleepQuality-Starter 应用。
- 构建并运行应用。该应用会显示
SleepTrackerFragment
fragment 的界面,但不会显示数据。按钮不会响应点按操作。
第 2 步:检查初始应用
- 查看 Gradle 文件:
- 项目 Gradle 文件:在项目级
build.gradle
文件中,注意用于指定库版本的变量。起始应用中所用的版本能够很好地协同工作,并能与此应用完美配合。当您完成此 Codelab 时,Android Studio 可能会提示您更新部分版本。是更新还是继续使用应用中的版本,完全由您决定。如果遇到奇怪的编译错误,请尝试使用最终解决方案应用所用的库版本组合。 - 模块 Gradle 文件:请注意针对所有 Android Jetpack 库(包括
Room
)提供的依赖项,以及协程的依赖项。
- 在“Project”窗口中,注意不同的软件包。该应用的结构是按功能划分的。这些软件包包含将在其中添加代码的占位符文件。
database
软件包:包含与Room
数据库相关的所有代码。sleepquality
和sleeptracker
软件包:包含每个屏幕的 fragment、视图模型和视图模型工厂。
- 查看
Util.kt
文件。此文件包含帮助显示睡眠质量数据的函数。一些代码已被注释掉,因为它们引用了您将在稍后创建的视图模型。 - 查看 androidTest 文件夹,注意
SleepDatabaseTest.kt
文件。您将使用此测试来验证数据库是否按预期运行。
在 Android 中,数据用数据类表示。这种数据通过函数调用进行访问并可能会进行修改。不过,在数据库环境中,您需要通过实体和查询来访问及修改数据。
- 实体表示要在数据库中存储的对象或概念及其属性。在应用代码中,我们需要一个用于定义表的实体类,该类的每个实例都代表相应表中的一行。实体类以映射告知 Room 它打算如何呈现数据库中的信息并与之交互。在您的应用中,实体将保存有关一晚睡眠的信息。
- 查询是从一个数据库表或多个表的组合中获取数据或信息的请求,或对数据执行操作的请求。常见的查询用于创建、读取、更新和删除实体。例如,您可以通过执行查询来读取应用记录的所有睡眠时段,并按开始时间排序。
在本地持久保留一些数据对提升应用的用户体验(与其他常见用例类似)大有裨益。通过缓存相关数据块,用户即使处于离线状态,也可以享受您的应用带来的乐趣。如果您的应用依赖于服务器,缓存支持用户在离线时修改在本地持久保留的内容。应用恢复网络连接后,这些已缓存更改可以在后台无缝同步到服务器。
Room
会完成所有艰苦的工作,包括获取可存储在 SQLite 表中的 Kotlin 数据类和实体,以及获取函数声明和 SQL 查询。
您必须将每个实体定义为带注解的数据类;并将与该实体的交互定义为带注解的接口,称为“数据访问对象”(DAO)。Room
使用这些带注解的类在数据库中创建表,以及创建针对数据库的查询。
第 1 步:创建 SleepNight 实体
在此任务中,您将一晚睡眠定义为一个带注解的表示数据库实体的数据类。
您需要记录一晚睡眠的开始时间、结束时间和质量评分。
此外,您还需要一个 ID 来唯一标识那一晚。
- 在
database
软件包中,找到并打开SleepNight.kt
文件。 - 创建
SleepNight
数据类,参数包括 ID、开始时间(以毫秒为单位)、结束时间(以毫秒为单位),以及数字形式的睡眠质量评分。
- 您必须初始化
sleepQuality
,因此请将其设置为-1
,以表明未收集到质量数据。 - 将开始时间初始化为一个已知有效的时间。建议选择以毫秒表示的当前时间。
- 您还必须初始化结束时间。将其设置为开始时间,这表示尚未记录任何结束时间。
data class SleepNight(
var nightId: Long = 0L,
val startTimeMilli: Long = System.currentTimeMillis(),
var endTimeMilli: Long = startTimeMilli,
var sleepQuality: Int = -1
)
- 在类声明的上方,为该数据类添加
@Entity
注解。此注解有多个可能的参数。默认情况下(@Entity
没有参数),表名称与类名称相同。不过,我们要使用daily_sleep_quality_table
这个有用的表名称。tableName
的此参数为可选参数,但强烈建议您使用。@Entity
还有其他几个参数,您可以参阅相关文档进行研究。
如果 Android Studio 提示,请从 androidx
库导入 Entity
和所有其他注解。
@Entity(tableName = "daily_sleep_quality_table")
data class SleepNight(...)
- 如需将
nightId
标识为主键,请为nightId
属性添加@PrimaryKey
注解。将参数autoGenerate
设置为true
,让Room
为每个实体生成 ID。这样做可以保证每晚的 ID 一定是唯一的。
@PrimaryKey(autoGenerate = true)
var nightId: Long = 0L,...
- 为其余属性添加
@ColumnInfo
注解。如下所示,使用参数自定义属性名称。
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "daily_sleep_quality_table")
data class SleepNight(
@PrimaryKey(autoGenerate = true)
var nightId: Long = 0L,
@ColumnInfo(name = "start_time_milli")
val startTimeMilli: Long = System.currentTimeMillis(),
@ColumnInfo(name = "end_time_milli")
var endTimeMilli: Long = startTimeMilli,
@ColumnInfo(name = "quality_rating")
var sleepQuality: Int = -1
)
- 构建并运行代码,以确保其没有错误。
在此任务中,您将定义一个数据访问对象 (DAO)。在 Android 中,DAO 提供了插入、删除和更新数据库的便捷方法。
使用 Room
数据库时,您需要通过在代码中定义和调用 Kotlin 函数来查询数据库。这些 Kotlin 函数会映射到 SQL 查询。您可以使用注解在 DAO 中定义这些映射,而 Room
会创建必要的代码。
可以将 DAO 视为定义用于访问数据库的自定义接口。
对于常见的数据库操作,Room
库会提供方便的注解,例如 @Insert
、@Delete
和 @Update
。对于所有其他操作,都使用 @Query
注解。您可以编写 SQLite 支持的任何查询。
另一个好处是,当您在 Android Studio 中创建查询时,编译器会检查您的 SQL 查询是否存在语法错误。
对于睡眠之夜的睡眠跟踪器数据库,您必须能够执行以下操作:
- 插入新夜晚。
- 更新现有夜晚的结束时间和质量评分。
- 根据键获取特定夜晚的数据。
- 获取所有夜晚的数据,并加以显示。
- 获取最近一晚的数据。
- 删除数据库中的所有条目。
第 1 步:创建 SleepDatabase DAO
- 在
database
软件包中,打开SleepDatabaseDao.kt
。 - 请注意,
interface
SleepDatabaseDao
带有@Dao
注解。所有 DAO 都需要使用@Dao
关键字进行注解。
@Dao
interface SleepDatabaseDao {}
- 在该接口的主体内添加
@Insert
注解。在@Insert
下,添加一个insert()
函数,该函数将Entity
类SleepNight
的实例作为其参数。
大功告成。Room
将生成在数据库中插入 SleepNight
所需的全部代码。当您从 Kotlin 代码调用 insert()
时,Room
将执行 SQL 查询以将该实体插入到数据库中。(注意:您可以随意调用该函数。)
@Insert
fun insert(night: SleepNight)
- 添加
@Update
注解以及将一个SleepNight
作为参数的update()
函数。更新的实体是与所传入实体具有相同键的实体。您可以更新该实体的部分或全部其他属性。
@Update
fun update(night: SleepNight)
其余功能没有方便使用的注解,因此您必须使用 @Query
注解并提供 SQLite 查询。
- 添加一个
@Query
注解以及一个get()
函数;该函数接受Long
key
参数,并返回可为 null 的SleepNight
。您会看到表示参数缺失的错误。
@Query
fun get(key: Long): SleepNight?
- 查询以字符串参数的形式提供给
@Query
注解。将String
参数添加到@Query
(这是一个 SQLite 查询),用于检索特定SleepNight
条目中的所有列。
- 选择
daily_sleep_quality_table
中的所有列 WHERE
语句中的nightId
匹配 :key
参数。
请注意 :key
。在查询中使用英文冒号是为了引用该函数中的参数。
("SELECT * from daily_sleep_quality_table WHERE nightId = :key")
- 再添加一个
@Query
,以及用于从daily_sleep_quality_table
中删除 (DELETE
) 所有信息的clear()
函数和 SQLite 查询。此查询不会删除表本身。
@Delete
注解会删除一项内容,您可以使用 @Delete
并提供要删除的夜晚列表。这种方法的缺点是,您需要提取或了解表中的内容。@Delete
注解非常适合用于删除特定条目,但在清除表中的所有条目方面效率较低。
@Query("DELETE FROM daily_sleep_quality_table")
fun clear()
- 添加
@Query
注解和一个getTonight()
函数。使getTonight()
返回的SleepNight
可为 null,以便函数能够处理表为空的情况。(该表在开始时是空的,在数据被清除后也是空的。)
为了从数据库中获取“今晚”的数据,可以编写一条 SQLite 查询,用于返回按 nightId
降序排列的结果列表中的第一个元素。使用 LIMIT 1
可仅返回一个元素。
@Query("SELECT * FROM daily_sleep_quality_table ORDER BY nightId DESC LIMIT 1")
fun getTonight(): SleepNight?
- 添加
@Query
注解和一个getAllNights()
函数:
- 让 SQLite 查询返回
daily_sleep_quality_table
中的所有列,并依降序排序。 - 让
getAllNights()
返回SleepNight
实体的列表作为LiveData
。Room
会为您保持更新此LiveData
,也就是说,您只需要显式获取一次数据。 - 您可能需要从
androidx.lifecycle.LiveData
导入LiveData
。
@Query("SELECT * FROM daily_sleep_quality_table ORDER BY nightId DESC")
fun getAllNights(): LiveData<List<SleepNight>>
- 尽管您不会看到任何明显的更改,但您仍应运行应用以确保其没有错误。
在此任务中,您将创建一个 Room
数据库,它将使用您在上一个任务中创建的 Entity
和 DAO。
您需要创建一个抽象数据库容器类,并为其添加 @Database
注解。这个类有一种方法,能够在数据库不存在时创建数据库的一个实例,或者返回对现有数据库的引用。
获取 Room
数据库的操作有点麻烦,因此在您开始编码之前,请按照以下常规流程操作:
- 创建一个用于执行
extends RoomDatabase
操作的public abstract
类。此类将充当数据库容器。它是抽象类,因为Room
会为您创建实现。 - 为该类添加
@Database
注解。在参数中,为数据库声明实体并设置版本号。 - 在
companion
对象内,定义一个返回SleepDatabaseDao
的抽象方法或属性。Room
将为您生成主体。 - 整个应用只需要
Room
数据库的一个实例,因此请将RoomDatabase
设为单例。 - 使用
Room
的数据库构建器,以仅在数据库不存在时创建数据库。否则,请返回现有数据库。
第 1 步:创建数据库
- 在
database
软件包中,打开SleepDatabase.kt
。 - 在该文件中,创建一个名为
SleepDatabase
的abstract
类,用于扩展RoomDatabase
。
为该类添加 @Database
注解。
@Database()
abstract class SleepDatabase : RoomDatabase() {}
- 您会看到表示实体和版本参数缺失的错误。
@Database
注解需要包含几个参数,Room
才能构建数据库。
- 提供
SleepNight
,作为entities
列表中的唯一项。 - 将
version
设置为1
。每当您更改架构时,都必须增加版本号。 - 将
exportSchema
设置为false
,这样就不会保留架构版本记录的备份。
@Database(entities = [SleepNight::class], version = 1, exportSchema = false)
- 该数据库需要知悉 DAO。在类的主体内,声明一个返回
SleepDatabaseDao
的抽象值。您可能有多个 DAO。
abstract val sleepDatabaseDao: SleepDatabaseDao
- 在这个抽象值下方,定义一个
companion
对象。伴生对象允许客户端访问用于创建或获取数据库,而无需该类进行实例化的方法。由于该类的唯一用途是提供数据库,因此没有必要对它进行实例化。
companion object {}
- 在
companion
对象内,为数据库声明一个私有的可为 null 变量INSTANCE
,并将其初始化为null
。INSTANCE
变量将在数据库创建后保留对数据库的引用。这有助于避免重复建立与数据库的连接,因为其计算开销较高。
为 INSTANCE
添加 @Volatile
注解。volatile 变量的值绝不会缓存,所有读写操作都将在主内存中完成。这有助于确保 INSTANCE
的值始终是最新的值,并且对所有执行线程都相同。也就是说,一个线程对 INSTANCE
所做的更改会立即对所有其他线程可见,并且不会出现这样的情况:比如说,两个线程都更新缓存中的同一个实体,这会造成问题。
@Volatile
private var INSTANCE: SleepDatabase? = null
- 在
INSTANCE
下,仍在companion
对象内,定义getInstance()
方法并提供数据库构建器所需的Context
参数。返回类型SleepDatabase
。您将看到一条错误,因为getInstance()
尚不会返回任何内容。
fun getInstance(context: Context): SleepDatabase {}
- 在
getInstance()
内,添加synchronized{}
代码块。传入this
,以便您可以访问上下文。
多个线程可能会同时请求数据库实例,导致产生两个数据库,而不是一个。此问题不太可能发生在这个示例应用中,但对于更复杂的应用来说,这是有可能的。通过封装代码将数据库放入 synchronized
中,意味着一次只有一个执行线程可以进入此代码块,这将确保数据库仅初始化一次。
synchronized(this) {}
- 在 synchronized 代码块内,将
INSTANCE
的当前值复制到局部变量instance
中。这是为了利用 Kotlin 的智能类型转换功能,该功能仅适用于局部变量。
var instance = INSTANCE
- 在
synchronized
代码块内,在synchronized
代码块的末尾执行return instance
操作。忽略返回值类型不匹配错误;操作完成后,永远不会返回 null。
return instance
- 在
return
语句上方添加if
语句,用于检查instance
是否为 null;如果为 null,即表示还没有数据库。
if (instance == null) {}
- 如果
instance
为null
,请使用数据库构建器获取数据库。在if
语句的主体中,调用Room.databaseBuilder
,并提供您传入的上下文、数据库类,以及数据库的名称sleep_history_database
。
instance = Room.databaseBuilder(
context.applicationContext,
SleepDatabase::class.java,
"sleep_history_database")
Android Studio 将生成类型不匹配错误。如需消除此错误,您必须在后续步骤中添加迁移策略和 build()
。
- 将所需的迁移策略添加到构建器中。使用
.fallbackToDestructiveMigration()
。
通常,您必须为迁移对象提供在架构发生更改时使用的迁移策略。迁移对象是发挥以下作用的对象:定义如何获取旧架构的所有行并将其转换为新架构中的行,使数据不会丢失。迁移不在此 Codelab 的范围内。一种简单的解决办法是销毁并重新构建数据库,这意味着数据会丢失。
.fallbackToDestructiveMigration()
- 最后,调用
.build()
。这样做应该就会消除 Android Studio 错误。
.build()
- 在
if
语句中,将INSTANCE = instance
指定为最后一步。
INSTANCE = instance
- 您的最终代码应如下所示:
@Database(entities = [SleepNight::class], version = 1, exportSchema = false)
abstract class SleepDatabase : RoomDatabase() {
abstract val sleepDatabaseDao: SleepDatabaseDao
companion object {
@Volatile
private var INSTANCE: SleepDatabase? = null
fun getInstance(context: Context): SleepDatabase {
synchronized(this) {
var instance = INSTANCE
if (instance == null) {
instance = Room.databaseBuilder(
context.applicationContext,
SleepDatabase::class.java,
"sleep_history_database"
)
.fallbackToDestructiveMigration()
.build()
INSTANCE = instance
}
return instance
}
}
}
}
- 构建并运行您的代码。
现在,您已拥有使用 Room
数据库所需的全部构建块。这些代码会编译并运行,但您无法判断它们是否确实能正常运行。因此,不妨在此时添加一些基本测试。
第 2 步:测试 SleepDatabase
在此步骤中,您将运行提供的测试,以验证您的数据库是否正常运行。这样一来,您可以在确保数据库正常运行之后再基于其进行构建。提供的测试为基本测试。对于生产应用,您需要练习所有 DAO 中的所有函数和查询。
起始应用包含 androidTest 文件夹。此 androidTest 文件夹包含的单元测试涉及 Android 插桩;这个华丽的术语表示测试需要用到 Android 框架,因此您需要在实体或虚拟设备上运行测试。当然,您还可以创建并运行不涉及 Android 框架的纯单元测试。
- 在 Android Studio 的 androidTest 文件夹中,打开 SleepDatabaseTest 文件。
- 如需取消注释这些代码,请选择所有带注释的代码,然后按
Cmd+/
或Control+/
键盘快捷键。 - 查看这个文件。
下面简要介绍了测试代码,因为它们又是一段可以重复使用的代码:
SleepDabaseTest
是一个测试类。@RunWith
注解用于标识测试运行程序,即设置和执行测试的程序。- 在设置期间,系统会执行带有
@Before
注解的函数,这个函数会使用SleepDatabaseDao
创建一个内存中SleepDatabase
。“内存中”表示此数据库不会保存到文件系统中,在测试运行完毕后会被删除。 - 同样,在构建内存中数据库时,代码会调用另一个测试专用方法
allowMainThreadQueries
。默认情况下,如果您尝试在主线程上运行查询,会遇到错误。此方法可让您在主线程上运行测试,这个操作只应在测试期间执行。 - 在带有
@Test
注解的测试方法中,您可以创建、插入和检索SleepNight
,还可以断言它们是相同的。出现任何问题时会抛出异常。在真实的测试中,您需要采用多种@Test
方法。 - 测试完成后,系统会执行带有
@After
注解的函数,以关闭数据库。
- 右键点击 Project 窗格中的测试文件,然后选择 Run ‘SleepDatabaseTest'。
- 运行测试后,在 SleepDatabaseTest 窗格中,验证是否所有测试均已通过。
因为所有测试均已通过,所以现在您了解了以下几方面的情况:
- 数据库已正确创建。
- 您可以在数据库中插入
SleepNight
。 - 您可以恢复
SleepNight
。 SleepNight
具有正确的质量值。
Android Studio 项目:TrackMySleepQualityRoomAndTesting
在测试数据库时,您需要练习 DAO 中定义的所有方法。为完成测试,请添加并执行测试,以练习其他 DAO 方法。
- 将您的表定义为带有
@Entity
注解的数据类。将带有@ColumnInfo
注解的属性定义为表中的列。 - 将数据访问对象 (DAO) 定义为带有
@Dao
注解的接口。DAO 用于将 Kotlin 函数映射到数据库查询。 - 使用注解来定义
@Insert
、@Delete
和@Update
函数。 - 将
@Query
注解和作为参数的 SQLite 查询字符串用于所有其他查询。 - 创建一个抽象类,它具有可返回数据库的
getInstance()
函数。 - 使用插桩测试来测试数据库和 DAO 是否按预期运行。您可以将提供的测试作为模板。
Udacity 课程:
Android 开发者文档:
其他文档和文章:
此部分列出了在由讲师主导的课程中,学生学习此 Codelab 后可能需要完成的家庭作业。讲师自行决定是否执行以下操作:
- 根据需要布置作业。
- 告知学生如何提交家庭作业。
- 给家庭作业评分。
讲师可以酌情采纳这些建议,并且可以自由布置自己认为合适的任何其他家庭作业。
如果您是在自学此 Codelab,可随时通过这些家庭作业来检测您的知识掌握情况。
回答以下问题
问题 1
如何指明某个类代表要存储在 Room
数据库中的实体?
- 使该类扩展
DatabaseEntity
。 - 为该类添加
@Entity
注解。 - 为该类添加
@Database
注解。 - 使该类扩展
RoomEntity
,并为该类添加@Room
注解。
问题 2
DAO(数据访问对象)是一个接口,Room
会使用该接口将 Kotlin 函数映射到数据库查询。
如何指明某个接口代表 Room
数据库的 DAO?
- 使该接口扩展
RoomDAO
。 - 使该接口扩展
EntityDao
,然后实现DaoConnection()
方法。 - 为该接口添加
@Dao
注解。 - 为该接口添加
@RoomConnection
注解。
问题 3
以下关于 Room
数据库的说法中,哪些是正确的?请选择所有适用的选项。
- 您可以将
Room
数据库的表定义为带注解的数据类。 - 如果您从查询返回
LiveData
,则Room
会在LiveData
发生变化时为您更新LiveData
。 - 每个
Room
数据库必须有且只能有一个 DAO。 - 为将某个类标识为
Room
数据库,应该使其成为RoomDatabase
的子类,并为其添加@Database
注解。
问题 4
您可以在 @Dao
接口中使用以下哪些注解?请选择所有适用的选项。
@Get
@Update
@Insert
@Query
问题 5
如何验证数据库是否在正常运行?请选择所有适用的选项。
- 编写插桩测试。
- 持续编写并运行应用,直到它显示数据。
- 将对 DAO 接口中的方法的调用替换为对
Entity
类中的等效方法的调用。 - 运行
Room
库提供的verifyDatabase()
函数。
开始学习下一课:
如需本课程中其他 Codelab 的链接,请参阅“Android Kotlin 基础知识”Codelab 着陆页。