
简介这份资源是基于 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 而不是裸 SQLite2.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 24Android 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(): FlowListNote // Flow 让列表自动刷新 Insert(onConflict OnConflictStrategy.REPLACE) // id 相同则覆盖 suspend fun insert(note: Note): Long Update suspend fun update(note: Note) Delete suspend fun delete(note: Note) }FlowListNote是关键数据库一变界面自动收到新数据不用手动调刷新。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 // 长按删除 ) : ListAdapterNote, NoteAdapter.NoteViewHolder(DIFF_CALLBACK) { companion object { // DiffUtil 让列表只刷新变化的项而不是整体重绘 private val DIFF_CALLBACK object : DiffUtil.ItemCallbackNote() { 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比 idareContentsTheSame比内容这两个方法写错会导致列表不刷新或乱刷新是高频翻车点。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() returnsetOnClickListener } 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里订阅 FlowlifecycleScope.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.launchDao 方法声明成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写成了比较内容。解决确认用ListAdaptersubmitListareItemsTheSame必须比唯一 idareContentsTheSame比全部字段。两者写反会导致该刷新的不刷新、不该刷新的狂刷新。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: StateFlowNoteUiState _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 都是同一套路子。希望帮到你。本文还有配套的精品资源点击获取