零基础也能看懂的Android编程实战指南:从HelloWorld到完整App开发全流程解析(含真实项目案例)

第一章:别怕,我们先认识一下这个”小绿人”

你是不是经常看到别人手机上那些花里胡哨的应用,心里暗暗想:”要是我也能做一个该多好?”别急,我们今天就从零开始,一步步把想法变成现实。

先来说说我认识”小绿人”的那一天——好吧,其实它不叫小绿人,它的正式名字叫Android,是一个以Linux内核为基础开发的移动操作系统。但为什么我们要用它学编程呢?原因很简单:

Android的应用生态太丰富了

你打开Google Play Store或者国内的应用商店,随便看看就能发现,几乎所有类型的App都在Android上运行。从社交聊天到健康管理,从在线教育到智能家居控制,Android几乎无所不包。这意味着你学会之后,完全有施展才华的空间。

Android的开发门槛其实不高

很多人被吓退,是因为听说了什么”需要精通Java”、”要学完一千行代码才能上手”之类的谣言。真相是:Google官方现在推荐的开发语言是Kotlin,这是一门非常简洁的现代编程语言,它的语法设计得非常贴近人类语言,写出来的代码干净得像诗一样。

让我给你展示一下真正的Kotlin代码长什么样:

// 这是一个最基础的Kotlin变量声明
val userName = "小明"  // 相当于:"把'小明'这个字符串存进一个叫userName的盒子"
var age = 25            // 这个盒子可以装不同的值,比如后面改成30

// 函数声明,相当于一个"动作"
fun greetUser(name: String) {
    println("你好,$name!今天天气真不错!")
}

// 调用函数
greetUser(userName)

看到没有?是不是比想象中的简单多了?

不过说实话,我第一次学Android开发的时候也走了一些弯路。我花了整整两周时间才搞明白一个事情:Android Studio这个开发工具怎么用。光是配置环境就让我差点放弃。但后来我发现,只要搞清楚几个关键步骤,其实也就那样。

第二章:先把”工厂”建好——开发环境搭建全攻略

2.1 下载Android Studio

这就像你想做菜,得先有个厨房。Android Studio就是Android开发的”厨房”,是Google官方出的集成开发环境(IDE)。

下载地址:https://developer.android.google.cn/studio

打开这个网址,你会看到一个非常直观的下载页面。选择对应你操作系统的版本(Windows、Mac或者Linux)点击下载就行。下载完成后双击安装,一路”下一步”就可以了。

这里有个小提醒:安装过程中会让你选择安装组件,直接全选即可,不要有任何犹豫。这些组件都是开发必需的。

2.2 配置Java环境(JDK)

Android开发需要Java Development Kit(JDK)来运行。很多人到这里就卡住了,但其实操作非常简单:

步骤:
1. 访问 Oracle官网 或 华为的开源镜像站下载JDK
2. 推荐下载JDK 17版本(这是目前Android开发的主流版本)
3. 安装时记住安装路径,比如:C:\Java\jdk-17

装好后需要配置环境变量,这一步Windows和Mac的操作略有不同:

Windows系统操作指南:

1. 右键点击"此电脑" → 属性 → 高级系统设置
2. 点击"环境变量"按钮
3. 在"系统变量"区域找到"Path",点击"编辑"
4. 点击"新建",输入你的JDK安装路径,例如:
   C:\Java\jdk-17\bin
5. 再新建一个系统变量:
   变量名:JAVA_HOME
   变量值:C:\Java\jdk-17
6. 点击确定保存所有设置

配置完成后,按Win + R键,输入cmd打开命令提示符,输入以下命令验证:

java -version
javac -version

如果显示出版本号信息(比如java version "17.0.1"),恭喜你,JDK配置成功了!

Mac系统操作指南:

# 在终端输入以下命令
/usr/libexec/java_home -V
# 这会列出所有已安装的Java版本

# 编辑 ~/.zshrc 文件(如果你用bash则编辑 ~/.bash_profile)
open -e ~/.zshrc

# 在文件末尾添加以下内容:
export JAVA_HOME=$(/usr/libexec/java_home -v 17)
export PATH="$JAVA_HOME/bin:$PATH"

# 保存后执行以下命令使配置生效
source ~/.zshrc

# 验证安装
java -version

2.3 创建第一个虚拟手机

Android Studio自带一个非常强大的功能——模拟器。你可以不用真机,在电脑上模拟各种Android设备。

启动Android Studio后,你会看到一个欢迎界面。点击“Configure” → “Virtual Device Manager”(虚拟设备管理器),你会看到一个设备列表。

对于初学者,我建议选择:

设备推荐:
- Pixel 6 或 Pixel 7(最新主流机型)
- 系统镜像:选择 Android 14(API 34)或 Android 13(API 33)
- 分辨率:默认即可
- RAM:建议分配4096MB以上
- 内部存储:建议10GB以上

创建完成后,点击绿色的播放按钮,模拟器就会启动。第一次启动可能需要几分钟,请耐心等待。看到启动画面变成Google的Android图标后,恭喜你,你的”虚拟手机”已经准备好了!

2.4 创建第一个项目

回到Android Studio主界面,点击“New Project”(新建项目),你会看到几个模板选择:

推荐的起步模板:
✅ Empty Activity —— 最干净的起点,从零开始学习
✅ Basic Activity —— 带有一个基本界面的模板
❌ 其他复杂模板 —— 暂时不要选,先打好基础

我强烈建议你选择Empty Activity,因为这样你可以清楚地看到每一个组件是如何添加的,不会被一堆预先写好的代码淹没。

填写项目信息时,按照以下建议填写:

项目名称:MyFirstApp(你可以改成任何你喜欢的名字)
包名:com.example.myfirstapp(反向域名格式,保证唯一性)
保存位置:选择一个你容易找到的文件夹
语言:选择 Kotlin
最小SDK版本:API 24(Android 7.0,覆盖99%以上的设备)

点击”Finish”按钮,Android Studio会自动开始下载和构建项目。第一次构建可能需要较长时间(因为要下载各种依赖),喝杯咖啡休息一下。

构建完成后,点击运行按钮(绿色三角形),选择你创建的模拟器,程序就会自动安装并运行在你的虚拟手机上。

看到屏幕上显示”Hello, World!“或者你项目名的时候,你的Android开发之旅就正式开始了!

第三章:HelloWorld背后的秘密——项目结构大揭秘

很多初学者一看到Android项目那几十个文件夹就懵了。别担心,我们一个一个来拆解。

3.1 文件结构速览

一个标准的Android项目看起来是这样的:

MyFirstApp/
├── app/                           # 核心模块(你的应用都在这里)
│   ├── src/
│   │   ├── main/
│   │   │   ├── java/com/example/myfirstapp/
│   │   │   │   └── MainActivity.kt  # 主界面逻辑代码
│   │   │   ├── res/                 # 资源文件目录
│   │   │   │   ├── drawable/        # 图片资源
│   │   │   │   ├── layout/          # 界面布局文件
│   │   │   │   ├── values/          # 字符串、颜色、样式等
│   │   │   │   └── mipmap/          # 应用图标
│   │   │   ├── AndroidManifest.xml  # 应用清单文件(非常重要!)
│   │   │   └── Android.mk           # 构建脚本
│   │   ├── test/                    # 单元测试
│   │   └── androidTest/             # 仪器测试
│   ├── build.gradle.kts             # 模块级构建配置
│   └── proguard-rules.pro           # 代码混淆规则
├── build.gradle.kts                 # 项目级构建配置
├── gradle/                          # Gradle包装器
├── gradle.properties                # Gradle属性配置
├── settings.gradle.kts              # 项目设置
└── local.properties                 # 本地路径配置(SDK路径等)

我知道这看起来很吓人,但我们一个一个来。

3.2 AndroidManifest.xml——应用的”身份证”

这个文件是Android项目的核心配置文件,相当于应用的”身份证”和”说明书”。系统通过它来识别你的应用。

<!-- AndroidManifest.xml 完整解读 -->
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.myfirstapp">

    <!-- 声明应用需要的权限 -->
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

    <!-- 应用的基本信息 -->
    <application
        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.MyFirstApp">

        <!-- 定义主活动(启动时显示的第一个页面) -->
        <activity
            android:name=".MainActivity"
            android:exported="true">         <!-- 是否允许其他应用启动这个Activity -->
            
            <!-- 意图过滤器:声明这个Activity是启动入口 -->
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
            
        </activity>

    </application>

</manifest>

让我用大白话解释一下关键部分:

package属性:这是你的应用的唯一标识符,类似身份证号。安装App Store时,Google用这个来区分不同的应用。一旦发布就不能改了,所以创建项目时要想好。

uses-permission:这些是你应用需要使用的系统权限。比如INTERNET表示需要联网权限,CAMERA表示需要相机权限。Google Play对权限审核非常严格,只申请你真正需要的权限。

application标签:这里定义了应用的通用属性。比如icon是指应用的图标,label是指应用显示的名称(通常引用values文件夹下的字符串资源)。

activity标签:每一个屏幕就是一个Activity。name=".MainActivity"表示这个Activity的完整类名是com.example.myfirstapp.MainActivity(点号开头表示相对于package)。exported="true"表示这个Activity可以被其他应用启动(比如点击通知栏消息跳转到你的应用)。

intent-filter:这是意图过滤器,告诉系统这个Activity可以响应什么类型的意图。MAIN动作加上LAUNCHER类别,就表示这个Activity是应用的启动入口。

3.3 build.gradle.kts——构建脚本

Android项目使用Gradle作为构建工具,通过Gradle来管理依赖、编译代码和打包应用。

// app/build.gradle.kts —— 模块级构建配置
plugins {
    id("com.android.application")    // Android应用插件
    id("org.jetbrains.kotlin.android")  // Kotlin支持插件
}

