Привет, я Данильян, работаю в Самокате фронтенд-разработчиком, разрабатываю бэкофисное приложение с использованием React. Когда я только начинал изучать Jetpack Сompose, я наткнулся на статью, в которой простым языком для людей, пришедших из мира веб-технологий в мир мобильной разработки, расписано, как писать код для мобильных устройств, чтобы было как в React. Из материала в статье особенно подкупали примеры: вот, что вы делали в React, а вот, как в Jetpack Compose получить то же самое. 

Делюсь с вами переводом этой статьи.


Я попробовал Jetpack Compose в личном проекте, и мне понравился API. В Compose реализовано довольно большое дополнение в API, и знания React мне пригодились куда больше, чем знания Android. Возможно, именно за счёт этого разработчики React Native вытеснят нативных разработчиков Android.

Многие концепции и функции в двух библиотеках одинаковы и отличаются лишь названиями. Вот подборка терминов, которые мне встретились, и пояснения к ним. Данный список актуален для Jetpack Compose и основан на версии 1.1.1.

Children Prop > Children Composable

И в React, и в Compose элементы, отображаемые внутри другого UI-компонента пользовательского интерфейса, называются children. React передает children по значению в специальном prop (параметре), именуемом children.

function Container(props) {
  return <div>{props.children}</div>;
}

<Container>
  <span>Hello world!</span>
</Container>;

Jetpack Compose передает composable-функции, и сами функции ничего не возвращают.

@Composable
fun Container(children: @Composable () -> Unit) {
  Box {
    children()
  }
}

Container {
  Text("Hello world"!)
}

Context > CompositionLocal

Хотя большинство данных передается через дерево компонентов в виде props, иногда эта модель может оказаться слишком громоздкой. На этот случай в React предусмотрен Context API. В Compose с той же целью применяется CompositionLocal. В альфа-версиях Jetpack Compose этот API назывался Ambient.

createContext > compositionLocalOf

React Context создается с помощью React.createContext, а Jetpack Compose ambient создается с помощью compositionLocalOf.

Provider > CompositionLocalProvider

Как в React, так и в Compose значение можно контролировать с помощью Provider-компонента.

<MyContext.Provider value={myValue}>
  <SomeChild />
</MyContext.Provider>
CompositionLocalProvider(MyLocal provides myValue) {
  SomeChild()
}

useContext > CompositionLocal.current

Доступ к значению React Context осуществляется с помощью хука useContext.

const myValue = useContext(MyContext);

Доступ к значению Ambient осуществляется с помощью геттера .current.

val myValue = MyLocal.current

Hook > Effect

Чтобы переносить логику компонентов в функции, которые можно переиспользовать, React позволяет создавать собственные хуки. Для инкапсуляции логики, относящейся к жизненному циклу компонента, эти функции могут использовать другие хуки, такие как useState и useEffect.

function useFriendStatus(friendID) {
  const [isOnline, setIsOnline] = useState(null);

  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }

    ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
    };
  }, [friendID]);

  return isOnline;
}

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

@Composable
fun friendStatus(friendID: String): State<Boolean?> {
  val isOnline = remember { mutableStateOf<Boolean?>(null) }

  DisposableEffect(friendID) {
    val handleStatusChange = { status: FriendStatus ->
      isOnline.value = status.isOnline
    }

    ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange)
    onDispose {
      ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange)
    }
  }

  return isOnline
}

useEffect > LaunchedEffect

Внутри основной части компонента UI не допускаются трансформации, подписки, таймеры, логирование и другие побочные эффекты. Их необходимо помещать в callback-функцию, которую и React, и Jetpack Compose вызовут в нужный момент.

Для выполнения побочных эффектов в React есть хук useEffect.

useEffect(() => {
  sideEffectRunEveryRender();
});

useEffect используется для различных целей: подписка на события, логирование, асинхронный код и многое другое. Compose разбивает эти случаи использования на отдельные функции с суффиксом Effect, включая DisposableEffect, LaunchedEffect и SideEffect.

Clean-up function > DisposableEffect

Побочные эффекты часто создают ресурсы, от которых необходимо избавиться после того, как компонент UI перестает использоваться. React позволяет функции useEffect возвращать вторую функцию, известную как clean-up function (функция очистки).

