Android启动时LiveData与NavigationView关联的索引越界异常问题
我开发了一款带有NavigationView的应用,其中包含一个使用Google一键登录流程的登录/注销菜单项:
<item android:id="@+id/NavigationLogInOut" android:icon="@drawable/material_login" android:orderInCategory="95" android:title="@string/button_log_in" />
我使用MutableLiveData控制该菜单项的启用/禁用状态,以便在登录过程中临时禁用它:
public final MutableLiveData<Boolean> logInOutEnabled = new MutableLiveData<>(false);
在Activity的OnCreate方法中添加了如下观察者:
logInOutEnabled.observe( this, (enabled) -> { application.logger.d( "Log In/Out Enabled: " + enabled ); logInOutMenuItem.setEnabled(enabled); } );
应用启动时,Logcat中显示调试信息:
08:34:29.350 13869-13869 D Log In/Out Enabled: false
此时不会崩溃。
OnCreate方法最后会检查用户是否有缓存凭据,若有则更新LiveData:
if (!isLoggedIn()) { // start the login flow here } else { activity.application.logInOutEnabled.postValue(true); }
Logcat中会立即显示:
08:34:29.664 13869-13869 D Log In/Out Enabled: true
但之后约50%的概率应用会崩溃,堆栈信息如下:
08:34:29.722 13869-13869 E FATAL EXCEPTION: main Process: com.company.game, PID: 13869 java.lang.IndexOutOfBoundsException: Inconsistency detected. Invalid item position 6(offset:6).state:7 com.google.android.material.internal.NavigationMenuView{d4afb4b VFED.V... ......ID 0,0-640,1632 #7f0900d2 app:id/design_navigation_view}, adapter:com.google.android.material.internal.NavigationMenuPresenter$NavigationMenuAdapter@e3e4641, layout:androidx.recyclerview.widget.LinearLayoutManager@131e7e6, context:com.company.game.GameActivity@9fccb96 at androidx.recyclerview.widget.RecyclerView$Recycler.tryGetViewHolderForPositionByDeadline(RecyclerView.java:6183) at androidx.recyclerview.widget.RecyclerView$Recycler.getViewForPosition(RecyclerView.java:6118) at androidx.recyclerview.widget.RecyclerView$Recycler.getViewForPosition(RecyclerView.java:6114) at androidx.recyclerview.widget.LinearLayoutManager$LayoutState.next(LinearLayoutManager.java:2303) at androidx.recyclerview.widget.LinearLayoutManager.layoutChunk(LinearLayoutManager.java:1627) at androidx.recyclerview.widget.LinearLayoutManager.fill(LinearLayoutManager.java:1587) at androidx.recyclerview.widget.LinearLayoutManager.onLayoutChildren(LinearLayoutManager.java:665) at androidx.recyclerview.widget.RecyclerView.dispatchLayoutStep2(RecyclerView.java:4134) at androidx.recyclerview.widget.RecyclerView.dispatchLayout(RecyclerView.java:3851) at androidx.recyclerview.widget.RecyclerView.onLayout(RecyclerView.java:4404) at android.view.View.layout(View.java:23693) at android.view.ViewGroup.layout(ViewGroup.java:6413) at android.widget.FrameLayout.layoutChildren(FrameLayout.java:332) at android.widget.FrameLayout.onLayout(FrameLayout.java:270) at android.view.View.layout(View.java:23693) at android.view.ViewGroup.layout(ViewGroup.java:6413) at androidx.drawerlayout.widget.DrawerLayout.onLayout(DrawerLayout.java:1287) at android.view.View.layout(View.java:23693) at android.view.ViewGroup.layout(ViewGroup.java:6413) at androidx.constraintlayout.widget.ConstraintLayout.onLayout(ConstraintLayout.java:1873) at android.view.View.layout(View.java:23693) at android.view.ViewGroup.layout(ViewGroup.java:6413) at android.widget.FrameLayout.layoutChildren(FrameLayout.java:332) at android.widget.FrameLayout.onLayout(FrameLayout.java:270) at android.view.View.layout(View.java:23693) at android.view.ViewGroup.layout(ViewGroup.java:6413) at android.widget.LinearLayout.setChildFrame(LinearLayout.java:1891) at android.widget.LinearLayout.layoutVertical(LinearLayout.java:1729) at android.widget.LinearLayout.onLayout(LinearLayout.java:1638) at android.view.View.layout(View.java:23693) at android.view.ViewGroup.layout(ViewGroup.java:6413) at android.widget.FrameLayout.layoutChildren(FrameLayout.java:332) at android.widget.FrameLayout.onLayout(FrameLayout.java:270) at android.view.View.layout(View.java:23693) at android.view.ViewGroup.layout(ViewGroup.java:6413) at android.widget.LinearLayout.setChildFrame(LinearLayout.java:1891) at android.widget.LinearLayout.layoutVertical(LinearLayout.java:1729) at android.widget.LinearLayout.onLayout(LinearLayout.java:1638) at android.view.View.layout(View.java:23693) at android.view.ViewGroup.layout(ViewGroup.java:6413) at android.widget.FrameLayout.layoutChildren(FrameLayout.java:332) at android.widget.FrameLayout.onLayout(FrameLayout.java:270) at com.android.internal.policy.DecorView.onLayout(DecorView.java:799) at android.view.View.layout(View.java:23693) at android.view.ViewGroup.layout(ViewGroup.java:6413) at android.view.ViewRootImpl.performLayout(ViewRootImpl.java:3999) at android.view.ViewRootImpl.performTraversals(ViewRootImpl.java:3372) at android.view.ViewRootImpl.doTraversal(ViewRootImpl.java:2328) at android.view.ViewRootImpl$TraversalRunnable.run(ViewRootImpl.java:9087) at android.view.Choreographer$CallbackRecord.run(Choreographer.java:1231) at android.view.Choreographer$CallbackRecord.run(Choreographer.java:1239) 08:34:29.724 13869-13869 E at android.view.Choreographer.doCallbacks(Choreographer.java:899) at android.view.Choreographer.doFrame(Choreographer.java:832) at android.view.Choreographer$FrameDisplayEventReceiver.run(Choreographer.java:1214) at android.os.Handler.handleCallback(Handler.java:942) at android.os.Handler.dispatchMessage(Handler.java:99) at android.os.Looper.loopOnce(Looper.java:201) at android.os.Looper.loop(Looper.java:288) at android.app.ActivityThread.main(ActivityThread.java:7872) at java.lang.reflect.Method.invoke(Native Method) at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548) at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936)
第一次更新菜单项启用状态成功,为何第二次更新会引发崩溃?注释观察者中的setEnabled()代码后,应用不会崩溃。我推测NavigationView使用RecyclerView展示菜单项,但两次setEnabled()之间未增删菜单项,且操作的是同一个MenuItem,无法理解为何会影响其在RecyclerView中的位置。
问题根源
NavigationView内部的NavigationMenuAdapter在收到MenuItem状态变更(比如setEnabled)时,会触发整个RecyclerView的重排,但此时Activity的布局初始化可能还未完全完成——第二次调用setEnabled时,NavigationView的RecyclerView正处于布局计算的关键阶段,状态变更导致Adapter的数据与RecyclerView的布局状态不一致,从而抛出索引越界异常。
修复方案
延迟状态更新到布局完成后
确保在Activity的布局完全初始化完成后,再更新菜单项的启用状态。可以通过View.post()来延迟执行:logInOutEnabled.observe( this, (enabled) -> { application.logger.d("Log In/Out Enabled: " + enabled); // 延迟到布局线程空闲时执行 logInOutMenuItem.post(() -> logInOutMenuItem.setEnabled(enabled)); } );调整LiveData更新的时机
将检查缓存凭据的逻辑移到onPostCreate()方法中,此时Activity的布局已经完成初始化:@Override protected void onPostCreate(Bundle savedInstanceState) { super.onPostCreate(savedInstanceState); if (isLoggedIn()) { activity.application.logInOutEnabled.postValue(true); } else { // start the login flow here } }使用NavigationView的菜单更新API
不要直接操作缓存的MenuItem实例,而是通过NavigationView.getMenu()重新获取菜单后修改状态,确保变更被正确通知到内部Adapter:logInOutEnabled.observe( this, (enabled) -> { application.logger.d("Log In/Out Enabled: " + enabled); navigationView.getMenu().findItem(R.id.NavigationLogInOut).setEnabled(enabled); } );
补充说明
这种偶发崩溃源于Android布局流程的异步特性:第一次setEnabled在布局初始化前执行,不会触发RecyclerView的重排;第二次在布局初始化过程中执行,导致Adapter与RecyclerView的状态不同步。通过延迟执行或调整执行时机,就能避免这种不一致问题。
内容的提问来源于stack exchange,提问作者Tim Gustafson