android {
    namespace = "com.example.myfirstapp"  // 命名空间
    compileSdk = 34  // 编译SDK版本(使用最新的Android版本)
    
    defaultConfig {
        applicationId = "com.example.myfirstapp"  // 应用唯一标识
        minSdk = 24  // 最低支持的Android版本
        targetSdk = 34  // 目标Android版本
        versionCode = 1  // 内部版本号(上架时递增)
        versionName = "1.0"  // 对外显示的版本号
    }

    buildTypes {
        release {
            isMinifyEnabled = false  // 是否启用代码混淆
            proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
        }
    }
    
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
    
    kotlinOptions {
        jvmTarget = "17"  // Kotlin编译目标JVM版本
    }
    
    buildFeatures {
        viewBinding = true  // 启用ViewBinding(推荐,替代findViewById)
    }
}

dependencies {
    // AndroidX核心库
    implementation("androidx.core:core-ktx:1.12.0")
    implementation("androidx.appcompat:appcompat:1.6.1")
    implementation("com.google.android.material:material:1.11.0")
    implementation("androidx.constraintlayout:constraintlayout:2.1.4")
    
    // 网络请求(Retrofit)
    implementation("com.squareup.retrofit2:retrofit:2.9.0")
    implementation("com.squareup.retrofit2:converter-gson:2.9.0")
    
    // 图片加载(Glide)
    implementation("com.github.bumptech.glide:glide:4.16.0")
    
    // 生命周期管理
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
    
    // 测试依赖
    testImplementation("junit:junit:4.13.2")
    androidTestImplementation("androidx.test.ext:junit:1.1.5")
    androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}

让我解释一下关键配置:

compileSdk:你指定要用哪个版本的Android SDK来编译你的应用。建议始终使用最新稳定版本。

minSdk:你的应用最低支持到哪个Android版本。设置为24意味着只能运行在Android 7.0及以上的设备上,这个设置会排除大约1%的设备。

targetSdk:你测试过并且适配到哪个版本。这个值应该与compileSdk保持一致。

buildFeatures.viewBinding = true:这是Google推荐的替代findViewById的方式。它通过自动生成绑定类来安全地访问布局中的视图,避免了空指针异常。

dependencies:这里声明了项目需要的所有外部库(依赖)。每个依赖都有group:artifact:version格式。Gradle会自动从Maven仓库下载这些库。

第四章:界面设计——用XML搭建你的第一屏

Android的界面设计主要使用XML语言。XML(可扩展标记语言)是一种很简单的标记语言,看起来有点像HTML,但用途完全不同。

4.1 从ConstraintLayout说起

ConstraintLayout是Android开发的现代布局方式,它能让界面在各种屏幕尺寸上完美适配。你可能会觉得它比传统的LinearLayout和RelativeLayout复杂,但一旦掌握了约束的思维方式,你会发现它非常强大。

让我用一个实际的例子来说明。假设我们要做一个登录界面,包含用户名输入框、密码输入框和登录按钮:

<!-- res/layout/activity_login.xml -->
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/white"
    android:padding="24dp"
    tools:context=".ui.login.LoginActivity">

    <!-- 应用Logo -->
    <ImageView
        android:id="@+id/iv_logo"
        android:layout_width="120dp"
        android:layout_height="120dp"
        android:src="@drawable/ic_app_logo"
        android:contentDescription="@string/app_name"
        app:layout_constraintBottom_toTopOf="@id/tv_welcome"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_chainStyle="packed"
        android:layout_marginBottom="16dp" />

    <!-- 欢迎标题 -->
    <TextView
        android:id="@+id/tv_welcome"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/welcome_back"
        android:textColor="@color/black"
        android:textSize="28sp"
        android:textStyle="bold"
        app:layout_constraintBottom_toTopOf="@id/et_email"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/iv_logo"
        android:layout_marginBottom="32dp" />

    <!-- 邮箱输入框 -->
    <com.google.android.material.textfield.TextInputLayout
        android:id="@+id/til_email"
        style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="@string/enter_email"
        app:hintTextColor="@color/blue"
        app:boxStrokeColor="@color/blue"
        app:endIconMode="clear_text"
        app:layout_constraintBottom_toTopOf="@id/et_password"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/tv_welcome"
        android:layout_marginBottom="16dp">

        <com.google.android.material.textfield.TextInputEditText
            android:id="@+id/et_email"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:inputType="textEmailAddress"
            android:maxLines="1" />

    </com.google.android.material.textfield.TextInputLayout>

    <!-- 密码输入框 -->
    <com.google.android.material.textfield.TextInputLayout
        android:id="@+id/til_password"
        style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="@string/enter_password"
        app:hintTextColor="@color/blue"
        app:boxStrokeColor="@color/blue"
        app:endIconMode="password_toggle"
        app:layout_constraintBottom_toTopOf="@id/btn_login"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/til_email"
        android:layout_marginBottom="24dp">

        <com.google.android.material.textfield.TextInputEditText
            android:id="@+id/et_password"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:inputType="textPassword"
            android:maxLines="1" />

    </com.google.android.material.textfield.TextInputLayout>

    <!-- 登录按钮 -->
    <com.google.android.material.button.MaterialButton
        android:id="@+id/btn_login"
        android:layout_width="match_parent"
        android:layout_height="56dp"
        android:text="@string/login"
        android:textSize="16sp"
        android:textStyle="bold"
        app:cornerRadius="12dp"
        app:layout_constraintBottom_toTopOf="@id/tv_forgot_password"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/til_password"
        android:layout_marginBottom="16dp" />

    <!-- 忘记密码链接 -->
    <TextView
        android:id="@+id/tv_forgot_password"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/forgot_password"
        android:textColor="@color/blue"
        android:textSize="14sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/btn_login" />

</androidx.constraintlayout.widget.ConstraintLayout>

这个XML文件看起来有点长,但每一个标签的含义都很清楚。让我拆解一下关键概念:

命名空间(xmlns):XML文件顶部的那些xmlns:xxx声明,就像是给标签起了一个”前缀”。xmlns:android声明了Android系统的命名空间,这样我们就可以使用Android系统提供的所有属性,比如android:layout_width、android:text等。xmlns:app声明了我们自己定义的命名空间(通常用于Material Design组件的属性)。

约束(Constraints):ConstraintLayout的核心思想是通过约束来定位视图。每个视图都需要至少四个约束(上、下、左、右),才能确定它的位置。比如:

app:layout_constraintBottom_toTopOf="@id/btn_login"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/til_email"

这四行约束的意思是:这个视图的底部连接到登录按钮的顶部,左边和右边都连接到父容器的边缘,顶部连接到邮箱输入框的底部。这样就确定了一个水平居中、垂直位置在邮箱输入框和登录按钮之间的位置。

宽高属性:

  • match_parent:填满父容器的整个宽度或高度
  • wrap_content:根据内容自动调整大小
  • 具体数值(如120dp):固定大小

dp和sp:

  • dp(密度无关像素):用于布局尺寸,保证在不同屏幕上物理大小一致
  • sp(缩放无关像素):用于字体大小,会跟随系统的字体大小设置变化

4.2 颜色与字符串资源管理

在实际开发中,我们永远不要在XML里硬编码颜色值或字符串。这会导致后期维护非常痛苦。正确的做法是创建资源文件:

<!-- res/values/colors.xml -->
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!-- 主题颜色 -->
    <color name="primary">#2196F3</color>        <!-- 主色调(蓝色) -->
    <color name="primary_dark">#1976D2</color>   <!-- 深色版本 -->
    <color name="accent">#FF5722</color>         <!-- 强调色(橙红色) -->
    
    <!-- 背景颜色 -->
    <color name="background">#FAFAFA</color>     <!-- 浅灰背景 -->
    <color name="surface">#FFFFFF</color>        <!-- 卡片表面 -->
    
    <!-- 文字颜色 -->
    <color name="text_primary">#212121</color>   <!-- 主要文字 -->
    <color name="text_secondary">#757575</color> <!-- 次要文字 -->
    <color name="text_hint">#BDBDBD</color>      <!-- 提示文字 -->
    
    <!-- 辅助颜色 -->
    <color name="white">#FFFFFF</color>
    <color name="black">#000000</color>
    <color name="blue">#2196F3</color>
    <color name="green">#4CAF50</color>
    <color name="red">#F44336</color>
    <color name="yellow">#FFC107</color>
</resources>
<!-- res/values/strings.xml -->
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!-- 应用基本信息 -->
    <string name="app_name">MyFirstApp</string>
    
    <!-- 登录界面 -->
    <string name="welcome_back">欢迎回来!</string>
    <string name="enter_email">请输入邮箱地址</string>
    <string name="enter_password">请输入密码</string>
    <string name="login">登录</string>
    <string name="forgot_password">忘记密码?</string>
    <string name="no_account">还没有账号?立即注册</string>
    
    <!-- 错误提示 -->
    <string name="error_email_empty">邮箱不能为空</string>
    <string name="error_email_invalid">邮箱格式不正确</string>
    <string name="error_password_empty">密码不能为空</string>
    <string name="error_password_too_short">密码至少需要6位</string>
    <string name="error_network">网络连接失败,请检查设置</string>
    <string name="error_login_failed">登录失败,请重试</string>
    
    <!-- 成功提示 -->
    <string name="success_login">登录成功!</string>
    <string name="success_register">注册成功!请登录</string>
    
    <!-- 通用 -->
    <string name="cancel">取消</string>
    <string name="confirm">确定</string>
    <string name="loading">加载中...</string>
    <string name="retry">重试</string>
</resources>

使用资源文件的好处非常明显:

  1. 统一管理:如果需要修改应用名称或某个文案,只需修改strings.xml中的一个地方
  2. 多语言支持:可以为每种语言创建单独的strings.xml文件
  3. 类型安全:IDE会自动检查资源引用是否有效
  4. 团队协作:设计师和开发者可以各自管理不同的资源文件

4.3 Material Design组件库

Google推出的Material Design是一套完整的UI设计规范,Android Studio内置了Material Components库,让我们可以轻松地创建符合现代设计标准的界面。

