简介:这份资源是基于 Android Studio 开发的安卓记事本 App 完整项目,面向安卓入门学习者、课程设计或毕业设计需求者,帮助其掌握从界面到本地存储的完整开发流程。项目功能覆盖登录、注册、记事本列表、添加与修改记事,并通过 SQLite 数据库存储记录时间,适合作为 Java 与安卓基础知识的综合练习。压缩包共 55 个文件,约 4.39MB,包含 12 个 Java 源文件、16 个 XML 布局、12 张图片资源及 Gradle 构建脚本,另附 APK 安装包、演示视频、说明文档与运行环境说明,便于直接安装体验或导入工程二次开发。目前已有 9309 人学习下载,读者可借助源码理解 Activity 跳转、列表适配器与数据库增删改查的配合方式,对照录屏快速跑通项目,并参考文档完成环境配置与常见问题排查。
1. 从零用 Android Studio 做一个能用的安卓记事本:先想清楚它到底解决什么
很多人第一次打开 Android Studio,新建一个 Empty Activity,然后盯着屏幕发呆——教程里全是「Hello World」,可我想做的是一个真正能用的记事本 app。这个标题说的就是这件事:用 Android Studio 从零搭一个安卓记事本,能新建、编辑、删除、保存笔记,退出再进来数据还在。它不追求花哨,但要求每一步都能跑通、每一行代码都知道为什么这么写。
适合谁看?适合刚学完 Java 或 Kotlin 基础、想找一个完整小项目练手的安卓开发新手;也适合做过一点界面但没搞明白数据持久化的同学。记事本这个选题的好处是:功能边界清晰,但麻雀虽小五脏俱全——列表、编辑页、数据库、权限、打包 APK,一个都不少。做完它,你对安卓 app 的完整生命周期就有了实感,而不是停留在「能跑个界面」的阶段。下面我按实际开发顺序,把选型、建库、写界面、存数据、打包这几件事讲透,中间踩过的坑也一并说清楚。
2. 技术选型与工程搭建:为什么我选 Room 而不是裸 SQLite
2.1 记事本的数据层选型:Room、SQLite 还是文件存储
记事本的核心是「存笔记」,选错存储方案后面全是返工。常见做法有三种:直接写文件(txt/json)、裸用 SQLite、用 Room。我一般会选 Room,理由很实在。
写文件最简单,但笔记一多,你要自己处理列表读取、单条更新、删除,还得手动解析 JSON,稍微复杂点就变成自己造数据库。裸 SQLite 能用,但Cursor、ContentValues、手写 SQL 字符串这套东西,字段一改就容易出错,而且没有编译期检查。Room 是 Google 官方在 SQLite 上的封装,用注解定义表和查询,编译时就能发现 SQL 写错,配合LiveData或Flow还能自动刷新列表。
代价是 Room 需要引入依赖、写 Entity、Dao、Database 三个类,前期多花二十分钟。但对记事本这种「增删改查齐全」的场景,这点成本很快就能赚回来。下面这张表是我实际对比后的结论:
| 方案 | 上手成本 | 增删改查便利度 | 字段变更风险 | 适合场景 |
|---|---|---|---|---|
| 文件存储 | 低 | 差 | 高 | 配置项、少量固定数据 |
| 裸 SQLite | 中 | 中 | 中 | 老项目、极致轻量 |
| Room | 中 | 高 | 低 | 记事本这类结构化数据 |
2.2 用 Android Studio 新建工程并配好依赖
打开 Android Studio,选 Empty Views Activity(如果你用 Compose 就选 Empty Activity,本文以传统 View 体系为例,更好理解)。语言选 Kotlin,最低 SDK 我建议设到 API 24(Android 7.0),覆盖足够广,又不用处理太老的兼容问题。
建好工程后,打开build.gradle(模块级,不是项目级),加 Room 和 Kotlin 协程依赖:
// build.gradle (Module: app) dependencies { // Room 三件套:运行时、编译器、Kotlin 扩展 implementation "androidx.room:room-runtime:2.6.1" implementation "androidx.room:room-ktx:2.6.1" // 支持协程和 Flow kapt "androidx.room:room-compiler:2.6.1" // 注解处理器,编译期生成代码 // 协程,数据库操作不能放主线程 implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3" // RecyclerView 显示笔记列表 implementation "androidx.recyclerview:recyclerview:1.3.2" }注意这里用的是kapt,如果你项目里用的是 KSP(新版 Android Studio 默认推荐),把kapt换成ksp,并在插件里加上 KSP 插件。参数说明:room-runtime是核心库,room-ktx提供suspend和Flow支持,room-compiler负责在编译期根据你的注解生成实现类,少了它运行时会直接崩。改完点 Sync Now,如果报kapt找不到,检查是否在插件区加了kotlin-kapt。
2.3 定义 Entity、Dao、Database 三个核心类
Room 的用法就三步:定义表结构、定义操作接口、定义数据库入口。先写笔记实体:
// Note.kt —— 对应数据库里的一张表 @Entity(tableName = "notes") data class Note( @PrimaryKey(autoGenerate = true) val id: Long = 0, // 自增主键 @ColumnInfo(name = "title") val title: String, // 标题 @ColumnInfo(name = "content") val content: String, // 正文 @ColumnInfo(name = "updated_at") val updatedAt: Long = System.currentTimeMillis() // 更新时间戳 )@Entity告诉 Room 这是一张表,@PrimaryKey(autoGenerate = true)让 id 自动增长,插入时传 0 即可。updatedAt存时间戳,方便列表按最近修改排序。
接着写 Dao,也就是增删改查接口:
// NoteDao.kt —— 所有数据库操作都从这里走 @Dao interface NoteDao { @Query("SELECT * FROM notes ORDER BY updated_at DESC") // 按更新时间倒序 fun getAllNotes(): Flow<List<Note>> // Flow 让列表自动刷新 @Insert(onConflict = OnConflictStrategy.REPLACE) // id 相同则覆盖 suspend fun insert(note: Note): Long @Update suspend fun update(note: Note) @Delete suspend fun delete(note: Note) }Flow<List<Note>>是关键:数据库一变,界面自动收到新数据,不用手动调刷新。suspend表示这些操作要在协程里跑,Room 不允许在主线程做数据库读写,否则直接抛异常。
最后是数据库入口:
// NoteDatabase.kt @Database(entities = [Note::class], version = 1, exportSchema = false) abstract class NoteDatabase : RoomDatabase() { abstract fun noteDao(): NoteDao companion object { @Volatile private var INSTANCE: NoteDatabase? = null fun getInstance(context: Context): NoteDatabase = INSTANCE ?: synchronized(this) { // 双重检查锁,保证单例 INSTANCE ?: Room.databaseBuilder( context.applicationContext, NoteDatabase::class.java, "note_database" // 数据库文件名 ).build().also { INSTANCE = it } } } }version = 1是数据库版本,以后加字段要改成 2 并写迁移逻辑,否则会崩。exportSchema = false只是不导出 schema 文件,学习阶段无所谓。单例模式很重要,多个数据库实例会导致数据不一致。
3. 界面与交互实现:列表页和编辑页怎么串起来
3.1 用 RecyclerView 搭笔记列表页
列表页是 app 的门面。布局上,主界面放一个 RecyclerView 加一个悬浮按钮(FloatingActionButton)用来新建笔记。先写列表项布局item_note.xml,一个标题 TextView 加一个时间 TextView 就够。然后写 Adapter:
// NoteAdapter.kt class NoteAdapter( private val onItemClick: (Note) -> Unit, // 点击进编辑页 private val onItemLongClick: (Note) -> Unit // 长按删除 ) : ListAdapter<Note, NoteAdapter.NoteViewHolder>(DIFF_CALLBACK) { companion object { // DiffUtil 让列表只刷新变化的项,而不是整体重绘 private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<Note>() { override fun areItemsTheSame(old: Note, new: Note) = old.id == new.id override fun areContentsTheSame(old: Note, new: Note) = old == new } } inner class NoteViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { val title: TextView = itemView.findViewById(R.id.tvTitle) val time: TextView = itemView.findViewById(R.id.tvTime) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NoteViewHolder { val view = LayoutInflater.from(parent.context).inflate(R.layout.item_note, parent, false) return NoteViewHolder(view) } override fun onBindViewHolder(holder: NoteViewHolder, position: Int) { val note = getItem(position) holder.title.text = note.title holder.time.text = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) .format(Date(note.updatedAt)) holder.itemView.setOnClickListener { onItemClick(note) } holder.itemView.setOnLongClickListener { onItemLongClick(note); true } } }用ListAdapter而不是普通RecyclerView.Adapter,是因为它内置了DiffUtil,数据更新时自动算差异,列表不会闪。areItemsTheSame比 id,areContentsTheSame比内容,这两个方法写错会导致列表不刷新或乱刷新,是高频翻车点。
3.2 编辑页的数据回填与保存逻辑
编辑页要同时支持「新建」和「编辑已有笔记」。做法是:新建时传一个空的 Note,编辑时传对应 id 的 Note。用 Intent 传 id,编辑页再查数据库回填。
// EditActivity.kt 核心逻辑 private var noteId: Long = 0L override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_edit) noteId = intent.getLongExtra("note_id", 0L) // 0 表示新建 if (noteId != 0L) { // 编辑模式:从数据库读出来回填 lifecycleScope.launch { val note = db.noteDao().getNoteById(noteId) etTitle.setText(note.title) etContent.setText(note.content) } } btnSave.setOnClickListener { val title = etTitle.text.toString().trim() val content = etContent.text.toString().trim() if (title.isEmpty() && content.isEmpty()) { Toast.makeText(this, "空笔记不保存", Toast.LENGTH_SHORT).show() return@setOnClickListener } lifecycleScope.launch { if (noteId == 0L) { db.noteDao().insert(Note(title = title, content = content)) } else { db.noteDao().update(Note(noteId, title, content, System.currentTimeMillis())) } finish() // 关掉编辑页,列表页 Flow 会自动刷新 } } }lifecycleScope.launch保证协程跟随 Activity 生命周期,页面销毁时自动取消,避免内存泄漏。保存后直接finish(),列表页因为用的是Flow,会自动收到数据库变化并刷新,不需要手动通知。这里有个细节:编辑已有笔记时,updatedAt要更新成当前时间,否则排序不会变。
3.3 列表页订阅数据并处理点击
列表页在onCreate里订阅 Flow:
lifecycleScope.launch { db.noteDao().getAllNotes().collect { notes -> adapter.submitList(notes) // ListAdapter 自动算差异刷新 } }collect会一直挂着,每次数据库变化都会回调。点击进编辑页传 id,长按弹 AlertDialog 确认删除:
val adapter = NoteAdapter( onItemClick = { note -> startActivity(Intent(this, EditActivity::class.java).putExtra("note_id", note.id)) }, onItemLongClick = { note -> AlertDialog.Builder(this) .setTitle("删除笔记") .setMessage("确定删除「${note.title}」吗?") .setPositiveButton("删除") { _, _ -> lifecycleScope.launch { db.noteDao().delete(note) } } .setNegativeButton("取消", null) .show() } )到这一步,一个能增删改查、数据持久化的记事本就跑通了。接下来是打包和那些让人抓狂的坑。
4. 打包 APK 与真机验证:从 Build 到装到手机上
4.1 生成签名 APK 的完整步骤
调试版 APK 只能自己装,要发给别人或上架,必须用签名版。步骤:菜单 Build → Generate Signed Bundle / APK → 选 APK → 点 Create new 新建密钥库。
密钥库信息要记牢:文件路径、密码、别名(key alias)、别名密码。这四个东西丢一个,以后就没法给同一个 app 升级,只能换包名重来,这是血泪经验。填好后选 release 构建变体,勾选 V1 和 V2 签名(V2 是 Android 7.0+ 的,V1 兼容老设备,两个都勾最稳)。
生成的 APK 在app/release/目录下。想验证签名对不对,用命令行:
# 查看 APK 签名信息,确认已签名 keytool -printcert -jarfile app-release.apk如果输出里有你的证书指纹和有效期,说明签名成功。没签名的话装到手机上会提示「解析包错误」。
4.2 真机安装与常见失败排查
把 APK 传到手机安装,最常见的三个失败:一是「应用未安装」,多半是手机上已装了同包名但签名不同的版本,先卸载旧的;二是「解析包错误」,通常是 APK 没签名或下载传输损坏,重新生成并核对文件大小;三是安装被拦截,部分手机需要在设置里允许「安装未知来源应用」。
真机调试更推荐直接用 USB 连 Android Studio 跑,比反复传 APK 高效。连上后如果设备列表不显示,检查开发者选项里的 USB 调试是否打开,Windows 上还可能需要装对应厂商的 USB 驱动。跑起来后重点验证:新建笔记、退出 app 再进、数据还在不在;长按删除、列表是否立即消失;旋转屏幕后编辑页内容是否丢失(这个和android:configChanges或 ViewModel 有关,后面坑里细说)。
5. 避坑与常见问题:那些让我加班到半夜的细节
5.1 主线程操作数据库直接崩溃
现象:点保存按钮,app 闪退,Logcat 报Cannot access database on the main thread。原因:Room 默认禁止主线程读写数据库,防止界面卡顿。解决:所有 Dao 操作放进lifecycleScope.launch或viewModelScope.launch,Dao 方法声明成suspend。别用runBlocking硬扛,那等于把主线程堵死,界面照样卡。
5.2 旋转屏幕后编辑内容全没了
现象:在编辑页输入一半,手机一转屏,输入框清空。原因:Activity 重建,成员变量和 View 状态丢失。解决:给 EditText 加android:id后系统会自动保存文本,但如果你手动setText回填,要在onCreate里判断savedInstanceState == null才回填,否则会把用户输入覆盖掉。更稳的做法是用 ViewModel 持有编辑状态。
5.3 数据库加字段后 app 一启动就崩
现象:给 Note 加了个isPinned字段,version 还是 1,运行直接抛IllegalStateException: Room cannot verify the data integrity。原因:表结构变了但版本号没变,Room 校验失败。解决:把version加 1,并提供Migration,或者学习阶段图省事用fallbackToDestructiveMigration()(会清空数据,仅限开发期)。生产环境必须写迁移,否则用户数据全丢。
5.4 列表数据变了界面不刷新
现象:删了一条笔记,数据库确实删了,但列表还在。原因:Adapter 用的不是ListAdapter,或者DiffUtil的areItemsTheSame写成了比较内容。解决:确认用ListAdapter+submitList,areItemsTheSame必须比唯一 id,areContentsTheSame比全部字段。两者写反会导致该刷新的不刷新、不该刷新的狂刷新。
5.5 打包 release 后网络或某些功能失效
现象:debug 版正常,release 版一装就出问题。原因:release 默认开启混淆(minifyEnabled true),把 Room 生成的类或反射用到的类混淆掉了。解决:在proguard-rules.pro里保留 Room 相关类,或者初期先把minifyEnabled设为 false 验证功能,确认无误再逐步开混淆。Room 官方有推荐的 keep 规则,别自己瞎猜。
6. 进阶技巧:用 ViewModel 把编辑页状态管住
前面编辑页的逻辑都写在 Activity 里,功能能跑,但状态管理很脆——转屏、进程被杀都会出问题。真正稳的做法是引入 ViewModel,把「当前笔记内容」和「保存动作」从界面里抽出来。这样 Activity 只负责显示,状态归 ViewModel 管,转屏重建时 ViewModel 还活着,数据不丢。
// EditViewModel.kt class EditViewModel(private val dao: NoteDao, private val noteId: Long) : ViewModel() { // 用 StateFlow 持有编辑状态,界面订阅它 private val _uiState = MutableStateFlow(NoteUiState()) val uiState: StateFlow<NoteUiState> = _uiState.asStateFlow() init { if (noteId != 0L) { viewModelScope.launch { val note = dao.getNoteById(noteId) _uiState.value = NoteUiState(note.title, note.content, loaded = true) } } } fun onTitleChange(value: String) { _uiState.value = _uiState.value.copy(title = value) } fun onContentChange(value: String) { _uiState.value = _uiState.value.copy(content = value) } fun save(onDone: () -> Unit) { val s = _uiState.value if (s.title.isBlank() && s.content.isBlank()) return viewModelScope.launch { if (noteId == 0L) dao.insert(Note(title = s.title, content = s.content)) else dao.update(Note(noteId, s.title, s.content, System.currentTimeMillis())) onDone() } } } data class NoteUiState( val title: String = "", val content: String = "", val loaded: Boolean = false )界面里用repeatOnLifecycle订阅uiState,把值写回输入框;输入框的doAfterTextChanged回调 ViewModel 的onTitleChange。这样数据和界面是单向流动的,转屏时 ViewModel 存活,uiState里的内容原样保留,不会再出现「输入一半没了」的玄学问题。
验证方法很简单:在编辑页输入内容,转屏,看内容是否还在;再按 Home 键切后台,等系统回收后回来(开发者选项里可以开「不保留活动」模拟),ViewModel 配合SavedStateHandle还能进一步扛住进程被杀。SavedStateHandle的用法是把noteId和草稿存进去,重建时读出来,这里不展开,但值得你专门花时间试一次。
我自己的习惯是:任何带输入和状态的页面,一律先建 ViewModel,再写界面。刚开始觉得多写一个类麻烦,被转屏坑过几次之后就老实了。这个记事本虽小,但把 Room + ViewModel + Flow 这套组合走通,你后面做任何带数据的 app 都是同一套路子。希望帮到你。
本文还有配套的精品资源,点击获取