androidinterview.com

Low Level Design (LLD) Interview Questions

Implement your own ViewModel

Tier: EssentialDifficulty: MediumAsked of: Mid, Senior

Design a way to keep a screen's state when Android recreates that screen during rotation. A ViewModel is the object holding the state. A store keeps that object available to the replacement screen.

The problem

A counter screen has count 7. After rotation, the new screen should get the same model with count 7. When the screen permanently finishes, its model should be cleared.

Start with one model per class in one store. All access happens on the main thread. The screen's owner keeps the store across rotation and calls clear when it finishes. Rebuilding real Android lifecycle plumbing and restoring after process death are follow-ups.

How to explain the design

“I keep models in a map. When a screen asks for a model, I return the existing instance. If there is no instance, I use a factory to create one and save it. Rotation reuses the same store. Finishing the screen clears the store and calls each model's cleanup method.”

ViewModel provides onCleared for cleanup. ViewModelStore owns the map. The factory is simply a function that knows how to construct the requested model.

Walk through a rotation

  1. The first screen asks for CounterViewModel and the factory creates it.
  2. The model's count becomes 7.
  3. The replacement screen receives the same store and asks for the same class.
  4. The store returns the existing model. The factory does not run again.
  5. When the owner finishes, clear calls onCleared and removes its models.

Interview implementation

The important requirement is who owns the store. Creating a new store inside each replacement screen loses the state, even if the map code is correct. Cleanup callbacks are assumed to complete normally in this version.

Java

ViewModelStore.java

package interview.viewmodel;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Supplier;

public class ViewModelStore {
    public static class ViewModel {
        public void onCleared() {}
    }

    private final Map<Class<? extends ViewModel>, ViewModel> models = new HashMap<>();

    // The screen owner keeps this store during rotation.
    public <T extends ViewModel> T get(Class<T> type, Supplier<T> factory) {
        ViewModel existing = models.get(type);
        if (existing != null) return type.cast(existing);
        T created = factory.get();
        models.put(type, created);
        return created;
    }

    // Call only when the owner permanently finishes.
    public void clear() {
        var oldModels = new ArrayList<>(models.values());
        models.clear();
        for (ViewModel model : oldModels) model.onCleared();
    }
}
package interview.viewmodel;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Supplier;

public class ViewModelStore {
    public static class ViewModel {
        public void onCleared() {}
    }

    private final Map<Class<? extends ViewModel>, ViewModel> models = new HashMap<>();

    public <T extends ViewModel> T get(Class<T> type, Supplier<T> factory) {
        ViewModel existing = models.get(type);
        if (existing != null) return type.cast(existing);
        T created = factory.get();
        models.put(type, created);
        return created;
    }

    public void clear() {
        var oldModels = new ArrayList<>(models.values());
        models.clear();
        for (ViewModel model : oldModels) model.onCleared();
    }
}

Kotlin

ViewModelStore.kt

package interview.viewmodel

open class ViewModel {
    open fun onCleared() {}
}

// The screen owner keeps this store during rotation.
class ViewModelStore {
    private val models = mutableMapOf<Class<out ViewModel>, ViewModel>()

    fun <T : ViewModel> get(type: Class<T>, factory: () -> T): T {
        val existing = models[type]
        if (existing != null) return type.cast(existing)
        val created = factory()
        models[type] = created
        return created
    }

    // Call only when the owner permanently finishes.
    fun clear() {
        val oldModels = models.values.toList()
        models.clear()
        for (model in oldModels) model.onCleared()
    }
}
package interview.viewmodel

open class ViewModel {
    open fun onCleared() {}
}

class ViewModelStore {
    private val models = mutableMapOf<Class<out ViewModel>, ViewModel>()

    fun <T : ViewModel> get(type: Class<T>, factory: () -> T): T {
        val existing = models[type]
        if (existing != null) return type.cast(existing)
        val created = factory()
        models[type] = created
        return created
    }

    fun clear() {
        val oldModels = models.values.toList()
        models.clear()
        for (model in oldModels) model.onCleared()
    }
}

Follow-up questions

Two models of the same class?

“I would identify a model by both its class and a caller supplied name.” A chat screen could request two instances of ChatViewModel, one for each conversation. Repeating the same name and class returns the same instance.

Replace the class key in the store with a key like this, and pass the name into get. In Java, place the record inside ViewModelStore so it can use the existing nested ViewModel type.

Kotlin

data class ModelKey(val name: String, val type: Class<out ViewModel>)
private val models = mutableMapOf<ModelKey, ViewModel>()

Java

private record ModelKey(String name, Class<? extends ViewModel> type) {}
private final Map<ModelKey, ViewModel> models = new HashMap<>();

Use this combined key for both lookup and insertion. Keep the existing type.cast when returning a cached model.

Two fragments sharing state?

“Both fragments should ask the same owner's store for the same model.” For example, a list and a detail fragment can use their activity's store. Giving each fragment its own store would create two separate objects. Clear the shared store when that owner permanently finishes, not when either fragment changes its view.

Process death?

“The in-memory model disappears, so I save enough data to rebuild it.” Save small values such as a selected item ID or a search query, then reload the actual data when creating the new model. Android's SavedStateHandle supports restoring small state after system initiated process death. Data that must survive a fresh launch belongs in persistent storage.

What should I test?

“Two lookups in the same store should return the very same object.” The factory should run once for that class. A different store should create a different object. Clearing should call each model's cleanup once, and clearing again should do nothing. A later lookup should create a fresh model.

Extended implementation and optional features

This reference explores a larger scope. Use it after you can explain and write the interview version. Its extra types and features are not required for the scope above.

Java

com.androidinterview.viewmodel.ViewModel.java

package com.androidinterview.viewmodel;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

// The base class. Two things make it a ViewModel rather than a plain object.
// It has one hook that runs exactly once, at the end of its life, and a bag of
// AutoCloseable resources closed at that same moment. Every scope and every
// subscription a real ViewModel holds is one of those closeables, so there is a
// single place that has to be right.
public abstract class ViewModel {

    private final Map<String, AutoCloseable> closeables = new LinkedHashMap<>();
    private int anonymous;
    private boolean cleared;

    // Called once, when the store that owns this model lets it go. Never on a
    // rotation. Override it to drop anything that is not already a closeable.
    protected void onCleared() { }

    public final void addCloseable(AutoCloseable closeable) {
        addCloseable("anonymous:" + anonymous++, closeable);
    }

    // The keyed form, and the one an extension like viewModelScope uses. First
    // caller wins, so a scope is created once even if two threads ask at once.
    @SuppressWarnings("unchecked")
    public final <T extends AutoCloseable> T addCloseable(String key, T closeable) {
        synchronized (this) {
            if (!cleared) {
                AutoCloseable existing = closeables.get(key);
                if (existing != null) {
                    return (T) existing;
                }
                closeables.put(key, closeable);
                return closeable;
            }
        }
        // Registering on an already cleared model is a race, not a bug. Close
        // the resource straight away rather than leak it.
        closeQuietly(closeable);
        return closeable;
    }