<!-- res/values/themes.xml -->
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!-- 基础主题 -->
    <style name="Base.Theme.MyFirstApp" parent="Theme.Material3.DayNight.NoActionBar">
        <!-- 自定义颜色 -->
        <item name="colorPrimary">@color/blue</item>
        <item name="colorPrimaryVariant">@color/blue_dark</item>
        <item name="colorOnPrimary">@color/white</item>
        <item name="colorSecondary">@color/accent</item>
        <item name="colorOnSecondary">@color/white</item>
        
        <!-- 状态栏颜色 -->
        <item name="android:statusBarColor">@color/white</item>
        <item name="android:windowLightStatusBar">true</item>
        
        <!-- 字体样式 -->
        <item name="fontFamily">@font/roboto</item>
    </style>

    <!-- 应用主题 -->
    <style name="Theme.MyFirstApp" parent="Base.Theme.MyFirstApp" />
</resources>

Material Design 3(Material You)是最新版本,它支持动态配色、圆角设计、统一的组件样式等特性。使用Material Components库时,你会发现很多组件都已经内置了动画效果和交互动画,不需要你额外编写代码。

第五章:Kotlin基础——用现代语法写Android代码

现在我们已经了解了项目结构和界面设计,接下来进入最核心的部分——用Kotlin编写业务逻辑。

5.1 变量与数据类型

// 不可变变量(推荐使用val)
val appName = "MyFirstApp"           // 字符串
val version = 1                       // 整数
val pi = 3.14159                      // 浮点数
val isLoggedIn = false               // 布尔值
val userEmail: String? = null        // 可空字符串

// 可变变量(仅在必要时使用var)
var loginAttempt = 0
loginAttempt++  // 递增

// 类型推断(Kotlin可以自动推断类型)
val name = "张三"      // 自动推断为String
val age = 25          // 自动推断为Int
val score = 98.5      // 自动推断为Double

// 字符串模板(非常方便的字符串拼接方式)
val greeting = "你好,$name!你今年$age岁了。"
val mathResult = "2 + 3 = ${2 + 3}"  // 可以插入表达式

为什么推荐val而不是var?

这是一个非常好的问题。在Kotlin中,优先使用val(不可变变量)而不是var(可变变量),这有几个原因:

  1. 线程安全:不可变变量在多线程环境下不会出问题
  2. 可预测性:变量的值不会意外改变,代码更容易理解和调试
  3. 编译器优化:编译器可以对不可变变量做更好的优化
  4. 减少bug:很多bug都是因为变量的值被意外修改导致的

只有当你确定需要修改变量的值时,才使用var。

5.2 函数与Lambda表达式

// 基础函数定义
fun login(email: String, password: String): Boolean {
    // 参数验证
    if (email.isBlank() || password.isBlank()) {
        return false
    }
    
    // 模拟登录逻辑
    val isValidEmail = android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches()
    val isValidPassword = password.length >= 6
    
    return isValidEmail && isValidPassword
}

// 带默认参数的函数
fun showMessage(message: String, duration: Int = 2000) {
    // duration默认值为2000毫秒(2秒)
    Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}

// 单表达式函数(简洁写法)
fun calculateTotal(price: Double, taxRate: Double = 0.1): Double 
    = price * (1 + taxRate)

// Lambda表达式(匿名函数)
val sortNames: (String, String) -> Boolean = { name1, name2 ->
    name1.length > name2.length
}

// 高阶函数(接受函数作为参数)
fun performAction(action: () -> Unit) {
    // 执行前
    showLoading()
    
    // 执行传入的action
    action()
    
    // 执行后
    hideLoading()
}

// 使用lambda
performAction {
    fetchDataFromServer()
}

// List的高级操作(函数式编程)
val users = listOf(
    User("小明", 25, "xm@example.com"),
    User("小红", 30, "xh@example.com"),
    User("小李", 22, "xl@example.com")
)

// 过滤
val adults = users.filter { it.age >= 18 }

// 映射
val emails = users.map { it.email }

// 查找
val found = users.find { it.name == "小红" }

// 排序
val sorted = users.sortedBy { it.age }

5.3 类与对象

// 数据类(自动提供toString、equals、hashCode等方法)
data class User(
    val name: String,
    val age: Int,
    val email: String
)

// 使用示例
val user1 = User("小明", 25, "xm@example.com")
val user2 = User("小红", 30, "xh@example.com")

// 复制(不可变数据类的强大功能)
val user1Updated = user1.copy(age = 26)

// 解构(可以同时获取多个值)
val (name, age, email) = user1
println("名字:$name,年龄:$age,邮箱:$email")

// 密封类(表示受限的类继承结构)
sealed class Resource<out T> {
    data class Success<T>(val data: T) : Resource<T>()
    data class Error(val message: String) : Resource<Nothing>()
    object Loading : Resource<Nothing>()
}

// 使用密封类
fun handleResource(resource: Resource<String>) {
    when (resource) {
        is Resource.Success -> {
            println("数据:${resource.data}")
        }
        is Resource.Error -> {
            println("错误:${resource.message}")
        }
        Resource.Loading -> {
            println("加载中...")
        }
    }
}

5.4 空安全——Kotlin的杀手锏

Java中最令人头疼的问题之一就是空指针异常(NullPointerException)。Kotlin从语言层面解决了这个问题。

// 非空类型(不允许为null)
val name: String = "Hello"
// name = null  // ❌ 编译错误!

// 可空类型(允许为null,用?标记)
val nickName: String? = null
nickName = "Hello"  // ✅ 可以赋值

// 安全调用运算符(?.)
val length = nickName?.length  // 如果nickName为null,返回null而不是崩溃

// 空合并运算符(??)
val safeName = nickName ?: "默认昵称"  // 如果nickName为null,使用默认值

// 非空断言(!!)—— 仅在确定不为null时使用
val definitelyNotNull = nickName!!  // 如果nickName为null,会抛出异常!

// Elvis运算符的实用场景
fun getUserName(user: User?): String {
    return user?.name ?: "匿名用户"
}

// 结合when表达式的安全处理
fun describeUser(user: User?) {
    when (user) {
        null -> println("用户不存在")
        else -> println("用户:${user.name},年龄:${user.age}")
    }
}

第六章:Activity与Fragment——应用的基本组件

Android应用由四大组件构成:Activity、Service、BroadcastReceiver和ContentProvider。其中Activity是最核心的组件,它代表一个屏幕界面。

6.1 Activity的生命周期

理解Activity生命周期是Android开发的基础。每个Activity从创建到销毁都会经历一系列状态变化:

创建流程:
onCreate() → onStart() → onResume()

暂停流程:
onPause() → onStop() → onRestart() → onStart() → onResume()

销毁流程:
onPause() → onStop() → onDestroy()

让我用一个实际的项目来说明每个阶段该做什么:

// MainActivity.kt
class MainActivity : AppCompatActivity() {
    
    private lateinit var binding: ActivityMainBinding  // ViewBinding
    private lateinit var viewModel: MainViewModel
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        // 1. 初始化ViewBinding(替代setContentView + findViewById)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        
        // 2. 初始化ViewModel
        viewModel = ViewModelProvider(this)[MainViewModel::class.java]
        
        // 3. 设置UI组件
        setupViews()
        
        // 4. 观察数据变化
        observeData()
        
        // 5. 处理保存的状态(如屏幕旋转后恢复数据)
        savedInstanceState?.let { restoreState(it) }
    }
    
    private fun setupViews() {
        // 按钮点击事件
        binding.btnLogin.setOnClickListener {
            val email = binding.etEmail.text.toString().trim()
            val password = binding.etPassword.text.toString().trim()
            
            if (validateInputs(email, password)) {
                viewModel.login(email, password)
            }
        }
        
        // 导航到注册页面
        binding.tvRegister.setOnClickListener {
            startActivity(Intent(this, RegisterActivity::class.java))
        }
    }
    
    private fun observeData() {
        // 观察登录状态(使用LiveData)
        viewModel.loginState.observe(this) { resource ->
            when (resource) {
                is Resource.Loading -> {
                    binding.progressBar.visibility = View.VISIBLE
                    binding.btnLogin.isEnabled = false
                }
                is Resource.Success -> {
                    binding.progressBar.visibility = View.GONE
                    binding.btnLogin.isEnabled = true
                    navigateToHome()
                }
                is Resource.Error -> {
                    binding.progressBar.visibility = View.GONE
                    binding.btnLogin.isEnabled = true
                    showError(resource.message)
                }
            }
        }
    }
    
    override fun onStart() {
        super.onStart()
        // 页面可见时更新UI
        binding.tvStatus.text = "页面可见"
    }
    
    override fun onResume() {
        super.onResume()
        // 页面获得焦点时执行(如刷新数据)
        viewModel.refreshUserData()
    }
    
    override fun onPause() {
        super.onPause()
        // 页面失去焦点时执行(如保存数据)
        saveCurrentState()
    }
    
    override fun onStop() {
        super.onStop()
        // 页面不可见时执行(如停止网络请求)
        viewModel.stopBackgroundTasks()
    }
    
    override fun onDestroy() {
        super.onDestroy()
        // 清理资源(取消订阅、关闭数据库等)
        binding.etEmail.setText("")
        binding.etPassword.setText("")
    }
    
    private fun validateInputs(email: String, password: String): Boolean {
        // 清空之前的错误提示
        binding.tilEmail.error = null
        binding.tilPassword.error = null
        
        var isValid = true
        
        if (email.isBlank()) {
            binding.tilEmail.error = getString(R.string.error_email_empty)
            isValid = false
        } else if (!android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches()) {
            binding.tilEmail.error = getString(R.string.error_email_invalid)
            isValid = false
        }
        
        if (password.isBlank()) {
            binding.tilPassword.error = getString(R.string.error_password_empty)
            isValid = false
        } else if (password.length < 6) {
            binding.tilPassword.error = getString(R.string.error_password_too_short)
            isValid = false
        }
        
        return isValid
    }
    
    private fun navigateToHome() {
        val intent = Intent(this, HomeActivity::class.java)
        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)
        startActivity(intent)
        finish()  // 关闭当前Activity
    }
    
    private fun showError(message: String) {
        binding.snackbarHost.showSnackbar(message)
    }
    
    private fun restoreState(savedInstanceState: Bundle) {
        // 恢复用户输入(如屏幕旋转后)
        val savedEmail = savedInstanceState.getString("email")
        val savedPassword = savedInstanceState.getString("password")
        binding.etEmail.setText(savedEmail)
        binding.etPassword.setText(savedPassword)
    }
    
    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        // 保存用户输入(防止屏幕旋转丢失)
        outState.putString("email", binding.etEmail.text.toString())
        outState.putString("password", binding.etPassword.text.toString())
    }
    
    // 处理返回键
    override fun onBackPressed() {
        // 自定义返回行为
        if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) {
            binding.drawerLayout.closeDrawer(GravityCompat.START)
        } else {
            // 显示退出确认对话框
            showExitDialog()
        }
    }
    
    private fun showExitDialog() {
        AlertDialog.Builder(this)
            .setTitle("退出应用")
            .setMessage("确定要退出吗?")
            .setPositiveButton("退出") { _, _ -> finish() }
            .setNegativeButton("取消", null)
            .show()
    }
}

6.2 Fragment——可复用的UI模块

Fragment是Activity内部的”子Activity”,可以灵活组合到不同的Activity中。现代Android开发中,Fragment被广泛使用,尤其是结合Navigation组件。

// HomeFragment.kt
class HomeFragment : Fragment() {
    
    private var _binding: FragmentHomeBinding? = null
    private val binding get() = _binding!!  // 安全的binding访问
    
    private lateinit var viewModel: HomeViewModel
    
    // Fragment生命周期方法
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentHomeBinding.inflate(inflater, container, false)
        return binding.root
    }
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        // 初始化ViewModel
        viewModel = ViewModelProvider(this)[HomeViewModel::class.java]
        
        // 设置UI
        setupUI()
        
        // 观察数据
        observeData()
        
        // 加载数据
        viewModel.loadInitialData()
    }
    
    override fun onResume() {
        super.onResume()
        // 页面可见时刷新数据
        viewModel.refreshData()
    }
    
    override fun onPause() {
        super.onPause()
        // 页面不可见时保存状态
        viewModel.saveState()
    }
    
    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null  // 清除binding引用,防止内存泄漏
    }
    
    private fun setupUI() {
        // 下拉刷新
        binding.swipeRefresh.setOnRefreshListener {
            viewModel.refreshData()
        }
        
        // 下拉刷新结束时隐藏loading
        binding.swipeRefresh.isRefreshing = false
        
        // 空状态显示
        binding.emptyView.visibility = View.GONE
        
        // 错误状态显示
        binding.errorView.visibility = View.GONE
        binding.errorView.btnRetry.setOnClickListener {
            viewModel.retryRequest()
        }
        
        // 列表点击事件
        binding.recyclerView.adapter = HomeAdapter(
            onItemClick = { item ->
                navigateToDetail(item)
            }
        )
    }
    
    private fun observeData() {
        // 观察数据加载状态
        viewModel.uiState.observe(viewLifecycleOwner) { state ->
            when (state) {
                is UiState.Loading -> showLoading()
                is UiState.Success -> showContent(state.data)
                is UiState.Error -> showError(state.message)
                is UiState.Empty -> showEmpty()
            }
        }
        
        // 观察刷新状态
        viewModel.isRefreshing.observe(viewLifecycleOwner) { refreshing ->
            binding.swipeRefresh.isRefreshing = refreshing
        }
    }
    
    private fun showLoading() {
        binding.progressBar.visibility = View.VISIBLE
        binding.recyclerView.visibility = View.GONE
        binding.emptyView.visibility = View.GONE
        binding.errorView.visibility = View.GONE
    }
    
    private fun showContent(data: List<Item>) {
        binding.progressBar.visibility = View.GONE
        binding.recyclerView.visibility = View.VISIBLE
        binding.emptyView.visibility = View.GONE
        binding.errorView.visibility = View.GONE
        
        // 更新适配器数据
        val adapter = binding.recyclerView.adapter as HomeAdapter
        adapter.submitList(data)
    }
    
    private fun showError(message: String) {
        binding.progressBar.visibility = View.GONE
        binding.recyclerView.visibility = View.GONE
        binding.emptyView.visibility = View.GONE
        binding.errorView.visibility = View.VISIBLE
        binding.errorView.tvErrorMessage.text = message
    }
    
    private fun showEmpty() {
        binding.progressBar.visibility = View.GONE
        binding.recyclerView.visibility = View.GONE
        binding.emptyView.visibility = View.VISIBLE
        binding.errorView.visibility = View.GONE
    }
    
    private fun navigateToDetail(item: Item) {
        // 使用Navigation组件进行页面跳转
        findNavController().navigate(
            HomeFragmentDirections.actionHomeToDetail(item.id)
        )
    }
}

6.3 页面导航——Navigation组件

现代Android开发推荐使用Navigation组件来进行页面导航,它提供了类型安全的导航方式,并且可以自动生成返回栈管理逻辑。

<!-- res/navigation/nav_graph.xml -->
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/nav_graph"
    app:startDestination="@id/loginFragment">

    <!-- 登录页面 -->
    <fragment
        android:id="@+id/loginFragment"
        android:name="com.example.myfirstapp.ui.login.LoginFragment"
        android:label="登录"
        tools:layout="@layout/fragment_login">
        
        <action
            android:id="@+id/action_login_to_home"
            app:destination="@id/homeFragment"
            app:popUpTo="@id/loginFragment"
            app:popUpToInclusive="true" />
            
        <argument
            android:name="fromRegister"
            app:argType="boolean"
            android:defaultValue="false" />
    </fragment>

    <!-- 首页 -->
    <fragment
        android:id="@+id/homeFragment"
        android:name="com.example.myfirstapp.ui.home.HomeFragment"
        android:label="首页"
        tools:layout="@layout/fragment_home">
        
        <action
            android:id="@+id/action_home_to_detail"
            app:destination="@id/detailFragment" />
            
        <action
            android:id="@+id/action_home_to_profile"
            app:destination="@id/profileFragment" />
            
        <argument
            android:name="userId"
            app:argType="string"
            android:defaultValue="" />
    </fragment>

    <!-- 详情页 -->
    <fragment
        android:id="@+id/detailFragment"
        android:name="com.example.myfirstapp.ui.detail.DetailFragment"
        android:label="详情"
        tools:layout="@layout/fragment_detail">
        
        <argument
            android:name="itemId"
            app:argType="string" />
            
        <action
            android:id="@+id/action_detail_to_comment"
            app:destination="@id/commentFragment" />
    </fragment>

    <!-- 评论页 -->
    <fragment
        android:id="@+id/commentFragment"
        android:name="com.example.myfirstapp.ui.comment.CommentFragment"
        android:label="评论"
        tools:layout="@layout/fragment_comment" />

    <!-- 个人中心 -->
    <fragment
        android:id="@+id/profileFragment"
        android:name="com.example.myfirstapp.ui.profile.ProfileFragment"
        android:label="个人中心"
        tools:layout="@layout/fragment_profile" />

</navigation>
// 在Activity中使用Navigation组件
class MainActivity : AppCompatActivity() {
    
    private lateinit var binding: ActivityMainBinding
    private lateinit var navController: NavController
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        
        // 获取NavigationController
        navController = findNavController(R.id.nav_host_fragment)
        
        // 设置BottomNavigationView的导航
        binding.bottomNav.setupWithNavController(navController)
        
        // 处理返回键
        binding.bottomNav.setOnApplyWindowInsetsListener { v, insets ->
            v.updatePadding(bottom = insets.systemWindowInsetBottom)
            insets
        }
    }
    
    override fun onSupportNavigateUp(): Boolean {
        return navController.navigateUp() || super.onSupportNavigateUp()
    }
}

第七章:网络请求——使用Retrofit与Gson

在现代Android开发中,网络请求是必不可少的一部分。Retrofit是Square公司开发的类型安全的HTTP客户端,配合Gson可以实现非常优雅的网络请求处理。

7.1 项目依赖配置

// build.gradle.kts
dependencies {
    // Retrofit核心库
    implementation("com.squareup.retrofit2:retrofit:2.9.0")
    
    // Gson转换器(将JSON自动转换为Kotlin对象)
    implementation("com.squareup.retrofit2:converter-gson:2.9.0")
    
    // OkHttp日志拦截器(方便调试)
    implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
    
    // Coroutines(协程,用于异步编程)
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
}

7.2 定义数据模型

// 使用data class定义数据结构
data class User(
    val id: Int,
    val name: String,
    val email: String,
    val avatar: String,
    val createdAt: String
)

data class LoginRequest(
    val email: String,
    val password: String
)

data class LoginResponse(
    val token: String,
    val user: User,
    val expiresIn: Int
)

// API响应包装类(统一处理分页、错误码等)
data class ApiResponse<T>(
    val code: Int,
    val message: String,
    val data: T? = null,
    val pagination: Pagination? = null
)

data class Pagination(
    val currentPage: Int,
    val totalPages: Int,
    val totalCount: Int,
    val pageSize: Int
)

7.3 定义API接口

// 使用注解定义API接口
interface ApiService {
    
    // 登录
    @POST("auth/login")
    suspend fun login(@Body request: LoginRequest): ApiResponse<LoginResponse>
    
    // 注册
    @POST("auth/register")
    suspend fun register(@Body request: LoginRequest): ApiResponse<User>
    
    // 获取用户信息
    @GET("users/me")
    suspend fun getCurrentUser(@Header("Authorization") token: String): ApiResponse<User>
    
    // 获取用户列表(支持分页)
    @GET("users")
    suspend fun getUsers(
        @Query("page") page: Int,
        @Query("size") size: Int,
        @Query("sort") sort: String = "createdAt",
        @Header("Authorization") token: String
    ): ApiResponse<List<User>>
    
    // 上传头像(使用FormData)
    @Multipart
    @POST("users/avatar")
    suspend fun uploadAvatar(
        @Header("Authorization") token: String,
        @Part file: MultipartBody.Part
    ): ApiResponse<User>
    
