简介:这是一套基于 Android Studio 开发的安卓记事本 App 完整项目资源,面向安卓入门学习者、课程设计或毕业设计需求者,帮助其快速理解一个具备登录注册、记事增删改查与本地存储的完整应用实现。压缩包共 55 个文件,约 4.39MB,以 12 个 Java 源文件、16 个 XML 布局、12 张 png 图片及 gradle 构建脚本为主,另含 apk 安装包、演示视频、说明文档与运行环境说明,覆盖从源码到可运行成品的完整链路。功能上包含登录、注册、记事本列表、添加与修改记事,并通过 SQLite 数据库存储记录时间,适合对照学习 Activity 跳转、列表适配与数据库操作等核心知识点。目前已有 9309 人学习下载,读者可借助源码、录屏与文档快速跑通项目,理解安卓本地数据持久化与界面布局的配合方式,也可在此基础上二次开发或作为课程作业参考。
1. 从一份能跑起来的 Android 记事本源码说起
Android Studio 里新建一个 Empty Activity 模板,到能真正跑出一个能增删改查、能持久化保存的记事本 app,中间隔着的不是几百行代码,而是一整套关于 SQLite 建表、RecyclerView 适配器、Activity 跳转传值的工程习惯。这份基于 Android Studio 开发的安卓记事本 app 源码包,解决的就是这个断层——它不是教你写 Hello World,而是给出一套结构完整、能直接编译安装的记事本实现,适合刚学完 Java 基础、想拿一个真实项目练手的安卓开发新手,也适合需要快速搭一个本地笔记原型的老手拿来改。
我拆过不少学生交上来的课程设计,最常见的翻车不是功能写不出来,而是数据存不住、列表刷新不及时、编辑和新建共用一个 Activity 时传参混乱。这份源码的价值就在于它把这几件事都落地了:SQLite 做本地存储,RecyclerView 做列表展示,Intent 带 id 在列表页和编辑页之间来回传值。下面我按「先看懂结构、再动手改、最后避坑」的顺序,把它拆成能照着复现的步骤。
2. 工程结构与 SQLite 数据层:先搞懂数据存哪、怎么读
2.1 目录结构和技术栈定位
拿到源码包,第一件事不是急着点 Run,而是先看目录。一个标准的记事本工程,app/src/main/java/包名/下面通常会有这么几类文件:MainActivity(列表页)、EditActivity(新建/编辑页)、NoteDbHelper(继承 SQLiteOpenHelper)、NoteDao(增删改查封装)、NoteAdapter(RecyclerView 适配器)、Note(实体类)。res/layout/下对应activity_main.xml、activity_edit.xml、item_note.xml三个布局。
技术栈上,这份源码走的是原生路线:Java + SQLite + RecyclerView + Intent。没有引入 Room、没有用 Jetpack Compose,这对新手反而是好事——所有逻辑都摊在明面上,你能看到 SQL 语句长什么样,能看到 Cursor 怎么一行行读出来。常见做法是新手先用原生 SQLite 把数据流跑通,等理解了「建表 → 插入 → 查询 → 游标遍历」这条链路,再迁移到 Room 会顺很多。
提示:如果你的 Android Studio 版本较新,Gradle 插件版本可能和源码里的不一致,导入后先让它自动同步一次,报错再手动改
build.gradle里的compileSdk和targetSdk。
2.2 建表语句与 DbHelper 的写法
数据层的核心是NoteDbHelper。它继承SQLiteOpenHelper,在onCreate里建表,在onUpgrade里处理版本升级。下面是我一般会用的建表写法,字段设计上留了create_time和update_time,方便列表按时间倒序排:
public class NoteDbHelper extends SQLiteOpenHelper { private static final String DB_NAME = "note.db"; private static final int DB_VERSION = 1; public static final String TABLE_NOTE = "note"; public NoteDbHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } @Override public void onCreate(SQLiteDatabase db) { // 主键自增,标题和内容允许为空,时间字段用于排序 String sql = "CREATE TABLE " + TABLE_NOTE + " (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "title TEXT, " + "content TEXT, " + "create_time INTEGER, " + "update_time INTEGER)"; db.execSQL(sql); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 简单粗暴:版本变了就重建,正式项目里要写迁移逻辑 db.execSQL("DROP TABLE IF EXISTS " + TABLE_NOTE); onCreate(db); } }逻辑说明:onCreate只在数据库第一次创建时调用,所以建表语句写在这里。AUTOINCREMENT保证 id 不重复,create_time和update_time用INTEGER存时间戳,比存字符串好排序。onUpgrade里直接 DROP 再重建,是课程设计里最常见的偷懒写法,代价是升级会丢数据——正式项目里得改成ALTER TABLE加字段。
参数说明:DB_VERSION从 1 开始,你改了表结构就把它加 1,否则onUpgrade不会触发。TABLE_NOTE抽成常量,避免在 Dao 里手写表名字符串写错。
2.3 Dao 层的增删改查封装
有了 Helper,接下来把增删改查封到一个NoteDao里,Activity 只调方法,不直接碰 Cursor。这样列表页和编辑页都能复用同一套数据操作:
public class NoteDao { private final NoteDbHelper helper; public NoteDao(Context context) { helper = new NoteDbHelper(context); } // 新增,返回新插入行的 id public long insert(Note note) { SQLiteDatabase db = helper.getWritableDatabase(); ContentValues values = new ContentValues(); values.put("title", note.getTitle()); values.put("content", note.getContent()); values.put("create_time", System.currentTimeMillis()); values.put("update_time", System.currentTimeMillis()); long id = db.insert(NoteDbHelper.TABLE_NOTE, null, values); db.close(); return id; } // 查询全部,按更新时间倒序 public List<Note> queryAll() { List<Note> list = new ArrayList<>(); SQLiteDatabase db = helper.getReadableDatabase(); Cursor cursor = db.query(NoteDbHelper.TABLE_NOTE, null, null, null, null, null, "update_time DESC"); while (cursor.moveToNext()) { Note note = new Note(); note.setId(cursor.getInt(cursor.getColumnIndexOrThrow("id"))); note.setTitle(cursor.getString(cursor.getColumnIndexOrThrow("title"))); note.setContent(cursor.getString(cursor.getColumnIndexOrThrow("content"))); note.setUpdateTime(cursor.getLong(cursor.getColumnIndexOrThrow("update_time"))); list.add(note); } cursor.close(); db.close(); return list; } }逻辑说明:insert用ContentValues组装数据,比拼 SQL 字符串安全,能防注入。queryAll里db.query的最后一个参数是排序,update_time DESC让最新编辑的笔记排最前。getColumnIndexOrThrow比getColumnIndex好,列名写错会直接抛异常,而不是悄悄返回 -1。
参数说明:db.insert第二个参数是nullColumnHack,当values为空时用它指定一个允许为空的列,一般传null就行。cursor.moveToNext()返回 false 表示遍历结束,别写成while(cursor.moveToNext())之外的死循环。
3. 列表页与编辑页:RecyclerView 适配器和 Intent 传值怎么接
3.1 RecyclerView 适配器的三个必写方法
列表页用 RecyclerView,适配器是绕不开的。一个能用的NoteAdapter必须实现onCreateViewHolder、onBindViewHolder、getItemCount三个方法,再加一个点击回调:
public class NoteAdapter extends RecyclerView.Adapter<NoteAdapter.VH> { private List<Note> data = new ArrayList<>(); private OnItemClickListener listener; public interface OnItemClickListener { void onClick(Note note); } public void setOnItemClickListener(OnItemClickListener l) { this.listener = l; } public void setData(List<Note> list) { this.data = list; notifyDataSetChanged(); // 简单场景够用,数据量大时用 DiffUtil } @NonNull @Override public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { View v = LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_note, parent, false); return new VH(v); } @Override public void onBindViewHolder(@NonNull VH holder, int position) { Note note = data.get(position); holder.tvTitle.setText(note.getTitle()); holder.tvContent.setText(note.getContent()); holder.itemView.setOnClickListener(v -> { if (listener != null) listener.onClick(note); }); } @Override public int getItemCount() { return data.size(); } static class VH extends RecyclerView.ViewHolder { TextView tvTitle, tvContent; VH(@NonNull View itemView) { super(itemView); tvTitle = itemView.findViewById(R.id.tv_title); tvContent = itemView.findViewById(R.id.tv_content); } } }逻辑说明:onCreateViewHolder负责把item_note.xml渲染成 View,onBindViewHolder负责把数据填进去,getItemCount告诉 RecyclerView 有多少条。点击事件通过接口回调抛给 Activity,适配器本身不处理跳转,职责分离。
参数说明:notifyDataSetChanged()会刷新整个列表,数据量小无所谓,上百条时会有卡顿,常见做法是换成DiffUtil。parent和false这两个参数在inflate里不能省,否则 item 的宽高会失效。
3.2 列表页加载数据与跳转编辑
MainActivity的职责很清晰:onResume里查一次数据库,把结果丢给适配器,点击 item 时带 id 跳到编辑页:
public class MainActivity extends AppCompatActivity { private NoteDao dao; private NoteAdapter adapter; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); dao = new NoteDao(this); RecyclerView rv = findViewById(R.id.rv_note); rv.setLayoutManager(new LinearLayoutManager(this)); adapter = new NoteAdapter(); rv.setAdapter(adapter); adapter.setOnItemClickListener(note -> { Intent intent = new Intent(this, EditActivity.class); intent.putExtra("note_id", note.getId()); startActivity(intent); }); findViewById(R.id.btn_add).setOnClickListener(v -> startActivity(new Intent(this, EditActivity.class))); } @Override protected void onResume() { super.onResume(); // 从编辑页返回时重新查,保证列表是最新的 adapter.setData(dao.queryAll()); } }逻辑说明:把查询放在onResume而不是onCreate,是因为从编辑页返回时onCreate不会再走,列表就不会刷新——这是新手最常踩的坑之一。点击 item 时只传note_id,编辑页自己按 id 去查完整数据,避免传整个对象序列化。
参数说明:putExtra("note_id", note.getId())的 key 要和编辑页getIntExtra的 key 一致,写错就是默认值 0,编辑页会以为你在新建。LinearLayoutManager是竖向列表,要横向就传HORIZONTAL。
3.3 编辑页区分新建和修改
EditActivity要同时处理「新建」和「编辑」两种状态,靠的就是 Intent 里有没有note_id:
public class EditActivity extends AppCompatActivity { private NoteDao dao; private int noteId = -1; // -1 表示新建 @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_edit); dao = new NoteDao(this); EditText etTitle = findViewById(R.id.et_title); EditText etContent = findViewById(R.id.et_content); noteId = getIntent().getIntExtra("note_id", -1); if (noteId != -1) { // 编辑模式:按 id 查出原数据回填 Note note = dao.queryById(noteId); if (note != null) { etTitle.setText(note.getTitle()); etContent.setText(note.getContent()); } } findViewById(R.id.btn_save).setOnClickListener(v -> { String title = etTitle.getText().toString().trim(); String content = etContent.getText().toString().trim(); if (title.isEmpty() && content.isEmpty()) { Toast.makeText(this, "内容不能为空", Toast.LENGTH_SHORT).show(); return; } Note note = new Note(); note.setTitle(title); note.setContent(content); if (noteId == -1) { dao.insert(note); } else { note.setId(noteId); dao.update(note); } finish(); // 返回列表页,触发 onResume 刷新 }); } }逻辑说明:noteId默认 -1,getIntExtra取不到就保持 -1,走新建分支。保存后调finish()回到列表页,列表页的onResume会自动重新查询,数据就刷新了。这个「编辑页只管存,列表页只管查」的分工,比在编辑页里手动通知列表刷新要干净。
参数说明:getIntExtra的第二个参数是默认值,必须和新建状态的判断值一致。trim()去掉首尾空格,避免用户误输入空格导致标题看起来是空的。
4. 避坑与排查:这几个翻车点我几乎每次都见
4.1 列表返回后不刷新
现象:新建或编辑完返回列表页,数据没变,要杀掉 app 重进才看得到。 原因:查询逻辑写在了onCreate里,而onCreate在 Activity 生命周期里只走一次,从编辑页返回走的是onResume。 解决:把adapter.setData(dao.queryAll())挪到onResume,或者用startActivityForResult在回调里刷新。前者更简单,推荐。
4.2 数据库版本没升导致表结构不生效
现象:给Note加了新字段,建表语句也改了,但运行起来查询报「no such column」。 原因:DB_VERSION还是 1,onCreate不会重新执行,旧表结构没变。 解决:把DB_VERSION加 1,让onUpgrade触发。如果onUpgrade里是 DROP 重建,记得提前备份数据,或者改成ALTER TABLE note ADD COLUMN xxx。
4.3 Cursor 用完没关导致资源泄漏
现象:反复进出列表页,日志里出现「CursorWindowAllocationException」或者 app 越来越卡。 原因:db.query返回的 Cursor 没调close(),底层窗口没释放。 解决:在while循环结束后立刻cursor.close(),db.close()也别忘了。更稳的写法是用 try-finally 包住。
4.4 Intent 传 id 的 key 写错
现象:点某条笔记进去,编辑页显示的是空白,保存后多出一条新笔记。 原因:列表页putExtra("note_id", ...),编辑页getIntExtra("id", -1),key 不一致,取到默认值 -1,走了新建分支。 解决:把 key 抽成常量,两边引用同一个。这种错误编译器不报,只能靠自查。
4.5 RecyclerView 没设 LayoutManager
现象:app 不崩溃,但列表一片空白,什么都不显示。 原因:RecyclerView 必须设置 LayoutManager 才能布局,忘了写就什么都不渲染。 解决:rv.setLayoutManager(new LinearLayoutManager(this))这行不能漏,横向列表换成GridLayoutManager或StaggeredGridLayoutManager。
5. 进阶改造:从能跑到好用,我一般会加这几样
源码能跑通只是起点,真正拿去当课程设计或者原型,还得补几个体验上的短板。第一个是搜索功能,在列表页顶部加一个SearchView,onQueryTextChange里按标题模糊查:
public List<Note> search(String keyword) { List<Note> list = new ArrayList<>(); SQLiteDatabase db = helper.getReadableDatabase(); Cursor cursor = db.query(NoteDbHelper.TABLE_NOTE, null, "title LIKE ? OR content LIKE ?", new String[]{"%" + keyword + "%", "%" + keyword + "%"}, null, null, "update_time DESC"); // 遍历逻辑同 queryAll cursor.close(); db.close(); return list; }LIKE ?配合%keyword%做模糊匹配,两个问号分别对应标题和内容。注意%要拼在 Java 字符串里,不能写进 SQL 占位符,否则会被当成字面量。
第二个是长按删除,给适配器再加一个OnItemLongClickListener,弹AlertDialog确认后调dao.delete(id)再刷新。删除的 SQL 是db.delete(TABLE_NOTE, "id = ?", new String[]{String.valueOf(id)}),同样用占位符防注入。
第三个是时间显示,数据库存的是时间戳,列表里直接显示一串数字很难看。我一般写个工具方法格式化成「今天 14:30」「昨天」「3 天前」这种相对时间,用SimpleDateFormat配合Calendar判断即可。
| 改造项 | 涉及文件 | 关键 API |
|---|---|---|
| 搜索 | MainActivity、NoteDao | SearchView、LIKE 查询 |
| 长按删除 | NoteAdapter、NoteDao | setOnLongClickListener、AlertDialog |
| 时间格式化 | 工具类、NoteAdapter | SimpleDateFormat、Calendar |
最后说个我自己的习惯:每次改完表结构,我都会先把DB_VERSION加 1,再手动跑一遍「新建 → 编辑 → 返回 → 删除」这条完整链路,确认列表每次都刷新。这个动作花不了两分钟,但能挡掉八成「数据对不上」的玄学问题。从那以后我每次动数据库相关代码,都强制走一遍这条链路,希望帮到你。
本文还有配套的精品资源,点击获取