    public final synchronized AutoCloseable getCloseable(String key) {
        return closeables.get(key);
    }

    // Package private on purpose. Only the store holding this model may end its
    // life, so no screen can clear a model another screen is still using.
    final void clear() {
        List<AutoCloseable> toClose;
        synchronized (this) {
            if (cleared) {
                return;
            }
            cleared = true;
            toClose = new ArrayList<>(closeables.values());
            closeables.clear();
        }
        toClose.forEach(ViewModel::closeQuietly);
        onCleared();
    }

    private static void closeQuietly(AutoCloseable closeable) {
        try {
            closeable.close();
        } catch (Exception ignored) {
            // One resource failing to close must not strand the rest.
        }
    }
}
package com.androidinterview.viewmodel;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public abstract class ViewModel {

    private final Map<String, AutoCloseable> closeables = new LinkedHashMap<>();
    private int anonymous;
    private boolean cleared;

    protected void onCleared() { }

    public final void addCloseable(AutoCloseable closeable) {
        addCloseable("anonymous:" + anonymous++, closeable);
    }

    @SuppressWarnings("unchecked")
    public final <T extends AutoCloseable> T addCloseable(String key, T closeable) {
        synchronized (this) {
            if (!cleared) {
                AutoCloseable existing = closeables.get(key);
                if (existing != null) {
                    return (T) existing;
                }
                closeables.put(key, closeable);
                return closeable;
            }
        }
        closeQuietly(closeable);
        return closeable;
    }

    public final synchronized AutoCloseable getCloseable(String key) {
        return closeables.get(key);
    }

    final void clear() {
        List<AutoCloseable> toClose;
        synchronized (this) {
            if (cleared) {
                return;
            }
            cleared = true;
            toClose = new ArrayList<>(closeables.values());
            closeables.clear();
        }
        toClose.forEach(ViewModel::closeQuietly);
        onCleared();
    }

    private static void closeQuietly(AutoCloseable closeable) {
        try {
            closeable.close();
        } catch (Exception ignored) {
        }
    }
}

com.androidinterview.viewmodel.ViewModelProvider.java

package com.androidinterview.viewmodel;

// Look up by key, and only build when the lookup misses. That is the entire
// class. It owns no models, it holds no cache of its own, and constructing a
// second provider over the same owner changes nothing, which is why calling it
// in onCreate every time is safe.
public final class ViewModelProvider {

    private static final String DEFAULT_KEY = "com.androidinterview.viewmodel.ViewModelProvider.DefaultKey";

    private final ViewModelStore store;
    private final Factory factory;

    public ViewModelProvider(ViewModelStoreOwner owner) {
        this(owner, new NewInstanceFactory());
    }

    public ViewModelProvider(ViewModelStoreOwner owner, Factory factory) {
        this.store = owner.viewModelStore();
        this.factory = factory;
    }

    // The default key is derived from the class, so one screen gets one model
    // of each type without anybody naming anything.
    public <T extends ViewModel> T get(Class<T> type) {
        return get(DEFAULT_KEY + ":" + type.getName(), type);
    }

    public <T extends ViewModel> T get(String key, Class<T> type) {
        ViewModel existing = store.get(key);
        if (existing != null) {
            if (type.isInstance(existing)) {
                return type.cast(existing);
            }
            throw new IllegalArgumentException(
                    "key " + key + " already holds a " + existing.getClass().getName());
        }
        T created = factory.create(type);
        store.put(key, created);
        return created;
    }

    // Creation is a separate job from lookup, because a model with constructor
    // arguments still has to be found in the store on the way back.
    public interface Factory {

        <T extends ViewModel> T create(Class<T> type);
    }

    // The fallback for a model that needs nothing. Anything with dependencies
    // gets its own factory, which is a lambda in practice.
    public static final class NewInstanceFactory implements Factory {

        @Override
        public <T extends ViewModel> T create(Class<T> type) {
            try {
                return type.getDeclaredConstructor().newInstance();
            } catch (ReflectiveOperationException failure) {
                throw new IllegalArgumentException(
                        type.getName() + " needs a public no argument constructor", failure);
            }
        }
    }
}
package com.androidinterview.viewmodel;

public final class ViewModelProvider {

    private static final String DEFAULT_KEY = "com.androidinterview.viewmodel.ViewModelProvider.DefaultKey";

    private final ViewModelStore store;
    private final Factory factory;

    public ViewModelProvider(ViewModelStoreOwner owner) {
        this(owner, new NewInstanceFactory());
    }

    public ViewModelProvider(ViewModelStoreOwner owner, Factory factory) {
        this.store = owner.viewModelStore();
        this.factory = factory;
    }

    public <T extends ViewModel> T get(Class<T> type) {
        return get(DEFAULT_KEY + ":" + type.getName(), type);
    }

    public <T extends ViewModel> T get(String key, Class<T> type) {
        ViewModel existing = store.get(key);
        if (existing != null) {
            if (type.isInstance(existing)) {
                return type.cast(existing);
            }
            throw new IllegalArgumentException(
                    "key " + key + " already holds a " + existing.getClass().getName());
        }
        T created = factory.create(type);
        store.put(key, created);
        return created;
    }

    public interface Factory {

        <T extends ViewModel> T create(Class<T> type);
    }

    public static final class NewInstanceFactory implements Factory {

        @Override
        public <T extends ViewModel> T create(Class<T> type) {
            try {
                return type.getDeclaredConstructor().newInstance();
            } catch (ReflectiveOperationException failure) {
                throw new IllegalArgumentException(
                        type.getName() + " needs a public no argument constructor", failure);
            }
        }
    }
}

com.androidinterview.viewmodel.ViewModelStore.java

package com.androidinterview.viewmodel;

import java.util.HashMap;
import java.util.Map;

// A map from key to instance, and nothing else. This is the whole of the
// mechanism people think is magic. It is deliberately not a singleton and not
// static, because there is one of these per screen and its life is the screen's
// life, not the process's.
public final class ViewModelStore {

    private final Map<String, ViewModel> models = new HashMap<>();

    public ViewModel get(String key) {
        return models.get(key);
    }

    public void put(String key, ViewModel model) {
        ViewModel previous = models.put(key, model);
        if (previous != null) {
            // Replacing under a live key would silently leak the old model's
            // resources, so it is ended here rather than left to the collector.
            previous.clear();
        }
    }

    // The only way a model ever dies. Called when the owner is finishing for
    // real, never when it is being recreated.
    public void clear() {
        for (ViewModel model : models.values()) {
            model.clear();
        }
        models.clear();
    }
}
package com.androidinterview.viewmodel;

