简介:一款基于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:theme="@style/AppTheme"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.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.Adapter<NoteAdapter.NoteViewHolder> { private List<Note> noteList; private OnNoteClickListener listener; public NoteAdapter(List<Note> 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() { List<Note> 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和NegativeButton,PositiveButton的监听里要先关闭Dialog再finish,否则Activity退出后会有一个残留的Dialog窗口导致内存泄漏。建议在删除后调用dialog.dismiss(),虽然AlertDialog在Activity销毁时会自动关闭,但主动关掉更稳妥。
修改和新增在数据库中其实是一套代码:先查是否有id,有就UPDATE,没有就INSERT。项目里通常封装成insertOrUpdate:
public 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 DESC:
SELECT * 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点击Run,Gradle同步报错,或者模拟器安装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() { List<Note> data = noteDao.queryAll(); noteAdapter.setData(data); noteAdapter.notifyDataSetChanged(); }在onResume里调用它。调试时如果发现刷新不生效,先在refresh里打Log看一下data.size()是否正确,八成是查询条件写错导致返回数据没改变。
5.3 输入框内容丢失:手机旋转导致Activity重建
现象:在编辑页输入一段文字,旋转屏幕后内容全部清空,回到未保存状态。
原因:默认情况下,屏幕旋转会让Activity销毁重建,新Activity的EditText是空的,onPause保存如果还没执行到,数据就丢了。Android里Activity重建时先走onDestroy,再走onCreate,onPause虽然会执行,但那时你还没法保证数据写进去了。
解决:两个方案。简单方案是把保存逻辑放到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:configChanges="orientation|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右下角编码设置,按下Alt+Enter或者右下角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的LIKE:
SELECT * 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 List<Note> 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:name="androidx.core.content.FileProvider" android:authorities="${applicationId}.fileprovider" android:exported="false" android:grantUriPermissions="true"> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> </provider>file_paths.xml里写上:
<paths> <files-path name="notes" 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里既写数据库又搞文件导出,排查问题浪费一下午,从那以后我每次接到类似资源,都会先画一下模块拓扑再动手改代码。这个记事本资源也是一样,你拿到手先按这个思路拆一遍,再改自己的功能点,会顺手很多。希望帮到你。
本文还有配套的精品资源,点击获取