manas-chaudhari / android-mvvm

MVVM on Android using RxJava and Data Binding
Apache License 2.0
502 stars 81 forks source link

Handling Configuration Changes #37

Open manas-chaudhari opened 8 years ago

szantogab commented 7 years ago

@manas-chaudhari What if we saved the ViewModel's instance state with IcePick? The only problem is that if a Navigator or any object is injected into the ViewModel, then obviously those dependencies should be somehow injected again on config changes. Which is not something IcePick will do :(

manas-chaudhari commented 7 years ago

That's a good catch. I am thinking along the lines of making ViewModels implement Parcelable. MvvmActivity can then serialize and deserialize them.

szantogab commented 7 years ago

But how will dependencies be injected back to the ViewModel like Navigator? Also, shouldn't we have an MvvmFragment as well?

manas-chaudhari commented 7 years ago

Support for fragments should be added. I have created https://github.com/manas-chaudhari/android-mvvm/issues/43 for this.

Right, perhaps we can have ViewModels return an instance of Parcelable. ViewModel can build an internal state class instance, when asked to save state. Similarly while restoring, it will populate its fields from the same Parcelable instance.

This approach also seems crude. Let's think of something better.

mike-mathieu commented 7 years ago

Would using Parcelable create an Android SDK dependency in the ViewModels?

I'm not sure of the best way to do it, but perhaps a singleton ViewModelManager that retains them as the activity gets recreated.

manas-chaudhari commented 7 years ago

Yes, Parcelable would create a dependency on Android SDK. It is not desirable.

The core issue here as @szantogab pointed is injecting the dependencies. Retaining the ViewModel would retain the dependencies. Other objects in ViewModel do not depend on Context, and hence can be retained.

Another approach that I have in mind, is to replace dependencies with a bus. For example, when ViewModel wants to invoke navigation, it would pass an event into a PublishSubject. View would subscribe and unsubscribe to this based on its lifecycle. This way, context reaches the ViewModel only when View subscribes and gets cleared on unsubscription, making the ViewModel safe to be retained over config changes.

While this looks promising, I have this concern:

Subscribing to the ViewModel's outputs, would vary from view to view based on its required functionality. This would result in boilerplate.

My thoughts on how this can be tackled: In the (current) dependencies approach, we have Dagger to simplify things. We can use similar way to provide the PublishSubjects as dependencies. Thus, the dependencies become subjects instead of interfaces.

With this idea, it is even possible to implement this while retaining the interfaces. The buses can be moved inside the implementation of Navigator, etc. Thus, these dependency objects would retain and release the Context, based on lifecycle. We could go with subscriptions to achieve this OR use a weak reference.

szantogab commented 7 years ago

@manas-chaudhari This sounds promising. Can you show a brief implementation here ?

manas-chaudhari commented 7 years ago

Sure. It would be something like this:

public abstract class BaseActivity extends MvvmActivity {

    protected final Navigator navigator = new NavigatorImpl();
    private Disposable subscription;

    // Also, the viewModel object created in MvvmActivity needs to be retained. This can be done using Fragment.

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        subscription = navigator.connect(this);
    }

    @Override
    protected void onDestroy() {
        subscription.dispose();
        super.onDestroy();
    }

    static class NavigatorImpl implements Navigator {

        private Context context;

        public Disposable connect(final Context context) {
            this.context = context;

            return Disposables.fromAction(new Action() {
                @Override
                public void run() throws Exception {
                    NavigatorImpl.this.context = null;
                }
            });
        }

        @Override
        public void openDetailsPage(Item item) {
            navigate(ItemDetailsActivity.class);
        }

        @Override
        public void navigateToFunctionalDemo() {
            navigate(DataLoadingActivity.class);
        }

        private void navigate(Class<?> destination) {
            if (context != null) {
                Intent intent = new Intent(context, destination);
                context.startActivity(intent);
            }
        }
    }
}

To reduce boilerplate, we can create an interface containing connect(Context), which all lifecycle-based dependencies would implement. Then, the code can be moved to MvvmActivity OR a separate class as @bigmikehoncho had created in #54 .

I am getting a good feeling about this approach. Lets give this more thought to ensure we aren't missing anything.

szantogab commented 7 years ago

So with Dagger, we would inject the Navigator into the ViewModel, and our Navigator does NOT depend on Context at first. Instead, the Context will be connected and disconnected to/from the Navigator.

Because of the ViewModel instance is retained, it holds a reference to the Navigator, which is also retained. The Context will be disconnected on orientation change from the Navigator, so no memory leak will occur.

This sounds good to me.

Now let's see another use case: what if we want to search for NFC tags in the foreground? To do that, we need to enable NFC foreground dispatch, so we need a foreground Activity to be able to do that. Let's create an NfcService interface for that, which will have a single property:

interface NfcService {
    val cardIds: Observable<String>
}
class ActivityNfcService(nfcAdater: NfcAdapter) : NfcService, Connectable {
    override val cardIds = PublishSubject.create<String>()
    override connect(activity: Activity): Disposable {
        // Init NFC related stuff
        nfcAdater?.enableForegroundDispatch(activity, .....)

       return Disposable.fromAction {
            nfcAdapter?.disableForegroundDispatch(activity)
       }
    }
}

With this, we are not holding a reference to the Activity, so we are not leaking memory. The only to watch out for is that we need to dispose the disposable from onPause().