import java.util.HashMap;
import java.util.Map;

public final class ViewModelStore {

    private final Map<String, ViewModel> models = new HashMap<>();

    public ViewModel get(String key) {
        return models.get(key);
    }

    public void put(String key, ViewModel model) {
        ViewModel previous = models.put(key, model);
        if (previous != null) {
            previous.clear();
        }
    }

    public void clear() {
        for (ViewModel model : models.values()) {
            model.clear();
        }
        models.clear();
    }
}

com.androidinterview.viewmodel.ViewModelStoreOwner.java

package com.androidinterview.viewmodel;

// One method, and it is the seam the whole design turns on. Anything that can
// hand back a store can own ViewModels, so the provider never has to know
// whether it is talking to an activity, a fragment or a test double.
public interface ViewModelStoreOwner {

    ViewModelStore viewModelStore();
}
package com.androidinterview.viewmodel;

public interface ViewModelStoreOwner {

    ViewModelStore viewModelStore();
}

com.androidinterview.viewmodel.host.ActivityThread.java

package com.androidinterview.viewmodel.host;

import java.util.HashMap;
import java.util.Map;
import java.util.function.Supplier;

// The framework side, and the reason any of this survives a rotation. It lives
// in the process rather than in a screen, so it can hold the retained slot in
// the gap where the old activity object is gone and the new one does not exist
// yet. It is also the whole answer to process death. Kill the process and this
// map goes with it, no callback runs, and nothing was ever written down.
public final class ActivityThread {

    private static final class Record {

        final Supplier<ComponentActivity> builder;
        ComponentActivity current;

        Record(Supplier<ComponentActivity> builder) {
            this.builder = builder;
        }
    }

    private final Map<String, Record> records = new HashMap<>();

    public ComponentActivity launch(String id, Supplier<ComponentActivity> builder) {
        Record record = new Record(builder);
        record.current = builder.get();
        record.current.attach(null);
        records.put(id, record);
        return record.current;
    }

    // A rotation. The old screen is destroyed and a brand new one is built, and
    // the only thing that crosses the gap is the retained slot.
    public ComponentActivity configurationChange(String id) {
        Record record = recordFor(id);
        ComponentActivity.NonConfigurationInstances retained =
                record.current.retainNonConfigurationInstances();
        record.current.performDestroy(true);
        record.current = record.builder.get();
        record.current.attach(retained);
        return record.current;
    }

    // A real finish, a back press or a call to finish. Nothing is retained, so
    // the destroy clears the store and every model gets onCleared.
    public void finish(String id) {
        recordFor(id).current.performDestroy(false);
        records.remove(id);
    }

    // Process death, modelled honestly. No destroy runs, no store is cleared,
    // and every model in the process is simply gone.
    public void killProcess() {
        records.clear();
    }

    private Record recordFor(String id) {
        Record record = records.get(id);
        if (record == null) {
            throw new IllegalArgumentException("no activity with id " + id);
        }
        return record;
    }
}
package com.androidinterview.viewmodel.host;

import java.util.HashMap;
import java.util.Map;
import java.util.function.Supplier;

public final class ActivityThread {

    private static final class Record {

        final Supplier<ComponentActivity> builder;
        ComponentActivity current;

        Record(Supplier<ComponentActivity> builder) {
            this.builder = builder;
        }
    }

    private final Map<String, Record> records = new HashMap<>();

    public ComponentActivity launch(String id, Supplier<ComponentActivity> builder) {
        Record record = new Record(builder);
        record.current = builder.get();
        record.current.attach(null);
        records.put(id, record);
        return record.current;
    }

    public ComponentActivity configurationChange(String id) {
        Record record = recordFor(id);
        ComponentActivity.NonConfigurationInstances retained =
                record.current.retainNonConfigurationInstances();
        record.current.performDestroy(true);
        record.current = record.builder.get();
        record.current.attach(retained);
        return record.current;
    }

    public void finish(String id) {
        recordFor(id).current.performDestroy(false);
        records.remove(id);
    }

    public void killProcess() {
        records.clear();
    }

    private Record recordFor(String id) {
        Record record = records.get(id);
        if (record == null) {
            throw new IllegalArgumentException("no activity with id " + id);
        }
        return record;
    }
}

com.androidinterview.viewmodel.host.ComponentActivity.java

package com.androidinterview.viewmodel.host;

import com.androidinterview.viewmodel.ViewModelStore;
import com.androidinterview.viewmodel.ViewModelStoreOwner;

// The screen. It owns a store, hands that store to the framework on the way
// out, and clears it only when it is going away for good. It never keeps a
// static reference to anything and it never asks whether the device rotated. It
// answers one question, am I finishing or am I being rebuilt.
public class ComponentActivity implements ViewModelStoreOwner {

    // The retained slot. In real Android this also carries retained fragments
    // and a custom object, and the only field that matters here is the store.
    public record NonConfigurationInstances(ViewModelStore viewModelStore) { }

    private final Lifecycle lifecycle = new Lifecycle();
    private ViewModelStore store;
    private boolean changingConfigurations;

    public ComponentActivity() {
        // The one line the whole question is about. A destroy that is part of a
        // rebuild leaves the store alone, because the next instance is about to
        // pick it up. Any other destroy ends every model in it.
        lifecycle.addObserver(state -> {
            if (state == Lifecycle.State.DESTROYED && !changingConfigurations && store != null) {
                store.clear();
            }
        });
    }

    public final Lifecycle lifecycle() {
        return lifecycle;
    }

    // Created on first use, so a screen that never asks for a ViewModel never
    // allocates a store and never has one retained for it.
    @Override
    public final ViewModelStore viewModelStore() {
        if (store == null) {
            store = new ViewModelStore();
        }
        return store;
    }

    public final boolean isChangingConfigurations() {
        return changingConfigurations;
    }

    protected void onCreate() { }

    protected void onDestroy() { }

    // The three calls below belong to the framework side and nothing else.
    final void attach(NonConfigurationInstances last) {
        if (last != null) {
            store = last.viewModelStore();
        }
        onCreate();
        lifecycle.moveTo(Lifecycle.State.RESUMED);
    }

    final NonConfigurationInstances retainNonConfigurationInstances() {
        return store == null ? null : new NonConfigurationInstances(store);
    }

    final void performDestroy(boolean changingConfigurations) {
        this.changingConfigurations = changingConfigurations;
        onDestroy();
        lifecycle.moveTo(Lifecycle.State.DESTROYED);
    }
}
package com.androidinterview.viewmodel.host;

import com.androidinterview.viewmodel.ViewModelStore;
import com.androidinterview.viewmodel.ViewModelStoreOwner;

public class ComponentActivity implements ViewModelStoreOwner {

    public record NonConfigurationInstances(ViewModelStore viewModelStore) { }

    private final Lifecycle lifecycle = new Lifecycle();
    private ViewModelStore store;
    private boolean changingConfigurations;

