Привет, Хабр! На связи Алина, старший 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-компонентах.
