
简介这是一套面向安卓初学者与课程设计需求的记事本应用完整源码工程基于Android Studio与Java开发采用SQLite数据库实现本地数据持久化。功能覆盖用户登录、注册、记事列表展示、添加记事、修改记事以及记录时间存储等核心模块适合作为移动开发入门练手、毕业设计参考或课堂作业提交的实战案例。资源包共55个文件包含12个Java源文件、16个XML布局、12张PNG图片资源以及Gradle构建脚本、属性配置等工程文件整体约4.39MB另附APK安装包、演示视频、说明文档与运行环境说明方便直接安装体验或导入工程二次开发。目前已有9309人学习下载读者可借此理清安卓项目从界面布局到数据库操作的完整链路对照源码理解Activity跳转、增删改查逻辑与工程目录组织方式快速搭建属于自己的记事本应用。1. 从零手写一个安卓记事本为什么我劝你先别急着敲代码很多人第一次打开 Android Studio脑子里蹦出来的第一个练手项目就是记事本。理由很朴素功能看得见、逻辑不复杂、能跑起来就有成就感。但真正动手之后你会发现记事本这个看似简单的需求背后牵扯到 Activity 生命周期、RecyclerView 列表复用、SQLite 事务、权限适配、软键盘遮挡、数据持久化时机等一连串问题。任何一个没处理好用户输入到一半切个后台回来笔记就没了——这种翻车体验比闪退还让人抓狂。这篇内容面向的是已经装好 Android Studio、能跑通 Hello World、想用一个完整项目把安卓开发核心链路串起来的开发者。我会按「数据层怎么设计 → 界面怎么搭 → 增删改查怎么串 → 打包怎么出 APK」的顺序把记事本 App 从空项目到可安装包的全过程拆开讲。中间会给出可直接抄的代码块、参数说明以及我在实际开发中踩过的坑。读完你至少能得到一个能装在自己手机上用的记事本并且知道每一行代码为什么这么写。2. 记事本的数据层Room 还是裸 SQLite选错后面全是坑2.1 为什么我最终选了 Room 而不是 SQLiteOpenHelper安卓本地存储方案大致有四种SharedPreferences、文件存储、SQLite、Room。记事本的核心是「多条结构化记录 增删改查 按时间排序」SharedPreferences 只能存键值对文件存储做模糊查询要自己遍历裸 SQLite 能用但写起来全是模板代码。Room 是 Google 在 Jetpack 里推的 ORM 框架本质是对 SQLite 的封装编译期校验 SQL 语句返回 LiveData 或 Flow 还能自动感知数据变化。选 Room 的核心理由有三个。第一编译期就能发现 SQL 写错不用等到运行时崩溃。第二和 LiveData/Flow 配合后插入一条笔记列表自动刷新不需要手动调 notifyDataSetChanged。第三迁移逻辑有官方 Migration 方案版本升级不会丢数据。裸 SQLite 当然也能做但你要自己管理 Cursor 关闭、事务边界、版本号递增代码量至少多一倍。提示Room 的最低兼容版本是 API 14但建议 minSdk 设到 21 以上否则部分注解处理器行为不一致。2.2 建表、DAO、数据库三件套的完整代码先加依赖。打开 app 模块的 build.gradle在 dependencies 里加dependencies { def room_version 2.6.1 implementation androidx.room:room-runtime:$room_version annotationProcessor androidx.room:room-compiler:$room_version // 如果用 Kotlin把 annotationProcessor 换成 kapt implementation androidx.room:room-ktx:$room_version }room-runtime是运行时库room-compiler是注解处理器负责在编译期生成NoteDatabase_Impl这类实现类。版本号写 2.6.1 是因为它和 AGP 8.x 兼容性稳定再高的版本对 Java 8 支持有变化。接着定义实体类。记事本一条记录至少要有自增主键、标题、内容、创建时间、更新时间。Entity(tableName notes) public class Note { PrimaryKey(autoGenerate true) public int id; ColumnInfo(name title) public String title; ColumnInfo(name content) public String content; ColumnInfo(name created_at) public long createdAt; ColumnInfo(name updated_at) public long updatedAt; }Entity声明这是一张表tableName不写默认用类名。PrimaryKey(autoGenerate true)让 SQLite 自己维护 id插入时不用赋值。时间戳用 long 存毫秒比存字符串省空间排序也快。ColumnInfo显式指定列名避免混淆。DAO 接口定义增删改查Dao public interface NoteDao { Query(SELECT * FROM notes ORDER BY updated_at DESC) LiveDataListNote getAllNotes(); Insert long insert(Note note); Update void update(Note note); Delete void delete(Note note); Query(SELECT * FROM notes WHERE id :id) Note getNoteById(int id); }getAllNotes返回LiveDataListNote数据一变 UI 自动收到通知。ORDER BY updated_at DESC保证最近修改的排最前。Insert返回 long 是新插入行的 id后面跳转详情页要用。数据库类Database(entities {Note.class}, version 1, exportSchema false) public abstract class NoteDatabase extends RoomDatabase { private static volatile NoteDatabase INSTANCE; public abstract NoteDao noteDao(); public static NoteDatabase getInstance(Context context) { if (INSTANCE null) { synchronized (NoteDatabase.class) { if (INSTANCE null) { INSTANCE Room.databaseBuilder( context.getApplicationContext(), NoteDatabase.class, note_database ).build(); } } } return INSTANCE; } }version 1是数据库版本号以后加字段要改成 2 并写 Migration。exportSchema false关掉 schema 导出不然编译会警告。双重检查锁保证单例getApplicationContext()防止内存泄漏。2.3 数据库操作必须放在子线程主线程查询会直接崩Room 默认不允许在主线程做数据库操作一旦在主线程调insert或getNoteById会抛IllegalStateException: Cannot access database on the main thread。这是保护机制不是 bug。正确做法是用 ExecutorService 或 Kotlin 协程。ExecutorService executor Executors.newSingleThreadExecutor(); public void saveNote(Note note) { executor.execute(() - { if (note.id 0) { note.createdAt System.currentTimeMillis(); note.updatedAt System.currentTimeMillis(); noteDao.insert(note); } else { note.updatedAt System.currentTimeMillis(); noteDao.update(note); } }); }单线程池保证插入顺序避免并发写冲突。note.id 0判断是新增还是更新因为 autoGenerate 的主键在插入前是 0。时间戳在保存时统一赋值不要放在构造函数里否则编辑后更新时间不会变。3. 列表页与编辑页RecyclerView 和软键盘的配合细节3.1 RecyclerView 的 Adapter 怎么写才不会错位列表页用 RecyclerView 展示所有笔记。Adapter 继承RecyclerView.AdapterNoteViewHolder在onBindViewHolder里绑定数据。public class NoteAdapter extends RecyclerView.AdapterNoteAdapter.NoteViewHolder { private ListNote notes new ArrayList(); private OnItemClickListener listener; public interface OnItemClickListener { void onItemClick(Note note); } public void setNotes(ListNote notes) { this.notes notes; notifyDataSetChanged(); } public void setOnItemClickListener(OnItemClickListener listener) { this.listener listener; } NonNull Override public NoteViewHolder onCreateViewHolder(NonNull ViewGroup parent, int viewType) { View view LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_note, parent, false); return new NoteViewHolder(view); } Override public void onBindViewHolder(NonNull NoteViewHolder holder, int position) { Note note notes.get(position); holder.titleText.setText(note.title); holder.contentText.setText(note.content); holder.timeText.setText(formatTime(note.updatedAt)); holder.itemView.setOnClickListener(v - { if (listener ! null) { listener.onItemClick(note); } }); } Override public int getItemCount() { return notes.size(); } static class NoteViewHolder extends RecyclerView.ViewHolder { TextView titleText, contentText, timeText; NoteViewHolder(NonNull View itemView) { super(itemView); titleText itemView.findViewById(R.id.tv_title); contentText itemView.findViewById(R.id.tv_content); timeText itemView.findViewById(R.id.tv_time); } } }setNotes里调notifyDataSetChanged()是简单做法但数据量大时会有性能问题。更优方案是用DiffUtil计算差异只刷新变化的 item。onBindViewHolder里设置点击监听通过接口回调把点击事件传到 Activity避免在 Adapter 里直接跳转。3.2 编辑页的软键盘遮挡问题与返回保存时机编辑页通常是一个全屏的 EditText 加一个保存按钮。软键盘弹出时会顶起布局如果根布局没设android:fitsSystemWindowstrue或者没在 Manifest 里配windowSoftInputModeEditText 会被键盘挡住。在 AndroidManifest.xml 里给编辑 Activity 加activity android:name.EditActivity android:windowSoftInputModeadjustResize android:exportedfalse /adjustResize让布局整体上移adjustPan是平移整个窗口前者体验更好。根布局用 ScrollView 包裹保证小屏手机也能滚动查看。返回保存的时机很关键。用户按返回键时如果直接 finish输入的内容就丢了。要在onBackPressed或OnBackPressedDispatcher里先保存再关闭。Override public void onBackPressed() { saveAndFinish(); } private void saveAndFinish() { String title etTitle.getText().toString().trim(); String content etContent.getText().toString().trim(); if (title.isEmpty() content.isEmpty()) { finish(); return; } if (title.isEmpty()) { title 无标题; } Note note new Note(); note.id currentNoteId; note.title title; note.content content; viewModel.saveNote(note); finish(); }标题为空时给个默认值「无标题」避免列表页显示空白。内容和标题都为空就不保存防止产生垃圾数据。currentNoteId从 Intent 里取新增时为 0。3.3 用 ViewModel 把数据逻辑从 Activity 里抽出来直接在 Activity 里操作数据库会导致配置变更比如旋转屏幕时数据丢失。ViewModel 的生命周期比 Activity 长适合持有 UI 相关数据。public class NoteViewModel extends AndroidViewModel { private final NoteDao noteDao; private final LiveDataListNote allNotes; private final ExecutorService executor Executors.newSingleThreadExecutor(); public NoteViewModel(NonNull Application application) { super(application); NoteDatabase db NoteDatabase.getInstance(application); noteDao db.noteDao(); allNotes noteDao.getAllNotes(); } public LiveDataListNote getAllNotes() { return allNotes; } public void saveNote(Note note) { executor.execute(() - { if (note.id 0) { note.createdAt System.currentTimeMillis(); note.updatedAt System.currentTimeMillis(); noteDao.insert(note); } else { note.updatedAt System.currentTimeMillis(); noteDao.update(note); } }); } public void deleteNote(Note note) { executor.execute(() - noteDao.delete(note)); } }Activity 里通过new ViewModelProvider(this).get(NoteViewModel.class)获取实例观察allNotes更新列表。这样旋转屏幕时数据不会重新加载编辑页的临时状态也能保留。4. 避坑与排查记事本开发中最容易翻车的五个地方4.1 插入数据后列表不刷新现象调了 insert数据库里确实有数据但 RecyclerView 没变化。原因getAllNotes()返回的是 LiveData但 Adapter 没有观察它或者观察了但没在回调里调setNotes。解决在 Activity 的onCreate里写viewModel.getAllNotes().observe(this, notes - adapter.setNotes(notes));。注意 observe 的第一个参数是 LifecycleOwnerActivity 本身实现了这个接口直接传 this。4.2 时间显示成 1970 年现象列表里每条笔记的时间都是 1970-01-01。原因updatedAt字段没赋值默认是 0格式化后就是 1970 年。解决在保存逻辑里统一赋值System.currentTimeMillis()。如果是从其他页面传递 Note 对象检查是否在传递过程中丢了时间戳。用SimpleDateFormat(yyyy-MM-dd HH:mm, Locale.getDefault())格式化不要用new Date(0)直接 toString。4.3 数据库版本升级后闪退现象改了 Entity 加了一个字段重新安装后打开 App 直接崩日志显示Room cannot verify the data integrity。原因数据库版本号没改或者改了版本号但没提供 MigrationRoom 发现表结构和 schema 不一致就抛异常。解决每次改 Entity 都要把Database的 version 加 1并写 Migration。如果只是开发阶段可以用fallbackToDestructiveMigration()让 Room 直接重建表但上线后绝对不能用会清空用户数据。Room.databaseBuilder(context, NoteDatabase.class, note_database) .fallbackToDestructiveMigration() // 仅开发阶段使用 .build();4.4 编辑页返回后内容没保存现象在编辑页输入内容按返回键回到列表发现刚才的修改没了。原因保存逻辑写在保存按钮的点击事件里返回键没触发。解决重写onBackPressed或在onPause里保存。推荐用OnBackPressedDispatcher加回调兼容 Android 13 以上的预测性返回手势。另外注意如果用户从最近任务列表划掉 ApponPause不一定会被调用所以关键数据最好在onStop里也存一次。4.5 打包 APK 后安装失败现象在 Android Studio 里跑得好好的导出 APK 传到手机上装不上提示「应用未安装」。原因最常见的是签名问题。Debug 包和 Release 包签名不同如果手机上已经装了 Debug 版再装 Release 版会冲突。另外 minSdk 设得太高手机系统版本低于 minSdk 也会装不上。解决先卸载旧版本再装新包。检查build.gradle里的minSdk和targetSdkminSdk 建议设 21 或 23。生成签名 APK 时用Build Generate Signed Bundle / APK创建 keystore 后保管好密码丢了就没法给同一个应用发更新。5. 从能跑到好用三个让记事本体验上一个台阶的技巧5.1 用 DiffUtil 替代 notifyDataSetChangednotifyDataSetChanged()会刷新整个列表即使只有一条笔记变了。列表长了之后会有明显卡顿。DiffUtil 能计算出新旧列表的差异只刷新变化的 item。public class NoteDiffCallback extends DiffUtil.Callback { private final ListNote oldList; private final ListNote newList; public NoteDiffCallback(ListNote oldList, ListNote newList) { this.oldList oldList; this.newList newList; } Override public int getOldListSize() { return oldList.size(); } Override public int getNewListSize() { return newList.size(); } Override public boolean areItemsTheSame(int oldPos, int newPos) { return oldList.get(oldPos).id newList.get(newPos).id; } Override public boolean areContentsTheSame(int oldPos, int newPos) { Note oldNote oldList.get(oldPos); Note newNote newList.get(newPos); return oldNote.title.equals(newNote.title) oldNote.content.equals(newNote.content) oldNote.updatedAt newNote.updatedAt; } }在 Adapter 里用DiffUtil.calculateDiff(new NoteDiffCallback(oldNotes, newNotes))算出结果再调dispatchUpdatesTo(this)。areItemsTheSame判断是不是同一条记录用 id 比较areContentsTheSame判断内容有没有变三个字段都比一遍。5.2 搜索功能给标题和内容加模糊查询记事本用久了找一条笔记要滑半天。加一个搜索框按关键字过滤。在 DAO 里加Query(SELECT * FROM notes WHERE title LIKE % || :keyword || % OR content LIKE % || :keyword || % ORDER BY updated_at DESC) LiveDataListNote searchNotes(String keyword);||是 SQLite 的字符串拼接符% || :keyword || %等价于%keyword%。搜索框加 TextWatcher文字变化时调viewModel.searchNotes(newText)把返回的 LiveData 重新 observe 一次。注意搜索空字符串时要显示全部笔记可以在 ViewModel 里判断keyword 为空就返回getAllNotes()。5.3 自动保存草稿防止用户误触返回丢内容编辑页最怕用户输入一大段不小心按了返回。除了在onBackPressed里保存还可以每隔几秒自动存一次草稿。private Handler autoSaveHandler new Handler(Looper.getMainLooper()); private Runnable autoSaveRunnable new Runnable() { Override public void run() { saveDraft(); autoSaveHandler.postDelayed(this, 5000); } }; Override protected void onResume() { super.onResume(); autoSaveHandler.postDelayed(autoSaveRunnable, 5000); } Override protected void onPause() { super.onPause(); autoSaveHandler.removeCallbacks(autoSaveRunnable); saveDraft(); } private void saveDraft() { String title etTitle.getText().toString().trim(); String content etContent.getText().toString().trim(); if (title.isEmpty() content.isEmpty()) return; Note note new Note(); note.id currentNoteId; note.title title.isEmpty() ? 无标题 : title; note.content content; viewModel.saveNote(note); }每 5 秒存一次onPause时再存一次并移除回调。这样即使 App 被系统杀掉最多丢 5 秒的内容。注意saveDraft里不要调finish()它只负责写数据库不关闭页面。我自己的习惯是任何涉及用户输入的页面都要在onPause里做一次持久化。这个习惯帮我省掉了无数次「用户投诉内容丢失」的麻烦。希望帮到你。本文还有配套的精品资源点击获取