    public ComponentActivity() {
        lifecycle.addObserver(state -> {
            if (state == Lifecycle.State.DESTROYED && !changingConfigurations && store != null) {
                store.clear();
            }
        });
    }

    public final Lifecycle lifecycle() {
        return lifecycle;
    }

    @Override
    public final ViewModelStore viewModelStore() {
        if (store == null) {
            store = new ViewModelStore();
        }
        return store;
    }

    public final boolean isChangingConfigurations() {
        return changingConfigurations;
    }

    protected void onCreate() { }

    protected void onDestroy() { }

    final void attach(NonConfigurationInstances last) {
        if (last != null) {
            store = last.viewModelStore();
        }
        onCreate();
        lifecycle.moveTo(Lifecycle.State.RESUMED);
    }

    final NonConfigurationInstances retainNonConfigurationInstances() {
        return store == null ? null : new NonConfigurationInstances(store);
    }

    final void performDestroy(boolean changingConfigurations) {
        this.changingConfigurations = changingConfigurations;
        onDestroy();
        lifecycle.moveTo(Lifecycle.State.DESTROYED);
    }
}

com.androidinterview.viewmodel.host.Fragment.java

package com.androidinterview.viewmodel.host;

import com.androidinterview.viewmodel.ViewModelProvider;
import com.androidinterview.viewmodel.ViewModelStore;
import com.androidinterview.viewmodel.ViewModelStoreOwner;

// A fragment is a second owner living inside the first, and that sentence is
// the whole of ViewModel sharing. Ask this fragment for a store and you get one
// private to it. Ask the activity and every fragment gets the same one, so a
// shared model is a choice of which owner you ask, not a feature.
public class Fragment implements ViewModelStoreOwner {

    private final String tag;
    private ComponentActivity host;

    public Fragment(String tag) {
        this.tag = tag;
    }

    public void attachTo(ComponentActivity activity) {
        this.host = activity;
    }

    public ComponentActivity requireActivity() {
        if (host == null) {
            throw new IllegalStateException("fragment " + tag + " is not attached");
        }
        return host;
    }

    @Override
    public ViewModelStore viewModelStore() {
        return holder().storeFor(tag);
    }

    // The shared owner. Two fragments that go through this get the same
    // instance, because there is one activity and one activity store.
    public ViewModelStoreOwner activityOwner() {
        return requireActivity();
    }

    // Popped off the back stack rather than rotated. The fragment's own store
    // is cleared here, which is why a fragment scoped model dies with the
    // fragment while an activity scoped one carries on.
    public void removeForGood() {
        holder().remove(tag);
        host = null;
    }

    private FragmentStoreHolder holder() {
        return new ViewModelProvider(requireActivity()).get(FragmentStoreHolder.class);
    }
}
package com.androidinterview.viewmodel.host;

import com.androidinterview.viewmodel.ViewModelProvider;
import com.androidinterview.viewmodel.ViewModelStore;
import com.androidinterview.viewmodel.ViewModelStoreOwner;

public class Fragment implements ViewModelStoreOwner {

    private final String tag;
    private ComponentActivity host;

    public Fragment(String tag) {
        this.tag = tag;
    }

    public void attachTo(ComponentActivity activity) {
        this.host = activity;
    }

    public ComponentActivity requireActivity() {
        if (host == null) {
            throw new IllegalStateException("fragment " + tag + " is not attached");
        }
        return host;
    }

    @Override
    public ViewModelStore viewModelStore() {
        return holder().storeFor(tag);
    }

    public ViewModelStoreOwner activityOwner() {
        return requireActivity();
    }

    public void removeForGood() {
        holder().remove(tag);
        host = null;
    }

    private FragmentStoreHolder holder() {
        return new ViewModelProvider(requireActivity()).get(FragmentStoreHolder.class);
    }
}

com.androidinterview.viewmodel.host.FragmentStoreHolder.java

package com.androidinterview.viewmodel.host;

import java.util.HashMap;
import java.util.Map;

import com.androidinterview.viewmodel.ViewModel;
import com.androidinterview.viewmodel.ViewModelStore;

// A ViewModel whose only job is to hold other stores, one per fragment tag.
// Fragments have no retained slot of their own, so this is the trick that gives
// them one. Their stores live inside a model in the activity's store, so they
// ride the same rotation and are cleared by the same call.
public final class FragmentStoreHolder extends ViewModel {

    private final Map<String, ViewModelStore> stores = new HashMap<>();

    public ViewModelStore storeFor(String tag) {
        return stores.computeIfAbsent(tag, key -> new ViewModelStore());
    }

    public void remove(String tag) {
        ViewModelStore removed = stores.remove(tag);
        if (removed != null) {
            removed.clear();
        }
    }

    @Override
    protected void onCleared() {
        for (ViewModelStore store : stores.values()) {
            store.clear();
        }
        stores.clear();
    }
}
package com.androidinterview.viewmodel.host;

import java.util.HashMap;
import java.util.Map;

import com.androidinterview.viewmodel.ViewModel;
import com.androidinterview.viewmodel.ViewModelStore;

public final class FragmentStoreHolder extends ViewModel {

    private final Map<String, ViewModelStore> stores = new HashMap<>();

    public ViewModelStore storeFor(String tag) {
        return stores.computeIfAbsent(tag, key -> new ViewModelStore());
    }

    public void remove(String tag) {
        ViewModelStore removed = stores.remove(tag);
        if (removed != null) {
            removed.clear();
        }
    }

    @Override
    protected void onCleared() {
        for (ViewModelStore store : stores.values()) {
            store.clear();
        }
        stores.clear();
    }
}

com.androidinterview.viewmodel.host.Lifecycle.java

package com.androidinterview.viewmodel.host;

import java.util.ArrayList;
import java.util.List;

// The minimum lifecycle the design needs. Real Android has more states and a
// bigger observer registry, and none of that changes the answer. What matters
// is that something can say the owner has reached its end, once, and that the
// store can listen for it.
public final class Lifecycle {

    public enum State { INITIALIZED, CREATED, STARTED, RESUMED, DESTROYED }

    public interface Observer {
        void onStateChanged(State state);
    }

    private final List<Observer> observers = new ArrayList<>();
    private State state = State.INITIALIZED;

    public State currentState() {
        return state;
    }

    public void addObserver(Observer observer) {
        observers.add(observer);
        observer.onStateChanged(state);
    }

    // Package private, so only the owner in this package drives the states.
    void moveTo(State next) {
        if (next == state) {
            return;
        }
        state = next;
        new ArrayList<>(observers).forEach(observer -> observer.onStateChanged(next));
    }
}
package com.androidinterview.viewmodel.host;

import java.util.ArrayList;
import java.util.List;

public final class Lifecycle {

    public enum State { INITIALIZED, CREATED, STARTED, RESUMED, DESTROYED }

