简介:本资源是一份基于Android Studio开发的备忘录App完整项目源码,面向Android初学者与课程设计实践者,聚焦移动端基础功能实现与工程化开发流程训练。项目涵盖用户界面搭建、SQLite本地数据持久化、备忘增删改查、时间排序检索、通知提醒集成等核心模块,帮助学习者系统掌握Android应用开发的关键技术栈。压缩包共81个文件,含10个Java业务逻辑类、34个XML布局与配置文件、11个PNG/WebP图标资源、3个Gradle构建脚本及1个可直接安装的APK调试包,整体2.61MB,结构清晰,适配Android Studio主流版本。目前已有736人学习下载,资源包含完整可运行工程(含memo.jks签名文件、gradlew环境脚本及.gitignore规范),无需额外配置即可导入编译,特别适合课设实践、毕业设计参考与Android入门项目复现。1. 这不是“Hello World”练习:一个能真正在手机里存下你明天会议纪要的备忘录App,从Android Studio里长出来你肯定试过在 Android Studio 里新建一个 Empty Activity,敲几行TextView.setText("Hello"),然后点 Run —— 模拟器一闪而过,日志里刷出一堆D/OpenGLRenderer,最后你关掉 IDE,心里清楚:这根本不算“做了一个 App”。真正的分水岭不在编译通过,而在你第一次把写好的备忘录 APK 装进自己手机,打开、输入“下午三点和客户对齐UI稿”,点保存,锁屏再亮起,它还在那里,没丢、没崩、没清空。这个基于 Android Studio 的备忘录 App 设计,就是冲着这个“真实可用”去的:它用Room做本地持久化(不是 SharedPreferences 存字符串那种玄学),支持带时间戳的增删改查,界面用Material3组件搭得干净,关键——所有代码都按 Android 官方推荐的分层架构(UI → ViewModel → Repository → DAO)组织,不是一坨 Activity 里塞满findViewById和 SQL 字符串。它适合刚啃完《第一行代码》第 8 章、正卡在“怎么让数据活过页面重建”的开发者,也适合需要快速交付一个轻量级内部工具的某公司前端转岗同学。别被标题里的“设计”二字骗了——这里没有 PPT 架构图,只有你能复制粘贴、改个包名就能跑起来的.java和.xml。2. 为什么不用 SQLiteOpenHelper?Room 不是炫技,是少写 37 行样板代码和 2 个崩溃现场2.1 Room 的底层逻辑:SQL 是你的契约,不是你的牢笼很多新手一看到 Room 就想绕道,觉得“又要学注解又要写 DAO 又要建 Entity,还不如直接db.execSQL("INSERT...")来得痛快”。但现实是:当你在onCreate()里手写CREATE TABLE notes (id INTEGER PRIMARY KEY, title TEXT, content TEXT, created_at INTEGER),三个月后需求变了——要加个is_pinned BOOLEAN DEFAULT 0字段,你得写ALTER TABLE notes ADD COLUMN is_pinned INTEGER DEFAULT 0,还得在onUpgrade()里判断版本号、执行迁移。稍有疏忽,用户升级后打开 App,CursorWindowAllocationException直接报给你看。Room 把这个过程反转了:你只定义 Java/Kotlin 类(Entity),Room 在编译期自动生成CREATE TABLE语句和ContentValues绑定逻辑。它强制你把“数据结构”和“操作逻辑”分离——Entity 描述“是什么”,DAO 描述“能做什么”,Repository 描述“谁来协调”。这种分离不是为了面试吹架构,而是当你某天发现created_at字段类型从Long改成String(比如要兼容 ISO8601 格式),你只需要改 Entity 里的字段声明和 DAO 里的查询返回类型,Room 编译器会立刻告诉你哪几个@Query里的SELECT语句漏改了strftime函数调用。这种编译期检查,比运行时NullPointerException好救十倍。2.2 三步落地:从建模到插入,一个完整闭环我们先定义备忘录的核心数据结构。注意@PrimaryKey(autoGenerate = true)和@ColumnInfo(name = "created_at")的写法——这是 Room 要求的显式契约,不能省:// app/src/main/java/com/example/memo/Note.java @Entity(tableName = "notes") public class Note { @PrimaryKey(autoGenerate = true) public long id; @ColumnInfo(name = "title") public String title; @ColumnInfo(name = "content") public String content; @ColumnInfo(name = "created_at") public long createdAt; // Unix timestamp in milliseconds @ColumnInfo(name = "updated_at") public long updatedAt; }接着是数据访问接口(DAO)。这里重点看@Insert(onConflict = OnConflictStrategy.REPLACE)——它解决的是“同名笔记重复创建”的经典问题。如果你不做这个声明,两次插入相同id会直接抛SQLiteConstraintException:// app/src/main/java/com/example/memo/NoteDao.java @Dao public interface NoteDao { @Insert(onConflict = OnConflictStrategy.REPLACE) long insert(Note note); @Update void update(Note note); @Delete void delete(Note note); @Query("SELECT * FROM notes ORDER BY updated_at DESC") LiveData<List<Note>> getAllNotes(); @Query("SELECT * FROM notes WHERE id = :id") Note getNoteById(long id); }最后是数据库类。Room.databaseBuilder()的第三个参数"memo_database"是数据库文件名,必须唯一;fallbackToDestructiveMigration()是开发阶段的后悔药(见后文避坑章),上线前必须移除:// app/src/main/java/com/example/memo/AppDatabase.java @Database(entities = {Note.class}, version = 1, exportSchema = false) public abstract class AppDatabase extends RoomDatabase { private static AppDatabase INSTANCE; public abstract NoteDao noteDao(); public static AppDatabase getDatabase(final Context context) { if (INSTANCE == null) { synchronized (AppDatabase.class) { if (INSTANCE == null) { INSTANCE = Room.databaseBuilder( context.getApplicationContext(), AppDatabase.class, "memo_database" ).fallbackToDestructiveMigration() // 开发期临时开关! .build(); } } } return INSTANCE; } }提示:exportSchema = false是为了减小 APK 体积。Room 默认会把数据库 schema 导出为 JSON 文件用于版本管理,但对单机备忘录这种无服务端同步的场景,它纯属冗余。上线前务必设为true并提交 schema 文件到 Git,否则后续升级会因找不到旧版 schema 而失败。3. ViewModel + LiveData:为什么你的备忘录一横屏就丢数据?答案在生命周期感知上3.1 Activity 重建时的数据断崖:从onSaveInstanceState()到ViewModel的进化假设你在MainActivity里声明了一个List<Note> notes = new ArrayList<>();,在onCreate()里从 Room 加载数据并填充它。当用户旋转屏幕,系统销毁并重建 Activity,notes这个引用直接变成null,界面上的 RecyclerView 一片空白——你得重写onSaveInstanceState(Bundle)把整个List序列化进去,再在onRestoreInstanceState()里反序列化。但Note里如果有Bitmap或InputStream,这条路直接堵死。ViewModel的价值就在这里:它由ViewModelProvider管理,生命周期绑定到Activity所在的LifecycleOwner,而不是Activity本身。只要Activity没被系统杀死(比如用户按 Home 键切走),ViewModel实例就一直活着。你把数据加载逻辑从Activity移到ViewModel,LiveData<List<Note>>作为可观察数据源,Activity只负责订阅和更新 UI。旋转?内存不足?ViewModel里的notes丝毫无损。3.2 从零写一个 NotesViewModel:把 Room 的 LiveData 接口串起来NotesViewModel不继承AndroidViewModel(它需要Application上下文,而我们只用 Room,不需要),直接继承ViewModel即可。关键点在于getAllNotes()返回的是LiveData<List<Note>>,它天然支持观察者模式,且线程安全:// app/src/main/java/com/example/memo/NotesViewModel.java public class NotesViewModel extends ViewModel { private final NoteDao noteDao; private final LiveData<List<Note>> allNotes; public NotesViewModel(@NonNull Application application) { super(); AppDatabase db = AppDatabase.getDatabase(application.getApplicationContext()); noteDao = db.noteDao(); allNotes = noteDao.getAllNotes(); // Room 自动生成的 LiveData 查询 } public LiveData<List<Note>> getAllNotes() { return allNotes; } public void insert(Note note) { new InsertAsyncTask(noteDao).execute(note); } public void update(Note note) { new UpdateAsyncTask(noteDao).execute(note); } public void delete(Note note) { new DeleteAsyncTask(noteDao).execute(note); } // 异步任务封装,避免在主线程执行数据库写入 private static class InsertAsyncTask extends AsyncTask<Note, Void, Void> { private final NoteDao mAsyncTaskDao; InsertAsyncTask(NoteDao dao) { mAsyncTaskDao = dao; } @Override protected Void doInBackground(Note... notes) { mAsyncTaskDao.insert(notes[0]); return null; } } private static class UpdateAsyncTask extends AsyncTask<Note, Void, Void> { private final NoteDao mAsyncTaskDao; UpdateAsyncTask(NoteDao dao) { mAsyncTaskDao = dao; } @Override protected Void doInBackground(Note... notes) { mAsyncTaskDao.update(notes[0]); return null; } } private static class DeleteAsyncTask extends AsyncTask<Note, Void, Void> { private final NoteDao mAsyncTaskDao; DeleteAsyncTask(NoteDao dao) { mAsyncTaskDao = dao; } @Override protected Void doInBackground(Note... notes) { mAsyncTaskDao.delete(notes[0]); return null; } } }在MainActivity中使用它,只需两步:获取ViewModel实例,然后观察LiveData:// app/src/main/java/com/example/memo/MainActivity.java public class MainActivity extends AppCompatActivity { private NotesViewModel notesViewModel; private NoteAdapter adapter; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); RecyclerView recyclerView = findViewById(R.id.recycler_view); adapter = new NoteAdapter(); recyclerView.setAdapter(adapter); recyclerView.setLayoutManager(new LinearLayoutManager(this)); notesViewModel = new ViewModelProvider(this).get(NotesViewModel.class); notesViewModel.getAllNotes().observe(this, notes -> { // 数据更新时自动回调,无需手动刷新 adapter.submitList(notes); }); } }注意:new ViewModelProvider(this).get(NotesViewModel.class)中的this是MainActivity,它实现了ViewModelStoreOwner接口。ViewModelProvider会根据this的哈希值缓存NotesViewModel实例,确保旋转后拿到的是同一个对象。4. 避坑:那些让你在凌晨两点对着 Logcat 咬牙切齿的 Room + ViewModel 组合雷区4.1 现象:App 启动时报java.lang.IllegalStateException: Cannot access database on the main thread原因:Room 默认禁止在主线程执行数据库写入(insert/update/delete),因为 SQLite 是阻塞 I/O,会卡住 UI 线程。但@Query的读操作(如getAllNotes())返回LiveData是允许的,因为 Room 内部已做了线程切换。你如果在ViewModel里直接调用noteDao.insert(note),就会触发此异常。解决:必须用异步方式。上面NotesViewModel里的InsertAsyncTask就是标准解法。Kotlin 用户可用viewModelScope.launch { }+withContext(Dispatchers.IO),Java 用户坚持用AsyncTask(虽然已废弃,但在 AndroidX 中仍可用)或ExecutorService。切记:getAllNotes()可以直接调,insert()必须异步。4.2 现象:修改 Note 后 UI 不刷新,submitList()调用了但 RecyclerView 没变原因:LiveData的观察者只在数据“变化”时触发。Room的getAllNotes()返回的LiveData是基于数据库变更通知的,但submitList()传入的是一个新List对象。如果Note类没重写equals()和hashCode(),ListAdapter的DiffUtil无法识别哪些项是新增、修改或删除,导致submitList()认为“数据没变”,跳过刷新。解决:给Note类补全equals()和hashCode()。Android Studio 可右键 → Generate →equals()andhashCode(),勾选所有字段。这是ListAdapter正常工作的前提,不是可选项。4.3 现象:升级 App 后,老用户打开闪退,Logcat 显示android.database.sqlite.SQLiteException: no such column: is_pinned原因:你新增了is_pinned字段,但没写数据库迁移逻辑。fallbackToDestructiveMigration()在开发期很爽,但上线后绝对不能留——它会直接删掉用户所有旧数据,重建新表。生产环境必须用addMigrations()。解决:定义迁移脚本。例如从 v1 升到 v2:static final Migration MIGRATION_1_2 = new Migration(1, 2) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE notes ADD COLUMN is_pinned INTEGER NOT NULL DEFAULT 0"); } }; // 在 AppDatabase.build() 时添加: .fallbackToDestructiveMigration() // 先注释掉! .addMigrations(MIGRATION_1_2)提示:Migration 脚本必须严格测试。用Room.inMemoryDatabaseBuilder()创建内存库,在单元测试里跑一遍MIGRATION_1_2,验证is_pinned字段是否成功添加且默认值为 0。4.4 现象:LiveData观察者被多次触发,每次旋转屏幕就多注册一个监听原因:observe()的第二个参数是Observer,如果在onCreate()里反复observe(),每次重建 Activity 都会新增一个观察者,导致数据更新时多个submitList()被调用,RecyclerView 刷新混乱。解决:observe()必须配对LifecycleOwner。this(即MainActivity)实现了LifecycleOwner,LiveData会自动在Activity销毁时移除观察者。只要确保observe()只在onCreate()里调用一次,就绝不会泄漏。不要用observeForever(),那玩意儿得手动removeObserver(),极易翻车。5. Material3 + Navigation:让备忘录不止能用,还能让人愿意多看两眼5.1 为什么选 Material3 而不是 AppCompat?颜色系统是生产力Android Studio 新建项目默认用Theme.Material3.*,这不是跟风。Material3 的动态色彩(Dynamic Color)能自动提取壁纸主色,生成整套 harmonious 色板。对备忘录这种高频使用的工具 App,视觉一致性直接降低认知负荷。你不需要手写 20 个colorPrimary、colorOnSurface,只需在themes.xml里启用:<!-- app/src/main/res/values/themes.xml --> <style name="Theme.Memo" parent="Theme.Material3.DayNight"> <item name="android:windowSplashScreenBackground">@color/splash_background</item> <!-- 启用动态色彩 --> <item name="android:forceDarkAllowed">false</item> </style>然后在MainActivity的onCreate()里加一行:AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM);系统会自动根据深色模式开关和壁纸,调整FloatingActionButton的背景色、TextField的光标色、甚至Chip的边框色。用户换张壁纸,你的 App 就焕然一新——这种体验,AppCompat做不到。5.2 Navigation Component:告别startActivity()的意大利面条式跳转备忘录至少有三个界面:列表页(MainActivity)、编辑页(EditNoteActivity)、详情页(NoteDetailActivity)。如果全用Intent跳转,EditNoteActivity里要getIntent().getLongExtra("note_id", -1),NoteDetailActivity里又要getIntent().getStringExtra("note_title")……参数传递像击鼓传花,错一个就NullPointerException。Navigation Component 把路由集中到nav_graph.xml里,用<argument>显式声明参数类型和默认值:<!-- app/src/main/res/navigation/nav_graph.xml --> <navigation xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/nav_graph" app:startDestination="@id/listFragment"> <fragment android:id="@+id/listFragment" android:name="com.example.memo.ListFragment" android:label="Notes"> <action android:id="@+id/action_list_to_edit" app:destination="@id/editFragment" /> </fragment> <fragment android:id="@+id/editFragment" android:name="com.example.memo.EditFragment" android:label="Edit Note"> <argument android:name="noteId" app:argType="long" android:defaultValue="-1" /> </fragment> </navigation>在ListFragment里跳转时,用findNavController().navigate()传参,类型安全:// ListFragment.java Bundle bundle = new Bundle(); bundle.putLong("noteId", note.getId()); findNavController().navigate(R.id.action_list_to_edit, bundle);EditFragment里用NavArgs自动生成的getArguments()获取,IDE 会提示noteId字段,再也不用getLongExtra()猜参数名。6. 最后一道防线:用 Instrumented Test 验证“保存”按钮真的把数据落盘了6.1 为什么单元测试不够?Room 的@Dao方法必须在真实数据库上跑你可以在JUnit里 mockNoteDao,验证insert()方法被调用了。但这只能证明“代码路径走通了”,不能证明“数据真的写进了/data/data/com.example.memo/databases/memo_database”。SQLite 是系统级组件,涉及文件 I/O、事务、锁机制,只有在真实设备或模拟器上运行的 Instrumented Test 才能覆盖。AndroidX Test 提供了TestDatabase工厂,让我们在测试中创建一个独立的、不污染主数据库的测试库:// app/src/androidTest/java/com/example/memo/NoteDaoTest.java @RunWith(AndroidJUnit4.class) public class NoteDaoTest { private AppDatabase database; private NoteDao noteDao; @Before public void createDb() { Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); database = Room.inMemoryDatabaseBuilder(context, AppDatabase.class) .allowMainThreadQueries() // 测试中允许主线程查询,简化写法 .build(); noteDao = database.noteDao(); } @After public void closeDb() { database.close(); } @Test public void insertAndGetNote() throws Exception { // Given Note note = new Note(); note.title = "Test Title"; note.content = "Test Content"; note.createdAt = System.currentTimeMillis(); note.updatedAt = note.createdAt; // When long id = noteDao.insert(note); Note retrieved = noteDao.getNoteById(id); // Then assertNotNull(retrieved); assertEquals("Test Title", retrieved.title); assertEquals("Test Content", retrieved.content); } }6.2 关键参数说明与执行命令Room.inMemoryDatabaseBuilder(...)创建的是内存数据库,进程退出即销毁,完全隔离;.allowMainThreadQueries()是测试专用开关,生产代码严禁开启;InstrumentationRegistry.getInstrumentation().getTargetContext()提供的是被测 App 的上下文,能访问其assets和databases目录;运行命令:在终端执行./gradlew connectedAndroidTest,或在 Android Studio 的Build Variants面板中将testBuildType切为debug,右键测试类 →Run 'NoteDaoTest'。从那以后我每次重构NoteDao的@Query语句,或者新增一个@Insert方法,都会先补一个对应的 Instrumented Test。不是为了覆盖率数字好看,而是当insertAndGetNote()绿了,我知道这行 SQL 在真实 SQLite 引擎里跑通了——它不会在用户手机上因为某个未声明的NOT NULL约束而静默失败。这种确定性,是深夜收到崩溃报告时,我能睡着的唯一后悔药。希望帮到你。本文还有配套的精品资源,点击获取
简介:本资源是一份基于Android Studio开发的备忘录App完整项目源码,面向Android初学者与课程设计实践者,聚焦移动端基础功能实现与工程化开发流程训练。项目涵盖用户界面搭建、SQLite本地数据持久化、备忘增删改查、时间排序检索、通知提醒集成等核心模块,帮助学习者系统掌握Android应用开发的关键技术栈。压缩包共81个文件,含10个Java业务逻辑类、34个XML布局与配置文件、11个PNG/WebP图标资源、3个Gradle构建脚本及1个可直接安装的APK调试包,整体2.61MB,结构清晰,适配Android Studio主流版本。目前已有736人学习下载,资源包含完整可运行工程(含memo.jks签名文件、gradlew环境脚本及.gitignore规范),无需额外配置即可导入编译,特别适合课设实践、毕业设计参考与Android入门项目复现。1. 这不是“Hello World”练习:一个能真正在手机里存下你明天会议纪要的备忘录App,从Android Studio里长出来你肯定试过在 Android Studio 里新建一个 Empty Activity,敲几行TextView.setText("Hello"),然后点 Run —— 模拟器一闪而过,日志里刷出一堆D/OpenGLRenderer,最后你关掉 IDE,心里清楚:这根本不算“做了一个 App”。真正的分水岭不在编译通过,而在你第一次把写好的备忘录 APK 装进自己手机,打开、输入“下午三点和客户对齐UI稿”,点保存,锁屏再亮起,它还在那里,没丢、没崩、没清空。这个基于 Android Studio 的备忘录 App 设计,就是冲着这个“真实可用”去的:它用Room做本地持久化(不是 SharedPreferences 存字符串那种玄学),支持带时间戳的增删改查,界面用Material3组件搭得干净,关键——所有代码都按 Android 官方推荐的分层架构(UI → ViewModel → Repository → DAO)组织,不是一坨 Activity 里塞满findViewById和 SQL 字符串。它适合刚啃完《第一行代码》第 8 章、正卡在“怎么让数据活过页面重建”的开发者,也适合需要快速交付一个轻量级内部工具的某公司前端转岗同学。别被标题里的“设计”二字骗了——这里没有 PPT 架构图,只有你能复制粘贴、改个包名就能跑起来的.java和.xml。2. 为什么不用 SQLiteOpenHelper?Room 不是炫技,是少写 37 行样板代码和 2 个崩溃现场2.1 Room 的底层逻辑:SQL 是你的契约,不是你的牢笼很多新手一看到 Room 就想绕道,觉得“又要学注解又要写 DAO 又要建 Entity,还不如直接db.execSQL("INSERT...")来得痛快”。但现实是:当你在onCreate()里手写CREATE TABLE notes (id INTEGER PRIMARY KEY, title TEXT, content TEXT, created_at INTEGER),三个月后需求变了——要加个is_pinned BOOLEAN DEFAULT 0字段,你得写ALTER TABLE notes ADD COLUMN is_pinned INTEGER DEFAULT 0,还得在onUpgrade()里判断版本号、执行迁移。稍有疏忽,用户升级后打开 App,CursorWindowAllocationException直接报给你看。Room 把这个过程反转了:你只定义 Java/Kotlin 类(Entity),Room 在编译期自动生成CREATE TABLE语句和ContentValues绑定逻辑。它强制你把“数据结构”和“操作逻辑”分离——Entity 描述“是什么”,DAO 描述“能做什么”,Repository 描述“谁来协调”。这种分离不是为了面试吹架构,而是当你某天发现created_at字段类型从Long改成String(比如要兼容 ISO8601 格式),你只需要改 Entity 里的字段声明和 DAO 里的查询返回类型,Room 编译器会立刻告诉你哪几个@Query里的SELECT语句漏改了strftime函数调用。这种编译期检查,比运行时NullPointerException好救十倍。2.2 三步落地:从建模到插入,一个完整闭环我们先定义备忘录的核心数据结构。注意@PrimaryKey(autoGenerate = true)和@ColumnInfo(name = "created_at")的写法——这是 Room 要求的显式契约,不能省:// app/src/main/java/com/example/memo/Note.java @Entity(tableName = "notes") public class Note { @PrimaryKey(autoGenerate = true) public long id; @ColumnInfo(name = "title") public String title; @ColumnInfo(name = "content") public String content; @ColumnInfo(name = "created_at") public long createdAt; // Unix timestamp in milliseconds @ColumnInfo(name = "updated_at") public long updatedAt; }接着是数据访问接口(DAO)。这里重点看@Insert(onConflict = OnConflictStrategy.REPLACE)——它解决的是“同名笔记重复创建”的经典问题。如果你不做这个声明,两次插入相同id会直接抛SQLiteConstraintException:// app/src/main/java/com/example/memo/NoteDao.java @Dao public interface NoteDao { @Insert(onConflict = OnConflictStrategy.REPLACE) long insert(Note note); @Update void update(Note note); @Delete void delete(Note note); @Query("SELECT * FROM notes ORDER BY updated_at DESC") LiveData<List<Note>> getAllNotes(); @Query("SELECT * FROM notes WHERE id = :id") Note getNoteById(long id); }最后是数据库类。Room.databaseBuilder()的第三个参数"memo_database"是数据库文件名,必须唯一;fallbackToDestructiveMigration()是开发阶段的后悔药(见后文避坑章),上线前必须移除:// app/src/main/java/com/example/memo/AppDatabase.java @Database(entities = {Note.class}, version = 1, exportSchema = false) public abstract class AppDatabase extends RoomDatabase { private static AppDatabase INSTANCE; public abstract NoteDao noteDao(); public static AppDatabase getDatabase(final Context context) { if (INSTANCE == null) { synchronized (AppDatabase.class) { if (INSTANCE == null) { INSTANCE = Room.databaseBuilder( context.getApplicationContext(), AppDatabase.class, "memo_database" ).fallbackToDestructiveMigration() // 开发期临时开关! .build(); } } } return INSTANCE; } }提示:exportSchema = false是为了减小 APK 体积。Room 默认会把数据库 schema 导出为 JSON 文件用于版本管理,但对单机备忘录这种无服务端同步的场景,它纯属冗余。上线前务必设为true并提交 schema 文件到 Git,否则后续升级会因找不到旧版 schema 而失败。3. ViewModel + LiveData:为什么你的备忘录一横屏就丢数据?答案在生命周期感知上3.1 Activity 重建时的数据断崖:从onSaveInstanceState()到ViewModel的进化假设你在MainActivity里声明了一个List<Note> notes = new ArrayList<>();,在onCreate()里从 Room 加载数据并填充它。当用户旋转屏幕,系统销毁并重建 Activity,notes这个引用直接变成null,界面上的 RecyclerView 一片空白——你得重写onSaveInstanceState(Bundle)把整个List序列化进去,再在onRestoreInstanceState()里反序列化。但Note里如果有Bitmap或InputStream,这条路直接堵死。ViewModel的价值就在这里:它由ViewModelProvider管理,生命周期绑定到Activity所在的LifecycleOwner,而不是Activity本身。只要Activity没被系统杀死(比如用户按 Home 键切走),ViewModel实例就一直活着。你把数据加载逻辑从Activity移到ViewModel,LiveData<List<Note>>作为可观察数据源,Activity只负责订阅和更新 UI。旋转?内存不足?ViewModel里的notes丝毫无损。3.2 从零写一个 NotesViewModel:把 Room 的 LiveData 接口串起来NotesViewModel不继承AndroidViewModel(它需要Application上下文,而我们只用 Room,不需要),直接继承ViewModel即可。关键点在于getAllNotes()返回的是LiveData<List<Note>>,它天然支持观察者模式,且线程安全:// app/src/main/java/com/example/memo/NotesViewModel.java public class NotesViewModel extends ViewModel { private final NoteDao noteDao; private final LiveData<List<Note>> allNotes; public NotesViewModel(@NonNull Application application) { super(); AppDatabase db = AppDatabase.getDatabase(application.getApplicationContext()); noteDao = db.noteDao(); allNotes = noteDao.getAllNotes(); // Room 自动生成的 LiveData 查询 } public LiveData<List<Note>> getAllNotes() { return allNotes; } public void insert(Note note) { new InsertAsyncTask(noteDao).execute(note); } public void update(Note note) { new UpdateAsyncTask(noteDao).execute(note); } public void delete(Note note) { new DeleteAsyncTask(noteDao).execute(note); } // 异步任务封装,避免在主线程执行数据库写入 private static class InsertAsyncTask extends AsyncTask<Note, Void, Void> { private final NoteDao mAsyncTaskDao; InsertAsyncTask(NoteDao dao) { mAsyncTaskDao = dao; } @Override protected Void doInBackground(Note... notes) { mAsyncTaskDao.insert(notes[0]); return null; } } private static class UpdateAsyncTask extends AsyncTask<Note, Void, Void> { private final NoteDao mAsyncTaskDao; UpdateAsyncTask(NoteDao dao) { mAsyncTaskDao = dao; } @Override protected Void doInBackground(Note... notes) { mAsyncTaskDao.update(notes[0]); return null; } } private static class DeleteAsyncTask extends AsyncTask<Note, Void, Void> { private final NoteDao mAsyncTaskDao; DeleteAsyncTask(NoteDao dao) { mAsyncTaskDao = dao; } @Override protected Void doInBackground(Note... notes) { mAsyncTaskDao.delete(notes[0]); return null; } } }在MainActivity中使用它,只需两步:获取ViewModel实例,然后观察LiveData:// app/src/main/java/com/example/memo/MainActivity.java public class MainActivity extends AppCompatActivity { private NotesViewModel notesViewModel; private NoteAdapter adapter; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); RecyclerView recyclerView = findViewById(R.id.recycler_view); adapter = new NoteAdapter(); recyclerView.setAdapter(adapter); recyclerView.setLayoutManager(new LinearLayoutManager(this)); notesViewModel = new ViewModelProvider(this).get(NotesViewModel.class); notesViewModel.getAllNotes().observe(this, notes -> { // 数据更新时自动回调,无需手动刷新 adapter.submitList(notes); }); } }注意:new ViewModelProvider(this).get(NotesViewModel.class)中的this是MainActivity,它实现了ViewModelStoreOwner接口。ViewModelProvider会根据this的哈希值缓存NotesViewModel实例,确保旋转后拿到的是同一个对象。4. 避坑:那些让你在凌晨两点对着 Logcat 咬牙切齿的 Room + ViewModel 组合雷区4.1 现象:App 启动时报java.lang.IllegalStateException: Cannot access database on the main thread原因:Room 默认禁止在主线程执行数据库写入(insert/update/delete),因为 SQLite 是阻塞 I/O,会卡住 UI 线程。但@Query的读操作(如getAllNotes())返回LiveData是允许的,因为 Room 内部已做了线程切换。你如果在ViewModel里直接调用noteDao.insert(note),就会触发此异常。解决:必须用异步方式。上面NotesViewModel里的InsertAsyncTask就是标准解法。Kotlin 用户可用viewModelScope.launch { }+withContext(Dispatchers.IO),Java 用户坚持用AsyncTask(虽然已废弃,但在 AndroidX 中仍可用)或ExecutorService。切记:getAllNotes()可以直接调,insert()必须异步。4.2 现象:修改 Note 后 UI 不刷新,submitList()调用了但 RecyclerView 没变原因:LiveData的观察者只在数据“变化”时触发。Room的getAllNotes()返回的LiveData是基于数据库变更通知的,但submitList()传入的是一个新List对象。如果Note类没重写equals()和hashCode(),ListAdapter的DiffUtil无法识别哪些项是新增、修改或删除,导致submitList()认为“数据没变”,跳过刷新。解决:给Note类补全equals()和hashCode()。Android Studio 可右键 → Generate →equals()andhashCode(),勾选所有字段。这是ListAdapter正常工作的前提,不是可选项。4.3 现象:升级 App 后,老用户打开闪退,Logcat 显示android.database.sqlite.SQLiteException: no such column: is_pinned原因:你新增了is_pinned字段,但没写数据库迁移逻辑。fallbackToDestructiveMigration()在开发期很爽,但上线后绝对不能留——它会直接删掉用户所有旧数据,重建新表。生产环境必须用addMigrations()。解决:定义迁移脚本。例如从 v1 升到 v2:static final Migration MIGRATION_1_2 = new Migration(1, 2) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE notes ADD COLUMN is_pinned INTEGER NOT NULL DEFAULT 0"); } }; // 在 AppDatabase.build() 时添加: .fallbackToDestructiveMigration() // 先注释掉! .addMigrations(MIGRATION_1_2)提示:Migration 脚本必须严格测试。用Room.inMemoryDatabaseBuilder()创建内存库,在单元测试里跑一遍MIGRATION_1_2,验证is_pinned字段是否成功添加且默认值为 0。4.4 现象:LiveData观察者被多次触发,每次旋转屏幕就多注册一个监听原因:observe()的第二个参数是Observer,如果在onCreate()里反复observe(),每次重建 Activity 都会新增一个观察者,导致数据更新时多个submitList()被调用,RecyclerView 刷新混乱。解决:observe()必须配对LifecycleOwner。this(即MainActivity)实现了LifecycleOwner,LiveData会自动在Activity销毁时移除观察者。只要确保observe()只在onCreate()里调用一次,就绝不会泄漏。不要用observeForever(),那玩意儿得手动removeObserver(),极易翻车。5. Material3 + Navigation:让备忘录不止能用,还能让人愿意多看两眼5.1 为什么选 Material3 而不是 AppCompat?颜色系统是生产力Android Studio 新建项目默认用Theme.Material3.*,这不是跟风。Material3 的动态色彩(Dynamic Color)能自动提取壁纸主色,生成整套 harmonious 色板。对备忘录这种高频使用的工具 App,视觉一致性直接降低认知负荷。你不需要手写 20 个colorPrimary、colorOnSurface,只需在themes.xml里启用:<!-- app/src/main/res/values/themes.xml --> <style name="Theme.Memo" parent="Theme.Material3.DayNight"> <item name="android:windowSplashScreenBackground">@color/splash_background</item> <!-- 启用动态色彩 --> <item name="android:forceDarkAllowed">false</item> </style>然后在MainActivity的onCreate()里加一行:AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM);系统会自动根据深色模式开关和壁纸,调整FloatingActionButton的背景色、TextField的光标色、甚至Chip的边框色。用户换张壁纸,你的 App 就焕然一新——这种体验,AppCompat做不到。5.2 Navigation Component:告别startActivity()的意大利面条式跳转备忘录至少有三个界面:列表页(MainActivity)、编辑页(EditNoteActivity)、详情页(NoteDetailActivity)。如果全用Intent跳转,EditNoteActivity里要getIntent().getLongExtra("note_id", -1),NoteDetailActivity里又要getIntent().getStringExtra("note_title")……参数传递像击鼓传花,错一个就NullPointerException。Navigation Component 把路由集中到nav_graph.xml里,用<argument>显式声明参数类型和默认值:<!-- app/src/main/res/navigation/nav_graph.xml --> <navigation xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/nav_graph" app:startDestination="@id/listFragment"> <fragment android:id="@+id/listFragment" android:name="com.example.memo.ListFragment" android:label="Notes"> <action android:id="@+id/action_list_to_edit" app:destination="@id/editFragment" /> </fragment> <fragment android:id="@+id/editFragment" android:name="com.example.memo.EditFragment" android:label="Edit Note"> <argument android:name="noteId" app:argType="long" android:defaultValue="-1" /> </fragment> </navigation>在ListFragment里跳转时,用findNavController().navigate()传参,类型安全:// ListFragment.java Bundle bundle = new Bundle(); bundle.putLong("noteId", note.getId()); findNavController().navigate(R.id.action_list_to_edit, bundle);EditFragment里用NavArgs自动生成的getArguments()获取,IDE 会提示noteId字段,再也不用getLongExtra()猜参数名。6. 最后一道防线:用 Instrumented Test 验证“保存”按钮真的把数据落盘了6.1 为什么单元测试不够?Room 的@Dao方法必须在真实数据库上跑你可以在JUnit里 mockNoteDao,验证insert()方法被调用了。但这只能证明“代码路径走通了”,不能证明“数据真的写进了/data/data/com.example.memo/databases/memo_database”。SQLite 是系统级组件,涉及文件 I/O、事务、锁机制,只有在真实设备或模拟器上运行的 Instrumented Test 才能覆盖。AndroidX Test 提供了TestDatabase工厂,让我们在测试中创建一个独立的、不污染主数据库的测试库:// app/src/androidTest/java/com/example/memo/NoteDaoTest.java @RunWith(AndroidJUnit4.class) public class NoteDaoTest { private AppDatabase database; private NoteDao noteDao; @Before public void createDb() { Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); database = Room.inMemoryDatabaseBuilder(context, AppDatabase.class) .allowMainThreadQueries() // 测试中允许主线程查询,简化写法 .build(); noteDao = database.noteDao(); } @After public void closeDb() { database.close(); } @Test public void insertAndGetNote() throws Exception { // Given Note note = new Note(); note.title = "Test Title"; note.content = "Test Content"; note.createdAt = System.currentTimeMillis(); note.updatedAt = note.createdAt; // When long id = noteDao.insert(note); Note retrieved = noteDao.getNoteById(id); // Then assertNotNull(retrieved); assertEquals("Test Title", retrieved.title); assertEquals("Test Content", retrieved.content); } }6.2 关键参数说明与执行命令Room.inMemoryDatabaseBuilder(...)创建的是内存数据库,进程退出即销毁,完全隔离;.allowMainThreadQueries()是测试专用开关,生产代码严禁开启;InstrumentationRegistry.getInstrumentation().getTargetContext()提供的是被测 App 的上下文,能访问其assets和databases目录;运行命令:在终端执行./gradlew connectedAndroidTest,或在 Android Studio 的Build Variants面板中将testBuildType切为debug,右键测试类 →Run 'NoteDaoTest'。从那以后我每次重构NoteDao的@Query语句,或者新增一个@Insert方法,都会先补一个对应的 Instrumented Test。不是为了覆盖率数字好看,而是当insertAndGetNote()绿了,我知道这行 SQL 在真实 SQLite 引擎里跑通了——它不会在用户手机上因为某个未声明的NOT NULL约束而静默失败。这种确定性,是深夜收到崩溃报告时,我能睡着的唯一后悔药。希望帮到你。本文还有配套的精品资源,点击获取