← 返回蜂巢洞察

如何在2026年构建并发布原生Android应用程序:从Jetpack Compose到Google Play商店

大多数关于Android的开发教程都只讲到“应用程序可以在模拟器上运行”这一阶段。但实际上,要将一个从空白项目开发出来的应用发布到Google Play商店供用户下载,还需要完成许多其他步骤,而这些内容在很多教程中都被忽略了:发布版本的构建、签名密钥的生成、应用包的制作、应用信息的填写、政策声明的提交,以及新开发者账户必须经历的测试环节。 在过去的几年里,Android开发领域也发生了很大的变化。Jetpack Compose现在已经成为了默认的UI开发工具包,XML布局方式已经过时了,Kotlin成为了官方模板中唯一支持的语言,而Google Play也对新开发者提出了更高的要求。 那些以`

大多数关于Android的开发教程都只讲到“应用程序可以在模拟器上运行”这一阶段。但实际上,要将一个从空白项目开发出来的应用发布到Google Play商店供用户下载,还需要完成许多其他步骤,而这些内容在很多教程中都被忽略了:发布版本的构建、签名密钥的生成、应用包的制作、应用信息的填写、政策声明的提交,以及新开发者账户必须经历的测试环节。

在过去的几年里,Android开发领域也发生了很大的变化。Jetpack Compose现在已经成为了默认的UI开发工具包,XML布局方式已经过时了,Kotlin成为了官方模板中唯一支持的语言,而Google Play也对新开发者提出了更高的要求。

那些以`findViewById`方法作为入门内容或以上传APK文件作为结束环节的旧教程,已经无法反映当前的实际开发流程了。

这本手册会带你完整地走完整个开发过程。你将使用Jetpack Compose、Room、ViewModel以及Navigation 3框架来构建一个功能齐全的小型笔记应用,编写相应的测试用例,生成经过签名的发布版本包,然后通过Google Play Console将其正式发布到应用商店。

目录

先决条件

您应该已经熟悉基本的编程概念,并且至少读过一些关于Kotlin的资料。不需要具备Android开发的经验,因为所有与Android相关的概念都会在相关内容中得到解释。

在工具方面,您需要以下设备:

  • 一台运行macOS、Windows或Linux操作系统的电脑,内存容量至少为16GB(8GB也可以使用,但模拟器的运行速度会较慢)

  • 最新版本的Android Studio

  • 一部真实的Android手机(开发阶段可选,但在后续进行Play Console账户验证时是必需的)

  • 一个Google账户,以及25美元的费用,用于支付一次性的Google Play开发者注册费

您将构建什么

这个示例应用程序名为“Quick Notes”。它会显示按最近编辑时间排序的笔记列表,同时允许用户通过浮动操作按钮创建新笔记、点击笔记进行编辑或删除笔记。所有数据都会存储在设备本地的SQLite数据库中,因此该应用程序可以在离线状态下运行,且不需要后端服务器。

虽然一个笔记应用看起来很简单,但它实际上涵盖了任何真实应用程序所必需的所有功能:列表界面和详细信息界面、用于在两者之间切换的返回按钮、持久化的数据存储机制、能够在屏幕旋转等配置变化后仍然保持数据完整性的机制,以及用户输入处理功能。一旦您了解了这些组件的工作原理,就可以用它们来构建其他类型的应用程序。

最终形成的应用程序架构如下:

+-------------------------------------------------------------+
|                         用户界面层                            |
|  MainActivity -> NavDisplay -> NoteListScreen               |
|                             -> NoteEditorScreen             |
|                  监听状态变化,发送相关事件           |
+------------------------------+------------------------------+
                               |
                               v
+-------------------------------------------------------------+
|                      笔记数据模型                         |
|      负责存储用户界面状态,能在屏幕旋转等操作后保持数据完整     |
+------------------------------+------------------------------+
                               |
                               v
+-------------------------------------------------------------+
|                       数据访问层                            |
|   NoteRepository -> NoteDao -> NotesDatabase (Room/SQLite)  |
+-------------------------------------------------------------+

该图示展示了这三层结构,箭头指向下方,表示各层之间的依赖关系。

位于最上层的用户界面层包含了活动主体以及两个显示界面。它仅与数据模型进行交互,从不直接访问数据库。

中间的数据模型负责存储各个界面需要显示的数据,并且能够在屏幕旋转等操作导致数据丢失的情况下保证数据的完整性。

最底层的数据层包裹着“Room”,而“Room”实际上是在SQLite基础上构建的一个类型化层。数据会以Flow的形式逐层向上传递,因此当数据库内容发生变化时,用户界面会自动更新。

这种分层架构正是谷歌在其官方应用开发指南中推荐的。

如何搭建你的开发环境

请从developer.android.com/studio下载Android Studio并运行安装程序。首次启动时,设置向导会自动下载Android SDK、平台工具(包括adb)以及模拟器的系统镜像。除非有特殊原因,否则请接受默认设置。Android Studio自带JDK,因此无需另行安装Java。

设置完成后,通过工具 > SDK管理器打开SDK Manager,确认已安装了最新的稳定版Android平台。在撰写本文时,这意味着至少需要安装Android 16(API级别36)。

Google Play要求新应用及应用更新必须针对较新的API级别进行开发,而且这一要求每年都会提高,通常在8月底进行调整。在开始开发之前,请先查看目标API级别要求页面,以确定应该选择哪个版本作为目标。

接下来,通过工具 > 设备管理器创建一个模拟器。请选择较新的Pixel手机配置文件以及包含Google Play服务的最新系统镜像。

如果你更愿意在真实设备上进行测试,可以在设备的设置 > 关于手机中连续点击7次版本号来启用开发者选项,然后在该菜单中打开USB调试(或无线调试)功能。

如何创建一个新的Compose项目

在Android Studio中,选择文件 > 新建 > 新项目,然后选择空活动模板。这个模板是基于Compose框架设计的。而名称相似的“空视图活动”模板则使用较旧的XML布局系统,因此请务必选择正确的模板。

填写项目相关信息。项目名称是用户看到的显示名称;包名则是你的应用在Google Play上唯一的标识符,其格式为反向域名形式,例如com.yourname.quicknotes。请仔细选择包名,因为一旦发布后就无法更改了。将“最低SDK版本”设置为API 24,这样几乎可以覆盖所有正在使用的Android设备;同时,请将构建配置语言保留为Kotlin DSL。

点击完成按钮,等待第一次Gradle同步操作结束。最终生成的项目结构大致如下:

QuickNotes/
├── app/
│   ├── build.gradle.kts
│   ├── proguard-rules.pro
│   └── src/
│       ├── main/
│       │   ├── AndroidManifest.xml
│       │   ├── java/com/yourname/quicknotes/
│       │   │   ├── MainActivity.kt
│       │   │   └── ui/theme/
│       │   └── res/
│       ├── test/
│       └── androidTest/
├── gradle/
│   └── libs.versions.toml
├── build.gradle.kts
└── settings.gradle.kts

该结构图展示了新建项目中的重要文件分布情况。

app文件夹是构成可安装应用程序的核心模块,其中的build.gradle.kts文件包含了Android项目的配置信息(如SDK版本、依赖库版本等)。

src/main目录存放着用于生产环境的代码、用于向系统声明应用程序组件的AndroidManifest.xml文件,以及图标、字符串等资源文件,这些资源都保存在res文件夹中。

test文件夹用于编写在计算机上运行的单元测试代码,而androidTest文件夹则用于存储需要在设备或模拟器上执行的测试脚本。

在项目根目录下,gradle/libs.versions.toml文件是一个版本管理目录,其中记录了所有依赖库的版本信息;settings.gradle.kts文件则列出了项目中所包含的所有模块。出于历史原因,Kotlin相关文件被保存在名为java的文件夹中,这是正常的做法。

点击绿色的运行按钮,经过短暂的构建过程后,模拟器屏幕上应该会显示“Hello Android!”这个提示信息,这说明你的开发工具链已经可以正常使用了。

如何使用版本管理目录配置依赖关系

现代的Android项目会将所有依赖库的版本信息保存在gradle/libs.versions.toml文件中,并通过构建脚本中的别名来引用这些依赖库。

打开这个文件,将其内容替换为以下内容:

[versions]
agp = "9.0.0"
kotlin = "2.3.0"
ksp = "2.3.4"
composeBom = "2026.01.00"
activityCompose = "1.12.2"
lifecycle = "2.10.0"
navigation3 = "1.0.0"
room = "2.8.4"
serialization = "1.9.0"
junit = "4.13.2"
androidxJunit = "1.3.0"

[libraries]
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version_ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-tooling = { group = "androidxCompose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-junit4 = { group = "androidxcompose.ui", name = "ui-test-junit4" }
androidx-compose-ui-test-manifest = { group = "androidxCompose.ui", name = "ui-test-manifest" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icons-core = { group = "androidxcompose_material", name = "material-icons-core" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version_ref = "lifecycle" }
androidx-lifecycle-runtime-compose = { group = "androidx 生命周期", name = "lifecycle-runtime-compose", version_ref = "lifecycle" }
androidx-navigation3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime", version_ref = "navigation3" }
androidx-navigation3-ui = { group = "androidx/navigation3", name = "navigation3-ui", version_ref = "navigation3" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version_ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version_ref = "room" }
androidx-room-compiler = { group = "androidx_room", name = "room-compiler", version_ref = "room" }
kotlinx-serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version_ref = "serialization" }
junit = { group = "junit", name = "junit", version_ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version_ref = "androidxJunit" }

[plugins]
android-application = { id = "com.android.application", version_ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version_ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version_ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version_ref = "ksp" }
room = { id = "androidx.room", version_ref = "room" }

该目录分为三个部分:[versions] 部分会为每个版本号列出一次其名称;[libraries] 部分会将简短的别名映射到 Maven 坐标上;而 [plugins] 部分则会对 Gradle 插件执行同样的操作。

Gradle 会将每个别名中的连字符替换成点,因此在你编写构建脚本时,androidx-room-runtime 会变成 libs.androidx.room.runtime。Compose 库本身并没有自己的版本号,因为它们的版本号是从 Compose BOM(物料清单)中获取的;Compose BOM 指定了需要一起测试的一组 Compose 库。Kotlin Compose 编译器插件与 Kotlin 本身的版本号是相同的,这种情况从 Kotlin 2.0 开始就一直存在。

KSP 是 Room 使用的注解处理器,用于生成数据库相关的代码。这里列出的版本号在本文编写时都是最新的。Android Studio 会标记目录中过时的条目,你应该按照它的建议来使用最新的稳定版本。

plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.kotlincompose) apply false
    alias(libs.plugins.kotlinserialization) apply false
    alias(libs.plugins.ksp) apply false
    alias(libs.plugins.room) apply false
}

这段代码通过 apply false 来声明每个插件,这样这些插件只会被添加到构建类的路径中,而不会被应用到整个项目中。每个模块可以根据实际需要选择是否使用这些插件。

你可能会注意到这里并没有 org.jetbrains.kotlin.android 这个插件。从 Android Gradle Plugin 9 开始,Kotlin 支持已经被内置到了 Android 插件中,因此不再需要单独的 Kotlin Android 插件了。不过如果你正在使用 AGP 8 的旧项目,那么仍然会看到这个插件的存在,这也是正常的。
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlincompose)
    alias(libs.plugins.kotlinserialization)
    alias(libs.plugins.ksp)
    alias(libs.plugins.room)
}

android {
    namespace = "com.yourname.quicknotes"
    compileSdk = 36

    defaultConfig {
        applicationId = "com.yourname.quicknotes"
        minSdk = 24
        targetSdk = 36
        versionCode = 1
        versionName = "1.0.0"
        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }

    buildFeatures {
        compose = true
    }
}

room {
    schemaDirectory("$projectDir/schemas")
}

dependencies {
    val composeBom = platform(libs.androidxcompose.bom)
    implementation(composeBom)
    androidTestImplementation(composeBom)

    implementation(libs.androidx.activity.compose)
    implementation(libs.androidxCompose.ui)
    implementation(libs.androidxCompose.ui.tooling.preview)
    implementation(libs.androidxCompose.material3)
    implementation(libs.androidxCompose.material.icons.core)
    implementation(libs.androidx.lifecycle.viewmodelcompose)
    implementation(libs.androidx 生命周期.runtimecompose)
    implementation(libs.androidx.navigation3.runtime)
    implementation(libs.androidx.navigation3.ui)
    implementation(libs.androidx.room.runtime)
    implementation(libs.androidx.room.ktx)
    implementation(libs.kotlinx.serialization.core)
    ksp(libs.androidx.room.compiler)

    testImplementation(libs.junit)
    androidTestImplementation/libs.androidx.junit)
    androidTestImplementation(libs.androidxCompose.ui.test.junit4)
    debugImplementation(libs.androidxCompose.ui.tooling)
    debugImplementation(libs.androidxCompose.ui.test.manifest)
}

这是项目中最重要的配置文件,因此请仔细阅读它。

namespace是指用于生成R类的包名称,而applicationId则是应用程序在设备上以及Google Play商店中的标识符。这两个值通常是相同的。

compileSdk表示你的代码所针对的API版本,而targetSdk则告诉Android系统你的应用程序经过了哪些平台的测试。当你提高targetSdk的值时,诸如强制使用边缘到边缘绘制功能这样的新特性就会被启用。

versionCode是一个整数,在每次将应用程序上传到Google Play商店时,这个数值都必须增加;而versionName则是用户能够看到的、以人类可读形式呈现的字符串。

release块会启用R8编译器,该编译器能够压缩、混淆代码并对其进行优化;而isShrinkResources则会删除那些未被使用的资源文件。

room块用于指定Room插件应该将数据库模式的JSON快照保存到哪个位置,在之后编写数据迁移脚本时这些快照会派上用场。

在依赖项配置中,platform(libs.androidx.compose.bom)的作用是确保Compose相关的库能够正确地被引入项目中;而ksp(...)则会在构建过程中运行Room编译器。debugImplementation这些配置仅存在于调试版本中,其中包含了Compose预览工具以及测试相关文件,因此它们永远不会被发布给用户。

请点击编辑器顶部横幅中的立即同步按钮。如果同步成功,那么你就可以开始编写代码了。

Jetpack Compose对UI的处理方式

在开始设计界面之前,了解Compose所基于的模型结构是非常有帮助的,因为这种模型与旧的View系统有所不同。

在Compose中,UI实际上是状态的一种函数表现形式。你需要编写带有@Composable注解的函数,这些函数用于描述当前数据状态下界面应该呈现的样子。你永远不需要直接访问某个视图组件并调用其setText方法——当数据发生变化时,Compose会使用新的数据重新调用这些函数,从而只更新那些真正发生了变化的界面部分。这种重新计算的过程被称为“重组”。

下面是一个最简单的示例:
@Composable
fun Counter() {
    var count by remember { mutableIntStateOf(0) }

    Button(onClick = { count++ }) {
        Text("已被点击 $count 次")
    }
}
Counter函数使用mutableIntStateOf(0)来定义一个状态变量。Compose会监控对这个状态的每一次读取操作。当按钮的onClick事件触发使count的值增加时,Compose会认为这是对旧数值的读取,因此会重新执行Counter函数,并更新Text组件中显示的新数值。
remember这个函数的作用是确保状态值在多次重组过程中能够保持不变。如果没有这个功能,每次函数被执行时count的值都会被重置为0。

by关键字实现了Kotlin中的属性委托机制,这使得你可以像操作普通变量一样来读取和修改count的值,而无需编写count.value这样的代码。

remember方法能够在组件重新组合时保持数据不变,但无法应对手机旋转等配置变化,因为在这种情况下Android系统会销毁并重新创建相应的活动。对于那些在屏幕旋转后仍需保留的简单数据,应使用rememberSaveable方法。而对于更复杂的数据,比如从数据库中读取的信息,应该将其保存在ViewModel中,因为ViewModel会在活动被销毁后仍然存在。在这个应用程序中,你会同时使用这两种方法。

另一个核心设计理念是“状态分离”。如果一个可组合组件拥有自己的状态,那么它既不容易被重复使用,也不容易进行测试。更好的做法是将状态作为参数传递进来,而将用户操作产生的事件通过Lambda函数的形式传递出去,这样该组件就只会显示接收到的数据,并报告用户的操作行为。状态的存储位置由调用者来决定。在列表界面中,你就会看到这种设计模式,它使得测试工作变得更加简单。

如何构建应用程序的结构

在“Project”面板中右键点击com.yourname.quicknotes这个包,然后选择新建 > 包,从而创建以下这些包:

com.yourname.quicknotes
├── data/
│   ├── Note.kt
│   ├── NoteDao.kt
│   ├── NotesDatabase.kt
│   └── NoteRepository.kt
├── ui/
│   ├── NotesViewModel.kt
│   ├── NoteListScreen.kt
│   ├── NoteEditorScreen.kt
│   └── theme/
├── navigation/
│   └── NavKeys.kt
├── MainActivity.kt
└── NotesApp.kt

这些包是按照不同的功能层次来组织的。

data包包含了所有与数据存储相关的内容:Note实体类、用于定义查询操作的DAO接口、Room数据库类,以及应用程序其他部分使用的数据仓库类——这些仓库类会替代直接与Room数据库进行交互的方式。

ui包中包含了ViewModel以及两个界面对应的代码文件,同时还包含模板自动生成的主题相关资源。

navigation包负责存储用于标识不同界面的标识信息。MainActivity是承载整个应用程序用户界面的主活动,而NotesApp则是一个自定义的Application类,它用于为整个应用程序一次性创建数据库。

对于规模更大的应用程序来说,通常会将这些组件拆分成独立的Gradle模块来管理,但在这个项目中,使用包结构就已经足够了。

如何使用Room在本地存储数据

Room是Jetpack提供的持久化库。你可以用Kotlin类来定义数据库表结构,用带注解的接口方法来描述查询操作,而Room会在编译时生成相应的SQLite代码。由于查询语句会在编译阶段进行验证,因此如果列名中存在拼写错误,只会导致编译失败,而不会在用户设备上引发程序崩溃。

首先从data/Note.kt文件开始编写数据模型:

package com.yourname.quicknotes.data

import androidx.room.Entity
import androidx_room.PrimaryKey

@Entity(tableName = "notes")
data class Note(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val title: String,
    val body: String,
    val.updated: Long = System.currentTimeMillis()
)
<@Entity>这一注解告诉Room,这个类对应于名为notes的表格,而且该类中的每个属性都会成为表格中的一列。
id字段是主键,而autoGenerate = true这一设置意味着:当您插入一条ID值为0的记录时,SQLite会自动为这条记录分配一个新的ID。因此,id字段的默认值就是0——因为一条新的笔记在未被保存之前是没有实际ID值的。
updatedAt属性用于存储以毫秒为单位的时间戳,每当创建一个Note对象时,这个属性的值就会被设置为当前时间。正是通过这一机制,应用程序才能知道哪些笔记是最最近被修改的。
使用普通的data class,就可以免费获得
equals、hashCode以及copy方法,而Compose正是依赖这些方法来判断列表中的元素是否发生了变化。 接下来,在data/NoteDao.kt文件中定义相关的查询语句:
package com.yourname.quicknotes.data

import androidx.room Dao
import androidx.room.Delete
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow

@Dao
interface NoteDao {
    @Query("SELECT * FROM notes ORDER BY.updated DESC")
    fun observeAll(): Flow>

    @Query("SELECT * FROM notes WHERE id = :id")
    suspend fun getById(id: Long): Note?

    @Upsert
    suspend fun upsert(note: Note)

    @Delete
    suspend fun delete(note: Note)
}
DAO是一个接口,Room会为你自动实现这个接口。observeAll方法返回的是一个Flow>类型,而不是一个普通的列表。而Flow是一种来自Kotlin协程的数据流——每当底层数据发生变化时,这个数据流就会生成新的值。因此,每当有笔记被插入、更新或删除时,Room都会自动重新执行查询语句,并生成最新的列表内容。正是这种机制使得列表界面能够始终保持同步状态,而无需人工进行刷新操作。 其他方法都被标记为suspend,这意味着这些方法必须通过协程来调用。Room会在后台线程中执行这些方法,因此数据库操作永远不会阻塞主线程,也不会导致用户界面冻结。 <@Upsert>这一注解表示:如果某条记录的主键不存在,就会插入这条记录;如果主键已经存在,就会更新这条记录。这样一来,同一个方法就可以同时完成创建和修改笔记的操作。 在第二个查询语句中,:id这种语法能够安全地将方法参数绑定到SQL语句中,而无需进行任何字符串拼接操作。 现在,在data/NotesDatabase.kt文件中创建数据库对象:
package com.yourname.quicknotes.data

import androidx.room.Database
import androidx.room.RoomDatabase

@Database(entities = [Note::class], version = 1, exportSchema = true)
abstract class NotesDatabase : RoomDatabase() {
    abstract fun noteDao(): NoteDao
}
<@Database>注解用于指定数据库中包含的所有实体以及数据库的模式版本号。Room会基于这个注解生成这个抽象类的具体子类,并实现noteDao()方法。version字段在应用程序正式上线后非常重要。如果你后来为Note字段添加了新的属性,就必须更新版本号并提供相应的迁移脚本,否则Room会拒绝在用户设备上打开现有的数据库文件。

exportSchema = true这一设置会将每个数据库模式的JSON描述文件写入你在Gradle中配置的schemas文件夹中。请将这个文件夹提交到版本控制系统中,因为Room会利用这些文件来验证数据结构并自动生成迁移脚本。

如何通过仓库和应用程序类来暴露数据

Repository层是ViewModel与Room之间的中间层。在这个应用程序中,它主要负责转发相关请求,但同时也提供了在后期添加缓存功能、进行网络同步或数据验证的操作,而无需修改UI代码。请创建data/NoteRepository.kt文件:
package com.yourname.quicknotes.data

import kotlinx.coroutines.flow.Flow

class NoteRepository(private val noteDao: NoteDao) {

    fun observeNotes(): Flow> = noteDao.observeAll()

    suspend fun getNote(id: Long): Note? = noteDao.getById(id)

    suspend fun save(note: Note) = noteDao.upsert(note)

    suspend fun delete(note: Note) = noteDao.delete(note)
}
Repository类的构造函数接收一个NoteDao对象作为参数,而不是自己创建这个对象。这就是最简单的依赖注入形式。由于依赖关系是外部提供的,因此在进行测试时可以为Repository提供假的NoteDao实现。该类中的公共方法使用的是业务逻辑相关的名称(如save、observeNotes),而不是数据库操作相关的术语(如upsert、observeAll),这样就可以避免Room的相关细节泄露到应用程序的其他部分。 数据库文件每个应用程序进程都应该只创建一次,因为打开SQLite数据库会消耗较多的系统资源,而且如果同时存在多个数据库实例,还可能会引发冲突。如果不使用依赖注入框架,一种常见的实现方法是创建一个自定义的Application子类。请在项目的根目录下创建NotesApp.kt文件:
package com.yourname.quicknotes

import android.app.Application
import androidx.room.Room
import com.yourname.quicknotes.data.NoteRepository
import com.yourname.quicknotes.data.NotesDatabase

class NotesApp : Application() {

    private val database: NotesDatabase by lazy {
        Room.databaseBuilder(this, NotesDatabase::class.java, "notes.db").build()
    }

    val repository: NoteRepository by lazy {
        NoteRepository(database.noteDao())
    }
}
Android系统会在任何活动启动之前创建一个Application实例,并在整个进程的生命周期内保持这个实例的有效性。因此,Application类非常适合用来存储全局单例对象。 by lazy这种写法会延迟创建数据库文件,直到第一次调用repository方法时才会进行实际的操作,这样就可以确保应用程序启动速度更快。

Room.databaseBuilder 需要一个上下文对象、数据库对应的类以及文件名,而 build() 方法会生成相应的实现代码。对于规模较大的应用程序来说,Hilt或Koin这类框架可以帮您完成这些配置工作,但通过手动操作来了解这一整个流程,反而能更清楚地理解这些框架究竟自动化了哪些步骤。

若要让Android使用这个类,需要在《AndroidManifest.xml》中为标签添加android:name属性来进行注册:

<application
    android:name=".NotesApp"
    android:allowBackup="true"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name"
    android:roundIcon="@mipmap/ic_launcher_round"
    android:supportsRtl="true"
    android:theme="@style/Theme.QuickNotes">

你需要添加的唯一一行代码就是android:name=".NotesApp"。前面的点表示你的应用的命名空间,因此Android会将其解析为com.yourname.quicknotes.NotesApp。

其他属性都是从模板中继承而来的:启动图标、图标下显示的应用名称、对从右到左布局的支持,以及Compose在绘制第一帧之前使用的默认主题。如果你忘记了添加android:name属性,Android会使用默认的Application类,而你的应用在第一次尝试将其转换为NotesApp类型时就会崩溃。

如何使用ViewModel来管理UI状态

ViewModel用于保存屏幕的状态,并且能够在配置发生变化时依然保持这些状态的完整性。当用户旋转手机时,对应的Activity会被销毁并重新创建,但ViewModel会继续保留在内存中,因此笔记列表无需重新加载。请创建文件ui/NotesViewModel.kt:

package com.yourname.quicknotes.ui

import androidx.lifecycle ViewModel
import androidxLifecycleViewModelProvider
import androidx 生命周期.viewmodelScope
import androidx.lifecycle.ViewModelInitializer
import androidx.lifecycle_vmFactory
import com.yourname.quicknotes.NotesApp
import com.yourname.quicknotes.data.Note
import com.yourname.quicknotes.data.NoteRepository
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx coroutine.flow.stateIn
import kotlinx.coroutines.launch

class NotesViewModel(private val repository: NoteRepository) : ViewModel() {

    val notes: StateFlow> = repository.observeNotes()
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = emptyList()
        )

    suspend fun getNote(id: Long): Note? = repository.getNote(id)

    fun saveNote(id: Long?, title: String, body: String) {
        viewModelScope.launch {
            repository.save(Note(id = id ?: 0, title = title.trim(), body = body.trim()))
        }
    }

    fun deleteNote(note: Note) {
        viewModelScope.launch {
            repository.delete(note)
        }
    }

    companion object {
        val Factory: ViewModelProvider.Factory = viewModelFactory {
            initializer {
                val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as NotesApp
                NotesViewModel(app.repository)
            }
        }
    }
}

这个类包含了三个核心理念。

第一个理念是`notes`属性。`repository.observeNotes()`返回的是一个“冷流”(即只有在有人正在获取数据时才会执行操作的流),而`stateIn`操作符可以将它转换为`StateFlow`,这样这个流就会始终保留当前的值(初始值为空列表),从而使UI能够立即读取这些数据。`SharingStarted.WhileSubscribed(5_000)`会在UI进行观察期间保持数据库查询处于活跃状态,并在观察停止后继续维持这一状态五秒钟。这种五秒的缓冲期可以确保在快速切换屏幕时不会导致查询被中断并重新开始;而如果应用程序在后台运行时间过长,这个缓冲期就会结束,从而导致查询被终止。

第二个理念是`viewModelScope`。保存和删除操作都是需要暂停执行的操作,因此它们需要在一个协程中执行。`viewModelScope`是一个与ViewModel的生命周期绑定的协程作用域,任何在这个作用域中启动的操作都会在ViewModel被销毁时自动取消。

第三个理念是“工厂模式”。Android会自动为你创建ViewModel对象,以便在屏幕切换时这些对象能够继续存在,因此你不能直接调用ViewModel的构造函数。工厂模式的目的是告诉Android在需要时如何创建ViewModel对象。`viewModelFactory`和`initializer`构建器会从创建参数中获取`Application`实例,将其转换为`NotesApp`类型,然后再将其中的repository传递给ViewModel的构造函数。

如何构建笔记列表界面

笔记列表界面由两个可组合组件构成:一个有状态的外层组件用于与ViewModel进行交互,另一个无状态的内部组件负责实际绘制界面内容。请创建`ui/NoteListScreen.kt`文件:

package com.yourname.quicknotes.ui

import androidx.compose.foundation.clickable
import androidxcomposefoundation.layout.Arrangement
import androidxComposeFoundation.layout.Box
import androidx-composeFOUNDATION.layout.Column
import androidx.composeoundation.layoutPaddingValues
import androidx.composeFOUNDATION.layout.Row
import androidx.composeFOUNDATION.layout.fillMaxSize
import androidx.composeFOUNDATION.layout.fillMaxWidth
import androidx.composeFOUNDATION.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.composefoundationlazy.items
import androidx.compose.material.icons.Icons
import androidxcompose_materialicons.filled.Add
import androidxComposeMaterialIcons.filled.Delete
import androidx-compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3'icon
import androidx.compose.material3 ICONButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidxCompose_material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx-compose.uiAlignment
import androidxcompose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.yourname.quicknotes.data.Note

@Composable
fun NoteListScreen(
    viewModel: NotesViewModel,
    onAddNote: () -> Unit,
    onOpenNote: (Long) -> Unit
) {
    val notes = viewModel.notes.collectAsStateWithLifecycle()

    NoteListContent(
        notes = notes,
        onAddNote = onAddNote,
        onOpenNote = onOpenNote,
        onDeleteNote = viewModel::deleteNote
    )
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NoteListContent(
    notes: List,
    onAddNote: () -> Unit,
    onOpenNote: (Long) -> Unit,
   onDeleteNote: (Note) -> Unit
) {
    Scaffold(
        topBar = { TopAppBar(title = Text("Quick Notes") }) },
        floatingActionButton = {
            FloatingActionButton(onClick = onAddNote) {
                Icon(Icons.Default.Add, contentDescription = "Add note")
            }
        }
    ) { innerPadding -> {
        if (notes.isEmpty()) {
            Box(
                modifier = Modifier.fillMaxSize().padding(innerPadding),
                contentAlignment = Alignment.Center
            ) {
                Text("No notes yet", style = MaterialTheme.typography.bodyLarge)
            }
        } else {
            LazyColumn(
                contentPadding = PaddingValues(16.dp),
                verticalArrangement = Arrangement.spacedBy(12(dp),
                modifier = Modifier.fillMaxSize().padding(innerPadding)
            ) {
                items(notes, key = { it.id }) { note -> {
                    NoteCard(
                        note = note,
                        onClick = { onOpenNote(note.id) },
                        onDelete = {onDeleteNote(note) }
                    )
                }
            }
        }
    }
}

@Composable
private fun NoteCard(note: Note, onClick: () -> Unit, onDelete: () -> Unit) {
    Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) {
        Row(
            modifier = Modifier.padding(16.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Column(modifier = Modifier.weight(1f)) {
                Text(note.title, style = MaterialTheme.typography.titleMedium)
                if (note.body.isNotBlank()) {
                    Text(
                        note.body,
                        style = MaterialTheme.typography.bodyMedium,
                        maxLines = 2,
                        overflow = TextOverflow.Ellipsis
                    )
                }
            }
            IconButton(onClick = onDelete) {
                IconIcons.Default.Delete, contentDescription = "Delete ${note.title}")
            }
        }
    }
}

NoteListScreen是唯一一个了解ViewModel结构的组件。它会调用collectAsStateWithLifecycle()方法,该方法会将StateFlow转换成Compose状态系统。与普通的collectAsState()不同,这种方法在应用程序进入后台时会停止数据收集,从而节省电池电量,并避免为那些无法被显示的屏幕执行数据库操作。之后,NoteListScreen会将处理后的列表以及一些Lambda表达式传递给NoteListContent组件。

viewModel::deleteNote这种写法实际上是一种函数引用,它是{ note -> viewModel.deleteNote(note) }的简写形式。

NoteListContent是一个无状态组件。它所有需要的数据都是通过参数传递进来的,而用户的点击操作则是通过Lambda表达式来处理的——这种设计实际上属于“状态提升”的技术范畴。在NoteListContent的内部,Scaffold用于构建Material 3风格的布局结构,它会为屏幕顶部提供应用栏和浮动动作按钮的位置,并计算出适当的innerPadding

当列表为空时,屏幕会显示一条居中的提示信息;否则,它就会使用LazyColumn组件来仅渲染当前可见的行——这与旧版View系统中的RecyclerView工作原理相同。

key = { it.id }这个参数为每一行数据分配了一个唯一的标识符。这样一来,当某条笔记被删除时,Compose状态系统就能准确知道应该删除哪一行,而无需重新绘制其下方的所有内容。

NoteCard组件用于展示单条笔记的内容。Modifier链则按顺序配置了组件的布局和行为:fillMaxWidth()使卡片占据整个屏幕的宽度,而clickable属性则使得整个卡片能够响应用户的点击操作。

在Row组件内部,Modifier.weight(1f)指令告诉文本列应占据删除按钮之后剩余的所有水平空间。contentDescription属性用于为图标添加描述文字,这样Android的系统屏幕阅读器TalkBack就能将这些描述读出来。例如,如果将笔记标题包含在删除按钮的描述中,那么TalkBack用户听到的就会是“删除杂货”这样的提示语,而不会是一排内容都相同的“删除”按钮。

如何构建笔记编辑器屏幕

这个编辑器既可以用于创建新的笔记,也可以用于修改现有的笔记。请创建文件ui/NoteEditorScreen.kt:

package com.yourname.quicknotes.ui

import androidx.compose.foundation.layout.Column
import androidxcomposefoundation.layout.Spacer
import androidxComposeFOUNDATION.layout.fillMaxSize
import androidx-composeoundation.layout.fillMaxWidth
import androidx.composeFoundation.layout.height
import androidx.composefoundation.layout.imePadding
import androidx.composefoundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material/icons.automirrored.filled.ArrowBack
import androidxcompose.material3.ExperimentalMaterial3Api
import androidx.compose.material3'icon
import androidx.compose.material3 ICONButton
import androidx-compose.material3.OutlinedTextField
import androidxCompose_material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidxcompose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx-compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NoteEditorScreen(
    noteId: Long?,
    viewModel: NotesViewModel,
    onDone: () -> Unit
) {
    var title by rememberSaveable { mutableStateOf("") }
    var body by rememberSaveable { mutableStateOf("") }
    var loaded by rememberSaveable { mutableStateOf(noteId == null) }

    LaunchedEffect(noteId) {
        if (noteId != null && !loaded) {
            viewModel.getNote(noteId)?.let {
                title = it.title
                body = it.body
            }
            loaded = true
        }
    }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text(if (noteId == null) "新建笔记" else "编辑笔记") },
                navigationIcon = {
                    IconButton(onClick = onDone) {
                        IconIcons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
                    }
                },
                actions = {
                    TextButton(
                        onClick = {
                            viewModel.saveNote(noteId, title, body)
                            onDone()
                        },
                        enabled = title.isNotBlank()
                    ) {
                        Text("保存")
                    }
                }
            )
        }
    ) { innerPadding -> {
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(innerPadding)
                .imePadding()
                .padding(16.dp)
        ) {
            OutlinedTextField(
                value = title,
                onValueChange = { title = it },
                label = { Text("标题") },
                singleLine = true,
                modifier = Modifier.fillMaxWidth()
            )
            Spacer(Modifier.height(12(dp))
            OutlinedTextField(
                value = body,
                onValueChange = { body = it },
                label = { Text("内容") },
                modifier = Modifier.fillMaxWidth().weight(1f)
            )
        }
    }
}
rememberSaveable这个方法会将用户正在输入的文本保存在本地状态中,因此即使屏幕旋转,半完成的笔记内容也会被保留下来。loaded标志用于指示现有的笔记是否已经从数据库中读取出来。对于新创建的笔记,这个标志的值初始为true(表示没有需要加载的内容);而对于已存在的笔记,其值为false。

LaunchedEffect(noteId)会在该可组合组件首次出现时运行一个协程,并且只有当noteId发生变化时才会再次运行这个协程。在该协程内部,系统会向ViewModel请求相应的笔记信息,并将笔记的标题和内容复制到文本输入框中。

在真正开始加载数据之前检查loaded标志的状态是非常重要的。如果不进行这种检查,当屏幕旋转时,该协程会再次被执行,从而导致用户所输入的内容被数据库中保存的旧版本覆盖。

OutlinedTextField是Material 3框架提供的一个文本输入组件。这类组件属于受控组件:它们会显示你传入的value值;当用户进行输入时,系统会调用onValueChange方法并将新输入的文本反映出来。如果你忘记在回调函数中更新title字段的值,那么该文本输入框就会一直显示旧的内容,因为Compose框架会不断重绘旧值。当title字段为空时,“保存”按钮会被禁用;用户点击该按钮后,系统会通过ViewModel将内容保存下来,并调用onDone方法来恢复之前的界面状态。

imePadding()方法会在文本输入框的底部添加相当于屏幕上键盘高度的空白区域,这样一来,当用户输入文字时,文本输入框的内容就不会被隐藏在键盘后面。

AutoMirrored图标集会被用于后退按钮,这样在阿拉伯语和希伯来语等从右向左阅读的语言环境中,后退按钮的图标就会自动翻转。

如何使用Navigation 3添加导航功能

Navigation 3是Jetpack团队开发的一款以Compose框架为基础的导航库。早期的Navigation Compose库是基于为Fragment设计的相关系统开发的,它将后退栈视为自己内部拥有的一种数据结构。

而Navigation 3采取了完全不同的设计思路:后退栈实际上只是一个由你控制的普通列表,进行导航操作就意味着向这个列表中添加或删除元素。该导航库会实时观察这个列表,并根据当前位于列表顶部的元素来显示相应的界面。

首先,在navigation/NavKeys.kt文件中为每个界面定义一个对应的键值对:
package com.yourname.quicknotes.navigation

import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable

@Serializable
data object NoteList : NavKey

@ Serializable
data class NoteEditor(val noteId: Long? = null) : NavKey

每个导航目标都是一种实现了NavKey接口的类。NoteList这个类不接受任何参数,因此它是一个只有一个实例的data object,其toString方法可以生成该对象的描述字符串。

NoteEditor类包含一个可选的笔记ID字段,其中null表示“创建新笔记”。该类的参数是类型化的属性,而不是嵌入在URL路径中的字符串,因此编译器能够检测到传递错误类型的参数这类问题。

@Serializable注解使得Navigation 3能够在Android在后台终止应用程序时保存返回栈信息,并在用户重新回到应用程序时恢复这些信息,正因如此,之前才添加了序列化插件。

现在,请将MainActivity.kt文件替换为以下内容:

package com.yourname.quicknotes

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidxcompose.runtime.Composable
import androidx.lifecycle.viewmodelComposeViewModel
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import com.yourname.quicknotesnavigation.NoteEditor
import com.yourname.quicknotes-navigation.NoteList
import com.yourname.quicknotes.ui.NoteEditorScreen
import com.yourname.quicknotes.ui.NoteListScreen
import com.yourname.quicknotes.ui.NotesViewModel
import com.yourname.quicknotes.ui.theme.QuickNotesTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            QuickNotesTheme {
                QuickNotesNavHost()
            }
        }
    }
}

@Composable
fun QuickNotesNavHost() {
    val backStack = rememberNavBackStack(NoteList)
    val notesViewModel: NotesViewModel = viewModel.Factory = NotesViewModel.Factory)

    NavDisplay(
        backStack = backStack,
        onBack = { backStack.removeLastOrNull() },
        entryProvider = entryProvider {
            entry  {
                NoteListScreen(
                    viewModel = notesViewModel,
                    onAddNote = { backStack.add(NoteEditor()) },
                    onOpenNote = { id -> backStack.add(NoteEditor(id)) }
                )
            }
            entry  { key -> 
                NoteEditorScreen(
                    noteId = key(noteId),
                    viewModel = notesViewModel,
                    onDone = { backStack.removeLastOrNull() }
                )
            }
        }
    )
}

MainActivity现在仅仅起到了宿主的作用。enableEdgeToEdge()使得应用程序能够在状态栏和导航栏后面进行绘制(关于这一点的更多内容将在下一节中介绍),而setContent则用由模板生成的主题所包裹的Compose树替换了传统的XML布局。

QuickNotesNavHost是实现导航功能的关键组件。rememberNavBackStack(NoteList)创建了一个可保存的返回栈,该返回栈以列表屏幕为起始点。你之前编写的工厂函数用于一次性创建这个ViewModel,由于这两个屏幕处理的是相同的笔记数据,因此它们会共享同一个ViewModel。

NavDisplay会将栈顶对应的界面呈现出来。entryProvider块通过entry 将每种类型的界面映射到相应的Composable组件上。对于NoteEditor来说,lambda表达式会接收键值对作为参数,因此key(noteId)可以直接获取到对应的笔记ID。

向前导航可以使用backStack.add(...)方法,而返回上一页则使用backStack.removeLastOrNull()方法。这些就是用于基本导航的全部API功能。

当用户按下系统的返回按钮或做出返回手势时,会调用onBack方法,此时系统会删除列表顶部的条目。Navigation 3还支持预测性返回功能:当用户滑动屏幕时,之前的界面会自动重新显示出来,而您无需编写任何额外的代码。

现在运行这个应用程序,你应该能够添加笔记、查看这些笔记在列表中的显示效果、点击某条笔记进行编辑、删除笔记、旋转设备而不会丢失任何数据,同时关闭并重新打开应用后,所有的笔记依然会保留下来。

如何处理主题设置与边缘到边缘显示功能

该模板创建了一个包含Color.kt、Type.kt和Theme.kt文件的ui/theme包。主题相关的代码大致如下所示:

@Composable
fun QuickNotesTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit
) {
    val colorScheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S => {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
        }
        darkTheme => DarkColorScheme
        else => LightColorScheme
    }

    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography,
        content = content
    )
}

MaterialTheme为所有基于Material 3设计的组件提供了颜色和字体样式,因此像MaterialTheme.typography.titleMedium这样的代码在您的界面中是可以正常使用的。

when语句用于选择合适的颜色方案。在Android 12(API级别31,即VERSION_CODES.S及以上版本)及后续版本中,如果设置了dynamicColor为true,系统会根据用户的壁纸生成相应的配色方案,从而使您的应用与手机的整体风格保持一致;在较早的版本中,或者将dynamicColor设置为false时,系统会使用Color.kt中定义的固定颜色方案,即LightColorScheme和DarkColorScheme》。而isSystemInDarkTheme()方法则会根据系统的深色模式设置来选择相应的颜色。

如果您希望在整个应用中统一使用某种品牌指定的颜色,可以将dynamicColor设置为false,然后修改Color.kt文件中的颜色值。Material Theme Builder工具可以根据一个基础颜色生成完整的浅色和深色配色方案。

“边缘到边缘显示”意味着您的应用会覆盖在系统栏的后面,而不是显示在系统栏之间的区域内。针对Android 15或更高版本设计的应用,默认都支持这种显示方式;从Android 16开始,用户不再可以选择是否启用这一功能。

在MainActivity中调用enableEdgeToEdge()方法,可以使较旧版本的Android设备也具备这种显示效果,并且系统栏图标也会自动调整为相应的浅色或深色。由于所有界面都使用了Scaffold组件及其设定的innerPadding属性,因此内容区域本来就会避开系统栏的位置。

当你不使用Scaffold来构建界面时,可以使用Modifier.safeDrawingPadding()或Modifier.systemBarsPadding()这样的修饰器,以确保文本和按钮不会出现在状态栏或手势区域下方。

如何测试你的可组合组件

由于NoteListContent没有状态,因此你可以在不使用数据库或ViewModel的情况下对其进行测试,只需传入一个假数据列表即可。创建文件app/src/androidTest/java/com/yourname/quicknotes/NoteListContentTest.kt:

package com.yourname.quicknotes

import androidx.compose.ui.test:IsDisplayed
import androidxcompose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.yourname.quicknotes.data.Note
import com.yourname.quicknotes.ui.NoteListContent
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test

class NoteListContentTest {

    @get:Rule
    val composeRule = createComposeRule()

    @Test
    fun showsEmptyMessageWhenThereAreNoNotes() {
        composeRule.setContent {
            NoteListContent(notes = emptyList(), onAddNote = {}, onOpenNote = {}, onDeleteNote ={})
        }

        composeRule.onNodeWithText("还没有笔记").assertIsDisplayed()
    }

    @Test
    fun tappingANoteReportsItsId() {
        var openedId: Long? = null
        val notes = listOf(Note(id = 42, title = "食品杂货", body = "牛奶、鸡蛋"))

        composeRule.setContent {
            NoteListContent(
                notes = notes,
                onAddNote = {},
                onOpenNote = { openedId = it },
                onDeleteNote = {}
            )
        }

        composeRule.onNodeWithText("食品杂货").performClick()
        assertEquals(42L, openedId)
    }

    @Test
    fun deleteButtonIsLabelledForScreenReaders() {
        val notes = listOf(Note(id = 1, title = "食品杂货", body = ""))

        composeRule.setContent {
            NoteListContent(notes = notes, onAddNote = {}, onOpenNote = {}, onDeleteNote = {}
        }

        composeRule.onNodeWithContentDescription("删除食品杂货").assertIsDisplayed()
    }
}

createComposeRule()会创建一个空白的活动环境,在这个环境中setContent可以用来独立渲染任何可组合组件。测试工具会通过语义树来查找元素,这种结构与辅助技术服务读取数据的方式是一样的。onNodeWithText会根据节点上显示的文本来定位该节点,而onNodeWithContentDescription则会根据节点的辅助描述标签来找到它。

第一个测试验证了当列表为空时界面确实会显示空状态;第二个测试通过一个Lambda表达式记录被点击笔记的ID,然后确认屏幕确实显示了正确的ID;第三个测试则检查删除按钮是否有清晰的标签,这也同时起到了辅助技术检测的作用。这些测试都没有涉及到Room或ViewModel,而这正是将状态管理逻辑从NoteListContent中分离出来的实际好处所在。

你可以通过右键点击该文件并选择“运行‘NoteListContentTest’”来执行测试,或者直接在终端中输入以下命令:

./gradlew connectedDebugAndroidTest

这个Gradle任务会生成应用程序的调试版本以及专门的测试APK文件,然后将它们安装到连接的模拟器或手机上,执行androidTest中的所有测试,并将测试结果写入app/build/reports/androidTests/connected/目录。进行仪器化测试时需要确保设备处于运行状态。对于那些与Android系统无关的逻辑部分,比如验证规则或格式化辅助功能,应该将相关的单元测试放在test文件夹中,并使用./gradlew testDebugUnitTest命令来运行这些测试,这样的测试在几秒钟内就能在你的电脑上完成。

如何为应用程序发布做准备

调试版本非常适合开发用途,但在其他人开始安装你的应用程序之前,还有几件事情需要处理。

替换默认的应用图标

模板中提供的图标是通用的绿色Android图标。请右键点击res文件夹,然后选择“新建 > 图片资源”。Image Asset Studio会生成一个自适应图标,这种图标的背景和前景是分开的,因此启动器可以将它裁剪成圆形、正方形或其他形状。

请确保你的图形设计中的重要部分位于预览图中显示的中央区域内,因为某些启动器会在边缘处对图标进行裁剪。同时还需要准备一个单色背景层。Android 13及更高版本会使用这个单色背景层来为图标添加主题效果,如果应用程序没有设置这样的背景层,在这些设备上运行时会显得很不协调。

将硬编码的字符串移至资源文件中

在这篇文章中,示例代码中使用了诸如"Quick Notes"和"No notes yet"这样的硬编码字符串,这样可以使代码更易于阅读。在实际的应用程序中,你应该将这些字符串移到res/values/strings.xml文件中,并通过stringResource(R.string.no_notes)来获取这些字符串的值。

正是这种做法使得翻译成为可能,因为Android系统会自动选择适合用户语言设置的strings.xml文件。Android Studio会在遇到硬编码的字符串时发出警告提示,你也可以使用“提取字符串资源”这个快速修复功能来将这些字符串移出代码中。

使用R8工具检查发布版本

你在发布构建类型中启用了isMinifyEnabled选项,这样R8工具就会开始工作。R8会删除未使用的代码,缩短类名和方法名,并优化剩余的代码。这样做通常会使应用程序的大小显著减小,运行速度也会加快,但需要注意的是,如果某些代码依赖于反射机制,那么使用R8可能会破坏这些代码的功能,因为R8无法识别那些没有被直接引用的类。

Room、Compose和kotlin.serialization这三个工具都提供了自己的优化规则,因此这个应用程序不需要额外进行配置。如果你添加了使用了反射机制的库,就需要查看这些库的文档,了解需要添加到proguard-rules.pro文件中的规则是什么。

要发现R8版本中存在的问题,其实可以在上传应用程序之前先运行发布版本的构建。从“构建”菜单中的“选择构建变体”选项中选择“发布”变体进行构建;在安装该版本后,需要依次完成所有安装步骤。如果只有在发布版本中才会出现崩溃现象,那么几乎可以肯定是由于缺少某些配置规则导致的。

移除不必要的权限

打开合并后的manifest文件(位于《AndroidManifest.xml》文件底部的“合并Manifest”标签页),查看其中列出的权限设置。一些第三方库可能会添加一些你并未请求的权限,而你在Play Console中声明的每一项权限都必须有合理的依据。Quick Notes这类应用会将所有数据存储在本地设备上,因此它根本不需要任何权限,甚至不需要`INTERNET`权限;权限数量越少,在Play Console中填写数据安全相关信息也会更加方便。

如何创建上传密钥并为应用程序签名

所有Android应用程序在安装之前都必须经过加密签名处理。Google Play采用了名为“Play App Signing”的双密钥签名机制:你使用自己创建并保存的上传密钥来为应用程序生成签名文件,而Google则会使用自己存储在安全系统中的应用签名密钥来重新签名这些文件。最终用户看到的只有应用签名密钥。

这种分离密钥的做法具有很大的优势——一旦你丢失了上传密钥或它被泄露,你可以通过Play Console请求谷歌重置该密钥,这样你的用户依然能够继续接收应用程序更新。在“Play App Signing”机制出现之前,如果丢失了唯一的签名密钥,那么你就再也无法更新你的应用程序了。

对于新发布的应用程序来说,“Play App Signing”是必须使用的;同时,这一机制也是实现Android应用打包功能的关键,因为谷歌需要使用签名密钥来为不同设备生成优化后的APK文件。

你可以在终端中使用`keytool`命令来创建上传密钥。这个工具包含在Android Studio自带的JDK中:

keytool -genkeypair -v \
  -keystore ~/keys/quicknotes-upload.jks \
  -alias upload \
  -keyalg RSA -keysize 2048 \
  -validity 10000

这条命令会在`~/keys/quicknotes-upload.jks`路径下生成一个Java密钥库文件,其中包含一个别名为“upload”的密钥对。`-keyalg RSA -keysize 2048`指定了密钥的类型和强度,而`-validity 10000`则使该密钥的有效期为大约27年。Google Play要求所有签名密钥的有效期至少要达到2033年10月。

使用此命令时,系统会提示你输入密钥库的密码,并要求你提供自己的姓名和所在机构的信息,这些信息将会被保存在证书中。如果你的系统中没有`keytool`工具,Android Studio的“构建”菜单中的“生成签名应用包或APK”向导也可以通过对话框来帮助你创建相同的密钥。

请将密钥库文件保存在项目文件夹之外的地方,并使用密码管理工具或加密存储方式对其进行备份。绝对不要将其提交到Git仓库中。

接下来,在项目根目录下创建一个`keystore.properties`文件,用于存储签名相关配置信息:

storeFile=/Users/yourname/keys/quicknotes-upload.jks
storePassword=your-keystore-password
keyAlias=upload
keyPassword=your-key-password

这个文件包含了Gradle进行签名操作所需的四项信息:密钥库的路径、密钥库密码、密钥在密钥库中的别名以及该密钥的密码。将这些配置放在单独的文件中,可以让你在共享项目的同时避免泄露敏感信息。请立即将`keystore.properties`文件以及所有`.jks`文件添加到你的`.gitignore`文件中。

然后,在`app/build.gradle.kts`文件中加载这个配置文件,并创建一个用于发布版本的签名配置:

import java.util.Properties

val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties().apply {
    if (keystorePropertiesFile.exists()) {
        keystorePropertiesFile.inputStream().use { load(it) }
    }
}

android {
    // namespace、compileSdk、defaultConfig等配置与之前相同

    signingConfigs {
        if (keystorePropertiesFileexists()) {
            create("release") {
                storeFile = file(keystoreProperties.getProperty("storeFile"))
                storePassword = keystoreProperties.getProperty("storePassword")
                keyAlias = keystoreProperties.getProperty("keyAlias")
                keyPassword = keystoreProperties.getProperty("keyPassword")
            }
        }
    }

    buildTypes {
        release {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
            signingConfig = signingConfigs.findByName("release")
        }
    }
}

`import`语句必须放在文件的最顶部,位于`plugins`块之前。如果`keystore.properties`文件存在,这段代码会将其内容读入一个`Properties`对象中。

在`signingConfigs`部分,只有当`keystore.properties`文件存在时,才会根据其中配置的信息创建名为“release”的签名配置。这样的设计非常必要,因为如果其他开发人员克隆了项目仓库,或者CI服务器在执行测试时,并不会拥有你的密钥库文件,但应用程序仍然可以正常进行调试阶段的构建。

对于发布版本的构建流程来说,`signingConfigs.findByName("release")`方法会在配置信息不存在时返回`null`,而不会导致程序失败。在CI管道中,通常会从环境变量或 secrets manager 中获取这些配置信息,而不是直接从文件中读取。

如何构建Android应用包

Google Play不允许为新发布的应用上传APK文件,而应该使用Android应用包(AAB)。应用包包含了适用于所有屏幕密度、CPU架构和语言版本的编译后的代码及资源文件。当用户安装应用程序时,Google Play会自动生成仅包含该设备所需资源的APK文件。例如,对于使用英语设置且屏幕密度较高的手机来说,系统不会下载日文字符串或低密度图像文件,因此下载的大小也会更小。

在终端中执行以下命令来构建应用包:
./gradlew bundleRelease
此操作会编译发布版本的应用代码,运行R8工具进行优化,使用你的上传密钥对生成的结果文件进行签名,最后将打包好的应用包保存到app/build/outputs/bundle/release/app-release.aab路径下。你也可以在Android Studio中选择“Build > Generate Signed App Bundle or APK”,系统会通过向导完成同样的操作。如果构建过程中出现签名错误,请仔细检查keystore.properties文件中的路径和密码设置。 在上传应用包之前,每次新建版本时都需要增加versionCode的值。首次上传时可以使用1,第二次上传则必须使用2或更高的数值,依此类推。Play Store会拒绝任何versionCode值已经存在过的应用包,即使这些应用包是在不同的测试渠道中发布的。versionName可以取任意值,但像1.0.0、1.0.1或1.1.0这样的版本号既便于用户理解,也方便你进行版本管理。

如何创建Google Play开发者账户

访问play.google.com/console,使用你想要用来注册开发者账户的Google账号登录。建议专门为这个目的创建一个新账户,而不是使用个人的Gmail账号,因为之后更改账户所有者会非常麻烦。 系统会要求你在个人账户和组织账户之间进行选择。个人账户适用于个人用户,而组织账户则适用于已注册的企业,使用组织账户还需要提供D-U-N-S编号——这是由邓白氏公司提供的免费企业识别码,获取这个编号可能需要一周或更长时间。 这种选择不仅会影响后续的流程。新的个人账户在正式发布之前必须完成一段时间的封闭测试,而组织账户则不受此限制。你的开发者名称,以及对于组织账户和已开启盈利功能的个人账户来说,你的地址都会在应用商店的列表中公开显示。 支付25美元的一次性注册费用,然后完成身份验证流程。Google会要求你提供与政府颁发的身份证件相匹配的合法姓名和地址信息,同时会核实你的联系邮箱和电话号码。对于个人账户来说,系统还会要求你通过安装Play Console应用并登录来确认自己确实拥有一台可用的Android设备。整个验证过程通常需要几天时间,在验证完成之前你无法发布应用,因此建议尽早开始这个步骤,甚至可以在应用程序开发尚未完成时就进行身份验证。 在整个Android生态系统中,身份验证正变得越来越重要。从2026年底开始,Google已经开始要求所有安装在经过认证的Android设备上的应用都必须完成开发者身份验证,这一规定也适用于那些通过非Play Store渠道分发的应用。如果你是通过Play Store进行应用的发布的,并且已经完成了Play Console的身份验证流程,那么你就已经符合要求了;不过,这仍然是一个理由,提醒你必须确保自己的账户信息是准确无误的。

如何在Play Console中创建您的应用

一旦您的账户通过了验证,请在Play Console的主页上点击创建应用。输入应用名称,该名称将在Google Play上显示(长度最多为30个字符),选择默认语言,然后选择应用而非游戏,最后选择免费或付费选项。

在这里需要特别注意:您可以将付费应用 later 更改为免费模式,但免费的应用却永远无法变为付费应用。如果您想收取费用,可以选择使用应用内购买或订阅功能。同意开发者计划政策及美国出口法律声明后,再点击创建应用即可。

接下来,系统会将您带到应用控制面板,该页面会列出按不同类别分类的任务清单。Play Console会按照合理的顺序引导您完成这些任务,但其中前两个部分尤其容易让人感到困惑。

如何完成应用内容声明

在政策与计划 > 应用内容选项下(也可以通过控制面板的任务清单链接进入),Google会询问您一系列关于应用的问题。所有应用在发布之前都必须完成这些问题的填写,包括内部测试阶段。

隐私政策要求提供一段公开网址,用于说明您的应用收集了哪些数据以及这些数据是如何被使用的。即使您的应用没有收集任何数据,这一要求也同样适用。对于简单的应用来说,只需编写一段简短的文字,说明笔记内容仅存储在设备上,不会被收集或共享即可。您可以将这段文字发布在GitHub Pages、Google Sites或自己的网站上,但必须确保这是一个可正常访问的公开网页,而不能是需要登录才能查看的PDF文件或Google文档。

应用访问权限询问应用的某些功能是否需要用户登录才能使用。如果需要登录,您必须提供测试用的用户名和密码,以便审核人员能够访问所有页面。对于像“快速笔记”这样的应用来说,不需要登录功能,因此请选择“所有功能均无需特殊权限即可使用”这一选项。

广告询问您的应用是否包含广告,包括那些并非由您自行开发的SDK所生成的广告。请如实回答,因为含有广告的应用在应用商店的列表中会标注“包含广告”,审核人员也会对此进行核查。

内容分级是IARC针对暴力、性内容、语言使用、受管制物质、用户交互等方面设计的问题问卷。根据您的回答,系统会为应用程序确定适合的不同年龄段的评级(北美地区采用ESRB评级标准,欧洲地区采用PEGI评级标准等)。对于那些不允许用户分享内容的笔记应用来说,无论在哪个地区都会被评定为最低等级。

目标受众与内容询问您的应用是为哪些年龄段的用户设计的。如果您允许13岁以下的儿童使用您的应用,那么该应用就需要遵守针对家庭用户的政策规定,这些规定会对广告投放、数据收集以及SDK的使用等方面提出额外的要求。对于通用型工具应用来说,通常可以选择“18岁以上用户”或“13岁以上用户”作为目标受众范围,除非您的确以儿童为主要目标用户群体。

数据安全是其中最为详细的一项要求。它会在你的店铺信息中设置“数据安全”板块,并会询问你的应用程序是否收集或共享各类用户数据——比如位置信息、联系人资料、个人隐私信息以及应用使用记录等——同时还会明确这些数据的收集目的。需要注意的是,你需要对应用程序中所有功能模块所收集的数据负责,而不仅仅是你自己编写的代码所涉及的数据。分析工具、崩溃报告系统以及广告SDK都会收集数据,因此这些功能也必须被明确标注出来。

大多数主要的SDK都会发布页面,明确说明需要声明哪些内容。而Quick Notes并未使用任何这样的库,也不会进行任何网络请求,因此坦率地说,并没有数据被收集或共享。

对于其他类型的应用(新闻应用、政府应用、金融类应用、健康应用等),所需填写的信息通常都是简单的“是”或“否”的选项。对于笔记应用而言,这些选项的答案都是“否”。

如何设置你的应用店铺信息

在发展用户 > 店铺展示 > 主要店铺信息这一板块中显示的应用店铺信息,是用户在下载应用之前会看到的内容。这些信息需要包含以下文本和图片。

所需元素 要求
应用名称 最多30个字符
简短描述 最多80个字符
详细说明 最多4000个字符
应用图标 512 x 512像素的PNG格式图片,32位颜色深度且支持透明效果,文件大小不超过1MB
功能展示图 1024 x 500像素的JPEG格式图片或24位颜色的PNG格式图片,不支持透明效果
手机屏幕截图 2到8张截图,分辨率应为16:9或9:16,每张图片的尺寸应在320至3840像素之间

左列列出了所有必需的元素,右列则显示了Google目前对这些元素设定的限制。

在搜索结果中,简短描述会直接出现在应用名称下方,因此请将其视为你的应用简介,并在其中包含最重要的关键词。详细说明部分则是用通俗的语言来介绍应用的功能。虽然Google Play会对其进行索引以便用户搜索,但如果在详细说明中过度使用关键词,就会违反元数据政策,导致你的应用店铺信息被拒绝审核。

512像素大小的图标与应用程序内部使用的启动图标是分开的,不过它们的外观应该是一样的。Google会自动对图标进行圆角处理,因此请上传一个边缘没有经过特殊处理的正方形图片。功能展示图会在某些布局中显示在应用店铺信息的顶部,也会在应用推广活动中使用。

屏幕截图是决定用户是否下载应用的重要因素。请使用模拟器工具栏中的相机按钮来拍摄截图,并考虑为每张截图添加简短的说明文字,解释该截图所展示的功能。如果你希望你的应用在平板电脑和Chromebook上也有良好的显示效果,Play Console提供了专门用于平板电脑的截图选项,提供这些截图有助于提升你在这些设备上的应用排名。

此外,请在店铺设置页面中填写应用类别(对于Quick Notes来说,适合归类为“生产力工具”)、标签以及联系邮箱地址。联系邮箱地址会在应用店铺信息中公开显示。

如何进行内部测试和封闭测试

Google Play提供了多种发布渠道。内部测试允许你邀请最多100名测试者参与测试,构建版本通常在几分钟内就能准备好,且所需的审核流程也很简单。封闭测试则是为规模更大的测试群体准备的,你可以通过电子邮件列表或Google群组来管理这些测试者。公开测试则允许任何用户通过应用店铺信息来参与测试。而最终发布的版本就是正式版。

首先进行内部测试。进入测试与发布 > 测试 > 内部测试选项,创建一个包含测试人员的电子邮件列表(你可以把自己也加进去),然后点击创建新版本。首次操作时,Play Console会要求你确认是否使用Play应用签名功能。接受默认设置,这样Google就会生成并保护应用程序的签名密钥。接下来上传你的app-release.aab文件,填写发布说明,然后正式推出这个版本。

将测试人员选项卡中的链接分享给你的测试团队成员。他们可以通过浏览器接受邀请,之后就可以像安装其他应用一样,在Google Play上安装这个应用了。通过Play平台进行安装,而不是通过侧载方式安装,这样才能确保应用程序与Play应用签名功能能够正常配合使用。

如果你使用的是个人开发者账户,那么暂时还不能直接将应用发布到正式环境。2023年11月13日之后创建的个人账户,必须先进行封闭测试,至少需要12名测试人员连续14天都保持参与测试的状态。Google制定这一规则是为了减少低质量或被弃用的应用程序的数量,而这一点往往会让很多初次尝试发布应用的开发者感到意外。

在测试与发布 > 测试 > 封闭测试选项下设置相关配置。创建一个测试项目,通过电子邮件列表或Google群组添加测试人员,然后上传应用版本(你也可以将内部测试阶段使用的同一个版本直接推送到正式环境)。

尽早招募测试人员。朋友、家人、同事以及开发者社区都是常见的招募来源。这12名测试人员必须真正通过链接注册参与测试,而且这个数字指的是那些持续保持参与状态的测试人员;如果有人在第10天就退出了测试,那么你的测试时间就会受到影响。因此最好准备超过12名测试人员,以留出缓冲余地。

充分利用这14天的时间。要求测试人员提供反馈,在监控与优化 > Android应用运行状况选项中查看应用程序是否出现崩溃现象,并在发现问题后及时推送更新版本。每次更新都需要使用一个更高的versionCode值。当Google审核你的正式发布申请时,它会重点考察你是否真的利用测试结果对应用程序进行了改进,而不仅仅只是看时间是否已经过去了。

14天期限结束后,控制面板上会出现申请正式发布按钮。在填写申请信息时,需要详细说明你是如何招募测试人员的、收到了哪些反馈、根据这些反馈做出了哪些修改,以及应用程序是否已经准备好正式发布了。回答这些问题时要尽可能具体;像“测试人员觉得这个应用不错”这样的模糊答案很可能会导致申请被拒绝。通常来说,审核过程需要一周左右的时间。

如何将应用发布到正式环境

一旦获得了正式发布的权限,进入测试与发布 > 正式发布选项,然后点击创建新版本。你可以上传一个新的应用程序包,或者将内部测试阶段使用的版本推送到正式环境。在“新增功能”字段中填写发布说明,这些信息会显示在应用商店的列表页面以及更新通知中。

在正式推出应用之前,先在国家/地区选项卡中选择好应用程序要发布的国家和地区。然后仔细查看发布摘要。Play Console会显示那些会阻碍应用程序发布的错误信息(比如声明文件不完整),以及那些不会影响发布的警告信息(比如缺少某些语言版本的翻译内容)。修复这些错误,阅读警告提示,然后点击提交审核即可。

每一款新应用的发布以及每一次更新都会经过审核流程。对于全新的应用而言,首次审核可能需要长达七天的时间,有时甚至会更久;而后续的更新通常只需要一两天即可完成审核。如果应用被拒绝通过审核,Play Console会告知你违反了哪项规定,同时你的开发者邮箱也会收到相关通知。你需要解决这些问题,然后增加versionCode的值,再重新提交申请。

默认情况下,一旦审核通过,应用程序就会立即上线发布。如果你想要控制应用的发布日期,可以在发布概览页面中启用托管发布功能。这样,只有当你点击发布按钮后,已审核通过的更新才会真正生效——这一设置在你需要与博客文章或营销活动同步安排发布时间时非常有用。

应用上线后的更新发布流程

将你的应用推向市场仅仅只是维护工作的开始,而非项目的结束。以下三种习惯有助于确保更新过程顺利进行。

第一种习惯是分阶段进行更新推送。当你向正式用户群推送更新时,Play Console允许你指定一定比例的用户先接收更新,比如10%。观察一到两天内这些用户的应用崩溃率或无响应情况,如果没有问题再逐步提高这一比例。如果出现任何异常,你可以立即停止更新推送,这样剩余的用户就不会收到有问题的版本。

谷歌会根据Android Vitals提供的数据阈值来决定是否在应用列表中显示警告信息或降低该应用的可见度,因此在刚开始推广更新时就发现潜在问题是非常重要的。

第二种习惯是确保你的应用程序使用的目标SDK版本是最新的。谷歌Play每年都会提高更新所需的目标SDK版本要求。如果你的应用版本落后了,就无法再发布更新;而在过渡期内,使用最新Android版本的新生用户可能也会找不到你的应用。

提升targetSdk版本并不仅仅是简单地更改一个数字而已。你需要阅读每个新Android版本的相关文档,了解其带来的行为变化,并对自己的应用进行相应的测试。一个好的做法是每年春天都在测试设备或模拟器上安装Android测试版,这样就能确保在8月的截止日期前做好所有准备。

第三种习惯是提前规划数据库结构的变更。每当你在数据库中添加新的列或创建新的数据结构时,都需要在@Database注解中增加version字段的值,并为Room框架提供相应的迁移脚本。对于一些简单的修改,比如添加一个带有默认值的列,如果你在注解中指定autoMigrations = [AutoMigration(from = 1, to = 2)],Room框架会自动生成迁移脚本。如果忽略这一步骤,所有现有用户在打开更新后的应用时都会遇到崩溃问题,而这正是更新后导致用户给出差评的最常见原因之一。

总结

通过学习本手册中的内容,你已经使用谷歌目前推荐的工具成功开发出了一款完整的原生Android应用。

Jetpack Compose将用户界面定义为状态的功能,而“状态提升”机制使得像NoteListContent这样的界面组件更易于进行测试。Room框架提供了在编译时进行检查的存储机制,能够通过Flow流式传输数据;ViewModel则将这些数据以StateFlow的形式呈现出来,这样即使在设备旋转后,数据也能保持一致。而Navigation 3框架将返回栈视为一份普通的列表来管理,因此向前或向后导航其实就是执行add和removeLastOrNull操作而已。

在发布环节,你需要配置R8工具来压缩应用程序的大小,生成上传密钥并将其安全地存储在项目外部,然后制作经过签名的Android应用包。由于Google掌握了最终的签名密钥,因此丢失上传密钥并不会导致无法发布应用程序,这种情况是可以恢复的。

将应用程序发布到Google Play商店并不只是简单地上传文件而已。你需要验证自己的开发者身份,如实填写隐私政策、内容分级信息以及数据安全声明(包括第三方库所收集的数据),创建应用列表页面。对于个人账户来说,在申请正式发布权限之前,还需要进行为期14天的封闭测试,至少邀请12名测试人员参与测试。从一开始就为这些测试环节做好规划,是避免应用程序发布延迟的最佳方法。

随着应用程序规模的扩大,接下来的合理步骤包括:添加Hilt这样的依赖注入框架,将笔记数据同步到后端服务器,通过`strings.xml`文件添加第二种语言支持,以及设置持续集成流程,以便在每次进行版本更新时自动运行测试并生成签名后的应用包。无论应用程序变得多么庞大,你在本手册中遵循的整个开发流程都是不变的。

相关文章

技术实践

如何防止你的安卓应用程序因“唤醒锁定”功能而过度消耗电池电量

“唤醒锁”是Android系统中最简单的API之一,同时也是最容易被误用的功能之一。获取一个唤醒锁只需要一行代码;但如果忘记释放它,手机的CPU可能会持续工作数小时,从而导致电池在夜间被耗尽,同时你的应用程序也可能会在Google Play上收到警告标签。 大多数与唤醒锁相关的错误并不是由于对API本身的误解造成的,而是源于一些常见的控制流程问题:比如在调用 acquire() 和 release() 方法之间抛出了异常、程序过早退出、回调函数从未被执行,或者引用计数出现了失衡。 这些错误在代码审查过程中很难被发现,在常规测试中也无法显现出来,因为通常情况下设备都是处于连接电源且处于唤醒状态的

阅读全文
技术实践

为什么绝不应该在客户端代码中嵌入Gemini API密钥(以及Firebase AI逻辑是如何解决这个问题的)

生成式人工智能的快速发展促使成千上万的网页开发者在他们的应用程序中添加智能功能。 人们的第一反应通常是从浏览器直接调用Gemini API的SDK。然而,这种做法存在严重的安全风险:会将你的API密钥暴露给外界。 在本文中,你将了解到为什么将原始的Gemini API密钥提供给客户端是危险的,Firebase AI Logic的代理架构是如何解决这一问题的,以及Firebase App Check又是如何弥补单独使用代理所无法解决的问题。 阅读完本文后,你将能够搭建出一个可正常使用的生产环境配置:一个受到保护的AI Logic客户端、一个配置正确的App Check流程(其中包含调试令牌),以

阅读全文
技术实践

如何利用人工智能提示更快地构建网站用户界面

过去,创建网站的用户界面需要花费数小时的时间来手动编写HTML、CSS代码以及各种组件代码。但现在,你只需用简单的文字描述页面的结构,就可以让人工智能编码工具生成最初的代码版本。 不过,最终生成的代码质量取决于你提供的指令。如果指令过于简短或模糊,那么生成的代码可能只是包含随机颜色和重复代码的简单页面;而如果你能提供详细的说明——包括所使用的技术栈、文件结构以及设计规则,那么工具就能生成可实际使用的代码。 在本教程中,你将学习如何编写这样的指令。通过这些指令,你可以使用Next.js、Tailwind CSS以及shadcn/ui组件来构建一个带有固定导航栏的全屏页面。 完成本教程后,你将能够

阅读全文