    public interface Observer {
        void onStateChanged(State state);
    }

    private final List<Observer> observers = new ArrayList<>();
    private State state = State.INITIALIZED;

    public State currentState() {
        return state;
    }

    public void addObserver(Observer observer) {
        observers.add(observer);
        observer.onStateChanged(state);
    }

    void moveTo(State next) {
        if (next == state) {
            return;
        }
        state = next;
        new ArrayList<>(observers).forEach(observer -> observer.onStateChanged(next));
    }
}

com.androidinterview.viewmodel.scope.CloseableTaskScope.java

package com.androidinterview.viewmodel.scope;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executor;
import java.util.function.Consumer;

// A scope of background work that can be shut down in one call. No coroutines
// on purpose, because the shape is the point and the shape is the same. Work is
// launched into it, closing it cancels everything, and it is an AutoCloseable so
// a ViewModel can hold it without knowing what is inside.
public final class CloseableTaskScope implements AutoCloseable {

    // A handle on one piece of work. Cancellation is cooperative, as it is for a
    // coroutine. Work that has not started never runs, and work that has sees
    // this go false at its next check.
    public static final class Job {

        private volatile boolean active = true;

        public boolean isActive() {
            return active;
        }

        public void cancel() {
            active = false;
        }
    }

    private final Executor executor;
    private final List<Job> jobs = new ArrayList<>();
    private boolean closed;

    public CloseableTaskScope() {
        this(Runnable::run);
    }

    public CloseableTaskScope(Executor executor) {
        this.executor = executor;
    }

    public Job launch(Consumer<Job> work) {
        Job job = new Job();
        synchronized (this) {
            if (closed) {
                job.cancel();
            } else {
                jobs.add(job);
            }
        }
        if (job.isActive()) {
            executor.execute(() -> {
                if (job.isActive()) {
                    work.accept(job);
                }
            });
        }
        return job;
    }

    @Override
    public void close() {
        List<Job> running;
        synchronized (this) {
            if (closed) {
                return;
            }
            closed = true;
            running = new ArrayList<>(jobs);
            jobs.clear();
        }
        for (Job job : running) {
            job.cancel();
        }
    }
}
package com.androidinterview.viewmodel.scope;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executor;
import java.util.function.Consumer;

public final class CloseableTaskScope implements AutoCloseable {

    public static final class Job {

        private volatile boolean active = true;

        public boolean isActive() {
            return active;
        }

        public void cancel() {
            active = false;
        }
    }

    private final Executor executor;
    private final List<Job> jobs = new ArrayList<>();
    private boolean closed;

    public CloseableTaskScope() {
        this(Runnable::run);
    }

    public CloseableTaskScope(Executor executor) {
        this.executor = executor;
    }

    public Job launch(Consumer<Job> work) {
        Job job = new Job();
        synchronized (this) {
            if (closed) {
                job.cancel();
            } else {
                jobs.add(job);
            }
        }
        if (job.isActive()) {
            executor.execute(() -> {
                if (job.isActive()) {
                    work.accept(job);
                }
            });
        }
        return job;
    }

    @Override
    public void close() {
        List<Job> running;
        synchronized (this) {
            if (closed) {
                return;
            }
            closed = true;
            running = new ArrayList<>(jobs);
            jobs.clear();
        }
        for (Job job : running) {
            job.cancel();
        }
    }
}

com.androidinterview.viewmodel.scope.ViewModelScope.java

package com.androidinterview.viewmodel.scope;

import com.androidinterview.viewmodel.ViewModel;

// The wiring behind viewModelScope, in one method. The scope is stored on the
// model under a known key the first time anybody asks, and it is closed by the
// model's own clear, so nobody has to remember to cancel anything. The real one
// is a property holding a coroutine scope. The mechanism is identical.
public final class ViewModelScope {

    private static final String KEY = "com.androidinterview.viewmodel.scope.ViewModelScope.JOB_KEY";

    private ViewModelScope() { }

    public static CloseableTaskScope of(ViewModel model) {
        AutoCloseable existing = model.getCloseable(KEY);
        if (existing instanceof CloseableTaskScope scope) {
            return scope;
        }
        // addCloseable hands back whichever scope actually landed in the map,
        // so two threads racing here still end up sharing one.
        return model.addCloseable(KEY, new CloseableTaskScope());
    }
}
package com.androidinterview.viewmodel.scope;

import com.androidinterview.viewmodel.ViewModel;

public final class ViewModelScope {

    private static final String KEY = "com.androidinterview.viewmodel.scope.ViewModelScope.JOB_KEY";

    private ViewModelScope() { }

    public static CloseableTaskScope of(ViewModel model) {
        AutoCloseable existing = model.getCloseable(KEY);
        if (existing instanceof CloseableTaskScope scope) {
            return scope;
        }
        return model.addCloseable(KEY, new CloseableTaskScope());
    }
}

Kotlin

com.androidinterview.viewmodel.ViewModel.kt

package com.androidinterview.viewmodel

// The base class. Two things make it a ViewModel rather than a plain object.
// It has one hook that runs exactly once, at the end of its life, and a bag of
// AutoCloseable resources closed at that same moment. Every scope and every
// subscription a real ViewModel holds is one of those closeables, so there is a
// single place that has to be right.
abstract class ViewModel {

    private val closeables = linkedMapOf<String, AutoCloseable>()
    private var anonymous = 0
    private var cleared = false

    // Called once, when the store that owns this model lets it go. Never on a
    // rotation. Override it to drop anything that is not already a closeable.
    protected open fun onCleared() = Unit

    fun addCloseable(closeable: AutoCloseable) {
        addCloseable("anonymous:${anonymous++}", closeable)
    }

    // The keyed form, and the one viewModelScope uses. First caller wins, so a
    // scope is created once even if two threads ask at the same moment.
    @Suppress("UNCHECKED_CAST")
    fun <T : AutoCloseable> addCloseable(key: String, closeable: T): T = synchronized(this) {
        // Registering on an already cleared model is a race, not a bug. Close
        // the resource straight away rather than leak it.
        if (cleared) closeable.also { it.closeQuietly() }
        else closeables.getOrPut(key) { closeable } as T
    }

    fun getCloseable(key: String): AutoCloseable? = synchronized(this) { closeables[key] }

    // internal on purpose. Only the store holding this model may end its life,
    // so no screen can clear a model another screen is still using.
    internal fun clear() {
        val toClose = synchronized(this) {
            if (cleared) return
            cleared = true
            closeables.values.toList().also { closeables.clear() }
        }
        toClose.forEach { it.closeQuietly() }
        onCleared()
    }
}

// One resource failing to close must not strand the rest.
private fun AutoCloseable.closeQuietly() {
    runCatching { close() }
}
package com.androidinterview.viewmodel

abstract class ViewModel {

