资讯详情

Android Studio开发记事本App:SQLite与RecyclerView实战

📅 2026/10/10 7:57:58 | 华诺云谱 👁 阅读
Android Studio开发记事本App:SQLite与RecyclerView实战
简介一款基于Android Studio开发的安卓记事本应用面向初学Android开发的Java学习者提供从登录注册到记事增删改查的完整移动端小项目。核心功能涵盖用户注册登录、记事列表展示、添加与修改记事、基于SQLite的数据持久化并自动记录存储时间。包体共55个文件压缩包约4.39MB主要包含Java源码、XML布局、Gradle构建脚本、演示视频、说明文档、APK安装包等结构完整适合直接导入Android Studio运行学习。资源另附带运行文档与演示视频可帮助快速理解项目启动流程和实际操作效果。已有9312人学习下载。对于想要巩固Android基础组件、数据库操作以及完整App开发流程的初学者这套代码和配套资料能提供清晰参照同时包含排错提示解压后即可按文档上手。1. 为什么还要自己写记事本从需求到技术选型的一次取舍市面上记事本App一抓一大把但用Android Studio从零写一个安卓记事本app仍然是很多初学者和转岗Android开发的第一课。原因很简单记事本虽然小却把Activity生命周期、ListView或RecyclerView、SQLite或SharedPreferences、Intent跳转、数据持久化这些核心知识点全部串起来了。我拆过不少这类项目这个基于Java的AndroidStudio记事本工程代码量不大但骨架完整——有数据层、有列表页、有编辑页、有删除确认适合用来理解一个App从创建到打包的完整流程。适合谁刚学完Java基础想摸Android门道的人或者需要一份能快速改造成待办清单、日记本甚至密码本的底子。它不是炫技项目胜在结构清楚改起来顺手。2. 项目结构拆解入口、数据层与UI层的分层方式2.1 入口Activity与清单配置别把MainActivity写成一坨拿到这个项目后第一件事是看AndroidManifest.xml。很多自学的人喜欢把所有逻辑塞进MainActivity但这个记事本项目的入口是NoteListActivity作为启动Activity而不是了一开始就用编辑页做入口。清单文件里这一段值得仔细看activity android:name.NoteListActivity android:label我的记事本 android:themestyle/AppTheme intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity这段配置决定了App启动时先加载哪个界面。label是用来显示在桌面上的应用名改成你自己的名字就行。theme指向styles.xml里定义的主题这个项目默认用的AppTheme继承自Theme.AppCompat.Light.DarkActionBar如果你编译时报找不到主题多半是styles.xml里没有定义AppTheme或者appcompat库版本没对齐。我一般会给这个入口Activity单独建一个launchMode记事本场景不用考虑任务栈复用保持默认standard即可。如果以后想做到从通知栏点进来直接打开编辑页再去配singleTop现在不需要动。2.2 数据层选型SharedPreferences还是SQLite记事本场景的取舍这个项目的数据层用的是SQLiteOpenHelper不是SharedPreferences。很多人问记事本这种轻量数据到底要不要上数据库我的看法是如果每条记事的正文可能很长、还要支持修改时间排序、以后想加搜索那SQLite比SharedPreferences可靠得多。SharedPreferences本质是存KV键值对把整篇记事塞进一个String读写方便但一遇到“按修改时间倒序”、“删除某一条”这种操作就得把所有数据读出来再拼回去代码非常容易翻车。项目里DatabaseHelper类继承了SQLiteOpenHelper核心代码基本长这样public class DatabaseHelper extends SQLiteOpenHelper { private static final String DB_NAME notes.db; private static final int DB_VERSION 1; public DatabaseHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } Override public void onCreate(SQLiteDatabase db) { db.execSQL(CREATE TABLE notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT, create_time INTEGER, modify_time INTEGER)); } Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { db.execSQL(DROP TABLE IF EXISTS notes); onCreate(db); } }create_time和modify_time都存的是毫秒时间戳用System.currentTimeMillis()写入。这里有个关键设计列表排序用modify_time所有字段用的是long类型而不是把时间格式化成字符串存进去。这样排序就是SQL层面的ORDER BY modify_time DESC不用解析字符串效率高而且不容易出错。onUpgrade这个方法是给将来改表结构留下的入口。现在直接DROP再重建是最简单的做法但要注意这样会清空用户已有数据。如果以后再升级版本必须改成ALTER TABLE加上增量字段不然用户更新App后记事全没了这是大坑。3. 核心功能实现增删改查与列表刷新的完整链路3.1 记事列表页RecyclerView 自定义Adapter列表页用的是RecyclerView不是ListView。RecyclerView在性能上和ViewHolder强制复用上比ListView更省心。Adapter里必须要做的三件事继承RecyclerView.Adapter、创建ViewHolder、重写onCreateViewHolder和onBindViewHolder。这个项目的列表Adapter核心代码如下public class NoteAdapter extends RecyclerView.AdapterNoteAdapter.NoteViewHolder { private ListNote noteList; private OnNoteClickListener listener; public NoteAdapter(ListNote noteList, OnNoteClickListener listener) { this.noteList noteList; this.listener listener; } Override public NoteViewHolder onCreateViewHolder(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(NoteViewHolder holder, int position) { Note note noteList.get(position); holder.tvContent.setText(note.getContent()); holder.tvTime.setText(TimeUtils.formatTime(note.getModifyTime())); holder.itemView.setOnClickListener(v - listener.onNoteClick(note)); } Override public int getItemCount() { return noteList null ? 0 : noteList.size(); } static class NoteViewHolder extends RecyclerView.ViewHolder { TextView tvContent; TextView tvTime; public NoteViewHolder(View itemView) { super(itemView); tvContent itemView.findViewById(R.id.tv_content); tvTime itemView.findViewById(R.id.tv_time); } } public interface OnNoteClickListener { void onNoteClick(Note note); } }这个Adapter用接口OnNoteClickListener把点击事件回调给Activity不直接在这里写跳转逻辑这样Activity和Adapter职责清楚。onCreateViewHolder里的R.layout.item_note是列表每一项的布局一般就两个TextView加一个分割线。注意item_note.xml的根布局高度不要设成match_parent列表项要的是wrap_content否则每一条都会撑满整个屏幕视觉上很怪。列表数据导出的方法是在Activity里调用setAdapter之后通过adapter.notifyDataSetChanged()刷新。常见做法是定义一个loadNotes()方法每次从数据库查询后重新给adapter设置数据private void loadNotes() { ListNote notes noteDao.queryAll(); noteAdapter.setData(notes); noteAdapter.notifyDataSetChanged(); }setData这个方法需要你在Adapter里补充原理就是替换noteList这个引用后通知RecyclerView重绘。没有这一步内存中的数据不会自动更新界面上永远是旧列表。3.2 编辑页EditText自动保存与返回键处理编辑页的核心是EditText的监听。这个项目在onPause里保存而不是写一个“保存”按钮强制用户点击。实现方式是在编辑页使用TextWatcher监听内容变化同时在onPause时将内容写入数据库public class NoteEditActivity extends AppCompatActivity { private EditText etContent; private long noteId -1; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_note_edit); etContent findViewById(R.id.et_content); noteId getIntent().getLongExtra(note_id, -1); if (noteId ! -1) { Note note noteDao.queryById(noteId); etContent.setText(note.getContent()); etContent.setSelection(note.getContent().length()); } etContent.addTextChangedListener(new TextWatcher() { Override public void afterTextChanged(Editable s) { pendingSave true; } }); } Override protected void onPause() { super.onPause(); if (pendingSave) { saveNote(); } } }这里有个细节从列表页跳转过来时通过getIntent().getLongExtra(note_id, -1)判断是新建还是编辑。注意ID要传long传int在真机上有可能溢出虽然记事本数据量到不了int上限但养成好习惯。setSelection让光标定位到文本末尾这个不写的话打开编辑页时光标会默认在开头用户往下输入时屏幕不会自动滚到底体验很差。onPause里保存而不是onStop因为用户按下Home键都会先走onPause而不会立即走onStop。在onPause里做轻量持久化操作是安全的但不要在onPause里做耗时操作写数据库如果频繁触发耗时很短可以接受。如果用户新建一条记事什么都没输入就返回不该在数据库留下空记录。常见做法是保存前判断如果content为空并且noteId -1直接删除这条记录。否则会产生一堆空壳数据列表页全是空白条目。3.3 删除与修改长按菜单和Dialog确认删除功能这里是长按列表项弹出上下文菜单或者点击列表项跳转到编辑页里再做删除。两种做法都行我更倾向在编辑页提供删除按钮因为用户更习惯看得见的操作。项目里用的是AlertDialog确认AlertDialog.Builder builder new AlertDialog.Builder(this); builder.setTitle(确认删除); builder.setMessage(这条记事会被永久删除想好了); builder.setPositiveButton(删除, (dialog, which) - { noteDao.deleteById(noteId); Toast.makeText(this, 已删除, Toast.LENGTH_SHORT).show(); finish(); }); builder.setNegativeButton(取消, null); builder.show();删除逻辑本身很简单但注意要先把noteId拿到如果是从列表页跳转来编辑再删除noteId不为-1。如果noteId为-1说明这是一条还没保存的新记事这时不需要调用deleteById直接finish就行因为onPause里保存逻辑会判断内容为空就不插入了。Dialog用PositiveButton和NegativeButtonPositiveButton的监听里要先关闭Dialog再finish否则Activity退出后会有一个残留的Dialog窗口导致内存泄漏。建议在删除后调用dialog.dismiss()虽然AlertDialog在Activity销毁时会自动关闭但主动关掉更稳妥。修改和新增在数据库中其实是一套代码先查是否有id有就UPDATE没有就INSERT。项目里通常封装成insertOrUpdatepublic long insertOrUpdate(Note note) { SQLiteDatabase db getWritableDatabase(); ContentValues values new ContentValues(); values.put(content, note.getContent()); values.put(modify_time, note.getModifyTime()); if (note.getId() -1) { values.put(create_time, note.getCreateTime()); return db.insert(notes, null, values); } else { values.put(create_time, note.getCreateTime()); return db.update(notes, values, id ?, new String[]{String.valueOf(note.getId())}); } }我在类似场景处理时会多做一条保护如果要保存的原有记录已经被其他地方删除这里update返回0不会创建新记录也不会报错。在代码里可以把返回值取出来为0时改为insert避免“编辑一条已被删的记事又复活”的怪异bug。4. 排序与时间戳让列表按“最近修改”而不是“创建时间”展示4.1 时间戳字段与排序逻辑记事本列表如果不排序就是按插入顺序排列但用户期望的是“我改过的记事出现在最上面”因为下次大概率还会再动它。用SQLite的ORDER BY modify_time DESC就能实现。查询语句写成SELECT * FROM notes ORDER BY modify_time DESC这里的modify_time在表创建时就定义了类型是INTEGER。注意不是用TEXT存“2024-01-01 12:00:00”这种字符串而是用System.currentTimeMillis()存long型。字符串排序和long排序结果不一样字符串按字典序“10:00”会排在“9:00”前面因为“1”小于“9”排序就乱了。这是记事本项目里最容易翻车的地方。如果要按创建时间倒序就换成ORDER BY create_time DESC。如果想让置顶功能可以加一个is_top字段排序时优先按is_top DESC再按modify_time DESCSELECT * FROM notes ORDER BY is_top DESC, modify_time DESC这是SQLite多字段排序的标准写法先按is_top分两组组内再按修改时间排。这个项目的原始结构可能没有is_top但你在数据库升级时加这一列完全可行。时间戳存储到数据库后列表显示时不能直接把毫秒数setText进TextView需要格式化。项目里会有一个TimeUtils工具类我推荐如下写法public static String formatTime(long time) { SimpleDateFormat sdf new SimpleDateFormat(MM-dd HH:mm, Locale.getDefault()); return sdf.format(new Date(time)); }这里有个细节如果列表项里还需要显示“今天”“昨天”就不能用SimpleDateFormat得用Calendar判断日期差。但记事本项目一般不需要那么复杂只显示“12-25 14:30”这种就够了用户能看清是哪天就行。4.2 修改时刷新列表的两种做法修改完记事返回列表页列表必须刷新。项目里有两种做法一种是在编辑页保存后调用setResult(RESULT_OK, intent)返回列表页在onActivityResult里重新loadNotes()。另一种是列表页重写onResume因为返回Activity时一定会走onResume直接在onResume里调用refresh()最省事。我推荐后者代码更少也不容易漏掉数据更新Override protected void onResume() { super.onResume(); loadNotes(); }但注意如果列表页和编辑页共用同一个Activity实例编辑页没有独立Activity此招不适用。一般记事本都是两个Activity用startActivityForResult跳转所以onResume刷新是安全的。onResume刷新也有个隐患每次从别的Activity回到列表页都会执行一次数据库查询如果数据量大几千条会有轻微卡顿。记事本场景一般没到那个量级不用优化。如果你非要较真可以在编辑页保存时设置一个标志位用onActivityResult处理只在该标志位为true时才刷新避免无谓查询。5. 避坑手册Android Studio里最常见的五个翻车现场5.1 虚拟机或真机安装不上Gradle版本与SDK对齐问题现象Android Studio点击RunGradle同步报错或者模拟器安装APK时提示Failed to install *.apk某个so文件找不到。原因项目用的compileSdkVersion和targetSdkVersion与本地SDK版本不一致或者Gradle插件版本和Gradle版本不匹配。很多下载来的项目是在旧版AS上建的打开后首先碰到这个坑。解决打开File Project Structure把SDK版本调整为你本机已安装的Android SDK版本。Gradle版本去gradle-wrapper.properties里改并跟build.gradle里的com.android.tools.build:gradle版本对表。我一般直接升级到Android Studio自带的稳定Gradle版本。改完后Sync Project如果还提示缺少某个SDK platform到SDK Manager里下载对应包。5.2 列表刷新后数据错乱Adapter没有调用notifyDataSetChanged现象修改记事内容后回列表页看到的是旧内容有时删除一条列表里最后一项残留。原因你改了数据库里的数据但没有通知RecyclerView重新绘制。RecyclerView的视图复用机制让它不会自动感知数据变化只会在滚动时重新绑定可见项。解决在每次查询完数据后必须调用adapter.notifyDataSetChanged()。如果用了DiffUtil可以在数据设置后调用adapter.submitList(newList)来自动计算差异但记事本项目没必要引DiffUtil。我一般写一个refresh()方法public void refresh() { ListNote data noteDao.queryAll(); noteAdapter.setData(data); noteAdapter.notifyDataSetChanged(); }在onResume里调用它。调试时如果发现刷新不生效先在refresh里打Log看一下data.size()是否正确八成是查询条件写错导致返回数据没改变。5.3 输入框内容丢失手机旋转导致Activity重建现象在编辑页输入一段文字旋转屏幕后内容全部清空回到未保存状态。原因默认情况下屏幕旋转会让Activity销毁重建新Activity的EditText是空的onPause保存如果还没执行到数据就丢了。Android里Activity重建时先走onDestroy再走onCreateonPause虽然会执行但那时你还没法保证数据写进去了。解决两个方案。简单方案是把保存逻辑放到onPause里同时配合onSaveInstanceState保存当前输入状态Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putString(draft, etContent.getText().toString()); } Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); ... if (savedInstanceState ! null) { String draft savedInstanceState.getString(draft); if (draft ! null) { etContent.setText(draft); } } }终极方案是在清单文件里给编辑页Activity加android:configChangesorientation|screenSize不显式处理的话旋转时不销毁Activity而是走onConfigurationChanged。但这不是好习惯除非你知道自己在干什么否则还是用状态保存。对这个项目来说把保存放onPause已经能解决大部分丢失问题因为旋转时onPause会先执行数据已经写库了重建后重新从库里读出来前提是你之前保存过noteId。如果新建的记事还没保存过旋转后noteId还是-1读取不到内容。所以新建状态要用savedInstanceState兜底这是最容易遗漏的。5.4 中文字符乱码文件编码与Gradle编译配置现象界面上的中文全部变成乱码或者显示成“???”。原因项目文件编码不是UTF-8或者Java源文件里写的中文字符没保存为UTF-8格式。Gradle编译时默认读文件编码可能与系统不一致。解决在build.gradle的android节点下加上compileOptions { encoding UTF-8 }同时检查AS右下角编码设置按下AltEnter或者右下角UTF-8字样选择Reload in UTF-8。如果是Windows系统把File Settings Editor File Encodings里的Project Encoding和Default encoding都设为UTF-8。很多下载来的项目在强调文本文件里已经写了编码实际还是在开发机上用了GBK所以必须统一。5.5 打包APK体积过大去掉无用依赖现象一个记事本APK打包出来有20多MB甚至50MB测试机安装极慢用户一看到APK大小就卸载了。原因引用了不必要的第三方库或者项目模板自带的全量库。比如com.android.support:appcompat-v7库本身就很大再加一堆RecyclerView、CardView、Glide、EventBus体积自然下不来。解决在app/build.gradle里逐个审查依赖。记事本项目只需要appcompat、recyclerview和design库如果要用FloatingActionButton。不需要的网络请求库、图片加载库统统去掉。去完后用Build Build Bundles Build APK看实际大小通常能压到2MB以下。另外开启ProGuard和资源压缩buildTypes { release { minifyEnabled true shrinkResources true proguardFiles getDefaultProguardFile(proguard-android-optimize.txt), proguard-rules.pro } }注意shrinkResources依赖minifyEnabled因为资源的清理逻辑是以字节码分析为基础的。开启shrinker后要验证所有界面都正常因为第三方库可能通过反射使用资源会误删。我通常会在release包上再跑一遍完整的手动回归防止资源被削掉导致崩溃。6. 进阶玩法把记事本升级成带搜索和导出功能6.1 全文搜索用SQLite的LIKE还是FTS4记事数量一旦到几百条用户就会开始找条目。最简单的搜索是SQL的LIKESELECT * FROM notes WHERE content LIKE %关键词% ORDER BY modify_time DESC但LIKE在数据量大时有性能瓶颈因为无法使用索引。记事本这个量级用LIKE完全够不要过度设计。如果你想尝试SQLite的FTS4全文搜索表结构要改成CREATE VIRTUAL TABLE notes_fts USING fts4(content, create_time, modify_time);FTS4支持更快的关键词匹配但需要维护虚拟表的同步更新插入、修改、删除都要同步维护FTS表对记事本项目来说是沉重的负担。我建议数据量到一两万条之前都不要碰FTS4用LIKE就够了。搜索功能的实现可以参考如下public ListNote searchNotes(String keyword) { SQLiteDatabase db getReadableDatabase(); String sql SELECT * FROM notes WHERE content LIKE ? ORDER BY modify_time DESC; String param % keyword %; return cursorToNoteList(db.rawQuery(sql, new String[]{param})); }占位符里的关键词要手动拼%号。注意不用拼接字符串否则特殊字符会破坏SQL语句出现SQL注入风险。在EditText上用TextWatcher做实时搜索每次输入都重新查询。防卡顿加上等300毫秒再查询或者用AsyncTask。这里用Java的话可以开一个Handler.postDelayed做debounce实在不行用搜索按钮触发也好。6.2 导出TXT文件并发送FileProvider与Intent记事本数据最可靠的兜底方案是导出成TXT文件这样即使App卸载记事也还在。导出过程分三步在应用私有目录写文件、通过FileProvider提供Uri、用Intent调起发送或分享界面。写文件用Context.getFilesDir()因为这是应用私有目录不需要存储权限。代码String filename note_ System.currentTimeMillis() .txt; File file new File(getFilesDir(), filename); try (FileWriter writer new FileWriter(file)) { writer.write(note.getContent()); } catch (IOException e) { e.printStackTrace(); }FileProvider需要在manifest里配置provider android:nameandroidx.core.content.FileProvider android:authorities${applicationId}.fileprovider android:exportedfalse android:grantUriPermissionstrue meta-data android:nameandroid.support.FILE_PROVIDER_PATHS android:resourcexml/file_paths / /providerfile_paths.xml里写上paths files-path namenotes path. / /paths然后调起系统分享Uri uri FileProvider.getUriForFile(this, getPackageName() .fileprovider, file); Intent intent new Intent(Intent.ACTION_SEND); intent.setType(text/plain); intent.putExtra(Intent.EXTRA_STREAM, uri); startActivity(Intent.createChooser(intent, 导出记事));这里最容易踩的坑是你直接传file://Uri是非法分享7.0以上会崩溃必须经过FileProvider转一次。别忘了在Intent上加FLAG_GRANT_READ_URI_PERMISSION不过通过createChooser分享时一般会自动加保险起见还是手动加一下。按这个思路做完搜索和导出记类App的基础框架就齐了。我自己在搭这种小项目时习惯性地把每个模块的边界画清楚数据库只管数据读写Activity只管界面交互Adapter只管绑定数据工具类只管时间格式化和文件导出。后来自从有一次因为在一个Activity里既写数据库又搞文件导出排查问题浪费一下午从那以后我每次接到类似资源都会先画一下模块拓扑再动手改代码。这个记事本资源也是一样你拿到手先按这个思路拆一遍再改自己的功能点会顺手很多。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