零基础也能看懂的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>
使用资源文件的好处非常明显:
- 统一管理:如果需要修改应用名称或某个文案,只需修改strings.xml中的一个地方
- 多语言支持:可以为每种语言创建单独的strings.xml文件
- 类型安全:IDE会自动检查资源引用是否有效
- 团队协作:设计师和开发者可以各自管理不同的资源文件
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(可变变量),这有几个原因:
- 线程安全:不可变变量在多线程环境下不会出问题
- 可预测性:变量的值不会意外改变,代码更容易理解和调试
- 编译器优化:编译器可以对不可变变量做更好的优化
- 减少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 项目规划
这个记账本应用包含以下功能:
- 添加记账记录:收入/支出、金额、类别、备注、日期
- 账单列表:按日期分组显示所有记录
- 统计分析:按类别、按月份统计收支
- 预算管理:设置月度预算,超支提醒
- 数据同步:云端同步(可选)
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上架流程
- 注册开发者账号:访问https://play.google.com/console,支付25美元一次性注册费
- 创建应用:填写应用名称、描述、分类等信息
- 上传AAB文件:上传打包好的AAB文件
- 填写 Store Listing:添加应用截图、描述、关键词等
- 设置内容分级:完成调查问卷
- 设置定价与发行:选择国家/地区、设置价格(或免费)
- 提交审核:提交后等待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开发者!🚀
