freeCodeCamp.org

How to Build and Publish a Native Android App in 2026: From Jetpack Compose to the Google Play Store

8.5内容质量

TL;DR · AI 摘要

本文详解2026年Android开发全流程,涵盖Jetpack Compose项目构建、Google Play发布规范及新开发者必经步骤。

核心要点

  • Jetpack Compose已成Android默认UI框架,XML布局被淘汰
  • 新开发者需支付25美元注册费并完成28天测试期
  • 发布需使用Android App Bundle而非传统APK格式

结构提纲

按章节快速跳转。

  1. 指出传统教程缺失发布流程,介绍2026年Android开发重大变化

  2. 明确需要16GB内存、最新Android Studio和Google账号

  3. 演示使用Jetpack Compose、Room和Navigation 3构建笔记应用

  4. 分步骤讲解从签名密钥生成到Google Play审核的完整发布链路

  5. 强调新开发者必须完成内容声明、隐私政策等合规文件准备

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • Android开发与发布全链路
    • 开发阶段
      • Jetpack Compose UI
      • Room数据库
      • Navigation 3路由
    • 发布阶段
      • App Bundle构建
      • Google Play审核
      • 28天测试期

金句 / Highlights

值得收藏与分享的关键句。

#Jetpack Compose#Android开发#Google Play发布#Kotlin#Android Studio
打开原文

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

2026年10月19日

/

#Android

Nikheel Vishwas Savant

大多数Android教程都止步于"它能在模拟器上运行"。但要将一个空白项目转化为真实用户可以从Google Play安装的应用,还需要经历一个很少被集中讨论的流程:发布构建、签名密钥、应用包、商店列表、政策文件,以及新开发者账户必须经历的测试期。

过去几年Android开发发生了巨大变化。Jetpack Compose现在是默认的UI工具包,XML布局已成为遗留技术,官方模板只使用Kotlin作为开发语言,Google Play也加强了对新开发者的审核要求。

那些以findViewById开头或以上传APK结尾的旧指南,已不再符合当前的开发流程。

本手册将完整演示整个开发路径。你将使用Jetpack Compose、Room、ViewModel和Navigation 3构建一个小型但完整备忘录应用,编写几个测试用例,生成签名的发布包,然后通过Google Play Console完成从开发到正式发布的全流程。

目录

  • 先决条件
  • 你将构建的应用
  • 如何设置开发环境
  • 如何创建新Compose项目
  • 如何通过版本目录配置依赖项
  • Jetpack Compose的UI设计理念
  • 如何构建应用架构
  • 如何使用Room进行本地数据存储
  • 如何通过仓库和应用类暴露数据
  • 如何使用ViewModel管理UI状态
  • 如何构建备忘录列表界面
  • 如何构建备忘录编辑界面
  • 如何使用Navigation 3添加导航功能
  • 如何处理主题和全屏显示
  • 如何测试Composable组件
  • 如何为发布准备应用
  • 如何创建上传密钥并签名应用
  • 如何构建Android应用包
  • 如何创建Google Play开发者账户
  • 如何在Play Console中创建应用
  • 如何完成应用内容声明
  • 如何设置商店列表
  • 如何运行内部测试和封闭测试
  • 如何发布到生产环境
  • 如何在发布后推送更新
  • 总结

先决条件

你应该熟悉基本编程概念,并至少阅读过一些Kotlin教程。你不需要有Android开发经验,所有Android特定概念都会在相应章节详细解释。

所需工具包括:

  • 运行macOS、Windows或Linux系统的电脑,至少16GB内存(8GB也可,但模拟器会较慢)
  • 最新稳定版Android Studio
  • 一部物理Android手机(开发阶段可选,但后续Play Console账户验证必需)
  • Google账号和25美元用于一次性Google Play开发者注册费用

你将构建的应用

示例应用名为Quick Notes。它显示按最后编辑时间排序的备忘录列表,允许通过浮动操作按钮创建新备忘录,点击备忘录进行编辑,删除备忘录。所有数据都存储在设备本地的SQLite数据库中,因此应用可离线使用且无需后端支持。

一个笔记应用看起来很简单,但它涵盖了真实应用所需的所有核心部分:列表界面和详情界面、它们之间的导航以及可用的返回按钮、持久化存储、能够跨配置更改(如屏幕旋转)保持状态的数据,以及用户输入处理。一旦你理解了这些组件如何协同工作,就可以将笔记功能替换为其他任何内容。

最终你将得到如下高层架构:

code
+-------------------------------------------------------------+
|                         UI Layer                            |
|  MainActivity -> NavDisplay -> NoteListScreen               |
|                             -> NoteEditorScreen             |
|                  observes StateFlow, sends events           |
+------------------------------+------------------------------+
                               |
                               v
+-------------------------------------------------------------+
|                      NotesViewModel                         |
|      holds UI state, survives rotation, launches coroutines |
+------------------------------+------------------------------+
                               |
                               v
+-------------------------------------------------------------+
|                       Data Layer                            |
|   NoteRepository -> NoteDao -> NotesDatabase (Room/SQLite)  |
+-------------------------------------------------------------+

该图示展示了三个垂直堆叠的层级,箭头方向表示依赖关系的流向。

顶部的UI层包含活动和两个界面,它仅与ViewModel通信,从不直接访问数据库。

中间的ViewModel负责界面显示的状态,并能处理像屏幕旋转这类原本会导致界面销毁的事件。

底部的数据层封装了Room,这是在SQLite之上实现的类型化接口。数据以笔记流(Flow of notes)的形式向上层传递,因此当数据库发生变化时,UI会自动更新。

这是Google在其官方应用架构指南中推荐的分层架构。

如何设置开发环境

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

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

Google Play要求新应用和应用更新必须针对近期的API级别,且这一要求每年都会更新,通常在8月底。在开始开发前,请先查看目标API级别要求页面,了解需要达到的版本号。

接下来,从Tools > Device Manager创建模拟器。选择最近的Pixel手机型号和带有Google Play服务的最新系统镜像。

如果你更倾向于在真机上测试,请在设备的设置 > 关于手机中点击构建号七次以启用开发者选项,然后在开发者选项菜单中开启USB调试(或无线调试)。

在 Android Studio 中,选择 文件 > 新建 > 新建项目 并选择 空活动 模板。该模板基于 Compose。名称相似的 "Empty Views Activity" 使用较旧的 XML 布局系统,因此请确保选择正确的模板。

填写项目详细信息。名称是用户看到的显示名称(Quick Notes)。包名是您在 Google Play 上应用的永久且全局唯一的标识符,采用反向域名格式书写,例如 com.yourname.quicknotes。请仔细选择,因为发布后无法更改。将最低 SDK 设置为 API 24,该版本覆盖几乎所有活跃的 Android 设备,并将构建配置语言保留为 Kotlin DSL。

点击 完成 并等待首次 Gradle 同步完成。您获得的项目结构大致如下:

code
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 中,并通过别名从构建脚本中引用这些库。

打开该目录文件,将其内容替换为以下内容:

code
[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"
kotlin
[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 = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icons-core = { group = "androidx.compose.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.lifecycle", 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(物料清单)获取版本,BOM 会固定一组经过验证的 Compose 库版本。Kotlin Compose 编译器插件的版本与 Kotlin 本身保持一致,这是从 Kotlin 2.0 开始的特性。

KSP 是 Room 使用的注解处理器,用于生成数据库代码。此处的版本号在本文撰写时是当前有效的。Android Studio 会高亮显示目录中过时的条目,你应该接受其建议,将依赖项更新到最新稳定版本。

现在更新根 build.gradle.kts 文件,使插件对 app 模块可用:

code
plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.kotlin.compose) apply false
    alias(libs.plugins.kotlin.serialization) 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 的旧项目,仍然会看到该插件被应用,这没有问题。

然后替换 app/build.gradle.kts 的内容:

code
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.compose)
    alias(libs.plugins.kotlin.serialization)
    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.androidx.compose.bom)
    implementation(composeBom)
    androidTestImplementation(composeBom)

    implementation(libs.androidx.activity.compose)
    implementation(libs.androidx.compose.ui)
    implementation(libs.androidx.compose.ui.tooling.preview)
    implementation(libs.androidx.compose.material3)
    implementation(libs.androidx.compose.material.icons.core)
    implementation(libs.androidx.lifecycle.viewmodel.compose)
    implementation(libs.androidx.lifecycle.runtime.compose)
    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.androidx.compose.ui.test.junit4)
    debugImplementation(libs.androidx.compose.ui.tooling)
    debugImplementation(libs.androidx.compose.ui.test.manifest)
}

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

命名空间是生成的 R 类使用的包,而 applicationId 是设备和 Google Play 上应用的身份标识。它们通常匹配。

compileSdk 是您的代码编译所针对的 API 级别,targetSdk 告诉 Android 您的应用已测试的平台行为。当您提升 targetSdk 时,新的行为(如强制全屏绘制)会启用。

versionCode 是一个整数,每次上传到 Play 需要递增,而 versionName 是用户看到的可读字符串。

发布配置启用 R8,它会缩减、混淆并优化你的代码,而 isShrinkResources 会移除未使用的资源。

room 配置块告诉 Room 插件将数据库模式的 JSON 快照写入指定位置,这些快照在后续编写迁移脚本时会用到。

在依赖项中,platform(libs.androidx.compose.bom) 应用 BOM 以确保 Compose 库版本统一,ksp(...) 会在构建时运行 Room 编译器。debugImplementation 引入的 Compose 预览工具和测试清单仅在调试构建中包含,因此不会打包到用户最终版本中。

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

Jetpack Compose 的 UI 设计理念

在编写界面之前,理解 Compose 的底层模型很有帮助,因为它与传统的 View 系统有显著差异。

在 Compose 中,UI 是状态的函数。你编写用 @Composable 注解的函数来描述当前数据对应的界面形态。你永远不会直接操作视图并调用 setText 方法。当数据变化时,Compose 会用新数据重新调用你的函数,仅更新实际发生变化的界面部分。这种重新执行的过程称为重新组合(recomposition)。

以下是第一个实用示例:

code
@Composable
fun Counter() {
    var count by remember { mutableIntStateOf(0) }

    Button(onClick = { count++ }) {
        Text("Clicked $count times")
    }
}

Counter 函数通过 mutableIntStateOf(0) 声明了一个状态。Compose 会监控该状态的所有读取操作。当按钮的 onClick 回调增加 count 时,Compose 会检测到 Counter 读取了旧值,于是安排重新执行 Counter 函数,Text 组件就会显示新数值。

remember 调用确保了状态值在重新组合期间持续存在。如果没有它,每次函数执行时 count 都会重置为 0。

by 关键字是 Kotlin 的属性委托语法,它让你可以像普通变量一样读写 count,而无需使用 count.value。

remember 可以跨越重新组合保持状态,但无法应对配置变更(如屏幕旋转),因为此时 Android 会销毁并重新创建 Activity。对于需要跨旋转保持的简单值,应使用 rememberSaveable。对于更复杂的数据(如从数据库加载的数据),应将状态保存在 ViewModel 中,ViewModel 的生命周期比 Activity 更长。在本应用中你将同时使用这两种方式。

另一个核心概念是状态提升(state hoisting)。拥有自己状态的可组合函数难以复用和测试。更好的模式是将状态作为参数传入,将事件作为 lambda 传出,这样可组合函数只显示给定内容并报告用户操作。调用方决定状态存储位置。你将在列表界面看到这种模式,它使测试变得非常直接。

如何构建应用