    // 搜索用户
    @GET("users/search")
    suspend fun searchUsers(
        @Query("keyword") keyword: String,
        @Header("Authorization") token: String
    ): ApiResponse<List<User>>
}

7.4 创建Retrofit实例

// 单例模式创建Retrofit实例
object RetrofitClient {
    
    private const val BASE_URL = "https://api.example.com/"
    private const val CONNECT_TIMEOUT = 30L
    private const val READ_TIMEOUT = 30L
    private const val WRITE_TIMEOUT = 30L
    
    // 日志拦截器(仅debug模式启用)
    private val loggingInterceptor = HttpLoggingInterceptor().apply {
        level = HttpLoggingInterceptor.Level.BODY
    }
    
    // 认证拦截器(自动添加Token)
    private val authInterceptor = Interceptor { chain ->
        val token = SharedPreferencesManager.getToken()
        val request = if (token != null) {
            chain.request().newBuilder()
                .addHeader("Authorization", "Bearer $token")
                .build()
        } else {
            chain.request()
        }
        chain.proceed(request)
    }
    
    // OkHttp客户端
    private val okHttpClient = OkHttpClient.Builder()
        .connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS)
        .readTimeout(READ_TIMEOUT, TimeUnit.SECONDS)
        .writeTimeout(WRITE_TIMEOUT, TimeUnit.SECONDS)
        .addInterceptor(loggingInterceptor)  // 日志拦截器
        .addInterceptor(authInterceptor)     // 认证拦截器
        .addNetworkInterceptor(CacheInterceptor())  // 网络缓存
        .build()
    
    // Retrofit实例
    val api: ApiService by lazy {
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(okHttpClient)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(ApiService::class.java)
    }
}

7.5 封装网络层

// 统一的网络响应封装
sealed class NetworkResult<out T> {
    data class Success<T>(val data: T) : NetworkResult<T>()
    data class Error(val message: String, val code: Int? = null) : NetworkResult<Nothing>()
    object Loading : NetworkResult<Nothing>()
    object Idle : NetworkResult<Nothing>()
}

// 网络管理器(封装所有网络请求)
class NetworkManager @Inject constructor() {
    
    private val apiService = RetrofitClient.api
    
    // 登录
    suspend fun login(email: String, password: String): NetworkResult<LoginResponse> {
        return try {
            NetworkResult.Loading
            val response = apiService.login(LoginRequest(email, password))
            if (response.code == 200 && response.data != null) {
                // 保存Token到本地
                SharedPreferencesManager.saveToken(response.data.token)
                NetworkResult.Success(response.data)
            } else {
                NetworkResult.Error(response.message ?: "登录失败", response.code)
            }
        } catch (e: HttpException) {
            NetworkResult.Error("服务器错误:${e.code()}", e.code())
        } catch (e: IOException) {
            NetworkResult.Error("网络连接失败,请检查网络设置")
        } catch (e: Exception) {
            NetworkResult.Error("未知错误:${e.message}")
        }
    }
    
    // 获取用户列表(带缓存)
    suspend fun getUsers(
        page: Int = 1,
        pageSize: Int = 20
    ): NetworkResult<List<User>> {
        return try {
            NetworkResult.Loading
            val response = apiService.getUsers(page, pageSize)
            if (response.code == 200 && response.data != null) {
                NetworkResult.Success(response.data)
            } else {
                NetworkResult.Error(response.message ?: "获取数据失败", response.code)
            }
        } catch (e: Exception) {
            NetworkResult.Error(e.message ?: "未知错误")
        }
    }
    
    // 搜索用户
    suspend fun searchUsers(keyword: String): NetworkResult<List<User>> {
        return try {
            NetworkResult.Loading
            val response = apiService.searchUsers(keyword)
            if (response.code == 200 && response.data != null) {
                NetworkResult.Success(response.data)
            } else {
                NetworkResult.Error(response.message ?: "搜索失败", response.code)
            }
        } catch (e: Exception) {
            NetworkResult.Error(e.message ?: "未知错误")
        }
    }
}

第八章:数据存储——SQLite、Room与SharedPreferences

数据持久化是任何应用的核心功能。Android提供了多种数据存储方式,我们根据具体需求选择合适的方案。

8.1 SharedPreferences——轻量级键值对存储

// 封装SharedPreferences工具类
object SharedPreferencesManager {
    
    private const val PREF_NAME = "app_prefs"
    private const val KEY_TOKEN = "user_token"
    private const val KEY_USER_ID = "user_id"
    private const val KEY_IS_LOGGED_IN = "is_logged_in"
    private const val KEY_THEME = "theme_mode"
    private const val KEY_LANGUAGE = "language"
    
    private val prefs by lazy {
        ApplicationProvider.getApplicationContext<Application>()
            .getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
    }
    
    // 保存Token
    fun saveToken(token: String) {
        prefs.edit()
            .putString(KEY_TOKEN, token)
            .apply()
    }
    
    // 获取Token
    fun getToken(): String? {
        return prefs.getString(KEY_TOKEN, null)
    }
    
    // 清除登录状态
    fun clearLogin() {
        prefs.edit()
            .remove(KEY_TOKEN)
            .remove(KEY_USER_ID)
            .putBoolean(KEY_IS_LOGGED_IN, false)
            .apply()
    }
    
    // 检查登录状态
    fun isLoggedIn(): Boolean {
        return prefs.getBoolean(KEY_IS_LOGGED_IN, false)
    }
    
    // 设置主题
    fun setTheme(theme: ThemeMode) {
        prefs.edit()
            .putString(KEY_THEME, theme.name)
            .apply()
    }
    
    // 获取主题
    fun getTheme(): ThemeMode {
        return try {
            ThemeMode.valueOf(prefs.getString(KEY_THEME, ThemeMode.SYSTEM.name) ?: ThemeMode.SYSTEM.name)
        } catch (e: IllegalArgumentException) {
            ThemeMode.SYSTEM
        }
    }
}

// 主题模式枚举
enum class ThemeMode {
    LIGHT, DARK, SYSTEM
}

8.2 Room数据库——类型安全的SQLite封装

Room是Google官方推荐的SQLite封装库,它通过编译时检查来保证类型安全,并且支持RxJava和Coroutines。

// 数据库实体
@Entity(tableName = "users")
data class UserEntity(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    val name: String,
    val email: String,
    val avatarUrl: String,
    val createdAt: Long,
    val updatedAt: Long
)

// 类型转换器(将Date类型转换为Long存储)
class Converters {
    @TypeConverter
    fun fromTimestamp(value: Long?): Date? {
        return value?.let { Date(it) }
    }
    
    @TypeConverter
    fun dateToTimestamp(date: Date?): Long? {
        return date?.time
    }
}

// 数据库
@Database(
    entities = [UserEntity::class, ArticleEntity::class, CommentEntity::class],
    version = 1,
    exportSchema = false
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
    
    abstract fun userDao(): UserDao
    abstract fun articleDao(): ArticleDao
    abstract fun commentDao(): CommentDao
    
    companion object {
        @Volatile
        private var INSTANCE: AppDatabase? = null
        
        fun getDatabase(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "app_database"
                )
                .addCallback(object : RoomDatabase.Callback() {
                    override fun onOpen(db: SupportSQLiteDatabase) {
                        super.onOpen(db)
                        // 数据库打开时的操作
                    }
                    
                    override fun onCreate(db: SupportSQLiteDatabase) {
                        super.onCreate(db)
                        // 数据库创建时的操作
                    }
                })
                .build()
                INSTANCE = instance
                instance
            }
        }
    }
}

// DAO(数据访问对象)
interface UserDao {
    
    @Query("SELECT * FROM users ORDER BY createdAt DESC")
    fun getAllUsers(): Flow<List<UserEntity>>
    
    @Query("SELECT * FROM users WHERE id = :userId")
    suspend fun getUserById(userId: Int): UserEntity?
    
    @Query("SELECT * FROM users WHERE email = :email LIMIT 1")
    suspend fun getUserByEmail(email: String): UserEntity?
    
    @Query("SELECT * FROM users WHERE name LIKE :keyword OR email LIKE :keyword")
    fun searchUsers(keyword: String): Flow<List<UserEntity>>
    
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertUser(user: UserEntity): Long
    
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertUsers(users: List<UserEntity>)
    
    @Update
    suspend fun updateUser(user: UserEntity)
    
    @Delete
    suspend fun deleteUser(user: UserEntity)
    
    @Query("DELETE FROM users")
    suspend fun deleteAllUsers()
    
    @Query("SELECT COUNT(*) FROM users")
    fun getUserCount(): Flow<Int>
}

// Repository(数据仓库层)
class UserRepository @Inject constructor(
    private val userDao: UserDao,
    private val networkManager: NetworkManager
) {
    
    // 从网络获取数据并写入本地数据库
    suspend fun syncUsers() {
        val result = networkManager.getUsers()
        if (result is NetworkResult.Success) {
            val entities = result.data.map { user ->
                UserEntity(
                    id = user.id,
                    name = user.name,
                    email = user.email,
                    avatarUrl = user.avatar,
                    createdAt = System.currentTimeMillis(),
                    updatedAt = System.currentTimeMillis()
                )
            }
            userDao.insertUsers(entities)
        }
    }
    
    // 获取用户列表(优先从本地获取)
    fun getUsers(): Flow<List<UserEntity>> {
        return userDao.getAllUsers()
    }
    
    // 搜索用户
    fun searchUsers(keyword: String): Flow<List<UserEntity>> {
        return userDao.searchUsers("%$keyword%")
    }
}

第九章:协程与异步编程——让网络请求不再阻塞UI

9.1 为什么需要协程?

在Android开发中,我们经常会遇到一个问题:网络请求需要时间,如果在主线程(UI线程)上执行网络请求,会导致界面卡顿甚至ANR(应用无响应)。解决方案是使用异步编程。

9.2 协程基础

