简体   繁体   中英

Jetpack Compose - preserve state of AndroidView on configuration change

Most likely a newbie question, as i'm fairly new to Android dev - I am having troubles preserving the state of AndroidView in my @Composable on configuration change/navigation, as factory block is called (as expected) and my chart gets reinstantiated.

@Composable
fun ChartView(viewModel:ViewModel, modifier:Modifier){
    val context = LocalContext.current
    val chart = remember { DataChart(context) }

    AndroidView(
        modifier = modifier,
        factory = { context ->
            Log.d("DEBUGLOG", "chart init")
            chart
        },
        update = { chart ->
            Log.d("DEBUGLOG", "chart update")
        })
}

The DataChart is a 3rd party component with a complex chart, i would like to preserve the zoom/scrolling state. I know i can use ViewModel to preserve UI state across conf. changes, but given the complexity of saving zoom/scrolling state, i'd like to ask if there is any other easier approach to achieve this?

I tried to move the whole chart instance to viewModel, but as it's using context i get a warning about context object leaks.

Any help would be appreciated!

If you want to preserve state of AndroidView on configuration change, Use rememberSaveable instead remember .

While remember helps you retain state across recompositions, the state is not retained across configuration changes. For this, you must use rememberSaveable . rememberSaveable automatically saves any value that can be saved in a Bundle. For other values, you can pass in a custom saver object.

If the DataChart is a type of Parcelable, Serializable or other data types that can be stored in bundle:

val chart = rememberSaveable { DataChart(context) }

If the above way not working then create a MapSave to save data, zoom/scrolling states... I assume the DataChart has zoomIndex, scrollingIndex, values properties which your need to save:

fun getDataChartSaver(context: Context) = run {
    val zoomKey = "ZoomState"
    val scrollingKey = "ScrollingState"
    val dataKey = "DataState"

    mapSaver(
        save = { mapOf(zoomKey to it.zoomIndex, scrollingKey to it.scrollingIndex, dataKey to it.values) },
        restore = { 
            DataChart(context).apply{
               zoomIndex = it[zoomKey]
               scrollingIndex = it[scrollingKey]
               values = it[dataKey]
            } 
        }
    )
}

Use:

val chart = rememberSaveable(stateSaver = getDataChartSaver(context)){ DataChart(context) }

See more Ways to store state

I'd say your instincts were correct to move the chart instance into the view model, but, as you noted, context dependencies can become a hassle when they are required for objects other than views. To me, this becomes a question of dependency injection where the dependency is the context or, in a broader sense, the entire data chart. I'd be interested in knowing how you source your view model, but I'll assume it relies on an Android view model provider (via by viewModels() or some sort of ViewModelProvider.Factory ).

An immediate solution to this issue is to convert the view model into a subclass of an AndroidViewModel which provides reference to the application context via the view model's constructor. While it remains an anti-pattern and should used sparingly, the Android team has recognized certain use cases to be valid. I personally do not use the AndroidViewModel because I believe it to be a crude solution to a problem which could otherwise be solved with refinements to the dependency graph. However, it's sanctioned by the official documentation, and this is only my personal opinion. From experience, I must say its use makes testing a view model quite a nightmare after-the-fact. If you are interested in a dependency injection library, I'd highly recommend the new Hilt implementation which recently launched a stable 1.0.0 release just this past month.

With this aside, I'll now provide two possible solutions to your predicament: one which utilizes the AndroidViewModel and another which does not. If your view model already has other dependencies outside of the context, the AndroidViewModel solution won't save you much overhead as you'd likely already be instantiating a ViewModelProvider.Factory at some point. These solutions will be considering the scope of an Android Fragment but could easily be implemented in an Activity or DialogFragment as well with some tweaks to lifecycle hooks and whatnot.

With AndroidViewModel

import android.app.Application
import androidx.lifecycle.AndroidViewModel

class MyViewModel(application: Application) : AndroidViewModel(application) {

    val dataChart: DataChart

    init {
        dataChart = DataChart(application.applicationContext)
    }
}

where the fragment could be

class MyFragment : Fragment() {

    private val viewModel: MyViewModel by viewModels()

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View { ... }
}

Without AndroidViewModel

import androidx.lifecycle.ViewModel

class MyViewModel(args: Args) : ViewModel() {

    data class Args(
        val dataChart: DataChart
    )

    val dataChart: DataChart = args.dataChart
}

where the fragment could be

class MyFragment : Fragment() {

    private lateinit var viewModel: MyViewModel

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        val applicationContext: Context = requireContext().applicationContext
        val dataChart = DataChart(applicationContext)

        val viewModel: MyViewModel by viewModels {
            ArgsViewModelFactory(
                args = MyViewModel.Args(
                    dataChart = dataChart,
                ),
                argsClass = MyViewModel.Args::class.java,
            )
        }

        this.viewModel = viewModel

        ...
    }
}

and where ArgsViewModelFactory is a creation of my own as shown below

import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider

class ArgsViewModelFactory<T>(
    private val args: T,
    private val argsClass: Class<T>,
) : ViewModelProvider.Factory {

    override fun <T : ViewModel?> create(modelClass: Class<T>): T = modelClass.getConstructor(
        argsClass,
    ).newInstance(
        args,
    )
}

edit (via Hilt module):

@Module
@InstallIn(...)
object DataChartModule {

    @Provides
    fun provideDataChart(
        @ApplicationContext context: Context,
    ): DataChart = DataChart(context)
}

Here's the simplest way I know of. This keeps the state and doesn't trigger a reload on the WebView when I rotate my phone. It should work with every View.

First create an application class:

class MainApplication : Application() {

    private var view: WebView? = null

    override fun onCreate() {
        super.onCreate()
        LOG("started application")
    }

    fun loadView(context: Context): WebView {
        if (view == null) {
            view = WebView(context)
            //init your view here
        }
        return view!!
    }
}

Then add the application class to manifest.xml

<manifest>
    ...
    <application
        android:name=".MainApplication"
    ...

Finally add this to the composable

AndroidView(modifier = Modifier.fillMaxSize(), factory = { context ->
    val application = context.applicationContext as MainApplication
    application.loadView(context = context)
})

That's it. I'm not sure if this can lead to memory leaks but I haven't had problems yet.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM