Привет, Хабр! На связи Алина, старший Android-разработчик в команде Инвестиций компании «Совкомбанк Технологии». Мы разрабатываем, поддерживаем и улучшаем приложение «Совкомбанк Инвестиции».
Если вы хоть раз теряли пользовательский ввод после поворота экрана или возвращались к приложению из фона и обнаруживали пустой экран вместо заполненной формы — вы знаете, о чем пойдет речь. Сохранение состояния в Android — одна из тех тем, которые кажутся простыми до первого бага в проде. Система может убить процесс в любой момент, конфигурация меняется, а пользователь ожидает, что все останется на своих местах.
Ниже расскажу, как паттерн Memento реализуется в Android через CustomView, SavedStateHandle, Compose и навигацию. Основное внимание уделю CustomView — недооцененному способу сохранения состояния, который позволяет держать экраны легкими. View сама знает, что сохранять и как восстанавливать, без необходимости тащить все в Activity или Fragment. Также рассмотрю типичные ошибки, ограничения Bundle и методы тестирования восстановления после process death.

Зачем Android-разработчику Memento
Memento (Снимок) — поведенческий паттерн, который позволяет сохранять и восстанавливать состояние объекта, не раскрывая его внутреннюю реализацию. В классическом виде участвуют 3 роли:
Originator — объект, чьё состояние сохраняется и восстанавливается
Memento — неизменяемый снимок состояния
Caretaker — сохраняет и передаёт снимки, но не интерпретирует их
В Android эта идея не просто «встречается», а системно поддерживается платформой и библиотеками. Главное для нас — научиться осознанно делегировать сохранение состояния тем компонентам, которые лучше всего знают, что именно им нужно сохранять. Особенно это важно для CustomView, где паттерн раскрывается максимально практично: вид сам хранит своё состояние и восстанавливает его, не перегружая Activity/Fragment/ViewModel.
Ниже представлена Android-ориентированная подборка проверенных применений Memento, с акцентом на View и платформенные механизмы.
Критерии, по которым мы называем решение Memento на Android
Originator — объект с чётко инкапсулированным состоянием, например, конкретная View.
Memento — компактный, неизменяемый переносимый снимок. Обычно это Parcelable или структура, которую в итоге сериализует Bundle.
Caretaker — хранитель снимков, который сохраняет и возвращает их, не зная внутренней структуры. На Android это часто сам фреймворк через SavedStateRegistry, иерархию View или навигацию.
Важно отметить, что в Android-реализации мы часто отступаем от строгой инкапсуляции классического Memento — Bundle и Parcelable структуры обычно публичны. Это компромисс между чистотой паттерна и практичностью платформы
1) CustomView как первоклассный Originator
Самый «взрослый» Android-кейс Memento — когда состояние UI лежит там, где оно возникает, то есть внутри View. Так вы не раздуваете Activity/Fragment и не плодите лишнюю связность.
Базовый шаблон с BaseSavedState
@Parcelize data class InputUiState( val userInput: String, val errorText: String?, val enabled: Boolean ) : Parcelable class InputViewSavedState : BaseSavedState { val uiState: InputUiState? constructor(uiState: InputUiState?, superState: Parcelable?) : super(superState) { this.uiState = uiState } internal constructor(source: Parcel) : super(source) { uiState = source.readParcelableCompat<InputUiState>() } override fun writeToParcel(out: Parcel, flags: Int) { super.writeToParcel(out, flags) out.writeParcelable(uiState, flags) } companion object CREATOR : Parcelable.Creator<InputViewSavedState> { override fun createFromParcel(source: Parcel) = InputViewSavedState(source) override fun newArray(size: Int) = arrayOfNulls<InputViewSavedState>(size) } } class InputView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : FrameLayout(context, attrs, defStyleAttr) { private var uiState = InputUiState("", errorText = null, enabled = true) private val binding = InputBinding.inflate(LayoutInflater.from(context), this, true) override fun onSaveInstanceState(): Parcelable { val superState = super.onSaveInstanceState() return InputViewSavedState(uiState, superState) } override fun onRestoreInstanceState(state: Parcelable?) { when (state) { is InputViewSavedState -> { super.onRestoreInstanceState(state.superState) state.uiState?.let(::applyState) } else -> super.onRestoreInstanceState(state) } } fun applyState(newState: InputUiState) { uiState = newState binding.inputEditText.setText(newState.userInput) binding.inputLayout.isEnabled = newState.enabled binding.inputLayout.apply { error = newState.errorText?.takeIf { it.isNotBlank() } isErrorEnabled = error != null } } fun currentState(): InputUiState = uiState } // ParcelExtensions.kt inline fun <reified T : Parcelable> Parcel.readParcelableCompat(): T? { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { readParcelable(T::class.java.classLoader, T::class.java) } else { @Suppress("DEPRECATION") readParcelable(T::class.java.classLoader) } }
Роли:
Originator — InputView
Memento — InputViewSavedState с InputUiState
Caretaker — Android Framework, который вызывает onSaveInstanceState и затем onRestoreInstanceState
Практические советы:
Держите снимок компактным и неизменяемым
Не храните ссылки на View, Context, слушатели и прочие несериализуемые поля
Убедитесь, что у View есть стабильный id — без него фреймворк не сохранит состояние
Сохранение работает по умолчанию. Исключение: если родитель вызвал setSaveFromParentEnabled(false), тогда дочерние View не сохранятся, даже с isSaveEnabled = true
Важно: @Parcelize требует плагин kotlin-parcelize, работает только с val-полями, и все поля должны быть Parcelable/примитивами или их коллекциями. При этом @Parcelize не поддерживает интерфейсы и sealed классы с type parameters.
Сохранение состояния без дочерних View Если требуется сохранить только собственное состояние, а не состояние всех дочерних View, можно переопределить методы dispatchSaveInstanceState и dispatchRestoreInstanceState.
override fun dispatchSaveInstanceState(container: SparseArray<Parcelable>) { dispatchFreezeSelfOnly(container) // сохраняем состояние только текущей View, не детей } override fun dispatchRestoreInstanceState(container: SparseArray<Parcelable>) { dispatchThawSelfOnly(container) // восстанавливаем только текущую View }
Это полезно для сложных составных компонентов, где вы сами управляете дочерними состояниями и не хотите дублирования.
Предупреждение: после этого дочерние элементы не будут сохраняться автоматически — вы обязаны управлять их состоянием вручную , иначе потеряете, например, позицию RecyclerView.
Фильтры со сбросом и включением
@Parcelize data class FilterState( val query: String = "", val categories: Set<String> = emptySet(), val onlyFavorites: Boolean = false, val enabled: Boolean = true ) : Parcelable { fun reset(): FilterState = FilterState(enabled = enabled) fun toggleEnabled(): FilterState = copy(enabled = !enabled) } class FilterPanelSavedState : BaseSavedState { val filterState: FilterState? constructor(filterState: FilterState?, superState: Parcelable?) : super(superState) { this.filterState = filterState } internal constructor(source: Parcel) : super(source) { filterState = source.readParcelableCompat<FilterState>() } override fun writeToParcel(out: Parcel, flags: Int) { super.writeToParcel(out, flags) out.writeParcelable(filterState, flags) } companion object CREATOR : Parcelable.Creator<FilterPanelSavedState> { override fun createFromParcel(source: Parcel) = FilterPanelSavedState(source) override fun newArray(size: Int) = arrayOfNulls<FilterPanelSavedState>(size) } } class FilterPanelView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : LinearLayout(context, attrs) { private val binding = ViewFilterPanelBinding.inflate( LayoutInflater.from(context), this, true ) private var filterState = FilterState() fun render(state: FilterState) { filterState = state isEnabled = state.enabled binding.queryEdit.setText(state.query) binding.categoriesChips.setSelectedCategories(state.categories) binding.favoritesSwitch.isChecked = state.onlyFavorites } fun getState(): FilterState = filterState fun reset() { render(filterState.reset()) } fun toggleEnabled() { render(filterState.toggleEnabled()) } override fun onSaveInstanceState(): Parcelable { val superState = super.onSaveInstanceState() return FilterPanelSavedState(filterState, superState) } override fun onRestoreInstanceState(state: Parcelable?) { when (state) { is FilterPanelSavedState -> { super.onRestoreInstanceState(state.superState) state.filterState?.let(::render) } else -> super.onRestoreInstanceState(state) } } }
Итог: Fragment/Activity остаются тонкими. View сама знает, что ей сохранять и как восстанавливать. Это чистое, локализованное применение Memento.
2) Activity/Fragment и onSaveInstanceState
class CounterActivity : AppCompatActivity() { private lateinit var binding: ActivityCounterBinding private var counter = 0 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityCounterBinding.inflate(layoutInflater) setContentView(binding.root) counter = savedInstanceState?.getInt(KEY_COUNTER) ?: 0 binding.counterText.text = counter.toString() binding.incrementButton.setOnClickListener { counter++ binding.counterText.text = counter.toString() } } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putInt(KEY_COUNTER, counter) } private companion object { const val KEY_COUNTER = "counter" } }
Роли:
Originator: CounterActivity или конкретные её View
Memento: часть Bundle
Caretaker: Framework, который сериализует и возвращает savedInstanceState
Уточнения:
Когда вызывает: при конфигурационных изменениях и когда система может повредить процесс в фоне — фреймворк заранее сохраняет снимок.
Важно: при явном закрытии экрана пользователем, например, через вызов метода
finish()или нажатие кнопки «Назад», методonSaveInstanceStateне вызывается, так как пользователь намеренно покинул экран и не ожидает его восстановления. ИспользуйтеonPause()илиonStop()для сохранения бизнес-данных.Что сохранять: только компактное UI-состояние. Большие данные сохраняйте в репозитории или базе данных.
Не дублируйте: если View сохраняет себя, не вытаскивайте её внутренности в Activity или Fragment.
Практические ограничения:
Bundle должен быть компактным. Храните только UI-состояние. Большие объёмы приведут к ошибкам уровня IPC
Bundle имеет жесткое ограничение ~1MB для IPC транзакций. При превышении получите TransactionTooLargeException. На Android 7.0+ строже — рекомендуется ~500KB
Сериализуемость обязательна. Избегайте сложных графов объектов.
Не храните: Bitmap, Context, View, Listener, большие коллекции, несериализуемые объекты
3) ViewModel с SavedStateHandle
SavedStateHandle — это обёртка над Bundle из Jetpack, которая сохраняет данные и восстанавливает их даже после убийства процесса.
Упрощенный подход
class CounterViewModel( private val stateHandle: SavedStateHandle ) : ViewModel() { companion object { private const val KEY_COUNTER = "counter" } val counter = stateHandle.getStateFlow(KEY_COUNTER, 0) fun increment() { stateHandle[KEY_COUNTER] = counter.value + 1 } }
Примечание: данный подход представляет собой «ослабленный» вариант паттерна Memento. Здесь нет явного создания неизменяемого снимка, состояние изменяется на месте через SavedStateHandle. Это скорее прозрачная персистентность, чем классический паттерн Memento, но с практической точки зрения он реализует суть паттерна — сохранение и восстановление состояния. Более каноничный Memento для ViewModel
Использование во Fragment:
class CounterFragment : Fragment() { private val viewModel: CounterViewModel by viewModels() private lateinit var binding: FragmentCounterBinding override fun onViewCreated(view: View, savedInstanceState: Bundle?) { binding = FragmentCounterBinding.bind(view) viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.counter.collect { state -> binding.counterText.text = state.value.toString() } } } binding.incrementButton.setOnClickListener { viewModel.increment() } } }
Роли точнее:
Originator: CounterViewModel
Memento: CounterState, который сериализуется в Bundle. Или в упрощенном варианте запись ключ-значение, которую в итоге сериализует SavedStateRegistry в Bundle
Caretaker: SavedStateRegistry/Framework. SavedStateHandle — это прокси, который предоставляет удобный API для работы с SavedStateRegistry
Важно: SavedStateHandle корректно подключается только если ViewModel создан фабрикой, которая «знает» про SavedStateRegistryOwner. Это может быть делегат by viewModels(), Hilt с аннотацией @HiltViewModel или навигационная фабрика. При ручном создании снимок не будет работать.
Практика:
Держите в SavedStateHandle только то, что критично для восстановления UI после процесс-килла и поворотов
Для сложных типов используйте маппинг к простым сериализуемым
Помните об ограничении ~1MB для Bundle