// ViewModel中使用协程
class LoginViewModel @Inject constructor(
    private val networkManager: NetworkManager
) : ViewModel() {
    
    private val _loginState = MutableLiveData<Resource<LoginResponse>>()
    val loginState: LiveData<Resource<LoginResponse>> = _loginState
    
    private val _loginError = MutableLiveData<String>()
    val loginError: LiveData<String> = _loginError
    
    // 登录方法
    fun login(email: String, password: String) {
        _loginState.value = Resource.Loading
        
        // 使用viewModelScope启动协程
        viewModelScope.launch {
            try {
                val result = networkManager.login(email, password)
                when (result) {
                    is NetworkResult.Success -> {
                        _loginState.value = Resource.Success(result.data)
                    }
                    is NetworkResult.Error -> {
                        _loginError.value = result.message
                        _loginState.value = Resource.Error(result.message)
                    }
                    is NetworkResult.Loading -> {
                        _loginState.value = Resource.Loading
                    }
                    is NetworkResult.Idle -> {
                        _loginState.value = Resource.Idle
                    }
                }
            } catch (e: Exception) {
                _loginError.value = e.message ?: "登录失败"
                _loginState.value = Resource.Error(e.message ?: "登录失败")
            }
        }
    }
    
    // 忘记密码
    fun forgotPassword(email: String) {
        viewModelScope.launch {
            // 模拟发送重置邮件
            delay(1000)  // 模拟网络延迟
            _loginState.value = Resource.Success(LoginResponse(
                token = "",
                user = User(0, "", email, "", ""),
                expiresIn = 0
            ))
        }
    }
}

// Resource封装类(统一管理加载状态)
sealed class Resource<out T> {
    data class Success<T>(val data: T) : Resource<T>()
    data class Error(val message: String) : Resource<Nothing>()
    object Loading : Resource<Nothing>()
    object Idle : Resource<Nothing>()
}

9.3 协程的作用域与取消

// 正确的协程使用方式
class UserFragment : Fragment() {
    
    private var job: Job? = null
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        // 创建协程作用域
        job = CoroutineScope(Dispatchers.Main + SupervisorJob())
        
        // 在作用域内启动协程
        job?.launch {
            val users = repository.getUsers()
            // 更新UI
            users.collect { userList ->
                adapter.submitList(userList)
            }
        }
    }
    
    override fun onDestroyView() {
        super.onDestroyView()
        // 销毁协程,防止内存泄漏
        job?.cancel()
        job = null
    }
}

// 使用viewModelScope(自动管理生命周期)
class DetailViewModel @Inject constructor(
    private val repository: ArticleRepository
) : ViewModel() {
    
    fun loadArticle(articleId: String) {
        viewModelScope.launch {
            try {
                // 带超时的网络请求
                withTimeout(5000) {
                    val article = repository.getArticle(articleId)
                    _article.value = Resource.Success(article)
                }
            } catch (e: TimeoutCancellationException) {
                _article.value = Resource.Error("请求超时")
            } catch (e: Exception) {
                _article.value = Resource.Error(e.message ?: "加载失败")
            }
        }
    }
}

第十章:完整项目实战——做一个”记账本”应用

现在我们已经掌握了所有基础知识,让我们把学到的东西串联起来,做一个完整的记账本应用。

10.1 项目规划

这个记账本应用包含以下功能:

  1. 添加记账记录:收入/支出、金额、类别、备注、日期
  2. 账单列表:按日期分组显示所有记录
  3. 统计分析:按类别、按月份统计收支
  4. 预算管理:设置月度预算,超支提醒
  5. 数据同步:云端同步(可选)

10.2 数据库设计

// 记账记录实体
@Entity(tableName = "transactions")
data class TransactionEntity(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    val type: TransactionType,        // INCOME或EXPENSE
    val amount: Double,               // 金额
    val categoryId: Int,              // 类别ID
    val categoryName: String,         // 类别名称(冗余存储,避免联表查询)
    val note: String,                 // 备注
    val date: Long,                   // 日期(时间戳)
    val createdAt: Long,              // 创建时间
    val updatedAt: Long               // 更新时间
)

// 类别实体
@Entity(tableName = "categories")
data class CategoryEntity(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    val name: String,
    val type: TransactionType,        // 收入类别或支出类别
    val icon: String,                 // 图标
    val color: Int,                   // 颜色
    val sortOrder: Int                // 排序
)

// 预算实体
@Entity(tableName = "budgets")
data class BudgetEntity(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    val categoryId: Int,
    val month: String,                // 格式:yyyy-MM
    val amount: Double,               // 预算金额
    val createdAt: Long
)

// 交易类型枚举
enum class TransactionType {
    INCOME, EXPENSE
}

// DAO接口
interface TransactionDao {
    
    @Query("SELECT * FROM transactions ORDER BY date DESC")
    fun getAllTransactions(): Flow<List<TransactionEntity>>
    
    @Query("SELECT * FROM transactions WHERE date BETWEEN :startDate AND :endDate ORDER BY date DESC")
    fun getTransactionsByDateRange(startDate: Long, endDate: Long): Flow<List<TransactionEntity>>
    
    @Query("SELECT * FROM transactions WHERE type = :type ORDER BY date DESC")
    fun getTransactionsByType(type: TransactionType): Flow<List<TransactionEntity>>
    
    @Query("SELECT * FROM transactions WHERE categoryId = :categoryId ORDER BY date DESC")
    fun getTransactionsByCategory(categoryId: Int): Flow<List<TransactionEntity>>
    
    @Query("SELECT SUM(amount) FROM transactions WHERE type = :type AND date BETWEEN :startDate AND :endDate")
    suspend fun sumByTypeAndDateRange(type: TransactionType, startDate: Long, endDate: Long): Double?
    
    @Query("SELECT * FROM transactions WHERE id = :id")
    suspend fun getTransactionById(id: Int): TransactionEntity?
    
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertTransaction(transaction: TransactionEntity): Long
    
    @Update
    suspend fun updateTransaction(transaction: TransactionEntity)
    
    @Delete
    suspend fun deleteTransaction(transaction: TransactionEntity)
    
    @Query("DELETE FROM transactions WHERE id = :id")
    suspend fun deleteTransactionById(id: Int)
    
    @Query("DELETE FROM transactions")
    suspend fun deleteAllTransactions()
}

interface CategoryDao {
    
    @Query("SELECT * FROM categories WHERE type = :type ORDER BY sortOrder")
    fun getCategoriesByType(type: TransactionType): Flow<List<CategoryEntity>>
    
    @Query("SELECT * FROM categories")
    fun getAllCategories(): Flow<List<CategoryEntity>>
    
    @Query("SELECT * FROM categories WHERE id = :id")
    suspend fun getCategoryById(id: Int): CategoryEntity?
    
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertCategory(category: CategoryEntity): Long
    
    @Update
    suspend fun updateCategory(category: CategoryEntity)
    
    @Delete
    suspend fun deleteCategory(category: CategoryEntity)
}

10.3 ViewModel实现

class MainViewModel @Inject constructor(
    private val transactionRepository: TransactionRepository,
    private val categoryRepository: CategoryRepository,
    private val budgetRepository: BudgetRepository
) : ViewModel() {
    
    // 当前选中的日期范围
    private val _currentDateRange = MutableStateFlow(Pair(getStartOfMonth(), getEndOfMonth()))
    val currentDateRange: StateFlow<Pair<Long, Long>> = _currentDateRange
    
    // 交易记录列表
    private val _transactions = MutableStateFlow<List<TransactionEntity>>(emptyList())
    val transactions: StateFlow<List<TransactionEntity>> = _transactions
    
    // 当前月份的总收入
    private val _totalIncome = MutableStateFlow(0.0)
    val totalIncome: StateFlow<Double> = _totalIncome
    
    // 当前月份的总支出
    private val _totalExpense = MutableStateFlow(0.0)
    val totalExpense: StateFlow<Double> = _totalExpense
    
    // 当前月份结余
    val balance: StateFlow<Double> = combine(_totalIncome, _totalExpense) { income, expense ->
        income - expense
    }.stateIn(viewModelScope, SharingStarted.Lazily, 0.0)
    
    // 交易类型
    enum class TransactionType { INCOME, EXPENSE }
    
    init {
        loadTransactions()
        loadCategories()
    }
    
    // 加载交易记录
    fun loadTransactions() {
        viewModelScope.launch {
            transactionRepository.getTransactionsByDateRange(
                startDate = _currentDateRange.value.first,
                endDate = _currentDateRange.value.second
            ).collect { list ->
                _transactions.value = list
                calculateTotals(list)
            }
        }
    }
    
    // 加载类别
    fun loadCategories() {
        viewModelScope.launch {
            categoryRepository.getCategoriesByType(TransactionType.EXPENSE).collect { list ->
                _expenseCategories.value = list
            }
            categoryRepository.getCategoriesByType(TransactionType.INCOME).collect { list ->
                _incomeCategories.value = list
            }
        }
    }
    
    // 添加交易
    fun addTransaction(
        type: TransactionType,
        amount: Double,
        categoryId: Int,
        categoryName: String,
        note: String
    ) {
        viewModelScope.launch {
            val transaction = TransactionEntity(
                type = when (type) {
                    TransactionType.INCOME -> TransactionType.INCOME
                    TransactionType.EXPENSE -> TransactionType.EXPENSE
                },
                amount = amount,
                categoryId = categoryId,
                categoryName = categoryName,
                note = note,
                date = System.currentTimeMillis(),
                createdAt = System.currentTimeMillis(),
                updatedAt = System.currentTimeMillis()
            )
            transactionRepository.insertTransaction(transaction)
            loadTransactions()  // 重新加载列表
        }
    }
    
    // 删除交易
    fun deleteTransaction(transaction: TransactionEntity) {
        viewModelScope.launch {
            transactionRepository.deleteTransactionById(transaction.id)
            loadTransactions()
        }
    }
    
    // 修改交易
    fun updateTransaction(transaction: TransactionEntity) {
        viewModelScope.launch {
            transactionRepository.updateTransaction(transaction)
            loadTransactions()
        }
    }
    
    // 切换日期范围
    fun switchDateRange(range: Pair<Long, Long>) {
        _currentDateRange.value = range
        loadTransactions()
    }
    
    // 计算收支总额
    private fun calculateTotals(list: List<TransactionEntity>) {
        val income = list
            .filter { it.type == TransactionType.INCOME }
            .sumOf { it.amount }
        val expense = list
            .filter { it.type == TransactionType.EXPENSE }
            .sumOf { it.amount }
        _totalIncome.value = income
        _totalExpense.value = expense
    }
    
    // 获取月份开始的毫秒时间戳
    private fun getStartOfMonth(): Long {
        val calendar = Calendar.getInstance()
        calendar.set(Calendar.DAY_OF_MONTH, 1)
        calendar.set(Calendar.HOUR_OF_DAY, 0)
        calendar.set(Calendar.MINUTE, 0)
        calendar.set(Calendar.SECOND, 0)
        calendar.set(Calendar.MILLISECOND, 0)
        return calendar.timeInMillis
    }
    
    // 获取月份结束的毫秒时间戳
    private fun getEndOfMonth(): Long {
        val calendar = Calendar.getInstance()
        calendar.set(Calendar.DAY_OF_MONTH, calendar.getActualMaximum(Calendar.DAY_OF_MONTH))
        calendar.set(Calendar.HOUR_OF_DAY, 23)
        calendar.set(Calendar.MINUTE, 59)
        calendar.set(Calendar.SECOND, 59)
        calendar.set(Calendar.MILLISECOND, 999)
        return calendar.timeInMillis
    }
}

