简介:这是一份面向Android开发初学者与课程设计学生的Java语言记事本应用实战项目,基于Android Studio完整实现用户登录注册、记事本增删改查及SQLite本地持久化存储功能,覆盖移动应用开发核心流程。资源包共55个文件,包含12个Java业务逻辑与Activity类、16个XML布局与配置文件、12个PNG图标资源、3个Gradle构建脚本及APK安装包、MP4演示视频、Word说明文档等,总大小4.39MB,结构清晰,便于逐模块学习调试。已有9305人学习下载,适合用于Android基础实训、课程大作业或毕业设计参考。用户可直接安装APK体验完整功能,结合源码理解SQLite数据库操作、Activity生命周期管理与UI交互逻辑,并通过运行文档快速搭建开发环境;针对部分用户反馈解压异常问题,资源已适配主流解压工具,推荐使用WinRAR确保文件完整性。
1. 项目概述:从零构建一个安卓记事本应用
最近在整理手机里的零散信息时,发现很多临时想法、待办事项和重要信息都散落在各个聊天记录、截图和便签里,找起来非常麻烦。市面上的笔记应用功能要么太复杂,要么充斥着广告,要么就是云端同步需要付费。作为一个有动手能力的开发者,我萌生了自己动手做一个纯粹、轻量、完全掌控的安卓记事本应用的想法。这个项目的核心目标很明确:打造一个功能完整、数据安全、界面简洁的本地记事本,它应该能快速创建、编辑、删除和搜索笔记,并且所有数据都存储在手机本地,不依赖任何网络服务。
这个项目非常适合有一定Java或Kotlin基础,并希望深入理解安卓应用开发核心流程的开发者。通过它,你不仅能实践Android Studio的使用、Activity与Fragment的生命周期管理、SQLite数据库操作、RecyclerView列表展示等核心技能,还能深刻体会到从需求分析、UI设计、数据建模到功能实现、测试调试的完整项目闭环。整个过程就像搭积木,从最基础的数据存储“地基”开始,一层层构建起用户交互的“楼层”,最终形成一个可用的产品。接下来,我将详细拆解这个记事本应用的开发全过程,分享其中的设计思路、关键技术实现以及我踩过的一些坑。
2. 开发环境搭建与项目初始化
2.1 Android Studio安装与基础配置
工欲善其事,必先利其器。开发安卓应用的首选工具就是Android Studio。直接从官网下载最新稳定版安装包即可,安装过程基本是“下一步”到底。安装完成后,首次启动会进行一些组件和SDK的下载,这里需要保持网络通畅。一个关键的配置点是SDK Manager。我建议至少安装一个目前市场占有率较高的API级别(例如API 33 Android 13)的SDK Platform,以及对应版本的System Image(用于模拟器)和Sources for Android SDK(方便查看源码)。
注意:国内网络环境下载SDK可能较慢或失败,可以在Android Studio的设置中,找到
Appearance & Behavior->System Settings->HTTP Proxy,设置一个可用的代理镜像源,例如将代理设置为mirrors.neusoft.edu.cn:80(大连东软信息学院镜像站),能显著提升下载速度。
创建新项目时,选择Empty Views Activity模板即可,语言我选择了Kotlin,因为它现在是安卓开发的官方首选语言,语法更简洁安全。Minimum SDK的选择需要权衡:版本太低无法使用新特性,版本太高会限制应用安装范围。对于记事本这种工具类应用,选择API 24: Android 7.0 (Nougat)是一个不错的平衡点,既能使用现代API,又能覆盖绝大多数仍在使用的设备。项目创建后,建议第一时间在File->Settings->Editor->General->Auto Import中,勾选Add unambiguous imports on the fly和Optimize imports on the fly,这能自动管理import语句,提升编码效率。
2.2 项目结构规划与依赖引入
一个清晰的项目结构是后续高效开发的基础。Android Studio默认的工程结构(Android视图)已经做了很好的归类。我们主要关注app模块下的几个关键目录:
manifests/: 存放AndroidManifest.xml,这是应用的“身份证”,声明了组件和权限。java/(或kotlin/): 存放所有的源代码,按功能分包。例如,我们可以创建com.yourname.notebook.ui(存放Activity、Fragment)、com.yourname.notebook.data(存放数据库、实体类、仓库)、com.yourname.notebook.adapter(存放RecyclerView适配器)等包。res/: 存放所有资源文件,包括布局(layout/)、图片(drawable/)、字符串(values/)、样式(values/)等。
对于记事本应用,我们需要引入一些额外的库来简化开发。在app模块的build.gradle.kts(或build.gradle) 文件的dependencies块中添加。核心依赖包括Room(用于SQLite数据库抽象)、ViewModel和LiveData(用于以生命周期感知的方式管理UI相关数据)。以下是Kotlin DSL的配置示例:
dependencies { 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") // Room for database val room_version = "2.6.1" implementation("androidx.room:room-runtime:$room_version") kapt("androidx.room:room-compiler:$room_version") implementation("androidx.room:room-ktx:$room_version") // optional - Kotlin Extensions and Coroutines support // Lifecycle components implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0") implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0") implementation("androidx.lifecycle:lifecycle-common-java8:2.7.0") // Coroutines for asynchronous operations implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") }添加后同步项目(Sync Now)。使用Room而不是直接操作SQLiteDatabase,可以让我们用面向对象的方式操作数据库,编译器会检查SQL语句的正确性,大大减少了样板代码和运行时错误。
3. 数据层设计:实体、DAO与数据库
记事本应用的核心是数据,因此我们先从数据层开始构建。采用Jetpack组件推荐的Repository模式,数据层由Entity(实体)、DAO(数据访问对象)和Database(数据库)组成。
3.1 定义数据实体(Entity)
实体类对应数据库中的一张表。一个简单的笔记需要哪些字段?我设计了id(主键,自增)、title(标题)、content(内容)、createdTime(创建时间)和updatedTime(最后修改时间)。创建时间有助于回顾,修改时间能快速定位最新编辑的笔记。在data包下创建Note.kt文件:
import androidx.room.Entity import androidx.room.PrimaryKey import java.util.Date @Entity(tableName = "notes") data class Note( @PrimaryKey(autoGenerate = true) val id: Long = 0, var title: String = "", var content: String = "", val createdTime: Date = Date(), var updatedTime: Date = Date() )@Entity注解标明这是一个Room实体,tableName指定表名。@PrimaryKey定义主键,autoGenerate = true表示由数据库自动生成递增的ID。使用Date类型存储时间,Room可以通过类型转换器(TypeConverter)将其存储为数据库能识别的格式(如Long类型的时间戳)。
3.2 创建类型转换器(TypeConverter)
由于Room不能直接存储Date对象,我们需要定义一个转换器。在data包下创建Converters.kt:
import androidx.room.TypeConverter import java.util.Date class Converters { @TypeConverter fun fromTimestamp(value: Long?): Date? { return value?.let { Date(it) } } @TypeConverter fun dateToTimestamp(date: Date?): Long? { return date?.time } }3.3 定义数据访问对象(DAO)
DAO是一个接口,其中定义了访问数据库的各种方法(增删改查)。Room会在编译时为我们生成具体的实现。在data包下创建NoteDao.kt:
import androidx.room.* import kotlinx.coroutines.flow.Flow @Dao interface NoteDao { @Query("SELECT * FROM notes ORDER BY updatedTime DESC") fun getAllNotes(): Flow<List<Note>> // 使用Flow,当数据变化时自动通知观察者 @Query("SELECT * FROM notes WHERE title LIKE '%' || :query || '%' OR content LIKE '%' || :query || '%' ORDER BY updatedTime DESC") fun searchNotes(query: String): Flow<List<Note>> @Query("SELECT * FROM notes WHERE id = :noteId") suspend fun getNoteById(noteId: Long): Note? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertNote(note: Note): Long // 返回插入行的ID @Update suspend fun updateNote(note: Note) @Delete suspend fun deleteNote(note: Note) }这里有几个关键点:
Flow<List<Note>>:getAllNotes和searchNotes返回Flow类型。这是Kotlin协程中的冷流,结合Room,它可以实现数据库的实时观察。当笔记数据发生任何变化(增删改)时,所有收集此Flow的UI都会自动收到更新后的列表,无需手动刷新。这是构建响应式UI的基石。- 搜索查询:
searchNotes方法使用了SQL的LIKE操作符和||连接符(SQLite中用于字符串拼接),实现了在标题和内容中进行模糊匹配。 suspend函数: 除了返回Flow的查询,其他操作数据库的函数都标记为suspend。这意味着它们必须在协程作用域内调用,Room会确保这些耗时的IO操作在后台线程执行,不会阻塞主线程。
3.4 创建数据库(Database)
最后,我们将Entity、DAO和TypeConverter组合起来,创建数据库实例。在data包下创建AppDatabase.kt:
import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters import android.content.Context @Database(entities = [Note::class], version = 1, exportSchema = false) @TypeConverters(Converters::class) 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) { val instance = Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, "notebook_database" ).build() INSTANCE = instance instance } } } }这里使用了单例模式来确保整个应用只有一个数据库实例,避免重复打开数据库造成资源浪费和潜在错误。exportSchema = false暂时关闭了架构导出,对于简单项目可以简化配置。数据库文件将以notebook_database命名保存在设备的私有存储空间中。
4. 仓库层与ViewModel:连接数据与UI
数据层准备好了,我们需要一个中间层来协调数据访问,并为UI提供易于观察的数据源。这就是Repository和ViewModel的职责。
4.1 实现仓库(Repository)
Repository封装了数据源(这里就是Room数据库),对上层提供统一的数据访问接口。在data包下创建NoteRepository.kt:
import kotlinx.coroutines.flow.Flow class NoteRepository(private val noteDao: NoteDao) { val allNotes: Flow<List<Note>> = noteDao.getAllNotes() fun searchNotes(query: String): Flow<List<Note>> = noteDao.searchNotes(query) suspend fun getNoteById(id: Long): Note? = noteDao.getNoteById(id) suspend fun insert(note: Note): Long = noteDao.insertNote(note) suspend fun update(note: Note) = noteDao.updateNote(note) suspend fun delete(note: Note) = noteDao.deleteNote(note) }仓库的构造依赖于DAO。它几乎只是对DAO方法的一层薄封装,但这样做的好处是,如果未来数据源发生变化(例如增加网络同步),我们只需要修改Repository内部的实现,而所有依赖Repository的ViewModel和UI代码都无需改动。
4.2 创建笔记列表的ViewModel
ViewModel负责为UI(Activity/Fragment)准备和管理数据。它会在配置变更(如屏幕旋转)时存活,避免数据丢失。在ui包下创建子包list,然后创建NoteListViewModel.kt:
import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch class NoteListViewModel(private val repository: NoteRepository) : ViewModel() { // 私有可变的搜索词流 private val _searchQuery = MutableStateFlow("") // 公开只读的搜索词流 val searchQuery: StateFlow<String> = _searchQuery // 笔记列表流:结合搜索词和所有笔记流,动态过滤 val notes: StateFlow<List<Note>> = combine( repository.allNotes, _searchQuery ) { notes, query -> if (query.isBlank()) { notes // 搜索词为空,返回全部笔记 } else { notes.filter { note -> note.title.contains(query, ignoreCase = true) || note.content.contains(query, ignoreCase = true) } } }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), // 当有订阅者时启动,最后一个订阅者离开5秒后停止 initialValue = emptyList() ) // 更新搜索词 fun onSearchQueryChanged(query: String) { _searchQuery.value = query } // 删除笔记 fun deleteNote(note: Note) { viewModelScope.launch { repository.delete(note) } } }这个ViewModel的设计有几个精妙之处:
- 响应式搜索:没有在每次用户输入时都去查询数据库。而是使用
combine操作符,将“所有笔记流”和“搜索词流”结合起来。每当其中任何一个流发出新数据时,combine块都会重新执行,根据最新的搜索词对笔记列表进行内存过滤。这比频繁的数据库查询更高效。 StateFlow的使用:_searchQuery是私有的MutableStateFlow,通过一个公开的只读StateFlow暴露给UI。notes也被转换为StateFlow,确保UI总能拿到一个最新的、非空的列表状态(初始值为空列表)。StateFlow是热流,会记住最后一个值,新的订阅者会立即得到当前值。- 协程作用域:所有可能耗时的操作(如
delete)都包裹在viewModelScope.launch中,确保在ViewModel销毁时自动取消,避免内存泄漏。
4.3 创建编辑笔记的ViewModel
在ui包下创建子包edit,然后创建NoteEditViewModel.kt,用于处理单条笔记的创建和编辑。
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch class NoteEditViewModel( private val repository: NoteRepository, savedStateHandle: SavedStateHandle // 用于获取导航传递的参数 ) : ViewModel() { private val noteId: Long = savedStateHandle.get<Long>("noteId") ?: -1L // 用于UI绑定的状态 private val _title = MutableStateFlow("") val title: StateFlow<String> = _title private val _content = MutableStateFlow("") val content: StateFlow<String> = _content private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow<Boolean> = _isLoading private val _saveSuccess = MutableStateFlow(false) val saveSuccess: StateFlow<Boolean> = _saveSuccess init { // 如果noteId有效,则加载已有笔记 if (noteId > 0) { loadNote() } } private fun loadNote() { viewModelScope.launch { _isLoading.value = true val note = repository.getNoteById(noteId) note?.let { _title.value = it.title _content.value = it.content } _isLoading.value = false } } fun updateTitle(newTitle: String) { _title.value = newTitle } fun updateContent(newContent: String) { _content.value = newContent } fun saveNote() { val currentTitle = _title.value val currentContent = _content.value if (currentTitle.isBlank() && currentContent.isBlank()) { // 标题和内容都为空,不保存或可视为删除,这里选择不保存 return } viewModelScope.launch { _isLoading.value = true val note = if (noteId > 0) { // 更新现有笔记 Note(id = noteId, title = currentTitle, content = currentContent, createdTime = Date(), updatedTime = Date()) } else { // 创建新笔记 Note(title = currentTitle, content = currentContent) } if (noteId > 0) { repository.update(note) } else { repository.insert(note) } _isLoading.value = false _saveSuccess.value = true // 通知UI保存成功,可用于导航回列表 } } }这个ViewModel处理了两种场景:创建新笔记(noteId为-1或0)和编辑已有笔记(noteId>0)。它通过SavedStateHandle获取从列表页面传递过来的笔记ID。saveNote方法包含了简单的验证逻辑,防止保存空笔记。保存成功后,通过_saveSuccess流发出信号,UI可以监听此信号并做出反应(例如关闭当前页面)。
5. UI层实现:列表、编辑与搜索
UI层我们使用Jetpack Compose来构建,这是目前官方推荐的现代UI工具包,声明式语法让UI代码更直观。如果你的项目仍在使用XML,思路是相通的,只是实现方式不同。
5.1 依赖配置与主题设置
首先,确保在app/build.gradle.kts中启用了Compose并添加了必要依赖:
android { buildFeatures { compose = true } composeOptions { kotlinCompilerExtensionVersion = "1.5.7" } } dependencies { // Compose implementation(platform("androidx.compose:compose-bom:2024.02.00")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.material3:material3") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0") // 用于在Compose中收集Flow implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") // 在Compose中获取ViewModel implementation("androidx.navigation:navigation-compose:2.7.6") // 导航 androidTestImplementation(platform("androidx.compose:compose-bom:2024.02.00")) debugImplementation("androidx.compose.ui:ui-tooling") }然后,修改MainActivity.kt,设置Compose主题和导航入口:
import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.ui.Modifier import com.yourname.notebook.ui.theme.NotebookTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { NotebookTheme { // 应用自定义主题(由Android Studio自动生成) Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { NotebookApp() // 应用的根Composable } } } } }5.2 实现导航与应用入口
在ui包下创建NotebookApp.kt,定义导航图:
import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.yourname.notebook.data.AppDatabase import com.yourname.notebook.data.NoteRepository import com.yourname.notebook.ui.edit.NoteEditScreen import com.yourname.notebook.ui.edit.NoteEditViewModel import com.yourname.notebook.ui.list.NoteListScreen import com.yourname.notebook.ui.list.NoteListViewModel @Composable fun NotebookApp() { val navController = rememberNavController() // 依赖注入:创建数据库、仓库,并传递给ViewModel val database = remember { AppDatabase.getDatabase(LocalContext.current) } val repository = remember { NoteRepository(database.noteDao()) } NavHost(navController = navController, startDestination = "noteList") { composable("noteList") { val viewModel: NoteListViewModel = viewModel( factory = NoteListViewModelFactory(repository) ) NoteListScreen( viewModel = viewModel, onNoteClick = { noteId -> navController.navigate("noteEdit/$noteId") }, onAddNoteClick = { navController.navigate("noteEdit/-1") // -1 表示新建 } ) } composable( route = "noteEdit/{noteId}", arguments = listOf(navArgument("noteId") { type = NavType.LongType }) ) { backStackEntry -> val noteId = backStackEntry.arguments?.getLong("noteId") ?: -1L val viewModel: NoteEditViewModel = viewModel( factory = NoteEditViewModelFactory(repository, noteId) ) NoteEditScreen( viewModel = viewModel, onBack = { navController.popBackStack() } ) } } }这里使用了Compose Navigation进行页面导航。我们定义了两个目的地:笔记列表(noteList)和笔记编辑(noteEdit/{noteId})。{noteId}是一个路径参数,用于传递要编辑的笔记ID。viewModel()函数配合自定义的Factory来创建ViewModel并注入依赖(Repository)。remember用于在重组过程中保持数据库和仓库实例的单例性。
5.3 实现笔记列表界面(NoteListScreen)
在ui/list包下创建NoteListScreen.kt:
import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Search import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun NoteListScreen( viewModel: NoteListViewModel, onNoteClick: (Long) -> Unit, onAddNoteClick: () -> Unit ) { val notes by viewModel.notes.collectAsStateWithLifecycle() // 收集笔记列表状态 var searchQuery by remember { mutableStateOf("") } val scope = rememberCoroutineScope() // 监听搜索词变化,并通知ViewModel LaunchedEffect(searchQuery) { viewModel.onSearchQueryChanged(searchQuery) } Scaffold( topBar = { TopAppBar( title = { Text("我的记事本") }, actions = { // 搜索图标,点击可展开搜索栏(简化版,实际可做更复杂交互) IconButton(onClick = { /* 可在此处切换搜索栏显示状态 */ }) { Icon(Icons.Default.Search, contentDescription = "搜索") } } ) }, floatingActionButton = { FloatingActionButton(onClick = onAddNoteClick) { Icon(Icons.Default.Add, contentDescription = "添加笔记") } } ) { innerPadding -> Column(modifier = Modifier.padding(innerPadding)) { // 搜索框 OutlinedTextField( value = searchQuery, onValueChange = { searchQuery = it }, modifier = Modifier .fillMaxWidth() .padding(16.dp), label = { Text("搜索标题或内容...") }, singleLine = true ) if (notes.isEmpty()) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Text( text = if (searchQuery.isNotBlank()) "未找到相关笔记" else "暂无笔记,点击右下角按钮添加", style = MaterialTheme.typography.bodyLarge ) } } else { LazyColumn(modifier = Modifier.fillMaxSize()) { items( items = notes, key = { it.id } // 为每个item设置唯一key,优化重组性能 ) { note -> NoteListItem( note = note, onNoteClick = { onNoteClick(note.id) }, onDeleteClick = { viewModel.deleteNote(note) } ) Divider() // 列表项之间的分割线 } } } } } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun NoteListItem( note: Note, onNoteClick: () -> Unit, onDeleteClick: () -> Unit ) { Card( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), onClick = onNoteClick ) { Row( modifier = Modifier .fillMaxWidth() .padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { Text( text = note.title.ifBlank { "(无标题)" }, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis ) Spacer(modifier = Modifier.height(4.dp)) Text( text = note.content.ifBlank { "(无内容)" }, style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(4.dp)) Text( text = "更新于:${SimpleDateFormat("yyyy-MM-dd HH:mm").format(note.updatedTime)}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline ) } IconButton(onClick = onDeleteClick) { Icon(Icons.Default.Delete, contentDescription = "删除", tint = MaterialTheme.colorScheme.error) } } } }列表界面主要包含一个顶栏、一个搜索框、一个笔记列表和一个悬浮添加按钮。使用LazyColumn高效渲染长列表。NoteListItem展示单条笔记的标题(截断)、内容预览和更新时间。删除按钮直接放在列表项上,操作便捷但需谨慎,最好可以增加确认对话框。
5.4 实现笔记编辑界面(NoteEditScreen)
在ui/edit包下创建NoteEditScreen.kt:
import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Save import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.text.input.ImeAction import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun NoteEditScreen( viewModel: NoteEditViewModel, onBack: () -> Unit ) { val title by viewModel.title.collectAsStateWithLifecycle() val content by viewModel.content.collectAsStateWithLifecycle() val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() val saveSuccess by viewModel.saveSuccess.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } // 监听保存成功信号,成功后返回上一页 LaunchedEffect(saveSuccess) { if (saveSuccess) { onBack() } } Scaffold( topBar = { TopAppBar( title = { Text(if (viewModel.noteId > 0) "编辑笔记" else "新建笔记") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, contentDescription = "返回") } }, actions = { IconButton( onClick = { viewModel.saveNote() }, enabled = !isLoading && (title.isNotBlank() || content.isNotBlank()) ) { if (isLoading) { CircularProgressIndicator(modifier = Modifier.size(24.dp)) } else { Icon(Icons.Default.Save, contentDescription = "保存") } } } ) } ) { innerPadding -> Column(modifier = Modifier.padding(innerPadding)) { OutlinedTextField( value = title, onValueChange = { viewModel.updateTitle(it) }, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), label = { Text("标题 (可选)") }, singleLine = true, imeAction = ImeAction.Next, // 自动聚焦到标题栏(新建时) focusRequester = focusRequester ) OutlinedTextField( value = content, onValueChange = { viewModel.updateContent(it) }, modifier = Modifier .fillMaxWidth() .weight(1f) .padding(horizontal = 16.dp, vertical = 8.dp), label = { Text("内容") }, singleLine = false, maxLines = Int.MAX_VALUE, // 允许多行 textStyle = LocalTextStyle.current.copy(lineHeight = TextStyle.Default.lineHeight * 1.2) // 增加行高 ) } } // 自动聚焦逻辑(仅在新笔记时) if (viewModel.noteId <= 0) { LaunchedEffect(Unit) { focusRequester.requestFocus() } } }编辑界面相对简单,包含一个返回按钮、一个保存按钮(在加载时显示进度条)、一个标题输入框和一个内容输入框。内容框使用maxLines = Int.MAX_VALUE使其可以无限扩展,Modifier.weight(1f)让它占据剩余的所有垂直空间。通过LaunchedEffect监听saveSuccess,一旦保存成功就自动返回列表页,提供了流畅的用户体验。自动聚焦功能提升了新建笔记时的操作效率。
6. 功能增强、测试与优化
基础功能完成后,一个可用的记事本应用已经成型。但要让其更健壮、更好用,还需要进行功能增强和优化。
6.1 添加笔记排序与筛选
用户可能希望按创建时间、更新时间或字母顺序查看笔记。我们可以在列表ViewModel和界面中增加排序功能。首先,在NoteListViewModel中增加排序状态和逻辑:
// 在NoteListViewModel中 sealed class SortOrder { object ByUpdatedTimeDesc : SortOrder() // 按更新时间降序(默认) object ByUpdatedTimeAsc : SortOrder() object ByCreatedTimeDesc : SortOrder() object ByTitleAsc : SortOrder() } private val _sortOrder = MutableStateFlow<SortOrder>(SortOrder.ByUpdatedTimeDesc) val sortOrder: StateFlow<SortOrder> = _sortOrder fun updateSortOrder(newOrder: SortOrder) { _sortOrder.value = newOrder } // 修改notes流的combine逻辑,加入排序 val notes: StateFlow<List<Note>> = combine( repository.allNotes, _searchQuery, _sortOrder ) { notes, query, order -> var filteredNotes = if (query.isBlank()) notes else { notes.filter { it.title.contains(query, ignoreCase = true) || it.content.contains(query, ignoreCase = true) } } // 根据排序规则排序 filteredNotes = when (order) { is SortOrder.ByUpdatedTimeDesc -> filteredNotes.sortedByDescending { it.updatedTime } is SortOrder.ByUpdatedTimeAsc -> filteredNotes.sortedBy { it.updatedTime } is SortOrder.ByCreatedTimeDesc -> filteredNotes.sortedByDescending { it.createdTime } is SortOrder.ByTitleAsc -> filteredNotes.sortedBy { it.title.lowercase() } else -> filteredNotes } filteredNotes }.stateIn(...)然后在NoteListScreen的TopBar的actions中添加一个下拉菜单用于选择排序方式。
6.2 数据备份与恢复(简易版)
对于本地应用,数据备份至关重要。一个简单的方法是定期将数据库文件复制到应用的“外部存储”私有目录或用户选择的目录(需要请求存储权限)。可以使用WorkManager定期执行备份任务。更用户友好的方式是提供“导出为文件”和“从文件导入”的功能,将笔记列表导出为JSON或CSV格式。
6.3 单元测试与UI测试
单元测试:为Repository和ViewModel编写单元测试,确保业务逻辑正确。使用androidx.arch.core:core-testing来测试ViewModel和LiveData/StateFlow,使用Room的内存数据库(Room.inMemoryDatabaseBuilder)来测试Repository,避免污染真实数据。
UI测试:使用Espresso(对于View系统)或Compose UI Test(对于Compose)来模拟用户操作,验证界面交互是否正确。例如,测试添加笔记、编辑内容、搜索、删除等流程。
6.4 性能优化与常见问题排查
数据库查询优化:确保
@Query中的SQL语句高效。为经常用于搜索和排序的列(如title,updatedTime)建立索引。在Note实体类中,可以通过@ColumnInfo(index = true)注解或直接在DAO的@Query中使用CREATE INDEX语句(需在数据库升级中处理)。@Entity(tableName = "notes", indices = [Index(value = ["updatedTime"], unique = false)]) data class Note(...)列表性能:在
NoteListItem中使用Modifier的clickable而非Card的onClick可能会在某些情况下有细微性能差异,但通常可忽略。确保LazyColumn的items提供了稳定的key,这是避免不必要的重组和动画错误的关键。内存泄漏预防:
- 在Compose中收集Flow时,使用
collectAsStateWithLifecycle()(需要添加androidx.lifecycle:lifecycle-runtime-compose依赖)而不是普通的collectAsState()。这能确保在应用进入后台时停止收集,节省资源。 - 在ViewModel中使用
viewModelScope启动协程,它会自动在ViewModel清除时取消。
- 在Compose中收集Flow时,使用
常见编译/运行时问题:
- “Cannot access database on the main thread”: 确保所有Room的
suspend函数都在协程中调用,或者使用allowMainThreadQueries()(仅用于调试,生产环境禁用)。 - “TypeConverter not found”: 检查
@TypeConverters注解是否正确添加到@Database类上,并且转换器方法是public的。 - Compose预览无法渲染:如果预览报错找不到ViewModel,可以在
@Preview函数中提供默认值或使用@PreviewParameter。对于依赖真实数据库的Composable,预览可能比较复杂,可以考虑将数据依赖抽象出来,在预览中注入假数据。
- “Cannot access database on the main thread”: 确保所有Room的
应用图标与名称:在
res/mipmap-*目录下替换应用图标,在res/values/strings.xml中修改app_name。在AndroidManifest.xml中检查android:icon和android:label属性是否正确引用了这些资源。
开发这样一个完整的应用,从设计到实现,会遇到各种预料之外的问题。我的经验是,每当遇到一个报错,首先仔细阅读错误信息,它通常已经指明了方向。其次,善用Android Studio的Logcat查看详细堆栈,并使用断点调试功能逐步执行代码,观察变量状态。最后,官方文档(developer.android.com)和社区(如Stack Overflow)是解决问题最可靠的资源。这个记事本项目麻雀虽小,五脏俱全,涵盖了现代安卓开发的主要技术点,是巩固知识、练习架构思维的绝佳练手项目。
本文还有配套的精品资源,点击获取