    private val closeables = linkedMapOf<String, AutoCloseable>()
    private var anonymous = 0
    private var cleared = false

    protected open fun onCleared() = Unit

    fun addCloseable(closeable: AutoCloseable) {
        addCloseable("anonymous:${anonymous++}", closeable)
    }

    @Suppress("UNCHECKED_CAST")
    fun <T : AutoCloseable> addCloseable(key: String, closeable: T): T = synchronized(this) {
        if (cleared) closeable.also { it.closeQuietly() }
        else closeables.getOrPut(key) { closeable } as T
    }

    fun getCloseable(key: String): AutoCloseable? = synchronized(this) { closeables[key] }

    internal fun clear() {
        val toClose = synchronized(this) {
            if (cleared) return
            cleared = true
            closeables.values.toList().also { closeables.clear() }
        }
        toClose.forEach { it.closeQuietly() }
        onCleared()
    }
}

private fun AutoCloseable.closeQuietly() {
    runCatching { close() }
}

com.androidinterview.viewmodel.ViewModelProvider.kt

package com.androidinterview.viewmodel

// Look up by key, and only build when the lookup misses. That is the entire
// class. It owns no models and holds no cache of its own, so constructing a
// second provider over the same owner changes nothing.
class ViewModelProvider(
    owner: ViewModelStoreOwner,
    private val factory: Factory = NewInstanceFactory,
) {

    private val store = owner.viewModelStore

    // Creation is a separate job from lookup, because a model with constructor
    // arguments still has to be found in the store on the way back. In Kotlin
    // this is usually a lambda, never a class.
    interface Factory {
        fun <T : ViewModel> create(type: Class<T>): T
    }

    // The fallback for a model that needs nothing.
    object NewInstanceFactory : Factory {
        override fun <T : ViewModel> create(type: Class<T>): T =
            runCatching { type.getDeclaredConstructor().newInstance() }
                .getOrElse { throw IllegalArgumentException("${type.name} needs a no argument constructor", it) }
    }

    // The key defaults to one derived from the class, so a screen gets one model
    // of each type without anybody naming anything. Pass a key and two models of
    // the same class live side by side.
    fun <T : ViewModel> get(type: Class<T>, key: String = defaultKey(type)): T {
        store[key]?.let { existing ->
            require(type.isInstance(existing)) { "key $key already holds a ${existing.javaClass.name}" }
            return type.cast(existing)
        }
        return factory.create(type).also { store[key] = it }
    }

    companion object {
        private const val PREFIX = "com.androidinterview.viewmodel.ViewModelProvider.DefaultKey"

        fun defaultKey(type: Class<*>): String = "$PREFIX:${type.name}"
    }
}

// Reified generics remove the Class argument the Java side has to carry.
inline fun <reified T : ViewModel> ViewModelProvider.get(key: String? = null): T =
    get(T::class.java, key ?: ViewModelProvider.defaultKey(T::class.java))

// The delegate, and the reason Kotlin reads better here. Written as
// `private val model: CartViewModel by viewModels()`, the lookup happens on
// first touch and the owner is whatever the screen is.
inline fun <reified T : ViewModel> ViewModelStoreOwner.viewModels(
    key: String? = null,
    crossinline factory: () -> ViewModelProvider.Factory = { ViewModelProvider.NewInstanceFactory },
): Lazy<T> = lazy { ViewModelProvider(this, factory()).get<T>(key) }
package com.androidinterview.viewmodel

class ViewModelProvider(
    owner: ViewModelStoreOwner,
    private val factory: Factory = NewInstanceFactory,
) {

    private val store = owner.viewModelStore

    interface Factory {
        fun <T : ViewModel> create(type: Class<T>): T
    }

    object NewInstanceFactory : Factory {
        override fun <T : ViewModel> create(type: Class<T>): T =
            runCatching { type.getDeclaredConstructor().newInstance() }
                .getOrElse { throw IllegalArgumentException("${type.name} needs a no argument constructor", it) }
    }

    fun <T : ViewModel> get(type: Class<T>, key: String = defaultKey(type)): T {
        store[key]?.let { existing ->
            require(type.isInstance(existing)) { "key $key already holds a ${existing.javaClass.name}" }
            return type.cast(existing)
        }
        return factory.create(type).also { store[key] = it }
    }

    companion object {
        private const val PREFIX = "com.androidinterview.viewmodel.ViewModelProvider.DefaultKey"

        fun defaultKey(type: Class<*>): String = "$PREFIX:${type.name}"
    }
}

inline fun <reified T : ViewModel> ViewModelProvider.get(key: String? = null): T =
    get(T::class.java, key ?: ViewModelProvider.defaultKey(T::class.java))

inline fun <reified T : ViewModel> ViewModelStoreOwner.viewModels(
    key: String? = null,
    crossinline factory: () -> ViewModelProvider.Factory = { ViewModelProvider.NewInstanceFactory },
): Lazy<T> = lazy { ViewModelProvider(this, factory()).get<T>(key) }

com.androidinterview.viewmodel.ViewModelStore.kt

package com.androidinterview.viewmodel

// One method, and it is the seam the whole design turns on. Anything that can
// hand back a store can own ViewModels, so the provider never has to know
// whether it is talking to an activity, a fragment or a test double.
interface ViewModelStoreOwner {

    val viewModelStore: ViewModelStore
}

// A map from key to instance, and nothing else. This is the whole of the
// mechanism people think is magic. It is deliberately not a singleton and not
// an object, because there is one per screen and its life is the screen's life.
class ViewModelStore {

    private val models = mutableMapOf<String, ViewModel>()

    operator fun get(key: String): ViewModel? = models[key]

    // Replacing under a live key would silently leak the old model's resources,
    // so the one being displaced is ended here.
    operator fun set(key: String, model: ViewModel) {
        models.put(key, model)?.clear()
    }

    // The only way a model ever dies. Called when the owner is finishing for
    // real, never when it is being recreated.
    fun clear() {
        models.values.forEach(ViewModel::clear)
        models.clear()
    }
}
package com.androidinterview.viewmodel

interface ViewModelStoreOwner {

    val viewModelStore: ViewModelStore
}

class ViewModelStore {

    private val models = mutableMapOf<String, ViewModel>()

    operator fun get(key: String): ViewModel? = models[key]

    operator fun set(key: String, model: ViewModel) {
        models.put(key, model)?.clear()
    }

    fun clear() {
        models.values.forEach(ViewModel::clear)
        models.clear()
    }
}

com.androidinterview.viewmodel.host.ActivityThread.kt

package com.androidinterview.viewmodel.host

// The framework side, and the reason any of this survives a rotation. It lives
// in the process rather than in a screen, so it can hold the retained slot in
// the gap where the old activity object is gone and the new one does not exist
// yet. It is also the whole answer to process death. Kill the process and this
// map goes with it, no callback runs, and nothing was ever written down.
class ActivityThread {

