DEV Community

Nikita
Nikita

Posted on

Суть Composable-функций

Эта статья является переводом главы 1 "Composable functions" из книги Jetpack Compose Internals

Начать книгу о внутренней работе Jetpack Compose лучше всего с разговора о самих Composable-функциях, так как они являются минимальными строительными блоками, основными "кирпичиками", с помощью которых строятся "деревья" Composable-функций. Я говорю "деревья" потому что Composable-функции на самом деле являются вершинами большого дерева, которое Compose-рантайм строит в памяти.

Любая функция в Kotlin превращается в Composable-функцию с помощью аннотации @Composable:

 @Composable
 fun NamePlate(name: String) {
   // Our composable code
 }
Enter fullscreen mode Exit fullscreen mode

Таким образом мы говорим компилятору, что эта функция будет участвовать в преобразовании входных данных в вершину дерева. Например, если мы рассмотрим Composable-функцию как @Composable (Input) -> Unit, то Input - это данные, принимаемые функцией, но Unit - это не возвращаемое нашей функцией значение, как может показаться, а определенное действие, с помощью которого эта функция попадает в дерево, хранящееся в памяти.

Возвращение значения Unit функцией, которая принимает на вход какие-либо данные, означает, что функция каким-либо образом "использует" данные внутри себя

Это "определенное действие" называется "излучением" (emitting) в контексте Jetpack Compose. Под "излучением" понимается некое запланированное изменение в Composable-дереве. Composable-функции "излучают" во время своего выполнения, это происходит во время композиции.

Composable-функция "излучает" изменение

Но не все Composable-функции возвращают Unit. Они могут возвращать значение. Такие Composable-функции не "используют" входные данные внутри себя, а "отдают" какое-то значение на основании входных данных. Например, функция remember():

  @Composable
  fun NamePlate() {
    val name = remember { generateName() }
    Text(name)
  }
Enter fullscreen mode Exit fullscreen mode

Такая CF может "запомнить" результат и вернуть его. Следовательно, каждый раз, когда вызывается функция remember(), дерево композиции обновляется в соответствии со значением, которое она возвращает. Вершина дерева содержит в себе сам вызов функции и ее результат. Смысл в том, чтобы дерево композиции находилось в наиболее актуальном состоянии.

Поговорим еще о нескольких свойствах, которые должны иметь Composable-функции. Эти свойства, однако, напрямую связаны с Jetpack Compose, так как они позволяют использовать все возможности библиотеки. Compose-рантайм ожидает, что Composable-функции будут обладать этими свойствами, чтобы он мог применить определенные оптимизации, такие как параллельное выполнение нескольких Composable-функций, выполнение их в случайном порядке (в зависимости от приоритета) и так далее.

Особенности Composable-функций

Вызывающий контекст

Любой функции с аннотацией @Composable компилятором (Jetpack Compose Compiler) добавляется дополнительный параметр Composer-контекст, который передается во все дочерние Composable-функции внутри этой функции, и так далее. Можно расценивать его как неявный параметр, о котором разработчику (и рантайму) можно ничего не знать. Например, есть такая функция:

@Composable
fun NamePlate(name: String, lastname: String) { 
   Column(modifier = Modifier.padding(16.dp)) {
   Text(text = name)
   Text(text = lastname, style  = MaterialTheme.typography.subtitle1)
   }
}
Enter fullscreen mode Exit fullscreen mode

Компилятор добавит неявный параметр во все места вызова этой Composable-функции в дереве композиции. Помимо этого, компилятор расставит маркеры, которые отмечают начало и конец Composable-функции. Следующий код упрощен, но суть сохранена:

 fun NamePlate(name:String, lastname: String, $composer:Composer<*>) { 
   $composer.start(123)
   Column(modifier = Modifier.padding(16.dp), $composer) {
      Text(
        text = lastname,
        $composer
      )
      Text(
        text = name,
        style = MaterialTheme.typography.subtitle1,
        $composer
      )
   }
   $composer.end()
}
Enter fullscreen mode Exit fullscreen mode

Такой код позволяет прокидывать Composer-контекст вниз по дереву, чтобы он всегда был доступен на любом уровне. Для этого необходимо, чтобы дерево состояло только из Composable-функций. Чтобы достичь этого, компилятор проверяет соблюдение одного важного правила: Composable-функции могут вызываться только из других Composable-функций. Другими словами, это и есть вызывающий контекст.

Благодаря тому, что Composable-функции могут вызываться только из других Composable-функций, необходимая для рантайма информация всегда доступна на любом уровне дерева композиции. Мы уже знаем, что Composable-функции "излучают" (emit) изменения вместо "излучения" самого UI. Composable-функция использует переданный ей параметр Composer, чтобы "излучить" изменения во время композиции, и последующие рекомпозиции будут зависеть от этих изменений.

Идемпотентность

Все Composable-функции должны быть идемпотентными, то есть, если мы вызовем такую функцию несколько раз, передавая ей на вход одни и те же данные, она должна "излучить" одни и те же изменения.