在 com.yourname.quicknotes 下右键点击包,选择 New > Package 创建以下包结构:

code
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

该结构按层级组织文件。

数据包包含所有与存储相关的元素:Note实体、定义查询的数据访问对象(DAO)、Room数据库类,以及应用其他部分使用的仓库(repository),而不是直接与Room交互。

ui包包含ViewModel和两个屏幕,以及模板生成的主题文件。

导航包包含标识每个屏幕的键。MainActivity是承载所有Compose UI的单一活动,NotesApp是一个自定义的Application类,用于为整个应用一次性创建数据库。

对于更大的应用,你可能会将这些拆分为单独的Gradle模块,但在此情况下,使用包已经足够。

如何使用 Room 本地存储数据

Room 是 Jetpack 持久化库。你可以用 Kotlin 类描述表,用带注解的接口方法描述查询,Room 会在编译时生成 SQLite 代码。由于构建期间会检查查询,列名拼写错误会变成编译错误,而不是导致用户手机上的崩溃。

从 data/Note.kt 中的实体类开始:

code
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 updatedAt: Long = System.currentTimeMillis()
)

@Entity 告诉 Room 这个类映射到名为 notes 的表,每个属性会变成一列。

id 字段是主键,autoGenerate = true 让 SQLite 在插入 id 为 0 的行时分配新 ID。这就是为什么 id 默认值是 0:新笔记在保存前没有真实 ID。

updatedAt 属性存储以毫秒为单位的时间戳,并在创建 Note 对象时默认为当前时间,这使应用能够知道哪些笔记最近被编辑过。

使用普通数据类可以免费获得 equals、hashCode 和 copy 方法,Compose 依赖这些方法来判断列表项是否发生变化。

接下来在 data/NoteDao.kt 中定义查询:

code
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 updatedAt DESC")
    fun observeAll(): Flow<List<Note>>

    @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<List<Note>> 而不是普通列表。Flow 是 Kotlin 协程的流,当底层数据变化时会发出新值,因此每次插入、更新或删除笔记时,Room 会自动重新运行查询并发出最新列表。这就是列表屏幕无需手动刷新逻辑也能保持同步的原因。

其他方法标记为 suspend,这意味着它们必须从协程中调用。Room 在后台线程运行它们,因此数据库操作不会阻塞主线程并冻结 UI。

@Upsert 在主键不存在时插入行,在存在时更新行,这让一个方法可以同时处理创建和编辑笔记。

第二个查询中的 :id 语法安全地将方法参数绑定到 SQL 语句,无需字符串拼接。

现在在 data/NotesDatabase.kt 中创建数据库:

code
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() 的实现。

当应用发布后,版本字段就变得重要了。如果之后向 Note 添加了新列,必须提升版本号并提供迁移方案,否则 Room 会拒绝打开用户设备上的现有数据库。

exportSchema = true 会将每个模式版本的 JSON 描述写入 Gradle 配置的 schemas 文件夹。请将该文件夹提交到版本控制系统中,因为 Room 会使用这些文件来验证并自动生成迁移。

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

仓库是 ViewModel 和 Room 之间的薄层。在这个应用中,它主要负责转发调用,但它为你提供了一个添加缓存、网络同步或验证的统一入口,而无需修改 UI 代码。创建 data/NoteRepository.kt:

code
package com.yourname.quicknotes.data

import kotlinx.coroutines.flow.Flow

class NoteRepository(private val noteDao: NoteDao) {

    fun observeNotes(): Flow<List<Note>> = 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)
}

仓库通过构造函数接收 NoteDao 而不是自己创建,这是最简单的依赖注入形式。由于依赖是通过参数传入的,测试时可以向仓库传递一个假的 DAO。公共方法使用领域语言(save、observeNotes)而不是数据库语言(upsert、observeAll),这样可以防止 Room 的实现细节泄露到应用其他部分。

由于打开 SQLite 数据库成本较高且多个实例可能产生冲突,因此每个应用进程应只创建一次数据库。在没有依赖注入框架的情况下,常用的方法是创建自定义的 Application 子类。在根包中创建 NotesApp.kt:

code
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 类的实例,并在整个进程生命周期内保持该实例。这使其成为存放全局单例对象的理想位置。

by lazy 委托会将数据库的创建延迟到首次访问 repository 时,这有助于保持应用启动速度。

Room.databaseBuilder 接收上下文、数据库类和文件名,build() 返回生成的实现。对于大型应用,Hilt 或 Koin 可以帮你管理这些连接,但手动实现方式能清晰展示这些框架自动化的具体过程。

为了让 Android 使用这个类,需要在 AndroidManifest.xml 中通过为 <application> 标签添加 android:name 属性进行注册:

code
<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 文件:

code
package com.yourname.quicknotes.ui

import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
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.coroutines.flow.stateIn
import kotlinx.coroutines.launch

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

    val notes: StateFlow<List<Note>> = 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() 返回一个冷流(cold Flow),只有在有收集者时才会执行工作。stateIn 操作符将其转换为 StateFlow,该流始终保存当前值(初始为空列表),UI 可以立即读取。SharingStarted.WhileSubscribed(5_000) 会在 UI 观察期间以及停止观察后 5 秒内保持数据库查询活跃。这 5 秒的宽限期意味着快速旋转屏幕不会中断并重启查询,而将应用置于后台更长时间会释放查询资源。

第二个思路是 viewModelScope 。保存和删除操作是挂起函数,因此需要协程。viewModelScope 是一个与 ViewModel 生命周期绑定的协程作用域,当 ViewModel 被清除时,其中启动的任何工作都会自动被取消。

在 saveNote 方法中,null ID 表示“这是一个新笔记”,因此会被设为 0,Room 会生成一个新的 ID。非空 ID 则会更新现有记录。由于每次保存都会创建新的 Note 对象,因此 updatedAt 会被刷新为当前时间。

