ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Android记事本开发实战:从SQLite到RecyclerView的完整CRUD实现

Android记事本开发实战:从SQLite到RecyclerView的完整CRUD实现 1. 项目概述一个Android记事本的完整实现最近在整理硬盘翻出来一个几年前写的Android记事本应用功能完整代码结构也还算清晰。当时写这个主要是为了练手把Android开发里那些基础但核心的东西串一遍比如数据存储、列表展示、增删改查这些。没想到后来带新人或者自己回顾基础时这个项目还挺好用。今天索性把APK安装包和完整的项目源码都整理出来分享给大家。无论你是刚入门Android开发想找一个麻雀虽小五脏俱全的练手项目还是已经有一定经验想看看一个典型应用从设计到实现的完整思路这个记事本都能给你提供一个不错的参考。这个记事本的核心功能很简单创建、查看、编辑、删除记事外加一个简单的搜索。但它覆盖了Android应用开发中几个非常关键的模块使用SQLite进行本地数据持久化、用RecyclerView高效展示列表、通过Activity和Fragment组织界面逻辑、以及应用基本的Material Design设计语言。我会在后面的内容里把这些模块的实现细节、我当时的设计考量以及实际编码中容易踩的坑都详细拆解一遍。源码是基于Java和AndroidX库写的在目前主流的Android Studio环境下都能直接编译运行。2. 项目整体设计与架构思路2.1 为什么选择“记事本”作为练手项目很多新手在学完Android的基础语法和组件后会陷入一个迷茫期知道Activity、Fragment是什么但不知道怎么把它们组合成一个能用的App。这时候一个功能明确、业务逻辑不复杂的“玩具项目”就非常关键。记事本就是一个绝佳的选择。它的业务模型Note清晰涉及的操作CRUD是绝大多数应用的基石技术栈刚好覆盖了Android开发的核心。通过实现它你能把分散的知识点如布局、事件处理、数据存储、列表适配串联成一个有机的整体形成对Android应用开发最初步的“体感”。在设计之初我给自己定了几条原则第一代码结构要清晰遵循单一职责方便后续扩展或修改第二用户体验要流畅比如列表滑动、数据加载不能卡顿第三要处理好生命周期带来的数据状态管理问题这是Android开发特有的难点。基于这些原则我采用了最经典也最稳妥的MVCModel-View-Controller架构的变体。虽然现在MVVM更流行但对于初学者和理解基础架构而言MVC的直白性更有优势。2.2 技术栈选型与核心组件这个项目没有使用任何花哨的第三方框架目的就是让大家看清Android SDK本身的能力。核心的技术栈如下语言Java。虽然Kotlin现在是官方首选但Java的存量代码和资料库依然庞大理解Java实现对底层机制掌握更有帮助。数据库Android SDK内置的SQLite。这是本地结构化数据存储的“瑞士军刀”轻量且无需网络权限。我们通过继承SQLiteOpenHelper类来管理数据库的创建和升级。UI列表RecyclerView。它比古老的ListView更灵活、性能更好是展示数据列表的不二之选。配合LinearLayoutManager和自定义的NoteAdapter使用。数据绑定没有使用DataBinding或ViewBinding而是传统的手动findViewById。这虽然繁琐但能让你更清楚地理解视图和数据的关联过程。异步处理原生的AsyncTask在源码中用于简单的数据库写入操作。需要注意的是AsyncTask在最新API中已被废弃在实际新项目中建议使用Kotlin协程、RxJava或Jetpack的LiveData/ViewModel组合。本项目保留它是为了展示最基本的后台任务处理概念。UI设计遵循Material Design指南使用了AppCompat主题、Toolbar、FloatingActionButton等组件保持界面风格与系统一致。注意项目源码中使用的AsyncTask和手动findViewById是出于教学和兼容性考虑。如果你基于此项目进行二次开发或学习新知识我强烈建议将其迁移到更现代的架构组件上例如使用Room替代原生SQLite操作用LiveData和ViewModel管理数据用DataBinding简化UI代码。这会是更有价值的学习路径。3. 核心模块详细拆解与实现3.1 数据模型与数据库设计任何以数据为核心的应用设计好模型和存储结构是第一步。记事本的数据模型非常简单就是一个Note笔记类。public class Note { private int id; private String title; private String content; private long createTime; private long updateTime; // 构造方法、getter和setter省略... }这里有几个设计点值得一说id作为主键用于唯一标识一条笔记在数据库中进行增删改查时使用。title content笔记的标题和正文。在数据库表中我定义content字段为TEXT类型可以存储长文本。createTime updateTime创建时间和更新时间类型为long存储的是自1970年1月1日以来的毫秒数System.currentTimeMillis()。记录时间戳对于“最近编辑”排序、版本追踪等功能非常有用是一个很好的实践习惯。数据库的帮助类NoteDbHelper继承自SQLiteOpenHelper。在onCreate方法中我们执行建表SQL语句。public class NoteDbHelper extends SQLiteOpenHelper { private static final String SQL_CREATE_ENTRIES CREATE TABLE NoteContract.NoteEntry.TABLE_NAME ( NoteContract.NoteEntry._ID INTEGER PRIMARY KEY AUTOINCREMENT, NoteContract.NoteEntry.COLUMN_NAME_TITLE TEXT, NoteContract.NoteEntry.COLUMN_NAME_CONTENT TEXT, NoteContract.NoteEntry.COLUMN_NAME_CREATE_TIME INTEGER, NoteContract.NoteEntry.COLUMN_NAME_UPDATE_TIME INTEGER); Override public void onCreate(SQLiteDatabase db) { db.execSQL(SQL_CREATE_ENTRIES); } // onUpgrade方法省略... }实操心得将表名、列名等字符串常量定义在一个单独的契约类NoteContract中而不是硬编码在SQL语句里。这样做的最大好处是避免因拼写错误导致的难以调试的bug。当你在多个地方如插入、查询、更新用到同一个列名时只需引用这个常量修改时也只需改一处。3.2 列表展示RecyclerView与Adapter的配合主界面是一个展示所有笔记的列表这是用RecyclerView实现的。它的工作流程可以概括为RecyclerView向NoteAdapter要视图ViewNoteAdapter从数据列表List 中取数据然后绑定到视图上再返回给RecyclerView显示。1. 列表项布局item_note.xml 一个简单的CardView里面包含两个TextView分别显示笔记标题和部分预览内容底部再放一个时间戳。2. 适配器NoteAdapter 这是连接数据和视图的桥梁也是逻辑最集中的地方之一。public class NoteAdapter extends RecyclerView.AdapterNoteAdapter.ViewHolder { private ListNote mNoteList; private OnItemClickListener mListener; // ViewHolder用于缓存视图组件避免频繁findViewById public static class ViewHolder extends RecyclerView.ViewHolder { public TextView titleTextView; public TextView previewTextView; public TextView timeTextView; public ViewHolder(View itemView) { super(itemView); titleTextView itemView.findViewById(R.id.text_note_title); previewTextView itemView.findViewById(R.id.text_note_preview); timeTextView itemView.findViewById(R.id.text_note_time); } } Override public void onBindViewHolder(ViewHolder holder, int position) { Note note mNoteList.get(position); holder.titleTextView.setText(note.getTitle()); // 预览内容只取前50个字符避免过长影响布局 String preview note.getContent().length() 50 ? note.getContent().substring(0, 50) ... : note.getContent(); holder.previewTextView.setText(preview); // 将时间戳转换为易读的格式如“2023-10-27” holder.timeTextView.setText(formatTime(note.getUpdateTime())); // 设置点击事件跳转到编辑页面 holder.itemView.setOnClickListener(v - { if (mListener ! null) { mListener.onItemClick(note); } }); } }3. 在主Activity中设置public class MainActivity extends AppCompatActivity { private RecyclerView mRecyclerView; private NoteAdapter mAdapter; private ListNote mNoteList new ArrayList(); Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); mRecyclerView findViewById(R.id.recycler_view); // 设置布局管理器决定列表的排列方式线性垂直列表 mRecyclerView.setLayoutManager(new LinearLayoutManager(this)); // 设置适配器 mAdapter new NoteAdapter(mNoteList); mRecyclerView.setAdapter(mAdapter); // 添加一个默认的分割线 mRecyclerView.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL)); // 加载数据 loadNotes(); } private void loadNotes() { // 从数据库查询所有笔记按修改时间倒序排列 ListNote notes queryAllNotesFromDatabase(); mNoteList.clear(); mNoteList.addAll(notes); // 通知适配器数据已变更 mAdapter.notifyDataSetChanged(); } }注意事项notifyDataSetChanged()会刷新整个列表如果只是增、删、改某一条数据建议使用更高效的局部更新方法如notifyItemInserted(),notifyItemRemoved()这样可以有更平滑的动画效果。在这个基础版中为了代码简单我使用了全局刷新。在实际生产环境中尤其是列表数据量大时局部刷新是必须考虑的优化点。3.3 增删改查CRUD与数据库交互所有对笔记的操作最终都要落到数据库上。我们封装一个NoteDaoData Access Object数据访问对象来统一处理这些逻辑。这里以插入和查询为例。插入一条新笔记public long insertNote(Note note) { SQLiteDatabase db dbHelper.getWritableDatabase(); ContentValues values new ContentValues(); values.put(NoteContract.NoteEntry.COLUMN_NAME_TITLE, note.getTitle()); values.put(NoteContract.NoteEntry.COLUMN_NAME_CONTENT, note.getContent()); long currentTime System.currentTimeMillis(); values.put(NoteContract.NoteEntry.COLUMN_NAME_CREATE_TIME, currentTime); values.put(NoteContract.NoteEntry.COLUMN_NAME_UPDATE_TIME, currentTime); // insert方法返回新插入行的ID如果出错返回-1 long newRowId db.insert(NoteContract.NoteEntry.TABLE_NAME, null, values); db.close(); return newRowId; }查询所有笔记按时间倒序public ListNote queryAllNotes() { ListNote noteList new ArrayList(); SQLiteDatabase db dbHelper.getReadableDatabase(); // 定义要查询的列 String[] projection { NoteContract.NoteEntry._ID, NoteContract.NoteEntry.COLUMN_NAME_TITLE, NoteContract.NoteEntry.COLUMN_NAME_CONTENT, NoteContract.NoteEntry.COLUMN_NAME_UPDATE_TIME }; // 按更新时间降序排序 String sortOrder NoteContract.NoteEntry.COLUMN_NAME_UPDATE_TIME DESC; Cursor cursor db.query( NoteContract.NoteEntry.TABLE_NAME, projection, null, // WHERE 子句null表示所有行 null, // WHERE 子句的参数 null, // GROUP BY null, // HAVING sortOrder ); while (cursor.moveToNext()) { Note note new Note(); note.setId(cursor.getInt(cursor.getColumnIndexOrThrow(NoteContract.NoteEntry._ID))); note.setTitle(cursor.getString(cursor.getColumnIndexOrThrow(NoteContract.NoteEntry.COLUMN_NAME_TITLE))); note.setContent(cursor.getString(cursor.getColumnIndexOrThrow(NoteContract.NoteEntry.COLUMN_NAME_CONTENT))); note.setUpdateTime(cursor.getLong(cursor.getColumnIndexOrThrow(NoteContract.NoteEntry.COLUMN_NAME_UPDATE_TIME))); noteList.add(note); } cursor.close(); db.close(); return noteList; }踩坑记录Cursor和SQLiteDatabase都是需要显式关闭的资源。如果不关闭会造成内存泄漏当泄漏积累到一定程度应用可能会崩溃并报出SQLiteDatabaseLeak之类的错误。务必在finally块中或使用try-with-resourcesJava 7确保它们被关闭。上面的示例在方法末尾直接关闭了在更复杂的多线程环境下需要更谨慎地处理。3.4 编辑页面的数据传递与回传点击列表项会跳转到EditNoteActivity进行编辑。这里涉及两个Activity之间的数据传递。从列表页传递数据到编辑页 在NoteAdapter的点击事件中我们将被点击笔记的ID或整个Note对象序列化后通过Intent的putExtra方法传递过去。Intent intent new Intent(context, EditNoteActivity.class); intent.putExtra(note_id, note.getId()); context.startActivity(intent);在编辑页接收并加载数据public class EditNoteActivity extends AppCompatActivity { private int noteId -1; // -1 代表新建笔记 private Note currentNote; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_edit_note); // 从Intent中获取传递过来的note_id Intent intent getIntent(); if (intent ! null intent.hasExtra(note_id)) { noteId intent.getIntExtra(note_id, -1); if (noteId ! -1) { // 从数据库根据ID查询出这条笔记 currentNote noteDao.queryNoteById(noteId); // 将数据填充到界面的EditText中 editTextTitle.setText(currentNote.getTitle()); editTextContent.setText(currentNote.getContent()); } } // 如果noteId为-1则是新建笔记流程界面留空 } }保存数据并回传给列表页 当用户点击保存按钮时需要判断是更新现有笔记还是创建新笔记然后更新数据库。这里的关键是保存完成后如何通知主列表页面刷新数据有两种常见做法通过startActivityForResult编辑页面setResult(RESULT_OK)后finish()主页面在onActivityResult中重新加载数据。这种方式耦合性较高且已被标记为废弃。通过全局状态管理推荐例如使用EventBus、LiveData等。在这个基础项目中为了简单我采用了另一种方式在编辑页面保存成功后直接finish()然后在主页面的onResume()生命周期方法中重新加载数据。因为从编辑页返回主页面时主页面会执行onResume。// 在MainActivity中 Override protected void onResume() { super.onResume(); loadNotes(); // 重新从数据库加载数据并刷新列表 }这种方式简单有效但不够精确无论数据是否改变都会重载。对于更复杂的场景需要使用观察者模式。4. 功能扩展与高级实现技巧4.1 实现笔记搜索功能搜索是记事本的常用功能。我们可以在主界面的Toolbar上增加一个搜索图标MenuItem点击后展开一个搜索框。搜索的逻辑是在内存中进行的过滤这对于笔记数量不多的情况是可行的。如果数据量巨大则需要在数据库层面使用LIKE语句进行查询。1. 在menu_main.xml中添加搜索项item android:idid/action_search android:iconandroid:drawable/ic_menu_search android:title搜索 app:showAsActionifRoom|collapseActionView app:actionViewClassandroidx.appcompat.widget.SearchView /2. 在MainActivity中配置SearchViewOverride public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(R.menu.menu_main, menu); MenuItem searchItem menu.findItem(R.id.action_search); SearchView searchView (SearchView) searchItem.getActionView(); searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { Override public boolean onQueryTextSubmit(String query) { // 提交搜索时执行过滤 filterNotes(query); return true; } Override public boolean onQueryTextChange(String newText) { // 文本变化时实时过滤可选注意性能 // filterNotes(newText); return true; } }); return true; } private void filterNotes(String keyword) { ListNote filteredList new ArrayList(); if (TextUtils.isEmpty(keyword)) { // 如果关键词为空显示全部 filteredList.addAll(mAllNoteList); // mAllNoteList是备份的完整列表 } else { String lowerCaseKeyword keyword.toLowerCase(); for (Note note : mAllNoteList) { // 在标题和内容中搜索 if (note.getTitle().toLowerCase().contains(lowerCaseKeyword) || note.getContent().toLowerCase().contains(lowerCaseKeyword)) { filteredList.add(note); } } } // 更新Adapter的数据源并刷新 mAdapter.updateList(filteredList); }3. 在NoteAdapter中增加updateList方法public void updateList(ListNote newList) { mNoteList newList; notifyDataSetChanged(); }性能提示在onQueryTextChange中做实时搜索边输入边搜虽然用户体验好但如果原始列表很大频繁的遍历和字符串操作会导致界面卡顿。一个优化策略是使用Handler或RxJava进行防抖Debounce即用户停止输入一段时间如300毫秒后再执行搜索逻辑。4.2 添加笔记分类与标签功能基础版记事本所有笔记都混在一起。一个很自然的扩展就是添加分类如“工作”、“生活”、“灵感”或标签。这需要在数据库层面增加新的表。方案一分类Category新建一张Category表包含id和name字段。在Note表中增加一个category_id字段作为外键关联到Category表。在UI上可以增加一个下拉菜单或页面让用户为笔记选择分类。在主界面可以提供按分类筛选笔记的功能。方案二标签Tag标签比分类更灵活一个笔记可以有多个标签。这需要多对多的关系。新建一张Tag表包含id和name字段。新建一张关联表Note_Tag只包含note_id和tag_id两个字段共同作为主键。添加标签时需要先检查Tag表是否存在该标签不存在则插入然后向Note_Tag表插入关联记录。查询带有某个标签的所有笔记需要使用JOIN语句。实现分类或标签功能是练习数据库关系设计和复杂SQL查询的好机会。在UI上也可以设计得更美观比如用不同颜色的圆点代表不同分类。4.3 数据备份与导出功能用户的数据是无价的提供备份功能能极大提升应用的专业度和用户信任感。最简单的备份是将数据库文件通常位于/data/data/你的包名/databases/复制到外部存储如SD卡或云存储。实现本地备份到Downloads目录public boolean backupDatabase(Context context) { try { File internalDbFile context.getDatabasePath(NoteDbHelper.DATABASE_NAME); if (!internalDbFile.exists()) { return false; } // 获取外部存储的Downloads目录 File downloadsDir Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); String backupFileName note_backup_ System.currentTimeMillis() .db; File backupFile new File(downloadsDir, backupFileName); // 执行文件复制 FileInputStream fis new FileInputStream(internalDbFile); FileOutputStream fos new FileOutputStream(backupFile); byte[] buffer new byte[1024]; int length; while ((length fis.read(buffer)) 0) { fos.write(buffer, 0, length); } fos.flush(); fos.close(); fis.close(); // 通知系统有文件更新方便在文件管理器中看到 MediaScannerConnection.scanFile(context, new String[]{backupFile.getAbsolutePath()}, null, null); return true; } catch (IOException e) { e.printStackTrace(); return false; } }重要提醒权限从Android 6.0 (API 23)开始写入外部存储需要动态申请WRITE_EXTERNAL_STORAGE权限。在Android 10 (API 29)及以上版本作用域存储Scoped Storage策略更加严格直接访问共享目录如Downloads可能需要使用MediaStoreAPI或申请MANAGE_EXTERNAL_STORAGE特殊权限上架Google Play受限。在实际开发中需要仔细处理权限和文件路径的兼容性问题。格式直接备份.db文件虽然简单但可读性差。更友好的做法是导出为JSON或CSV格式这样用户甚至可以用文本编辑器查看。这需要你编写额外的逻辑来遍历数据库并生成格式化的文本文件。5. 常见问题排查与开发心得5.1 数据库版本升级与数据迁移这是初学Android数据库开发最容易踩的坑。当你发布第一个版本后如果想在下一个版本为Note表新增一个color字段用于标记笔记颜色直接修改NoteDbHelper中的onCreate建表语句是没用的因为老用户的手机里已经存在旧表了onCreate只在第一次安装时运行。正确的做法是升级数据库版本号并在onUpgrade方法中执行迁移SQL。public class NoteDbHelper extends SQLiteOpenHelper { public static final int DATABASE_VERSION 2; // 从1升级到2 public static final String DATABASE_NAME Notepad.db; Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 这是一个简单的升级示例添加一个新列 if (oldVersion 2) { db.execSQL(ALTER TABLE NoteContract.NoteEntry.TABLE_NAME ADD COLUMN NoteContract.NoteEntry.COLUMN_NAME_COLOR INTEGER DEFAULT 0); } // 如果以后版本号升到3可以在这里继续添加if判断 // if (oldVersion 3) { ... } } }核心要点onUpgrade的逻辑必须能处理从任何旧版本升级到当前新版本的情况。用户可能从v1.0直接升级到v3.0所以你的代码需要依次判断oldVersion 2,oldVersion 3并执行所有缺失的升级步骤。设计之初就考虑好表结构避免频繁升级。对于复杂的表结构变更如修改列类型、拆分表可能需要创建新表、迁移数据、删除旧表等一系列操作。5.2 RecyclerView列表数据更新不及时或错乱这个问题非常常见症状包括删除了某项但列表没变或者滑动后数据内容显示错乱。根本原因通常有两个数据源与Adapter不同步你操作了数据库也修改了ListNote mNoteList但忘记调用adapter.notifyDataSetChanged()或更精确的notifyItemRemoved来通知UI刷新。解决确保任何对mNoteList的增、删、改操作后都立即调用合适的notify方法。ViewHolder复用导致的数据错乱在onBindViewHolder中你只为某些条件设置了视图内容当Item被复用时旧的数据可能残留。错误示例在onBindViewHolder中如果笔记有图片就显示ImageView否则隐藏。但复用时属于上一个有图片笔记的ImageView可能仍然可见。正确做法在onBindViewHolder中必须为每一项视图属性设置明确的值不能依赖默认状态。Override public void onBindViewHolder(ViewHolder holder, int position) { Note note mNoteList.get(position); holder.titleView.setText(note.getTitle()); // 必须明确设置图片状态 if (note.hasImage()) { holder.imageView.setVisibility(View.VISIBLE); // 加载图片... } else { // 明确隐藏 holder.imageView.setVisibility(View.GONE); } }5.3 应用崩溃主线程执行耗时操作如果你在按钮点击事件里直接执行数据库的复杂查询或写入大量数据很可能会遇到Application Not Responding (ANR)错误导致应用崩溃。因为数据库操作是I/O操作可能很慢阻塞了主线程UI线程。错误示范saveButton.setOnClickListener(v - { String content editText.getText().toString(); // 这是一个潜在的耗时操作 long id noteDao.insertNote(new Note(content)); });正确做法使用异步任务。saveButton.setOnClickListener(v - { String content editText.getText().toString(); new AsyncTaskVoid, Void, Long() { Override protected Long doInBackground(Void... voids) { // 在后台线程执行数据库操作 return noteDao.insertNote(new Note(content)); } Override protected void onPostExecute(Long resultId) { // 回到主线程更新UI如显示保存成功的提示 if (resultId ! -1) { Toast.makeText(EditNoteActivity.this, 保存成功, Toast.LENGTH_SHORT).show(); finish(); } } }.execute(); });再次强调AsyncTask已废弃现代开发中应使用ExecutorService、RxJava或Kotlin协程来管理后台任务。5.4 关于源码与APK的使用建议提供的源码是一个完整的Android Studio项目。下载后用Android Studio打开项目根目录即可。确保你的SDK和构建工具版本不要太旧项目使用的是AndroidX库所以需要较新版本的Android Studio支持。APK文件可以直接安装在Android手机上进行体验。安装前你可能需要在手机设置中开启“允许安装来自未知来源的应用”。这个APK是发布版Release构建进行了代码混淆和压缩无法直接反编译得到可读源码仅供功能体验。我个人在开发这个小项目时最深的一点体会是把基础打牢远比追逐新框架重要。这个记事本没有用任何炫技的框架但把Activity生命周期、Intent传值、SQLite操作、RecyclerView使用这些基本功扎扎实实地走了一遍。后来无论学Room、LiveData还是MVVM发现底层理念都是相通的理解了这个“朴素”的版本再学那些封装好的框架会感觉豁然开朗。如果你是一名初学者我建议你在读懂这份源码的基础上尝试自己去实现前面提到的“分类”或“备份”功能这是最好的学习方式。
返回列表