Compose-рантайм полагается на процесс рекомпозиции. В книге есть глава, посвященная ей, но стоит в двух словах обсудить, что это такое.

Рекомпозиция - это повторный вызов Composable-функций в тот момент, когда меняются данные, от которых они зависят. Если посмотреть на это в контексте UI, при рекомпозиции Composable-функция "излучает" новую вершину дерева или обновляет существуюущую.

Рекомпозиция может происходить по ряду причин, следовательно, Composable-функция может вызываться снова и снова множество раз. Именно поэтому так важно, чтобы она была идемпотентной, иначе при каждом таком вызове будет меняться состояние в программе, т.е. появится сайд-эффект.

При рекомпозиции дерево проходится сверху вниз в поиске вершин, которые поменялись, чтобы вызвать рекомпозицию их функций. Существует понятие "умной рекомпозиции", которое означает, что вершины дерева, которые не зависят от измененных данных, могут оставаться неизменными, то есть их функции не будут заново вызваны. Очевидно это напрямую влияет на производительность. "Умная рекомпозиция" возможна потому что изменения, "излученные" функциями, уже хранятся внутри дерева в памяти, и их можно переиспользовать, если данные не поменялись. Именно поэтому так важно, чтобы Composable-функции были идемпотентными. Ведь, если бы они не выдавали один и тот же результат при вызове с теми же параметрами, рантайм не мог бы сделать вывод, что данные на самом деле не поменялись и функцию можно не перевызывать в дальнейшем.

Отсутствие сайд-эффектов

Возможно вы уже слышали термин "чистая функция". Чистые функции - это функции без сайд-эффектов.

Сайд-эффект - это действие, которое совершается за пределами функции. В контексте Jetpack Compose, этим действием может быть изменение состояния в приложении, которое происходит вне самой Composable-функции. В более широком смысле, такие действия как изменение глобальной переменной или совершение запроса в сеть рассматриваются как сайд-эффекты. Почему? Подумаем, что будет, если запрос в сеть упадет? Или значение глобальной переменной поменяется между вызовами функции? Ее поведение зависит от подобных действий, что делает функцию недетерминированной.

Как вы уже знаете, идемпотентная функция не должна иметь сайд-эффектов. Если Composable-функция будет иметь сайд-эффект, она (в каком-то случае) может стать зависимой от результата предыдущей Composable-функции. Этого ни в коем случае нельзя допускать, потому что Composable-функции могут вызываться в любом порядке. Это позволяет вынести рекомпозицию некоторых из них в другой поток, например, чтобы задействовать несколько процессорных ядер для повышения производительности.

@Composable
fun MainScreen() {
   Header()
   ProfileDetail()
   EventList()
}
Enter fullscreen mode Exit fullscreen mode

Функции в примере выше могут вызываться в любом порядке. Следовательно, нельзя рассчитывать на то, что порядок останется тем же. Например, функция ProfileDetail() обновляет состояние приложения, чтобы EventList() среагировала на это обновление и совершила какое-то действие. Что будет, если EventList() вызовется одновременно с функцией ProfileDetail() или даже раньше нее? Любая связь между функциями, основанная на сайд-эффектах, приведет к неверной работе кода. Бизнес-логика не должна располагаться внутри Composable-функций, ее необходимо вынести в другие слои приложения.