4) Compose: rememberSaveable и Saver как мост к Memento
Базовый случай
@Composable fun Counter() { var counter by rememberSaveable { mutableIntStateOf(0) } Column { Text("Counter: $counter") Button(onClick = { counter++ }) { Text("Increment") } } }
Сложный тип через Saver
data class FormState(val text: String, val enabled: Boolean) private const val KEY_TEXT = "text" private const val KEY_ENABLED = "enabled" val FormStateSaver: Saver<FormState, Bundle> = Saver( save = { state -> bundleOf( KEY_TEXT to state.text, KEY_ENABLED to state.enabled ) }, restore = { bundle -> FormState( text = bundle.getString(KEY_TEXT).orEmpty(), enabled = bundle.getBoolean(KEY_ENABLED, true) ) } ) @Composable fun Form() { var state by rememberSaveable(stateSaver = FormStateSaver) { mutableStateOf(FormState("", true)) } TextField( value = state.text, onValueChange = { state = state.copy(text = it) }, enabled = state.enabled ) }
Роли:
Originator — конкретный composable или его состояние
Memento — результат Saver.save, который далее сериализуется в Bundle
Caretaker — Compose runtime вместе с SavedStateRegistry
Практика:
Поддерживайте снимки компактными
Явно создавайте Saver для собственных типов, чтобы контролировать сериализацию
5) Навигация: сохранение back stack
Если вы реализуете собственную навигацию, сохранение стека экранов можно оформить через паттерн Memento.
// Тип маршрута. В реальном проекте лучше выделить собственный тип. typealias Route = String interface StateSavable<T : Parcelable> { fun saveState(): T fun restoreState(state: T) } class BackStackNavigator( initialStack: List<Route> = emptyList() ) : StateSavable<BackStackNavigator.SavedState> { private val stack = ArrayDeque<Route>().apply { addAll(initialStack) } fun push(route: Route) = stack.addLast(route) fun pop(): Route? = stack.removeLastOrNull() fun current(): Route? = stack.lastOrNull() override fun saveState(): SavedState = SavedState(stack.toList()) override fun restoreState(state: SavedState) { stack.clear() stack.addAll(state.routes) } @Parcelize data class SavedState(val routes: List<Route>) : Parcelable } class NavigatorStateSaver<T : Parcelable>( private val handle: SavedStateHandle, private val owner: StateSavable<T>, private val key: String ) { fun save() { handle[key] = owner.saveState() } fun restore() { handle.get<T>(key)?.let(owner::restoreState) } fun clear() { handle.remove<T>(key) } }
Originator — BackStackNavigator управляет состоянием и сам умеет сохраняться/восстанавливаться
Memento — SavedState содержит сериализуемый снимок, не раскрывая внутреннюю структуру
Caretaker — NavigatorStateSaver работает с любыми Originator-объектами через интерфейс StateSavable, не зная их деталей
Если вы используете официальную Navigation, у неё есть собственное сохранение стека и состояния экранов. Дублировать снимки не нужно, иначе возможна рассинхронизация. В любом случае сохраняйте только легкие route-токены, а не данные экрана.
6) Где Memento не нужен: кэши и репозитории
In-memory кэш и репозитории — это про хранение и доступ к данным, а не про снимок инкапсулированного состояния Originator. Хранение временных сущностей не делает их Memento.
// ❌ Это НЕ Memento - это просто кэш
class UserCacheDataSource { private val cache = mutableMapOf<String, User>() fun getUser(id: String): User? = cache[id] fun saveUser(user: User) { cache[user.id] = user } }
Ограничения Memento в Android
Когда НЕ использовать savedInstanceState:
1. Большие объемы данных
Bundle ограничен ~1MB
Для списков >100 элементов, изображений → Room/DataStore
2. Сложные графы объектов
Циклические ссылки не сериализуются
Храните ID, восстанавливайте из репозитория
3. Бизнес-данные
Критичные данные → Room (savedInstanceState ненадежен)
Теряется при явном finish()
4. Несериализуемые объекты
Context, View, Callback, Thread, InputStream
Только Parcelable/Serializable примитивы
5. Пересчитываемые данные
Производные значения, кэши → пересоздавайте при восстановлении
Быстрый выбор подхода
UI-компонент → BaseSavedState
Экран (ViewModel) → SavedStateHandle
Compose → rememberSaveable
Данные >1MB → Room/DataStore
Интеграция: делегируем сохранение состояния View
Идея, которую удобно применять почти в любом проекте: перенести всю логику сохранения UI-состояния в соответствующие компоненты UI.
Пример: экран с фильтрами и результатами
FilterPanelView хранит свой FilterPanelState через собственный SavedState
ResultsRecyclerView опирается на свой LayoutManager с сохранен��ем позиции скролла через стандартный механизм View
Fragment минимален, его задача — связать источники данных, а не таскать бандлы
class FiltersFragment : Fragment(R.layout.fragment_filters) { private val binding by viewBinding(FragmentFiltersBinding::bind) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { // FilterPanelView сама управляет своим состоянием binding.filterPanel.setOnApplyClickListener { state -> viewModel.applyFilters(state) } // RecyclerView автоматически сохраняет позицию скролла viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.results.collect { results -> binding.resultsRecycler.submitList(results) } } } } }
Что получается:
Каждая составная View сама является Originator и несёт ответственность за свой снимок
Fragment/Activity не разрастаются «знанием» о внутренностях UI-состояния
Восстановление состояния после конфигурационных изменений и процесс-килла становится надёжнее и локальнее
Практические правила и подводные камни
Размер снимка. Всё, что идёт в Bundle, должно быть небольшим. Данные загружаем заново, а UI-состояние сохраняем компактно.
Только сериализуемые типы. Никаких ссылок на Context, View, Listener.
Не дублируйте сохранение. Если View уже хранит состояние сама, не пытайтесь ещё раз складывать то же в Activity.onSaveInstanceState.
Чётко отделяйте UI-снимок и данные. UI-снимки живут в SavedState, данные живут в репозиториях/БД.
Тестируйте цикл поворота и процесс-килл. Прогоняйте сценарии пересоздания, чтобы гарантировать корректное восстановление.
Compose. Для кастомных типов всегда задавайте явный Saver, чтобы контролировать формат снимка.
CustomView. Если ваш компонент — контейнер, и вы управляете дочерними View сами, используйте dispatchFreezeSelfOnly/dispatchThawSelfOnly.
Процесс-килл: как это работает и как корректно тестировать
Как это работает:
Система может убить ваш процесс в фоне, чтобы освободить ресурсы. Перед этим фреймворк стремится сохранить снимки состояний активных компонентов в SavedStateRegistry/Bundle. При последующем возврате в задачу из «Недавних» система воссоздает компоненты и передает сохраненный savedInstanceState.
Важно: при явном закрытии экрана пользователем, например, через вызов метода
finish()или свайп задачи из «Недавних», сохранениеsavedInstanceStateне гарантировано. Это не инструмент для бизнес-данных.
Что точно не эмулирует процесс-килл адекватно:
Опция разработчика Don’t keep activities — она уничтожает активность при уходе с экрана, но это не тождественно убийству процесса системой. Полезно для ловли ошибок жизненного цикла, но не для чистой симуляции процесс-килла.
Рабочие сценарии проверки
Домой → убить процесс → вернуться из «Недавних»
Откройте нужный экран, приведите его в требуемое состояние
Нажмите Home, чтобы уйти на главный экран
Убейте процесс командой: adb shell am kill your.package.name — убивает все фоновые процессы пакета, если они не на переднем плане.
Откройте приложение из «Недавних» — система должна воссоздать процесс и активность, передав savedInstanceState. Проверьте, что состояние восстановилось.
Свернуть → нагрузить память → вернуться
Сверните приложение
Откройте тяжелые приложения, чтобы вытеснить ваш процесс
Вернитесь из «Недавних». Если процесс был выгружен, произойдет восстановление из снимка
Лимит фоновых процессов = 0
В настройках разработчика включите «Лимит фоновых процессов» и установите «Без фоновых процессов»
Переключитесь в другое приложение
На большинстве устройств система будет убивать ваш процесс сразу при уходе в фон, что помогает поймать дефекты восстановления. Поведение может отличаться между версиями Android и прошивками
Force stop - НЕ эквивалент процесс-киллу
«Settings - Apps - Your app - Force stop» или adb shell am force-stop your.package.nameЭто ближе к полной остановке приложения: снимаются будильники, блокируются широковещательные сообщения до следующего запуска. Возврат из списка «Недавние приложения» часто недоступен или приведет к холодному старту без
savedInstanceState. Это тест не на процесс-килл, а на «холодный запуск после остановки». Полезен, но для другой категории сценариев.
Автоматизация тестирования process death:
@RunWith(AndroidJUnit4::class) class SaveStateTest { @get:Rule val activityScenario = ActivityScenarioRule(MainActivity::class.java) @Test fun testStateRestorationAfterRecreation() { // Arrange: устанавливаем состояние onView(withId(R.id.counter_text)).check(matches(withText("0"))) onView(withId(R.id.increment_button)).perform(click()) onView(withId(R.id.counter_text)).check(matches(withText("1"))) // Act: эмулируем пересоздание (rotation) activityScenario.scenario.recreate() // Assert: проверяем восстановление onView(withId(R.id.counter_text)).check(matches(withText("1"))) } }
Чеклист в конце теста:
Возврат из «Недавних» после am kill восстанавливает экран с ожидаемым savedInstanceState
CustomView с BaseSavedState вернула свое состояние без участия Activity/Fragment
ViewModel восстановил критичные поля из SavedStateHandle
Compose компоненты с rememberSaveable восстановили значения
Нет крашей из-за classloader или не-saveable типов

Итоги:
CustomView как Originator — сильнейший Android-кейс Memento. Вид сам знает, что сохранять и как восстанавливать, а фреймворк выступает Caretaker.
Activity/Fragment — используем для небольших UI-снимков через onSaveInstanceState.
ViewModel + SavedStateHandle - храните критичные для восстановления UI значения, помня про сериализацию и размер.
Compose — rememberSaveable и Saver маппят ваше состояние к сериализуемому снимку.
Навигация — делайте явный Parcelable memento стека и восстанавливайте из него.
Кэш и репозитории — это не Memento. Не смешивайте роли.
Такой подход снижает связность, делает экраны тоньше и повышает надёжность восстановления. Главное — держать снимки компактными и близкими к месту, где живёт состояние, то есть в самих UI-компонентах.