    private class Record(val builder: () -> ComponentActivity) {
        lateinit var current: ComponentActivity
    }

    private val records = mutableMapOf<String, Record>()

    fun launch(id: String, builder: () -> ComponentActivity): ComponentActivity =
        Record(builder).also { record ->
            record.current = builder().apply { attach(null) }
            records[id] = record
        }.current

    // A rotation. The old screen is destroyed and a brand new one is built, and
    // the only thing that crosses the gap is the retained slot.
    fun configurationChange(id: String): ComponentActivity {
        val record = recordFor(id)
        val retained = record.current.retainNonConfigurationInstances()
        record.current.performDestroy(changingConfigurations = true)
        record.current = record.builder().apply { attach(retained) }
        return record.current
    }

    // A real finish, a back press or a call to finish. Nothing is retained, so
    // the destroy clears the store and every model gets onCleared.
    fun finish(id: String) {
        recordFor(id).current.performDestroy(changingConfigurations = false)
        records -= id
    }

    // Process death, modelled honestly. No destroy runs, no store is cleared,
    // and every model in the process is simply gone.
    fun killProcess() = records.clear()

    private fun recordFor(id: String) = records[id] ?: error("no activity with id $id")
}
package com.androidinterview.viewmodel.host

class ActivityThread {

    private class Record(val builder: () -> ComponentActivity) {
        lateinit var current: ComponentActivity
    }

    private val records = mutableMapOf<String, Record>()

    fun launch(id: String, builder: () -> ComponentActivity): ComponentActivity =
        Record(builder).also { record ->
            record.current = builder().apply { attach(null) }
            records[id] = record
        }.current

    fun configurationChange(id: String): ComponentActivity {
        val record = recordFor(id)
        val retained = record.current.retainNonConfigurationInstances()
        record.current.performDestroy(changingConfigurations = true)
        record.current = record.builder().apply { attach(retained) }
        return record.current
    }

    fun finish(id: String) {
        recordFor(id).current.performDestroy(changingConfigurations = false)
        records -= id
    }

    fun killProcess() = records.clear()

    private fun recordFor(id: String) = records[id] ?: error("no activity with id $id")
}

com.androidinterview.viewmodel.host.ComponentActivity.kt

package com.androidinterview.viewmodel.host

import com.androidinterview.viewmodel.ViewModelStore
import com.androidinterview.viewmodel.ViewModelStoreOwner

// The minimum lifecycle the design needs. What matters is that something can
// say the owner has reached its end, once, and that the store can hear it.
class Lifecycle {

    enum class State { INITIALIZED, CREATED, STARTED, RESUMED, DESTROYED }

    fun interface Observer {
        fun onStateChanged(state: State)
    }

    private val observers = mutableListOf<Observer>()

    var currentState: State = State.INITIALIZED
        private set

    fun addObserver(observer: Observer) {
        observers += observer
        observer.onStateChanged(currentState)
    }

    internal fun moveTo(next: State) {
        if (next == currentState) return
        currentState = next
        observers.toList().forEach { it.onStateChanged(next) }
    }
}

// The screen. It owns a store, hands that store to the framework on the way
// out, and clears it only when it is going away for good. It never keeps a
// static reference to anything and it never asks whether the device rotated. It
// answers one question, am I finishing or am I being rebuilt.
open class ComponentActivity : ViewModelStoreOwner {

    // The retained slot. In real Android this also carries retained fragments
    // and a custom object, and the only field that matters here is the store.
    data class NonConfigurationInstances(val viewModelStore: ViewModelStore)

    val lifecycle = Lifecycle()

    private var store: ViewModelStore? = null

    var isChangingConfigurations: Boolean = false
        private set

    init {
        // The one line the whole question is about. A destroy that is part of a
        // rebuild leaves the store alone, because the next instance is about to
        // pick it up. Any other destroy ends every model in it.
        lifecycle.addObserver { state ->
            if (state == Lifecycle.State.DESTROYED && !isChangingConfigurations) store?.clear()
        }
    }

    // Created on first use, so a screen that never asks for a ViewModel never
    // allocates a store and never has one retained for it.
    override val viewModelStore: ViewModelStore
        get() = store ?: ViewModelStore().also { store = it }

    protected open fun onCreate() = Unit

    protected open fun onDestroy() = Unit

    // The three calls below belong to the framework side and nothing else.
    internal fun attach(last: NonConfigurationInstances?) {
        last?.let { store = it.viewModelStore }
        onCreate()
        lifecycle.moveTo(Lifecycle.State.RESUMED)
    }

    internal fun retainNonConfigurationInstances(): NonConfigurationInstances? =
        store?.let(::NonConfigurationInstances)

    internal fun performDestroy(changingConfigurations: Boolean) {
        isChangingConfigurations = changingConfigurations
        onDestroy()
        lifecycle.moveTo(Lifecycle.State.DESTROYED)
    }
}
package com.androidinterview.viewmodel.host

import com.androidinterview.viewmodel.ViewModelStore
import com.androidinterview.viewmodel.ViewModelStoreOwner

class Lifecycle {

    enum class State { INITIALIZED, CREATED, STARTED, RESUMED, DESTROYED }

    fun interface Observer {
        fun onStateChanged(state: State)
    }

    private val observers = mutableListOf<Observer>()

    var currentState: State = State.INITIALIZED
        private set

    fun addObserver(observer: Observer) {
        observers += observer
        observer.onStateChanged(currentState)
    }

    internal fun moveTo(next: State) {
        if (next == currentState) return
        currentState = next
        observers.toList().forEach { it.onStateChanged(next) }
    }
}

open class ComponentActivity : ViewModelStoreOwner {

    data class NonConfigurationInstances(val viewModelStore: ViewModelStore)

    val lifecycle = Lifecycle()

    private var store: ViewModelStore? = null

    var isChangingConfigurations: Boolean = false
        private set

    init {
        lifecycle.addObserver { state ->
            if (state == Lifecycle.State.DESTROYED && !isChangingConfigurations) store?.clear()
        }
    }

    override val viewModelStore: ViewModelStore
        get() = store ?: ViewModelStore().also { store = it }

    protected open fun onCreate() = Unit

    protected open fun onDestroy() = Unit

    internal fun attach(last: NonConfigurationInstances?) {
        last?.let { store = it.viewModelStore }
        onCreate()
        lifecycle.moveTo(Lifecycle.State.RESUMED)
    }

    internal fun retainNonConfigurationInstances(): NonConfigurationInstances? =
        store?.let(::NonConfigurationInstances)

    internal fun performDestroy(changingConfigurations: Boolean) {
        isChangingConfigurations = changingConfigurations
        onDestroy()
        lifecycle.moveTo(Lifecycle.State.DESTROYED)
    }
}

com.androidinterview.viewmodel.host.Fragment.kt