第三个思路是工厂模式。Android 会为你创建 ViewModel,这样它可以跨屏幕旋转保持存活,这意味着你不能直接调用构造函数。

工厂告诉 Android 在需要时如何构建 ViewModel。viewModelFactory 和 initializer 构建器从创建参数中提取 Application 实例,将其转换为 NotesApp,并将其仓库传递给构造函数。

如何构建笔记列表界面

列表界面分为两个可组合项。一个有状态的包装器连接到 ViewModel,一个无状态的内容可组合项负责实际绘制。创建 ui/NoteListScreen.kt :

code
package com.yourname.quicknotes.ui

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.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 androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.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 by viewModel.notes.collectAsStateWithLifecycle()

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

@OptIn(ExperimentalMaterial3Api::class) @Composable fun NoteListContent( notes: List<Note>, 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) { Icon(Icons.Default.Delete, contentDescription = "Delete ${note.title}") } } } }

code

NoteListScreen 是唯一知晓 ViewModel 的组件。它调用 collectAsStateWithLifecycle(),该函数会将 StateFlow 转换为 Compose 状态,与普通的 collectAsState() 不同的是,当应用进入后台时会停止收集。这可以节省电量,避免对不可见屏幕执行数据库操作。随后会将原始列表和一些 lambda 表达式传递给 NoteListContent。

viewModel::deleteNote 的语法是函数引用,这是将 { note -> viewModel.deleteNote(note) } 简写的一种方式。

NoteListContent 是无状态组件。它通过参数接收所有数据,并通过 lambda 表达式上报点击事件,这实际上就是状态提升的实现方式。在内部,Scaffold 是 Material 3 的布局组件,它为顶部应用栏和浮动操作按钮提供插槽,并计算 innerPadding 以确保内容不会被这些栏或系统状态栏/导航栏遮挡。

当列表为空时,屏幕会显示居中的提示信息。否则会使用 LazyColumn,它只会对当前可见的行进行组合,类似于旧版 View 系统中的 RecyclerView。

关键参数 key = { it.id } 为每一行赋予稳定的标识,这样当删除笔记时,Compose 能准确识别需要移除的行,而不是重新绘制下方所有内容。

NoteCard 组件渲染单个笔记。修饰符链按顺序配置布局和行为:fillMaxWidth() 使卡片横跨整个屏幕,clickable 使整个卡片响应点击操作。

在 Row 组件内部,Modifier.weight(1f) 指示文本列占据删除按钮测量后剩余的所有水平空间。图标上的 contentDescription 值会被 TalkBack(安卓屏幕阅读器)朗读。将笔记标题包含在删除按钮的描述中,能让 TalkBack 用户听到 "Delete Groceries" 而不是一排相同的 "Delete" 按钮。

## 如何构建笔记编辑界面

编辑器同时处理新笔记和已有笔记。创建 ui/NoteEditorScreen.kt 文件:

package com.yourname.quicknotes.ui

import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.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 } }

code

Scaffold( topBar = { TopAppBar( title = { Text(if (noteId == null) "New note" else "Edit note") }, navigationIcon = { IconButton(onClick = onDone) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } }, actions = { TextButton( onClick = { viewModel.saveNote(noteId, title, body) onDone() }, enabled = title.isNotBlank() ) { Text("Save") } } ) } ) { innerPadding -> Column( modifier = Modifier .fillMaxSize() .padding(innerPadding) .imePadding() .padding(16.dp) ) { OutlinedTextField( value = title, onValueChange = { title = it }, label = { Text("Title") }, singleLine = true, modifier = Modifier.fillMaxWidth() ) Spacer(Modifier.height(12.dp)) OutlinedTextField( value = body, onValueChange = { body = it }, label = { Text("Note") }, modifier = Modifier.fillMaxWidth().weight(1f) ) } }

code

编辑器使用 `rememberSaveable` 将用户输入的文本保存在本地状态中,因此在屏幕旋转时未完成的笔记仍会保留。`loaded` 标志用于跟踪现有笔记是否已从数据库中读取。对于新笔记,该标志初始值为 `true`(无需加载),而对于已有笔记则为 `false`。

`LaunchedEffect(noteId)` 在可组合项首次出现时启动协程,并且仅在 `noteId` 发生变化时再次运行。在此过程中,屏幕会向 ViewModel 请求笔记,并将标题和正文内容复制到文本字段中。

在加载前检查 `loaded` 状态非常重要。如果没有这个检查,屏幕旋转会导致效果再次运行,并用数据库中保存的版本覆盖用户输入的内容。

`OutlinedTextField` 是 Material 3 的文本输入组件。Compose 的文本字段是受控组件:字段会显示你传入的值,当用户输入时,它会通过 `onValueChange` 回调传递新文本。如果忘记在回调中更新 `title`,字段会因为 Compose 持续重绘旧值而显得冻结。标题为空时,“Save” 按钮会被禁用,点击按钮会通过 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

code

每个目的地都是实现 NavKey 接口的类型。NoteList 不需要参数,因此它是一个数据对象,即一个带有可读 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 androidx.compose.runtime.Composable import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.ui.NavDisplay import com.yourname.quicknotes.navigation.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<NoteList> { NoteListScreen( viewModel = notesViewModel, onAddNote = { backStack.add(NoteEditor()) }, onOpenNote = { id -> backStack.add(NoteEditor(id)) } ) } entry<NoteEditor> { key -> NoteEditorScreen( noteId = key.noteId, viewModel = notesViewModel, onDone = { backStack.removeLastOrNull() } ) } } ) }

code

MainActivity 现在只是一个宿主。enableEdgeToEdge() 使应用能够绘制在状态栏和导航栏后面(关于这一点将在下一节详细说明),而 setContent 用 Compose 树替换了传统的XML布局,并用模板生成的主题包裹。

QuickNotesNavHost 是导航发生的地方。rememberNavBackStack(NoteList) 创建了一个可保存的返回栈,从列表屏幕开始。ViewModel 通过你之前编写的工厂创建一次,并在两个屏幕之间共享,因为它们处理的是相同的笔记。

NavDisplay 在堆栈顶部渲染键的条目。entryProvider 块通过 entry<T> 将每种键类型映射到其对应的可组合项。对于 NoteEditor,lambda 表达式接收键实例,因此 key.noteId 可直接获取类型化参数。

向前导航使用 backStack.add(...),返回操作使用 backStack.removeLastOrNull()。这就是基本导航的完整 API。

当用户按下系统返回按钮或执行返回手势时会触发 onBack,它会移除顶部条目。Navigation 3 还支持预测性返回,即用户滑动时前一个屏幕会以动画形式预览,无需您编写额外代码。

现在运行应用。您应该能够添加笔记、在列表中看到它们、点击编辑、删除笔记、旋转设备而不会丢失数据,并且关闭再重新打开应用后所有笔记仍然存在。

## 如何处理主题和全屏显示

模板创建了 ui/theme 包,包含 Color.kt、Type.kt 和 Theme.kt。主题函数大致如下:

@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 ) }

code

MaterialTheme 会将颜色和字体样式提供给其下方的所有 Material 3 组件,这就是为什么您的屏幕中可以使用 MaterialTheme.typography.titleMedium 等调用。

when 代码块选择颜色方案。在 Android 12(API 31,VERSION_CODES.S)及更高版本中,动态颜色会根据用户的壁纸生成调色板,使应用与手机其他部分风格一致。在旧版本或设置 dynamicColor 为 false 时,会回退到 Color.kt 中定义的固定 LightColorScheme 和 DarkColorScheme。isSystemInDarkTheme() 会遵循系统的深色模式设置。

如果需要在所有地方使用一致的品牌颜色,请设置 dynamicColor = false 并在 Color.kt 中编辑颜色。Material 主题生成器可以从单个种子颜色生成完整的明暗配色方案。

全屏显示意味着应用会在系统栏后方绘制,而不是在它们之间绘制的框内。目标为 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

code

import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.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("No notes yet").assertIsDisplayed() }

@Test fun tappingANoteReportsItsId() { var openedId: Long? = null val notes = listOf(Note(id = 42, title = "Groceries", body = "Milk, eggs"))

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

composeRule.onNodeWithText("Groceries").performClick() assertEquals(42L, openedId) }

@Test fun deleteButtonIsLabelledForScreenReaders() { val notes = listOf(Note(id = 1, title = "Groceries", body = ""))

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

composeRule.onNodeWithContentDescription("Delete Groceries").assertIsDisplayed() } }

code

createComposeRule() 会创建一个空的 Activity,用于在隔离环境中渲染任意 Composable。Compose 测试通过语义树查找元素,这与辅助功能服务读取的结构相同。onNodeWithText 通过可见文本查找节点,onNodeWithContentDescription 通过辅助功能标签查找节点。

第一个测试验证当列表为空时是否显示空状态。第二个测试传递一个记录接收到 ID 的 lambda 表达式,点击笔记后断言屏幕报告了正确的 ID。第三个测试检查删除按钮是否具有有意义的标签,这同时也作为辅助功能检查。这些测试都不涉及 Room 或 ViewModel,这正是将状态从 NoteListContent 中提取出来的实际优势。

右键点击文件并选择 "Run 'NoteListContentTest'",或者从终端运行:

code
./gradlew connectedDebugAndroidTest

这个 Gradle 任务会构建应用的调试版本和一个独立的测试 APK,将两者安装到连接的模拟器或手机上,运行 androidTest 中的所有测试,并将 HTML 报告写入 app/build/reports/androidTests/connected/。仪器化测试需要运行设备。对于不依赖 Android 的逻辑(如验证规则或格式辅助函数),将单元测试放在 test 文件夹中,并通过 ./gradlew testDebugUnitTest 运行,这可以在几秒钟内在你的电脑上完成。

如何为发布准备应用

调试构建适用于开发,但在其他人安装你的应用之前,需要处理几项重要事项。

替换默认应用图标

code

模板中自带通用的绿色Android图标。右键点击res文件夹,选择New > Image Asset。Image Asset Studio会生成一个自适应图标,该图标包含独立的前景层和背景层,这样启动器可以将其裁剪成圆形、圆角矩形或其他形状。

请将作品的重要部分保留在预览中显示的中央安全区域内,因为在某些启动器上边缘会被裁剪。同时请提供一个单色图层。Android 13及更高版本会使用该图层生成主题图标,此时启动器会将所有图标着色以匹配壁纸,缺少该图层的应用在这些设备上会显得格格不入。

### 将硬编码字符串移入资源文件

本文中的界面使用了"Quick Notes"和"No notes yet"等硬编码字符串以保持代码可读性。在实际应用中,应将这些字符串移入res/values/strings.xml文件,并通过stringResource(R.string.no_notes)读取。

这种做法使翻译成为可能,因为Android会自动为用户的语言选择对应的strings.xml文件。Android Studio会通过lint警告标记硬编码字符串,并可通过"Extract string resource"快速修复功能为您提取这些字符串。

### 使用R8检查发布构建

您已在发布构建类型中启用了isMinifyEnabled,这会激活R8。R8会移除未使用的代码,缩短类名和方法名,并优化剩余代码。这通常会使应用体积明显减小且运行更快,但可能会破坏依赖反射的代码,因为R8无法检测到未被直接引用的类。

Room、Compose和kotlinx.serialization已自带保留规则,因此本应用无需额外配置。如果添加使用反射的库,请查阅其文档并按需向proguard-rules.pro添加规则。

发现R8问题的唯一方法是实际运行一次发布构建。从Build > Select Build Variant中选择发布版本(在下一节配置发布签名前会使用调试签名密钥),安装应用后逐一点击每个界面进行测试。仅在发布构建中出现的崩溃几乎总是缺失保留规则导致的。

