最近在做一个本地记账小demo需求很简单记一笔、列出来、能搜索数据本地存。我一开始偷懒直接用老一套的SQLiteOpenHelper加Adapter.notifyDataSetChanged结果数据一多、界面一转代码就开始失控了。尤其是屏幕旋转后Activity重建每次都得重新查数据库查完还要手动关游标新增一条数据回去之后界面也不一定刷新线程一乱甚至直接崩。后来实在忍不了决定把这套Android开发的Jetpack组件组合——Room数据库、ViewModel、LiveData、DataBinding、Lifecycle——一次性全部引入。因为项目用的是Java而不是Kotlin网上很多示例都是Kotlin写的换成Java之后各种编译细节和坑完全不一样所以我把这次的完整实现过程、踩坑点、以及为什么这样设计的原因都记录下来。这篇文章适合谁看刚接触Jetpack、想用Java写Room数据层的朋友或者已经用SQLiteOpenHelper但被维护成本折磨的人。我会从工程配置讲到数据层再讲到界面层和生命周期最后集中讲几个我实际跑起来之后遇到的坑。代码都是Java可以直接抄作业。1. 项目背景为什么我非要把这五个组件拼在一起1.1 一开始用SQLiteOpenHelper的混乱现场很多人学Android数据库都是从SQLiteOpenHelper开始的我也不例外。但真的写业务功能时问题就暴露了你需要自己管理SQLiteOpenHelper的单例、自己写ContentValues、手动拼SQL查询、手动把Cursor里的数据转成List还要时刻记得关闭Cursor。听起来都能做但代码会越积越脏。尤其是多个页面都要读同一个表的时候每个页面复制粘贴一段差不多的查询代码改个字段名要全局搜索漏掉一个地方就直接出bug。我的项目还只是本地记账表结构和查询都很简单就已经这样了很难想象一个复杂业务系统用原生SQLite写会有多痛苦。另一个让我崩溃的点是线程。SQLite数据库操作不能直接放主线程我一开始用AsyncTask后来换成ThreadPool但任务一多界面刷新时机就变得无法控制。比如用户点保存我先插库再查询列表然后在主线程更新Adapter这套流程只要中间忘了切线程立马崩。1.2 屏幕旋转暴露出的生命周期问题项目做到一半我发现一个很典型的场景用户正在编辑一条账目旋转手机屏幕Activity直接重建所有输入内容全没了。如果保存到数据库的时机不对甚至会出现重复插入。这不仅是UI状态的问题还牵涉到一个很根本的点界面的数据不应该绑定在Activity的生命周期上。Activity重建了但数据本身还在为什么非要重新查数据库如果有一份数据能独立于Activity存在重建后可以继续用那体验就会好很多。ViewModel就是干这个用的。1.3 五个组件各自解决什么先建立整体认知在动手写代码之前我建议先把这五个东西的角色理清楚。我第一次学的时候就很容易混淆尤其是LiveData和DataBinding感觉都能“更新界面”但位置完全不一样。组件要解决的问题我的理解RoomSQLite样板代码太多、容易出错用注解定义表结构和SQL编译期帮你生成实现相当于数据库层的ORM框架ViewModelActivity重建时页面数据丢失数据存放在ViewModel里配置变更时ViewModel实例不会销毁重建后直接复用LiveData数据变化后界面不知道什么时候刷新一个可观察的数据容器界面处于活跃状态时才推送更新DataBindingfindViewById和setText代码太啰嗦在布局XML里直接绑定变量和事件减少Java代码里的控件操作Lifecycle生命周期管理分散在各回调里LiveData和ViewModel底层都依赖它让组件能感知Activity/Fragment当前状态把它们串起来一条完整链路就是数据库的表字段变化后Room自动把新的查询结果塞给LiveDataLiveData观察到了变化在界面处于活跃状态时通知观察者观察者拿到数据后更新UI而ViewModel负责持有LiveData和业务逻辑让数据跨过配置变更存活DataBinding负责把ViewModel里的状态和用户输入事件直接对应到布局上。2. 工程配置Java项目里让Room和DataBinding共存的前置条件2.1 build.gradle依赖配置Java用annotationProcessor而不是kapt如果你用的是KotlinRoom的编译器通常用kapt或ksp。但Java项目里一定不要跟风写kapt用annotationProcessor就够了。我一开始照着Kotlin的教程改在Java模块里也配置kapt结果build半天报错告诉我kapt插件没应用。我最终的依赖配置如下建议Room版本和Lifecycle版本保持一致避免兼容性问题。android { // AGP 7.0以上启用DataBinding用buildFeatures buildFeatures { dataBinding true } // 如果项目还在用旧版写法也可以用: // dataBinding { enabled true } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } } dependencies { def room_version 2.6.1 def lifecycle_version 2.6.1 implementation androidx.room:room-runtime:$room_version annotationProcessor androidx.room:room-compiler:$room_version implementation androidx.lifecycle:lifecycle-viewmodel:$lifecycle_version implementation androidx.lifecycle:lifecycle-livedata:$lifecycle_version implementation androidx.lifecycle:lifecycle-runtime:$lifecycle_version }这里有个坑很隐蔽如果你在dependencies里忘了加annotationProcessor androidx.room:room-compiler:...项目是能正常编译的但你写的Dao接口不会生成实现类运行期直接崩在数据库.getNoteDao()这一步报错极其抽象。2.2 room.schemaLocation尽早配置避免警告和团队协作问题Room默认会往控制台打印一行警告大意是“Schema export directory is not provided”。第一次用的人总觉得这是无关紧要的警告其实它会影响后续的数据库版本升级。Room在编译时会把数据库结构导出成JSON文件专门用来记录表结构变化。如果不配置导出目录以后做迁移时没有JSON参考不太好判断当前用户手里的表结构到底长什么样。我在项目里做了如下配置android { defaultConfig { javaCompileOptions { annotationProcessorOptions { arguments [room.schemaLocation: $projectDir/schemas.toString()] } } } }配置之后工程目录下会生成一个schema文件夹里面是按版本号命名的JSON文件。这个目录建议提交到版本控制里团队其他人拉下来后数据库迁移时能直接对比结构变化。2.3 Java 8与Lambda的兼容处理Room 2.x要求Java 8及以上Android本身从AGP 3.0开始也默认支持Java 8语言特性。不过实际写代码时要注意一个问题有些老项目minSdkVersion很低或者还没开启desugaring直接写() -这种lambda在低版本设备上可能崩溃。我这次的项目minSdkVersion是23用lambda问题不大。但如果你倾向于稳妥完全可以在Java里写匿名内部类比如ObserverListNote。我会在后面的代码里两种都展示一下方便对应自己的项目情况。3. 数据层落地Entity、DAO和Database的Java实现3.1 实体类的编写规范无参构造函数、主键策略Room里的实体类就是普通的POJO用Entity注解标记表名字段默认映射成列。我第一次写的时候漏了无参构造函数编译期没报错运行期Room却疯狂提示无法实例化实体类。原因很简单Room框架需要通过无参构造函数反射创建对象如果你的实体类自己定义了带参构造一定还要补一个空的构造函数。我设计了一个Note实体代表一条记账记录package com.example.roomdemo.model; import androidx.annotation.NonNull; import androidx.room.Entity; import androidx.room.PrimaryKey; Entity(tableName note) public class Note { PrimaryKey(autoGenerate true) private int id; private String title; private String content; private long createTime; public Note() { } public Note(String title, String content, long createTime) { this.title title; this.content content; this.createTime createTime; } public int getId() { return id; } public void setId(int id) { this.id id; } public String getTitle() { return title; } public void setTitle(String title) { this.title title; } public String getContent() { return content; } public void setContent(String content) { this.content content; } public long getCreateTime() { return createTime; } public void setCreateTime(long createTime) { this.createTime createTime; } }关于主键我用了autoGenerate true的自增整型。这里有个细节插入成功后怎么拿到新增记录的id如果DAO的insert方法返回long这个long就是新插入行的rowid也就是id不需要再开一次查询去取。这个回到Java里比Kotlin更直观因为Kotlin的Long和Java的long在泛型层面偶尔还会有点绕。3.2 DAO接口LiveData作为返回值的写法与真正意义DAO是Room里最核心的接口不需要写实现类Room会在编译期帮你生成NoteDao_Impl。我在接口里定义了一个返回LiveDataListNote的方法这个方法会拿到数据库记录列表并且在表数据变化时自动重新查询、自动推送新列表。package com.example.roomdemo.data; import androidx.lifecycle.LiveData; import androidx.room.Dao; import androidx.room.Delete; import androidx.room.Insert; import androidx.room.Query; import androidx.room.Update; import com.example.roomdemo.model.Note; import java.util.List; Dao public interface NoteDao { Insert long insert(Note note); Update int update(Note note); Delete int delete(Note note); Query(SELECT * FROM note ORDER BY createTime DESC) LiveDataListNote observeAllNotes(); Query(SELECT * FROM note WHERE title LIKE % || :keyword || % OR content LIKE % || :keyword || % ORDER BY createTime DESC) LiveDataListNote searchNotes(String keyword); }为什么返回值要用LiveDataListNote而不是ListNote如果你在DAO里直接返回ListNoteRoom只会在调用方法的那个时刻执行一次查询之后数据变了你完全不知道。而返回LiveDataListNote后Room会把查询操作放到后台线程执行同时监听这张表的数据变化表一有更新就重新跑一遍查询然后用新的结果通知Observer。也就是说LiveData帮你把“主动查数据库”变成了“被动等通知”。有个概念要记住Room的LiveData是粘性的观察者添加后会立刻收到当前数据库里的最新数据而不是等下一次表变化才通知。这一点后面排障时会提到它既是便利也是坑。3.3 数据库单例与线程模型的约定数据库本身是重量级对象创建成本很高而且一个App里通常只需要一个实例。官方推荐用单例模式保存它。我用双重检查锁写了一个AppDatabasepackage com.example.roomdemo.data; import android.content.Context; import androidx.room.Database; import androidx.room.Room; import androidx.room.RoomDatabase; import com.example.roomdemo.model.Note; Database(entities {Note.class}, version 1, exportSchema true) public abstract class AppDatabase extends RoomDatabase { private static volatile AppDatabase INSTANCE; public abstract NoteDao noteDao(); public static AppDatabase getInstance(Context context) { if (INSTANCE null) { synchronized (AppDatabase.class) { if (INSTANCE null) { INSTANCE Room.databaseBuilder( context.getApplicationContext(), AppDatabase.class, note_demo.db) .build(); } } } return INSTANCE; } }Room.databaseBuilder的第一个参数我传的是context.getApplicationContext()这个细节很重要。因为单例是全局持有的如果传了Activity的Context那Activity销毁后这个数据库对象还持有它的引用会导致内存泄漏。传ApplicationContext就不会有问题。关于.fallbackToDestructiveMigration()我在Demo里先没加因为还没有做版本迁移。如果你在开发早期乱改表结构版本号又没变可能会遇到崩溃提示数据库版本冲突这时候要么删掉App重装要么加一个fallbackToDestructiveMigration()让Room在检测到版本不匹配时直接清空重建表。注意这是破坏性操作生产环境绝对不要用。4. 异步更新与生命周期Repository、ViewModel和LiveData联调4.1 Repository仓库层把线程和DAO隔离很多初学Room的人直接在ViewModel里调DAO这其实也能跑通。但项目稍微变大一点ViewModel里就会塞进各种业务逻辑和线程调度代码就越写越乱。我这次加了一个NoteRepository把DAO操作收拢起来ViewModel只和Repository打交道。这个分层的好处是以后如果要给数据加缓存、加网络同步只需要改Repository内部实现ViewModel不需要动。package com.example.roomdemo.data; import android.content.Context; import androidx.lifecycle.LiveData; import com.example.roomdemo.model.Note; import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class NoteRepository { private final NoteDao noteDao; private final ExecutorService ioExecutor; public NoteRepository(Context context) { noteDao AppDatabase.getInstance(context).noteDao(); ioExecutor Executors.newSingleThreadExecutor(); } public LiveDataListNote observeAllNotes() { return noteDao.observeAllNotes(); } public LiveDataListNote searchNotes(String keyword) { return noteDao.searchNotes(keyword); } public void insert(final Note note) { ioExecutor.execute(new Runnable() { Override public void run() { noteDao.insert(note); } }); } public void update(final Note note) { ioExecutor.execute(new Runnable() { Override public void run() { noteDao.update(note); } }); } public void delete(final Note note) { ioExecutor.execute(new Runnable() { Override public void run() { noteDao.delete(note); } }); } }我特意用了一个简单的单线程ExecutorService来做写操作。Room本身不允许在主线程访问数据库写操作如果不放到后台线程运行期会直接抛IllegalStateException。需要注意的是读取操作因为返回的是LiveDataRoom会自己切后台线程所以不需要我们额外处理。4.2 ViewModel的两种写法AndroidViewModel和ViewModelProvider.FactoryViewModel的核心作用是持有界面状态并在Activity重建时保持存活。最常见写法是继承ViewModel但它拿不到Context也就没法初始化Repository。我这次为了省事直接继承AndroidViewModel因为它构造方法能拿到Application再用它创建Repository。package com.example.roomdemo.ui; import android.app.Application; import androidx.annotation.NonNull; import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.LiveData; import com.example.roomdemo.data.NoteRepository; import com.example.roomdemo.model.Note; import java.util.List; public class NoteViewModel extends AndroidViewModel { private final NoteRepository repository; private final LiveDataListNote allNotes; public NoteViewModel(NonNull Application application) { super(application); repository new NoteRepository(application); allNotes repository.observeAllNotes(); } public LiveDataListNote getAllNotes() { return allNotes; } public void insert(String title, String content) { Note note new Note(title, content, System.currentTimeMillis()); repository.insert(note); } public void delete(Note note) { repository.delete(note); } }如果你不想让ViewModel直接依赖Application可以用ViewModelProvider.Factory来传入Repository实例。Java写法比Kotlin啰嗦不少但逻辑更清楚。我这里没有用Factory因为初体验阶段用AndroidViewModel最省心也不容易写错。界面里通过ViewModelProvider获取实例保证同一个Activity在配置变更后拿到的是同一个ViewModel对象。这里需要特别说明一个点ViewModel的生命周期并不是无限长的。它是在Activity的onDestroy被调用、且不是配置变更导致的销毁时才真正清掉的。也就是说旋转屏幕时Activity销毁重建ViewModel不会销毁但用户按返回键彻底退出时ViewModel就会跟着清理。4.3 LiveData在Java中的订阅生命周期细节与粘性特性有了ViewModel之后Activity里第一件事就是获取ViewModel实例然后观察LiveData。Java中观察LiveData的代码如下viewModel.getAllNotes().observe(this, new ObserverListNote() { Override public void onChanged(ListNote notes) { adapter.submitList(notes); } });也有人喜欢用Java 8 lambda写notes - adapter.submitList(notes)代码确实简洁但要注意minSdkVersion和desugaring配置。我给老项目折腾过一次lambda导致的诡异崩溃后来在低版本设备上还是换回匿名内部类最稳。observe的第一个参数是this也就是Activity本身。Activity实现了LifecycleOwner接口LiveData会根据它的生命周期状态决定是否派发数据Activity处于STARTED或RESUMED状态时LiveData才会推送更新。Activity不可见时LiveData不会浪费资源更新UI。Activity销毁时LiveData自动解除观察避免内存泄漏。这就是Lifecycle在整套机制中的隐藏作用。你没有直接写过Lifecycle代码但它一直默默工作。如果你想看它到底是怎么运行的可以在MainActivity里加一个Lifecycle观察器getLifecycle().addObserver(new LifecycleEventObserver() { Override public void onStateChanged(NonNull LifecycleOwner source, NonNull Lifecycle.Event event) { Log.d(LifecycleDemo, event: event); } });运行后你会发现Activity从创建到销毁的各个事件都会按顺序打出来。LiveData内部也是通过这种方式感知当前状态的。LiveData还有个特性我前面提过就是粘性。当Observer第一次添加时会立刻收到当前最新数据不用等数据库变化。这个特性在列表展示场景很好用因为进页面就需要显示已有数据但如果你用LiveData去发一次性事件比如弹Toast粘性特性会造成每次观察都重新弹一次这是新手很容易踩的坑。5. DataBinding把ViewModel挂到界面上MVVM最后一公里的实操5.1 启用DataBinding并改造布局文件启用DataBinding我在第二节已经写了配置。启用后布局文件的根节点要改成layout内部包含data和原来的视图结构。我的界面很简单上面两个输入框和一个保存按钮下面一个RecyclerView。布局里定义了一个变量viewModel类型是com.example.roomdemo.ui.NoteViewModel。注意这里要用完整的包名路径Android Studio的自动补全不一定可靠我吃过好几次亏写完变量后编译找不到类。?xml version1.0 encodingutf-8? layout xmlns:androidhttp://schemas.android.com/apk/res/android xmlns:apphttp://schemas.android.com/apk/res-auto data variable nameviewModel typecom.example.roomdemo.ui.NoteViewModel / /data LinearLayout android:layout_widthmatch_parent android:layout_heightmatch_parent android:orientationvertical android:padding16dp EditText android:idid/et_title android:layout_widthmatch_parent android:layout_heightwrap_content android:hint标题 android:text{viewModel.noteTitle} / EditText android:idid/et_content android:layout_widthmatch_parent android:layout_heightwrap_content android:hint内容 android:text{viewModel.noteContent} / Button android:layout_widthmatch_parent android:layout_heightwrap_content android:onClick{() - viewModel.save()} android:text保存 / androidx.recyclerview.widget.RecyclerView android:idid/rv_notes android:layout_widthmatch_parent android:layout_height0dp android:layout_weight1 / /LinearLayout /layout这里的双向绑定语法是{viewModel.noteTitle}等号表示双向绑定。用户在EditText里输入内容时ViewModel里的ObservableField会同步更新反过来如果代码里给ObservableField赋了新值EditText的显示内容也会跟着变。保存成功后清空输入框就是靠这个反向更新做到的。android:onClick{() - viewModel.save()}这种写法是DataBinding对事件绑定的封装比在Java里写setOnClickListener干净很多。不过要注意绑定的方法必须是public且不能是private否则编译期能过运行期点击时会因为反射调用失败而崩。5.2 ViewModel里的ObservableField用DataBinding就不能只靠普通字段DataBinding能自动感知的变量不是普通Java字段而是继承了BaseObservable的类或者在类里使用ObservableField。我在NoteViewModel里加了两个ObservableField分别对应标题和内容的输入框public final ObservableFieldString noteTitle new ObservableField(); public final ObservableFieldString noteContent new ObservableField(); public void save() { String title noteTitle.get() null ? : noteTitle.get(); String content noteContent.get() null ? : noteContent.get(); insert(title, content); noteTitle.set(); noteContent.set(); }ObservableField.get()和set()就是普通的getter/setter重点是set()之后会主动通知DataBinding刷新UI。如果你只用普通String字段输入内容不会同步DataBinding也不会感知变化。这是DataBinding新手最容易犯的错误。5.3 Activity里的绑定逻辑DataBindingUtil与setLifecycleOwnerActivity里不再用setContentView而是用DataBindingUtil.setContentView生成绑定对象。绑定对象会生成一个ActivityMainBinding类这是DataBinding根据布局文件名自动生成的命名规则就是把下划线转成驼峰再加Binding后缀。package com.example.roomdemo.ui; import android.os.Bundle; import androidx.appcompat.app.AppCompatActivity; import androidx.lifecycle.ViewModelProvider; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.ListAdapter; import com.example.roomdemo.R; import com.example.roomdemo.databinding.ActivityMainBinding; import com.example.roomdemo.model.Note; public class MainActivity extends AppCompatActivity { private ActivityMainBinding binding; private NoteViewModel viewModel; private NoteListAdapter adapter; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // 1. DataBinding绑定布局 binding DataBindingUtil.setContentView(this, R.layout.activity_main); // 2. 创建ViewModel viewModel new ViewModelProvider(this) .get(NoteViewModel.class); // 3. 把ViewModel和LifecycleOwner交给DataBinding binding.setViewModel(viewModel); binding.setLifecycleOwner(this); // 4. 初始化RecyclerView adapter new NoteListAdapter(); binding.rvNotes.setLayoutManager(new LinearLayoutManager(this)); binding.rvNotes.setAdapter(adapter); // 5. 观察LiveData数据变化后自动刷新列表 viewModel.getAllNotes().observe(this, notes - adapter.submitList(notes)); } }这里有个很容易忽略的关键点binding.setLifecycleOwner(this);必须写。如果你忘了这一句DataBinding里涉及LiveData或生命周期感知的表达式不会正常工作。因为我们这个布局里的绑定变量是ObservableField可能暂时不触发这个坑但一旦你在布局里直接使用LiveData类型的变量或者用了需要生命周期判断的绑定表达式不设置LifecycleOwner会出各种奇怪的更新问题。5.4 RecyclerView的适配器简单方案和正规方案RecyclerView适配器有很多种写法。我第一次为了快速跑通直接继承RecyclerView.Adapter在onChanged里调用adapter.notifyDataSetChanged()。这种方案能跑但不是最佳做法因为每次列表全量刷新会丢失RecyclerView的动画效果数据量大了还有性能问题。后来我换成了ListAdapter加DiffUtil。ListAdapter是RecyclerView适配器的进阶版它会用DiffUtil对比新旧数据只更新变化的条目自带增删移动动画。关键代码如下public class NoteListAdapter extends ListAdapterNote, NoteListAdapter.NoteViewHolder { public NoteListAdapter() { super(new DiffUtil.ItemCallbackNote() { Override public boolean areItemsTheSame(NonNull Note oldItem, NonNull Note newItem) { return oldItem.getId() newItem.getId(); } Override public boolean areContentsTheSame(NonNull Note oldItem, NonNull Note newItem) { return oldItem.getTitle().equals(newItem.getTitle()) oldItem.getContent().equals(newItem.getContent()) oldItem.getCreateTime() newItem.getCreateTime(); } }); } // onCreateViewHolder、onBindViewHolder在这里实现 // ... }areItemsTheSame判断两个Item是不是同一条记录通常用主键id判断areContentsTheSame判断同一条记录的内容是否发生了变化。列表更新时ListAdapter内部会先在后台线程计算差异再在主线程更新UI避免了无谓的全量刷新。我在实际测试中观察到一个现象LiveData粘性特性会把数据库中已有的全部数据发给新建的适配器如果此时适配器里已有同样的数据且DiffUtil的实现是正确的界面会保持不变不会闪烁。如果直接用notifyDataSetChanged每次旋转屏幕都会看到列表重新加载一遍体验差很多。6. 真机实测与常见问题排障从编译不过到数据不刷新6.1 编译期报错Schema export directory is not provided这个警告几乎是第一次用Room的人都会遇到的。控制台会提示类似warning: Schema export directory is not provided to the annotation processor so we cannot export the schema.如果你不关心数据库迁移确实可以忽略。但只要你后续准备升级数据库版本导出schema文件非常有必要。解决办法就是在build.gradle的defaultConfig里加annotationProcessorOptions把schemaLocation指到工程目录下的schema文件夹defaultConfig { javaCompileOptions { annotationProcessorOptions { arguments [room.schemaLocation: $projectDir/schemas.toString()] } } }配置好后编译一次工程目录下就会生成schema/com.example.roomdemo.data.AppDatabase/1.json这种结构的文件。以后数据库版本从1升级到2时Room可以用它对比结构变化自动生成迁移代码你不必自己去数有哪些列变了。6.2 运行期崩溃Room不能在主线程访问数据库我刚开始写Repository时偷懒直接在Activity点击事件里调用noteDao.insert(note)结果一运行就崩溃错误信息是java.lang.IllegalStateException: Cannot access database on the main thread since it may potentially lock the UI for a long period of time.这是因为Room默认禁止在主线程执行数据库操作防止界面卡死。解决办法有两个第一把所有写操作放到后台线程这也是我最推荐的方式也就是在Repository里用ExecutorService执行DAO调用。我们在上面的代码里就是这么做的。第二使用Room.databaseBuilder(...).allowMainThreadQueries()。这个方法是专门给Debug或测试用的生产环境不建议用因为一旦数据库查询慢一点主线程就会卡住用户看到的画面直接掉帧甚至ANR。6.3 LiveData和DataBinding不刷新的几类原因这几个问题我在调试时都撞见过表现起来都是“数据确实插进去了但界面死活不变”。第一个原因是观察者没有绑定LifecycleOwner。如果你在observe时传入的是this并且Activity实现了LifecycleOwner那没问题。但如果你为了省事传了个null或者传入了自定义的LifecycleOwner且其状态一直不是活跃的LiveData就不会推送数据。第二个原因是DataBinding没有设置binding.setLifecycleOwner(this)。这个我在前面强调过了尤其是在布局里直接用LiveData的时候少了这行绑定表达式里的LiveData不会自动观察。第三个原因是使用了普通字段而不是ObservableField。如果你在ViewModel里定义的是public String noteTitle;然后在Activity里修改这个字段DataBinding是不会知道的因为普通字段没有任何通知机制。换成ObservableField或者继承BaseObservable并调用notifyChange()数据才会自动同步到UI。第四个原因是数据库插入的数据本身没变。LiveData不是每次都通知它是基于精确比较数据内容来决定的吗其实是Room在表被修改后重新查询然后通过LiveData的setValue推送。如果你的表数据没有实际变化LiveData自然不会触发onChanged。这个不算坑但新手容易误以为是自己代码错了。6.4 Room迁移与破坏性重建的取舍数据库版本升级是绕不开的话题。开发阶段最省事的方式是加fallbackToDestructiveMigration()版本不一致时直接清空所有表然后重建。但如果你已经发布到市场用户手里的数据不可能因为升级而白丢。正规做法是定义迁移对象static final Migration MIGRATION_1_2 new Migration(1, 2) { Override public void migrate(NonNull SupportSQLiteDatabase database) { database.execSQL(ALTER TABLE note ADD COLUMN category TEXT DEFAULT ); } };然后注册到Room.databaseBuilder里Room.databaseBuilder(context, AppDatabase.class, note_demo.db) .addMigrations(MIGRATION_1_2) .build();迁移不是简单地把数据库删掉重建而是通过SQL语句在保留已有数据的前提下改表结构。开发体验和线上体验是两回事fallbackToDestructiveMigration只能用来节省开发时间正式版本请老老实实写迁移。6.5 不要忽略IDE和辅助工具带来的假报错做这个项目时我用的开发环境里还装了AI代码补全类工具。有一阵子我总会看到一个特别奇怪的报错大概内容是error running remote compact task: codex ran out of room in the models context之类光看文字像是编译器的错误实际上和我的项目代码一点关系都没有。这类报错多数是代码辅助工具的上下文窗口被塞满了或者远端任务执行中断了。遇到这种情况第一反应应该是先检查工具状态而不是从头查自己的代码。我当时花了一晚上排查依赖冲突最后发现只是辅助工具的提示删除对应的请求记录之后就恢复了。做开发要分清哪些是真实错误哪些是环境噪音不然特别浪费时间。6.6 最终项目结构和我的一点体会跑通整个流程之后我回头看自己的项目结构已经比最初用SQLiteOpenHelper时清晰了很多。最终的项目分层大概是com.example.roomdemo ├── data │ ├── AppDatabase.java // 数据库单例 │ ├── NoteDao.java // DAO接口 │ └── NoteRepository.java // 仓库层隔离线程和DAO ├── model │ └── Note.java // 实体类 └── ui ├── MainActivity.java // 负责绑定和观察 ├── NoteListAdapter.java // RecyclerView适配器 └── NoteViewModel.java // ViewModel持有LiveData和业务逻辑这套结构的好处是每个类只有一个职责实体类只管字段映射DAO只管SQLRepository管线程调度ViewModel管界面状态和数据暴露Activity管绑定和导航。我后来再新增一个“账本分类”功能时基本上只改了实体类、DAO和RepositoryActivity和ViewModel几乎没有动这就是分层带来的直接收益。再回到最初让我抓狂的旋转屏幕问题现在Activity重建后ViewModel还在LiveData还会把最后一次查询结果再次推送给新重建的界面用户输入的内容通过DataBinding双向绑定也保留在ObservableField里。整套组合看起来像是在写各自的代码但都在默契地配合Room负责在数据变化时主动通知LiveDataLiveData负责在界面活跃时推送更新ViewModel负责跨配置变更存活DataBinding负责减少控件的机械操作Lifecycle则在背后控制所有更新的时机。如果让我给刚接触这套组合的人一个建议那就是不要试图一步到位理解所有原理先照着这个Java版本跑通一个增删改查的小项目然后把数据插入、数据库升级、Observer触发这几个关键点逐个做实验等代码跑起来再回头研究为什么LiveData不粘性、ViewModel何时销毁很多概念会在动手过程中自然清晰。
企业数字化 ERP 产品动态
相关推荐
node-sass安装报错排查与迁移Dart Sass完整指南 如果你正在读这篇文章,大概率屏幕上还挂着这样一行让人血压升高的红色报错:gyp ERR! stack Error: \gyp failed with exit code: 1,或者是Downloading binary from https://github.com/sass/node-sass/releases/download 卡了很久然后 Timeou… · 2026/9/26 3:32:51
开源界震撼消息:Baichuan-M2 配 TaoToken,挑战 GPT-5 地位! /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 3:32:44
Python打卡第26天 浙大疏锦行 001 002 003 004 005 006 007 008 009 010 011 012 013 014 015 016 017 018 019 020 021 022 023 024 025 026 027 028 029 030 031 032 033 034 035 036 037 038 039 040 041 042 043 044 045 046 047 048 049 050 051 052 053 054 055 056 057 058 059 060 061 0… · 2026/9/26 4:19:49
Ubuntu下载 Ubuntu操作系统安装与配置
目录
一、Ubuntu安装过程 1、下载Ubuntu映像文件2、制作Ubuntu安装盘3、关闭BitLocker4、压缩Windows分区5、BIOS设置6、安装Ubuntu系统 二、软件资源配置三、问题及解决
前言
本篇博客记录我安装Ubuntu 22.04.5 LTS 双系统的完整过程,… · 2026/9/26 4:19:49
周五高峰流量大考与全链路压测复盘:每秒百单零丢单 周五高峰流量大考与全链路压测复盘:每秒百单零丢单今天是 9 月 25 日(周五),周报生成器迎来了商业化全量上线后的第一个“周五终极流量洪峰大考”。
在很多 SaaS 平台的发展史上,周五下午 16:00 ~ 18:30 永远是系统崩溃… · 2026/9/26 4:19:49
输入“cc”两个字母快速打开ClaudeCode:TaoToken 统一 Key 配置与别名验证 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 4:19:43
Codex和ChatGPT在图像生成能力上有什么区别? Codex 加上图像生成以后,这两个东西确实越来越容易让人搞混。因为表面上看,现在都是输入一句话,然后让 AI 给你生成图片,甚至已有图片也都可以继续改。OpenAI 目前的官方说明里也明确写了,ChatGPT 可以创建、编辑图片&… · 2026/9/26 4:19:43
微信小程序人脸核身实战:腾讯云慧眼增强版对接流程与避坑指南 上周接了一个实名核身的小程序项目,需求方要求“用户必须在当前设备上完成活体检测”,不能被一张身份证照片糊弄过去。我第一反应是直接用微信原生的人脸识别能力,但仔细评估后发现,原生能力只能验证“你是不是真人”,… · 2026/9/26 4:19:31
数据库课后习题答案别硬背:当测试用例集刷,效率翻倍 简介:万常选版《数据库原理与设计》课后习题答案资源,覆盖第2至6章及第9章,适合正在学习关系模型、数据库建模、关系数据理论与模式求精的本科生、自学者作为复习与自测材料。压缩包共7个文件,含3个doc参考答案、2个sql示例脚本、… · 2026/9/26 0:00:21
OpenClaw 替代品?Hermes Agent 踩坑实录:macOS 飞书接入 TaoToken 配置 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 0:00:40
向下兼容与向上兼容:接口设计中的兼容性策略与工程实践 一次版本升级事故,是很多团队绕不过去的坎。线上环境里,服务端明明已经上线了新版接口,老的移动端还在照着旧文档传参数。请求一到网关,校验直接拒绝,用户操作失败,客服群炸了锅,开发群里开始互… · 2026/9/26 0:00:46