useEffect(() => {
  const subscription = source.subscribe(id);
  return () => {
    subscription.unsubscribe(id);
  };
}, [id]);

В Jetpack Compose есть composable-функция DisposableEffect, которая в этом сценарии заменяет useEffect. Вместо того, чтобы возвращать функцию, вы можете вызвать onDispose и передать туда callback. Когда composable функция на следующей рекомпозиции по условию перестает вызываться, то срабатывает колбек onDispose, установленный на предыдущей композиции.

DisposableEffect(id) {
  val subscription = source.subscribe(id)
  onDispose {
    subscription.unsubscribe(id)
  }
}

useEffect(promise, deps) > LaunchedEffect

Асинхронные функции создаются в JavaScript с помощью ключевого слова async. Если re-render (повторный рендеринг) произойдет до завершения выполнения promise (обещания), React выполнит функцию, но не отменит promise. Поскольку из callback не нужно возвращать ничего, кроме функции очистки, нужно создать и сразу же вызвать асинхронную функцию.

useEffect(() => {
  async function asyncEffect() {
    await apiClient.fetchUser(id);
  }
  asyncEffect();
}, [id]);

Приведенный выше код получает данные из API при каждом изменении значения id. Вы также можете написать этот же код, используя IIFE, или Immediately Invoked Function Expression (немедленно вызываемую функцию).

useEffect(() => {
  (async () => {
    await apiClient.fetchUser(id);
  })();
}, [id]);

Выполнение отмены promise немного сложнее, потому что оно не встроено в React. Для отмены вызовов fetch и некоторых других promises можно использовать AbortController.

useEffect(() => {
  const controller = new AbortController();

  (async () => {
    // The abort signal will send abort events to the API client
    await apiClient.fetchUser(id, controller.signal);
  })();

  // Abort when id changes, or when the component is unmounted
  return () => controller.abort();
}, [id]);

В отличие от JavaScript, в Kotlin вместо асинхронных функций и promises используются suspend functions (функции приостановки) и coroutines (корутины). Для обработки suspend functions в побочных эффектах в Jetpack Compose есть специальная composable-функция LaunchedEffect. Зависимости эффектов называются keys (ключами) и работают так же, как и в React, за исключением того, что их необходимо задавать не в конце функции, а в начале.

При любом изменении keys (ключей) Compose дополнительно отменяет coroutine. В результате 11 строк кода выше заменяются на 3 строки ниже.

LaunchedEffect(id) {
  apiClient.fetchUser(id)
}

useEffect(callback) > SideEffect(callback)

Побочные эффекты, которые выполняются при каждом рендеринге, создаются в React с помощью useEffect без списка зависимостей.

useEffect(() => {
  sideEffectRunEveryRender();
});

В Jetpack Compose это достигается с помощью функции SideEffect. Эти побочные эффекты выполняются при каждом composition (исполнении функции).

SideEffect {
  sideEffectRunEveryComposition()
}

Dependency array > Keys

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

useEffect(() => {
  // Run when id changes
}, [id]);

useEffect(() => {
  // Run every render
});

useEffect(() => {
  // Run on the first render
}, []);

Вместо того, чтобы передавать массив в качестве последнего параметра, можно передать любое количество аргументов в качестве первых нескольких параметров в DisposableEffect и LaunchedEffect.

LaunchedEffect(id) {
  // Run when id changes
}

Для выполнения чего-либо при каждом рендеринге используйте SideEffect. В DisposableEffect и LaunchedEffect нет эквивалента для непередачи списка зависимостей в useEffect. В каждом случае необходимо передавать какую-то зависимость или key (ключ).

SideEffect {
  // Run every composition
}

Чтобы выполнить эффект только при первом composition, следует использовать ключ, который никогда не меняется. Например, Unit или true.

LaunchedEffect(Unit) {
  // Run only on the first composition
}

useState c useEffect > produceState

Часто в React используется комбинация хука useEffect для получения данных и useState для хранения этих данных.