10.4 主界面实现

// 主界面Fragment
class MainFragment : Fragment() {
    
    private var _binding: FragmentMainBinding? = null
    private val binding get() = _binding!!
    private val viewModel: MainViewModel by viewModels()
    
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentMainBinding.inflate(inflater, container, false)
        return binding.root
    }
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        setupToolbar()
        setupRecyclerView()
        setupDatePicker()
        observeViewModel()
        setupFAB()
    }
    
    private fun setupToolbar() {
        binding.toolbar.setTitle("我的记账本")
        binding.toolbar.setSubtitle("记录每一笔收支")
        (activity as? AppCompatActivity)?.setSupportActionBar(binding.toolbar)
        
        binding.toolbar.setNavigationOnClickListener {
            (activity as? MainActivity)?.openDrawer()
        }
    }
    
    private fun setupRecyclerView() {
        val adapter = TransactionAdapter(
            onDelete = { transaction ->
                showDeleteConfirmation(transaction)
            },
            onEdit = { transaction ->
                navigateToAddTransaction(transaction)
            }
        )
        
        binding.recyclerView.apply {
            layoutManager = LinearLayoutManager(requireContext())
            adapter = adapter
            addItemDecoration(
                DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL)
            )
            // 添加下拉刷新
            binding.swipeRefresh.setOnRefreshListener {
                viewModel.loadTransactions()
                binding.swipeRefresh.isRefreshing = false
            }
        }
    }
    
    private fun setupDatePicker() {
        binding.btnDateRange.setOnClickListener {
            val calendar = Calendar.getInstance()
            val startOfMonth = Calendar.getInstance().apply {
                set(Calendar.DAY_OF_MONTH, 1)
            }
            val endOfMonth = Calendar.getInstance().apply {
                set(Calendar.DAY_OF_MONTH, getActualMaximum(Calendar.DAY_OF_MONTH))
            }
            
            DatePickerDialog(
                requireContext(),
                { _, year, month, day ->
                    val selectedDate = Calendar.getInstance().apply {
                        set(year, month, day)
                    }.timeInMillis
                    viewModel.switchDateRange(Pair(startOfMonth.timeInMillis, endOfMonth.timeInMillis))
                },
                calendar.get(Calendar.YEAR),
                calendar.get(Calendar.MONTH),
                calendar.get(Calendar.DAY_OF_MONTH)
            ).show()
        }
    }
    
    private fun observeViewModel() {
        // 观察交易列表
        viewModel.transactions.observe(viewLifecycleOwner) { transactions ->
            val adapter = binding.recyclerView.adapter as TransactionAdapter
            adapter.submitList(transactions)
            
            // 更新空状态显示
            binding.emptyView.visibility = if (transactions.isEmpty()) View.VISIBLE else View.GONE
            binding.recyclerView.visibility = if (transactions.isEmpty()) View.GONE else View.VISIBLE
        }
        
        // 观察收入总额
        viewModel.totalIncome.observe(viewLifecycleOwner) { income ->
            binding.tvTotalIncome.text = "收入: ¥${String.format("%.2f", income)}"
        }
        
        // 观察支出总额
        viewModel.totalExpense.observe(viewLifecycleOwner) { expense ->
            binding.tvTotalExpense.text = "支出: ¥${String.format("%.2f", expense)}"
        }
        
        // 观察结余
        viewModel.balance.observe(viewLifecycleOwner) { balance ->
            binding.tvBalance.text = "结余: ¥${String.format("%.2f", balance)}"
            binding.tvBalance.setTextColor(
                if (balance >= 0) 
                    ContextCompat.getColor(requireContext(), R.color.green)
                else 
                    ContextCompat.getColor(requireContext(), R.color.red)
            )
        }
    }
    
    private fun setupFAB() {
        binding.fabAdd.setOnClickListener {
            navigateToAddTransaction()
        }
        
        // 长按FAB显示菜单
        binding.fabAdd.setOnLongClickListener {
            showFABMenu()
            true
        }
    }
    
    private fun navigateToAddTransaction(transaction: TransactionEntity? = null) {
        val action = MainFragmentDirections.actionMainToAddTransaction(transaction?.id)
        findNavController().navigate(action)
    }
    
    private fun showDeleteConfirmation(transaction: TransactionEntity) {
        AlertDialog.Builder(requireContext())
            .setTitle("删除记录")
            .setMessage("确定要删除这条记录吗?此操作不可撤销。")
            .setPositiveButton("删除") { _, _ ->
                viewModel.deleteTransaction(transaction)
                Snackbar.make(binding.root, "记录已删除", Snackbar.LENGTH_SHORT).show()
            }
            .setNegativeButton("取消", null)
            .show()
    }
    
    private fun showFABMenu() {
        // 显示快捷菜单
        val menu = MenuPopupWindow(
            requireContext(),
            listOf(
                PopupMenuItem("添加收入", R.drawable.ic_income, MainFragment::class.java),
                PopupMenuItem("添加支出", R.drawable.ic_expense, MainFragment::class.java)
            )
        )
        menu.showAsDropDown(binding.fabAdd)
    }
    
    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

10.5 添加交易界面

// 添加/编辑交易Fragment
class AddTransactionFragment : Fragment() {
    
    private var _binding: FragmentAddTransactionBinding? = null
    private val binding get() = _binding!!
    private val viewModel: AddTransactionViewModel by viewModels()
    