### 移除不必要的权限

打开合并后的清单文件(AndroidManifest.xml底部的Merged Manifest标签页),查看列出的权限。库文件可能会添加您从未请求的权限,而您声明的每个权限都需要在Play Console中进行合理性说明。Quick Notes所有数据均本地存储,因此不应需要任何权限,甚至包括INTERNET权限。更少的权限会使Play Console中的数据安全表单更易于填写。

## 如何创建上传密钥并签署应用

每个Android应用在安装前都必须进行加密签名。Google Play使用名为Play App Signing的双密钥系统。您使用自行创建并保管的上传密钥对上传文件进行签名。Google会验证上传内容,剥离您的签名,并使用Google自己安全基础设施中存储的应用签名密钥重新签署应用。用户最终看到的只有应用签名密钥。

这种分隔方式的优势在于可恢复性。如果丢失上传密钥或发生泄露,您可以从Play Console请求Google重置密钥,而用户仍能继续接收更新。在Play App Signing出现之前,丢失唯一的签名密钥意味着您将永远无法再次更新应用。

使用 Play 应用签名对于新应用是强制要求,这也是 Android App Bundle 能够实现的关键,因为 Google 需要签名密钥来为每台设备生成优化后的 APK。

通过终端中的 keytool 工具(该工具包含在 Android Studio 的 JDK 中)创建上传密钥:

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

code

此命令会在 ~/keys/quicknotes-upload.jks 路径下创建一个 Java 密钥库文件,其中包含一个别名为 upload 的密钥对。-keyalg RSA -keysize 2048 设置了 Google Play 期望的密钥类型和强度,-validity 10000 使密钥有效期约为 27 年。Google Play 要求密钥至少要到 2033 年 10 月仍然有效。

该工具会提示输入密钥库密码,并要求输入您的姓名和组织信息,这些信息会存储在证书中。如果 keytool 不在您的系统路径中,可以通过 Android Studio 的 Build > Generate Signed App Bundle 或 APK 向导通过对话框创建相同的密钥。

将密钥库存储在项目文件夹之外的某个位置,并备份到密码管理器或加密存储中。永远不要将密钥库提交到 Git。

接下来,在项目根目录中创建 keystore.properties 文件以保存签名信息:

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

code

该文件映射了 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 (keystorePropertiesFile.exists()) { 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") } } }

code

import 语句必须位于文件最顶部,插件块上方。该脚本会在文件存在时将 keystore.properties 读取到 Properties 对象中。

在 signingConfigs 中,它会从这些值创建一个名为 release 的配置,但仅当文件存在时才会创建。这个保护机制很重要,因为克隆仓库的同事或运行测试的 CI 服务器可能没有您的密钥库,但构建仍应能正常处理调试任务。

发布构建类型则使用 findByName("release") ,当配置不存在时该方法会返回 null 而不是报错。在 CI 流水线中,通常会从环境变量或密钥管理器中读取这些值,而不是从文件中读取。

## 如何构建 Android 应用包

Google Play 不再接受新应用的 APK 文件。您需要上传 Android 应用包(AAB)文件。应用包包含所有针对不同屏幕密度、CPU 架构和语言的编译代码和资源。当用户安装应用时,Google Play 会生成仅包含该设备所需内容的 APK。例如,使用英文地区设置且屏幕密度高的手机不会下载日文字符串或低密度图片,因此下载体积更小。

从终端构建应用包:

./gradlew bundleRelease

code

该任务会编译发布版本,运行 R8 优化,使用上传密钥对结果进行签名,并将应用包写入 app/build/outputs/bundle/release/app-release.aab。您也可以在 Android Studio 中使用 "Build > Generate Signed App Bundle or APK" 功能,通过向导完成相同操作。如果构建因签名错误失败,请检查 keystore.properties 文件中的路径和密码是否正确。

上传前请为每次提交到 Play 的新版本递增 versionCode。首次上传可使用 1,后续版本必须为 2 或更高。Play 会拒绝 versionCode 与已有版本重复的任何应用包,即使该版本属于不同的测试轨道。versionName 可以是任意值,但语义化版本(如 1.0.0、1.0.1、1.1.0)对用户和开发者都更易于理解。

## 如何创建 Google Play 开发者账户

访问 play.google.com/console 并使用您希望关联开发者账户的 Google 账户登录。建议为开发者账户创建专用账户,而非使用个人 Gmail 账户,因为后续转移账户所有权会很麻烦。

您需要在个人账户和组织账户之间做出选择。个人账户面向个人开发者,组织账户面向注册企业,需要提供 D-U-N-S 号码(邓白氏公司提供的免费商业标识符,获取可能需要一周或更长时间)。

这个选择会影响更多流程。新注册的个人账户在首次发布到生产环境前必须完成封闭测试期(详见下文),而组织账户则无需满足此要求。您的开发者名称(以及组织账户或 monetized 个人账户的地址)会显示在商店列表中。

支付一次性 25 美元注册费用后,完成身份验证。Google 需要您提供与政府身份证件匹配的法定姓名和地址,验证联系邮箱和电话号码。对于个人账户,还需要您安装 Play Console 应用并登录以确认拥有实体 Android 设备。验证通常需要几天时间。在验证完成前无法发布应用,因此建议尽早开始此步骤,即使应用尚未完成。

身份验证在 Android 生态系统中的重要性日益增加。Google 正在向所有安装在认证 Android 设备上的应用(包括 Google Play 以外分发的应用)逐步推出开发者验证功能,首批覆盖 2026 年底部分国家。如果您通过 Play 发布且已完成 Play Console 验证,已满足要求,但这再次强调了确保账户信息准确的重要性。

## 如何在 Play Console 中创建应用

账户验证完成后,在 Play Console 首页点击 **创建应用**。输入应用名称(在 Google Play 上显示的名称,最多 30 个字符),选择默认语言,选择 **应用** 而非 **游戏**,并选择 **免费** 或 **付费**。

此处需特别注意。你可以将付费应用改为免费,但免费应用永远无法转为付费。如果需要收费,请使用应用内购买或订阅功能。接受 **开发者计划政策** 和 **美国出口法律声明**,然后点击 **创建应用**。

你将进入应用仪表板,这里会以分组形式显示任务清单。Play Console 会按合理顺序引导你完成这些任务,但接下来的两个部分是用户最容易混淆的内容。

## 如何完成应用内容声明

在 **政策与计划 > 应用内容**(也可从仪表板清单链接进入)中,Google 会询问一系列关于应用的问题。所有应用在发布到任何轨道(包括内部测试)前都必须完成这些声明。

**隐私政策** 需要提供一个公开 URL,指向描述应用收集数据方式及用途的页面。即使应用不收集任何数据,这也属于强制要求。对于 Quick Notes,只需一个简短页面说明“笔记仅存储在设备本地,不收集或共享任何数据”即可。你可以使用 GitHub Pages、Google Sites 或自有网站托管,但必须是一个可公开访问的网页,不能是 PDF 或需要登录的 Google Docs。

**应用访问** 会询问应用是否需要登录。如果需要,必须提供测试账号,以便审核人员访问所有界面。Quick Notes 不需要登录,因此选择“所有功能无需特殊权限即可使用”。

**广告** 会询问应用是否包含广告(包括你未自行开发的 SDK 广告)。请如实回答,因为包含广告的应用会在商店页面显示“包含广告”标签,且审核人员会核查。

**内容评级** 是 IARC 关于暴力、性内容、语言、管制物质、用户互动等主题的问卷。你的回答将生成全球各地区(北美 ESRB、欧洲 PEGI 等)的年龄评级。一个不包含用户生成内容分享的笔记应用将获得所有地区最低评级。

**目标受众与内容** 会询问应用的目标年龄群体。如果你包含 13 岁以下儿童,应用将受“家庭政策”约束,需满足广告、数据收集和 SDK 相关的额外要求。对于通用工具类应用,除非目标用户确实是儿童,否则请选择“18 岁及以上”或从 13 岁开始的年龄范围。

**数据安全** 是最详细的声明表单。它会生成商店页面的“数据安全”部分,并询问应用是否收集或共享各类用户数据(如位置、联系人、个人资料、应用活动等)及其用途。关键点在于:你需对应用中所有库收集的数据负责,而不仅是自己代码。分析、崩溃报告和广告 SDK 都会收集数据,必须声明。

大多数主流 SDK 都会发布说明页面,明确需要声明的内容。Quick Notes 不使用任何此类库且不进行网络请求,因此诚实回答是“不收集或共享任何数据”。

其余声明(新闻应用、政府应用、金融功能、健康应用等)均为是非题。对于笔记应用,所有问题的答案均为“否”。

## 如何设置商店页面

应用商店列表(位于“增长用户 > 商店存在 > 主商店列表”)是用户在安装前看到的内容,需要包含以下文本和图形。

**资源** | **要求**  
---|---  
应用名称 | 最多30个字符  
简短描述 | 最多80个字符  
完整描述 | 最多4000个字符  
应用图标 | 512 x 512像素 PNG,32位带透明通道,最大1 MB  
功能图形 | 1024 x 500像素 JPEG 或 24位 PNG,无透明通道  
手机截图 | 2至8张图片,比例16:9或9:16,每边像素在320至3840之间  

左侧列列出每项必填资源,右侧列显示Google当前的限制。

简短描述会直接显示在搜索结果中应用名称下方,因此要将其视为你的核心卖点,并放入最重要的关键词。完整描述用于用通俗语言解释功能,Google Play会对其进行索引,但过度堆砌关键词会违反元数据政策,可能导致商店列表被拒绝。

512像素图标与应用内的启动器图标是独立的,但视觉风格应保持一致。Google会自行应用圆角遮罩,因此上传时请使用完整方形图片,无需预先设置圆角或阴影。功能图形在部分布局中会显示在商店列表顶部,当应用被推广时也会显示。

截图是影响用户是否安装的最关键因素。请通过模拟器的摄像头按钮截取截图,并考虑添加简短说明以解释每张截图的功能。若希望应用在平板电脑和Chromebook上显示良好,Play Console提供了专门的平板截图上传入口,提供这些截图可提升在这些设备上的商店列表效果。

同时在商店设置页面填写应用分类(如“生产力”适合“快速笔记”)、标签和联系邮箱地址。联系邮箱会公开显示在商店列表中。

## 如何运行内部测试和封闭测试

Google Play提供多种发布渠道。内部测试适用于通过电子邮件添加的最多100名测试者,构建版本通常几分钟内即可通过最小审核上线。封闭测试适用于由电子邮件列表或Google Group管理的更大测试群体。开放测试允许任何人通过商店列表加入。生产发布是面向公众的正式版本。

建议从内部测试开始。进入“测试与发布 > 测试 > 内部测试”,创建测试者电子邮件列表(可包含自己),点击“创建新版本”。首次操作时,Play Console会要求确认“Google Play应用签名”。接受默认设置,允许Google生成并保护应用签名密钥。随后上传你的app-release.aab文件,输入发布说明并发布版本。

通过“测试者”标签页分享加入链接给测试者。他们通过浏览器接受邀请后,即可像安装其他应用一样从Google Play安装应用。通过Play安装而非侧载,可验证捆绑包与Play应用签名的端到端兼容性。

如果你创建的是个人开发者账号,无法立即发布生产版本。2023年11月13日后创建的个人账号必须先进行封闭测试,且需至少有12名测试者连续14天保持加入状态。Google引入此规则旨在减少低质量应用和废弃应用,这也是最容易让初次开发者感到意外的步骤。

在“测试和发布 > 测试 > 封闭测试”下进行设置。创建一个轨道,通过电子邮件列表或Google Group添加测试人员,并上传一个版本(可以将内部测试中相同的构建版本推广至封闭测试)。

尽早招募测试人员。通常的招募来源包括朋友、家人、同事和开发者社区。12名测试人员必须通过链接主动加入,且统计的是持续保持加入状态的测试人员数量,因此如果某人在第10天退出,将会影响你的进度。建议招募超过12人以预留缓冲空间。

充分利用14天时间。要求测试人员提交反馈,监控Monitor中的崩溃情况并优化Android vitals,同时在解决问题后推送更新版本。每次更新都需要更高的versionCode。当Google审核你的生产环境访问申请时,会关注你是否通过测试改进了应用,而不仅仅是时间是否已过。

14天结束后,仪表板会显示“申请生产环境”按钮。申请时需要说明你是如何招募测试人员的、收到哪些反馈、因此做了哪些修改,以及应用是否已准备好发布。请具体作答。模糊的回答(如“测试人员喜欢这个应用”)是常见的被拒绝原因。审核通常需要最多一周时间。

## 如何发布到生产环境

获得生产环境访问权限后,前往“测试和发布 > 生产环境”并点击“创建新版本”。你可以上传新的应用包或从测试轨道推广已有版本。在“新功能”字段中添加版本说明。用户会在商店页面和更新通知中看到这些信息。

发布前,选择在哪些国家和地区上线应用,这可以在“生产环境”页面的“国家/地区”标签下进行设置。然后查看版本摘要。Play Console会显示任何阻止发布的错误(如声明不完整)和不影响发布的警告(如缺少翻译)。修复错误,阅读警告,然后点击“提交审核”。

所有新应用和更新都需要经过审核。对于全新应用,首次审核可能需要最多7天,偶尔会更久。后续更新通常需要1-2天。如果应用被拒绝,Play Console和你的开发者邮箱会说明违反了哪项政策。修复问题后,增加versionCode并重新提交。

默认情况下,审核通过的版本会在通过审核后立即上线。如果你想控制发布日期,请在“发布概览”页面开启“管理发布”。审核通过的更改将等待你点击“发布”按钮,这在与博客文章或营销日期协调时非常有用。

## 发布后如何推送更新

让应用上线只是维护工作的开始,而非项目结束。三个习惯能让更新流程更加顺畅。

第一个是分阶段发布。当你向生产环境推送更新时,Play Console允许你选择部分用户接收更新,例如10%的用户。在Android vitals中观察一两天的崩溃率和ANR(应用无响应)率,然后逐步增加比例。如果出现问题,可以暂停发布,剩余用户将不会收到损坏的版本。

Google会根据Android vitals的阈值决定是否在你的应用页面显示警告或降低其可见性,因此在10%用户比例时发现问题至关重要。

第三点是为数据库变更做好规划。当你向Note表添加一列或创建新实体时,需要在@Database注解中递增版本号,并为Room提供迁移方案。对于简单变更(例如添加带有默认值的列),如果在注解中声明autoMigrations = [AutoMigration(from = 1, to = 2)],Room可以利用你导出的schema JSON文件自动生成迁移方案。跳过这一步会导致所有现有用户在打开更新后的应用时立即崩溃,这也是更新后出现一星评价的最常见原因之一。

## 总结

在本指南中,你使用了Google目前推荐的工具构建了一个完整的原生Android应用。

Jetpack Compose将UI描述为状态的函数,状态提升模式使NoteListContent等界面保持易于测试。Room提供了编译时校验的存储方案,通过Flow流式传输更新,ViewModel则将数据暴露为可跨屏幕旋转的StateFlow。Navigation 3将返回栈视为普通列表,因此前进和后退操作只需执行add和removeLastOrNull。

在发布方面,你配置了R8进行代码缩减,创建了上传密钥并将其安全地存储在项目之外,构建了签名的Android App Bundle。Play App Signing意味着Google持有最终签名密钥,因此丢失上传密钥是可以恢复的,而非致命错误。

向Google Play发布应用不仅仅是上传APK。你验证了开发者身份,诚实地填写了隐私政策、内容评级和数据安全声明(包括第三方库收集的数据),创建了商店列表,并在申请生产环境访问权限前,为个人账户运行了至少12名测试者的14天封闭测试。从一开始就规划测试窗口是避免发布延迟的最关键措施。

接下来的合理步骤包括:随着应用增长添加依赖注入框架(如Hilt),将笔记同步到后端,通过strings.xml添加第二种语言,以及设置CI流水线在每次标记发布时运行测试并构建签名包。无论应用规模如何扩大,你在本指南中遵循的完整流程始终保持不变。

合作伙伴推荐课程

(在新标签页中打开)

ADVERTISEMENT

- Udemy Jetpack Compose Android开发Kotlin快速入门 Catalin Ghita 4.5星(3,999人评价) 10小时57分钟 初学者 证书 查看课程 (在新标签页中打开)

- Coursera Jetpack Compose高级Android开发 Packt - 课程讲师 5周课程,每周2小时 中级 证书 查看课程 (在新标签页中打开)

- Udemy Android Jetpack Compose:全面入门课程 Paulo Dichone 4.5星(3,101人评价) 1天12小时45分钟 初学者 证书 查看课程 (在新标签页中打开)

- 在Coursera上:中级Jetpack Compose与移动应用开发(Packt课程)- 课程讲师 6周,每周2小时 中级 证书 查看课程(在新标签页中打开)

Class Central上的更多Android开发课程

→

阅读更多文章。

如果这篇文章对你有帮助,请分享它。

免费学习编程。freeCodeCamp的开源课程已帮助超过40,000人成为开发者。立即开始