'Android: lazy-loading of DataBinding view throws exception

There is a large data-bound view, which may take several seconds to inflate. I would like to display the user a splash screen and inflate the main view a delayed action. Android studio throws an exception "Failed to call observer method".

MainActivity:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.screen_splash)
    Handler(Looper.getMainLooper()).postDelayed({
        val binding = DataBindingUtil.setContentView<ActivityMainBinding>(
            this,
            R.layout.activity_main
        )
        binding.lifecycleOwner = this // this line throws exception
    }, 1000)
}

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:bind="http://schemas.android.com/apk/res-auto"
xmlns:app="http://schemas.android.com/apk/res-auto">

<data>
    <variable
        name="vm"
        type="com.example.ViewModel"/>
</data>

<RelativeLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.fragment.app.FragmentContainerView
            android:id="@+id/map_list"
            android:name="com.google.android.gms.maps.SupportMapFragment"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            tools:context=".MainActivity" />
</RelativeLayout>

Exception:

2021-12-05 13:42:56.638 23701-23701/com.example E/AndroidRuntime: FATAL EXCEPTION: main
Process: com.example, PID: 23701
java.lang.RuntimeException: Failed to call observer method
    at androidx.lifecycle.ClassesInfoCache$MethodReference.invokeCallback(ClassesInfoCache.java:226)
    at androidx.lifecycle.ClassesInfoCache$CallbackInfo.invokeMethodsForEvent(ClassesInfoCache.java:194)
    at androidx.lifecycle.ClassesInfoCache$CallbackInfo.invokeCallbacks(ClassesInfoCache.java:185)
    at androidx.lifecycle.ReflectiveGenericLifecycleObserver.onStateChanged(ReflectiveGenericLifecycleObserver.java:37)
    at androidx.lifecycle.LifecycleRegistry$ObserverWithState.dispatchEvent(LifecycleRegistry.java:354)
    at androidx.lifecycle.LifecycleRegistry.addObserver(LifecycleRegistry.java:196)
    at androidx.databinding.ViewDataBinding.setLifecycleOwner(ViewDataBinding.java:434)
    at com.example.databinding.ActivityMainBindingImpl.setLifecycleOwner(ActivityMainBindingImpl.java:166)
    at com.example.MainActivity.onCreate$lambda-3(MainActivity.kt:106)
    at com.example.MainActivity.$r8$lambda$lffeScwTEbHi2B1isKEoQYU2po4(Unknown Source:0)
    at com.example.MainActivity$$ExternalSyntheticLambda5.run(Unknown Source:2)
    at android.os.Handler.handleCallback(Handler.java:888)
    at android.os.Handler.dispatchMessage(Handler.java:100)
    at android.os.Looper.loop(Looper.java:213)
    at android.app.ActivityThread.main(ActivityThread.java:8178)
    at java.lang.reflect.Method.invoke(Native Method)
    at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:513)
    at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1101)
 Caused by: java.lang.NumberFormatException: s == null
    at java.lang.Integer.parseInt(Integer.java:577)
    at java.lang.Integer.valueOf(Integer.java:801)
    at com.example.databinding.ControlPanelBindingImpl.executeBindings(ControlPanelBindingImpl.java:800)...


Solution 1:[1]

I am not sure about the structure of your application. In our case we had a similar requirement where we wanted to show a loader until the initial fragment is bound. So we created a viewStub in the activity. Then when the fragment is attached we set a liveData in the shared view model to SHOW which notifies the activity to inflate the viewStub. This way we inflate the view stub which hides the full screen displaying a splash image. Then once the view in the fragment is created and in the onViewCreated we again set the liveData in the shared view model to HIDE which hides the viewStub and the fragment is displayed.

Solution 2:[2]

Use fragmentContainerView inside your main activity. Show the view that you want to show in this container. Create a view in front of the container. Show splash message in this view. Make the visibility of the splash view gone when the main view is loaded. So the splash screen will use the activity life cycle and the main view will use the fragment lifecycle. This may be a solution for you.

Solution 3:[3]

Disclaimer: turns out that the issue is resolved in UPDATE 2 section in the answer; the other sections are left if they could help future visitors in other potential issues

At first look, thought that Caused by: java.lang.NumberFormatException: s == null is related to the issue; although you told in comments that it's working synchronously.

And the exception java.lang.RuntimeException: Failed to call observer method won't help to know the error by tracing it in the code.

But your code successfully worked with me in simple layouts; probably the issue is related to the heavy layout that you try to load synchronously along while accessing the binding.lifecycleOwner; I guess the latter snippet requires a while before accessing the lifecycleOwner. So, you could post some delay in advance.