function useFriendStatus(friendID) {
  const [isOnline, setIsOnline] = useState(null);

  useEffect(() => {
    ChatAPI.subscribeToFriendStatus(friendID, (status) => {
      setIsOnline(status.isOnline);
    });

    return () => {
      ChatAPI.unsubscribeFromFriendStatus(friendID);
    };
  }, [friendID]);

  return isOnline;
}

Для реализации этих задач в Jetpack Compose имеется единая функция produceState.

@Composable
fun friendStatus(friendID: String): State<Boolean?> {
  return produceState(initialValue = null, friendID) {
    ChatAPI.subscribeToFriendStatus(friendID) { status ->
      value = status.isOnline
    }

    awaitDispose {
      ChatAPI.unsubscribeFromFriendStatus(friendID)
    }
  }
}

Вместо вызова функции set можно использовать сеттер value внутри produce state scope. Текущее состояние можно узнать, посмотрев на возвращаемое produceState значение.

Побочный эффект внутри produceState может быть coroutine или самостоятельно удаляться с помощью функции awaitDispose. Это похоже на возврат функции очистки внутри хука useEffect в React.

Key Prop > Key Composable

Ключи в React и Jetpack Compose используются для определения того, какие элементы были изменены, добавлены или удалены. В списке элементов, которые отображаюся в данный момент в компоненте UI, они должны быть уникальными.

В React есть специальный string prop (строковый параметр) под названием key.

<ul>
  {todos.map((todo) => (
    <li key={todo.id}>{todo.text}</li>
  ))}
</ul>

В Jetpack Compose есть специальная utility composable (вспомогательная composable-функция) под названием key, которая может принимать любые входные данные.

Column {
  for (todo in todos) {
    key(todo.id) { Text(todo.text) }
  }
}

В Compose входные данные для key можно передавать несколько раз, в то время как React требует одну строку (хотя возможна компоновка нескольких строк).

.map > For Loop

Поскольку React передает элементы по значению, элементы, соответствующие массиву, обычно создаются с помощью array.map(). Возвращаемые в map callback элементы могут быть встроены в JSX.

function NumberList(props) {
  return (
    <ul>
      {props.numbers.map((number) => (
        <ListItem value={number} />
      ))}
    </ul>
  );
}

Composable-функции UI в Jetpack Compose генерируют другие composable-функции UI и ничего не возвращают. В результате вместо .map() можно использовать простой for loop (цикл for).

@Composable
fun NumberList(numbers: List<Int>) {
  Column {
    for (number in numbers) {
      ListItem(value = number)
    }
  }
}

По сути можно использовать любой метод итерации, например, .forEach().

PropTypes.oneOfType > Function overloading

JavaScript и TypeScript поддерживают передачу различных типов переменных для одного и того же параметра. В React это можно реализовать с помощью PropTypes.oneOfType. В TypeScript это можно реализовать с помощью типа union (типа объединения).

function FancyButton(props) {
  return ...
}

FancyButton.propTypes = {
  text: PropTypes.string,
  background: PropTypes.oneOfType([
    PropTypes.instanceOf(Color),
    PropTypes.number,
  ]),
};
interface FancyButtonProps {
  text: string;
  background: Color | number;
}

В Kotlin это можно реализовать с помощью overloads (перегрузок).

@Composable fun FancyButton(
  text: String,
  background: Color,
) {
  ...
}
@Composable fun FancyButton(
  text: String,
  background: Int,
) {
  ...
}

useMemo > remember

React позволяет повторно вычислять значения с помощью хука useMemo только при изменении определенных зависимостей внутри компонента.

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

В Jetpack Compose имеется аналогичная функция под названием remember, она вычисляет значение повторно только при изменении входных данных.

val memoizedValue = remember(a, b) { computeExpensiveValue(a, b) }

React Component > Composable

В React для разделения пользовательского интерфейса на независимые и переиспользуемые части используются Components (компоненты). Они могут быть представлены в виде функции, которая принимает параметр props и возвращает React node (узел).

function Greeting(props) {
  return <span>Hello {props.name}!</span>;
}

В Jetpack Compose для разделения UI на независимые и переиспользуемые части используются Сomposable-функции, которые представляют собой building blocks (строительные блоки). Это функции с аннотацией @Composable, которые могут принимать любое количество параметров.

@Composable
fun Greeting(name: String) {
  Text(text = "Hello $name!")
}

В обеих библиотеках эти концепции также называются компонентами UI. Однако в Jetpack Compose composable-функции также используются для реализации других задач, см. примеры в разделе hook > effect и key.

Render > Composition

После изменения данных внутри компонента UI библиотека должна скорректировать то, что выводится на экран. В React это называется rendering (рендерингом), а в Jetpack Compose это называется composition (исполнением функции).

Reconciler > Composer

Под капотом React выясняет, что меняется при рендеринге компонента. Этот diffing algorithm (эвристический алгоритм) называется Reconciler (сверка). В React Fiber говорилось о выпуске нового Fiber Reconciler, пришедшего на смену старому алгоритму.

В Jetpack Compose diffing (сверка) осуществляется с помощью Composer. Он определяет, как изменяются nodes (узлы) каждый раз, когда завершается composition (исполнение) composable-функции.

State > State

И в React, и в Compose локальные переменные, которые задумывались как изменяемые, называются state variables (переменные состояния).

useState > state

В React создание новой state variable (переменной состояния) осуществляется с помощью хука useState. Он возвращает tuple (кортеж) со значением и сеттером.

const [count, setCount] = useState(0);

<button onClick={() => setCount(count + 1)}>You clicked {count} times</button>;

В Compose используется функция mutableStateOf, возвращающая объект MutableState, который содержит переменную с геттером и сеттером.

val count = remember { mutableStateOf(0) }

Button(onClick = { count.value++ }) {
  Text("You clicked ${count.value} times")
}

MutableState содержит функции componentN(), позволяющие выполнить деструктуризацию геттера и сеттера подобно React.

val (count, setCount) = remember { mutableStateOf(0) }

Button(onClick = { setCount(count + 1) }) {
  Text("You clicked ${count} times")
}

Чтобы избежать повторного вычисления начального состояния, mutableStateOf обычно вкладывается в функцию remember.

setState updater function > Snapshot

При обновлении состояния в компоненте React на основе предыдущего значения можно передать функцию updater, которая получает текущее состояние в качестве аргумента функции.

class Button extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
  }

  render() {
    <button
      onClick={() => this.setState((state) => ({ count: state.count + 1 }))}
    >
      You clicked {this.state.count} times
    </button>;
  }
}

В Jetpack Compose это поведение инкапсулировано в концепции, называемой snapshots (моментальный снимок). Snapshot (моментальный снимок) отражает state values (значения состояния) в определенный момент времени. Класс Snapshot раскрывает функцию enter для выполнения updater callback (обратного вызова обновления). Внутри callback все state objects (объекты состояния) возвращают значение, которое они имели на момент создания snapshot (моментального снимка).

Storybook > Preview

Инструмент Storybook позволяет самостоятельно запускать превью компонентов React в интернете путем создания stories. Аннотация @Preview в Jetpack Compose позволяет создавать примеры composable-функций, для которых можно запустить превью в Android Studio.

Ternary Operator > If Statement

В React в components (компонентах) для условного рендеринга компонентов часто используется ternary operator (условный оператор) (cond ? a : b), поскольку возвращается успешная ветка (в отличие от условия if в JavaScript).

function Greeting(props) {
  return (
    <span>{props.name != null ? `Hello ${props.name}!` : 'Goodbye.'}</span>
  );
}

В Kotlin нет условных операторов, поскольку в нем условие if возвращает результат успешной ветки. Поскольку в Kotlin условие if действует как условный оператор в JavaScript, необходимость во втором варианте отпадает.

@Composable
fun Greeting(name: String?) {
  Text(text = if (name != null) {
    "Hello $name!"
  } else {
    "Goodbye."
  })
}

Спасибо Tiger Oakes за статью! Ссылку на оригинал вы найдёте в шапке этого поста.

Есть мнение, что если мидл-фронтендеру, который понимает, как писать на React, дать почитать эту статью, а потом предложить сделать мобильное приложение — то он сделает, даже если не знаком с Android. Как думаете, справедливо?

Комментарии (2)


  1. otomir23
    12.01.2023 17:34
    +4

    Полезная статья, спасибо за перевод!


  1. yarkov
    12.01.2023 21:50

    Огромное спасибо