Есть еще один аргумент в пользу того, что сайд-эффекты не должны располагаться внутри Composable-функций. Дело в том, что они (функции) могут вызываться множество раз подряд. Логично, что такие функции могут создать состояние гонки (race condition) и нарушить целостность кода. Представьте Composable-функцию, которая должна загрузить данные из сети и отобразить их:

 @Composable
    fun EventsFeed(networkService: EventsNetworkService) {
        val events = networkService.loadAllEvents()
        LazyColumn {
            items(events) { event ->
                Text(text = event.name)
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

Сайд-эффект внутри этой функции будет выполняться при каждой рекомпозиции и приведет в итоге к тому, что будет запущено множество эффектов одновременно без какой-либо связи между собой.

Однако, если сайд-эффекты все же необходимы, в Jetpack Compose есть механизм под названием "effect handlers", который позволяет вызывать сайд-эффекты из Composable-функций. Достигается это благодаря тому, что сайд-эффекты становятся lifecycle aware, то есть знают о жизненном цикле Composable-функции.

Есть еще одна проблема, которая может возникнуть, если вызывать сайд-эффекты из Composable-функций. Например, необходимо обновлять значение переменной внутри Composable-функции. Так как эта функция может вызываться из разных потоков, доступ к такой переменной является непотокобезопасным.

@Composable
    fun BuggyEventFeed(events: List<Event>) {
        var totalEvents = 0
        Column {
            Text(if (totalEvents == 0) "No events." else "Total events $totalEvents")
            events.forEach { event ->
                Text("Item: ${event.name}") totalEvents ++ // 
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

В этом примере значение переменной totalEvents меняется каждый раз при рекомпозиции Column. А это значит, что итоговое значение переменной будет отличаться от ожидаемого.

Позиционное запоминание

Чтобы понять, что такое "позиционная мемоизация" (positional memoization) необходимо сначала обсудить "мемоизацию функции" (function memoization). "Мемоизация функции" - это способность функции кэшировать результат на основании входных параметров, чтобы не вычислять результат заново при вызове функции с теми же параметрами. Мы ранее обсудили, что это возможно лишь тогда, когда функция является "чистой" (детерминированной), так как в таком случае мы уверены, что функция вернет один и тот же результат для одних и тех же входных данных.

"Позиционная мемоизация" основана на той же идее, но имеет одно ключевое отличие. Composable-функции всегда знают о том, где они находятся в дереве композиции. Рантайм отличает вызовы одной и той же функции с помощью идентификатора, который генерируется на основе позиции вызова Composable-функции. Таким образом рантайм может отличить эти три вызова функции Text():

@Composable
    fun MyComposable() {
        Text("Hello!") 
        Text ("Hello!") 
        Text ("Hello!")
    }
Enter fullscreen mode Exit fullscreen mode

Эти функции вызваны из трех различных мест внутри родительской функции, поэтому в дереве композиции будет содержаться 3 экземпляра функции Text(), каждая со своим идентификатором. Этот идентификатор сохраняется при рекомпозициях, поэтому рантайм знает, вызывалась ли данная Composable-функция ранее, и поменялась ли она.

В некоторых случаях рантайму проблематично сгенерировать идентификатор, так как он ориентируется на позиции вызова функций в исходном коде. Есть случаи, когда позиция будет одинаковой для нескольких вызовов одной и той же Composable-функции. Например, Composable-функции, вызываемые в цикле:

@Composable
    fun TalksScreen(talks: List<Talk>) {
        Column {
            for (talk in talks) {
                Talk(talk)
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

Вызов Talk(talk) происходит с одной и той же позиции в коде на каждой итерации цикла, но каждый Talk должен быть уникальным. В таких случаях рантайм полагается на порядок вызовов, чтобы с его помощью сгенерировать уникальный идентификатор для каждого Talk. Такой подход работает хорошо, если требуется добавить элемент в конец списка, но проблемы начинаются, если попытаться добавить элемент в начало или середину списка. Все функции Talk "ниже" в списке будут вызваны заново, хотя их входные данные не поменялись. Это может быть серьезной проблемой.

Для таких случаев есть Composable-функция key(), с помощью которой можно вручную задать идентификатор для каждого вызова.

@Composable
    fun TalksScreen(talks: List<Talk>) {
        Column {
            for (talk in talks) {
                key(talk.id) {
                    Talk(talk)
                }
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

Таким способом можно задать идентификатор вызову функции с помощью id объекта класса Talk. В этом случае смена позиций функций не будет препятствием, так как у каждого вызова есть уникальный идентификатор.

Когда Composable-функция "добавляет вершину в дерево композиции", на самом деле вся информация об этой Composable-функции сохраняется в вершину дерева: ее параметры, вызовы вложенных в нее функций, результат вызова remember-функций. Все это возможно благодаря объекту класса Composer, который добавляется в Composable-функцию.

Так как Composable-функции знают свою позицию в дереве, любое кэшируемое ими значение, кэшируется в контексте, ограниченном этой позицией. Чтобы было более понятно, рассмотрим на примере:

@Composable
    fun FilteredImage(path: String) {
        val filters = remember { computeFilters(path) } ImageWithFiltersApplied (filters)
    }

    @Composable
    fun ImageWithFiltersApplied(filters: List<Filter>) {
        TODO()
    }
Enter fullscreen mode Exit fullscreen mode

В этом примере используется функция remember() для того, чтобы закэшировать результат тяжелой операции вычисления фильтров для изображения по его пути. Как только фильтры будут вычислены, можно рендерить изображение.

Функция remember() знает, как получить данные из дерева композиции. При вызове remember посмотрит, есть ли кэшированное значение. Если есть, просто вернет его, а если нет, вычислит и сохранит в вершине дерева.

Функция remember() использует "позиционное запоминание", чтобы получить закэшированное значение из контекста, ограниченного функцией FilteredImage(). Можно сказать, что remember() является неким синглтоном внутри скоупа. Но важно понимать, что, если, например, одна и та же функция была "запомнена" в другой Composable-функции, то "запомненное" значение будет уже другим объектом.

Jetpack Compose в целом и "умная рекомпозиция" в частности основаны на принципе "позиционной мемоизации"

В этой статье разработчик Jetpack Compose из Google рассказывет подробно о "позиционной мемоизации" с большим количеством визуализации, что может упростить понимание.

Итак, мы рассмотрели ограничения, которые Compose-рантайм накладывает на Composable-функции, а также особенности этих функций. Соблюдение требований к Composable-функциям по возможности проверяется компилятором.

Discussion (0)