我最近完成了一个个人项目------一个能帮我整理日记的 Android App。整个开发过程踩了不少坑,也学到了很多东西。写这篇文章的目的,是想把我从完全不懂 Android 开发,到做出一个能用的 App 的过程记录下来,希望对同样想入门移动端开发的朋友有所帮助。
这不是一篇面面俱到的教科书,而是一个真实项目的开发笔记。我会尽量少讲理论,多讲实操,遇到什么问题就说什么问题。
一、先聊聊这个项目是干什么的
在开始写代码之前,我想先说说这个 App 要解决的问题。
我有记日记的习惯,但总是坚持不下去。原因很简单:每天晚上想起来今天发生了什么,脑子里一片空白。等想起来的时候,又懒得动笔(打字)。
后来我在 YouTube 上看到一个视频,博主用 iPhone 的快捷指令做了一个工作流:白天随时用语音记录零碎的想法(比如"今天中午吃的拉面不错"、"下午开会被老板骂了"),晚上让 AI 把这些碎片整理成一篇完整的日记。
这思路太妙了!但那个方案是 iOS 专属的,我用的是 Android。于是我决定自己做一个。
核心功能就三个:
随时记录碎片想法(语音或打字)
一键让 AI 整理成日记
顺便把日记里的待办事项提取出来
听起来不复杂对吧?但真正动手做的时候,你会发现需要学的东西还挺多。好,废话不多说,开始搞。
二、开发环境:Android Studio 的安装和配置
2.1 下载 Android Studio
去 Google 官网下载:https://developer.android.com/studio
下载下来是一个几百 MB 的安装包。Windows 用户直接双击 exe,Mac 用户拖到 Applications,Linux 用户解压后运行 studio.sh。
第一次启动会比较慢,因为它要下载 SDK、Gradle 之类的东西。建议挂个梯子,不然可能卡很久。
2.2 创建第一个项目
安装好之后,点 New Project,选择 Empty Activity(注意是 Compose 版本的,不是传统 View 版本)。
填几个信息:
Name: Journal(或者你喜欢的名字)
Package name: com.example.journal
Language: Kotlin(别选 Java,2024 年了)
Minimum SDK: API 26(Android 8.0)
点 Finish,等它 Sync 完成。第一次 Sync 会下载一堆依赖,可能要等几分钟。
2.3 跑起来看看
点击工具栏上的绿色三角形(Run),选择一个模拟器或者连接的真机。如果没有模拟器,去 Device Manager 创建一个。
看到屏幕上显示 "Hello Android!" 就说明环境没问题了。
💡 小提示:真机调试比模拟器快很多。手机上打开「开发者选项」→「USB 调试」,用数据线连电脑就行。
三、项目结构:先搞清楚文件放哪里
打开项目,左边有一堆文件夹。别慌,常用的就这几个:
app/
├── src/main/
│ ├── java/com/example/journal/ # 写 Kotlin 代码的地方
│ ├── res/ # 资源文件(图片、字符串等)
│ └── AndroidManifest.xml # App 配置文件
├── build.gradle.kts # 这个模块的依赖配置
根目录还有个
build.gradle.kts
,是整个项目的配置。现在不用管它,后面加依赖的时候会用到。
四、Kotlin 速成:够用就行
Android 开发用的是 Kotlin 语言。如果你写过 Java、Python 或者 JavaScript,上手会很快。我挑几个最常用的语法讲一下。
4.1 变量
val name = "张三" // val 是常量,不能改
var age = 25 // var 是变量,可以改
age = 26 // OK
// name = "李四" // 报错!
4.2 函数
fun greet(name: String): String {
return "你好,KaTeX parse error: Expected 'EOF', got '}' at position 7: name" }̲ // 简写 fun gree...name"
4.3 数据类
这个特别好用,一行代码就能定义一个类,自动生成 equals、hashCode、toString:
data class Note(
val id: Long,
val content: String,
val timestamp: Long
)
4.4 空安全
Kotlin 最大的特点是变量默认不能为 null。如果可能为空,要加问号:
var title: String = "日记" // 不能为 null
var subtitle: String? = null // 可以为 null
// 访问可空变量
println(subtitle?.length) // 如果 subtitle 是 null,返回 null
println(subtitle ?: "无副标题") // 如果 subtitle 是 null,返回默认值
4.5 Lambda
经常用来做回调:
button.setOnClickListener {
println("按钮被点击了")
}
// 带参数
list.filter { item -> item.length > 5 }
// 只有一个参数时可以用 it
list.filter { it.length > 5 }
Kotlin 的语法糖很多,但这些够应付大部分场景了。剩下的边写边查。
五、UI 开发:Jetpack Compose 入门
传统的 Android UI 用 XML 写布局,然后在 Java/Kotlin 里操作 View。现在 Google 推荐用 Jetpack Compose,直接用 Kotlin 代码写 UI,更简洁。
5.1 第一个 Composable 函数
@Composable
fun Greeting(name: String) {
Text(text = "你好,$name")
}
@Composable 注解表示这是一个 UI 组件。Compose 用函数来描述界面,所以叫「声明式 UI」。
5.2 常用组件
// 文本
Text("这是一段文字")
// 按钮
Button(onClick = { /* 点击事件 */ }) {
Text("点我")
}
// 输入框
var text by remember { mutableStateOf("") }
TextField(
value = text,
onValueChange = { text = it },
placeholder = { Text("请输入...") }
)
// 图片
Image(
painter = painterResource(R.drawable.icon),
contentDescription = "图标"
)
5.3 布局
// 垂直排列
Column {
Text("第一行")
Text("第二行")
Text("第三行")
}
// 水平排列
Row {
Text("左边")
Text("右边")
}
// 层叠
Box {
Image(...)
Text("覆盖在图片上的文字")
}
5.4 列表
普通 Column 会一次性渲染所有内容,数据多了会卡。用 LazyColumn 可以实现懒加载:
LazyColumn {
items(noteList) { note ->
NoteCard(note)
}
}
5.5 状态管理
Compose 的核心概念:状态变了,UI 自动更新。
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Column {
Text("当前计数:$count")
Button(onClick = { count++ }) {
Text("加一")
}
}
}
remember 让状态在重组时保持不变,mutableStateOf 创建可观察的状态。当 count 变化时,用到它的 UI 会自动刷新。
六、做个能用的主页
理论讲完了,开始写实际代码。先做主页:显示今天的碎片想法,底部有个输入框可以添加新内容。
6.1 定义数据模型
在 data/local/database/entity 目录下创建
Note.kt
:
package com.example.journal.data.local.database.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "notes")
data class Note(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val content: String,
val timestamp: Long = System.currentTimeMillis(),
val date: Long // 归属日期,用于按天分组
)
@Entity 告诉 Room 数据库这是一张表。@PrimaryKey(autoGenerate = true) 表示 id 自动生成。
6.2 创建 DAO(数据访问对象)
在 data/local/database/dao 目录下创建
NoteDao.kt
:
package com.example.journal.data.local.database.dao
import androidx.room.*
import com.example.journal.data.local.database.entity.Note
import kotlinx.coroutines.flow.Flow
@Dao
interface NoteDao {
@Insert
suspend fun insertNote(note: Note): Long
@Delete
suspend fun deleteNote(note: Note)
@Query("SELECT * FROM notes WHERE date = :date ORDER BY timestamp DESC")
fun getNotesByDate(date: Long): Flow<List>
@Query("SELECT * FROM notes ORDER BY timestamp DESC")
fun getAllNotes(): Flow<List>
}
几个要点:
@Insert、@Delete 是 Room 提供的注解,自动生成 SQL
@Query 手写 SQL
suspend 表示这是个协程函数,不会阻塞主线程
返回
Flow
是响应式的,数据变了会自动通知
6.3 创建数据库
在 data/local/database 目录下创建
AppDatabase.kt
:
package com.example.journal.data.local.database
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.example.journal.data.local.database.dao.NoteDao
import com.example.journal.data.local.database.entity.Note
@Database(entities = [Note::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun noteDao(): NoteDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"journal_database"
).build().also { INSTANCE = it }
}
}
}
}
这是个典型的单例模式,保证整个 App 只有一个数据库实例。
6.4 添加依赖
打开
app/build.gradle.kts
,添加 Room 依赖:
dependencies {
// Room
val roomVersion = "2.6.1"
implementation("androidx.room:room-runtime:roomVersion")implementation("androidx.room:room−ktx:roomVersion") implementation("androidx.room:room-ktx:roomVersion")implementation("androidx.room:room−ktx:roomVersion")
ksp("androidx.room:room-compiler:$roomVersion")
}
在 plugins 块添加 KSP:
plugins {
// ... 其他插件
id("com.google.devtools.ksp") version "1.9.21-1.0.15"
}
点 Sync Now。
6.5 写主页 UI
创建
presentation/screen/main/MainScreen.kt
:
package com.example.journal.presentation.screen.main
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.example.journal.data.local.database.AppDatabase
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen() {
val context = LocalContext.current
val database = AppDatabase.getDatabase(context)
val noteDao = database.noteDao()
val scope = rememberCoroutineScope()
// 今天的日期(去掉时分秒)
val today = remember {
val cal = java.util.Calendar.getInstance()
cal.set(java.util.Calendar.HOUR_OF_DAY, 0)
cal.set(java.util.Calendar.MINUTE, 0)
cal.set(java.util.Calendar.SECOND, 0)
cal.set(java.util.Calendar.MILLISECOND, 0)
cal.timeInMillis
}
// 收集今天的笔记
val notes by noteDao.getNotesByDate(today).collectAsState(initial = emptyList())
// 输入框内容
var inputText by remember { mutableStateOf("") }
Scaffold(
topBar = {
TopAppBar(title = { Text("今日想法") })
},
bottomBar = {
// 底部输入栏
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
OutlinedTextField(
value = inputText,
onValueChange = { inputText = it },
modifier = Modifier.weight(1f),
placeholder = { Text("记录一个想法...") }
)
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
if (inputText.isNotBlank()) {
scope.launch {
noteDao.insertNote(
Note(content = inputText.trim(), date = today)
)
inputText = ""
}
}
}
) {
Text("添加")
}
}
}
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(16.dp)
) {
items(notes) { note ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
) {
Text(
text = note.content,
modifier = Modifier.padding(16.dp)
)
}
}
}
}
}
这段代码做了几件事:
获取数据库实例和 DAO
用 Flow.collectAsState() 订阅数据变化
底部有个输入框和按钮,点击后插入数据
用 LazyColumn 显示列表
6.6 跑起来看看
把
MainActivity.kt
改成:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
MainScreen()
}
}
}
}
运行,应该能看到一个简单的笔记列表了。输入文字点添加,列表会自动更新。
七、架构重构:为什么代码要分层
上面的代码能跑,但有个问题:UI 里直接操作数据库,耦合太紧了。如果以后要换数据库、加缓存、写测试,改起来会很痛苦。
所以我们要做架构分层。
7.1 Clean Architecture 简介
这个架构把代码分成三层:
┌─────────────────────────────────────┐
│ Presentation (UI + ViewModel) │ ← 展示层:用户看到的界面
├─────────────────────────────────────┤
│ Domain (UseCase + Model) │ ← 领域层:业务逻辑
├─────────────────────────────────────┤
│ Data (Repository + DataSource) │ ← 数据层:数据来源
└─────────────────────────────────────┘
好处:
每层职责单一,改一个地方不会影响其他
方便单元测试
代码可读性高
7.2 添加 Repository
Repository 是数据层的门面,UI 不直接访问 DAO,而是通过 Repository。
创建
data/repository/NoteRepository.kt
:
package com.example.journal.data.repository
import com.example.journal.data.local.database.dao.NoteDao
import com.example.journal.data.local.database.entity.Note
import kotlinx.coroutines.flow.Flow
class NoteRepository(private val noteDao: NoteDao) {
fun getNotesByDate(date: Long): Flow<List> {
return noteDao.getNotesByDate(date)
}
fun getAllNotes(): Flow<List> {
return noteDao.getAllNotes()
}
suspend fun insertNote(note: Note): Long {
return noteDao.insertNote(note)
}
suspend fun deleteNote(note: Note) {
noteDao.deleteNote(note)
}
}
看起来只是包了一层?没错,简单场景下确实是。但当你需要加缓存、做数据转换、或者同时从本地和网络取数据时,Repository 的价值就体现出来了。
7.3 添加 ViewModel
ViewModel 负责持有 UI 状态,处理用户操作。它独立于 UI 生命周期,屏幕旋转时数据不会丢失。
创建
presentation/screen/main/MainViewModel.kt
:
package com.example.journal.presentation.screen.main
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.example.journal.data.local.database.entity.Note
import com.example.journal.data.repository.NoteRepository
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.util.Calendar
class MainViewModel(
private val noteRepository: NoteRepository
) : ViewModel() {
private val _inputText = MutableStateFlow("")
val inputText: StateFlow = _inputText.asStateFlow()
private val today: Long = Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
val todayNotes: StateFlow<List> = noteRepository
.getNotesByDate(today)
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
fun updateInput(text: String) {
_inputText.value = text
}
fun addNote() {
val content = _inputText.value.trim()
if (content.isBlank()) return
viewModelScope.launch {
noteRepository.insertNote(
Note(content = content, date = today)
)
_inputText.value = ""
}
}
fun deleteNote(note: Note) {
viewModelScope.launch {
noteRepository.deleteNote(note)
}
}
class Factory(private val repository: NoteRepository) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun create(modelClass: Class): T {
return MainViewModel(repository) as T
}
}
}
7.4 更新 UI
现在
MainScreen
变得很干净:
@Composable
fun MainScreen() {
val context = LocalContext.current
val database = AppDatabase.getDatabase(context)
val repository = NoteRepository(database.noteDao())
val viewModel: MainViewModel = viewModel(factory = MainViewModel.Factory(repository))
val notes by viewModel.todayNotes.collectAsState()
val inputText by viewModel.inputText.collectAsState()
Scaffold(
topBar = { TopAppBar(title = { Text("今日想法") }) },
bottomBar = {
Row(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
OutlinedTextField(
value = inputText,
onValueChange = { viewModel.updateInput(it) },
modifier = Modifier.weight(1f),
placeholder = { Text("记录一个想法...") }
)
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = { viewModel.addNote() }) {
Text("添加")
}
}
}
) { padding ->
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp)
) {
items(notes) { note ->
NoteCard(note = note, onDelete = { viewModel.deleteNote(note) })
}
}
}
}
UI 只负责展示和收集用户操作,业务逻辑都在 ViewModel 里。
八、接入 AI:让日记自己写
这是最有意思的部分。我们要调用 LLM API,把碎片想法整理成日记。
8.1 选择一个 LLM 服务
国内能用的 LLM API 很多:
DeepSeek:便宜好用,中文效果不错
通义千问:阿里的,有免费额度
智谱 GLM:清华背景,也不错
我用的是 DeepSeek,注册后充几块钱能用很久。
8.2 添加网络权限和依赖
在
AndroidManifest.xml
添加:
在 build.gradle.kts 添加 OkHttp(网络请求库)和 Gson(JSON 解析):
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.google.code.gson:gson:2.10.1")
8.3 写 LLM 调用代码
创建 data/remote/LlmService.kt:
package com.example.journal.data.remote
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit
object LlmService {
private val client = OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.build()
private val gson = Gson()
private const val API_URL = "https://api.deepseek.com/v1/chat/completions"
suspend fun generateDiary(notes: List, apiKey: String): Result {
return withContext(Dispatchers.IO) {
try {
val notesText = notes.mapIndexed { i, n -> "{i + 1}. n" }.joinToString("\n")
val prompt = """
请将以下碎片想法整理成一篇日记。要求:
1. 保持原意,可以适当润色
2. 按时间或主题组织
3. 语气自然,像在和朋友聊天
4. 结尾可以加一两句感悟
碎片想法:
$notesText
""".trimIndent()
val requestBody = mapOf(
"model" to "deepseek-chat",
"messages" to listOf(
mapOf("role" to "user", "content" to prompt)
)
)
val request = Request.Builder()
.url(API_URL)
.addHeader("Authorization", "Bearer $apiKey")
.addHeader("Content-Type", "application/json")
.post(gson.toJson(requestBody).toRequestBody("application/json".toMediaType()))
.build()
val response = client.newCall(request).execute()
val body = response.body?.string() ?: throw Exception("Empty response")
// 解析响应
val json = gson.fromJson(body, Map::class.java)
val choices = json["choices"] as? List<*>
val message = (choices?.firstOrNull() as? Map<*, *>)?.get("message") as? Map<*, *>
val content = message?.get("content") as? String ?: throw Exception("Invalid response")
Result.success(content)
} catch (e: Exception) {
Result.failure(e)
}
}
}
}
8.4 添加生成按钮
在
MainScreen
添加一个「生成日记」按钮:
Button(
onClick = {
scope.launch {
val noteContents = notes.map { it.content }
val result = LlmService.generateDiary(noteContents, "your-api-key")
result.onSuccess { diary ->
// 保存到数据库或显示出来
}.onFailure { e ->
// 显示错误提示
}
}
}
) {
Text("生成日记")
}
⚠️ 注意:不要把 API Key 硬编码在代码里!应该让用户在设置页面输入,然后存到 SharedPreferences。
九、添加更多功能
基础框架搭好后,加功能就快了。我简单列一下我后来加的东西:
9.1 日记列表页
新建一个页面,用 LazyColumn 显示所有日记。每个日记卡片显示日期、内容预览、情绪标签。
9.2 搜索功能
在 DAO 里加一个搜索方法:
@Query("SELECT * FROM diaries WHERE content LIKE '%' || :query || '%'")
fun searchByContent(query: String): Flow<List>
UI 上加个搜索框,输入时调用这个查询。
9.3 导出功能
把日记导出成 Markdown 文件:
fun exportToMarkdown(diary: Diary): String {
return """
${formatDate(diary.date)} 的日记
${diary.content}
---
*情绪:${diary.moodLabel}*
""".trimIndent()
}
然后用 MediaStore API 保存到 Downloads 文件夹。
9.4 待办事项提取
让 AI 在生成日记的同时提取待办:
val prompt = """
请完成两件事:
-
将以下想法整理成日记
-
提取其中提到的待办事项
输出格式:
【日记】
...【待办】
- 待办1
- 待办2
"""
然后解析响应,分别保存日记和待办。
9.5 语音输入
Android 自带语音识别 API:
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
putExtra(RecognizerIntent.EXTRA_LANGUAGE, "zh-CN")
}
speechRecognizer.startListening(intent)
识别结果通过回调返回,直接添加到笔记列表。
十、调试和发布
10.1 常见问题
Q: 应用闪退了怎么办? 看 Logcat。在 Android Studio 底部找到 Logcat 标签,过滤 Error 级别,找红色的 Exception。
Q: 数据库结构改了,应用崩溃? 数据库版本号要加 1,并且添加 Migration 或者用 fallbackToDestructiveMigration()(开发阶段)。
Q: 网络请求没反应?
检查有没有加 INTERNET 权限
检查是不是主线程调的(要用协程)
看 Logcat 有没有报错
10.2 打包 APK
菜单栏 Build → Generate Signed Bundle / APK,选 APK,创建或选择签名文件,选 release 版本,点 Finish。
生成的 APK 在 app/release 目录下。
十一、总结
写到这里,你应该对 Android 开发有个基本概念了。回顾一下我们做了什么:
环境搭建:安装 Android Studio,创建 Compose 项目
Kotlin 基础:变量、函数、数据类、空安全、Lambda
Compose UI:组件、布局、状态管理、列表
Room 数据库:Entity、DAO、Database
架构设计:Repository、ViewModel、分层
网络请求:调用 LLM API
这些是开发任何 Android App 都会用到的基础。剩下的就是根据需求堆功能了。
我的建议是:别光看,动手写。遇到问题就去查文档、搜 Stack Overflow。踩坑是学习的必经之路。
最后附上项目地址:https://github.com/kai200407/AI-Diary-App-Android
有问题欢迎在评论区讨论。
这篇文章写于 2025 年 12 月,如果你看到时 API 变了,以官方文档为准。