What do you think about this solution? If we solve this use case (which is really closely related to the Activity lifecycle), then I can't think of any other use case which we couldn't solve with this.

PS: Sorry for Kotlin :)

manas-chaudhari commented 7 years ago

Yes, your explanation is accurate.

Regarding NFC, I think that we would be mixing two different things by invoking connect from onResume.

  1. Availability of the resource: Invoking connect means the resource (context in this case) is available.
  2. Using the resource: Reading NFC tags should not be done after onPause, even though the activity is available.

I think the responsibility of Activity should be to notify when its available and when its not, i.e. in onCreate and onDestroy.

The second part can be handled inside the implementation of ActivityNfcService. When activity invokes connect, NfcService would subscribe to the activity's lifecycle. When it receives Resume, it would enable the nfc and disable when it gets Pause.

Also, you might not want to enableForegroundDispatch inside connect, but instead when cardIds is subscribed. I am sure this is doable using Rx.

With NFC, I am assuming that its the restriction of Android to ensure that its invoked only in Resume/Pause lifecycle. In some cases, resume/pause lifecycle dependency may come based on functionality requirement instead of platform restrictions. For example, lets say we want to refresh data in onResume. Handling for such cases should be done inside the ViewModel by passing the lifecycle to it.

szantogab commented 7 years ago

When activity invokes connect, NfcService would subscribe to the activity's lifecycle. When it receives Resume, it would enable the nfc and disable when it gets Pause.

How do you achieve this? With this library: https://github.com/trello/navi ?

manas-chaudhari commented 7 years ago

Yes

szantogab commented 7 years ago

Let's see a simple login example from the ViewModel's perspective:

class LoginViewModel @Inject constructor(userService: UserService, navigator: Navigator, messageHelper: MessageHelper) : ViewModel {
    // Input
    val userName = ObservableField<String>("")
    val password = ObservableField<String>("")
    val loginButtonTap = PublishSubject.create<Any>()

    // Output
    private val loginResult = loginButtonTap.switchMap {
          userService.login(userName.get(), password.get())
    }.share()

    val loginSuccessText = loginResult.asSuccess()
         .doOnNext { navigator.navigateToMain() }
         .map { messageHelper.getString(R.string.login_success) }

    val loginErrorText = loginResult.asError()
         .map { error -> error.format() }

Is this a valid example? The whole thing of Rx is that we should avoid creating side-effects as much as possible. But here, loginSuccessText creates a side-effect, because it calls navigator.navigateToMain()

Is this how it is supposed to be done? What do you think?

manas-chaudhari commented 7 years ago

Interesting example. I haven't figured out yet how to avoid such side-effects. Normally, I implement an Action which subscribes to the api call. But even that leads to imperative code.

Perhaps it is possible to avoid side-effects if we structure dependencies to use Observables.

class LoginViewModel @Inject constructor(userService: UserService, navigator: Navigator, messageHelper: MessageHelper) : ViewModel {
    // Input
    val userName = ObservableField<String>("")
    val password = ObservableField<String>("")
    val loginButtonTap = PublishSubject.create<Any>()

    // Output
    private val loginResult = loginButtonTap.switchMap {
          userService.login(userName.get(), password.get())
    }.share()

    val loginSuccess = loginResult.asSuccess()

    navigator.navigateToMainOn(loginSuccess) 
// This would internally subscribe to loginSuccess based on lifecycle of the navigator (its connect).
// However, that means the viewmodel would stay in memory until Activity is destroyed, irrespective of whether its used OR not.

    val loginSuccessText = loginResult
         .map { messageHelper.getString(R.string.login_success) }

    val loginErrorText = loginResult.asError()
         .map { error -> error.format() }

Even this approach has a drawback as mentioned.

Also, I am thinking about this example wrt Configuration Changes. What happens when configuration changes during the api call?

  1. In the original implementation, api call would get cancelled as view unsubscribes. So, we can say that lifecycle of the api call matches with view.
  2. With Action, subscription doesn't get unsubscribed until api completes, thus, even after config changes, api would be in progress. Here, api call's lifecycle matches with the application process. There's no stop to it.
  3. With Observable implementation above, subscription gets unsubscribed on activity's onDestroy. Thus, api call's lifecycle matches with lifecycle of the view.

I feel that change of view orientation shouldn't affect viewModel's state. For example, changing orientation shouldn't modify whether loading is visible or not. Action implementation aligns with this. It is unsafe though, as the viewmodel stays alive even after activity gets destroyed. This needs to be tackled. @szantogab What do you think?

rayworks commented 5 years ago

It seems this reply comes up one and a half years later.:)

For handling configuration change, could it be possible to replace your ViewModel with the ViewModel in AAC ?

manas-chaudhari commented 5 years ago

That will take care of retaining the ViewModel instance. But invoking actions which require context, in a reusable manner, is the main hindrance. So the questions raised in the above discussion are still open.

On Fri 23 Nov, 2018, 11:11 AM rayworks <notifications@github.com wrote:

It seems this reply comes up one and a half years later.:)

For handling configuration change, could it be possible to replace your ViewModel with the ViewModel https://developer.android.com/topic/libraries/architecture/viewmodel in AAC ?

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/manas-chaudhari/android-mvvm/issues/37#issuecomment-441159767, or mute the thread https://github.com/notifications/unsubscribe-auth/AEJL3J1gQXnc-v4xDk-i2xHfNtokW-J3ks5ux4qSgaJpZM4KA44A .