    private var isEditMode = false
    private var editingTransactionId: Int? = null
    
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentAddTransactionBinding.inflate(inflater, container, false)
        return binding.root
    }
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        // 检查是否是编辑模式
        val args = AddTransactionFragmentArgs.fromBundle(requireArguments())
        isEditMode = args.transactionId != null
        editingTransactionId = args.transactionId
        
        setupToolbar()
        setupTypeToggle()
        setupAmountInput()
        setupCategoryPicker()
        setupDatepicker()
        setupNoteInput()
        setupSaveButton()
        
        if (isEditMode) {
            loadTransactionData()
        }
        
        observeViewModel()
    }
    
    private fun setupToolbar() {
        binding.toolbar.setTitle(if (isEditMode) "编辑记录" else "添加记录")
        binding.toolbar.setNavigationOnClickListener {
            requireActivity().onBackPressed()
        }
    }
    
    private fun setupTypeToggle() {
        // 收入/支出切换
        binding.groupType.check(R.id.radio_expense)
        binding.groupType.setOnCheckedChangeListener { _, checkedId ->
            when (checkedId) {
                R.id.radio_income -> {
                    binding.tvAmountHint.text = "收入金额"
                    binding.btnCategory.setIconResource(R.drawable.ic_income)
                }
                R.id.radio_expense -> {
                    binding.tvAmountHint.text = "支出金额"
                    binding.btnCategory.setIconResource(R.drawable.ic_expense)
                }
            }
        }
    }
    
    private fun setupAmountInput() {
        // 金额输入,限制为数字和小数点
        binding.etAmount.setKeyListener(DigitsKeyListener.getInstance("0123456789."))
        
        binding.etAmount.addTextChangedListener(object : TextWatcher {
            override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
            override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
            override fun afterTextChanged(s: Editable?) {
                val amount = s?.toString()?.toDoubleOrNull() ?: 0.0
                binding.tvAmountPreview.text = "¥${String.format("%.2f", amount)}"
            }
        })
    }
    
    private fun setupCategoryPicker() {
        binding.btnCategory.setOnClickListener {
            showCategoryDialog()
        }
    }
    
    private fun showCategoryDialog() {
        val type = if (binding.groupType.checkedRadioButtonId == R.id.radio_income) {
            TransactionType.INCOME
        } else {
            TransactionType.EXPENSE
        }
        
        viewModel.getCategories(type).observe(viewLifecycleOwner) { categories ->
            val names = categories.map { it.name }.toTypedArray()
            AlertDialog.Builder(requireContext())
                .setTitle("选择类别")
                .setItems(names) { _, which ->
                    val selected = categories[which]
                    binding.btnCategory.text = selected.name
                    binding.btnCategory.tag = selected.id
                }
                .show()
        }
    }
    
    private fun setupDatepicker() {
        binding.btnDate.setOnClickListener {
            val calendar = Calendar.getInstance()
            DatePickerDialog(
                requireContext(),
                { _, year, month, day ->
                    val date = Calendar.getInstance().apply {
                        set(year, month, day)
                    }
                    binding.btnDate.text = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
                        .format(date.time)
                    binding.btnDate.tag = date.timeInMillis
                },
                calendar.get(Calendar.YEAR),
                calendar.get(Calendar.MONTH),
                calendar.get(Calendar.DAY_OF_MONTH)
            ).show()
        }
        // 默认设置为今天
        binding.btnDate.text = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
            .format(Calendar.getInstance().time)
        binding.btnDate.tag = Calendar.getInstance().timeInMillis
    }
    
    private fun setupNoteInput() {
        // 备注输入框,限制最大长度
        binding.etNote.setFilters arrayOf(
            InputFilter.LengthFilter(200)
        )
    }
    
    private fun setupSaveButton() {
        binding.btnSave.setOnClickListener {
            if (validateInputs()) {
                saveTransaction()
            }
        }
    }
    
    private fun validateInputs(): Boolean {
        var isValid = true
        
        // 验证金额
        val amountStr = binding.etAmount.text.toString()
        val amount = amountStr.toDoubleOrNull()
        if (amount == null || amount <= 0) {
            binding.tilAmount.error = "请输入有效的金额"
            isValid = false
        } else {
            binding.tilAmount.error = null
        }
        
        // 验证类别
        val categoryId = binding.btnCategory.tag as? Int
        if (categoryId == null) {
            binding.tilCategory.error = "请选择类别"
            isValid = false
        } else {
            binding.tilCategory.error = null
        }
        
        // 验证日期
        val date = binding.btnDate.tag as? Long
        if (date == null) {
            binding.tilDate.error = "请选择日期"
            isValid = false
        } else {
            binding.tilDate.error = null
        }
        
        return isValid
    }
    
    private fun saveTransaction() {
        val type = if (binding.groupType.checkedRadioButtonId == R.id.radio_income) {
            TransactionType.INCOME
        } else {
            TransactionType.EXPENSE
        }
        val amount = binding.etAmount.text.toString().toDouble()
        val categoryId = binding.btnCategory.tag as Int
        val categoryName = binding.btnCategory.text.toString()
        val note = binding.etNote.text.toString()
        val date = binding.btnDate.tag as Long
        
        if (isEditMode && editingTransactionId != null) {
            // 更新现有记录
            val transaction = TransactionEntity(
                id = editingTransactionId!!,
                type = type,
                amount = amount,
                categoryId = categoryId,
                categoryName = categoryName,
                note = note,
                date = date,
                createdAt = System.currentTimeMillis(),
                updatedAt = System.currentTimeMillis()
            )
            viewModel.updateTransaction(transaction)
        } else {
            // 添加新记录
            viewModel.addTransaction(
                type = type,
                amount = amount,
                categoryId = categoryId,
                categoryName = categoryName,
                note = note,
                date = date
            )
        }
        
        // 显示成功提示并返回
        Snackbar.make(binding.root, "保存成功", Snackbar.LENGTH_SHORT).show()
        requireActivity().onBackPressed()
    }
    
    private fun loadTransactionData() {
        editingTransactionId?.let { id ->
            viewModel.getTransaction(id).observe(viewLifecycleOwner) { transaction ->
                transaction?.let {
                    binding.etAmount.setText(it.amount.toString())
                    binding.etNote.setText(it.note)
                    binding.btnDate.text = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
                        .format(Date(it.date))
                    binding.btnDate.tag = it.date
                    binding.btnCategory.text = it.categoryName
                    binding.btnCategory.tag = it.categoryId
                    
                    // 设置金额预览
                    binding.tvAmountPreview.text = "¥${String.format("%.2f", it.amount)}"
                    
                    // 设置类型
                    when (it.type) {
                        TransactionType.INCOME -> binding.groupType.check(R.id.radio_income)
                        TransactionType.EXPENSE -> binding.groupType.check(R.id.radio_expense)
                    }
                }
            }
        }
    }
    
    private fun observeViewModel() {
        viewModel.isSuccess.observe(viewLifecycleOwner) { success ->
            if (success) {
                requireActivity().onBackPressed()
            }
        }
        
        viewModel.error.observe(viewLifecycleOwner) { error ->
            Snackbar.make(binding.root, error, Snackbar.LENGTH_LONG).show()
        }
    }
    
    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

第十一章:应用打包与发布

当你的应用开发完成后,就需要将它打包发布到各个应用商店。

11.1 签名配置

// build.gradle.kts(项目级)
android {
    signingConfigs {
        create("release") {
            // 从本地properties文件读取签名信息(不要硬编码密码!)
            storeFile = file("../keystore.jks")
            storePassword = findProperty("storePassword") as String
            keyAlias = findProperty("keyAlias") as String
            keyPassword = findProperty("keyPassword") as String
        }
    }
    
    defaultConfig {
        // ...
    }
    
    buildTypes {
        release {
            isMinifyEnabled = true  // 启用代码混淆
            isShrinkResources = true  // 移除未使用的资源
            signingConfig = signingConfigs.getByName("release")
            
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
}

11.2 ProGuard混淆规则

# 基础混淆规则
-keepattributes Signature,Annotation,EnclosingMethod
-keepattributes *Annotation*
-keepattributes InnerClasses

# 保留数据类(不混淆字段名)
-keep class com.example.myfirstapp.data.model.** { *; }
-keep class com.example.myfirstapp.data.entity.** { *; }

# 保留ViewModel(反射访问)
-keep class com.example.myfirstapp.ui.** { *; }

# 保留网络请求模型
-keep class com.example.myfirstapp.network.model.** { *; }

# 保留Gson序列化的类
-keep class com.example.myfirstapp.data.model.** { *; }

# Retrofit相关
-keep class retrofit2.** { *; }
-dontwarn okhttp3.**
-dontwarn okio.**
-dontwarn javax.annotation.**

# Coroutines相关
-keep class kotlinx.coroutines.** { *; }
-dontwarn kotlinx.coroutines.**

# Material Design
-keep class com.google.android.material.** { *; }
-dontwarn com.google.android.material.**

11.3 打包步骤

# 生成签署的APK
./gradlew assembleRelease

# 生成签名的AAB(Android App Bundle,Google Play推荐格式)
./gradlew bundleRelease

# 或者直接通过Android Studio界面操作
# Build → Generate Signed Bundle / APK

11.4 Google Play上架流程

  1. 注册开发者账号:访问https://play.google.com/console,支付25美元一次性注册费
  2. 创建应用:填写应用名称、描述、分类等信息
  3. 上传AAB文件:上传打包好的AAB文件
  4. 填写 Store Listing:添加应用截图、描述、关键词等
  5. 设置内容分级:完成调查问卷
  6. 设置定价与发行:选择国家/地区、设置价格(或免费)
  7. 提交审核:提交后等待Google审核(通常需要1-3天)

第十二章:持续学习与进阶方向

恭喜你!到这里你已经掌握了一个完整Android应用开发的基础知识。但这只是开始,Android开发的世界非常广阔,以下是一些进阶方向:

12.1 推荐学习路径

基础阶段(已完成):
✅ Android Studio安装配置
✅ Kotlin基础语法
✅ XML布局与ConstraintLayout
✅ Activity/Fragment生命周期
✅ ViewModel + LiveData
✅ Room数据库
✅ Retrofit网络请求
✅ 协程异步编程

进阶阶段(下一步):
🔄 架构模式:MVVM、MVI
🔄 Jetpack组件:Navigation、WorkManager、Hilt
🔄 自定义View绘制
🔄 性能优化:内存泄漏、卡顿排查
🔄 多模块架构设计
🔄 CI/CD自动化构建
🔄 测试:单元测试、UI测试

高级阶段(长期目标):
🔜 多平台开发:Kotlin Multiplatform
🔜 跨平台方案:Flutter、React Native对比
🔜 原生性能优化:Native开发
🔜 系统级开发:AOSP定制

12.2 实用工具推荐

开发工具:
- Android Studio(必备)
- Git + GitHub(版本控制)
- Postman(API测试)
- Figma(UI设计协作)

调试工具:
- Android Profiler(性能分析)
- LeakCanary(内存泄漏检测)
- Timber(日志管理)
- Stetho(数据库调试)

效率插件:
- Material Theme UI(主题美化)
- Key Promoter X(快捷键提示)
- Rainbow Brackets(括号高亮)
- .ignore(忽略文件管理)

12.3 常见问题排查技巧

// 1. 内存泄漏检测(使用LeakCanary)
dependencies {
    debugImplementation("com.squareup.leakcanary:leakcanary-android:2.12")
}

// 2. 网络请求失败排查
private val loggingInterceptor = HttpLoggingInterceptor { message ->
    Log.d("HttpLog", message)  // 查看完整的请求和响应
}.apply {
    level = HttpLoggingInterceptor.Level.BODY
}

// 3. 数据库查询优化
// 使用索引加速查询
@Query("SELECT * FROM transactions WHERE type = :type AND date BETWEEN :start AND :end ORDER BY date DESC")
fun getTransactionsByTypeAndDate(type: TransactionType, start: Long, end: Long): Flow<List<TransactionEntity>>

// 4. ANR(应用无响应)排查
// 确保所有耗时操作都在后台线程执行
viewModelScope.launch(Dispatchers.IO) {
    // 耗时操作
}

结语:每个人的代码之路都是独一无二的

回想起我刚开始学习Android开发的时候,面对那些密密麻麻的XML布局和看不懂的报错信息,心里满是困惑和焦虑。但是,当我第一次看到自己写的代码在模拟器上跑起来,当我第一次发布应用到应用商店,那种成就感是无与伦比的。

编程学习最忌讳的就是”等全部学会再动手”。实际上,你只需要学会一个概念,就立刻去用它做个小东西,在做的过程中遇到问题、解决问题,这样学到的东西才会真正属于你。

不要害怕犯错,每一个报错信息都是你进步的机会。不要害怕重复,每个优秀开发者都是从写HelloWorld开始的。最重要的是,保持好奇心,保持动手实践的热情。

希望这篇指南能帮助你踏上Android开发的旅程。记住,最棒的App不是从教程里学来的,而是从你自己的创意和实践中诞生的。现在,打开Android Studio,开始你的第一个项目吧!

加油,未来的App开发者!🚀