For that, I am going to use coroutines instead of posting a delay; as the code would be more linear and readable:

CoroutineScope(Main).launch {

    delay(1000) // Original delay of yours
    val binding = DataBindingUtil.setContentView<ActivityMainBinding>(
        this@MainActivity,
        R.layout.activity_main
    )

    delay(1000) // try and error to manipulate this delay 
    binding.lifecycleOwner = this@MainActivity 

}

If not already used, the coroutine dependency is

def coroutine_version = "1.5.2"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutine_version"

UPDATE

The posted delay in your code doesn't help in showing the splash/launch screen during that delay while the main activity is loading;

Handler(Looper.getMainLooper()).postDelayed({
    val binding = DataBindingUtil.setContentView<ActivityMainBinding>(
        this,
        R.layout.activity_main
    ) // This won't be called unless the 1000 sec is over
    binding.lifecycleOwner = this 
}, 1000)

What your code does:

  1. A splash screen is shown
  2. A delay is posted (still the main layout is not loading in here)
  3. main layout is shown when the delay is over

So, the posted delay is just accumulating to the time of loading the main layout; this even make it more lagged. Furthermore this is not the recommended way of using splash screen (This medium post would help in that)

Instead, I think what you intend to do:

  1. Show a splash screen
  2. Load main layout
  3. Post a delay so that the main layout takes time to load during the delay
  4. Show the main layout when the delay is over

But, the problem is that the thing need to be loaded is UI which requires to do that in the main thread, not in a background thread. So, we instead of using two different layout and call setContentView() twice; you could instead create a single layout for your main layout, and add some view that represents the splash screen which will obscure the main layout entirely (be in front of it) until the layout is loaded (i.e. the delay is over); then remove this splash view then:

Demo:

splash_screen.xml (Any layout you want that must match parent to obscure it):

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/splash_screen"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/black"
    android:gravity="center">

    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:src="@mipmap/ic_launcher" />
</LinearLayout>

Main activity:

class MainActivity : AppCompatActivity() {

    companion object {
        const val TAG = "LOG_TAG"
    }

    lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        Log.d(TAG, "Start Inflating layout")
        binding = DataBindingUtil.setContentView(
            this@MainActivity,
            R.layout.activity_main
        )

        // Show only the first time app launches, not in configuration changes
        if (savedInstanceState == null) {
            CoroutineScope(IO).launch {
                Log.d(TAG, "Start of delay")
                delay(1000)
                Log.d(TAG, "End of delay")
                withContext(Main) {
                    hideSplash()
                }
            }
            showSplash()
        }

        binding.lifecycleOwner = this@MainActivity
        Log.d(TAG, "End Inflating layout")

    }

    private fun showSplash() {
        supportActionBar?.hide()

        // Inflate splash screen layout
        val splashLayout =
            layoutInflater.inflate(
                R.layout.splash_screen,
                binding.rootLayout,
                false
            ) as LinearLayout

        binding.rootLayout.addView(
            splashLayout
        )

    }

    private fun hideSplash() {
        supportActionBar?.show()
        binding.rootLayout.removeView(
            findViewById(R.id.splash_screen)
        )
    }

}

Logs

2021-12-11 21:59:18.349 20681-20681/  D/LOG_TAG: Start Inflating layout
2021-12-11 21:59:18.452 20681-20707/  D/LOG_TAG: Start of delay
2021-12-11 21:59:18.476 20681-20681/  D/LOG_TAG: End Inflating layout
2021-12-11 21:59:20.457 20681-20707/  D/LOG_TAG: End of delay

Now the delay is running along with inflating the layout; the splash screen shown while it loads; and ends when the delay is over.

UPDATE 2

It's definitely not going to work: databinding = ... line takes 2.5 seconds to complete, can't add a view to "databinding.root" before it's ready. It works in the presented code because your main view is tiny.

Now try to separate inflating the layout from setContentView() in dataBinding; still both requires to be in the main thread

setContentView(R.layout.screen_splash)

CoroutineScope(Main).launch {
    // Inflate main screen layout asynchronously 
    binding = ActivityMainBinding.inflate(layoutInflater) 

    delay(2500) // 2.5 sec delay of loading the mainLayout before setContentView

    setContentView(binding.root)
    binding.lifecycleOwner = this@MainActivity
}

Solution 4:[4]

Finally found the problem and, in retrospect, it was too elementary for the question:

Must assign ViewModel before lifecycleOwner

binding.viewModel = myViewModer
binding.livecycleOwner = this@MainActivity

Just changing order of these lines fixed it.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Sagar
Solution 2 Alperen Acikgoz
Solution 3
Solution 4 Gonki