package com.androidinterview.viewmodel.host

import com.androidinterview.viewmodel.ViewModel
import com.androidinterview.viewmodel.ViewModelProvider
import com.androidinterview.viewmodel.ViewModelStore
import com.androidinterview.viewmodel.ViewModelStoreOwner
import com.androidinterview.viewmodel.get

// A ViewModel whose only job is to hold other stores, one per fragment tag.
// Fragments have no retained slot of their own, so this is the trick that gives
// them one. Their stores live inside a model in the activity's store, so they
// ride the same rotation and are cleared by the same call.
class FragmentStoreHolder : ViewModel() {

    private val stores = mutableMapOf<String, ViewModelStore>()

    fun storeFor(tag: String): ViewModelStore = stores.getOrPut(tag) { ViewModelStore() }

    fun remove(tag: String) {
        stores.remove(tag)?.clear()
    }

    override fun onCleared() {
        stores.values.forEach(ViewModelStore::clear)
        stores.clear()
    }
}

// A fragment is a second owner living inside the first, and that sentence is
// the whole of ViewModel sharing. Ask this fragment for a store and you get one
// private to it. Ask the activity and every fragment gets the same one, so a
// shared model is a choice of which owner you ask, not a feature.
class Fragment(private val tag: String) : ViewModelStoreOwner {

    private var host: ComponentActivity? = null

    fun attachTo(activity: ComponentActivity) {
        host = activity
    }

    fun requireActivity(): ComponentActivity = checkNotNull(host) { "fragment $tag is not attached" }

    override val viewModelStore: ViewModelStore
        get() = holder().storeFor(tag)

    // Popped off the back stack rather than rotated. The fragment's own store is
    // cleared here, which is why a fragment scoped model dies with the fragment
    // while an activity scoped one carries on.
    fun removeForGood() {
        holder().remove(tag)
        host = null
    }

    private fun holder() = ViewModelProvider(requireActivity()).get<FragmentStoreHolder>()
}

// The shared lookup, written the way androidx writes it. Two fragments that go
// through this get the same instance, because there is one activity store.
inline fun <reified T : ViewModel> Fragment.activityViewModels(key: String? = null): Lazy<T> =
    lazy { ViewModelProvider(requireActivity()).get<T>(key) }
package com.androidinterview.viewmodel.host

import com.androidinterview.viewmodel.ViewModel
import com.androidinterview.viewmodel.ViewModelProvider
import com.androidinterview.viewmodel.ViewModelStore
import com.androidinterview.viewmodel.ViewModelStoreOwner
import com.androidinterview.viewmodel.get

class FragmentStoreHolder : ViewModel() {

    private val stores = mutableMapOf<String, ViewModelStore>()

    fun storeFor(tag: String): ViewModelStore = stores.getOrPut(tag) { ViewModelStore() }

    fun remove(tag: String) {
        stores.remove(tag)?.clear()
    }

    override fun onCleared() {
        stores.values.forEach(ViewModelStore::clear)
        stores.clear()
    }
}

class Fragment(private val tag: String) : ViewModelStoreOwner {

    private var host: ComponentActivity? = null

    fun attachTo(activity: ComponentActivity) {
        host = activity
    }

    fun requireActivity(): ComponentActivity = checkNotNull(host) { "fragment $tag is not attached" }

    override val viewModelStore: ViewModelStore
        get() = holder().storeFor(tag)

    fun removeForGood() {
        holder().remove(tag)
        host = null
    }

    private fun holder() = ViewModelProvider(requireActivity()).get<FragmentStoreHolder>()
}

inline fun <reified T : ViewModel> Fragment.activityViewModels(key: String? = null): Lazy<T> =
    lazy { ViewModelProvider(requireActivity()).get<T>(key) }

com.androidinterview.viewmodel.scope.CloseableTaskScope.kt

package com.androidinterview.viewmodel.scope

import java.util.concurrent.Executor

import com.androidinterview.viewmodel.ViewModel

// A scope of background work that can be shut down in one call. No coroutines
// on purpose, because the shape is the point and the shape is the same. Work is
// launched into it, closing it cancels everything, and it is an AutoCloseable so
// a ViewModel can hold it without knowing what is inside.
class CloseableTaskScope(private val executor: Executor = Executor(Runnable::run)) : AutoCloseable {

    // A handle on one piece of work. Cancellation is cooperative, as it is for a
    // coroutine. Work that has not started never runs, and work that has sees
    // this go false at its next check.
    class Job {

        @Volatile
        var isActive: Boolean = true
            private set

        fun cancel() {
            isActive = false
        }
    }

    private val jobs = mutableListOf<Job>()
    private var closed = false

    fun launch(work: (Job) -> Unit): Job {
        val job = Job()
        synchronized(this) { if (closed) job.cancel() else jobs += job }
        if (job.isActive) executor.execute { if (job.isActive) work(job) }
        return job
    }

    override fun close() {
        val running = synchronized(this) {
            if (closed) return
            closed = true
            jobs.toList().also { jobs.clear() }
        }
        running.forEach(Job::cancel)
    }
}

private const val SCOPE_KEY = "com.androidinterview.viewmodel.scope.CloseableTaskScope.JOB_KEY"

// The wiring behind viewModelScope, in one property. The scope is stored on the
// model under a known key the first time anybody asks, and it is closed by the
// model's own clear, so nobody has to remember to cancel anything. The real one
// holds a coroutine scope. The mechanism is identical.
val ViewModel.viewModelScope: CloseableTaskScope
    get() = getCloseable(SCOPE_KEY) as CloseableTaskScope?
        ?: addCloseable(SCOPE_KEY, CloseableTaskScope())
package com.androidinterview.viewmodel.scope

import java.util.concurrent.Executor

import com.androidinterview.viewmodel.ViewModel

class CloseableTaskScope(private val executor: Executor = Executor(Runnable::run)) : AutoCloseable {

    class Job {

        @Volatile
        var isActive: Boolean = true
            private set

        fun cancel() {
            isActive = false
        }
    }

    private val jobs = mutableListOf<Job>()
    private var closed = false

    fun launch(work: (Job) -> Unit): Job {
        val job = Job()
        synchronized(this) { if (closed) job.cancel() else jobs += job }
        if (job.isActive) executor.execute { if (job.isActive) work(job) }
        return job
    }

    override fun close() {
        val running = synchronized(this) {
            if (closed) return
            closed = true
            jobs.toList().also { jobs.clear() }
        }
        running.forEach(Job::cancel)
    }
}

private const val SCOPE_KEY = "com.androidinterview.viewmodel.scope.CloseableTaskScope.JOB_KEY"

val ViewModel.viewModelScope: CloseableTaskScope
    get() = getCloseable(SCOPE_KEY) as CloseableTaskScope?
        ?: addCloseable(SCOPE_KEY, CloseableTaskScope())

Watch